diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 334158061..21df5e9bf 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -117,7 +117,9 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash to install (default: none)") 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.RegistryUsername, "registry-user", "", "Username for direct registry access") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RegistryPassword, "registry-password", "", "Password or token for direct access to an alternative registry") + 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.") @@ -196,9 +198,14 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { c.CodesphereEnv.RegistryType = gcp.RegistryType(c.InputRegistryType) c.CodesphereEnv.OmsWorkdir = c.Env.GetOmsWorkdir() - if c.CodesphereEnv.GitHubPAT != "" { + if c.CodesphereEnv.ContainerRegistryURL != "" { + c.CodesphereEnv.RegistryType = gcp.RegistryTypeExternal + if c.CodesphereEnv.RegistryUsername == "" || c.CodesphereEnv.RegistryPassword == "" { + return fmt.Errorf("registry-user and registry-password must be set when using an alternative registry") + } + } else if c.CodesphereEnv.GitHubPAT != "" { c.CodesphereEnv.RegistryType = gcp.RegistryTypeGitHub - if c.CodesphereEnv.RegistryUser == "" { + if c.CodesphereEnv.RegistryUsername == "" { return fmt.Errorf("registry-user must be set when using GitHub registry type") } } @@ -238,8 +245,8 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { packageName := "-installer" installCmd := "oms install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml" - if gcp.RegistryType(bs.Env.RegistryType) == gcp.RegistryTypeGitHub { - log.Printf("You set a GitHub PAT for direct image access. Make sure to use a lite package, as VM root disk sizes are reduced.") + if gcp.RegistryType(bs.Env.RegistryType) == gcp.RegistryTypeGitHub || gcp.RegistryType(bs.Env.RegistryType) == gcp.RegistryTypeExternal { + log.Printf("You configured direct registry access. Make sure to use a lite package, as VM root disk sizes are reduced.") installCmd += " -s load-container-images" packageName += "-lite" 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..ede25a676 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" @@ -118,6 +119,16 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm 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 if err := stlog.Substep("Load vault data", func() error { @@ -133,8 +144,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 +156,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 096b3081d..1e8a084d3 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -74,8 +74,10 @@ 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-password string Password or token for direct access to an alternative registry --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) + --registry-user string Username for direct registry access --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) --root-disk-size int Instance root disk size in GB (default: 50) (default 50) --secrets-dir string Directory for secrets (default: /etc/codesphere/secrets) (default "/etc/codesphere/secrets") diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index 9551845c5..e7b16fdfa 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,virtual-machines]) --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 9db6fedec..a168c1688 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -48,6 +48,10 @@ const ( // bootstrapping only configures GitHub access and installs the lite // package. RegistryTypeGitHub RegistryType = "github" + + // RegistryTypeExternal pulls images directly from a user-provided registry + // with explicit credentials and installs the lite package. + RegistryTypeExternal RegistryType = "external" ) // CheckOMSManagedLabel checks if the given labels map indicates an OMS-managed project. @@ -161,7 +165,6 @@ type CodesphereEnvironment struct { GitHubAppName string `json:"-"` GitHubTeamOrg string `json:"github_team_org"` GitHubTeamSlug string `json:"github_team_slug"` - RegistryUser string `json:"-"` InternalFlags []string `json:"internal"` PreviewFlags []string `json:"preview"` FeatureFlags []string `json:"feature_flags"` @@ -355,8 +358,8 @@ func (b *GCPBootstrapper) Bootstrap() error { } } - if b.Env.RegistryType == RegistryTypeGitHub { - err = b.stlog.Step("Ensure GitHub access configured", b.EnsureGitHubAccessConfigured) + if b.Env.RegistryType == RegistryTypeGitHub || b.Env.RegistryType == RegistryTypeExternal { + err = b.stlog.Step("Ensure registry access configured", b.EnsureRegistryAccessConfigured) if err != nil { return fmt.Errorf("failed to update install config: %w", err) } @@ -551,7 +554,7 @@ func (b *GCPBootstrapper) validateInstallVersion() error { } requiredFilename := "installer.tar.gz" - if b.Env.RegistryType == RegistryTypeGitHub { + if b.Env.RegistryType == RegistryTypeGitHub || b.Env.RegistryType == RegistryTypeExternal { requiredFilename = "installer-lite.tar.gz" } @@ -1068,14 +1071,35 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { return nil } -func (b *GCPBootstrapper) EnsureGitHubAccessConfigured() error { - if b.Env.GitHubPAT == "" { - return fmt.Errorf("GitHub PAT is not set") +// EnsureRegistryAccessConfigured stores credentials and configures direct access +// to either GitHub Container Registry or an explicitly selected external registry. +func (b *GCPBootstrapper) EnsureRegistryAccessConfigured() error { + registryPassword := b.Env.RegistryPassword + if b.Env.RegistryType == RegistryTypeGitHub { + if b.Env.GitHubPAT == "" { + return fmt.Errorf("GitHub PAT is not set") + } + + registryPassword = b.Env.GitHubPAT + } else { + registryURL := strings.TrimSuffix(strings.TrimPrefix(b.Env.ContainerRegistryURL, "oci://"), "/") + if registryURL == "" { + return fmt.Errorf("external registry URL is not set") + } + + b.Env.InstallConfig.Registry.Server = registryURL } - 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.GitHubPAT}}) + if b.Env.RegistryUsername == "" { + return fmt.Errorf("registry username is not set") + } + + if registryPassword == "" { + return fmt.Errorf("registry password is not set") + } + + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUsername}}) + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: registryPassword}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false b.Env.InstallConfig.Registry.LoadContainerImages = false @@ -1158,7 +1182,7 @@ func (b *GCPBootstrapper) codespherePackageFilename() string { } func (b *GCPBootstrapper) codespherePackageArchiveName() string { - if b.Env.RegistryType == RegistryTypeGitHub { + if b.Env.RegistryType == RegistryTypeGitHub || b.Env.RegistryType == RegistryTypeExternal { return "installer-lite.tar.gz" } @@ -1211,7 +1235,7 @@ func (b *GCPBootstrapper) generateSkipStepsArg() string { skipSteps := []string{"kubernetes"} skipSteps = util.AppendUnique(skipSteps, b.Env.InstallSkipSteps...) - if b.Env.RegistryType == RegistryTypeGitHub { + if b.Env.RegistryType == RegistryTypeGitHub || b.Env.RegistryType == RegistryTypeExternal { skipSteps = util.AppendUnique(skipSteps, "load-container-images") } diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index ea7224163..4d2600726 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -977,34 +977,61 @@ var _ = Describe("GCP Bootstrapper", func() { }) }) - Describe("EnsureGitHubAccessConfigured", func() { + Describe("EnsureRegistryAccessConfigured", func() { BeforeEach(func() { csEnv.GitHubPAT = "fake-pat" - csEnv.RegistryUser = "custom-registry" + csEnv.RegistryUsername = "custom-registry" + csEnv.RegistryType = gcp.RegistryTypeGitHub }) It("sets configuration options in installconfig", func() { vault := &files.InstallVault{} icg.EXPECT().GetVault().Return(vault) - err := bs.EnsureGitHubAccessConfigured() + err := bs.EnsureRegistryAccessConfigured() Expect(err).NotTo(HaveOccurred()) - Expect(bs.Env.InstallConfig.Registry.Server).To(Equal("ghcr.io")) - Expect(vault.GetSecret(files.SecretRegistryUsername).Fields.Password).To(Equal(csEnv.RegistryUser)) + Expect(bs.Env.InstallConfig.Registry.Server).To(BeEmpty()) + Expect(vault.GetSecret(files.SecretRegistryUsername).Fields.Password).To(Equal(csEnv.RegistryUsername)) 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 explicit credentials for an external registry", func() { + csEnv.ContainerRegistryURL = "oci://registry.example.com/mirror/" + csEnv.RegistryType = gcp.RegistryTypeExternal + csEnv.RegistryPassword = "registry-password" + vault := &files.InstallVault{} + icg.EXPECT().GetVault().Return(vault) + + Expect(bs.EnsureRegistryAccessConfigured()).To(Succeed()) + Expect(bs.Env.InstallConfig.Registry.Server).To(Equal("registry.example.com/mirror")) + Expect(vault.GetSecret(files.SecretRegistryUsername).Fields.Password).To(Equal(csEnv.RegistryUsername)) + Expect(vault.GetSecret(files.SecretRegistryPassword).Fields.Password).To(Equal(csEnv.RegistryPassword)) + Expect(bs.Env.InstallConfig.Registry.LoadContainerImages).To(BeFalse()) + Expect(bs.Env.InstallConfig.Registry.ReplaceImagesInBom).To(BeFalse()) + }) + Context("When GitHub PAT is missing", func() { BeforeEach(func() { csEnv.GitHubPAT = "" }) It("returns an error", func() { - err := bs.EnsureGitHubAccessConfigured() + err := bs.EnsureRegistryAccessConfigured() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("GitHub PAT is not set")) }) }) + + Context("When an external registry password is missing", func() { + BeforeEach(func() { + csEnv.ContainerRegistryURL = "registry.example.com" + csEnv.RegistryType = gcp.RegistryTypeExternal + }) + + It("returns an error", func() { + Expect(bs.EnsureRegistryAccessConfigured()).To(MatchError("registry password is not set")) + }) + }) }) Describe("EnsureVPC", func() { @@ -1381,7 +1408,7 @@ var _ = Describe("GCP Bootstrapper", func() { Context("Direct GitHub access", func() { BeforeEach(func() { csEnv.GitHubPAT = "fake-pat" - csEnv.RegistryUser = "fake-user" + csEnv.RegistryUsername = "fake-user" csEnv.RegistryType = "github" }) It("downloads and installs lite package", func() { @@ -1397,6 +1424,20 @@ var _ = Describe("GCP Bootstrapper", func() { }) }) + Context("External registry access", func() { + BeforeEach(func() { + csEnv.RegistryType = gcp.RegistryTypeExternal + }) + + It("downloads and installs the lite package", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", "oms download package -f installer-lite.tar.gz -H abc1234567890 v1.2.3").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml -p v1.2.3-abc1234567890-installer-lite.tar.gz -s kubernetes,load-container-images").Return(nil) + + Expect(bs.InstallCodesphere()).To(Succeed()) + }) + }) + Context("without explicit hash", func() { BeforeEach(func() { // Simulate that ValidateInput has populated the hash diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 75132dc15..5de84d895 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -124,7 +124,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.Env.InstallConfig.Datacenter.CountryCode = "DE" b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir - if b.Env.RegistryType != RegistryTypeGitHub { + if b.Env.RegistryType != RegistryTypeGitHub && b.Env.RegistryType != RegistryTypeExternal { b.Env.InstallConfig.Registry.ReplaceImagesInBom = true b.Env.InstallConfig.Registry.LoadContainerImages = true } diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index be82d1d6d..e528c3c48 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,24 @@ 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 { @@ -512,6 +522,26 @@ 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 +718,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..512ad6e58 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,35 @@ 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..3535ad5a7 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,18 @@ 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 +125,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 +240,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..527a930c5 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,42 @@ 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..a9ec753b0 100644 --- a/internal/installer/bom/bom.go +++ b/internal/installer/bom/bom.go @@ -96,6 +96,73 @@ 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") + } + + for componentName, component := range b.Components { + for name, image := range component.ContainerImages { + rewritten, err := rewriteRegistry(image, registry) + 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 := rewriteRegistry(file.OciRef, registry) + 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 +} + +func rewriteRegistry(value, registry 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 +} + // 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"` }