diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 5e36f993b..d4b50db05 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -100,6 +100,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringArrayVarP(&bootstrapGcpCmd.CodesphereEnv.InstallSkipSteps, "install-skip-steps", "s", []string{}, "Installation steps to skip during Codesphere installation (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RemoteOmsBinaryPath, "remote-oms-binary", "", "Path to a local Linux amd64 OMS binary to copy to and use on the jumpbox instead of downloading a release (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username (only for GitHub registry type) (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ContainerRegistryURL, "registry", "", "Alternative container registry used for Codesphere images and charts") flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: local-container)") flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.InternalFlags, "internal-flags", gcp.DefaultInternalFlags, "Internal flags to enable in Codesphere installation (optional)") flags.StringArrayVar(&bootstrapGcpCmd.experiments, "experiments", []string{}, "Deprecated: use --internal-flags instead. Values are added to the internal flags.") diff --git a/cli/cmd/bootstrap_local.go b/cli/cmd/bootstrap_local.go index c9dcf8315..bcc23dd01 100644 --- a/cli/cmd/bootstrap_local.go +++ b/cli/cmd/bootstrap_local.go @@ -77,6 +77,7 @@ func AddBootstrapLocalCmd(parent *cobra.Command) { flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallLocal, "install-local", "", "Path to a local installer package (tar.gz or unpacked directory)") // Registry flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username") + flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.ContainerRegistryURL, "registry", "", "Alternative container registry used for Codesphere images and charts") // Codesphere Environment flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.BaseDomain, "base-domain", "cs.local", "Base domain for Codesphere") @@ -97,8 +98,6 @@ func AddBootstrapLocalCmd(parent *cobra.Command) { flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "", "Path to secrets file (default: /prod.vault.yaml)") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.CephDeviceFilter, "ceph-device-filter", "", "Regular expression selecting Ceph block devices by name") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.CephDevicePathFilter, "ceph-device-path-filter", "", "Regular expression selecting Ceph block devices by path") - // ArgoCD integration - flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.ArgoCDRegistryURL, "registry-url", "oci://ghcr.io/codesphere-cloud/charts", "OCI registry URL used for the ArgoCD helm pull secret") bootstrapLocalCmd.cmd.RunE = bootstrapLocalCmd.RunE util.MarkFlagRequired(bootstrapLocalCmd.cmd, "registry-user") diff --git a/cli/cmd/codesphere/install_codesphere_dependencies.go b/cli/cmd/codesphere/install_codesphere_dependencies.go index 8abb936d5..04ea09329 100644 --- a/cli/cmd/codesphere/install_codesphere_dependencies.go +++ b/cli/cmd/codesphere/install_codesphere_dependencies.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "runtime" + "strings" argov1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" "github.com/codesphere-cloud/cs-go/pkg/io" @@ -117,6 +118,15 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm if err != nil { return fmt.Errorf("failed to parse installer BOM: %w", err) } + configuredRegistryURL := "" + if cfg.Registry != nil { + configuredRegistryURL = strings.TrimSuffix(strings.TrimPrefix(cfg.Registry.Server, "oci://"), "/") + if configuredRegistryURL != "" && configuredRegistryURL != "ghcr.io" { + if err := bomConfig.UseRegistry(configuredRegistryURL); err != nil { + return fmt.Errorf("failed to configure installer BOM registry: %w", err) + } + } + } var install *argocdinstaller.AppInstaller @@ -133,8 +143,8 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm return fmt.Errorf("registry password not found in vault (secret %q)", files.SecretRegistryPassword) } registryURL := opts.ArgoCDRegistryURL - if registryURL == "" && cfg.Registry != nil { - registryURL = cfg.Registry.Server + "/codesphere-cloud/charts" + if registryURL == "" && configuredRegistryURL != "" { + registryURL = configuredRegistryURL + "/codesphere-cloud/charts" } argoCDInstall, err := argocdinstaller.NewInstaller(argocdinstaller.InstallerConfig{ Version: opts.ArgoCDVersion, @@ -145,6 +155,7 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm FullInstall: true, ForceConflicts: opts.ArgoCDForceConflicts, RepoURL: opts.ArgoCDRepoURL, + BOM: bomConfig, ValueFiles: opts.ArgoCDValues, RESTConfig: restConfig, }) diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index fd6e88e75..f600b67da 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -74,6 +74,7 @@ oms beta bootstrap-gcp [flags] --prometheus-remote-write-user string Prometheus remote write username (optional) --recover-config Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false) --region string GCP Region (default: europe-west4) (default "europe-west4") + --registry string Alternative container registry used for Codesphere images and charts --registry-type string Container registry type to use (options: local-container, artifact-registry) (default: local-container) (default "local-container") --registry-user string Custom Registry username (only for GitHub registry type) (optional) --remote-oms-binary string Path to a local Linux amd64 OMS binary to copy to and use on the jumpbox instead of downloading a release (optional) diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index 3dbb5d8c9..1669691e7 100644 --- a/docs/oms_beta_bootstrap-local.md +++ b/docs/oms_beta_bootstrap-local.md @@ -31,7 +31,7 @@ oms beta bootstrap-local [flags] --pod-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [openfga-authz,cluster-admin,secret-management,sub-path-mount,workspace-ssh]) --profile string Profile to apply to the install config like resources (supported: dev, minimal, prod) (default "dev") - --registry-url string OCI registry URL used for the ArgoCD helm pull secret (default "oci://ghcr.io/codesphere-cloud/charts") + --registry string Alternative container registry used for Codesphere images and charts --registry-user string Custom Registry username --secrets-file string Path to secrets file (default: /prod.vault.yaml) --service-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 2902070ca..ebd472e6c 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -1029,7 +1029,10 @@ func (b *GCPBootstrapper) EnsureGitHubAccessConfigured() error { if b.Env.GitHubPAT == "" { return fmt.Errorf("GitHub PAT is not set") } - b.Env.InstallConfig.Registry.Server = "ghcr.io" + registryURL := strings.TrimSuffix(strings.TrimPrefix(b.Env.ContainerRegistryURL, "oci://"), "/") + if registryURL != "" { + b.Env.InstallConfig.Registry.Server = registryURL + } b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUser}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: b.Env.GitHubPAT}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 43edc3ccd..f2ae45ea0 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -991,13 +991,21 @@ var _ = Describe("GCP Bootstrapper", func() { err := bs.EnsureGitHubAccessConfigured() Expect(err).NotTo(HaveOccurred()) - Expect(bs.Env.InstallConfig.Registry.Server).To(Equal("ghcr.io")) + Expect(bs.Env.InstallConfig.Registry.Server).To(BeEmpty()) Expect(vault.GetSecret(files.SecretRegistryUsername).Fields.Password).To(Equal(csEnv.RegistryUser)) Expect(vault.GetSecret(files.SecretRegistryPassword).Fields.Password).To(Equal(csEnv.GitHubPAT)) Expect(bs.Env.InstallConfig.Registry.LoadContainerImages).To(BeFalse()) Expect(bs.Env.InstallConfig.Registry.ReplaceImagesInBom).To(BeFalse()) }) + It("uses the configured registry URL", func() { + csEnv.ContainerRegistryURL = "oci://registry.example.com/mirror/" + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + Expect(bs.EnsureGitHubAccessConfigured()).To(Succeed()) + Expect(bs.Env.InstallConfig.Registry.Server).To(Equal("registry.example.com/mirror")) + }) + Context("When GitHub PAT is missing", func() { BeforeEach(func() { csEnv.GitHubPAT = "" diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index be82d1d6d..b197e908c 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -85,8 +85,9 @@ type CodesphereEnvironment struct { InstallHash string `json:"install_hash"` InstallLocal string `json:"install_local"` // Registry - RegistryUser string `json:"-"` - RegistryPassword string `json:"-"` + RegistryUser string `json:"-"` + RegistryPassword string `json:"-"` + ContainerRegistryURL string `json:"container_registry_url,omitempty"` // Config InstallDir string `json:"-"` ExistingConfigUsed bool `json:"-"` @@ -99,8 +100,6 @@ type CodesphereEnvironment struct { ServiceCIDR string `json:"service_cidr"` CephDeviceFilter string `json:"-"` CephDevicePathFilter string `json:"-"` - // ArgoCD integration - ArgoCDRegistryURL string `json:"-"` } // NewLocalBootstrapper creates a bootstrapper for a local Codesphere cluster. @@ -237,13 +236,23 @@ func (b *LocalBootstrapper) Bootstrap() error { } func (b *LocalBootstrapper) newArgoCDAndAppsInstall() (*argocd.AppInstaller, error) { + version := "9.5.21" + if b.installerBOM != nil { + version = "" + } + registryURL := "" + if b.Env.InstallConfig.Registry != nil && b.Env.InstallConfig.Registry.Server != "" { + registryURL = strings.TrimSuffix(b.Env.InstallConfig.Registry.Server, "/") + "/codesphere-cloud/charts" + } + // renovate: datasource=helm depName=argo-cd registryUrl=https://argoproj.github.io/argo-helm argoCDInstall, err := argocd.NewInstaller(argocd.InstallerConfig{ - Version: "9.5.21", + Version: version, OciPassword: b.Env.RegistryPassword, - OciRegistryURL: strings.TrimPrefix(b.Env.ArgoCDRegistryURL, "oci://"), + OciRegistryURL: strings.TrimPrefix(registryURL, "oci://"), FullInstall: true, ForceConflicts: true, + BOM: b.installerBOM, RESTConfig: b.restConfig, }) if err != nil { @@ -511,6 +520,22 @@ func (b *LocalBootstrapper) EnsureInstallConfig() error { } b.Env.InstallConfig = b.icg.GetInstallConfig() + configuredRegistry := strings.TrimSuffix(strings.TrimPrefix(b.Env.ContainerRegistryURL, "oci://"), "/") + if configuredRegistry != "" { + if b.Env.InstallConfig.Registry == nil { + b.Env.InstallConfig.Registry = &files.RegistryConfig{} + } + b.Env.InstallConfig.Registry.Server = configuredRegistry + } + effectiveRegistry := "" + if b.Env.InstallConfig.Registry != nil { + effectiveRegistry = strings.TrimSuffix(strings.TrimPrefix(b.Env.InstallConfig.Registry.Server, "oci://"), "/") + } + if b.installerBOM != nil && effectiveRegistry != "" && effectiveRegistry != "ghcr.io" { + if err := b.installerBOM.UseRegistry(effectiveRegistry); err != nil { + return fmt.Errorf("failed to configure installer BOM registry: %w", err) + } + } return nil } @@ -688,7 +713,6 @@ func (b *LocalBootstrapper) EnsureGitHubAccessConfigured() error { if b.Env.RegistryPassword == "" { return fmt.Errorf("registry password is not set") } - b.Env.InstallConfig.Registry.Server = "ghcr.io" b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUser}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: b.Env.RegistryPassword}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false diff --git a/internal/installer/argocd/install_and_apps.go b/internal/installer/argocd/install_and_apps.go index 6d11a524c..332693a67 100644 --- a/internal/installer/argocd/install_and_apps.go +++ b/internal/installer/argocd/install_and_apps.go @@ -128,6 +128,13 @@ func (i *AppInstaller) InstallPCApps(ctx context.Context, bomConfig *bom.Config) // Values derived from the install config form the base; an explicit pcApps block in // config.yaml wins over them, and the --pc-apps-values files win over both. values := util.DeepMergeMaps(installer.OpenFgaPcAppsValues(&i.cfg.Config, i.cfg.Vault), i.cfg.Config.PcApps) + if i.cfg.Config.Registry != nil && i.cfg.Config.Registry.Server != "" { + values = util.DeepMergeMaps(map[string]any{ + "global": map[string]any{ + "imageRegistry": i.cfg.Config.Registry.Server, + }, + }, values) + } pcApps, err := installer.NewPcAppsFromBom( i.cfg.KubeClient, diff --git a/internal/installer/argocd/install_and_apps_test.go b/internal/installer/argocd/install_and_apps_test.go index 7e6814c45..6e37bae12 100644 --- a/internal/installer/argocd/install_and_apps_test.go +++ b/internal/installer/argocd/install_and_apps_test.go @@ -4,18 +4,28 @@ package argocd_test import ( + "context" + "encoding/json" "os" "os/exec" "path/filepath" "strings" + argov1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/argocd" + "github.com/codesphere-cloud/oms/internal/installer/bom" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" "github.com/codesphere-cloud/oms/internal/installer/vault/sops" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func sopsAndAgeAvailable() bool { @@ -42,6 +52,33 @@ var _ = Describe("AppInstaller", func() { Expect(install.InstallArgoCD()).To(Succeed()) Expect(argoCDInstall.called).To(BeTrue()) }) + + It("configures the pc-applications global image registry", func() { + scheme := runtime.NewScheme() + Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed()) + Expect(argov1alpha1.AddToScheme(scheme)).To(Succeed()) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd-codesphere-oci-read", Namespace: "argocd"}, + Data: map[string][]byte{"url": []byte("registry.example.com/mirror/codesphere-cloud/charts")}, + }).Build() + install := argocd.NewAppInstaller(argocd.AppInstallerConfig{ + Config: files.RootConfig{Registry: &files.RegistryConfig{Server: "registry.example.com/mirror"}}, + Vault: &files.InstallVault{}, + KubeClient: kubeClient, + }) + bomConfig := &bom.Config{Components: map[string]bom.ComponentConfig{ + "pc-applications": {Files: map[string]bom.FileRef{ + "chart": {OciRef: "oci://registry.example.com/mirror/codesphere-cloud/charts/pc-applications:1.2.3"}, + }}, + }} + + Expect(install.InstallPCApps(context.Background(), bomConfig)).To(Succeed()) + app := &argov1alpha1.Application{} + Expect(kubeClient.Get(context.Background(), client.ObjectKey{Name: "pc-applications", Namespace: "argocd"}, app)).To(Succeed()) + values := map[string]any{} + Expect(json.Unmarshal(app.Spec.Source.Helm.ValuesObject.Raw, &values)).To(Succeed()) + Expect(values).To(HaveKeyWithValue("global", map[string]any{"imageRegistry": "registry.example.com/mirror"})) + }) }) var _ = Describe("VaultAndRESTConfig", func() { diff --git a/internal/installer/argocd/installer.go b/internal/installer/argocd/installer.go index be03780c2..ab3b501a0 100644 --- a/internal/installer/argocd/installer.go +++ b/internal/installer/argocd/installer.go @@ -11,6 +11,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/bom" k8s "github.com/codesphere-cloud/oms/internal/util" "helm.sh/helm/v4/pkg/chart/common/util" "helm.sh/helm/v4/pkg/cli/values" @@ -34,6 +35,7 @@ type InstallerConfig struct { FullInstall bool ForceConflicts bool RepoURL string + BOM *bom.Config ValueFiles []string RESTConfig *rest.Config } @@ -89,6 +91,17 @@ func NewInstaller(cfg InstallerConfig) (*Installer, error) { // Install is the top-level orchestrator. It delegates every Helm interaction // to the HelmClient interface, keeping this function short and testable. func (a *Installer) Install() error { + chartName := "argo-cd" + usingBOMChart := false + if a.BOM != nil && a.RepoURL == "" && a.Version == "" { + if chart, ok := a.BOM.GetChart("argocd"); ok { + chartName = "oci://" + chart.Name() + usingBOMChart = true + a.Version = chart.Tag() + log.Printf("Using ArgoCD chart %s:%s from BOM\n", chart.Name(), chart.Tag()) + } + } + if err := a.validateRepoURL(); err != nil { return err } @@ -111,9 +124,18 @@ func (a *Installer) Install() error { defaults := map[string]any{ "dex": map[string]any{"enabled": false}, } + if usingBOMChart { + // The Codesphere argocd chart is a wrapper around the upstream + // argo-cd chart. Helm passes dependency values through the dependency + // name, so upstream defaults must be nested under "argo-cd". The + // upstream chart installed directly expects the same values at root. + defaults = map[string]any{ + "argo-cd": defaults, + } + } vals = util.MergeTables(vals, defaults) - chartName, repoURL := a.resolveChartRef("argo-cd") + chartName, repoURL := a.resolveChartRef(chartName) cfg := installer.ChartConfig{ ReleaseName: "argocd", ChartName: chartName, @@ -217,6 +239,9 @@ func (a *Installer) validateRepoURL() error { } func (a *Installer) resolveChartRef(chartName string) (string, string) { + if strings.HasPrefix(chartName, "oci://") { + return chartName, "" + } repoURL := a.RepoURL if repoURL == "" { repoURL = DefaultRepoURL diff --git a/internal/installer/argocd/installer_test.go b/internal/installer/argocd/installer_test.go index 631f46823..dbc6df0ed 100644 --- a/internal/installer/argocd/installer_test.go +++ b/internal/installer/argocd/installer_test.go @@ -10,6 +10,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/argocd" + "github.com/codesphere-cloud/oms/internal/installer/bom" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -173,6 +174,40 @@ var _ = Describe("Installer.Install", func() { }) }) + Context("BOM chart", func() { + It("uses the argocd OCI chart and version from the BOM", func() { + bomPath := filepath.Join(GinkgoT().TempDir(), "bom.json") + Expect(os.WriteFile(bomPath, []byte(`{"components":{"argocd":{"files":{"chart":{"ociRef":"ghcr.io/codesphere-cloud/charts/argocd:1.2.3"}}}}}`), 0o600)).To(Succeed()) + bomConfig, err := bom.Parse(bomPath) + Expect(err).NotTo(HaveOccurred()) + + helmMock.EXPECT().FindRelease("argocd", "argocd").Return(nil, nil) + helmMock.EXPECT().InstallChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { + argoValues, ok := cfg.Values["argo-cd"].(map[string]interface{}) + if !ok { + return false + } + dex, ok := argoValues["dex"].(map[string]interface{}) + return cfg.ChartName == "oci://ghcr.io/codesphere-cloud/charts/argocd" && + cfg.RepoURL == "" && cfg.Version == "1.2.3" && + ok && dex["enabled"] == false && cfg.Values["dex"] == nil + }), mock.Anything).Return(nil) + + a = &argocd.Installer{InstallerConfig: argocd.InstallerConfig{BOM: bomConfig}, Helm: helmMock} + Expect(a.Install()).To(Succeed()) + }) + + It("falls back to the upstream chart when no BOM is provided", func() { + helmMock.EXPECT().FindRelease("argocd", "argocd").Return(nil, nil) + helmMock.EXPECT().InstallChart(mock.Anything, mock.MatchedBy(func(cfg installer.ChartConfig) bool { + return cfg.ChartName == "argo-cd" && cfg.RepoURL == argocd.DefaultRepoURL + }), mock.Anything).Return(nil) + + a = &argocd.Installer{Helm: helmMock} + Expect(a.Install()).To(Succeed()) + }) + }) + Context("values overrides", func() { BeforeEach(func() { helmMock.EXPECT().FindRelease("argocd", "argocd").Return(nil, nil) diff --git a/internal/installer/bom/bom.go b/internal/installer/bom/bom.go index d631fc7a2..0b14f9ae9 100644 --- a/internal/installer/bom/bom.go +++ b/internal/installer/bom/bom.go @@ -96,6 +96,62 @@ func Parse(filePath string) (*Config, error) { return &cfg, nil } +// UseRegistry rewrites every image and OCI chart reference to registry while +// preserving its repository path, tag, or digest. +func (b *Config) UseRegistry(registry string) error { + registry = strings.TrimSuffix(strings.TrimPrefix(registry, "oci://"), "/") + if registry == "" { + return fmt.Errorf("registry must not be empty") + } + + rewrite := func(value string) (string, error) { + ociPrefix := "" + if strings.HasPrefix(value, "oci://") { + ociPrefix = "oci://" + } + ref, err := reference.ParseAnyReference(strings.TrimPrefix(value, "oci://")) + if err != nil { + return "", fmt.Errorf("invalid OCI reference %q: %w", value, err) + } + named, ok := ref.(reference.Named) + if !ok { + return "", fmt.Errorf("OCI reference %q has no repository name", value) + } + path := reference.Path(named) + suffix := "" + switch typed := ref.(type) { + case reference.Digested: + suffix = "@" + typed.Digest().String() + case reference.Tagged: + suffix = ":" + typed.Tag() + } + return ociPrefix + registry + "/" + path + suffix, nil + } + + for componentName, component := range b.Components { + for name, image := range component.ContainerImages { + rewritten, err := rewrite(image) + if err != nil { + return fmt.Errorf("component %q image %q: %w", componentName, name, err) + } + component.ContainerImages[name] = rewritten + } + for name, file := range component.Files { + if file.OciRef == "" { + continue + } + rewritten, err := rewrite(file.OciRef) + if err != nil { + return fmt.Errorf("component %q file %q: %w", componentName, name, err) + } + file.OciRef = rewritten + component.Files[name] = file + } + b.Components[componentName] = component + } + return nil +} + // GetPCApps returns the pc-applications chart version from the BOM by // parsing the tag out of the OCI image reference stored at // components["pc-applications"].files["chart"].ociRef. diff --git a/internal/installer/bom/bom_test.go b/internal/installer/bom/bom_test.go index b871ed737..02784cec5 100644 --- a/internal/installer/bom/bom_test.go +++ b/internal/installer/bom/bom_test.go @@ -264,4 +264,29 @@ var _ = Describe("Bom", func() { })) }) }) + + Describe("UseRegistry", func() { + It("rewrites images and OCI charts while preserving paths, tags, and digests", func() { + cfg := &bom.Config{Components: map[string]bom.ComponentConfig{ + "codesphere": { + ContainerImages: map[string]string{ + "api": "ghcr.io/codesphere-cloud/api:v1", + "worker": "ghcr.io/codesphere-cloud/worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + Files: map[string]bom.FileRef{ + "chart": {OciRef: "oci://ghcr.io/codesphere-cloud/charts/codesphere:v1"}, + }, + }, + }} + + Expect(cfg.UseRegistry("oci://registry.example.com/mirror/")).To(Succeed()) + Expect(cfg.Components["codesphere"].ContainerImages["api"]).To(Equal("registry.example.com/mirror/codesphere-cloud/api:v1")) + Expect(cfg.Components["codesphere"].ContainerImages["worker"]).To(Equal("registry.example.com/mirror/codesphere-cloud/worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + Expect(cfg.Components["codesphere"].Files["chart"].OciRef).To(Equal("oci://registry.example.com/mirror/codesphere-cloud/charts/codesphere:v1")) + }) + + It("rejects an empty registry", func() { + Expect((&bom.Config{}).UseRegistry("")).To(MatchError("registry must not be empty")) + }) + }) }) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index b0f3050f4..8851f7ae9 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -133,7 +133,7 @@ type SecretsConfig struct { } type RegistryConfig struct { - Server string `yaml:"server"` + Server string `yaml:"server,omitempty"` ReplaceImagesInBom bool `yaml:"replaceImagesInBom"` LoadContainerImages bool `yaml:"loadContainerImages"` }