diff --git a/NOTICE b/NOTICE index 5d7a920a..2a1a9aee 100644 --- a/NOTICE +++ b/NOTICE @@ -1181,15 +1181,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.286.0 +Version: v0.287.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.286.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.287.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.286.0 +Version: v0.287.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.286.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.287.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1211,9 +1211,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/b703f567277d/googlea ---------- Module: google.golang.org/grpc -Version: v1.81.1 +Version: v1.82.0 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.81.1/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.82.0/LICENSE ---------- Module: google.golang.org/protobuf diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index de02ab75..02368144 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -28,7 +28,9 @@ type BootstrapGcpCmd struct { CodesphereEnv *gcp.CodesphereEnvironment InputRegistryType string SSHQuiet bool - FeatureFlagList []string + // experiments backs the deprecated --experiments flag; its values + // are folded into the internal bucket for backwards compatibility. + experiments []string } func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { @@ -53,7 +55,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { }, Opts: opts, Env: env.NewEnv(), - CodesphereEnv: &gcp.CodesphereEnvironment{FeatureFlags: map[string]bool{}}, + CodesphereEnv: &gcp.CodesphereEnvironment{}, } bootstrapGcpCmd.cmd.RunE = bootstrapGcpCmd.RunE @@ -97,8 +99,11 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.StringArrayVarP(&bootstrapGcpCmd.CodesphereEnv.InstallSkipSteps, "install-skip-steps", "s", []string{}, "Installation steps to skip during Codesphere installation (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username (only for GitHub registry type) (optional)") 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.Experiments, "experiments", gcp.DefaultExperiments, "Experiments to enable in Codesphere installation (optional)") - flags.StringArrayVar(&bootstrapGcpCmd.FeatureFlagList, "feature-flags", []string{}, "Feature flags to enable in Codesphere installation (optional)") + 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.") + _ = flags.MarkDeprecated("experiments", "use --internal-flags instead") + flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.PreviewFlags, "preview-flags", gcp.DefaultPreviewFlags, "Preview flags to enable in Codesphere installation (optional)") + flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.FeatureFlags, "feature-flags", gcp.DefaultFeatureFlags, "Feature flags to enable in Codesphere installation (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ExternalLokiEndpoint, "external-loki-endpoint", "", "External Loki endpoint for Grafana Alloy log forwarding (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ExternalLokiSecret, "external-loki-secret", "", "External Loki password stored in the generated vault (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ExternalLokiUser, "external-loki-user", "", "External Loki username for Grafana Alloy log forwarding (optional)") @@ -172,8 +177,12 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { } } - for _, flag := range c.FeatureFlagList { - c.CodesphereEnv.FeatureFlags[flag] = true + if c.cmd.Flags().Changed("experiments") { + if c.cmd.Flags().Changed("internal-flags") { + log.Printf("Warning: both --experiments and --internal-flags were set; ignoring deprecated --experiments values %v", c.experiments) + } else { + c.CodesphereEnv.InternalFlags = c.experiments + } } err = bs.Bootstrap() diff --git a/cli/cmd/bootstrap_local.go b/cli/cmd/bootstrap_local.go index 06171bd1..d3c2add1 100644 --- a/cli/cmd/bootstrap_local.go +++ b/cli/cmd/bootstrap_local.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" stdio "io" + "log" "os" "os/exec" "path/filepath" @@ -35,10 +36,12 @@ import ( ) type BootstrapLocalCmd struct { - cmd *cobra.Command - CodesphereEnv *local.CodesphereEnvironment - Yes bool - FeatureFlagList []string + cmd *cobra.Command + CodesphereEnv *local.CodesphereEnvironment + Yes bool + // Experiments backs the deprecated --experiments flag; its values + // are folded into the internal bucket for backwards compatibility. + experiments []string } func (c *BootstrapLocalCmd) RunE(_ *cobra.Command, args []string) error { @@ -75,8 +78,11 @@ func AddBootstrapLocalCmd(parent *cobra.Command) { // Codesphere Environment flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.BaseDomain, "base-domain", "cs.local", "Base domain for Codesphere") - flags.StringArrayVar(&bootstrapLocalCmd.CodesphereEnv.Experiments, "experiments", gcp.DefaultExperiments, "Experiments to enable in Codesphere installation (optional)") - flags.StringArrayVar(&bootstrapLocalCmd.FeatureFlagList, "feature-flags", []string{}, "Feature flags to enable in Codesphere installation (optional)") + flags.StringArrayVar(&bootstrapLocalCmd.CodesphereEnv.InternalFlags, "internal-flags", gcp.DefaultInternalFlags, "Internal flags to enable in Codesphere installation (optional)") + flags.StringArrayVar(&bootstrapLocalCmd.experiments, "experiments", []string{}, "Deprecated: use --internal-flags instead. Values are added to the internal flags.") + _ = flags.MarkDeprecated("experiments", "use --internal-flags instead") + flags.StringArrayVar(&bootstrapLocalCmd.CodesphereEnv.PreviewFlags, "preview-flags", gcp.DefaultPreviewFlags, "Preview flags to enable in Codesphere installation (optional)") + flags.StringArrayVar(&bootstrapLocalCmd.CodesphereEnv.FeatureFlags, "feature-flags", gcp.DefaultFeatureFlags, "Feature flags to enable in Codesphere installation (optional)") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.Profile, "profile", installer.PROFILE_DEV, "Profile to apply to the install config like resources (supported: dev, minimal, prod)") flags.BoolVar(&bootstrapLocalCmd.CodesphereEnv.K0s, "k0s", false, "Use k0s-specific configuration (required to deploy to k0s clusters)") @@ -124,8 +130,12 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { return err } - for _, flag := range c.FeatureFlagList { - c.CodesphereEnv.FeatureFlags[flag] = true + if c.cmd.Flags().Changed("experiments") { + if c.cmd.Flags().Changed("internal-flags") { + log.Printf("Warning: both --experiments and --internal-flags were set; ignoring deprecated --experiments values %v", c.experiments) + } else { + c.CodesphereEnv.InternalFlags = c.experiments + } } stlog := bootstrap.NewStepLogger(false) diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index 4d0ebf38..38c33695 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -87,7 +87,7 @@ func AddDownloadPackageCmd(download *cobra.Command, opts *GlobalOptions) { pkg.cmd.Flags().StringVarP(&pkg.Opts.Version, "version", "V", "", "Codesphere version to download") pkg.cmd.Flags().StringVarP(&pkg.Opts.Hash, "hash", "H", "", "Hash of the version to download if multiple builds exist for the same version") - pkg.cmd.Flags().StringVarP(&pkg.Opts.Filename, "file", "f", "installer.tar.gz", "Specify artifact to download") + pkg.cmd.Flags().StringVarP(&pkg.Opts.Filename, "file", "f", "installer-lite.tar.gz", "Specify artifact to download") pkg.cmd.Flags().BoolVarP(&pkg.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") AddCmd(download, pkg.cmd) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index 6a834fc2..a073f498 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -110,7 +110,9 @@ codesphere: cNameBaseDomain: custom.example.com dnsServers: - 8.8.8.8 - experiments: [] + internal: [] + preview: {} + features: {} deployConfig: images: ubuntu-24.04: diff --git a/cli/cmd/install.go b/cli/cmd/install.go index 7b5d946c..53cdc67a 100644 --- a/cli/cmd/install.go +++ b/cli/cmd/install.go @@ -26,4 +26,5 @@ func AddInstallCmd(rootCmd *cobra.Command, opts *GlobalOptions) { AddInstallCodesphereCmd(install.cmd, opts) AddInstallK0sCmd(install.cmd, opts) AddInstallOpenBaoCmd(install.cmd, opts) + AddInstallDockerCmd(install.cmd, opts) } diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 14bcea07..0263586e 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -116,7 +116,7 @@ func AddInstallCodesphereCmd(install *cobra.Command, opts *GlobalOptions) { Opts: &InstallCodesphereOpts{GlobalOptions: opts}, Env: env.NewEnv(), } - codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load binaries, installer etc. from") + codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load binaries, installer etc. from") codesphere.cmd.PersistentFlags().BoolVarP(&codesphere.Opts.Force, "force", "f", false, "Enforce package extraction") codesphere.cmd.PersistentFlags().StringArrayVarP(&codesphere.Opts.Configs, "config", "c", nil, "Path to a Codesphere Private Cloud configuration file (yaml). Can be specified multiple times and merged in order") codesphere.cmd.PersistentFlags().StringVar(&codesphere.Opts.Vault, "vault", "", "Path to the SOPS-encrypted prod.vault.yaml file used for config templating") diff --git a/cli/cmd/install_codesphere_dependencies.go b/cli/cmd/install_codesphere_dependencies.go index 3ac87d52..bed99794 100644 --- a/cli/cmd/install_codesphere_dependencies.go +++ b/cli/cmd/install_codesphere_dependencies.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "path/filepath" "runtime" "strings" @@ -118,10 +119,14 @@ type argoCDAndAppsInstall struct { } func (i *argoCDAndAppsInstall) loadVaultData() error { - var err error - i.vault, err = installer.LoadVaultData(i.opts.Vault, i.opts.PrivKey) + vaultPath, err := i.resolveVaultPath() + if err != nil { + return err + } + + i.vault, err = installer.LoadVaultData(vaultPath, i.opts.PrivKey) if err != nil { - return fmt.Errorf("failed to load vault: %w", err) + 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 @@ -145,6 +150,16 @@ func (i *argoCDAndAppsInstall) loadVaultData() error { return nil } +func (i *argoCDAndAppsInstall) resolveVaultPath() (string, error) { + if strings.TrimSpace(i.opts.Vault) != "" { + return i.opts.Vault, nil + } + if strings.TrimSpace(i.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 +} + func (i *argoCDAndAppsInstall) installArgoCD() error { i.ociRegistryURL = i.opts.ArgoCDRegistryURL if i.ociRegistryURL == "" && i.config.Registry != nil { @@ -224,15 +239,15 @@ func AddInstallCodesphereDepenciesCmd(codesphere *cobra.Command, opts *InstallCo Pass --skip-steps argocd or add argocd to operations.skip to skip the ArgoCD pre-step.`), Example: formatExamples("install codesphere dependencies", []io.Example{ { - Cmd: "-p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml", + Cmd: "-p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml", Desc: "Install cluster dependencies (including ArgoCD)", }, { - Cmd: "-p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml -s argocd", + Cmd: "-p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml -s argocd", Desc: "Install cluster dependencies without the ArgoCD pre-step", }, { - Cmd: "-p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml --pc-apps-values base.yaml --pc-apps-values dc-overlay.yaml", + Cmd: "-p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml --pc-apps-values base.yaml --pc-apps-values dc-overlay.yaml", Desc: "Install cluster dependencies with custom pc-apps values files", }, }), diff --git a/cli/cmd/install_codesphere_dependencies_test.go b/cli/cmd/install_codesphere_dependencies_test.go new file mode 100644 index 00000000..b0bf8e4d --- /dev/null +++ b/cli/cmd/install_codesphere_dependencies_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/installer/files" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("argoCDAndAppsInstall.loadVaultData", func() { + It("falls back to config secrets.baseDir when --vault is not set", func() { + tmpDir := GinkgoT().TempDir() + secretsDir := filepath.Join(tmpDir, "secrets") + Expect(os.MkdirAll(secretsDir, 0700)).To(Succeed()) + + vault := &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: files.SecretRegistryPassword, + Fields: &files.SecretFields{ + Password: "registry-password", + }, + }, + { + Name: files.SecretKubeConfig, + File: &files.SecretFile{ + Name: "kubeconfig", + Content: `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 +`, + }, + }, + }, + } + vaultYAML, err := vault.Marshal() + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(filepath.Join(secretsDir, "prod.vault.yaml"), vaultYAML, 0600)).To(Succeed()) + + install := &argoCDAndAppsInstall{ + opts: &InstallCodesphereOpts{}, + config: files.RootConfig{ + Secrets: files.SecretsConfig{ + BaseDir: secretsDir, + }, + }, + } + + err = install.loadVaultData() + Expect(err).ToNot(HaveOccurred()) + Expect(install.vault).ToNot(BeNil()) + Expect(install.ociPassword).To(Equal("registry-password")) + Expect(install.kubeConfig).ToNot(BeNil()) + Expect(install.kubeClient).ToNot(BeNil()) + }) +}) diff --git a/cli/cmd/install_codesphere_infra.go b/cli/cmd/install_codesphere_infra.go index 4b8c7f78..2fd6c83a 100644 --- a/cli/cmd/install_codesphere_infra.go +++ b/cli/cmd/install_codesphere_infra.go @@ -63,7 +63,7 @@ func AddInstallCodesphereInfraCmd(codesphere *cobra.Command, opts *InstallCodesp Runs steps: copy-dependencies, extract-dependencies, load-container-images, sops, docker, postgres, ceph, kubernetes.`), Example: formatExamples("install codesphere infra", []io.Example{ { - Cmd: "-p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml", + Cmd: "-p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml", Desc: "Install infrastructure components only", }, { diff --git a/cli/cmd/install_codesphere_platform.go b/cli/cmd/install_codesphere_platform.go index 578bd805..7a259c71 100644 --- a/cli/cmd/install_codesphere_platform.go +++ b/cli/cmd/install_codesphere_platform.go @@ -65,7 +65,7 @@ func AddInstallCodespherePlatformCmd(codesphere *cobra.Command, opts *InstallCod Requires the infrastructure and dependencies phases to have completed successfully.`), Example: formatExamples("install codesphere platform", []io.Example{ { - Cmd: "-p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml", + Cmd: "-p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml", Desc: "Install Codesphere platform only", }, }), diff --git a/cli/cmd/install_codesphere_test.go b/cli/cmd/install_codesphere_test.go index c3df5253..332b20b6 100644 --- a/cli/cmd/install_codesphere_test.go +++ b/cli/cmd/install_codesphere_test.go @@ -28,7 +28,7 @@ var _ = Describe("InstallCodesphereCmd", func() { globalOpts = &cmd.GlobalOptions{} opts = &cmd.InstallCodesphereOpts{ GlobalOptions: globalOpts, - Package: "codesphere-v1.66.0-installer.tar.gz", + Package: "codesphere-v1.66.0-installer-lite.tar.gz", Force: false, } c = cmd.InstallCodesphereCmd{ diff --git a/cli/cmd/install_docker.go b/cli/cmd/install_docker.go new file mode 100644 index 00000000..5b2982b2 --- /dev/null +++ b/cli/cmd/install_docker.go @@ -0,0 +1,87 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + packageio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer/docker" + "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +// InstallDockerCmd represents the docker install command. +type InstallDockerCmd struct { + cmd *cobra.Command + Opts InstallDockerOpts + Env env.Env + FileWriter util.FileIO +} + +// InstallDockerOpts holds all CLI flags for the docker sub-command. +type InstallDockerOpts struct { + *GlobalOptions + + // SSH / remote host + // SSHKeyPath string + SSHUser string + SSHHost string + SSHPort int +} + +func (c *InstallDockerCmd) RunE(_ *cobra.Command, args []string) error { + return c.InstallDocker() +} + +// AddInstallDockerCmd registers the "install docker" sub-command under +// the provided parent install command, following the same pattern as +// AddInstallK0sCmd. +func AddInstallDockerCmd(install *cobra.Command, opts *GlobalOptions) { + pg := InstallDockerCmd{ + cmd: &cobra.Command{ + Use: "docker", + Short: "Install Docker on a remote host", + Long: packageio.Long(`Install Docker (if not already present) on a remote host accessed via SSH.`), + Example: formatExamples("install docker", []packageio.Example{}), + }, + Opts: InstallDockerOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + FileWriter: util.NewFilesystemWriter(), + } + + f := pg.cmd.Flags() + + // SSH flags + f.StringVar(&pg.Opts.SSHHost, "ssh-host", "", "Remote host IP or hostname (required)") + f.IntVar(&pg.Opts.SSHPort, "ssh-port", 22, "SSH port on the remote host") + f.StringVar(&pg.Opts.SSHUser, "ssh-user", "root", "SSH username") + // f.StringVar(&pg.Opts.SSHKeyPath, "ssh-key-path", "", "Path to SSH private key") + + _ = pg.cmd.MarkFlagRequired("ssh-host") + + AddCmd(install, pg.cmd) + pg.cmd.RunE = pg.RunE +} + +func (c *InstallDockerCmd) InstallDocker() error { + node := &node.Node{ + Name: "node", + ExternalIP: c.Opts.SSHHost, + + NodeClient: node.NewSSHNodeClient(true), + } + + dockerClient := docker.New("root", node) + if !dockerClient.IsInstalled() { + err := dockerClient.InstallWithApt() + if err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + } + + return nil +} diff --git a/cli/cmd/install_k0s.go b/cli/cmd/install_k0s.go index c5f3f040..52a8baaa 100644 --- a/cli/cmd/install_k0s.go +++ b/cli/cmd/install_k0s.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "path/filepath" + "strings" packageio "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/spf13/cobra" @@ -65,7 +66,7 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { {Cmd: "--install-config ", Desc: "Path to Codesphere install-config file to generate k0s config from"}, {Cmd: "--version ", Desc: "Version of k0s to install (e.g., v1.30.0+k0s.0)"}, {Cmd: "--k0sctl-version ", Desc: "Version of k0sctl to use (e.g., v0.17.4)"}, - {Cmd: "--package ", Desc: "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from"}, + {Cmd: "--package ", Desc: "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from"}, {Cmd: "--ssh-key-path ", Desc: "SSH private key path for remote installation"}, {Cmd: "--force", Desc: "Force new download and installation"}, {Cmd: "--no-download", Desc: "Skip downloading k0s binary (expects it to be on remote nodes)"}, @@ -77,7 +78,7 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { } k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", "", "Version of k0s to install") k0s.cmd.Flags().StringVar(&k0s.Opts.K0sctlVersion, "k0sctl-version", "", "Version of k0sctl to use") - k0s.cmd.Flags().StringVarP(&k0s.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from") + k0s.cmd.Flags().StringVarP(&k0s.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from") k0s.cmd.Flags().StringVar(&k0s.Opts.InstallConfig, "install-config", "", "Path to Codesphere install-config file (required)") k0s.cmd.Flags().StringVar(&k0s.Opts.SSHKeyPath, "ssh-key-path", "", "SSH private key path for remote installation") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") @@ -236,6 +237,7 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 if err != nil { return fmt.Errorf("failed to retrieve kubeconfig from k0sctl: %w", err) } + kubeconfigContent = strings.TrimRight(kubeconfigContent, "\n\r") vault, wasEncrypted, err := c.loadOrCreateVault() if err != nil { diff --git a/cli/cmd/install_k0s_test.go b/cli/cmd/install_k0s_test.go index aa21eec0..3c65f937 100644 --- a/cli/cmd/install_k0s_test.go +++ b/cli/cmd/install_k0s_test.go @@ -258,7 +258,9 @@ var _ = Describe("InstallK0sCmd", func() { setupCommonMocks() mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\n", nil) mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(false) - mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil) + mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool { + return !strings.Contains(string(data), "content: |+") + }), os.FileMode(0600)).Return(nil) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) @@ -290,7 +292,9 @@ var _ = Describe("InstallK0sCmd", func() { setupCommonMocks() mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\n", nil) mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(true) - mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil) + mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool { + return !strings.Contains(string(data), "content: |+") + }), os.FileMode(0600)).Return(nil) err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) @@ -322,12 +326,47 @@ var _ = Describe("InstallK0sCmd", func() { setupCommonMocks() mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\nnew: true\n", nil) mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(true) - mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.Anything, os.FileMode(0600)).Return(nil) + mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool { + return !strings.Contains(string(data), "content: |+") + }), os.FileMode(0600)).Return(nil) err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) }) + It("trims trailing newlines from kubeconfig before storing in vault", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Package = "test-package.tar.gz" + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.Vault = filepath.Join(tempDir, "prod.vault.yaml") + + setupCommonMocks() + // kubeconfig with multiple trailing newlines — should be stripped + mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl"). + Return("apiVersion: v1\nkind: Config\n\n\n", nil) + mockFileWriter.EXPECT().Exists(c.Opts.Vault).Return(false) + mockFileWriter.EXPECT().WriteFile(c.Opts.Vault, mock.MatchedBy(func(data []byte) bool { + // Must not contain |+ chomping — trailing newlines should be stripped + if strings.Contains(string(data), "content: |+") { + return false + } + // Verify the stored kubeconfig has no trailing newlines + var vault files.InstallVault + if err := yaml.Unmarshal(data, &vault); err != nil { + return false + } + for _, s := range vault.Secrets { + if s.Name == "kubeConfig" && s.File != nil { + return s.File.Content == "apiVersion: v1\nkind: Config" + } + } + return false + }), os.FileMode(0600)).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + It("fails when GetKubeconfig fails", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "test-package.tar.gz" diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 41cf3222..3ead587d 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -126,7 +126,9 @@ codesphere: dnsServers: - 8.8.8.8 - 8.8.4.4 - experiments: [] + internal: [] + preview: {} + features: {} deployConfig: images: {} plans: diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index bfeac3a5..ce081e6c 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -33,7 +33,6 @@ oms beta bootstrap-gcp [flags] --datacenter-name string Datacenter name (default: dev) (default "dev") --dns-project-id string GCP Project ID for Cloud DNS (optional) --dns-zone-name string Cloud DNS Zone Name (optional) (default "oms-testing") - --experiments stringArray Experiments to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls,secret-management,sub-path-mount]) --external-loki-endpoint string External Loki endpoint for Grafana Alloy log forwarding (optional) --external-loki-secret string External Loki password stored in the generated vault (optional) --external-loki-user string External Loki username for Grafana Alloy log forwarding (optional) @@ -54,6 +53,7 @@ oms beta bootstrap-gcp [flags] --install-local string Install Codesphere from local package (default: none) -s, --install-skip-steps stringArray Installation steps to skip during Codesphere installation (optional) --install-version string Codesphere version to install (default: none) + --internal-flags stringArray Internal flags to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls]) --local-trace-endpoint string Endpoint for exporting traces to an in-cluster storage (optional) --oidc-client-id string OIDC OAuth provider Client ID (optional) --oidc-client-secret string OIDC OAuth provider Client Secret (optional) @@ -64,6 +64,7 @@ oms beta bootstrap-gcp [flags] --openbao-uri string URI for OpenBao (optional) --openbao-user string OpenBao username (optional) (default "admin") --preemptible Use preemptible VMs for Codesphere infrastructure. Mutually exclusive with --spot-vms (default: false) + --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [secret-management,sub-path-mount,workspace-ssh]) --project-name string Unique GCP Project Name (required) --project-ttl string Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours) (default "2h") --prometheus-remote-write-password string Prometheus remote write password stored in the generated vault (optional) diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index 896e8697..ee43c3b2 100644 --- a/docs/oms_beta_bootstrap-local.md +++ b/docs/oms_beta_bootstrap-local.md @@ -16,24 +16,25 @@ oms beta bootstrap-local [flags] ### Options ``` - --argocd After infra setup: install ArgoCD, update the OCI pull secret, and install pc-apps from the BOM version - --base-domain string Base domain for Codesphere (default "cs.local") - --experiments stringArray Experiments to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls,secret-management,sub-path-mount]) - --feature-flags stringArray Feature flags to enable in Codesphere installation (optional) - -h, --help help for bootstrap-local - --install-config string Path to install config file (default: /config.yaml) - --install-dir string Directory for config, secrets, and bundle files (default ".installer") - --install-hash string Codesphere package hash (required when install-version is set) - --install-local string Path to a local installer package (tar.gz or unpacked directory) - --install-version string Codesphere version to install (downloaded from the OMS portal) - --k0s Use k0s-specific configuration (required to deploy to k0s clusters) - --pod-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. - --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 (only relevant with --argocd) (default "oci://ghcr.io/codesphere-cloud/charts") - --registry-user string Custom Registry username (optional) - --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. - -y, --yes Auto-approve the local bootstrapping warning prompt + --argocd After infra setup: install ArgoCD, update the OCI pull secret, and install pc-apps from the BOM version + --base-domain string Base domain for Codesphere (default "cs.local") + --feature-flags stringArray Feature flags to enable in Codesphere installation (optional) + -h, --help help for bootstrap-local + --install-config string Path to install config file (default: /config.yaml) + --install-dir string Directory for config, secrets, and bundle files (default ".installer") + --install-hash string Codesphere package hash (required when install-version is set) + --install-local string Path to a local installer package (tar.gz or unpacked directory) + --install-version string Codesphere version to install (downloaded from the OMS portal) + --internal-flags stringArray Internal flags to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls]) + --k0s Use k0s-specific configuration (required to deploy to k0s clusters) + --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 [secret-management,sub-path-mount,workspace-ssh]) + --profile string Profile to apply to the install config like resources (supported: dev, minimal, prod) (default "dev") + --registry-url string OCI registry URL used for the ArgoCD helm pull secret (only relevant with --argocd) (default "oci://ghcr.io/codesphere-cloud/charts") + --registry-user string Custom Registry username (optional) + --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. + -y, --yes Auto-approve the local bootstrapping warning prompt ``` ### SEE ALSO diff --git a/docs/oms_download_package.md b/docs/oms_download_package.md index 6a831ea4..a9d12498 100644 --- a/docs/oms_download_package.md +++ b/docs/oms_download_package.md @@ -28,7 +28,7 @@ $ oms download package --version codesphere-v1.55.0 --file installer-lite.tar.gz ### Options ``` - -f, --file string Specify artifact to download (default "installer.tar.gz") + -f, --file string Specify artifact to download (default "installer-lite.tar.gz") -H, --hash string Hash of the version to download if multiple builds exist for the same version -h, --help help for package -q, --quiet Suppress progress output during download diff --git a/docs/oms_install.md b/docs/oms_install.md index 21543f1d..c65b1ad0 100644 --- a/docs/oms_install.md +++ b/docs/oms_install.md @@ -16,6 +16,7 @@ Install Codesphere and other components like Ceph and PostgreSQL. * [oms](oms.md) - Codesphere Operations Management System (OMS) * [oms install codesphere](oms_install_codesphere.md) - Install a Codesphere instance +* [oms install docker](oms_install_docker.md) - Install Docker on a remote host * [oms install k0s](oms_install_k0s.md) - Install k0s Kubernetes distribution * [oms install openbao](oms_install_openbao.md) - Bootstrap OpenBao with Bank-Vaults Operator and DR backup diff --git a/docs/oms_install_codesphere.md b/docs/oms_install_codesphere.md index c52728b7..dcf6c214 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -36,7 +36,7 @@ $ oms install codesphere -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml +$ oms install codesphere dependencies -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml # Install cluster dependencies without the ArgoCD pre-step -$ oms install codesphere dependencies -p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml -s argocd +$ oms install codesphere dependencies -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml -s argocd # Install cluster dependencies with custom pc-apps values files -$ oms install codesphere dependencies -p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml --pc-apps-values base.yaml --pc-apps-values dc-overlay.yaml +$ oms install codesphere dependencies -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml --pc-apps-values base.yaml --pc-apps-values dc-overlay.yaml ``` @@ -45,7 +45,7 @@ $ oms install codesphere dependencies -p codesphere-v1.2.3-installer.tar.gz -k < -c, --config stringArray Path to a Codesphere Private Cloud configuration file (yaml). Can be specified multiple times and merged in order --direct-connection Use direct connection for installation, requires having access to the cluster nodes from your machine -f, --force Enforce package extraction - -p, --package string Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load binaries, installer etc. from + -p, --package string Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load binaries, installer etc. from --pc-apps-values stringArray pc-apps values YAML file (can be specified multiple times) -k, --priv-key string Path to the private key to encrypt/decrypt secrets -s, --skip-steps strings Steps to be skipped. E.g. copy-dependencies, extract-dependencies, load-container-images, ceph, postgres, kubernetes, docker, argocd diff --git a/docs/oms_install_codesphere_infra.md b/docs/oms_install_codesphere_infra.md index 7bb69ef3..cd174c53 100644 --- a/docs/oms_install_codesphere_infra.md +++ b/docs/oms_install_codesphere_infra.md @@ -15,7 +15,7 @@ oms install codesphere infra [flags] ``` # Install infrastructure components only -$ oms install codesphere infra -p codesphere-v1.2.3-installer.tar.gz -k -c config.yaml +$ oms install codesphere infra -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml # Skip loading container images when using a lite package $ oms install codesphere infra -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml -s load-container-images @@ -40,7 +40,7 @@ $ oms install codesphere infra -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml +$ oms install codesphere platform -p codesphere-v1.2.3-installer-lite.tar.gz -k -c config.yaml ``` @@ -38,7 +38,7 @@ $ oms install codesphere platform -p codesphere-v1.2.3-installer.tar.gz -k # Version of k0sctl to use (e.g., v0.17.4) $ oms install k0s --k0sctl-version -# Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from +# Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from $ oms install k0s --package # SSH private key path for remote installation @@ -50,7 +50,7 @@ $ oms install k0s --no-download --install-config string Path to Codesphere install-config file (required) --k0sctl-version string Version of k0sctl to use --no-download Skip downloading k0s binary - -p, --package string Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from + -p, --package string Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from --ssh-key-path string SSH private key path for remote installation --vault string Path to prod.vault.yaml to save the kubeconfig into (optional) --vault-priv-key string Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults) diff --git a/go.mod b/go.mod index 89c9e15e..349c684e 100644 --- a/go.mod +++ b/go.mod @@ -36,8 +36,8 @@ require ( golang.org/x/mod v0.37.0 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.44.0 - google.golang.org/api v0.286.0 - google.golang.org/grpc v1.81.1 + google.golang.org/api v0.287.0 + google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v4 v4.2.2 diff --git a/go.sum b/go.sum index f1336cd9..05f24086 100644 --- a/go.sum +++ b/go.sum @@ -6133,8 +6133,8 @@ google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1Gk google.golang.org/api v0.222.0/go.mod h1:efZia3nXpWELrwMlN5vyQrD4GmJN1Vw0x68Et3r+a9c= google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4= -google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= -google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= +google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -6650,8 +6650,8 @@ google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y= google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM= diff --git a/internal/bootstrap/gcp/gce_test.go b/internal/bootstrap/gcp/gce_test.go index 0dc86348..4d6a1f5f 100644 --- a/internal/bootstrap/gcp/gce_test.go +++ b/internal/bootstrap/gcp/gce_test.go @@ -145,15 +145,16 @@ var _ = Describe("GCE", func() { BeforeEach(func() { csEnv = &gcp.CodesphereEnvironment{ - ProjectName: "test", - Region: "us-central1", - Zone: "us-central1-a", - BaseDomain: "example.com", - DNSProjectID: "dns-project", - DNSZoneName: "test-zone", - SecretsDir: "/etc/codesphere/secrets", - DatacenterID: 1, - Experiments: gcp.DefaultExperiments, + ProjectName: "test", + Region: "us-central1", + Zone: "us-central1-a", + BaseDomain: "example.com", + DNSProjectID: "dns-project", + DNSZoneName: "test-zone", + SecretsDir: "/etc/codesphere/secrets", + DatacenterID: 1, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, } gc := gcp.NewMockGCPClientManager(GinkgoT()) bs = newTestBootstrapper(csEnv, gc) @@ -202,7 +203,8 @@ var _ = Describe("GCE", func() { DNSZoneName: "test-zone", SecretsDir: "/etc/codesphere/secrets", DatacenterID: 1, - Experiments: gcp.DefaultExperiments, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, InstallConfigPath: "fake-config", SecretsFilePath: "fake-secrets", GitHubAppName: "fake-app", @@ -248,16 +250,17 @@ var _ = Describe("GCE", func() { BeforeEach(func() { gc = gcp.NewMockGCPClientManager(GinkgoT()) csEnv = &gcp.CodesphereEnvironment{ - ProjectName: "test", - ProjectID: "test-pid", - Region: "us-central1", - Zone: "us-central1-a", - BaseDomain: "example.com", - DNSProjectID: "dns-project", - DNSZoneName: "test-zone", - SecretsDir: "/etc/codesphere/secrets", - DatacenterID: 1, - Experiments: gcp.DefaultExperiments, + ProjectName: "test", + ProjectID: "test-pid", + Region: "us-central1", + Zone: "us-central1-a", + BaseDomain: "example.com", + DNSProjectID: "dns-project", + DNSZoneName: "test-zone", + SecretsDir: "/etc/codesphere/secrets", + DatacenterID: 1, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, } logCh = make(chan string, 10) bs = newTestBootstrapper(csEnv, gc) @@ -487,15 +490,16 @@ var _ = Describe("GCE", func() { gc := gcp.NewMockGCPClientManager(GinkgoT()) fw = util.NewMockFileIO(GinkgoT()) csEnv := &gcp.CodesphereEnvironment{ - ProjectName: "test", - Region: "us-central1", - Zone: "us-central1-a", - BaseDomain: "example.com", - DNSProjectID: "dns-project", - DNSZoneName: "test-zone", - SecretsDir: "/etc/codesphere/secrets", - DatacenterID: 1, - Experiments: gcp.DefaultExperiments, + ProjectName: "test", + Region: "us-central1", + Zone: "us-central1-a", + BaseDomain: "example.com", + DNSProjectID: "dns-project", + DNSZoneName: "test-zone", + SecretsDir: "/etc/codesphere/secrets", + DatacenterID: 1, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, } bs = newTestBootstrapperWithFileIO(csEnv, gc, fw) }) @@ -546,8 +550,9 @@ var _ = Describe("GCE", func() { SecretsDir: "/etc/codesphere/secrets", DatacenterID: 1, SSHPublicKeyPath: "key.pub", - Experiments: gcp.DefaultExperiments, - FeatureFlags: map[string]bool{}, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, + FeatureFlags: gcp.DefaultFeatureFlags, RootDiskSize: 50, } bs = newTestBootstrapperAll(csEnv, gc, fw, mockGitHubClient) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 99f46d43..3a189512 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -62,15 +62,26 @@ func GetDNSRecordNames(baseDomain string) []struct { } } -var DefaultExperiments []string = []string{ +// This should ALWAYS be empty. Internal flags are for internal feature +// development and not intended for customer use. +// Atm. it's not empty as the internal flags below are likely preview or +// feature flags, but are still in the internal bucket for historical +// reasons (before we only had one "experiments" bucket). +var DefaultInternalFlags []string = []string{ "headless-services", "vcluster", "custom-service-image", "ms-in-ls", +} + +var DefaultPreviewFlags []string = []string{ "secret-management", "sub-path-mount", + "workspace-ssh", } +var DefaultFeatureFlags []string = []string{} + type GCPBootstrapper struct { ctx context.Context stlog *bootstrap.StepLogger @@ -87,41 +98,42 @@ type GCPBootstrapper struct { } type CodesphereEnvironment struct { - ProjectID string `json:"project_id"` - ProjectTTL string `json:"project_ttl"` - ProjectName string `json:"project_name"` - DNSProjectID string `json:"dns_project_id"` - Jumpbox *node.Node `json:"jumpbox"` - PostgreSQLNode *node.Node `json:"postgres_node"` - ControlPlaneNodes []*node.Node `json:"control_plane_nodes"` - CephNodes []*node.Node `json:"ceph_nodes"` - ContainerRegistryURL string `json:"-"` - ExistingConfigUsed bool `json:"-"` - InstallVersion string `json:"install_version"` - InstallLocal string `json:"install_local"` - InstallHash string `json:"install_hash"` - InstallSkipSteps []string `json:"install_skip_steps"` - Preemptible bool `json:"preemptible"` - SpotVMs bool `json:"spot_vms"` - WriteConfig bool `json:"-"` - RecoverConfig bool `json:"-"` - GatewayIP string `json:"gateway_ip"` - PublicGatewayIP string `json:"public_gateway_ip"` - SshProxyIP string `json:"ssh_proxy_ip"` - RegistryType RegistryType `json:"registry_type"` - GitHubPAT string `json:"-"` - GitHubAppName string `json:"-"` - GitHubTeamOrg string `json:"github_team_org"` - GitHubTeamSlug string `json:"github_team_slug"` - RegistryUser string `json:"-"` - Experiments []string `json:"experiments"` - FeatureFlags map[string]bool `json:"feature_flags"` - ExternalLokiEndpoint string `json:"external_loki_endpoint,omitempty"` - ExternalLokiSecret string `json:"-"` - ExternalLokiUser string `json:"external_loki_user,omitempty"` - PrometheusRemoteWriteUser string `json:"prometheus_remote_write_user,omitempty"` - PrometheusRemoteWritePassword string `json:"-"` - PrometheusRemoteWriteURL string `json:"prometheus_remote_write_url,omitempty"` + ProjectID string `json:"project_id"` + ProjectTTL string `json:"project_ttl"` + ProjectName string `json:"project_name"` + DNSProjectID string `json:"dns_project_id"` + Jumpbox *node.Node `json:"jumpbox"` + PostgreSQLNode *node.Node `json:"postgres_node"` + ControlPlaneNodes []*node.Node `json:"control_plane_nodes"` + CephNodes []*node.Node `json:"ceph_nodes"` + ContainerRegistryURL string `json:"-"` + ExistingConfigUsed bool `json:"-"` + InstallVersion string `json:"install_version"` + InstallLocal string `json:"install_local"` + InstallHash string `json:"install_hash"` + InstallSkipSteps []string `json:"install_skip_steps"` + Preemptible bool `json:"preemptible"` + SpotVMs bool `json:"spot_vms"` + WriteConfig bool `json:"-"` + RecoverConfig bool `json:"-"` + GatewayIP string `json:"gateway_ip"` + PublicGatewayIP string `json:"public_gateway_ip"` + SshProxyIP string `json:"ssh_proxy_ip"` + RegistryType RegistryType `json:"registry_type"` + GitHubPAT string `json:"-"` + 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"` + ExternalLokiEndpoint string `json:"external_loki_endpoint,omitempty"` + ExternalLokiSecret string `json:"-"` + ExternalLokiUser string `json:"external_loki_user,omitempty"` + PrometheusRemoteWriteUser string `json:"prometheus_remote_write_user,omitempty"` + PrometheusRemoteWritePassword string `json:"-"` + PrometheusRemoteWriteURL string `json:"prometheus_remote_write_url,omitempty"` // ACME Issuer GoogleACMEIssuer bool `json:"google_acme_issuer,omitempty"` diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index a10c84fd..81a95782 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -95,8 +95,9 @@ var _ = Describe("GCP Bootstrapper", func() { DNSZoneName: "test-zone", SSHPublicKeyPath: "key.pub", ProjectID: "pid", - Experiments: gcp.DefaultExperiments, - FeatureFlags: map[string]bool{}, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, + FeatureFlags: gcp.DefaultFeatureFlags, RootDiskSize: 50, InstallConfig: &files.RootConfig{ Registry: &files.RegistryConfig{}, diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index 21aba720..ee37a846 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -88,8 +88,9 @@ var _ = Describe("IAM & Admin", func() { DNSZoneName: "test-zone", SSHPublicKeyPath: "key.pub", ProjectID: "pid", - Experiments: gcp.DefaultExperiments, - FeatureFlags: map[string]bool{}, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, + FeatureFlags: gcp.DefaultFeatureFlags, InstallConfig: &files.RootConfig{ Registry: &files.RegistryConfig{}, Postgres: files.PostgresConfig{ diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 36ab16cb..0308f517 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -363,8 +363,9 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { } } - b.Env.InstallConfig.Codesphere.Experiments = b.Env.Experiments - b.Env.InstallConfig.Codesphere.Features = b.Env.FeatureFlags + 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) b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index ddfd6953..7d8212e1 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -92,8 +92,9 @@ var _ = Describe("Installconfig & Secrets", func() { DNSZoneName: "test-zone", SSHPublicKeyPath: "key.pub", ProjectID: "pid", - Experiments: gcp.DefaultExperiments, - FeatureFlags: map[string]bool{}, + InternalFlags: gcp.DefaultInternalFlags, + PreviewFlags: gcp.DefaultPreviewFlags, + FeatureFlags: gcp.DefaultFeatureFlags, InstallConfig: &files.RootConfig{ Registry: &files.RegistryConfig{}, Postgres: files.PostgresConfig{ @@ -345,7 +346,8 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("dev")) Expect(bs.Env.InstallConfig.Codesphere.Domain).To(Equal("cs.example.com")) Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal(map[string]bool{})) - Expect(bs.Env.InstallConfig.Codesphere.Experiments).To(Equal(gcp.DefaultExperiments)) + Expect(bs.Env.InstallConfig.Codesphere.Internal).To(Equal(gcp.DefaultInternalFlags)) + Expect(bs.Env.InstallConfig.Codesphere.Preview).To(Equal(util.StringSliceToBoolMap(gcp.DefaultPreviewFlags))) expectedInstallURI := "https://github.com/apps/" + bs.Env.GitHubAppName + "/installations/new" Expect(bs.Env.InstallConfig.Codesphere.GitProviders.GitHub.OAuth.InstallationURI).To(Equal(expectedInstallURI)) @@ -379,11 +381,11 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("staging")) }) - Context("When Experiments are set in CodesphereEnvironment", func() { + Context("When internal flags are set in CodesphereEnvironment", func() { BeforeEach(func() { - csEnv.Experiments = []string{"fake-exp1", "fake-exp2"} + csEnv.InternalFlags = []string{"fake-exp1", "fake-exp2"} }) - It("uses those experiments instead of defaults", func() { + It("uses those internal flags instead of defaults", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) icg.EXPECT().WriteVault("fake-secret", true).Return(nil) @@ -393,12 +395,29 @@ var _ = Describe("Installconfig & Secrets", func() { err := bs.UpdateInstallConfig() Expect(err).NotTo(HaveOccurred()) - Expect(bs.Env.InstallConfig.Codesphere.Experiments).To(Equal(csEnv.Experiments)) + Expect(bs.Env.InstallConfig.Codesphere.Internal).To(Equal(csEnv.InternalFlags)) + }) + }) + Context("When preview flags are set in CodesphereEnvironment", func() { + BeforeEach(func() { + csEnv.PreviewFlags = []string{"fake-preview1", "fake-preview2"} + }) + It("uses those preview flags instead of defaults", 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.Preview).To(Equal(util.StringSliceToBoolMap(csEnv.PreviewFlags))) }) }) Context("When feature flags are set in CodesphereEnvironment", func() { BeforeEach(func() { - csEnv.FeatureFlags = map[string]bool{"fake-flag1": true, "fake-flag2": true} + csEnv.FeatureFlags = []string{"fake-flag1", "fake-flag2"} }) It("uses those feature flags", func() { icg.EXPECT().GenerateSecrets().Return(nil) @@ -410,7 +429,7 @@ var _ = Describe("Installconfig & Secrets", func() { err := bs.UpdateInstallConfig() Expect(err).NotTo(HaveOccurred()) - Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal(csEnv.FeatureFlags)) + Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal(util.StringSliceToBoolMap(csEnv.FeatureFlags))) }) }) Context("When GitHub App name is not set ", func() { diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index f7e26aa7..8e0ac35b 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -69,10 +69,11 @@ type LocalBootstrapper struct { } type CodesphereEnvironment struct { - BaseDomain string `json:"base_domain"` - Experiments []string `json:"experiments"` - FeatureFlags map[string]bool `json:"feature_flags"` - Profile string `json:"profile"` + BaseDomain string `json:"base_domain"` + InternalFlags []string `json:"internal"` + PreviewFlags []string `json:"preview"` + FeatureFlags []string `json:"feature_flags"` + Profile string `json:"profile"` // Installer InstallVersion string `json:"install_version"` InstallHash string `json:"install_hash"` @@ -636,8 +637,9 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { } b.Env.InstallConfig.Codesphere.Plans = bootstrap.DefaultCodespherePlans() - b.Env.InstallConfig.Codesphere.Experiments = b.Env.Experiments - b.Env.InstallConfig.Codesphere.Features = b.Env.FeatureFlags + 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) if !b.Env.ExistingConfigUsed { err := b.icg.GenerateSecrets() diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index fa974ef9..ec4f0b25 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -115,8 +115,14 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Codesphere.DNSServers == nil { g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} } - if g.Config.Codesphere.Experiments == nil { - g.Config.Codesphere.Experiments = []string{} + if g.Config.Codesphere.Internal == nil { + g.Config.Codesphere.Internal = []string{} + } + if g.Config.Codesphere.Preview == nil { + g.Config.Codesphere.Preview = map[string]bool{} + } + if g.Config.Codesphere.Features == nil { + g.Config.Codesphere.Features = map[string]bool{} } if g.Config.Codesphere.WorkspaceImages == nil { g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{ diff --git a/internal/installer/docker/docker.go b/internal/installer/docker/docker.go new file mode 100644 index 00000000..c066ccde --- /dev/null +++ b/internal/installer/docker/docker.go @@ -0,0 +1,165 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// package docker installs docker on a remote host +package docker + +import ( + "fmt" + "log" + + "github.com/codesphere-cloud/oms/internal/installer/node" +) + +// DockerManager abstracts Docker operations on a remote host. +// The interface makes the command easy to unit-test with mocks. +// +//mockery:generate: true +type DockerManager interface { + // IsInstalled checks whether the docker binary is available on the remote host. + IsInstalled() bool + + // Install installs Docker Engine on the remote host using Docker's official apt repository. + InstallWithApt() error +} + +type dockerManager struct { + remoteUser string + remoteNode *node.Node +} + +func New(user string, node *node.Node) DockerManager { + return &dockerManager{ + remoteUser: user, + remoteNode: node, + } +} + +// IsInstalled checks whether the docker binary is available on the remote host. +func (d *dockerManager) IsInstalled() bool { + err := d.remoteNode.RunSSHCommand(d.remoteUser, "command -v docker") + + return err == nil +} + +// InstallWithApt installs Docker Engine on the remote host using Docker's official apt repository +// see https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository +func (d *dockerManager) InstallWithApt() error { + log.Println("Installing Docker on remote host via apt...") + + if err := d.removeConflictingPackages(); err != nil { + return fmt.Errorf("failed to remove conflicting docker packages") + } + + if err := d.installAptPrerequisites(); err != nil { + return fmt.Errorf("failed to install docker apt prequisites") + } + + if err := d.addDockerRepository(); err != nil { + return fmt.Errorf("failed to add docker apt repository") + } + + if err := d.installDockerPackages(); err != nil { + return fmt.Errorf("failed to install docker packages") + } + + if err := d.startDaemon(); err != nil { + return fmt.Errorf("failed to start docker daemon") + } + + return nil +} + +// removeConflictingPackages removes any unofficial Docker packages that may +// conflict with the official Docker Engine packages. The list matches what the +// official docs specify. +func (d *dockerManager) removeConflictingPackages() error { + cmd := "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true" + if err := d.remoteNode.RunSSHCommand(d.remoteUser, cmd); err != nil { + return fmt.Errorf("failed to remove conflicting packages: %w", err) + } + + return nil +} + +// installAptPrerequisites ensures ca-certificates and curl are present; +// these are required before the Docker GPG key and repo can be added. +func (d *dockerManager) installAptPrerequisites() error { + log.Println("Installing Docker apt prerequisites...") + for _, cmd := range []string{ + "apt-get update -qq", + "apt-get install -y -qq ca-certificates curl", + } { + if err := d.remoteNode.RunSSHCommand(d.remoteUser, cmd); err != nil { + return fmt.Errorf("failed to install apt prerequisites (%q): %w", cmd, err) + } + } + + return nil +} + +// addDockerRepository adds Docker's official GPG key and apt repository, +// exactly as described in the official Ubuntu install docs. +func (d *dockerManager) addDockerRepository() error { + log.Println("Adding Docker apt repository...") + dockerAddRepoCmd := fmt.Sprintf( + "sudo install -m 0755 -d /etc/apt/keyrings && " + + "sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && " + + "sudo chmod a+r /etc/apt/keyrings/docker.asc && " + + "SUITE=$(. /etc/os-release && echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") && " + + "ARCH=$(dpkg --print-architecture) && " + + "sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null </dev/null || true"). + Return(errors.New("ssh error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to remove conflicting docker packages")) + }) + + It("fails when installing apt prerequisites fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(errors.New("apt error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install docker apt prequisites")) + }) + + It("fails when adding the docker repository fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(errors.New("repo error")). + Once() + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add docker apt repository")) + }) + + It("fails when installing docker packages fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(errors.New("install error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install docker packages")) + }) + + It("fails when starting the docker daemon fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl start docker"). + Return(errors.New("daemon error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to start docker daemon")) + }) + + It("succeeds when all steps succeed", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl start docker"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl enable docker"). + Return(nil) + + err := manager.InstallWithApt() + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/internal/installer/docker/mocks.go b/internal/installer/docker/mocks.go new file mode 100644 index 00000000..5a4ec8f6 --- /dev/null +++ b/internal/installer/docker/mocks.go @@ -0,0 +1,124 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package docker + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockDockerManager creates a new instance of MockDockerManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDockerManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDockerManager { + mock := &MockDockerManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockDockerManager is an autogenerated mock type for the DockerManager type +type MockDockerManager struct { + mock.Mock +} + +type MockDockerManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDockerManager) EXPECT() *MockDockerManager_Expecter { + return &MockDockerManager_Expecter{mock: &_m.Mock} +} + +// InstallWithApt provides a mock function for the type MockDockerManager +func (_mock *MockDockerManager) InstallWithApt() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for InstallWithApt") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockDockerManager_InstallWithApt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InstallWithApt' +type MockDockerManager_InstallWithApt_Call struct { + *mock.Call +} + +// InstallWithApt is a helper method to define mock.On call +func (_e *MockDockerManager_Expecter) InstallWithApt() *MockDockerManager_InstallWithApt_Call { + return &MockDockerManager_InstallWithApt_Call{Call: _e.mock.On("InstallWithApt")} +} + +func (_c *MockDockerManager_InstallWithApt_Call) Run(run func()) *MockDockerManager_InstallWithApt_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDockerManager_InstallWithApt_Call) Return(err error) *MockDockerManager_InstallWithApt_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDockerManager_InstallWithApt_Call) RunAndReturn(run func() error) *MockDockerManager_InstallWithApt_Call { + _c.Call.Return(run) + return _c +} + +// IsInstalled provides a mock function for the type MockDockerManager +func (_mock *MockDockerManager) IsInstalled() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsInstalled") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockDockerManager_IsInstalled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInstalled' +type MockDockerManager_IsInstalled_Call struct { + *mock.Call +} + +// IsInstalled is a helper method to define mock.On call +func (_e *MockDockerManager_Expecter) IsInstalled() *MockDockerManager_IsInstalled_Call { + return &MockDockerManager_IsInstalled_Call{Call: _e.mock.On("IsInstalled")} +} + +func (_c *MockDockerManager_IsInstalled_Call) Run(run func()) *MockDockerManager_IsInstalled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDockerManager_IsInstalled_Call) Return(b bool) *MockDockerManager_IsInstalled_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockDockerManager_IsInstalled_Call) RunAndReturn(run func() bool) *MockDockerManager_IsInstalled_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index 4bf0071b..f617c29c 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -302,7 +302,8 @@ type CodesphereConfig struct { CertIssuer CertIssuerConfig `yaml:"certIssuer"` CustomDomains CustomDomainsConfig `yaml:"customDomains"` DNSServers []string `yaml:"dnsServers"` - Experiments []string `yaml:"experiments"` + Internal []string `yaml:"internal"` + Preview map[string]bool `yaml:"preview"` Features map[string]bool `yaml:"features"` ExtraCAPem string `yaml:"extraCaPem,omitempty"` ExtraWorkspaceEnvVars map[string]string `yaml:"extraWorkspaceEnvVars,omitempty"` diff --git a/internal/installer/vault_encryption.go b/internal/installer/vault_encryption.go index 91a108b3..321ddab7 100644 --- a/internal/installer/vault_encryption.go +++ b/internal/installer/vault_encryption.go @@ -14,6 +14,7 @@ import ( "filippo.io/age" sopsage "github.com/getsops/sops/v3/age" + "go.yaml.in/yaml/v3" ) var ( @@ -158,7 +159,7 @@ func generateAgeKey(keyPath string) (string, error) { // EncryptFileWithSOPS encrypts src with SOPS+age and writes ciphertext to target. func EncryptFileWithSOPS(src, target, recipient string) error { - cmd := exec.Command("sops", "--encrypt", "--age", recipient, "--output", target, src) + cmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", target, src) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("sops encrypt failed: %w: %s", err, out) @@ -169,7 +170,7 @@ func EncryptFileWithSOPS(src, target, recipient string) error { // DecryptFileWithSOPS decrypts a SOPS-encrypted file and returns the plaintext bytes. // If keyPath is non-empty, SOPS_AGE_KEY_FILE is set for the sops process. func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) { - cmd := exec.Command("sops", "--decrypt", src) + cmd := exec.Command("sops", "--decrypt", "--input-type", "yaml", src) if keyPath != "" { cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+keyPath) } @@ -182,3 +183,27 @@ func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) { } return out, nil } + +// unwrapSOPSData strips a top-level "data" literal block scalar wrapper if +// present. When SOPS encrypts with --input-type yaml, it +// wraps the entire document under a data: | key. +func unwrapSOPSData(data []byte) []byte { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return data + } + if len(doc.Content) == 0 { + return data + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode || len(root.Content) != 2 { + return data + } + keyNode := root.Content[0] + valNode := root.Content[1] + if keyNode.Value != "data" || valNode.Kind != yaml.ScalarNode { + return data + } + // The scalar value is the inner YAML content. + return []byte(valNode.Value) +} diff --git a/internal/installer/vault_encryption_test.go b/internal/installer/vault_encryption_test.go index 8b64ebbd..f40ea89a 100644 --- a/internal/installer/vault_encryption_test.go +++ b/internal/installer/vault_encryption_test.go @@ -207,6 +207,83 @@ var _ = Describe("VaultEncryption", func() { Expect(err).To(HaveOccurred()) }) }) + + Describe("LoadVaultData", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "load-vault-test-*") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + It("parses a plain vault file without data: wrapper", func() { + vaultPath := filepath.Join(tmpDir, "plain.vault.yaml") + plainYAML := "secrets:\n - name: test-secret\n fields:\n password: hunter2\n" + Expect(os.WriteFile(vaultPath, []byte(plainYAML), 0644)).To(Succeed()) + + vault, err := installer.LoadVaultData(vaultPath, "") + Expect(err).ToNot(HaveOccurred()) + Expect(vault.Secrets).To(HaveLen(1)) + Expect(vault.Secrets[0].Name).To(Equal("test-secret")) + Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + }) + + It("unwraps a plain file with data: | wrapper (SOPS whole-file format edge case)", func() { + vaultPath := filepath.Join(tmpDir, "wrapped.vault.yaml") + wrappedYAML := "data: |\n secrets:\n - name: test-secret\n fields:\n password: hunter2\n" + Expect(os.WriteFile(vaultPath, []byte(wrappedYAML), 0644)).To(Succeed()) + + vault, err := installer.LoadVaultData(vaultPath, "") + Expect(err).ToNot(HaveOccurred()) + Expect(vault.Secrets).To(HaveLen(1)) + Expect(vault.Secrets[0].Name).To(Equal("test-secret")) + Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + }) + + It("loads and decrypts a SOPS-encrypted vault end-to-end", func() { + if !sopsAndAgeAvailable() { + Skip("sops and age-keygen not available") + } + + // Generate an age keypair. + ageKeyPath := filepath.Join(tmpDir, "age_key.txt") + out, err := exec.Command("age-keygen", "-o", ageKeyPath).CombinedOutput() + Expect(err).ToNot(HaveOccurred(), string(out)) + + // Extract the public key (recipient). + recipient, _, err := installer.ResolveAgeKey(ageKeyPath, tmpDir) + Expect(err).ToNot(HaveOccurred()) + + // Write a plain vault file. + plainPath := filepath.Join(tmpDir, "plain.vault.yaml") + plainYAML := "secrets:\n - name: sops-secret\n fields:\n password: s3cr3t\n" + Expect(os.WriteFile(plainPath, []byte(plainYAML), 0644)).To(Succeed()) + + // Encrypt with SOPS using --input-type yaml (whole-file mode, + // which wraps content under data: |). + vaultPath := filepath.Join(tmpDir, "encrypted.vault.yaml") + encryptCmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", vaultPath, plainPath) + encOut, err := encryptCmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), string(encOut)) + + // LoadVaultData should detect SOPS, decrypt, unwrap data: |, and parse. + vault, err := installer.LoadVaultData(vaultPath, ageKeyPath) + Expect(err).ToNot(HaveOccurred()) + Expect(vault.Secrets).To(HaveLen(1)) + Expect(vault.Secrets[0].Name).To(Equal("sops-secret")) + Expect(vault.Secrets[0].Fields.Password).To(Equal("s3cr3t")) + }) + + It("returns an error for a non-existent file", func() { + _, err := installer.LoadVaultData(filepath.Join(tmpDir, "missing.yaml"), "") + Expect(err).To(HaveOccurred()) + }) + }) }) func splitLines(s string) []string { diff --git a/internal/installer/vault_encryption_unexported_test.go b/internal/installer/vault_encryption_unexported_test.go new file mode 100644 index 00000000..2697450f --- /dev/null +++ b/internal/installer/vault_encryption_unexported_test.go @@ -0,0 +1,47 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("unwrapSOPSData", func() { + It("returns data unchanged when there is no data: wrapper", func() { + input := []byte("secrets:\n - name: foo\n fields:\n password: bar\n") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal(string(input))) + }) + + It("strips a top-level data: | wrapper and returns inner content", func() { + input := []byte("data: |\n secrets:\n - name: foo\n fields:\n password: bar\n") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal("secrets:\n - name: foo\n fields:\n password: bar\n")) + }) + + It("returns data unchanged for an empty document", func() { + input := []byte("") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal(string(input))) + }) + + It("returns data unchanged when root has multiple keys", func() { + input := []byte("data: some-value\nsops:\n key: val\n") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal(string(input))) + }) + + It("returns data unchanged for invalid YAML", func() { + input := []byte("not: valid: yaml: [[") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal(string(input))) + }) + + It("returns data unchanged when data is not a scalar", func() { + input := []byte("data:\n nested: value\n") + output := unwrapSOPSData(input) + Expect(string(output)).To(Equal(string(input))) + }) +}) diff --git a/internal/installer/vault_templating_secret_store.go b/internal/installer/vault_templating_secret_store.go index 42a7efcf..4bcbd500 100644 --- a/internal/installer/vault_templating_secret_store.go +++ b/internal/installer/vault_templating_secret_store.go @@ -179,6 +179,8 @@ func isSOPSEncryptedYAML(data []byte) (bool, error) { } func parseVaultData(data []byte) (*files.InstallVault, error) { + data = unwrapSOPSData(data) + vault := &files.InstallVault{} if err := vault.Unmarshal(data); err != nil { return nil, err diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 5d7a920a..2a1a9aee 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -1181,15 +1181,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.286.0 +Version: v0.287.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.286.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.287.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.286.0 +Version: v0.287.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.286.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.287.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1211,9 +1211,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/b703f567277d/googlea ---------- Module: google.golang.org/grpc -Version: v1.81.1 +Version: v1.82.0 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.81.1/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.82.0/LICENSE ---------- Module: google.golang.org/protobuf diff --git a/internal/util/map.go b/internal/util/map.go index a06658d7..e8835d03 100644 --- a/internal/util/map.go +++ b/internal/util/map.go @@ -3,6 +3,20 @@ package util +// StringSliceToBoolMap converts a slice of strings into a set-style map where +// every element maps to true. A nil slice yields a nil map; a non-nil (possibly +// empty) slice yields a non-nil map. +func StringSliceToBoolMap(items []string) map[string]bool { + if items == nil { + return nil + } + m := make(map[string]bool, len(items)) + for _, item := range items { + m[item] = true + } + return m +} + // DeepMergeMaps recursively merges two maps of string to any, returning a new merged map. // Values from the src map will overwrite those in dst when there are conflicts, except when both values are maps themselves, // in which case they will be merged recursively. The original dst map is not modified; a new map is returned. diff --git a/internal/util/map_test.go b/internal/util/map_test.go new file mode 100644 index 00000000..3478b88b --- /dev/null +++ b/internal/util/map_test.go @@ -0,0 +1,34 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("StringSliceToBoolMap", func() { + It("maps every element to true", func() { + Expect(util.StringSliceToBoolMap([]string{"a", "b"})).To(Equal(map[string]bool{ + "a": true, + "b": true, + })) + }) + + It("returns nil for a nil slice", func() { + Expect(util.StringSliceToBoolMap(nil)).To(BeNil()) + }) + + It("returns an empty map for an empty slice", func() { + result := util.StringSliceToBoolMap([]string{}) + Expect(result).NotTo(BeNil()) + Expect(result).To(BeEmpty()) + }) + + It("deduplicates repeated elements", func() { + Expect(util.StringSliceToBoolMap([]string{"a", "a"})).To(Equal(map[string]bool{"a": true})) + }) +}) diff --git a/internal/util/tar.go b/internal/util/tar.go index 11154f27..e60bae77 100644 --- a/internal/util/tar.go +++ b/internal/util/tar.go @@ -43,7 +43,6 @@ func openTar(filename string, fileIo FileIO) (*tar.Reader, error) { } bufferedFile := bufio.NewReader(file) - log.Println("Reading TAR archive contents...") tr := tar.NewReader(bufferedFile) return tr, nil } @@ -62,7 +61,6 @@ func openTarGz(filename string, fileIo FileIO) (*tar.Reader, error) { return nil, fmt.Errorf("failed to create gzip reader: %w", err) } - log.Println("Reading TAR archive contents...") tr := tar.NewReader(gzr) return tr, nil } @@ -71,13 +69,11 @@ func openTarGz(filename string, fileIo FileIO) (*tar.Reader, error) { func extractEntry(header *tar.Header, targetPath string, fileIo FileIO, tr *tar.Reader) error { switch header.Typeflag { case tar.TypeDir: - log.Printf("Creating directory: %s", targetPath) if err := fileIo.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { return fmt.Errorf("failed to create directory %s: %w", targetPath, err) } case tar.TypeReg: - log.Printf("Extracting file: %s", targetPath) if err := fileIo.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", targetPath, err) }