├── mise.toml ├── Makefile ├── crd ├── internal │ ├── pkg │ │ ├── templates │ │ │ ├── .github │ │ │ │ └── workflows │ │ │ │ │ └── crd-publish.yaml │ │ │ └── go.mod │ │ ├── filename-templates.yaml │ │ ├── testdata │ │ │ ├── sdks_single.yaml │ │ │ └── sdks_multiple.yaml │ │ ├── config.go │ │ ├── config_test.go │ │ └── generate.go │ └── cmd │ │ ├── root.go │ │ └── generate.go ├── Makefile ├── main.go ├── go.mod └── go.sum ├── .gitignore ├── sdks.yaml ├── bin ├── get-sdk-version.py ├── download-crds.py └── find-new-tags.py └── README.md /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | go = '1.25.1' 3 | crd2pulumi = '1.5.4' -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ensure:: crdk8s 2 | 3 | crdk8s: 4 | make -C crd "bin/crdk8s" 5 | -------------------------------------------------------------------------------- /crd/internal/pkg/templates/.github/workflows/crd-publish.yaml: -------------------------------------------------------------------------------- 1 | on: {} 2 | jobs: {} -------------------------------------------------------------------------------- /crd/Makefile: -------------------------------------------------------------------------------- 1 | bin/crdk8s: $(shell find internal -type f) 2 | go build -o ../bin/crdk8s 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binary create by code from `crd` folder 2 | /bin/crdk8s 3 | 4 | # IDE Specific project files 5 | /.idea 6 | -------------------------------------------------------------------------------- /crd/internal/pkg/filename-templates.yaml: -------------------------------------------------------------------------------- 1 | ".github/workflows/crd-publish.yaml": ".github/workflows/${crd}-publish.yaml" 2 | "go.mod": "${crd}/go.mod" 3 | -------------------------------------------------------------------------------- /crd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/pulumiverse/kubernetes-crd/crd/internal/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /sdks.yaml: -------------------------------------------------------------------------------- 1 | cert-manager: 2 | repository: https://github.com/cert-manager/cert-manager 3 | version: 1.0.0 4 | crd: 5 | - https://github.com/cert-manager/cert-manager/releases/download/v${VERSION}/cert-manager.crds.yaml 6 | -------------------------------------------------------------------------------- /crd/internal/pkg/testdata/sdks_single.yaml: -------------------------------------------------------------------------------- 1 | cert-manager: 2 | repository: https://github.com/cert-manager/cert-manager 3 | version: 1.0.0 4 | crd: 5 | - https://github.com/cert-manager/cert-manager/releases/download/${VERSION}/cert-manager.crds.yaml 6 | -------------------------------------------------------------------------------- /crd/internal/pkg/templates/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pulumiverse/kubernetes-crd/${crd} 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/blang/semver v3.5.1+incompatible 7 | github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.21.1 8 | github.com/pulumi/pulumi/sdk/v3 v3.175.0 9 | ) 10 | -------------------------------------------------------------------------------- /crd/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pulumiverse/kubernetes-crd/crd 2 | 3 | go 1.25 4 | 5 | require github.com/spf13/cobra v1.10.1 6 | 7 | require ( 8 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 9 | github.com/spf13/pflag v1.0.9 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /bin/get-sdk-version.py: -------------------------------------------------------------------------------- 1 | from yaml import safe_load, YAMLError 2 | import sys 3 | 4 | with open("./sdks.yaml", "r") as stream: 5 | try: 6 | sdks = safe_load(stream) 7 | except YAMLError as exc: 8 | print(f"Error parsing SDK Yaml: {exc}") 9 | 10 | details = sdks.get(sys.argv[1]) 11 | sdk_version_string = f'{sys.argv[1]}|{details["repository"]}|{sys.argv[2]}' 12 | 13 | print(sdk_version_string) 14 | -------------------------------------------------------------------------------- /crd/internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var rootCmd = &cobra.Command{ 10 | Use: "crdk8s", 11 | Short: "Generate the setup for a specific CRD", 12 | Long: `Generate the setup to generate & publish the various SDKs for a specific CRD.`, 13 | } 14 | 15 | func Execute() { 16 | err := rootCmd.Execute() 17 | if err != nil { 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bin/download-crds.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from yaml import safe_load, YAMLError 3 | import requests 4 | import sys 5 | 6 | with open("./sdks.yaml", "r") as stream: 7 | try: 8 | sdks = safe_load(stream) 9 | except YAMLError as exc: 10 | print(f"Error parsing SDK Yaml: {exc}") 11 | 12 | version: str = sys.argv[2] 13 | details = sdks.get(sys.argv[1]) 14 | crd_urls: List[str] = details["crds"] 15 | 16 | with open("crd.yaml", 'wb') as f: 17 | for crd_url in crd_urls: 18 | crd = requests.get(crd_url.replace('${VERSION}', version)).content 19 | f.write(crd) 20 | f.write(b"---\n") 21 | -------------------------------------------------------------------------------- /crd/internal/pkg/testdata/sdks_multiple.yaml: -------------------------------------------------------------------------------- 1 | # Multiple CRDs, and one CRD with multiple schema URLs 2 | cert-manager: 3 | repository: https://github.com/cert-manager/cert-manager 4 | version: 1.11.2 5 | crd: 6 | - https://github.com/cert-manager/cert-manager/releases/download/${VERSION}/cert-manager.crds.yaml 7 | external-dns: 8 | repository: https://github.com/kubernetes-sigs/external-dns 9 | version: 0.14.1 10 | crd: 11 | - https://raw.githubusercontent.com/kubernetes-sigs/external-dns/v${VERSION}/docs/contributing/crd-source/crd-release-1.yaml 12 | - https://raw.githubusercontent.com/kubernetes-sigs/external-dns/v${VERSION}/docs/contributing/crd-source/crd-release-2.yaml 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pulumi SDKs for Kubernetes Custom Resource Definitions 2 | 3 | This project builds and generates the Pulumi SDKs for Kubernetes Custom Resource Definitions 4 | in a number of Pulumi supported programming languages. 5 | 6 | ## Published SDKs 7 | 8 | * [cert-manager](https://cert-manager.io/) 9 | * Typescript - [`@pulumiverse/kubernetes-crd-cert-manager`](https://www.npmjs.com/package/@pulumiverse/kubernetes-crd-cert-manager) 10 | * Python - [`pulumiverse_kubernetes_crd_cert_manager`](https://pypi.org/project/pulumiverse-kubernetes-crd-cert-manager/) 11 | * Go - [`github.com/pulumiverse/kubernetes-crd/cert-manager/go`](https://pkg.go.dev/github.com/pulumiverse/kubernetes-crd/cert-manager/go/kubernetes/crd/certmanager) 12 | * C# - [`Pulumiverse.Kubernetes.Crd.CertManager`](https://www.nuget.org/packages/Pulumiverse.Kubernetes.Crd.CertManager/) 13 | 14 | -------------------------------------------------------------------------------- /crd/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 5 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 6 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 7 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 8 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 11 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /bin/find-new-tags.py: -------------------------------------------------------------------------------- 1 | from atoma import parse_atom_bytes 2 | from datetime import datetime, timedelta, timezone 3 | from json import dumps 4 | from requests import get as httpget 5 | from typing import List, Optional 6 | from yaml import safe_load, YAMLError 7 | import sys 8 | 9 | 10 | def has_been_updated(updated_at: Optional[datetime] = None) -> bool: 11 | if updated_at == None: 12 | return False 13 | hours_since: int = int(sys.argv[1]) if len(sys.argv) > 1 else 12 14 | 15 | return (datetime.now(timezone.utc) - updated_at) < timedelta(hours=hours_since) 16 | 17 | 18 | def get_new_tags(repository: str) -> List[str]: 19 | response = httpget(f'https://{repository}/tags.atom') 20 | feed = parse_atom_bytes(response.content) 21 | 22 | return list(map(lambda entry: entry.title.value, filter(lambda entry: has_been_updated(entry.updated), feed.entries))) 23 | 24 | 25 | with open("./sdks.yaml", "r") as stream: 26 | try: 27 | sdks = safe_load(stream) 28 | except YAMLError as exc: 29 | print(f"Error parsing SDK Yaml: {exc}") 30 | 31 | sdks_to_build: List[str] = [] 32 | for name, details in sdks.items(): 33 | for tag in get_new_tags(details['repository']): 34 | sdks_to_build.append( 35 | f'{name}|{details["repository"]}|{tag}') 36 | 37 | print(dumps(sdks_to_build)) 38 | -------------------------------------------------------------------------------- /crd/internal/pkg/config.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // Config describes the top-level structure of `sdks.yaml`. 10 | // 11 | // The YAML file is a mapping where each key is the name of a CRD and the value 12 | // contains the metadata needed to fetch and generate SDKs for that CRD. 13 | // Example: 14 | // cert-manager: 15 | // 16 | // repository: https://github.com/cert-manager/cert-manager 17 | // version: 1.0.0 18 | // crd: 19 | // - https://.../${VERSION}/cert-manager.crds.yaml 20 | // 21 | // Therefore, Config is represented as a map keyed by CRD name to its definition. 22 | type Config map[string]CRDDefinition 23 | 24 | // CRDDefinition represents the per-CRD configuration block. 25 | type CRDDefinition struct { 26 | // Repository is a URL to the upstream repository of the CRD project. 27 | Repository string `yaml:"repository"` 28 | // Version is a semantic version in the form MAJOR.MINOR.BUILD. 29 | Version string `yaml:"version"` 30 | // CRD is a list of URLs to the CRD schema files. The placeholder ${VERSION} 31 | // can be used to be replaced with the Version value. 32 | CRD []string `yaml:"crd"` 33 | } 34 | 35 | func ReadConfig(path string) (*Config, error) { 36 | b, err := os.ReadFile(path) 37 | if err != nil { 38 | return nil, err 39 | } 40 | var cfg Config 41 | if err := yaml.Unmarshal(b, &cfg); err != nil { 42 | return nil, err 43 | } 44 | return &cfg, nil 45 | } 46 | -------------------------------------------------------------------------------- /crd/internal/cmd/generate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pulumiverse/kubernetes-crd/crd/internal/pkg" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type generateArguments struct { 11 | CrdName string 12 | } 13 | 14 | var generateArgs generateArguments 15 | 16 | // generateCmd represents the generate command 17 | var generateCmd = &cobra.Command{ 18 | Use: "generate", 19 | Short: "Generate SDKs for given CRD.", 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | // Read the config file `sdks.yaml` 22 | config, err := pkg.ReadConfig("sdks.yaml") 23 | if err != nil { 24 | return err 25 | } 26 | allCrds := *config 27 | 28 | // Check the CRD name passed on the CLI 29 | if generateArgs.CrdName == "" { 30 | return fmt.Errorf("CRD name must be given via the -n/--name flag") 31 | } 32 | 33 | // Check if the given CRD name is configured in the config file 34 | crd, found := allCrds[generateArgs.CrdName] 35 | if !found { 36 | return fmt.Errorf("CRD '%v' is not configured in `sdks.yaml` file", generateArgs.CrdName) 37 | } 38 | 39 | err = pkg.GenerateSDKs(generateArgs.CrdName, crd) 40 | return err 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(generateCmd) 46 | 47 | generateCmd.Flags().StringVarP(&generateArgs.CrdName, "name", "n", "", "name of CRD to generate SDKs for. Name must be configured in `sdks.yaml` file.") 48 | //generateCmd.Flags().StringVarP(&generateArgs.OutDir, "out", "o", ".", "directory to write generate files to") 49 | //generateCmd.Flags().StringVarP(&generateArgs.TemplateName, "template", "t", "", "template name to generate (default \"{config.template}\" or otherwise \"bridged-provider\")") 50 | //generateCmd.Flags().StringVarP(&generateArgs.ConfigPath, "config", "c", ".ci-mgmt.yaml", "local config file to use") 51 | //generateCmd.Flags().BoolVar(&generateArgs.SkipMigrations, "skip-migrations", false, "skip running migrations") 52 | } 53 | -------------------------------------------------------------------------------- /crd/internal/pkg/config_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | // helper to load a YAML file from testdata 9 | func loadYAMLConfig(t *testing.T, name string) *Config { 10 | t.Helper() 11 | path := filepath.Join("testdata", name) 12 | cfg, err := ReadConfig(path) 13 | if err != nil { 14 | t.Fatalf("failed to fetch config %s: %v", path, err) 15 | } 16 | return cfg 17 | } 18 | 19 | func TestUnmarshalSingle(t *testing.T) { 20 | cfg := *loadYAMLConfig(t, "sdks_single.yaml") 21 | 22 | if len(cfg) != 1 { 23 | t.Fatalf("expected 1 CRD entry, got %d", len(cfg)) 24 | } 25 | 26 | crd, ok := cfg["cert-manager"] 27 | if !ok { 28 | t.Fatalf("expected key 'cert-manager' to exist") 29 | } 30 | if crd.Repository != "https://github.com/cert-manager/cert-manager" { 31 | t.Errorf("repository mismatch: %q", crd.Repository) 32 | } 33 | if crd.Version != "1.0.0" { 34 | t.Errorf("version mismatch: %q", crd.Version) 35 | } 36 | if len(crd.CRD) != 1 { 37 | t.Fatalf("expected 1 crd url, got %d", len(crd.CRD)) 38 | } 39 | if crd.CRD[0] != "https://github.com/cert-manager/cert-manager/releases/download/${VERSION}/cert-manager.crds.yaml" { 40 | t.Errorf("crd url mismatch: %q", crd.CRD[0]) 41 | } 42 | } 43 | 44 | func TestUnmarshalMultiple(t *testing.T) { 45 | cfg := *loadYAMLConfig(t, "sdks_multiple.yaml") 46 | 47 | if len(cfg) != 2 { 48 | t.Fatalf("expected 2 CRD entries, got %d", len(cfg)) 49 | } 50 | 51 | cm, ok := cfg["cert-manager"] 52 | if !ok { 53 | t.Fatalf("expected key 'cert-manager' to exist") 54 | } 55 | if cm.Version != "1.11.2" { 56 | t.Errorf("cert-manager version mismatch: %q", cm.Version) 57 | } 58 | 59 | ed, ok := cfg["external-dns"] 60 | if !ok { 61 | t.Fatalf("expected key 'external-dns' to exist") 62 | } 63 | if ed.Repository != "https://github.com/kubernetes-sigs/external-dns" { 64 | t.Errorf("external-dns repository mismatch: %q", ed.Repository) 65 | } 66 | if ed.Version != "0.14.1" { 67 | t.Errorf("external-dns version mismatch: %q", ed.Version) 68 | } 69 | if len(ed.CRD) != 2 { 70 | t.Fatalf("expected 2 crd urls for external-dns, got %d", len(ed.CRD)) 71 | } 72 | if ed.CRD[0] != "https://raw.githubusercontent.com/kubernetes-sigs/external-dns/v${VERSION}/docs/contributing/crd-source/crd-release-1.yaml" { 73 | t.Errorf("first crd url mismatch: %q", ed.CRD[0]) 74 | } 75 | if ed.CRD[1] != "https://raw.githubusercontent.com/kubernetes-sigs/external-dns/v${VERSION}/docs/contributing/crd-source/crd-release-2.yaml" { 76 | t.Errorf("second crd url mismatch: %q", ed.CRD[1]) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crd/internal/pkg/generate.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | // kebabToPascal converts strings like "cert-manager" to "CertManager". 14 | func kebabToPascal(s string) string { 15 | parts := strings.Split(s, "-") 16 | for i, p := range parts { 17 | if len(p) > 0 { 18 | parts[i] = strings.ToUpper(p[:1]) + p[1:] 19 | } 20 | } 21 | return strings.Join(parts, "") 22 | } 23 | 24 | func GenerateSDKs(name string, crd CRDDefinition) error { 25 | // Clean up the folder for the CRD and recreate it 26 | if err := os.RemoveAll(name); err != nil { 27 | return fmt.Errorf("failed to clean up folder %s: %v", name, err) 28 | } 29 | if err := os.Mkdir(name, 0755); err != nil { 30 | return err 31 | } 32 | 33 | err := generateFor("dotnet", name, crd) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | err = generateFor("go", name, crd) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | err = generateFor("nodejs", name, crd) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | err = generateFor("python", name, crd) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func generateFor(language string, name string, crd CRDDefinition) error { 57 | crdTempDir, err := os.MkdirTemp("", "crd") 58 | if err != nil { 59 | return fmt.Errorf("failed to create temp dir: %v", err) 60 | } 61 | defer os.RemoveAll(crdTempDir) 62 | crdFiles, err := downloadCRDs(crd, crdTempDir) 63 | if err != nil { 64 | return fmt.Errorf("error downloading CRD files: %v", err) 65 | } 66 | 67 | languageOption := fmt.Sprintf("--%s", language) 68 | packagePathOption := fmt.Sprintf("--%sPath", language) 69 | packagePath := fmt.Sprintf("%s/%s", name, language) 70 | packageNameOption := fmt.Sprintf("--%sName", language) 71 | packageName := generatePackageName(name, language) 72 | 73 | args := append([]string{languageOption, packagePathOption, packagePath, packageNameOption, packageName}, crdFiles...) 74 | fmt.Printf("crd2pulumi args: %v\n", args) 75 | cmd := exec.Command("crd2pulumi", args...) 76 | 77 | var out bytes.Buffer 78 | cmd.Stdout = &out 79 | 80 | err = cmd.Run() 81 | 82 | if err != nil { 83 | return fmt.Errorf("Error running crd2pulumi: %v", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func downloadCRDs(crd CRDDefinition, path string) ([]string, error) { 90 | versionedCrdUrls := make([]string, len(crd.CRD)) 91 | for i, v := range crd.CRD { 92 | versionedCrdUrls[i] = strings.Replace(v, "${VERSION}", crd.Version, -1) 93 | } 94 | crdFileNames := make([]string, len(versionedCrdUrls)) 95 | for i, v := range versionedCrdUrls { 96 | crdFileNames[i] = strings.Split(v, "/")[len(strings.Split(v, "/"))-1] 97 | } 98 | downloadedCrdFiles := make([]string, len(crdFileNames)) 99 | for i, crdFileName := range crdFileNames { 100 | downloadedCrdFiles[i] = fmt.Sprintf("%s/%s", path, crdFileName) 101 | err := downloadFile(downloadedCrdFiles[i], versionedCrdUrls[i]) 102 | if err != nil { 103 | return []string{}, err 104 | } 105 | } 106 | return downloadedCrdFiles, nil 107 | } 108 | 109 | // DownloadFile will download from a given url to a file. It will 110 | // write as it downloads (useful for large files). 111 | func downloadFile(filepath string, url string) error { 112 | fmt.Printf("Downloading CRD %v to %v\n", url, filepath) 113 | 114 | // Get the data 115 | resp, err := http.Get(url) 116 | if err != nil { 117 | return fmt.Errorf("failed to download CRD %v: %v", url, err) 118 | } 119 | defer resp.Body.Close() 120 | 121 | // Create the file 122 | out, err := os.Create(filepath) 123 | if err != nil { 124 | return fmt.Errorf("failed to create temporary file %v: %v", filepath, err) 125 | } 126 | defer out.Close() 127 | 128 | // Write the body to file 129 | _, err = io.Copy(out, resp.Body) 130 | return err 131 | } 132 | 133 | func generatePackageName(name string, language string) string { 134 | packageName := name 135 | switch language { 136 | case "dotnet": 137 | packageName = fmt.Sprintf("Kubernetes.Crd.%s", kebabToPascal(name)) 138 | case "go": 139 | packageName = strings.Replace(name, "-", "", -1) 140 | case "nodejs": 141 | packageName = fmt.Sprintf("kubernetes-crd-%s", name) 142 | case "python": 143 | packageName = fmt.Sprintf("kubernetes_crd_%s", strings.Replace(name, "-", "_", -1)) 144 | } 145 | return packageName 146 | } 147 | --------------------------------------------------------------------------------