From 865f0b3313ac21cbb1b32116355c0395bb4b0272 Mon Sep 17 00:00:00 2001 From: Niklas Brodnicke Date: Fri, 3 Jul 2026 15:23:53 +0200 Subject: [PATCH 1/3] feat(fga): Cluster Admin Email assignment gets part of bootstrap command Signed-off-by: Niklas Brodnicke --- cli/cmd/bootstrap_gcp.go | 1 + cli/cmd/install_codesphere.go | 4 +- cli/cmd/install_codesphere_dependencies.go | 14 +++-- cli/cmd/install_codesphere_platform.go | 53 +++++++++++++++++-- docs/oms_beta_bootstrap-gcp.md | 1 + internal/bootstrap/gcp/gcp.go | 27 ++++++++++ internal/bootstrap/gcp/gcp_test.go | 31 +++++++++++ internal/bootstrap/gcp/install_config.go | 4 ++ internal/bootstrap/gcp/install_config_test.go | 34 ++++++++++++ internal/clusteradmin/clusteradmin.go | 27 ++++++++-- internal/clusteradmin/clusteradmin_test.go | 28 ++++++++++ internal/installer/files/config_yaml.go | 1 + 12 files changed, 213 insertions(+), 12 deletions(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 02368144..c5f22da7 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -110,6 +110,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.PrometheusRemoteWriteURL, "prometheus-remote-write-url", "", "Prometheus remote write URL (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.PrometheusRemoteWriteUser, "prometheus-remote-write-user", "", "Prometheus remote write username (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.PrometheusRemoteWritePassword, "prometheus-remote-write-password", "", "Prometheus remote write password stored in the generated vault (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ClusterAdminEmail, "cluster-admin-email", "", "Email address of the initial cluster admin. Written to the install config and applied as the cluster-admin-email secret before the Codesphere platform is installed (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 0263586e..965dc314 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -73,7 +73,7 @@ func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, _ []string) error { } if c.Opts.CodesphereOnly { - return installCodespherePlatform(effectiveOpts, c.Env) + return installCodespherePlatform(effectiveOpts, cfg, c.Env) } if infraInstaller.HasExecutableSteps(cfg) { @@ -92,7 +92,7 @@ func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, _ []string) error { return nil } - return installCodespherePlatform(effectiveOpts, c.Env) + return installCodespherePlatform(effectiveOpts, cfg, c.Env) } func AddInstallCodesphereCmd(install *cobra.Command, opts *GlobalOptions) { diff --git a/cli/cmd/install_codesphere_dependencies.go b/cli/cmd/install_codesphere_dependencies.go index bed99794..e6fd449e 100644 --- a/cli/cmd/install_codesphere_dependencies.go +++ b/cli/cmd/install_codesphere_dependencies.go @@ -151,13 +151,19 @@ func (i *argoCDAndAppsInstall) loadVaultData() error { } func (i *argoCDAndAppsInstall) resolveVaultPath() (string, error) { - if strings.TrimSpace(i.opts.Vault) != "" { - return i.opts.Vault, nil + return resolveVaultPath(i.opts.Vault, i.config) +} + +// resolveVaultPath returns the explicit --vault path or falls back to +// prod.vault.yaml in the config's secrets baseDir. +func resolveVaultPath(vaultPath string, config files.RootConfig) (string, error) { + if strings.TrimSpace(vaultPath) != "" { + return vaultPath, nil } - if strings.TrimSpace(i.config.Secrets.BaseDir) == "" { + if strings.TrimSpace(config.Secrets.BaseDir) == "" { return "", fmt.Errorf("vault path is not set and config.yaml secrets.baseDir is empty") } - return filepath.Join(i.config.Secrets.BaseDir, "prod.vault.yaml"), nil + return filepath.Join(config.Secrets.BaseDir, "prod.vault.yaml"), nil } func (i *argoCDAndAppsInstall) installArgoCD() error { diff --git a/cli/cmd/install_codesphere_platform.go b/cli/cmd/install_codesphere_platform.go index 7a259c71..b952c4ab 100644 --- a/cli/cmd/install_codesphere_platform.go +++ b/cli/cmd/install_codesphere_platform.go @@ -9,10 +9,14 @@ import ( "runtime" "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/clusteradmin" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/system" + "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" ) // InstallCodespherePlatformCmd runs only the Codesphere platform step (Phase 3). @@ -23,16 +27,20 @@ type InstallCodespherePlatformCmd struct { } func (c *InstallCodespherePlatformCmd) RunE(_ *cobra.Command, _ []string) error { - effectiveOpts, _, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) + effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err } defer cleanup() - return installCodespherePlatform(effectiveOpts, c.Env) + return installCodespherePlatform(effectiveOpts, cfg, c.Env) } -func installCodespherePlatform(opts *InstallCodesphereOpts, env env.Env) error { +func installCodespherePlatform(opts *InstallCodesphereOpts, cfg files.RootConfig, env env.Env) error { + if err := ensureClusterAdminSecret(context.Background(), opts, cfg); err != nil { + return fmt.Errorf("failed to set cluster admin email: %w", err) + } + workdir := env.GetOmsWorkdir() pm := installer.NewPackage(workdir, opts.Package) cm := installer.NewConfig() @@ -55,6 +63,45 @@ func installCodespherePlatform(opts *InstallCodesphereOpts, env env.Env) error { return nil } +// ensureClusterAdminSecret applies the cluster admin email configured via +// codesphere.clusterAdminEmail to the cluster-admin-email secret before the +// platform is installed, so the auth-service finds it on first start. +// It is a no-op when the config does not set an email. +func ensureClusterAdminSecret(ctx context.Context, opts *InstallCodesphereOpts, cfg files.RootConfig) error { + email := cfg.Codesphere.ClusterAdminEmail + if email == "" { + return nil + } + + vaultPath, err := resolveVaultPath(opts.Vault, cfg) + if err != nil { + return err + } + vault, err := installer.LoadVaultData(vaultPath, opts.PrivKey) + if err != nil { + return fmt.Errorf("failed to load vault %s: %w", vaultPath, err) + } + kubeConfigContent, err := kubeConfigContentFromVault(vault) + if err != nil { + return err + } + restConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(kubeConfigContent)) + if err != nil { + return fmt.Errorf("failed to load kubernetes config from vault: %w", err) + } + clientset, _, err := util.NewClientsFromRESTConfig(restConfig) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + return clusteradmin.AddClusterAdmin(ctx, clientset, clusteradmin.Opts{ + Email: email, + Namespace: clusteradmin.DefaultNamespace, + SecretName: clusteradmin.DefaultSecretName, + CreateNamespace: true, + }) +} + func AddInstallCodespherePlatformCmd(codesphere *cobra.Command, opts *InstallCodesphereOpts) { platform := InstallCodespherePlatformCmd{ cmd: &cobra.Command{ diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index ce081e6c..337fbacf 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -27,6 +27,7 @@ oms beta bootstrap-gcp [flags] --central-otel-password string Central OpenTelemetry password. Needed when sending spans to central collector (optional) --central-otel-span-metrics Enable span metrics in Central OpenTelemetry export (default: false) --central-otel-username string Central OpenTelemetry username. Needed when sending spans to central collector (optional) + --cluster-admin-email string Email address of the initial cluster admin. Written to the install config and applied as the cluster-admin-email secret before the Codesphere platform is installed (optional) --create-test-user Create a test user with API token on the bootstrapped instance for smoke testing (default: false) --custom-pg-ip string Custom PostgreSQL IP (optional) --datacenter-id int Datacenter ID (default: 1) (default 1) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 3a189512..1dd4a276 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -15,6 +15,7 @@ import ( "cloud.google.com/go/compute/apiv1/computepb" "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/clusteradmin" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/github" "github.com/codesphere-cloud/oms/internal/installer" @@ -134,6 +135,7 @@ type CodesphereEnvironment struct { PrometheusRemoteWriteUser string `json:"prometheus_remote_write_user,omitempty"` PrometheusRemoteWritePassword string `json:"-"` PrometheusRemoteWriteURL string `json:"prometheus_remote_write_url,omitempty"` + ClusterAdminEmail string `json:"cluster_admin_email,omitempty"` // ACME Issuer GoogleACMEIssuer bool `json:"google_acme_issuer,omitempty"` @@ -438,9 +440,34 @@ func (b *GCPBootstrapper) ValidateInput() error { return err } + err = b.validateClusterAdminEmail() + if err != nil { + return err + } + return b.validateTelemetryExportParams() } +func (b *GCPBootstrapper) validateClusterAdminEmail() error { + if b.Env.ClusterAdminEmail == "" { + return nil + } + + // The email reaches the cluster via the install config, which is only + // updated when the config is written. + if !b.Env.WriteConfig { + return fmt.Errorf("cluster admin email requires write-config to be enabled") + } + + email, err := clusteradmin.NormalizeEmail(b.Env.ClusterAdminEmail) + if err != nil { + return fmt.Errorf("invalid cluster admin email: %w", err) + } + b.Env.ClusterAdminEmail = email + + return nil +} + // validateInstallVersion checks if the specified install version exists and contains the required installer artifact func (b *GCPBootstrapper) validateInstallVersion() error { if b.Env.InstallLocal != "" { diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 81a95782..963b4f7b 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -308,6 +308,37 @@ var _ = Describe("GCP Bootstrapper", func() { }) }) }) + Context("When a cluster admin email is set", func() { + BeforeEach(func() { + csEnv.ClusterAdminEmail = "Admin@Codesphere.com" + csEnv.WriteConfig = true + }) + It("passes validation and normalizes the email", func() { + err := bs.ValidateInput() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.ClusterAdminEmail).To(Equal("admin@codesphere.com")) + }) + + Context("when the email is invalid", func() { + BeforeEach(func() { + csEnv.ClusterAdminEmail = "not-an-email" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("invalid cluster admin email"))) + }) + }) + + Context("when write-config is disabled", func() { + BeforeEach(func() { + csEnv.WriteConfig = false + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("cluster admin email requires write-config"))) + }) + }) + }) Context("When a version and hash are specified", func() { BeforeEach(func() { mockPortalClient = portal.NewMockPortal(GinkgoT()) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 0308f517..8ecb2fa0 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -366,6 +366,10 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.Env.InstallConfig.Codesphere.Internal = b.Env.InternalFlags b.Env.InstallConfig.Codesphere.Preview = util.StringSliceToBoolMap(b.Env.PreviewFlags) b.Env.InstallConfig.Codesphere.Features = util.StringSliceToBoolMap(b.Env.FeatureFlags) + // Only set when the flag is provided so a recovered config keeps its value on re-runs. + if b.Env.ClusterAdminEmail != "" { + b.Env.InstallConfig.Codesphere.ClusterAdminEmail = b.Env.ClusterAdminEmail + } b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 7d8212e1..6515bf4c 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -432,6 +432,40 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal(util.StringSliceToBoolMap(csEnv.FeatureFlags))) }) }) + Context("When cluster admin email is set", func() { + BeforeEach(func() { + csEnv.ClusterAdminEmail = "admin@codesphere.com" + }) + It("writes the email to the install config", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.ClusterAdminEmail).To(Equal("admin@codesphere.com")) + }) + }) + Context("When cluster admin email is not set", func() { + BeforeEach(func() { + csEnv.InstallConfig.Codesphere.ClusterAdminEmail = "existing@codesphere.com" + }) + It("keeps the value of an existing config", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.ClusterAdminEmail).To(Equal("existing@codesphere.com")) + }) + }) Context("When GitHub App name is not set ", func() { BeforeEach(func() { csEnv.GitHubAppName = "" diff --git a/internal/clusteradmin/clusteradmin.go b/internal/clusteradmin/clusteradmin.go index e941c477..70780b26 100644 --- a/internal/clusteradmin/clusteradmin.go +++ b/internal/clusteradmin/clusteradmin.go @@ -31,6 +31,10 @@ type Opts struct { Email string Namespace string SecretName string + // CreateNamespace creates the target namespace if it does not exist yet + // instead of failing. Used during installation, where the secret may be + // written before the platform charts have created the namespace. + CreateNamespace bool } // AddClusterAdmin writes the given email to the cluster-admin-email secret in @@ -40,7 +44,7 @@ type Opts struct { // The email is stored under the EmailKey data key. Running the command again // with a different email overwrites the previous value. func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts Opts) error { - email, err := normalizeEmail(opts.Email) + email, err := NormalizeEmail(opts.Email) if err != nil { return err } @@ -52,6 +56,12 @@ func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts O return fmt.Errorf("secret name must not be empty") } + if opts.CreateNamespace { + if err := ensureNamespace(ctx, clientset, opts.Namespace); err != nil { + return err + } + } + secrets := clientset.CoreV1().Secrets(opts.Namespace) existing, err := secrets.Get(ctx, opts.SecretName, metav1.GetOptions{}) @@ -92,8 +102,19 @@ func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts O return nil } -// normalizeEmail validates and canonicalizes an email address. -func normalizeEmail(raw string) (string, error) { +// ensureNamespace creates the namespace if it does not exist yet. +func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namespace string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + } + if _, err := clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating namespace %s: %w", namespace, err) + } + return nil +} + +// NormalizeEmail validates and canonicalizes an email address. +func NormalizeEmail(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "", fmt.Errorf("email must not be empty") diff --git a/internal/clusteradmin/clusteradmin_test.go b/internal/clusteradmin/clusteradmin_test.go index 2cf66607..3e3c9dd3 100644 --- a/internal/clusteradmin/clusteradmin_test.go +++ b/internal/clusteradmin/clusteradmin_test.go @@ -99,6 +99,34 @@ var _ = Describe("AddClusterAdmin", func() { Expect(clusteradmin.AddClusterAdmin(ctx, clientset, opts)).To(MatchError(ContainSubstring("invalid email"))) }) + It("creates the namespace when CreateNamespace is set and it does not exist yet", func() { + opts.CreateNamespace = true + Expect(clusteradmin.AddClusterAdmin(ctx, clientset, opts)).To(Succeed()) + + _, err := clientset.CoreV1().Namespaces().Get(ctx, opts.Namespace, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(getEmail()).To(Equal("niklas@codesphere.com")) + }) + + It("succeeds when CreateNamespace is set and the namespace already exists", func() { + _, err := clientset.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: opts.Namespace}, + }, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + opts.CreateNamespace = true + Expect(clusteradmin.AddClusterAdmin(ctx, clientset, opts)).To(Succeed()) + + Expect(getEmail()).To(Equal("niklas@codesphere.com")) + }) + + It("does not create the namespace when CreateNamespace is not set", func() { + Expect(clusteradmin.AddClusterAdmin(ctx, clientset, opts)).To(Succeed()) + + _, err := clientset.CoreV1().Namespaces().Get(ctx, opts.Namespace, metav1.GetOptions{}) + Expect(err).To(HaveOccurred()) + }) + It("rejects an empty namespace", func() { opts.Namespace = " " Expect(clusteradmin.AddClusterAdmin(ctx, clientset, opts)).To(MatchError(ContainSubstring("namespace must not be empty"))) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index f617c29c..51f041f6 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -305,6 +305,7 @@ type CodesphereConfig struct { Internal []string `yaml:"internal"` Preview map[string]bool `yaml:"preview"` Features map[string]bool `yaml:"features"` + ClusterAdminEmail string `yaml:"clusterAdminEmail,omitempty"` ExtraCAPem string `yaml:"extraCaPem,omitempty"` ExtraWorkspaceEnvVars map[string]string `yaml:"extraWorkspaceEnvVars,omitempty"` ExtraWorkspaceFiles []ExtraWorkspaceFile `yaml:"extraWorkspaceFiles,omitempty"` From 7573a150a8bbde4159fc32fc30c842f04b7a41df Mon Sep 17 00:00:00 2001 From: Niklas Brodnicke Date: Mon, 6 Jul 2026 11:58:25 +0200 Subject: [PATCH 2/3] addressing Oliver Signed-off-by: Niklas Brodnicke --- cli/cmd/install_codesphere.go | 7 +- cli/cmd/install_codesphere_dependencies.go | 47 +---------- cli/cmd/install_codesphere_platform.go | 52 ++----------- cli/cmd/install_codesphere_test.go | 5 +- internal/installer/cluster_admin.go | 90 ++++++++++++++++++++++ 5 files changed, 106 insertions(+), 95 deletions(-) create mode 100644 internal/installer/cluster_admin.go diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 965dc314..674d60b3 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -52,7 +52,8 @@ type InstallCodesphereOpts struct { PCAppsValues []string } -func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, _ []string) error { +func (c *InstallCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -73,7 +74,7 @@ func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, _ []string) error { } if c.Opts.CodesphereOnly { - return installCodespherePlatform(effectiveOpts, cfg, c.Env) + return installCodespherePlatform(ctx, effectiveOpts, cfg, c.Env) } if infraInstaller.HasExecutableSteps(cfg) { @@ -92,7 +93,7 @@ func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, _ []string) error { return nil } - return installCodespherePlatform(effectiveOpts, cfg, c.Env) + return installCodespherePlatform(ctx, effectiveOpts, cfg, c.Env) } func AddInstallCodesphereCmd(install *cobra.Command, opts *GlobalOptions) { diff --git a/cli/cmd/install_codesphere_dependencies.go b/cli/cmd/install_codesphere_dependencies.go index e6fd449e..caa4adf8 100644 --- a/cli/cmd/install_codesphere_dependencies.go +++ b/cli/cmd/install_codesphere_dependencies.go @@ -7,9 +7,7 @@ import ( "context" "fmt" "os" - "path/filepath" "runtime" - "strings" "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/codesphere-cloud/oms/internal/bootstrap" @@ -21,7 +19,6 @@ import ( "github.com/codesphere-cloud/oms/internal/system" "github.com/spf13/cobra" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -119,30 +116,19 @@ type argoCDAndAppsInstall struct { } func (i *argoCDAndAppsInstall) loadVaultData() error { - vaultPath, err := i.resolveVaultPath() + vault, kubeConfig, err := installer.VaultAndRESTConfig(i.opts.Vault, i.opts.PrivKey, i.config) if err != nil { return err } + i.vault = vault + i.kubeConfig = kubeConfig - i.vault, err = installer.LoadVaultData(vaultPath, i.opts.PrivKey) - if err != nil { - return fmt.Errorf("failed to load vault %s: %w", vaultPath, err) - } if s := i.vault.GetSecret(files.SecretRegistryPassword); s != nil && s.Fields != nil { i.ociPassword = s.Fields.Password } if i.ociPassword == "" { return fmt.Errorf("registry password not found in vault (secret %q)", files.SecretRegistryPassword) } - kubeConfigContent, err := kubeConfigContentFromVault(i.vault) - if err != nil { - return err - } - - i.kubeConfig, err = clientcmd.RESTConfigFromKubeConfig([]byte(kubeConfigContent)) - if err != nil { - return fmt.Errorf("failed to load kubernetes config from vault: %w", err) - } i.kubeClient, err = ctrlclient.New(i.kubeConfig, ctrlclient.Options{}) if err != nil { return fmt.Errorf("failed to create kubernetes client: %w", err) @@ -150,22 +136,6 @@ func (i *argoCDAndAppsInstall) loadVaultData() error { return nil } -func (i *argoCDAndAppsInstall) resolveVaultPath() (string, error) { - return resolveVaultPath(i.opts.Vault, i.config) -} - -// resolveVaultPath returns the explicit --vault path or falls back to -// prod.vault.yaml in the config's secrets baseDir. -func resolveVaultPath(vaultPath string, config files.RootConfig) (string, error) { - if strings.TrimSpace(vaultPath) != "" { - return vaultPath, nil - } - if strings.TrimSpace(config.Secrets.BaseDir) == "" { - return "", fmt.Errorf("vault path is not set and config.yaml secrets.baseDir is empty") - } - return filepath.Join(config.Secrets.BaseDir, "prod.vault.yaml"), nil -} - func (i *argoCDAndAppsInstall) installArgoCD() error { i.ociRegistryURL = i.opts.ArgoCDRegistryURL if i.ociRegistryURL == "" && i.config.Registry != nil { @@ -223,17 +193,6 @@ func (i *argoCDAndAppsInstall) installPcApps() error { return nil } -func kubeConfigContentFromVault(vault *files.InstallVault) (string, error) { - if vault == nil { - return "", fmt.Errorf("vault is not loaded") - } - kubeConfig := vault.GetSecret(files.SecretKubeConfig) - if kubeConfig == nil || kubeConfig.File == nil || strings.TrimSpace(kubeConfig.File.Content) == "" { - return "", fmt.Errorf("kubeconfig not found in vault (secret %q)", files.SecretKubeConfig) - } - return kubeConfig.File.Content, nil -} - func AddInstallCodesphereDepenciesCmd(codesphere *cobra.Command, opts *InstallCodesphereOpts) { deps := InstallCodesphereDepenciesCmd{ cmd: &cobra.Command{ diff --git a/cli/cmd/install_codesphere_platform.go b/cli/cmd/install_codesphere_platform.go index b952c4ab..2a178d8e 100644 --- a/cli/cmd/install_codesphere_platform.go +++ b/cli/cmd/install_codesphere_platform.go @@ -9,14 +9,11 @@ import ( "runtime" "github.com/codesphere-cloud/cs-go/pkg/io" - "github.com/codesphere-cloud/oms/internal/clusteradmin" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/system" - "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" - "k8s.io/client-go/tools/clientcmd" ) // InstallCodespherePlatformCmd runs only the Codesphere platform step (Phase 3). @@ -26,25 +23,25 @@ type InstallCodespherePlatformCmd struct { Env env.Env } -func (c *InstallCodespherePlatformCmd) RunE(_ *cobra.Command, _ []string) error { +func (c *InstallCodespherePlatformCmd) RunE(cmd *cobra.Command, _ []string) error { effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err } defer cleanup() - return installCodespherePlatform(effectiveOpts, cfg, c.Env) + return installCodespherePlatform(cmd.Context(), effectiveOpts, cfg, c.Env) } -func installCodespherePlatform(opts *InstallCodesphereOpts, cfg files.RootConfig, env env.Env) error { - if err := ensureClusterAdminSecret(context.Background(), opts, cfg); err != nil { +func installCodespherePlatform(ctx context.Context, opts *InstallCodesphereOpts, cfg files.RootConfig, env env.Env) error { + if err := installer.EnsureClusterAdminSecret(ctx, opts.Vault, opts.PrivKey, cfg); err != nil { return fmt.Errorf("failed to set cluster admin email: %w", err) } workdir := env.GetOmsWorkdir() pm := installer.NewPackage(workdir, opts.Package) cm := installer.NewConfig() - im := system.NewImage(context.Background()) + im := system.NewImage(ctx) ci := &installer.CodesphereInstaller{ ConfigPath: opts.ConfigPath, @@ -63,45 +60,6 @@ func installCodespherePlatform(opts *InstallCodesphereOpts, cfg files.RootConfig return nil } -// ensureClusterAdminSecret applies the cluster admin email configured via -// codesphere.clusterAdminEmail to the cluster-admin-email secret before the -// platform is installed, so the auth-service finds it on first start. -// It is a no-op when the config does not set an email. -func ensureClusterAdminSecret(ctx context.Context, opts *InstallCodesphereOpts, cfg files.RootConfig) error { - email := cfg.Codesphere.ClusterAdminEmail - if email == "" { - return nil - } - - vaultPath, err := resolveVaultPath(opts.Vault, cfg) - if err != nil { - return err - } - vault, err := installer.LoadVaultData(vaultPath, opts.PrivKey) - if err != nil { - return fmt.Errorf("failed to load vault %s: %w", vaultPath, err) - } - kubeConfigContent, err := kubeConfigContentFromVault(vault) - if err != nil { - return err - } - restConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(kubeConfigContent)) - if err != nil { - return fmt.Errorf("failed to load kubernetes config from vault: %w", err) - } - clientset, _, err := util.NewClientsFromRESTConfig(restConfig) - if err != nil { - return fmt.Errorf("failed to create kubernetes client: %w", err) - } - - return clusteradmin.AddClusterAdmin(ctx, clientset, clusteradmin.Opts{ - Email: email, - Namespace: clusteradmin.DefaultNamespace, - SecretName: clusteradmin.DefaultSecretName, - CreateNamespace: true, - }) -} - func AddInstallCodespherePlatformCmd(codesphere *cobra.Command, opts *InstallCodesphereOpts) { platform := InstallCodespherePlatformCmd{ cmd: &cobra.Command{ diff --git a/cli/cmd/install_codesphere_test.go b/cli/cmd/install_codesphere_test.go index 332b20b6..4a5cd14d 100644 --- a/cli/cmd/install_codesphere_test.go +++ b/cli/cmd/install_codesphere_test.go @@ -4,6 +4,7 @@ package cmd_test import ( + "context" "os" "runtime" @@ -56,7 +57,9 @@ var _ = Describe("InstallCodesphereCmd", func() { c.Opts.Configs = []string{tempConfigFile.Name()} mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") - err = c.RunE(nil, []string{}) + runCmd := &cobra.Command{} + runCmd.SetContext(context.Background()) + err = c.RunE(runCmd, []string{}) Expect(err).To(HaveOccurred()) if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { diff --git a/internal/installer/cluster_admin.go b/internal/installer/cluster_admin.go new file mode 100644 index 00000000..6e85eb7f --- /dev/null +++ b/internal/installer/cluster_admin.go @@ -0,0 +1,90 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/internal/clusteradmin" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// ResolveVaultPath returns the explicit vault path or falls back to +// prod.vault.yaml in the config's secrets baseDir. +func ResolveVaultPath(vaultPath string, config files.RootConfig) (string, error) { + if strings.TrimSpace(vaultPath) != "" { + return vaultPath, nil + } + if strings.TrimSpace(config.Secrets.BaseDir) == "" { + return "", fmt.Errorf("vault path is not set and config.yaml secrets.baseDir is empty") + } + return filepath.Join(config.Secrets.BaseDir, "prod.vault.yaml"), nil +} + +// VaultAndRESTConfig loads the vault at vaultPath (or the config's secrets +// baseDir fallback) and builds a Kubernetes REST config from the kubeconfig +// stored in it. +func VaultAndRESTConfig(vaultPath, privKey string, cfg files.RootConfig) (*files.InstallVault, *rest.Config, error) { + resolvedPath, err := ResolveVaultPath(vaultPath, cfg) + if err != nil { + return nil, nil, err + } + vault, err := LoadVaultData(resolvedPath, privKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to load vault %s: %w", resolvedPath, err) + } + kubeConfigContent, err := kubeConfigContentFromVault(vault) + if err != nil { + return nil, nil, err + } + restConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(kubeConfigContent)) + if err != nil { + return nil, nil, fmt.Errorf("failed to load kubernetes config from vault: %w", err) + } + return vault, restConfig, nil +} + +func kubeConfigContentFromVault(vault *files.InstallVault) (string, error) { + if vault == nil { + return "", fmt.Errorf("vault is not loaded") + } + kubeConfig := vault.GetSecret(files.SecretKubeConfig) + if kubeConfig == nil || kubeConfig.File == nil || strings.TrimSpace(kubeConfig.File.Content) == "" { + return "", fmt.Errorf("kubeconfig not found in vault (secret %q)", files.SecretKubeConfig) + } + return kubeConfig.File.Content, nil +} + +// EnsureClusterAdminSecret applies the cluster admin email configured via +// codesphere.clusterAdminEmail to the cluster-admin-email secret before the +// platform is installed, so the auth-service finds it on first start. +// It is a no-op when the config does not set an email. +func EnsureClusterAdminSecret(ctx context.Context, vaultPath, privKey string, cfg files.RootConfig) error { + email := cfg.Codesphere.ClusterAdminEmail + if email == "" { + return nil + } + + _, restConfig, err := VaultAndRESTConfig(vaultPath, privKey, cfg) + if err != nil { + return err + } + clientset, _, err := util.NewClientsFromRESTConfig(restConfig) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + return clusteradmin.AddClusterAdmin(ctx, clientset, clusteradmin.Opts{ + Email: email, + Namespace: clusteradmin.DefaultNamespace, + SecretName: clusteradmin.DefaultSecretName, + CreateNamespace: true, + }) +} From 720b54137e836ef5dbb2f084a46934b8f82a14ca Mon Sep 17 00:00:00 2001 From: Niklas Brodnicke Date: Mon, 6 Jul 2026 15:20:56 +0200 Subject: [PATCH 3/3] adding tests Signed-off-by: Niklas Brodnicke --- internal/installer/cluster_admin_test.go | 196 +++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 internal/installer/cluster_admin_test.go diff --git a/internal/installer/cluster_admin_test.go b/internal/installer/cluster_admin_test.go new file mode 100644 index 00000000..e1f84161 --- /dev/null +++ b/internal/installer/cluster_admin_test.go @@ -0,0 +1,196 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "context" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +const testKubeConfig = `apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: https://127.0.0.1:6443 +contexts: +- name: test + context: + cluster: test + user: test +current-context: test +users: +- name: test + user: + token: test-token +` + +// writeVaultFile marshals the vault to dir/prod.vault.yaml and returns the path. +func writeVaultFile(dir string, vault *files.InstallVault) string { + vaultYAML, err := vault.Marshal() + Expect(err).ToNot(HaveOccurred()) + vaultPath := filepath.Join(dir, "prod.vault.yaml") + Expect(os.WriteFile(vaultPath, vaultYAML, 0600)).To(Succeed()) + return vaultPath +} + +func vaultWithKubeConfig() *files.InstallVault { + return &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: files.SecretKubeConfig, + File: &files.SecretFile{ + Name: "kubeconfig", + Content: testKubeConfig, + }, + }, + }, + } +} + +var _ = Describe("ResolveVaultPath", func() { + It("returns the explicit vault path when set", func() { + path, err := installer.ResolveVaultPath("/some/vault.yaml", files.RootConfig{ + Secrets: files.SecretsConfig{BaseDir: "/ignored"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("/some/vault.yaml")) + }) + + It("falls back to prod.vault.yaml in the config secrets baseDir", func() { + path, err := installer.ResolveVaultPath("", files.RootConfig{ + Secrets: files.SecretsConfig{BaseDir: "/base"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(filepath.Join("/base", "prod.vault.yaml"))) + }) + + It("treats a whitespace-only vault path as unset", func() { + path, err := installer.ResolveVaultPath(" ", files.RootConfig{ + Secrets: files.SecretsConfig{BaseDir: "/base"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(filepath.Join("/base", "prod.vault.yaml"))) + }) + + It("fails when neither vault path nor secrets baseDir is set", func() { + _, err := installer.ResolveVaultPath("", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("vault path is not set"))) + }) +}) + +var _ = Describe("VaultAndRESTConfig", func() { + It("loads the vault and builds a REST config from the kubeconfig secret", func() { + vaultPath := writeVaultFile(GinkgoT().TempDir(), vaultWithKubeConfig()) + + vault, restConfig, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + Expect(err).ToNot(HaveOccurred()) + Expect(vault).ToNot(BeNil()) + Expect(vault.GetSecret(files.SecretKubeConfig)).ToNot(BeNil()) + Expect(restConfig).ToNot(BeNil()) + Expect(restConfig.Host).To(Equal("https://127.0.0.1:6443")) + Expect(restConfig.BearerToken).To(Equal("test-token")) + }) + + It("resolves the vault path via the config secrets baseDir", func() { + secretsDir := GinkgoT().TempDir() + writeVaultFile(secretsDir, vaultWithKubeConfig()) + + vault, restConfig, err := installer.VaultAndRESTConfig("", "", files.RootConfig{ + Secrets: files.SecretsConfig{BaseDir: secretsDir}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(vault).ToNot(BeNil()) + Expect(restConfig).ToNot(BeNil()) + }) + + It("fails when the vault path cannot be resolved", func() { + _, _, err := installer.VaultAndRESTConfig("", "", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("vault path is not set"))) + }) + + It("fails when the vault file does not exist", func() { + vaultPath := filepath.Join(GinkgoT().TempDir(), "missing.vault.yaml") + + _, _, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("failed to load vault"))) + }) + + It("fails when the vault does not contain a kubeconfig secret", func() { + vaultPath := writeVaultFile(GinkgoT().TempDir(), &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: files.SecretRegistryPassword, + Fields: &files.SecretFields{Password: "registry-password"}, + }, + }, + }) + + _, _, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("kubeconfig not found in vault"))) + }) + + It("fails when the kubeconfig secret content is empty", func() { + vaultPath := writeVaultFile(GinkgoT().TempDir(), &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: files.SecretKubeConfig, + File: &files.SecretFile{Name: "kubeconfig", Content: " "}, + }, + }, + }) + + _, _, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("kubeconfig not found in vault"))) + }) + + It("fails when the kubeconfig content is not a valid kubeconfig", func() { + vaultPath := writeVaultFile(GinkgoT().TempDir(), &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: files.SecretKubeConfig, + File: &files.SecretFile{Name: "kubeconfig", Content: "not: a kubeconfig"}, + }, + }, + }) + + _, _, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + Expect(err).To(MatchError(ContainSubstring("failed to load kubernetes config from vault"))) + }) +}) + +var _ = Describe("EnsureClusterAdminSecret", func() { + It("is a no-op when the config does not set a cluster admin email", func() { + // No vault path or secrets baseDir: reaching the vault loading would fail, + // so a nil result proves the email check short-circuits first. + err := installer.EnsureClusterAdminSecret(context.Background(), "", "", files.RootConfig{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("propagates vault loading errors when an email is set", func() { + cfg := files.RootConfig{ + Codesphere: files.CodesphereConfig{ClusterAdminEmail: "admin@codesphere.com"}, + } + + err := installer.EnsureClusterAdminSecret(context.Background(), "", "", cfg) + Expect(err).To(MatchError(ContainSubstring("vault path is not set"))) + }) + + It("fails when the vault file does not exist", func() { + vaultPath := filepath.Join(GinkgoT().TempDir(), "missing.vault.yaml") + cfg := files.RootConfig{ + Codesphere: files.CodesphereConfig{ClusterAdminEmail: "admin@codesphere.com"}, + } + + err := installer.EnsureClusterAdminSecret(context.Background(), vaultPath, "", cfg) + Expect(err).To(MatchError(ContainSubstring("failed to load vault"))) + }) +})