From 50580da36900d290ae991f4310b8558d63f01fab Mon Sep 17 00:00:00 2001 From: Argannor <4489279+Argannor@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:21:18 +0200 Subject: [PATCH] feat: registry mirrors (#564) Signed-off-by: Argannor --- api/v1alpha1/localbuild_types.go | 24 +- api/v1alpha1/zz_generated.deepcopy.go | 22 +- pkg/build/build.go | 5 +- pkg/cmd/create/root.go | 72 ++++- pkg/cmd/create/root_test.go | 130 +++++++++ .../idpbuilder.cnoe.io_localbuilds.yaml | 17 ++ pkg/kind/config.go | 59 +++- pkg/kind/config_integration_test.go | 275 ++++++++++++++++++ pkg/kind/config_test.go | 120 ++++++++ pkg/kind/resources/hosts-mirror.toml.tmpl | 7 + 10 files changed, 703 insertions(+), 28 deletions(-) create mode 100644 pkg/cmd/create/root_test.go create mode 100644 pkg/kind/config_integration_test.go create mode 100644 pkg/kind/resources/hosts-mirror.toml.tmpl diff --git a/api/v1alpha1/localbuild_types.go b/api/v1alpha1/localbuild_types.go index 6e1ce71bc..39c13a0fa 100644 --- a/api/v1alpha1/localbuild_types.go +++ b/api/v1alpha1/localbuild_types.go @@ -53,15 +53,25 @@ type PackageConfigsSpec struct { CorePackageCustomization map[string]PackageCustomization `json:"packageCustomization,omitempty"` } +// RegistryMirror defines an external registry mirror configuration +type RegistryMirror struct { + // TargetRegistry is the registry that should be mirrored (e.g., "docker.io", "ghcr.io") + TargetRegistry string `json:"targetRegistry,omitempty"` + // RegistryAddress is the address of the mirror registry (e.g., "http://kind-registry:5000") + RegistryAddress string `json:"registryAddress,omitempty"` +} + // BuildCustomizationSpec fields cannot change once a cluster is created type BuildCustomizationSpec struct { - Protocol string `json:"protocol,omitempty"` - Host string `json:"host,omitempty"` - IngressHost string `json:"ingressHost,omitempty"` - Port string `json:"port,omitempty"` - UsePathRouting bool `json:"usePathRouting,omitempty"` - SelfSignedCert string `json:"selfSignedCert,omitempty"` - StaticPassword bool `json:"staticPassword,omitempty"` + Protocol string `json:"protocol,omitempty"` + Host string `json:"host,omitempty"` + IngressHost string `json:"ingressHost,omitempty"` + Port string `json:"port,omitempty"` + UsePathRouting bool `json:"usePathRouting,omitempty"` + SelfSignedCert string `json:"selfSignedCert,omitempty"` + StaticPassword bool `json:"staticPassword,omitempty"` + RegistryMirrors []RegistryMirror `json:"registryMirrors,omitempty"` + InsecureRegistryMirrors bool `json:"insecureRegistryMirrors,omitempty"` } type LocalbuildSpec struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2512108a9..a6b02171c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -72,6 +72,11 @@ func (in *ArgoPackageConfigSpec) DeepCopy() *ArgoPackageConfigSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BuildCustomizationSpec) DeepCopyInto(out *BuildCustomizationSpec) { *out = *in + if in.RegistryMirrors != nil { + in, out := &in.RegistryMirrors, &out.RegistryMirrors + *out = make([]RegistryMirror, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BuildCustomizationSpec. @@ -399,7 +404,7 @@ func (in *LocalbuildList) DeepCopyObject() runtime.Object { func (in *LocalbuildSpec) DeepCopyInto(out *LocalbuildSpec) { *out = *in in.PackageConfigs.DeepCopyInto(&out.PackageConfigs) - out.BuildCustomization = in.BuildCustomization + in.BuildCustomization.DeepCopyInto(&out.BuildCustomization) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalbuildSpec. @@ -529,6 +534,21 @@ func (in *Provider) DeepCopy() *Provider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryMirror) DeepCopyInto(out *RegistryMirror) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryMirror. +func (in *RegistryMirror) DeepCopy() *RegistryMirror { + if in == nil { + return nil + } + out := new(RegistryMirror) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteRepositorySpec) DeepCopyInto(out *RemoteRepositorySpec) { *out = *in diff --git a/pkg/build/build.go b/pkg/build/build.go index 0ec8cfa34..536275d7d 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "time" "github.com/cnoe-io/idpbuilder/api/v1alpha1" @@ -292,5 +293,7 @@ func isBuildCustomizationSpecEqual(s1, s2 v1alpha1.BuildCustomizationSpec) bool s1.Port == s2.Port && s1.UsePathRouting == s2.UsePathRouting && s1.SelfSignedCert == s2.SelfSignedCert && - s1.StaticPassword == s2.StaticPassword + s1.StaticPassword == s2.StaticPassword && + s1.InsecureRegistryMirrors == s2.InsecureRegistryMirrors && + slices.Equal(s1.RegistryMirrors, s2.RegistryMirrors) } diff --git a/pkg/cmd/create/root.go b/pkg/cmd/create/root.go index f12689a85..e7b35c0b5 100644 --- a/pkg/cmd/create/root.go +++ b/pkg/cmd/create/root.go @@ -35,7 +35,9 @@ const ( extraPackagesUsage = "Paths to locations containing custom packages" packageCustomizationFilesUsage = "Name of the package and the path to file to customize the core packages with. " + "valid package names are: argocd, nginx, and gitea. e.g. argocd:/tmp/argocd.yaml" - noExitUsage = "When set, idpbuilder will not exit after all packages are synced. Useful for continuously syncing local directories." + registryMirrorsUsage = "List of registry mirrors in format target=address (e.g. \"docker.io=http://kind-registry:5000,ghcr.io=http://kind-registry:5000\")" + insecureRegistryMirrorsUsage = "When set, configure registry mirrors with insecure TLS verification (skip_verify = true)." + noExitUsage = "When set, idpbuilder will not exit after all packages are synced. Useful for continuously syncing local directories." ) var ( @@ -48,6 +50,8 @@ var ( kindConfigPath string extraPackages []string registryConfig []string + registryMirrors []string + insecureRegistryMirrors bool packageCustomizationFiles []string noExit bool protocol string @@ -78,6 +82,8 @@ func init() { CreateCmd.PersistentFlags().StringVar(&kindConfigPath, "kind-config", "", kindConfigPathUsage) CreateCmd.PersistentFlags().StringSliceVar(®istryConfig, "registry-config", []string{}, registryConfigUsage) CreateCmd.PersistentFlags().Lookup("registry-config").NoOptDefVal = "$XDG_RUNTIME_DIR/containers/auth.json,$HOME/.docker/config.json" + CreateCmd.PersistentFlags().StringSliceVar(®istryMirrors, "registry-mirrors", []string{}, registryMirrorsUsage) + CreateCmd.PersistentFlags().BoolVar(&insecureRegistryMirrors, "insecure-registry-mirrors", false, insecureRegistryMirrorsUsage) // in-cluster resources related flags CreateCmd.PersistentFlags().StringVar(&host, "host", globals.DefaultHostName, hostUsage) @@ -136,6 +142,11 @@ func create(cmd *cobra.Command, args []string) error { o[c.Name] = c } + parsedMirrors, err := parseRegistryMirrors(registryMirrors) + if err != nil { + return err + } + exitOnSync := true if cmd.Flags().Changed("no-exit") { exitOnSync = !noExit @@ -158,12 +169,14 @@ func create(cmd *cobra.Command, args []string) error { RegistryConfig: maybeRegistryConfig, TemplateData: v1alpha1.BuildCustomizationSpec{ - Protocol: protocol, - Host: host, - IngressHost: ingressHost, - Port: port, - UsePathRouting: pathRouting, - StaticPassword: devPassword, + Protocol: protocol, + Host: host, + IngressHost: ingressHost, + Port: port, + UsePathRouting: pathRouting, + StaticPassword: devPassword, + RegistryMirrors: parsedMirrors, + InsecureRegistryMirrors: insecureRegistryMirrors, }, CustomPackageFiles: localFiles, @@ -208,6 +221,12 @@ func validate() error { } _, _, _, err = helpers.ParsePackageStrings(extraPackages) + if err != nil { + return err + } + + // Validate registry mirrors + _, err = parseRegistryMirrors(registryMirrors) return err } @@ -240,6 +259,45 @@ func getPackageCustomFile(input string) (v1alpha1.PackageCustomization, error) { }, nil } +func parseRegistryMirrors(mirrors []string) ([]v1alpha1.RegistryMirror, error) { + var result []v1alpha1.RegistryMirror + for _, mirror := range mirrors { + // Format: target=address (e.g. "docker.io=http://kind-registry:5000") + parts := strings.SplitN(mirror, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid registry mirror format: %s, expected format: target=address (e.g. docker.io=http://kind-registry:5000)", mirror) + } + + target := strings.TrimSpace(parts[0]) + address := strings.TrimSpace(parts[1]) + + if target == "" { + return nil, fmt.Errorf("target registry cannot be empty in mirror: %s", mirror) + } + if address == "" { + return nil, fmt.Errorf("registry address cannot be empty in mirror: %s", mirror) + } + + // target must be [hostname|ip][:port] (https://github.com/containerd/containerd/blob/main/docs/hosts.md#registry-host-namespace) + parsedTarget, err := url.Parse("https://" + target) + if err != nil || parsedTarget.Host == "" || parsedTarget.Path != "" { + return nil, fmt.Errorf("invalid target registry %q: expected format [hostname|ip][:port] (e.g. docker.io, 192.168.1.1:5000)", target) + } + + // address must be a valid URL + parsedURL, err := url.Parse(address) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("invalid registry address URL: %s, expected format: http(s)://host:port", address) + } + + result = append(result, v1alpha1.RegistryMirror{ + TargetRegistry: target, + RegistryAddress: address, + }) + } + return result, nil +} + func printSuccessMsg() { subDomain := "argocd." subPath := "" diff --git a/pkg/cmd/create/root_test.go b/pkg/cmd/create/root_test.go new file mode 100644 index 000000000..4b16e0ac5 --- /dev/null +++ b/pkg/cmd/create/root_test.go @@ -0,0 +1,130 @@ +package create + +import ( + "testing" + + "github.com/cnoe-io/idpbuilder/api/v1alpha1" +) + +func TestParseRegistryMirrors(t *testing.T) { + type test struct { + name string + input []string + expect []v1alpha1.RegistryMirror + wantErr bool + } + + tests := []test{ + { + name: "empty input", + input: []string{}, + expect: []v1alpha1.RegistryMirror{}, + wantErr: false, + }, + { + name: "single mirror", + input: []string{"docker.io=http://kind-registry:5000"}, + expect: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + wantErr: false, + }, + { + name: "multiple mirrors", + input: []string{"docker.io=http://kind-registry:5000", "ghcr.io=http://kind-registry:5000"}, + expect: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + { + TargetRegistry: "ghcr.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + wantErr: false, + }, + { + name: "mirror with space", + input: []string{" docker.io = http://kind-registry:5000 "}, + expect: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + wantErr: false, + }, + { + name: "missing equals", + input: []string{"docker.io:http://kind-registry:5000"}, + expect: nil, + wantErr: true, + }, + { + name: "empty target", + input: []string{"=http://kind-registry:5000"}, + expect: nil, + wantErr: true, + }, + { + name: "empty address", + input: []string{"docker.io="}, + expect: nil, + wantErr: true, + }, + { + name: "target with path", + input: []string{"docker.io/library=http://my-registry:5000"}, + wantErr: true, + }, + { + name: "target with scheme", + input: []string{"https://docker.io=http://my-registry:5000"}, + wantErr: true, + }, + { + name: "malformed address URL", + input: []string{"docker.io=not-a-url"}, + wantErr: true, + }, + { + name: "mirror with https", + input: []string{"docker.io=https://my-registry:5000"}, + expect: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "https://my-registry:5000", + }, + }, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := parseRegistryMirrors(tc.input) + if (err != nil) != tc.wantErr { + t.Errorf("parseRegistryMirrors() error = %v, wantErr %v", err, tc.wantErr) + return + } + if !tc.wantErr { + if len(result) != len(tc.expect) { + t.Errorf("parseRegistryMirrors() got %d mirrors, want %d", len(result), len(tc.expect)) + return + } + for i := range result { + if result[i].TargetRegistry != tc.expect[i].TargetRegistry { + t.Errorf("parseRegistryMirrors()[%d].TargetRegistry = %v, want %v", i, result[i].TargetRegistry, tc.expect[i].TargetRegistry) + } + if result[i].RegistryAddress != tc.expect[i].RegistryAddress { + t.Errorf("parseRegistryMirrors()[%d].RegistryAddress = %v, want %v", i, result[i].RegistryAddress, tc.expect[i].RegistryAddress) + } + } + } + }) + } +} diff --git a/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml b/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml index e9bfcc9af..a409f9d80 100644 --- a/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml +++ b/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml @@ -45,10 +45,27 @@ spec: type: string ingressHost: type: string + insecureRegistryMirrors: + type: boolean port: type: string protocol: type: string + registryMirrors: + items: + description: RegistryMirror defines an external registry mirror + configuration + properties: + registryAddress: + description: RegistryAddress is the address of the mirror + registry (e.g., "http://kind-registry:5000") + type: string + targetRegistry: + description: TargetRegistry is the registry that should + be mirrored (e.g., "docker.io", "ghcr.io") + type: string + type: object + type: array selfSignedCert: type: string staticPassword: diff --git a/pkg/kind/config.go b/pkg/kind/config.go index 06c874993..5a7f1f3b2 100644 --- a/pkg/kind/config.go +++ b/pkg/kind/config.go @@ -88,18 +88,7 @@ func findRegistryConfig(registryConfigPaths []string) string { } func renderRegistryCertsDir(cfg v1alpha1.BuildCustomizationSpec) (string, error) { - // Render out the template - rawConfigTempl, err := fs.ReadFile(configFS, "resources/hosts.toml.tmpl") - if err != nil { - return "", fmt.Errorf("reading insecure registry config %w", err) - } - - var retBuff []byte - if retBuff, err = files.ApplyTemplate(rawConfigTempl, cfg); err != nil { - return "", fmt.Errorf("templating insecure registry config %w", err) - } - - // Generate the directory structure and write the file to hosts.toml + // Generate the directory structure dir, err := os.MkdirTemp("", "idpbuilder-registry-certs.d-*") if err != nil { return "", fmt.Errorf("creating temp dir %w", err) @@ -116,6 +105,18 @@ func renderRegistryCertsDir(cfg v1alpha1.BuildCustomizationSpec) (string, error) if err != nil { return "", fmt.Errorf("creating temp dir for host %w", err) } + + // Render out the template + rawConfigTempl, err := fs.ReadFile(configFS, "resources/hosts.toml.tmpl") + if err != nil { + return "", fmt.Errorf("reading insecure registry config %w", err) + } + + var retBuff []byte + if retBuff, err = files.ApplyTemplate(rawConfigTempl, cfg); err != nil { + return "", fmt.Errorf("templating insecure registry config %w", err) + } + hostsFile := filepath.Join(hostCertsDir, "hosts.toml") err = os.WriteFile(hostsFile, retBuff, 0700) @@ -123,5 +124,39 @@ func renderRegistryCertsDir(cfg v1alpha1.BuildCustomizationSpec) (string, error) return "", fmt.Errorf("writing insecure registry config %w", err) } + // Render and write hosts.toml for each registry mirror + for _, mirror := range cfg.RegistryMirrors { + mirrorCertsDir := filepath.Join(dir, mirror.TargetRegistry) + err = os.Mkdir(mirrorCertsDir, 0700) + if err != nil { + return "", fmt.Errorf("creating temp dir for mirror %w", err) + } + + // Render out the mirror template + rawMirrorTempl, err := fs.ReadFile(configFS, "resources/hosts-mirror.toml.tmpl") + if err != nil { + return "", fmt.Errorf("reading registry mirror config %w", err) + } + + mirrorData := struct { + RegistryAddress string + InsecureRegistryMirrors bool + }{ + RegistryAddress: mirror.RegistryAddress, + InsecureRegistryMirrors: cfg.InsecureRegistryMirrors, + } + + var retBuff []byte + if retBuff, err = files.ApplyTemplate(rawMirrorTempl, mirrorData); err != nil { + return "", fmt.Errorf("templating registry mirror config %w", err) + } + + hostsFile := filepath.Join(mirrorCertsDir, "hosts.toml") + err = os.WriteFile(hostsFile, retBuff, 0700) + if err != nil { + return "", fmt.Errorf("writing registry mirror config %w", err) + } + } + return dir, nil } diff --git a/pkg/kind/config_integration_test.go b/pkg/kind/config_integration_test.go new file mode 100644 index 000000000..c20738b07 --- /dev/null +++ b/pkg/kind/config_integration_test.go @@ -0,0 +1,275 @@ +package kind + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cnoe-io/idpbuilder/api/v1alpha1" +) + +func TestRegistryMirrorHostsTomlContent(t *testing.T) { + // Test that the generated hosts.toml for mirrors has the correct content + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + InsecureRegistryMirrors: true, + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Check docker.io hosts.toml content + dockerHostsFile := filepath.Join(dir, "docker.io", "hosts.toml") + content, err := os.ReadFile(dockerHostsFile) + if err != nil { + t.Fatalf("failed to read hosts.toml: %v", err) + } + + contentStr := string(content) + + // Verify key content exists + expectedContent := []string{ + `server = "http://kind-registry:5000"`, + `skip_verify = true`, + `[host."http://kind-registry:5000"]`, + `capabilities = ["pull", "resolve"]`, + } + + for _, expected := range expectedContent { + if !strings.Contains(contentStr, expected) { + t.Errorf("hosts.toml missing expected content: %s\nActual content:\n%s", expected, contentStr) + } + } + + // Verify content that should NOT exist + unexpectedContent := []string{ + `proxy`, + `https://docker.io`, // Should not reference target registry directly + } + + for _, unexpected := range unexpectedContent { + if strings.Contains(contentStr, unexpected) { + t.Errorf("hosts.toml should not contain: %s\nActual content:\n%s", unexpected, contentStr) + } + } +} + +func TestMultipleMirrors(t *testing.T) { + // Test with multiple mirrors pointing to different registries + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + { + TargetRegistry: "ghcr.io", + RegistryAddress: "http://kind-registry:5000", + }, + { + TargetRegistry: "quay.io", + RegistryAddress: "https://my-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Verify all mirror directories exist + registries := []string{"docker.io", "ghcr.io", "quay.io"} + for _, registry := range registries { + mirrorDir := filepath.Join(dir, registry) + if _, err := os.Stat(mirrorDir); os.IsNotExist(err) { + t.Errorf("expected mirror directory %s does not exist", mirrorDir) + } + + hostsFile := filepath.Join(mirrorDir, "hosts.toml") + if _, err := os.Stat(hostsFile); os.IsNotExist(err) { + t.Errorf("expected hosts.toml file %s does not exist", hostsFile) + } + } + + // Verify docker.io uses http + dockerFile := filepath.Join(dir, "docker.io", "hosts.toml") + content, _ := os.ReadFile(dockerFile) + if !strings.Contains(string(content), `server = "http://kind-registry:5000"`) { + t.Errorf("docker.io should have http server URL") + } + + // Verify quay.io uses https (since RegistryAddress starts with https) + quayFile := filepath.Join(dir, "quay.io", "hosts.toml") + content, _ = os.ReadFile(quayFile) + if !strings.Contains(string(content), `server = "https://my-registry:5000"`) { + t.Errorf("quay.io should have https server URL") + } +} + +func TestMirrorWithExistingGiteaConfig(t *testing.T) { + // Test that mirrors work alongside the existing gitea registry config + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Verify gitea config exists + giteaDir := filepath.Join(dir, "gitea.cnoe.localtest.me:8443") + if _, err := os.Stat(giteaDir); os.IsNotExist(err) { + t.Errorf("expected gitea directory %s does not exist", giteaDir) + } + + giteaHostsFile := filepath.Join(giteaDir, "hosts.toml") + if _, err := os.Stat(giteaHostsFile); os.IsNotExist(err) { + t.Errorf("expected gitea hosts.toml file %s does not exist", giteaHostsFile) + } + + // Verify docker.io mirror exists + dockerDir := filepath.Join(dir, "docker.io") + if _, err := os.Stat(dockerDir); os.IsNotExist(err) { + t.Errorf("expected docker.io directory %s does not exist", dockerDir) + } + + dockerHostsFile := filepath.Join(dockerDir, "hosts.toml") + if _, err := os.Stat(dockerHostsFile); os.IsNotExist(err) { + t.Errorf("expected docker.io hosts.toml file %s does not exist", dockerHostsFile) + } +} + +func TestMirrorWithHTTPS(t *testing.T) { + // Test that mirrors work with HTTPS addresses + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "https://secure-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Check docker.io hosts.toml content + dockerHostsFile := filepath.Join(dir, "docker.io", "hosts.toml") + content, err := os.ReadFile(dockerHostsFile) + if err != nil { + t.Fatalf("failed to read hosts.toml: %v", err) + } + + contentStr := string(content) + + // Verify https is used + if !strings.Contains(contentStr, `server = "https://secure-registry:5000"`) { + t.Errorf("hosts.toml should contain https server URL\nActual content:\n%s", contentStr) + } + + if !strings.Contains(contentStr, `[host."https://secure-registry:5000"]`) { + t.Errorf("hosts.toml should contain https host configuration\nActual content:\n%s", contentStr) + } +} + +func TestMirrorWithHTTP(t *testing.T) { + // Test that mirrors work with HTTP addresses + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://insecure-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Check docker.io hosts.toml content + dockerHostsFile := filepath.Join(dir, "docker.io", "hosts.toml") + content, err := os.ReadFile(dockerHostsFile) + if err != nil { + t.Fatalf("failed to read hosts.toml: %v", err) + } + + contentStr := string(content) + + // Verify http is used + if !strings.Contains(contentStr, `server = "http://insecure-registry:5000"`) { + t.Errorf("hosts.toml should contain http server URL\nActual content:\n%s", contentStr) + } + + if !strings.Contains(contentStr, `[host."http://insecure-registry:5000"]`) { + t.Errorf("hosts.toml should contain http host configuration\nActual content:\n%s", contentStr) + } + + if strings.Contains(contentStr, `skip_verify = true`) { + t.Errorf("hosts.toml should not contain skip_verify unless insecure-registry-mirrors is set\nActual content:\n%s", contentStr) + } +} + +func TestMirrorWithHTTPInsecure(t *testing.T) { + cfg := v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + InsecureRegistryMirrors: true, + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://insecure-registry:5000", + }, + }, + } + + dir, err := renderRegistryCertsDir(cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + dockerHostsFile := filepath.Join(dir, "docker.io", "hosts.toml") + content, err := os.ReadFile(dockerHostsFile) + if err != nil { + t.Fatalf("failed to read hosts.toml: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, `skip_verify = true`) { + t.Errorf("hosts.toml should contain skip_verify when insecure-registry-mirrors is set\nActual content:\n%s", contentStr) + } +} diff --git a/pkg/kind/config_test.go b/pkg/kind/config_test.go index 2bdd2f51b..784974804 100644 --- a/pkg/kind/config_test.go +++ b/pkg/kind/config_test.go @@ -5,9 +5,13 @@ import ( "io" "io/fs" "net/http" + "os" + "path/filepath" "reflect" "strings" "testing" + + "github.com/cnoe-io/idpbuilder/api/v1alpha1" ) type MockHttpClient struct{} @@ -184,3 +188,119 @@ func TestFindRegistryConfig(t *testing.T) { } } } + +func TestRenderRegistryCertsDirWithMirrors(t *testing.T) { + type test struct { + name string + cfg v1alpha1.BuildCustomizationSpec + expectedDirs []string + expectedFileCount int + expectSkipVerify bool + } + + tests := []test{ + { + name: "no mirrors", + cfg: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + }, + expectedDirs: []string{"gitea.cnoe.localtest.me:8443"}, + expectedFileCount: 1, + }, + { + name: "with single mirror", + cfg: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + }, + expectedDirs: []string{"gitea.cnoe.localtest.me:8443", "docker.io"}, + expectedFileCount: 2, + }, + { + name: "with mirrors and insecure skip verify", + cfg: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + InsecureRegistryMirrors: true, + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + }, + expectedDirs: []string{"gitea.cnoe.localtest.me:8443", "docker.io"}, + expectedFileCount: 2, + expectSkipVerify: true, + }, + { + name: "with multiple mirrors", + cfg: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + RegistryMirrors: []v1alpha1.RegistryMirror{ + { + TargetRegistry: "docker.io", + RegistryAddress: "http://kind-registry:5000", + }, + { + TargetRegistry: "ghcr.io", + RegistryAddress: "http://kind-registry:5000", + }, + }, + }, + expectedDirs: []string{"gitea.cnoe.localtest.me:8443", "docker.io", "ghcr.io"}, + expectedFileCount: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir, err := renderRegistryCertsDir(tc.cfg) + if err != nil { + t.Fatalf("failed to render registry certs dir: %v", err) + } + defer os.RemoveAll(dir) + + // Check all expected directories exist + for _, expectedDir := range tc.expectedDirs { + fullPath := filepath.Join(dir, expectedDir) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + t.Errorf("expected directory %s does not exist", fullPath) + } + + // Check hosts.toml exists in each directory + hostsFile := filepath.Join(fullPath, "hosts.toml") + if _, err := os.Stat(hostsFile); os.IsNotExist(err) { + t.Errorf("expected hosts.toml file %s does not exist", hostsFile) + } + + // For mirrors, check the content + if expectedDir != "gitea.cnoe.localtest.me:8443" { + content, err := os.ReadFile(hostsFile) + if err != nil { + t.Fatalf("failed to read hosts.toml: %v", err) + } + contentStr := string(content) + if tc.expectSkipVerify { + if !strings.Contains(contentStr, "skip_verify = true") { + t.Errorf("hosts.toml for mirror %s should contain skip_verify = true", expectedDir) + } + } else if strings.Contains(contentStr, "skip_verify = true") { + t.Errorf("hosts.toml for mirror %s should not contain skip_verify = true", expectedDir) + } + if !strings.Contains(contentStr, "[host.") { + t.Errorf("hosts.toml for mirror %s should contain host configuration", expectedDir) + } + } + } + }) + } +} diff --git a/pkg/kind/resources/hosts-mirror.toml.tmpl b/pkg/kind/resources/hosts-mirror.toml.tmpl new file mode 100644 index 000000000..6b28d8a21 --- /dev/null +++ b/pkg/kind/resources/hosts-mirror.toml.tmpl @@ -0,0 +1,7 @@ +server = "{{ .RegistryAddress }}" + +[host."{{ .RegistryAddress }}"] + capabilities = ["pull", "resolve"] +{{- if .InsecureRegistryMirrors }} + skip_verify = true +{{- end }} \ No newline at end of file