Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/cmd/bootstrap_gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
4 changes: 2 additions & 2 deletions cli/cmd/install_codesphere.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
14 changes: 10 additions & 4 deletions cli/cmd/install_codesphere_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 50 additions & 3 deletions cli/cmd/install_codesphere_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
}
Comment on lines +40 to +42

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we thread a context.Context through installCodespherePlatform instead of using a fixed context.Background()?


workdir := env.GetOmsWorkdir()
pm := installer.NewPackage(workdir, opts.Package)
cm := installer.NewConfig()
Expand All @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it duplicates the vault-loading and kubeconfig-extraction pattern that already exists in loadVaultData in install_codesphere_dependencies.go

extract a shared helper maybe?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this function does a lot of infrastructure stuff. So maybe this function would make more sense in the installer or clusteradmin package and not the cli command layer

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{
Expand Down
1 change: 1 addition & 0 deletions docs/oms_beta_bootstrap-gcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions internal/bootstrap/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Comment on lines +451 to +469

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we allow the flag without --write-config when --recover-config is also set?


// validateInstallVersion checks if the specified install version exists and contains the required installer artifact
func (b *GCPBootstrapper) validateInstallVersion() error {
if b.Env.InstallLocal != "" {
Expand Down
31 changes: 31 additions & 0 deletions internal/bootstrap/gcp/gcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions internal/bootstrap/gcp/install_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
34 changes: 34 additions & 0 deletions internal/bootstrap/gcp/install_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
27 changes: 24 additions & 3 deletions internal/clusteradmin/clusteradmin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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{})
Expand Down Expand Up @@ -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
}
Comment on lines +106 to +114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't wait for the namespace, so the namespace might not be visible yet when secrets.Create is called. Is that a problem?


// 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")
Expand Down
28 changes: 28 additions & 0 deletions internal/clusteradmin/clusteradmin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand Down
1 change: 1 addition & 0 deletions internal/installer/files/config_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading