diff --git a/Makefile b/Makefile index ca435c4..ffc6595 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ common.mk: printf '%s' '$(DEV_KIT_VERSION)' > .common.mk-version touch .common.mk-checked +DOCKER ?= docker + .PHONY: fmt fmt: $(GOLANGCI_LINT) ## Format code $(GO) fmt ./... diff --git a/README.md b/README.md index 939c1df..c13b687 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ ocm-kit "http://localhost:5000/my-components//opendefense.cloud/arc:0.1.0" \ - `-r, --chart-resource string` - Name of the Helm chart resource in the component (default: "") - `-f, --local-helm-values-template string` - Path to a local Helm values template file (overrides component template) +- `-p, --pull-secrets-file string` - Path to a pull secrets JSON file mapping registries to Kubernetes secret names - `-h, --help` - Display help message ### Registry Credentials @@ -163,6 +164,69 @@ config, so you can override individual hosts when needed. See the [OCM credentials tutorial](https://ocm.software/docs/tutorials/credentials-in-an-.ocmconfig-file/) for the full set of identity types and credential providers. +### Pull Secrets + +When rendering the Helm values template, you may need to attach +`imagePullSecrets` to deployments. The `--pull-secrets-file` flag and the +`pullSecretFor` template function work together to map OCI registries to +Kubernetes Secret names. + +This approach allows the template author to specify where to add +`imagePullSecrets` in a `values.yaml` while the deployer has control over +deployment specific data like the concrete secret names. + +The pull secrets file uses the following format: + +```json +{ + "$schema": "https://raw.githubusercontent.com/opendefensecloud/ocm-kit/refs/heads/main/helmvalues/pullsecrets-schema.json", + "pullSecrets": [ + { + "registry": "docker.io", + "secretName": "docker-hub-cred" + }, + { + "registry": "ghcr.io/my-org", + "secretName": "ghcr-org-cred" + }, + { + "registry": "localhost:5000", + "secretName": "regcred" + } + ] +} +``` + +The `pullSecretFor` function in templates resolves an OCI reference (hostname, +hostname/repo, or full image ref) to the matching secret name. Resolution walks +from most-specific to least-specific path segments: + +- `pullSecretFor "ghcr.io/my-org/my-repo:latest"` checks `ghcr.io/my-org/my-repo` -> `ghcr.io/my-org` -> `ghcr.io` +- `pullSecretFor "docker.io"` checks the registry host directly + +If no match is found `pullSecretFor` returns the empty string. + +It is advised that templates may always be written in a way that gracefully +handle `pullSecretFor` returning an empty string value. Like in the example +below, template authors can make use of go-template's `with` expression. + +Example template usage: + +```yaml +{{- $image := index .OCIResources "my-image" }} +{{- with pullSecretFor $image.Host }} +imagePullSecrets: + - name: {{ . }} +{{- end }} +``` + +CLI invocation: + +```bash +ocm-kit "https://example.com/my-components//example.com/my-component:0.1.0" \ + --pull-secrets-file ./pull-secrets.json +``` + ### Example #### Values Template @@ -210,7 +274,7 @@ import ( "context" "fmt" "log" - + "go.opendefense.cloud/ocm-kit/helmvalues" "go.opendefense.cloud/ocm-kit/compver" "ocm.software/ocm/api/ocm" @@ -219,13 +283,13 @@ import ( func main() { ctx := context.Background() - + // Parse component version reference cvr, err := compver.SplitRef("http://localhost:5000/my-components//opendefense.cloud/arc:0.1.0") if err != nil { log.Fatal(err) } - + // Setup OCM repository octx := ocm.FromContext(ctx) repo, err := octx.RepositoryForSpec(ocireg.NewRepositorySpec(cvr.BaseURL())) @@ -233,32 +297,32 @@ func main() { log.Fatal(err) } defer repo.Close() - + // Get component version compVer, err := repo.LookupComponentVersion(cvr.ComponentName, cvr.Version) if err != nil { log.Fatal(err) } defer compVer.Close() - + // Find helm values template for a specific chart tmpl, err := helmvalues.GetHelmValuesTemplate(compVer, "helm-chart") if err != nil { log.Fatal(err) } - + // Get rendering input with component data input, err := helmvalues.GetRenderingInput(compVer) if err != nil { log.Fatal(err) } - + // Render the template renderedValues, err := helmvalues.Render(tmpl, input) if err != nil { log.Fatal(err) } - + fmt.Println(renderedValues) } ``` @@ -285,6 +349,11 @@ repository: {{ $image.host }}/{{ $image.repository }} tag: {{ $image.tag }} ``` +### `.PullSecrets` +A map of registries to secret-names. Use the `pullSecretFor` template function +to refer to possible `imagePullSecrets`. It implements a hierarchical +path-resolution logic (See [Pull Secrets](#pull-secrets)). + ### `.Component` Component metadata available as a `compdesc.ComponentSpec`, providing access to: - Component name and version diff --git a/cmd/ocm-kit/main.go b/cmd/ocm-kit/main.go index c3d79ef..e5757b1 100644 --- a/cmd/ocm-kit/main.go +++ b/cmd/ocm-kit/main.go @@ -17,6 +17,7 @@ func main() { var ( chartResName string localHelmValuesTemplate string + pullSecretsFilePath string ) rootCmd := &cobra.Command{ @@ -81,6 +82,14 @@ It takes a component version reference and renders the first Helm values templat return fmt.Errorf("failed to build rendering input: %w", err) } + if pullSecretsFilePath != "" { + ps, err := helmvalues.ParsePullSecretsFile(pullSecretsFilePath) + if err != nil { + return fmt.Errorf("failed to parse pull secrets file: %w", err) + } + input.PullSecrets = ps + } + output, err := helmvalues.Render(template, input) if err != nil { return fmt.Errorf("failed to render helm values template: %w", err) @@ -93,6 +102,7 @@ It takes a component version reference and renders the first Helm values templat rootCmd.Flags().StringVarP(&chartResName, "chart-resource", "r", "", "Name of the Helm chart resource in the component to render a specific helm values template") rootCmd.Flags().StringVarP(&localHelmValuesTemplate, "local-helm-values-template", "f", "", "Path to a local Helm values template file (overrides component template)") + rootCmd.Flags().StringVarP(&pullSecretsFilePath, "pull-secrets-file", "p", "", "Path to a pull secrets JSON file mapping registries to Kubernetes secret names") if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/helmvalues/helmvalues.go b/helmvalues/helmvalues.go index f718d61..b940344 100644 --- a/helmvalues/helmvalues.go +++ b/helmvalues/helmvalues.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "slices" + "strings" "text/template" "github.com/Masterminds/sprig/v3" @@ -24,10 +25,8 @@ const ( HelmValuesTemplateLabelName = "opendefense.cloud/helm/values-for" ) -var ( - // ErrNotFound is returned when a requested Helm values template is not found - ErrNotFound = errors.New("not found") -) +// ErrNotFound is returned when a requested Helm values template is not found +var ErrNotFound = errors.New("not found") // HelmValuesTemplate represents a Helm values template found in an OCM component. // It contains the template content along with metadata about its resource. @@ -46,11 +45,47 @@ type ImageReference struct { Digest string } +// PullSecrets is a collection of registry-to-secret mappings for pull secrets. +type PullSecrets map[string]string + +// Get safely returns the secret name for a registry, falling back to the empty string. +func (p PullSecrets) Get(registry string) string { + if p == nil { + return "" + } + return p[registry] +} + +// Resolve parses ref as an OCI reference and walks the path from most specific +// to least specific (Host/path/to/image -> Host/path/to -> … -> Host). +// If no match is found this way it tries to lookup the raw string. +func (p PullSecrets) Resolve(ref string) string { + if !strings.Contains(ref, "/") { + return p.Get(ref) + } + parsed, err := ParseOCIRef(ref) + if err != nil || parsed.Repository == "" { + return p.Get(ref) + } + parts := strings.Split(parsed.Repository, "/") + for i := len(parts); i >= 0; i-- { + key := parsed.Host + if i > 0 { + key += "/" + strings.Join(parts[:i], "/") + } + if secret := p.Get(key); secret != "" { + return secret + } + } + return p.Get(ref) +} + // RenderingInput contains all the data needed to render a Helm values template. // It provides access to component resources and the component descriptor for template processing. type RenderingInput struct { OCIResources map[string]ImageReference Component *compdesc.ComponentSpec + PullSecrets PullSecrets } // RenderOption is a functional option for configuring Render behavior @@ -291,7 +326,7 @@ func Render(tmpl *HelmValuesTemplate, input *RenderingInput, opts ...RenderOptio // Create template with custom function map t, err := template.New(tmpl.ResourceName). Option("missingkey=error"). - Funcs(getFuncMap()). + Funcs(getFuncMap(input.PullSecrets)). Parse(tmpl.TemplateContent) if err != nil { return "", fmt.Errorf("failed to parse template: %w", err) @@ -350,7 +385,7 @@ func matchLabelValue(value any, target string) bool { // plus custom functions for JSON conversion and OCI reference parsing. // // Returns a template.FuncMap with all available template functions. -func getFuncMap() template.FuncMap { +func getFuncMap(pullSecrets PullSecrets) template.FuncMap { f := sprig.TxtFuncMap() // Remove potentially unsafe functions delete(f, "env") @@ -367,6 +402,10 @@ func getFuncMap() template.FuncMap { f["parseRef"] = ParseOCIRef + f["pullSecretFor"] = func(ref string) string { + return pullSecrets.Resolve(ref) + } + return f } diff --git a/helmvalues/helmvalues_test.go b/helmvalues/helmvalues_test.go index f4a4ada..e3f58cc 100644 --- a/helmvalues/helmvalues_test.go +++ b/helmvalues/helmvalues_test.go @@ -234,6 +234,195 @@ func TestMatchLabelValue(t *testing.T) { } } +// TestPullSecretsResolve tests the Resolve method with OCI refs and raw registries +func TestPullSecretsResolve(t *testing.T) { + tests := []struct { + name string + ref string + secrets PullSecrets + want string + }{ + { + name: "full ref matches Host/Repository", + ref: "ghcr.io/org/myapp:v1.0.0", + secrets: PullSecrets{"ghcr.io/org/myapp": "repo-cred"}, + want: "repo-cred", + }, + { + name: "full ref matches host only", + ref: "ghcr.io/org/myapp:v1.0.0", + secrets: PullSecrets{"ghcr.io": "org-cred"}, + want: "org-cred", + }, + { + name: "Host/Repository takes priority over host", + ref: "ghcr.io/org/myapp:v1.0.0", + secrets: PullSecrets{ + "ghcr.io/org/myapp": "repo-cred", + "ghcr.io": "org-cred", + }, + want: "repo-cred", + }, + { + name: "ref with nested path matches correctly", + ref: "registry.example.com/team/service/sub:v2", + secrets: PullSecrets{"registry.example.com/team/service/sub": "nested-cred"}, + want: "nested-cred", + }, + { + name: "ref matches intermediate org path, not just host", + ref: "docker.io/team-a/my-repo:latest", + secrets: PullSecrets{"docker.io/team-a": "team-a-secret"}, + want: "team-a-secret", + }, + { + name: "most specific match wins among path hierarchy", + ref: "docker.io/team-a/my-repo:latest", + secrets: PullSecrets{ + "docker.io/team-a/my-repo": "repo-secret", + "docker.io/team-a": "org-secret", + "docker.io": "global-secret", + }, + want: "repo-secret", + }, + { + name: "intermediate org resolves independently per organization", + ref: "docker.io/team-a/svc:latest", + secrets: PullSecrets{"docker.io/team-b": "team-b-secret"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.secrets.Resolve(tt.ref) + if got != tt.want { + t.Errorf("PullSecrets.Resolve(%q) = %q, want %q", tt.ref, got, tt.want) + } + }) + } +} + +// TestPullSecretsGet tests the PullSecrets type directly +func TestPullSecretsGet(t *testing.T) { + tests := []struct { + name string + secrets PullSecrets + registry string + want string + }{ + { + name: "known registry returns secret", + secrets: PullSecrets{"docker.io": "regcred", "ghcr.io": "ghcr-cred"}, + registry: "docker.io", + want: "regcred", + }, + { + name: "unknown registry returns empty string", + secrets: PullSecrets{"docker.io": "regcred"}, + registry: "unknown.registry.io", + want: "", + }, + { + name: "nil PullSecrets returns empty string", + secrets: nil, + registry: "docker.io", + want: "", + }, + { + name: "empty PullSecrets returns empty string", + secrets: PullSecrets{}, + registry: "docker.io", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.secrets.Get(tt.registry) + if got != tt.want { + t.Errorf("PullSecrets.Get(%q) = %q, want %q", tt.registry, got, tt.want) + } + }) + } +} + +// TestRenderPullSecretFor tests the pullSecretFor template function via Render +func TestRenderPullSecretFor(t *testing.T) { + tests := []struct { + name string + template *HelmValuesTemplate + input *RenderingInput + want string + wantErr bool + }{ + { + name: "pullSecretFor with matching registry", + template: &HelmValuesTemplate{ + ResourceName: "pull-secret-test", + ResourceVersion: "1.0.0", + TemplateContent: `secret: {{ pullSecretFor "docker.io" }}`, + }, + input: &RenderingInput{ + OCIResources: map[string]ImageReference{}, + PullSecrets: PullSecrets{ + "docker.io": "regcred", + }, + }, + want: "secret: regcred", + wantErr: false, + }, + { + name: "pullSecretFor with non-matching registry", + template: &HelmValuesTemplate{ + ResourceName: "pull-secret-no-match", + ResourceVersion: "1.0.0", + TemplateContent: `secret: {{ pullSecretFor "unknown.io" }}`, + }, + input: &RenderingInput{ + OCIResources: map[string]ImageReference{}, + PullSecrets: PullSecrets{ + "docker.io": "regcred", + }, + }, + want: "secret: ", + wantErr: false, + }, + { + name: "pullSecretFor with ref", + template: &HelmValuesTemplate{ + ResourceName: "pull-secret-ref", + ResourceVersion: "1.0.0", + TemplateContent: `secret: {{ pullSecretFor "registry.example.com/repo/image:tag" }}`, + }, + input: &RenderingInput{ + OCIResources: map[string]ImageReference{}, + PullSecrets: PullSecrets{ + "registry.example.com": "example-cred", + }, + }, + want: "secret: example-cred", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Render(tt.template, tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Render() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + if got != tt.want { + t.Errorf("Render() = %q, want %q", got, tt.want) + } + }) + } +} + // Helper function to check if a string contains a substring func contains(s, substr string) bool { return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && diff --git a/helmvalues/pullsecrets-schema.json b/helmvalues/pullsecrets-schema.json new file mode 100644 index 0000000..afa4373 --- /dev/null +++ b/helmvalues/pullsecrets-schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:ocm-kit:pullsecrets:schema", + "title": "PullSecrets", + "description": "Container registry to Kubernetes SecretName mapping file, consumed by ocm-kit --pull-secrets-file", + "type": "object", + "properties": { + "pullSecrets": { + "description": "Registry-to-Secret mappings. The registry field can be a hostname (e.g. ghcr.io) or hostname/subpath (e.g. ghcr.io/my-org/my-repo). Resolve walks path segments from most-specific to least-specific: Host/Repository -> Host/parent -> … -> Host, allowing per-repository and per-organization overrides alongside a host-level fallback.", + "type": "array", + "items": { + "type": "object", + "properties": { + "registry": { + "description": "OCI registry hostname (e.g. docker.io, localhost:5000) or hostname with subpath (e.g. ghcr.io/my-org/my-repo)", + "type": "string" + }, + "secretName": { + "description": "Name of the Kubernetes Secret containing registry credentials", + "type": "string" + } + }, + "required": ["registry", "secretName"] + } + } + }, + "required": ["pullSecrets"] +} diff --git a/helmvalues/pullsecrets_file.go b/helmvalues/pullsecrets_file.go new file mode 100644 index 0000000..2463666 --- /dev/null +++ b/helmvalues/pullsecrets_file.go @@ -0,0 +1,43 @@ +package helmvalues + +import ( + "bytes" + "encoding/json" + "fmt" + "os" +) + +// pullSecretsFile holds mappings from registries to pull secrets in the cluster +type pullSecretsFile struct { + PullSecrets []pullSecretMapping `json:"pullSecrets"` +} + +// PullSecretEntry maps an OCI registry to the Kubernetes secret containing its credentials. +type pullSecretMapping struct { + Registry string `json:"registry"` + SecretName string `json:"secretName"` +} + +func ParsePullSecretsFile(path string) (PullSecrets, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read pull secrets file: %w", err) + } + + var psf pullSecretsFile + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&psf); err != nil { + return nil, fmt.Errorf("failed to parse pull secrets file: %w", err) + } + + ps := make(PullSecrets, len(psf.PullSecrets)) + for _, e := range psf.PullSecrets { + if e.Registry == "" || e.SecretName == "" { + return nil, fmt.Errorf("invalid pull secret entry: registry and secretName must be non-empty") + } + ps[e.Registry] = e.SecretName + } + + return ps, nil +} diff --git a/helmvalues/pullsecrets_file_test.go b/helmvalues/pullsecrets_file_test.go new file mode 100644 index 0000000..5de9302 --- /dev/null +++ b/helmvalues/pullsecrets_file_test.go @@ -0,0 +1,102 @@ +package helmvalues + +import ( + "os" + "path/filepath" + "testing" +) + +// TestParsePullSecretsFile tests the ParsePullSecretsFile function +func TestParsePullSecretsFile(t *testing.T) { + t.Run("valid file with multiple entries", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secrets.json") + content := `{"pullSecrets": [{"registry": "docker.io", "secretName": "regcred"}, {"registry": "ghcr.io", "secretName": "ghcr-cred"}]}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ParsePullSecretsFile(path) + if err != nil { + t.Fatalf("ParsePullSecretsFile() unexpected error: %v", err) + } + if got == nil { + t.Fatal("ParsePullSecretsFile() returned nil") + } + if g, w := got.Get("docker.io"), "regcred"; g != w { + t.Errorf("PullSecrets.Get(\"docker.io\") = %q, want %q", g, w) + } + if g, w := got.Get("ghcr.io"), "ghcr-cred"; g != w { + t.Errorf("PullSecrets.Get(\"ghcr.io\") = %q, want %q", g, w) + } + }) + + t.Run("valid file with empty pullSecrets list", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.json") + content := `{"pullSecrets": []}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ParsePullSecretsFile(path) + if err != nil { + t.Fatalf("ParsePullSecretsFile() unexpected error: %v", err) + } + if got == nil { + t.Fatal("ParsePullSecretsFile() returned nil") + } + if g, w := len(got), 0; g != w { + t.Errorf("PullSecrets length = %d, want %d", g, w) + } + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + _, err := ParsePullSecretsFile("/nonexistent/path/secrets.json") + if err == nil { + t.Fatal("ParsePullSecretsFile() expected error for nonexistent file, got nil") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "invalid.json") + content := `{invalid json}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ParsePullSecretsFile(path) + if err == nil { + t.Fatal("ParsePullSecretsFile() expected error for invalid JSON, got nil") + } + }) + + t.Run("malformed structure returns error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "malformed.json") + content := `{"pullSecrets": "not-an-array"}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ParsePullSecretsFile(path) + if err == nil { + t.Fatal("ParsePullSecretsFile() expected error for wrong structure, got nil") + } + }) + + t.Run("entry missing fields returns error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "incomplete_entry.json") + content := `{"pullSecrets": [{"registry": "docker.io"}]}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ParsePullSecretsFile(path) + if err == nil { + t.Fatal("ParsePullSecretsFile() expected error for incomplete entry, got nil") + } + }) +} diff --git a/test/e2e.sh b/test/e2e.sh index d44aafe..f1d5371 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -151,6 +151,21 @@ else exit 1 fi +# Test 5: Render with pull secrets file +echo "Test 5: Rendering with pull secrets file..." +OUTPUT5=$(${GO} run cmd/ocm-kit/main.go "http://localhost:5000/my-components//opendefense.cloud/arc:${VERSION}" \ + --local-helm-values-template "$SCRIPT_DIR/fixtures/arc/pull-secrets-values.yaml.tpl" \ + --pull-secrets-file "$SCRIPT_DIR/fixtures/arc/pull-secrets.json") +if [ "$(echo "$OUTPUT5" | grep -c -- "- name: regcred")" -eq 3 ] && \ + [ "$(echo "$OUTPUT5" | grep -c -- "imagePullSecrets:")" -eq 3 ]; then + echo "✓ Test 5 passed: Pull secrets rendered correctly" +else + echo "✗ Test 5 failed: Pull secrets output missing expected content" + echo "Output was:" + echo "$OUTPUT5" + exit 1 +fi + # Cleanup only if --keep-zot was not provided if [ "$KEEP_ZOT" = false ]; then echo "Stopping zot registry..." diff --git a/test/fixtures/arc/pull-secrets-values.yaml.tpl b/test/fixtures/arc/pull-secrets-values.yaml.tpl new file mode 100644 index 0000000..affce94 --- /dev/null +++ b/test/fixtures/arc/pull-secrets-values.yaml.tpl @@ -0,0 +1,23 @@ +foobar: + image: + {{- $apiserver := index .OCIResources "arc-apiserver-image" }} + repository: {{ $apiserver.Host }}/{{ $apiserver.Repository }} + tag: {{ $apiserver.Tag }} + imagePullSecrets: + - name: {{ pullSecretFor $apiserver.Host }} + +fizzbuzz: + image: + {{- $controller := index .OCIResources "arc-controller-manager-image" }} + repository: {{ $controller.Host }}/{{ $controller.Repository }} + tag: {{ $controller.Tag }} + imagePullSecrets: + - name: {{ pullSecretFor (printf "%s/%s" $controller.Host $controller.Repository) }} + +helloworld: + image: + {{- $etcdImage := index .OCIResources "etcd-image" }} + repository: {{ $etcdImage.Host }}/{{ $etcdImage.Repository }} + tag: {{ $etcdImage.Tag }} + imagePullSecrets: + - name: {{ pullSecretFor (printf "%s/%s:%s" $etcdImage.Host $etcdImage.Repository $etcdImage.Tag) }} diff --git a/test/fixtures/arc/pull-secrets.json b/test/fixtures/arc/pull-secrets.json new file mode 100644 index 0000000..d0818e0 --- /dev/null +++ b/test/fixtures/arc/pull-secrets.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../helmvalues/pullsecrets-schema.json", + "pullSecrets": [ + { + "registry": "localhost:5000", + "secretName": "regcred" + } + ] +}