diff --git a/cli/cmd/apikey/api_key_test_helpers_test.go b/cli/cmd/apikey/api_key_test_helpers_test.go index ff4a195e..650eaf96 100644 --- a/cli/cmd/apikey/api_key_test_helpers_test.go +++ b/cli/cmd/apikey/api_key_test_helpers_test.go @@ -20,6 +20,7 @@ func (e *testEnv) GetOmsPortalApiKey() (string, error) { if e.apiKey == "" { return "", errors.New("OMS_PORTAL_API_KEY not set in test env") } + return e.apiKey, nil } @@ -27,6 +28,7 @@ func (e *testEnv) GetOmsPortalApi() string { if e.apiURL == "" { return "https://oms-portal.codesphere.com/api" } + return e.apiURL } @@ -34,6 +36,7 @@ func (e *testEnv) GetOmsWorkdir() string { if e.workdir == "" { return "./oms-workdir" } + return e.workdir } diff --git a/cli/cmd/apikey/register.go b/cli/cmd/apikey/register.go index d79633fb..99a6ea58 100644 --- a/cli/cmd/apikey/register.go +++ b/cli/cmd/apikey/register.go @@ -36,6 +36,7 @@ type RegisterOpts struct { func (c *RegisterCmd) RunE(_ *cobra.Command, args []string) error { p := portal.NewPortalClient() + newKey, err := c.Register(p) if err != nil { return err @@ -73,6 +74,7 @@ func (c *RegisterCmd) Register(p portal.Portal) (*portal.ApiKey, error) { } var expiresAt time.Time + if c.Opts.ValidFor != "" { validForDuration, err := intutil.GetDurationFromString(c.Opts.ValidFor) if err != nil { diff --git a/cli/cmd/apikey/register_test.go b/cli/cmd/apikey/register_test.go index 60effb6d..dcdef9ab 100644 --- a/cli/cmd/apikey/register_test.go +++ b/cli/cmd/apikey/register_test.go @@ -46,6 +46,7 @@ var _ = Describe("RegisterCmd", func() { Context("when valid-for duration is valid", func() { It("registers the API key successfully", func() { start := time.Now() + mockPortal.EXPECT().RegisterAPIKey( owner, organization, @@ -110,6 +111,7 @@ var _ = Describe("RegisterCmd", func() { Context("when valid-for duration is not provided", func() { It("passes zero expiration time to portal client", func() { c.Opts.ValidFor = "" + mockPortal.EXPECT().RegisterAPIKey(owner, organization, role, time.Time{}).Return(&portal.ApiKey{}, nil) ak, err := c.Register(mockPortal) @@ -133,13 +135,16 @@ var _ = Describe("AddRegisterCmd", func() { parent := &cobra.Command{} opts := &util.GlobalOptions{} apikey.AddRegisterCmd(parent, opts) + found := false + for _, c := range parent.Commands() { if c.Use == "register" { found = true break } } + Expect(found).To(BeTrue()) }) }) diff --git a/cli/cmd/apikey/revoke_api_key_test.go b/cli/cmd/apikey/revoke_api_key_test.go index 8ac1f698..c136c1cc 100644 --- a/cli/cmd/apikey/revoke_api_key_test.go +++ b/cli/cmd/apikey/revoke_api_key_test.go @@ -54,13 +54,16 @@ var _ = Describe("AddRevokeAPIKeyCmd", func() { parent := &cobra.Command{} opts := &util.GlobalOptions{} apikey.AddRevokeCmd(parent, opts) + found := false + for _, c := range parent.Commands() { if c.Use == "api-key" { found = true break } } + Expect(found).To(BeTrue()) }) }) diff --git a/cli/cmd/apikey/update_api_key.go b/cli/cmd/apikey/update_api_key.go index 1eeebb12..1a6e59aa 100644 --- a/cli/cmd/apikey/update_api_key.go +++ b/cli/cmd/apikey/update_api_key.go @@ -62,5 +62,6 @@ func (c *UpdateAPIKeyCmd) UpdateAPIKey(p portal.Portal) error { } log.Printf("Successfully updated API key '%s' with new expiration date %s.\n", c.Opts.APIKeyID, expiresAt.Format(time.RFC1123)) + return nil } diff --git a/cli/cmd/apikey/update_api_key_test.go b/cli/cmd/apikey/update_api_key_test.go index 51a426e7..574bbea4 100644 --- a/cli/cmd/apikey/update_api_key_test.go +++ b/cli/cmd/apikey/update_api_key_test.go @@ -16,7 +16,6 @@ import ( ) var _ = Describe("UpdateAPIKey", func() { - var ( mockPortal *portal.MockPortal c apikey.UpdateAPIKeyCmd @@ -40,6 +39,7 @@ var _ = Describe("UpdateAPIKey", func() { RunAndReturn(func(id string, gotExpiresAt time.Time) error { Expect(id).To(Equal(apiKeyID)) Expect(gotExpiresAt).To(BeTemporally("~", expectedExpiresAt, 5*time.Second)) + return nil }) @@ -59,6 +59,7 @@ var _ = Describe("UpdateAPIKey", func() { RunAndReturn(func(id string, gotExpiresAt time.Time) error { Expect(id).To(Equal(apiKeyID)) Expect(gotExpiresAt).To(BeTemporally("~", expectedExpiresAt, 5*time.Second)) + return fmt.Errorf("invalid api key id format") }) diff --git a/cli/cmd/argocd.go b/cli/cmd/argocd.go index 7664197c..d0924e7c 100644 --- a/cli/cmd/argocd.go +++ b/cli/cmd/argocd.go @@ -39,6 +39,7 @@ func (c *InstallArgoCDCmd) RunE(_ *cobra.Command, args []string) error { if err != nil { return err } + ociPassword = pw gitPassword = os.Getenv("OMS_GIT_PASSWORD") } @@ -57,6 +58,7 @@ func (c *InstallArgoCDCmd) RunE(_ *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to initialize ArgoCD installer: %w", err) } + err = install.Install() if err != nil { return fmt.Errorf("failed to install chart ArgoCD: %w", err) @@ -77,14 +79,19 @@ func resolveOCIPassword() (string, error) { } fmt.Print("OCI registry password/token: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { return "", fmt.Errorf("failed to read password: %w", err) } + if len(pw) == 0 { return "", fmt.Errorf("password is required; set OMS_REGISTRY_PASSWORD or enter it when prompted") } + return string(pw), nil } diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 8f87eae5..4aab1c33 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -173,6 +173,7 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { } c.CodesphereEnv.RegistryType = gcp.RegistryType(c.InputRegistryType) + c.CodesphereEnv.OmsWorkdir = c.Env.GetOmsWorkdir() if c.CodesphereEnv.GitHubPAT != "" { c.CodesphereEnv.RegistryType = gcp.RegistryTypeGitHub @@ -200,6 +201,7 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { if bs.Env.Jumpbox != nil && bs.Env.Jumpbox.GetExternalIP() != "" { log.Printf("To debug on the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", bs.Env.Jumpbox.GetExternalIP()) } + return fmt.Errorf("failed to bootstrap GCP: %w", err) } @@ -214,11 +216,14 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { packageName := "-installer" installCmd := "oms install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml" + if gcp.RegistryType(bs.Env.RegistryType) == gcp.RegistryTypeGitHub { log.Printf("You set a GitHub PAT for direct image access. Make sure to use a lite package, as VM root disk sizes are reduced.") + installCmd += " -s load-container-images" packageName += "-lite" } + log.Printf("example install command (run from jumpbox):\n%s -p %s.tar.gz", installCmd, packageName) return nil diff --git a/cli/cmd/bootstrap_gcp_cleanup_test.go b/cli/cmd/bootstrap_gcp_cleanup_test.go index 3337fd4c..dc4873ef 100644 --- a/cli/cmd/bootstrap_gcp_cleanup_test.go +++ b/cli/cmd/bootstrap_gcp_cleanup_test.go @@ -73,6 +73,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Expect(err).NotTo(HaveOccurred()) var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) Expect(err).NotTo(HaveOccurred()) @@ -93,6 +94,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Expect(err).NotTo(HaveOccurred()) var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) Expect(err).NotTo(HaveOccurred()) @@ -240,6 +242,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Context("when project ID is provided via flag", func() { It("should use the provided project ID", func() { cleanupCmd.Opts.ProjectID = "flag-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("flag-project").Return(false, nil) @@ -252,6 +255,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Context("when OMS management check fails", func() { It("should return the verification error", func() { cleanupCmd.Opts.ProjectID = "test-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(false, errors.New("API error")) @@ -265,6 +269,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { It("should skip OMS management check and proceed to confirmation", func() { cleanupCmd.Opts.ProjectID = "test-project" cleanupCmd.Opts.Force = true + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) @@ -277,6 +282,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { It("should abort the cleanup", func() { cleanupCmd.Opts.ProjectID = "test-project" deps.ConfirmReader = bytes.NewBufferString("wrong-input\n") + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(true, nil) diff --git a/cli/cmd/bootstrap_gcp_postconfig.go b/cli/cmd/bootstrap_gcp_postconfig.go index d0eb064b..302cbd6f 100644 --- a/cli/cmd/bootstrap_gcp_postconfig.go +++ b/cli/cmd/bootstrap_gcp_postconfig.go @@ -35,13 +35,16 @@ func (c *BootstrapGcpPostconfigCmd) RunE(_ *cobra.Command, args []string) error fw := intutil.NewFilesystemWriter() infraFilePath := gcp.GetInfraFilePath() + codesphereEnv, exists, err := gcp.LoadInfraFile(fw, infraFilePath) if err != nil { return fmt.Errorf("failed to load gcp infra file: %w", err) } + if !exists { return fmt.Errorf("gcp infra file not found at %s", infraFilePath) } + c.CodesphereEnv = codesphereEnv err = icg.LoadInstallConfigFromFile(c.Opts.InstallConfigPath) diff --git a/cli/cmd/bootstrap_gcp_restart_vms.go b/cli/cmd/bootstrap_gcp_restart_vms.go index 900b9353..459bbc8c 100644 --- a/cli/cmd/bootstrap_gcp_restart_vms.go +++ b/cli/cmd/bootstrap_gcp_restart_vms.go @@ -38,21 +38,26 @@ func (c *BootstrapGcpRestartVMsCmd) resolveProjectAndZone(fw intutil.FileIO) (st if (projectID == "") != (zone == "") { return "", "", fmt.Errorf("--project-id and --zone must be provided together") } + if projectID != "" { return projectID, zone, nil } infraFilePath := gcp.GetInfraFilePath() + infraEnv, exists, err := gcp.LoadInfraFile(fw, infraFilePath) if err != nil { return "", "", fmt.Errorf("failed to load infra file: %w", err) } + if !exists { return "", "", fmt.Errorf("infra file not found at %s; use --project-id and --zone flags", infraFilePath) } + if infraEnv.ProjectID == "" || infraEnv.Zone == "" { return "", "", fmt.Errorf("infra file is missing project ID or zone; use --project-id and --zone flags") } + return infraEnv.ProjectID, infraEnv.Zone, nil } @@ -83,15 +88,19 @@ func (c *BootstrapGcpRestartVMsCmd) RunE(_ *cobra.Command, _ []string) error { if c.Opts.Name != "" { log.Printf("Restarting VM %s in project %s (zone %s)...", c.Opts.Name, projectID, zone) + if err := bs.RestartVM(c.Opts.Name); err != nil { return fmt.Errorf("failed to restart VM: %w", err) } + log.Printf("VM %s restarted successfully.", c.Opts.Name) } else { log.Printf("Restarting all VMs in project %s (zone %s)...", projectID, zone) + if err := bs.RestartVMs(); err != nil { return fmt.Errorf("failed to restart VMs: %w", err) } + log.Printf("All VMs restarted successfully.") } diff --git a/cli/cmd/bootstrap_gcp_restart_vms_test.go b/cli/cmd/bootstrap_gcp_restart_vms_test.go index 7a9be24d..3b4a93af 100644 --- a/cli/cmd/bootstrap_gcp_restart_vms_test.go +++ b/cli/cmd/bootstrap_gcp_restart_vms_test.go @@ -28,6 +28,7 @@ var _ = Describe("BootstrapGcpRestartVMsCmd", func() { c, _, err := parentCmd.Find([]string{"restart-vms"}) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) + return c } diff --git a/cli/cmd/bootstrap_local.go b/cli/cmd/bootstrap_local.go index 19c4fb9f..249ac693 100644 --- a/cli/cmd/bootstrap_local.go +++ b/cli/cmd/bootstrap_local.go @@ -48,7 +48,6 @@ type BootstrapLocalCmd struct { func (c *BootstrapLocalCmd) RunE(_ *cobra.Command, args []string) error { err := c.BootstrapLocal() - if err != nil { return fmt.Errorf("failed to bootstrap: %w", err) } @@ -119,6 +118,7 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { if c.CodesphereEnv.InstallConfigPath == "" { c.CodesphereEnv.InstallConfigPath = filepath.Join(c.CodesphereEnv.InstallDir, "config.yaml") } + if c.CodesphereEnv.SecretsFilePath == "" { c.CodesphereEnv.SecretsFilePath = filepath.Join(c.CodesphereEnv.InstallDir, "prod.vault.yaml") } @@ -143,6 +143,7 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { stlog := bootstrap.NewStepLogger(false) icg := installer.NewInstallConfigManager() fw := intutil.NewFilesystemWriter() + kubeClient, restConfig, err := c.GetKubeClient(ctx) if err != nil { return fmt.Errorf("failed to initialize Kubernetes client: %w", err) @@ -154,6 +155,7 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { } bs := local.NewLocalBootstrapper(ctx, stlog, kubeClient, restConfig, fw, icg, helmClient, c.CodesphereEnv) + return bs.Bootstrap() } @@ -162,16 +164,23 @@ func (c *BootstrapLocalCmd) resolveRegistryPassword() error { c.CodesphereEnv.RegistryPassword = pw return nil } + fmt.Print("Registry password: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { return fmt.Errorf("failed to read registry password: %w", err) } + if len(pw) == 0 { return fmt.Errorf("registry password is required; set OMS_REGISTRY_PASSWORD or enter it when prompted") } + c.CodesphereEnv.RegistryPassword = string(pw) + return nil } @@ -203,7 +212,9 @@ func (c *BootstrapLocalCmd) ConfirmLocalBootstrapWarning() error { } fmt.Print("\nType 'yes' to continue: ") + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') if err != nil && !errors.Is(err, stdio.EOF) { return fmt.Errorf("failed to read confirmation: %w", err) @@ -243,6 +254,7 @@ func (c *BootstrapLocalCmd) GetKubeClient(ctx context.Context) (ctrlclient.Clien if err != nil { return nil, nil, fmt.Errorf("failed to initialize Kubernetes client: %w", err) } + return kubeClient, kubeConfig, nil } diff --git a/cli/cmd/build_image_test.go b/cli/cmd/build_image_test.go index cce1499e..f0a17b60 100644 --- a/cli/cmd/build_image_test.go +++ b/cli/cmd/build_image_test.go @@ -142,6 +142,7 @@ var _ = Describe("AddBuildImageCmd", func() { cmd.AddBuildImageCmd(parentCmd, globalOpts) var imageCmd *cobra.Command + for _, c := range parentCmd.Commands() { if c.Use == "image" { imageCmd = c diff --git a/cli/cmd/build_images.go b/cli/cmd/build_images.go index 48cd3c4e..5fc8f010 100644 --- a/cli/cmd/build_images.go +++ b/cli/cmd/build_images.go @@ -72,6 +72,7 @@ func (c *BuildImagesCmd) BuildAndPushImages(pm installer.PackageManager, cm inst if len(config.Codesphere.DeployConfig.Images) == 0 { return fmt.Errorf("no images defined in the config") } + if len(config.Registry.Server) == 0 { return fmt.Errorf("registry server (property registry.server) not defined in the config, please specify a valid registry to which the image shall be pushed") } @@ -89,6 +90,7 @@ func (c *BuildImagesCmd) BuildAndPushImages(pm installer.PackageManager, cm inst for imageName, imageConfig := range config.Codesphere.DeployConfig.Images { for flavorName, flavorConfig := range imageConfig.Flavors { log.Printf("Processing image '%s' with flavor '%s'", imageName, flavorName) + if flavorConfig.Image.Dockerfile == "" { log.Printf("Skipping flavor '%s', no dockerfile defined", flavorName) continue diff --git a/cli/cmd/build_images_test.go b/cli/cmd/build_images_test.go index 9bf2e3b2..5832cca1 100644 --- a/cli/cmd/build_images_test.go +++ b/cli/cmd/build_images_test.go @@ -80,10 +80,12 @@ var _ = Describe("BuildImagesCmd", func() { tempConfigFile, err := os.CreateTemp("", "test-config.yaml") Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempConfigFile.Name()) }() _, err = tempConfigFile.WriteString(validConfigYaml) Expect(err).To(BeNil()) + _ = tempConfigFile.Close() c.Opts.Config = tempConfigFile.Name() @@ -105,6 +107,7 @@ var _ = Describe("BuildImagesCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) c.Opts.Config = "non-existent-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("non-existent-config.yaml").Return(files.RootConfig{}, errors.New("failed to parse config")) err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) @@ -407,6 +410,7 @@ var _ = Describe("AddBuildImagesCmd", func() { cmd.AddBuildImagesCmd(parentCmd, globalOpts) var imagesCmd *cobra.Command + for _, c := range parentCmd.Commands() { if c.Use == "images" { imagesCmd = c diff --git a/cli/cmd/cmd_suite_test.go b/cli/cmd/cmd_suite_test.go index d0decbc0..bf64f06c 100644 --- a/cli/cmd/cmd_suite_test.go +++ b/cli/cmd/cmd_suite_test.go @@ -75,6 +75,7 @@ var _ = Describe("RootCmd", func() { "keyId": keyId, } body, _ := json.Marshal(response) + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body)), diff --git a/cli/cmd/codesphere/install_codesphere.go b/cli/cmd/codesphere/install_codesphere.go index 141bf27b..f6e3d2ef 100644 --- a/cli/cmd/codesphere/install_codesphere.go +++ b/cli/cmd/codesphere/install_codesphere.go @@ -56,6 +56,7 @@ type InstallCodesphereOpts struct { func (c *InstallCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() + effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -177,6 +178,7 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage } merged := map[string]any{} + for _, configPath := range configFiles { renderedPath := configPath if opts.Vault != "" { @@ -185,6 +187,7 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage cleanup() return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to render config template %s: %w", configPath, err) } + cleanupFns = append(cleanupFns, renderCleanup) renderedPath = tmpPath } @@ -200,9 +203,11 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage cleanup() return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to parse config file %s: %w", renderedPath, err) } + if partial == nil { partial = map[string]any{} } + merged = intutil.DeepMergeMaps(merged, partial) } @@ -217,24 +222,35 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage cleanup() return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to create merged config directory: %w", err) } + mergedPath := filepath.Join(mergedDir, mergedInstallConfigFileName) + tmp, err := os.OpenFile(mergedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { cleanup() + _ = os.RemoveAll(mergedDir) + return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to create merged config file %s: %w", mergedPath, err) } + if _, err := tmp.Write(mergedBytes); err != nil { _ = tmp.Close() _ = os.RemoveAll(mergedDir) + cleanup() + return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to write merged config file: %w", err) } + if err := tmp.Close(); err != nil { _ = os.RemoveAll(mergedDir) + cleanup() + return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to close merged config file: %w", err) } + cleanupFns = append(cleanupFns, func() { _ = os.RemoveAll(mergedDir) }) @@ -247,6 +263,7 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage effectiveOpts := *opts effectiveOpts.ConfigPath = mergedPath + effectiveOpts.Configs = append([]string(nil), configFiles...) return &effectiveOpts, cfg, cleanup, nil diff --git a/cli/cmd/codesphere/install_codesphere_config_test.go b/cli/cmd/codesphere/install_codesphere_config_test.go index f26872d3..977a2c48 100644 --- a/cli/cmd/codesphere/install_codesphere_config_test.go +++ b/cli/cmd/codesphere/install_codesphere_config_test.go @@ -20,6 +20,7 @@ import ( var _ = Describe("prepareInstallConfig", func() { It("merges multiple config files in order and returns a single parsed config path", func() { tmpDir, err := os.MkdirTemp("", "install-config-merge-*") + Expect(err).ToNot(HaveOccurred()) defer func() { Expect(os.RemoveAll(tmpDir)).To(Succeed()) @@ -77,6 +78,7 @@ pcApps: effectiveOpts, cfg, cleanup, err := prepareInstallConfig(opts, installer.NewConfig()) Expect(err).ToNot(HaveOccurred()) + defer cleanup() Expect(effectiveOpts.ConfigPath).ToNot(BeEmpty()) @@ -104,6 +106,7 @@ pcApps: Expect(statErr).ToNot(HaveOccurred()) cleanup() + _, statErr = os.Stat(effectiveOpts.ConfigPath) Expect(os.IsNotExist(statErr)).To(BeTrue()) }) @@ -174,6 +177,7 @@ pcApps: effectiveOpts, cfg, cleanup, err := prepareInstallConfig(opts, installer.NewConfig()) Expect(err).ToNot(HaveOccurred()) + defer cleanup() Expect(effectiveOpts.Vault).To(Equal(vaultPath)) @@ -215,6 +219,7 @@ codesphere: effectiveOpts, cfg, cleanup, err := prepareInstallConfig(opts, installer.NewConfig()) Expect(err).ToNot(HaveOccurred()) + defer cleanup() Expect(effectiveOpts.Configs).To(Equal([]string{configPath})) @@ -227,8 +232,10 @@ func installCodesphereSopsAndAgeAvailable() bool { if _, err := exec.LookPath("sops"); err != nil { return false } + if _, err := exec.LookPath("age-keygen"); err != nil { return false } + return true } diff --git a/cli/cmd/codesphere/install_codesphere_dependencies.go b/cli/cmd/codesphere/install_codesphere_dependencies.go index 799b532d..7fcec487 100644 --- a/cli/cmd/codesphere/install_codesphere_dependencies.go +++ b/cli/cmd/codesphere/install_codesphere_dependencies.go @@ -63,6 +63,7 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi if err := ci.ExtractAndValidatePackage(pm); err != nil { return fmt.Errorf("failed to extract and validate package: %w", err) } + if err := stlog.Step("Install ArgoCD pre-step", func() error { return installArgoCDAndApps(opts, cfg, pm, stlog) }); err != nil { @@ -73,6 +74,7 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi if err := ci.Install(pm, cm, im, runtime.GOOS, runtime.GOARCH); err != nil { return fmt.Errorf("failed to install dependencies: %w", err) } + return nil } @@ -80,33 +82,41 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi // before the main dependency steps. func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm installer.PackageManager, stlog *bootstrap.StepLogger) error { var install *argocdinstaller.AppInstaller + if err := stlog.Substep("Load vault data", func() error { installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, cfg) if err != nil { return err } + registryPassword := "" if secret := installVault.GetSecret(files.SecretRegistryPassword); secret != nil && secret.Fields != nil { registryPassword = secret.Fields.Password } + if registryPassword == "" { return fmt.Errorf("registry password not found in vault (secret %q)", files.SecretRegistryPassword) } + scheme := k8sruntime.NewScheme() if err := k8sscheme.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add kubernetes core scheme: %w", err) } + if err := argov1alpha1.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add ArgoCD scheme: %w", err) } + kubeClient, err := ctrlclient.New(restConfig, ctrlclient.Options{Scheme: scheme}) if err != nil { return fmt.Errorf("failed to create kubernetes client: %w", err) } + registryURL := opts.ArgoCDRegistryURL if registryURL == "" && cfg.Registry != nil { registryURL = cfg.Registry.Server + "/codesphere-cloud/charts" } + argoCDInstall, err := argocdinstaller.NewInstaller(argocdinstaller.InstallerConfig{ Version: opts.ArgoCDVersion, DatacenterId: fmt.Sprintf("%d", cfg.Datacenter.ID), @@ -122,6 +132,7 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm if err != nil { return fmt.Errorf("failed to initialize ArgoCD installer: %w", err) } + install = argocdinstaller.NewAppInstaller(argocdinstaller.AppInstallerConfig{ Config: cfg, Vault: installVault, @@ -130,18 +141,22 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm Installer: argoCDInstall, PCAppsValues: opts.PCAppsValues, }) + return nil }); err != nil { return err } + if err := stlog.Substep("Install ArgoCD", install.InstallArgoCD); err != nil { return err } + if err := stlog.Substep("Sync vault secret", func() error { return install.SyncVaultSecret(context.Background()) }); err != nil { return err } + if err := stlog.Substep("Install pc-apps", func() error { return install.InstallPCApps(context.Background(), pm.GetDependencyPath("bom.json")) }); err != nil { diff --git a/cli/cmd/codesphere/install_codesphere_infra.go b/cli/cmd/codesphere/install_codesphere_infra.go index 70274e9b..e90b88d0 100644 --- a/cli/cmd/codesphere/install_codesphere_infra.go +++ b/cli/cmd/codesphere/install_codesphere_infra.go @@ -52,6 +52,7 @@ func installCodesphereInfra(opts *InstallCodesphereOpts, env env.Env) error { if err := ci.Install(pm, cm, im, runtime.GOOS, runtime.GOARCH); err != nil { return fmt.Errorf("failed to install infra: %w", err) } + return nil } diff --git a/cli/cmd/codesphere/install_codesphere_platform.go b/cli/cmd/codesphere/install_codesphere_platform.go index 7b9a7c3e..9d355cc8 100644 --- a/cli/cmd/codesphere/install_codesphere_platform.go +++ b/cli/cmd/codesphere/install_codesphere_platform.go @@ -58,6 +58,7 @@ func installCodespherePlatform(ctx context.Context, opts *InstallCodesphereOpts, if err := ci.Install(pm, cm, im, runtime.GOOS, runtime.GOARCH); err != nil { return fmt.Errorf("failed to install platform: %w", err) } + return nil } diff --git a/cli/cmd/codesphere/install_codesphere_test.go b/cli/cmd/codesphere/install_codesphere_test.go index 2e362320..d21d327a 100644 --- a/cli/cmd/codesphere/install_codesphere_test.go +++ b/cli/cmd/codesphere/install_codesphere_test.go @@ -49,13 +49,16 @@ var _ = Describe("InstallCodesphereCmd", func() { tempConfigFile, err := os.CreateTemp("", "test-config.yaml") Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempConfigFile.Name()) }() _, err = tempConfigFile.WriteString("codesphere:\n deployConfig:\n images: {}\n") Expect(err).To(BeNil()) + _ = tempConfigFile.Close() c.Opts.Configs = []string{tempConfigFile.Name()} + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") runCmd := &cobra.Command{} @@ -63,6 +66,7 @@ var _ = Describe("InstallCodesphereCmd", func() { err = c.RunE(runCmd, []string{}) Expect(err).To(HaveOccurred()) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { // Should fail with platform error on non-Linux platform Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) @@ -72,7 +76,6 @@ var _ = Describe("InstallCodesphereCmd", func() { } }) }) - }) var _ = Describe("AddInstallCodesphereCmd", func() { @@ -90,6 +93,7 @@ var _ = Describe("AddInstallCodesphereCmd", func() { codesphere.AddInstallCmd(parentCmd, globalOpts) var codesphereCmd *cobra.Command + for _, c := range parentCmd.Commands() { if c.Use == "codesphere" { codesphereCmd = c diff --git a/cli/cmd/codesphere/smoketest_codesphere.go b/cli/cmd/codesphere/smoketest_codesphere.go index 54425365..5d67a998 100644 --- a/cli/cmd/codesphere/smoketest_codesphere.go +++ b/cli/cmd/codesphere/smoketest_codesphere.go @@ -45,6 +45,7 @@ func (c *SmoketestCodesphereCmd) RunE(_ *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create Codesphere client: %w", err) } + c.Opts.Client = client return c.RunSmoketest() @@ -132,13 +133,16 @@ func (c *SmoketestCodesphereCmd) RunSmoketest() (err error) { } var workspaceID int + deleteStep := &teststeps.DeleteWorkspaceStep{} + defer func() { if err != nil { log.Printf("Smoketest failed: %s", err.Error()) } shouldDelete := false + for _, s := range stepsToRun { if s.Name() == deleteStep.Name() { shouldDelete = true @@ -166,6 +170,7 @@ func (c *SmoketestCodesphereCmd) RunSmoketest() (err error) { if step.Name() == deleteStep.Name() { continue } + if err = step.Run(ctx, c.Opts, &workspaceID); err != nil { return err } diff --git a/cli/cmd/codesphere/smoketest_codesphere_test.go b/cli/cmd/codesphere/smoketest_codesphere_test.go index e740356e..8856be71 100644 --- a/cli/cmd/codesphere/smoketest_codesphere_test.go +++ b/cli/cmd/codesphere/smoketest_codesphere_test.go @@ -182,6 +182,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { }) It("completes successfully with all steps", func() { mockFullTestRun(mockClient, teamIdInt, planIdInt, 789) + err := c.RunSmoketest() Expect(err).To(BeNil()) }) @@ -645,13 +646,16 @@ var _ = Describe("AddSmoketestCodesphereCmd", func() { parent := &cobra.Command{} opts := &util.GlobalOptions{} codesphere.AddSmoketestCmd(parent, opts) + found := false + for _, c := range parent.Commands() { if c.Use == "codesphere" { found = true break } } + Expect(found).To(BeTrue()) }) }) diff --git a/cli/cmd/create_test_user_test.go b/cli/cmd/create_test_user_test.go index 577d603a..4539ac0e 100644 --- a/cli/cmd/create_test_user_test.go +++ b/cli/cmd/create_test_user_test.go @@ -14,8 +14,10 @@ import ( var _ = Describe("CreateTestUser", func() { Context("AddCreateTestUserCmd", func() { - var createCmd cobra.Command - var opts *util.GlobalOptions + var ( + createCmd cobra.Command + opts *util.GlobalOptions + ) BeforeEach(func() { createCmd = cobra.Command{} diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index fa671a2b..b5754423 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -42,6 +42,7 @@ func (c *DownloadPackageCmd) RunE(_ *cobra.Command, args []string) error { } p := portal.NewPortalClient() + build, err := p.GetBuild(portal.CodesphereProduct, c.Opts.Version, c.Opts.Hash) if err != nil { return fmt.Errorf("failed to get codesphere package: %w", err) @@ -102,6 +103,7 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, } fullFilename := build.BuildPackageFilename(filename) + out, err := c.FileWriter.OpenAppend(fullFilename) if err != nil { out, err = c.FileWriter.Create(fullFilename) @@ -113,6 +115,7 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, // get already downloaded file size of fullFilename fileSize := 0 + fileInfo, err := out.Stat() if err == nil { fileSize = int(fileInfo.Size()) diff --git a/cli/cmd/download_package_test.go b/cli/cmd/download_package_test.go index c38fe1dc..f5028bee 100644 --- a/cli/cmd/download_package_test.go +++ b/cli/cmd/download_package_test.go @@ -18,7 +18,6 @@ import ( ) var _ = Describe("DownloadPackages", func() { - var ( c cmd.DownloadPackageCmd filename string @@ -60,8 +59,10 @@ var _ = Describe("DownloadPackages", func() { }) Context("AddDownloadPackageCmd", func() { - var downloadCmd cobra.Command - var opts *util.GlobalOptions + var ( + downloadCmd cobra.Command + opts *util.GlobalOptions + ) BeforeEach(func() { downloadCmd = cobra.Command{} diff --git a/cli/cmd/extend_baseimage_test.go b/cli/cmd/extend_baseimage_test.go index 8ebb274b..c6bc649f 100644 --- a/cli/cmd/extend_baseimage_test.go +++ b/cli/cmd/extend_baseimage_test.go @@ -54,6 +54,7 @@ var _ = Describe("ExtendBaseimageCmd", func() { It("calls GetOmsWorkdir and fails on package operations", func() { c.Opts.Package = "non-existent-package.tar.gz" + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") err := c.RunE(nil, []string{}) @@ -107,6 +108,7 @@ var _ = Describe("ExtendBaseimageCmd", func() { // Create a temporary file for the Dockerfile generation to work with tempFile, err := os.CreateTemp("", "dockerfile-test-*") Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempFile.Name()) }() defer func() { _ = tempFile.Close() }() @@ -127,6 +129,7 @@ var _ = Describe("ExtendBaseimageCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) c.Opts.Force = true + mockPackageManager.EXPECT().Extract(true).Return(nil) mockPackageManager.EXPECT().GetFullImageTag("").Return("", errors.New("failed to extract package to workdir: extraction failed")) @@ -143,6 +146,7 @@ var _ = Describe("ExtendBaseimageCmd", func() { // Create a temporary file for the Dockerfile generation to work with tempFile, err := os.CreateTemp("", "dockerfile-test-*") Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempFile.Name()) }() defer func() { _ = tempFile.Close() }() @@ -174,6 +178,7 @@ var _ = Describe("AddExtendBaseimageCmd", func() { cmd.AddExtendBaseimageCmd(parentCmd, globalOpts) var baseimagCmd *cobra.Command + for _, c := range parentCmd.Commands() { if c.Use == "baseimage" { baseimagCmd = c diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 2277528e..cfefbd53 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -233,6 +233,7 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana if !c.Opts.Interactive { return fmt.Errorf("configuration validation failed: %s", strings.Join(validationWarnings, ", ")) } + c.printWarningsMessage(validationWarnings) } @@ -262,9 +263,11 @@ func (c *InitInstallConfigCmd) printWelcomeMessage() { func (c *InitInstallConfigCmd) printWarningsMessage(warnings []string) { log.Println("\n" + strings.Repeat("!", 70)) log.Printf("Configuration has %d warning(s):\n", len(warnings)) + for _, w := range warnings { log.Printf(" WARNING: %s\n", w) } + log.Println(strings.Repeat("!", 70)) log.Println("The configuration files will be generated.") log.Println("Please review and fix the issues in the generated files before use!") @@ -272,11 +275,13 @@ func (c *InitInstallConfigCmd) printWarningsMessage(warnings []string) { func (c *InitInstallConfigCmd) printSuccessMessage(warningCount int) { log.Println("\n" + strings.Repeat("=", 70)) + if warningCount > 0 { log.Printf("Configuration files generated with %d warning(s)! Review before use!\n", warningCount) } else { log.Println("Configuration files successfully generated!") } + log.Println(strings.Repeat("=", 70)) log.Println("\nIMPORTANT: Keys and certificates have been generated and embedded in the vault file.") @@ -297,6 +302,7 @@ func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) log.Printf("Validating configuration files...\n") log.Printf("Reading install config file: %s\n", c.Opts.ConfigFile) + err := icg.LoadInstallConfigFromFile(c.Opts.ConfigFile) if err != nil { return fmt.Errorf("failed to load config file: %w", err) @@ -331,12 +337,15 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.DatacenterID != 0 { config.Datacenter.ID = c.Opts.DatacenterID } + if c.Opts.DatacenterCity != "" { config.Datacenter.City = c.Opts.DatacenterCity } + if c.Opts.DatacenterCountryCode != "" { config.Datacenter.CountryCode = c.Opts.DatacenterCountryCode } + if c.Opts.DatacenterName != "" { config.Datacenter.Name = c.Opts.DatacenterName } @@ -367,9 +376,11 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if config.Postgres.Primary == nil { config.Postgres.Primary = &files.PostgresPrimaryConfig{} } + if postgresPrimaryHostname != "" { config.Postgres.Primary.Hostname = postgresPrimaryHostname } + if c.Opts.PostgresPrimaryIP != "" { config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP } @@ -391,14 +402,17 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.CephCsiKubeletDir != "" { config.Ceph.CsiKubeletDir = c.Opts.CephCsiKubeletDir } + if c.Opts.CephNodesSubnet != "" { config.Ceph.NodesSubnet = c.Opts.CephNodesSubnet } + if len(c.Opts.CephHosts) > 0 { cephHosts := []files.CephHost{} for _, hostCfg := range c.Opts.CephHosts { cephHosts = append(cephHosts, files.CephHost(hostCfg)) } + config.Ceph.Hosts = cephHosts } @@ -406,9 +420,11 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.KubernetesAPIServerHost != "" { config.Kubernetes.APIServerHost = c.Opts.KubernetesAPIServerHost } + if c.Opts.KubernetesPodCIDR != "" { config.Kubernetes.PodCIDR = c.Opts.KubernetesPodCIDR } + if c.Opts.KubernetesServiceCIDR != "" { config.Kubernetes.ServiceCIDR = c.Opts.KubernetesServiceCIDR } @@ -420,6 +436,7 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va IPAddress: ip, }) } + config.Kubernetes.ControlPlanes = kubernetesControlPlanes } @@ -430,6 +447,7 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va IPAddress: ip, }) } + config.Kubernetes.Workers = kubernetesWorkers } @@ -437,12 +455,15 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.ClusterGatewayServiceType != "" { config.Cluster.Gateway.ServiceType = c.Opts.ClusterGatewayServiceType } + if len(c.Opts.ClusterGatewayIPAddresses) > 0 { config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPAddresses } + if c.Opts.ClusterPublicGatewayServiceType != "" { config.Cluster.PublicGateway.ServiceType = c.Opts.ClusterPublicGatewayServiceType } + if len(c.Opts.ClusterPublicGatewayIPAddresses) > 0 { config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPAddresses } @@ -470,15 +491,18 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if certIssuer.Acme == nil { certIssuer.Acme = &files.ACMEConfig{} } + certIssuer.Type = files.CertIssuerTypeACME certIssuer.Acme.Enabled = true if c.Opts.ACMEIssuerName != "" { certIssuer.Acme.Name = c.Opts.ACMEIssuerName } + if c.Opts.ACMEEmail != "" { certIssuer.Acme.Email = c.Opts.ACMEEmail } + if c.Opts.ACMEServer != "" { certIssuer.Acme.Server = c.Opts.ACMEServer } @@ -486,6 +510,7 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.ACMEEABKeyID != "" { certIssuer.Acme.EABKeyID = c.Opts.ACMEEABKeyID } + if c.Opts.ACMEEABMacKey != "" { vault.SetSecret(files.SecretEntry{Name: files.SecretAcmeEabMacKey, Fields: &files.SecretFields{Password: c.Opts.ACMEEABMacKey}}) } @@ -502,15 +527,19 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if c.Opts.CodesphereDomain != "" { config.Codesphere.Domain = c.Opts.CodesphereDomain } + if c.Opts.CodespherePublicIP != "" { config.Codesphere.PublicIP = c.Opts.CodespherePublicIP } + if c.Opts.CodesphereWorkspaceHostingBaseDomain != "" { config.Codesphere.WorkspaceHostingBaseDomain = c.Opts.CodesphereWorkspaceHostingBaseDomain } + if c.Opts.CodesphereCustomDomainsCNameBaseDomain != "" { config.Codesphere.CustomDomains = files.CustomDomainsConfig{CNameBaseDomain: c.Opts.CodesphereCustomDomainsCNameBaseDomain} } + if len(c.Opts.CodesphereDNSServers) > 0 { config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers } @@ -519,6 +548,7 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if config.Codesphere.WorkspaceImages == nil { config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} } + config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{ BomRef: c.Opts.CodesphereWorkspaceImageBomRef, } @@ -528,8 +558,10 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if config.Codesphere.OpenBao == nil { config.Codesphere.OpenBao = &files.OpenBaoConfig{} } + config.Codesphere.OpenBao.URI = c.Opts.CodesphereOpenBaoUri config.Codesphere.OpenBao.Engine = c.Opts.CodesphereOpenBaoEngine + config.Codesphere.OpenBao.User = c.Opts.CodesphereOpenBaoUser if c.Opts.CodesphereOpenBaoPassword != "" { vault.SetSecret(files.SecretEntry{Name: files.SecretOpenBaoPassword, Fields: &files.SecretFields{Password: c.Opts.CodesphereOpenBaoPassword}}) @@ -571,8 +603,10 @@ func determinePostgresServerConfig(postgresMode, postgresServer, primaryHostname if postgresServer == "" { return primaryHostname, serverAddress } + if postgresMode == "install" { return postgresServer, "" } + return primaryHostname, postgresServer } diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 1be47680..ce3cb8cf 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -87,13 +87,17 @@ var _ = Describe("Interactive profile usage", func() { It("should generate valid config files with profile", func() { configFile, err := os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(configFile.Name()) }() + err = configFile.Close() Expect(err).NotTo(HaveOccurred()) vaultFile, err := os.CreateTemp("", "vault-*.yaml") Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(vaultFile.Name()) }() + err = vaultFile.Close() Expect(err).NotTo(HaveOccurred()) @@ -173,13 +177,17 @@ var _ = Describe("Interactive profile usage", func() { It("should still fail in non-interactive mode with validation errors", func() { configFile, err := os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(configFile.Name()) }() + err = configFile.Close() Expect(err).NotTo(HaveOccurred()) vaultFile, err := os.CreateTemp("", "vault-*.yaml") Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(vaultFile.Name()) }() + err = vaultFile.Close() Expect(err).NotTo(HaveOccurred()) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index d098b641..b5c2c45f 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -29,6 +29,7 @@ var _ = Describe("ApplyProfile", func() { Expect(err).To(HaveOccurred()) } else { Expect(err).NotTo(HaveOccurred()) + config := icg.GetInstallConfig() Expect(config.Datacenter.Name).To(Equal(checkDatacenterName)) } @@ -47,6 +48,7 @@ var _ = Describe("ApplyProfile", func() { err := icg.ApplyProfile("dev") Expect(err).NotTo(HaveOccurred()) + config := icg.GetInstallConfig() Expect(config.Datacenter.ID).To(Equal(1)) Expect(config.Datacenter.Name).To(Equal("dev")) @@ -105,6 +107,7 @@ var _ = Describe("ValidateConfig", func() { BeforeEach(func() { var err error + configFile, err = os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -228,6 +231,7 @@ codesphere: err = vaultFile.Close() Expect(err).NotTo(HaveOccurred()) + tempDir := GinkgoT().TempDir() ageKeyPath := filepath.Join(tempDir, "age_key.txt") plaintextVaultPath := filepath.Join(tempDir, "prod.vault.plain.yaml") @@ -236,13 +240,16 @@ codesphere: recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") + Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) DeferCleanup(func() { if hadPreviousAgeKeyFile { Expect(os.Setenv("SOPS_AGE_KEY_FILE", previousAgeKeyFile)).To(Succeed()) return } + Expect(os.Unsetenv("SOPS_AGE_KEY_FILE")).To(Succeed()) }) diff --git a/cli/cmd/install_openbao.go b/cli/cmd/install_openbao.go index 262a22ee..c29ebdb3 100644 --- a/cli/cmd/install_openbao.go +++ b/cli/cmd/install_openbao.go @@ -55,6 +55,7 @@ func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("determining user config directory: %w", err) } + fallbackDir := filepath.Join(configDir, "sops", "age") // Pass --age-key-file explicitly so ResolveAgeKey prefers it without @@ -97,13 +98,16 @@ func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { log.Print("Type 'yes' to continue: ") reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("failed to read confirmation: %w", err) } + if strings.TrimSpace(strings.ToLower(input)) != "yes" { return fmt.Errorf("installation cancelled: confirmation not given (type 'yes' or pass --yes to proceed)") } + return nil } @@ -160,8 +164,10 @@ func validateOpenBaoPrereqs() error { if _, err := exec.LookPath("sops"); err != nil { return fmt.Errorf("sops not found in PATH — install from https://github.com/getsops/sops") } + if _, err := exec.LookPath("age-keygen"); err != nil { return fmt.Errorf("age-keygen not found in PATH — install from https://github.com/FiloSottile/age") } + return nil } diff --git a/cli/cmd/k0s/download_k0s.go b/cli/cmd/k0s/download_k0s.go index cff80a7f..f080fc44 100644 --- a/cli/cmd/k0s/download_k0s.go +++ b/cli/cmd/k0s/download_k0s.go @@ -74,6 +74,7 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { version := c.Opts.Version + var err error if version == "" { version, err = k0s.GetLatestVersion() diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index bdb3fc4a..8a213c06 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -52,6 +52,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "" // Test auto-version detection + mockK0sManager.EXPECT().GetLatestVersion().Return("", errors.New("network error")) err := c.DownloadK0s(mockK0sManager) @@ -64,6 +65,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("", errors.New("download failed")) err := c.DownloadK0s(mockK0sManager) @@ -76,6 +78,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) @@ -88,6 +91,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "" // Test auto-version detection c.Opts.Force = true c.Opts.Quiet = true + mockK0sManager.EXPECT().GetLatestVersion().Return("v1.29.1+k0s.0", nil) mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true).Return("/test/workdir/k0s", nil) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 09b95f15..29c496e8 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -163,12 +163,15 @@ func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, e k0sVersion := c.Opts.Version if k0sVersion == "" { var err error + k0sVersion, err = k0s.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest k0s version: %w", err) } + log.Printf("Using latest k0s version: %s", k0sVersion) } + return k0sVersion, nil } @@ -181,6 +184,7 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal if err := pm.ExtractDependency(defaultK0sPath, c.Opts.Force); err != nil { return "", fmt.Errorf("failed to extract k0s from package: %w", err) } + return pm.GetDependencyPath(defaultK0sPath), nil } @@ -188,20 +192,24 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } + return k0sBinaryPath, nil } func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, error) { log.Println("Downloading k0sctl...") + k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, c.Opts.Force, false) if err != nil { return "", fmt.Errorf("failed to download k0sctl: %w", err) } + return k0sctlPath, nil } func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string) (string, error) { log.Println("Generating k0sctl configuration from install-config...") + k0sctlConfig, err := installer.GenerateK0sctlConfig(config, k0sVersion, c.Opts.SSHKeyPath, k0sBinaryPath) if err != nil { return "", fmt.Errorf("failed to generate k0sctl config: %w", err) @@ -218,11 +226,13 @@ func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersio } log.Printf("Generated k0sctl configuration at %s", k0sctlConfigPath) + return k0sctlConfigPath, nil } func (c *InstallK0sCmd) deployK0sCluster(k0sctl installer.K0sctlManager, k0sctlPath string, k0sctlConfigPath string) error { log.Println("Applying k0sctl configuration to deploy k0s cluster...") + if err := k0sctl.Apply(k0sctlConfigPath, k0sctlPath, c.Opts.Force); err != nil { return fmt.Errorf("failed to apply k0sctl config: %w", err) } @@ -235,10 +245,12 @@ func (c *InstallK0sCmd) deployK0sCluster(k0sctl installer.K0sctlManager, k0sctlP func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0sctlConfigPath, k0sctlPath string) error { log.Println("Retrieving kubeconfig from k0sctl for vault...") + kubeconfigContent, err := k0sctl.GetKubeconfig(k0sctlConfigPath, k0sctlPath) if err != nil { return fmt.Errorf("failed to retrieve kubeconfig from k0sctl: %w", err) } + kubeconfigContent = strings.TrimRight(kubeconfigContent, "\n\r") vault, err := c.loadOrCreateVault() @@ -271,6 +283,7 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 } log.Printf("Saved kubeconfig to %s", c.Opts.Vault) + return nil } @@ -295,6 +308,7 @@ func (c *InstallK0sCmd) writeEncryptedVault(vaultYAML []byte) error { } _ = c.FileWriter.Remove(tmpPath) + return nil } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 0c46fe43..b9bd07f9 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -64,6 +64,7 @@ var _ = Describe("InstallK0sCmd", func() { Context("RunE method", func() { It("fails when install-config is not provided", func() { c.Opts.InstallConfig = "" + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Times(2) mockFileWriter.EXPECT().MkdirAll("/test/workdir", os.FileMode(0755)).Return(nil) @@ -85,7 +86,9 @@ var _ = Describe("InstallK0sCmd", func() { mockPM = installer.NewMockPackageManager(GinkgoT()) mockK0s = installer.NewMockK0sManager(GinkgoT()) mockK0sctl = installer.NewMockK0sctlManager(GinkgoT()) + var err error + tempDir, err = os.MkdirTemp("", "install-k0s-test-*") Expect(err).NotTo(HaveOccurred()) }) @@ -94,6 +97,7 @@ var _ = Describe("InstallK0sCmd", func() { mockPM.AssertExpectations(GinkgoT()) mockK0s.AssertExpectations(GinkgoT()) mockK0sctl.AssertExpectations(GinkgoT()) + if tempDir != "" { _ = os.RemoveAll(tempDir) } @@ -134,6 +138,7 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred()) err = os.WriteFile(configPath, configData, 0644) Expect(err).NotTo(HaveOccurred()) + return configPath } @@ -254,6 +259,7 @@ var _ = Describe("InstallK0sCmd", func() { if !testutil.SopsAndAgeAvailable() { Skip("sops and age-keygen not available") } + c.FileWriter = intutil.NewFilesystemWriter() }) @@ -280,6 +286,7 @@ var _ = Describe("InstallK0sCmd", func() { loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config")) @@ -315,6 +322,7 @@ var _ = Describe("InstallK0sCmd", func() { vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := c.Opts.Vault + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -365,6 +373,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := c.Opts.Vault + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -380,6 +389,7 @@ var _ = Describe("InstallK0sCmd", func() { loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config\nnew: true")) @@ -407,6 +417,7 @@ var _ = Describe("InstallK0sCmd", func() { loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config")) @@ -455,6 +466,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -518,6 +530,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -582,6 +595,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) diff --git a/cli/cmd/list_api_keys.go b/cli/cmd/list_api_keys.go index 0a5930b7..4d82ccc7 100644 --- a/cli/cmd/list_api_keys.go +++ b/cli/cmd/list_api_keys.go @@ -22,12 +22,14 @@ type ListAPIKeysCmd struct { func (c *ListAPIKeysCmd) RunE(_ *cobra.Command, args []string) error { p := portal.NewPortalClient() + keys, err := p.ListAPIKeys() if err != nil { return fmt.Errorf("failed to list api keys: %w", err) } c.PrintKeysTable(keys) + return nil } diff --git a/cli/cmd/list_packages.go b/cli/cmd/list_packages.go index ad276476..f0c5622c 100644 --- a/cli/cmd/list_packages.go +++ b/cli/cmd/list_packages.go @@ -33,12 +33,14 @@ func (c *ListBuildsCmd) RunE(_ *cobra.Command, args []string) error { } p := portal.NewPortalClient() + packages, err := p.ListBuilds(portal.CodesphereProduct, c.Opts.Sort) if err != nil { return fmt.Errorf("failed to list codesphere packages: %w", err) } c.PrintPackagesTable(packages) + return nil } @@ -75,14 +77,17 @@ func (c *ListBuildsCmd) PrintPackagesTable(packages portal.Builds) { } artifacts := "" + for i, art := range build.Artifacts { if i > 0 { artifacts += ", " } + artifacts = artifacts + art.Filename } c.TableWriter.AppendRow(table.Row{int, build.Version, build.Date, build.Hash, artifacts}) } + c.TableWriter.Render() } diff --git a/cli/cmd/list_packages_test.go b/cli/cmd/list_packages_test.go index b0cb4671..9fcf5e52 100644 --- a/cli/cmd/list_packages_test.go +++ b/cli/cmd/list_packages_test.go @@ -17,13 +17,13 @@ import ( ) var _ = Describe("ListPackages", func() { - var ( mockTableWriter *util.MockTableWriter c cmd.ListBuildsCmd internal bool buildDate time.Time ) + JustBeforeEach(func() { mockTableWriter = util.NewMockTableWriter(GinkgoT()) c = cmd.ListBuildsCmd{ @@ -65,7 +65,6 @@ var _ = Describe("ListPackages", func() { }, }) }) - }) Context("Internal packages are included", func() { BeforeEach(func() { diff --git a/cli/cmd/pc_apps.go b/cli/cmd/pc_apps.go index c5a222b0..e07a76d9 100644 --- a/cli/cmd/pc_apps.go +++ b/cli/cmd/pc_apps.go @@ -41,6 +41,7 @@ func (c *InstallPCAppsCmd) RunE(cmd *cobra.Command, args []string) error { if err := clientgoscheme.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add kubernetes core scheme: %w", err) } + if err := argov1alpha1.AddToScheme(scheme); err != nil { return fmt.Errorf("failed to add ArgoCD scheme: %w", err) } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index c6d7114a..d447d8c0 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -33,8 +33,8 @@ func GetRootCmd() *cobra.Command { log.Println("Attempting to upgrade to the new format...") portalClient := portal.NewPortalClient() - keyId, err := portalClient.GetApiKeyId(apiKey) + keyId, err := portalClient.GetApiKeyId(apiKey) if err != nil { log.Printf("Error: Failed to upgrade old API key: %v\n", err) return @@ -46,6 +46,7 @@ func GetRootCmd() *cobra.Command { log.Printf("Error: Failed to set environment variable: %v\n", err) return } + opts.OmsPortalApiKey = newApiKey log.Println("Please update your environment variable:") diff --git a/cli/cmd/template_config.go b/cli/cmd/template_config.go index 48d1010c..d6a21c2d 100644 --- a/cli/cmd/template_config.go +++ b/cli/cmd/template_config.go @@ -96,6 +96,7 @@ func (c *TemplateConfigCmd) Render() ([]byte, error) { } store := vault.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.AgeKey) + rendered, err := configtemplating.RenderInstallConfigTemplate(data, store) if err != nil { return nil, fmt.Errorf("failed to render config template: %w", err) diff --git a/cli/cmd/template_config_test.go b/cli/cmd/template_config_test.go index 8e611f0a..f3afcf28 100644 --- a/cli/cmd/template_config_test.go +++ b/cli/cmd/template_config_test.go @@ -62,11 +62,13 @@ postgres: vaultYaml, err := testVault.Marshal() Expect(err).NotTo(HaveOccurred()) Expect(os.WriteFile(plaintextVaultPath, vaultYaml, 0600)).To(Succeed()) + recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) rootCmd := cmd.GetRootCmd() + var output bytes.Buffer rootCmd.SetOut(&output) rootCmd.SetErr(&output) diff --git a/cli/cmd/testutil/testutil.go b/cli/cmd/testutil/testutil.go index 42e65e93..11682f12 100644 --- a/cli/cmd/testutil/testutil.go +++ b/cli/cmd/testutil/testutil.go @@ -9,8 +9,10 @@ func SopsAndAgeAvailable() bool { if _, err := exec.LookPath("sops"); err != nil { return false } + if _, err := exec.LookPath("age-keygen"); err != nil { return false } + return true } diff --git a/cli/cmd/update_dockerfile.go b/cli/cmd/update_dockerfile.go index 601362ae..9000e394 100644 --- a/cli/cmd/update_dockerfile.go +++ b/cli/cmd/update_dockerfile.go @@ -113,6 +113,7 @@ func (c *UpdateDockerfileCmd) UpdateDockerfile(pm installer.PackageManager, im s defer intutil.CloseFileIgnoreError(dockerfileFile) dockerfileManager := intutil.NewDockerfileManager() + updatedContent, err := dockerfileManager.UpdateFromStatement(dockerfileFile, imageName) if err != nil { return fmt.Errorf("failed to update FROM statement: %w", err) diff --git a/cli/cmd/update_dockerfile_test.go b/cli/cmd/update_dockerfile_test.go index 0505197b..38051ee1 100644 --- a/cli/cmd/update_dockerfile_test.go +++ b/cli/cmd/update_dockerfile_test.go @@ -147,6 +147,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }) + _, err = tempFile.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) // Reset file position to beginning @@ -181,6 +182,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }) + _, err = tempFile.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) // Reset file position to beginning @@ -214,6 +216,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }) + _, err = tempFile.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) // Reset file position to beginning @@ -247,6 +250,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }) + _, err = tempFile.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) // Reset file position to beginning @@ -285,6 +289,7 @@ var _ = Describe("AddUpdateDockerfileCmd", func() { cmd.AddUpdateDockerfileCmd(parentCmd, globalOpts) var dockerfileCmd *cobra.Command + for _, c := range parentCmd.Commands() { if c.Use == "dockerfile" { dockerfileCmd = c diff --git a/cli/cmd/update_install_config.go b/cli/cmd/update_install_config.go index 26551adf..a77f5295 100644 --- a/cli/cmd/update_install_config.go +++ b/cli/cmd/update_install_config.go @@ -151,12 +151,14 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfigManager) error { log.Printf("Loading existing configuration from: %s\n", c.Opts.ConfigFile) + err := icg.LoadInstallConfigFromFile(c.Opts.ConfigFile) if err != nil { return fmt.Errorf("failed to load config file: %w", err) } log.Printf("Loading existing vault from: %s\n", c.Opts.VaultFile) + err = icg.LoadVaultFromFile(c.Opts.VaultFile) if err != nil { return fmt.Errorf("failed to load vault file: %w", err) @@ -175,6 +177,7 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig if tracker.HasChanges() { log.Println("\nRegenerating affected secrets and certificates...") + if err := c.regenerateSecrets(config, vault, tracker); err != nil { return fmt.Errorf("failed to regenerate secrets: %w", err) } @@ -217,11 +220,14 @@ func (c *UpdateInstallConfigCmd) applyPostgresUpdates(config *files.RootConfig, if c.Opts.PostgresPrimaryIP != "" && config.Postgres.Primary.IP != c.Opts.PostgresPrimaryIP { log.Printf("Updating PostgreSQL primary IP: %s -> %s\n", config.Postgres.Primary.IP, c.Opts.PostgresPrimaryIP) config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP + tracker.MarkPostgresPrimaryCertNeedsRegen() } + if primaryHostname != "" && config.Postgres.Primary.Hostname != primaryHostname { log.Printf("Updating PostgreSQL primary hostname: %s -> %s\n", config.Postgres.Primary.Hostname, primaryHostname) config.Postgres.Primary.Hostname = primaryHostname + tracker.MarkPostgresPrimaryCertNeedsRegen() } } @@ -232,11 +238,14 @@ func (c *UpdateInstallConfigCmd) applyPostgresUpdates(config *files.RootConfig, if c.Opts.PostgresReplicaIP != "" && config.Postgres.Replica.IP != c.Opts.PostgresReplicaIP { log.Printf("Updating PostgreSQL replica IP: %s -> %s\n", config.Postgres.Replica.IP, c.Opts.PostgresReplicaIP) config.Postgres.Replica.IP = c.Opts.PostgresReplicaIP + tracker.MarkPostgresReplicaCertNeedsRegen() } + if c.Opts.PostgresReplicaName != "" && config.Postgres.Replica.Name != c.Opts.PostgresReplicaName { log.Printf("Updating PostgreSQL replica name: %s -> %s\n", config.Postgres.Replica.Name, c.Opts.PostgresReplicaName) config.Postgres.Replica.Name = c.Opts.PostgresReplicaName + tracker.MarkPostgresReplicaCertNeedsRegen() } } @@ -280,6 +289,7 @@ func (c *UpdateInstallConfigCmd) applyClusterGatewayUpdates(config *files.RootCo if len(c.Opts.ClusterGatewayIPAddresses) > 0 { log.Printf("Updating cluster gateway IP addresses\n") + config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPAddresses } @@ -290,6 +300,7 @@ func (c *UpdateInstallConfigCmd) applyClusterGatewayUpdates(config *files.RootCo if len(c.Opts.ClusterPublicGatewayIPAddresses) > 0 { log.Printf("Updating cluster public gateway IP addresses\n") + config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPAddresses } } @@ -300,6 +311,7 @@ func (c *UpdateInstallConfigCmd) applyACMEUpdates(config *files.RootConfig, vaul } acmeChanged := false + certIssuer := config.Codesphere.EnsureCertIssuer() if certIssuer.Acme == nil { certIssuer.Acme = &files.ACMEConfig{} @@ -307,12 +319,14 @@ func (c *UpdateInstallConfigCmd) applyACMEUpdates(config *files.RootConfig, vaul if certIssuer.Type != files.CertIssuerTypeACME { log.Printf("Setting cert issuer type to ACME\n") + certIssuer.Type = files.CertIssuerTypeACME acmeChanged = true } if !certIssuer.Acme.Enabled { log.Printf("Enabling ACME certificate issuer\n") + certIssuer.Acme.Enabled = true acmeChanged = true } @@ -346,9 +360,11 @@ func (c *UpdateInstallConfigCmd) applyACMEUpdates(config *files.RootConfig, vaul if s := vault.GetSecret(files.SecretAcmeEabMacKey); s != nil && s.Fields != nil { currentKey = s.Fields.Password } + if currentKey != c.Opts.ACMEEABMacKey { log.Printf("Updating ACME EAB MAC key\n") vault.SetSecret(files.SecretEntry{Name: files.SecretAcmeEabMacKey, Fields: &files.SecretFields{Password: c.Opts.ACMEEABMacKey}}) + acmeChanged = true } } @@ -358,6 +374,7 @@ func (c *UpdateInstallConfigCmd) applyACMEUpdates(config *files.RootConfig, vaul if certIssuer.Acme.Solver.DNS01 == nil { certIssuer.Acme.Solver.DNS01 = &files.ACMEDNS01Solver{} } + if certIssuer.Acme.Solver.DNS01.Provider != c.Opts.ACMEDNS01Provider { log.Printf("Updating ACME DNS-01 provider: %s -> %s\n", certIssuer.Acme.Solver.DNS01.Provider, c.Opts.ACMEDNS01Provider) @@ -394,6 +411,7 @@ func (c *UpdateInstallConfigCmd) applyCodesphereUpdates(config *files.RootConfig if len(c.Opts.CodesphereDNSServers) > 0 { log.Printf("Updating DNS servers\n") + config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers } } @@ -401,10 +419,12 @@ func (c *UpdateInstallConfigCmd) applyCodesphereUpdates(config *files.RootConfig func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vault *files.InstallVault, tracker *SecretDependencyTracker) error { if tracker.NeedsPostgresPrimaryCertRegen() { log.Println(" - Regenerating PostgreSQL primary server certificate...") + caSecret := vault.GetSecret(files.SecretPostgresCaKeyPem) if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + primaryKeyPEM, primaryCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, config.Postgres.CACertPem, @@ -414,16 +434,20 @@ func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vau if err != nil { return fmt.Errorf("failed to regenerate primary PostgreSQL certificate: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: primaryKeyPEM}}) + config.Postgres.Primary.SSLConfig.ServerCertPem = primaryCertPEM } if tracker.NeedsPostgresReplicaCertRegen() && config.Postgres.Replica != nil { log.Println(" - Regenerating PostgreSQL replica server certificate...") + caSecret := vault.GetSecret(files.SecretPostgresCaKeyPem) if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + replicaKeyPEM, replicaCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, config.Postgres.CACertPem, @@ -433,7 +457,9 @@ func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vau if err != nil { return fmt.Errorf("failed to regenerate replica PostgreSQL certificate: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresReplicaServerKeyPem, File: &files.SecretFile{Name: "replica.key", Content: replicaKeyPEM}}) + config.Postgres.Replica.SSLConfig.ServerCertPem = replicaCertPEM } @@ -447,12 +473,15 @@ func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTr if tracker.HasChanges() { log.Println("\nRegenerated secrets:") + if tracker.NeedsPostgresPrimaryCertRegen() { log.Println(" ✓ PostgreSQL primary server certificate") } + if tracker.NeedsPostgresReplicaCertRegen() { log.Println(" ✓ PostgreSQL replica server certificate") } + if tracker.ACMEConfigChanged() { log.Println(" ✓ ACME configuration updated") } diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 8d078373..9953bc49 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -26,6 +26,7 @@ func quoteYAMLString(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, "\n", `\n`) + return `"` + s + `"` } @@ -47,6 +48,7 @@ var _ = Describe("UpdateInstallConfig", func() { } var err error + configFile, err = os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -197,13 +199,16 @@ codesphere: recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") + Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) DeferCleanup(func() { if hadPreviousAgeKeyFile { Expect(os.Setenv("SOPS_AGE_KEY_FILE", previousAgeKeyFile)).To(Succeed()) return } + Expect(os.Unsetenv("SOPS_AGE_KEY_FILE")).To(Succeed()) }) @@ -241,6 +246,7 @@ codesphere: encrypted, err := vault.IsSOPSEncryptedFile(vaultFile.Name()) Expect(err).NotTo(HaveOccurred()) Expect(encrypted).To(BeTrue()) + updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") Expect(err).NotTo(HaveOccurred()) Expect(updatedVault.GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) @@ -390,6 +396,7 @@ codesphere: initialVault := &files.InstallVault{} err := initialVault.Unmarshal(initialVaultContent) Expect(err).NotTo(HaveOccurred()) + initialSecrets := make(map[string]files.SecretEntry) for _, secret := range initialVault.Secrets { initialSecrets[secret.Name] = secret @@ -406,13 +413,17 @@ codesphere: // Verify all initial secrets are still present with the same values for secretName, initialSecret := range initialSecrets { found := false + for _, secret := range updatedVault.Secrets { if secret.Name == secretName { found = true + Expect(secret.Fields).To(Equal(initialSecret.Fields), "Secret %s values should be preserved", secretName) + break } } + Expect(found).To(BeTrue(), "Initial secret %s should be preserved after update", secretName) } }) @@ -421,6 +432,7 @@ codesphere: initialVault := &files.InstallVault{} err := initialVault.Unmarshal(initialVaultContent) Expect(err).NotTo(HaveOccurred()) + initialSecrets := make(map[string]files.SecretEntry) for _, secret := range initialVault.Secrets { initialSecrets[secret.Name] = secret @@ -439,20 +451,25 @@ codesphere: "postgresPassword": true, "postgresReplicaPassword": true, } + for secretName, initialSecret := range initialSecrets { found := false + for _, secret := range updatedVault.Secrets { if secret.Name == secretName { found = true + Expect(secret.Fields).To(Equal(initialSecret.Fields), "Secret %s values should be preserved", secretName) if passwordSecrets[secretName] { Expect(secret.Fields).NotTo(BeNil(), "Secret %s should have fields", secretName) Expect(secret.Fields.Password).NotTo(BeEmpty(), "Password for %s should not be empty", secretName) } + break } } + Expect(found).To(BeTrue(), "Initial secret %s should be preserved after certificate regeneration", secretName) } }) diff --git a/cli/cmd/update_oms.go b/cli/cmd/update_oms.go index e6296ccb..d5f334eb 100644 --- a/cli/cmd/update_oms.go +++ b/cli/cmd/update_oms.go @@ -28,6 +28,7 @@ func (s *OMSSelfUpdater) Update(ctx context.Context, current string, repo selfup if err != nil { return current, "", err } + if !found { return current, "", fmt.Errorf("latest version could not be found from GitHub repository") } diff --git a/cli/cmd/update_oms_test.go b/cli/cmd/update_oms_test.go index 3b64f3fb..b2d5c5a2 100644 --- a/cli/cmd/update_oms_test.go +++ b/cli/cmd/update_oms_test.go @@ -44,6 +44,7 @@ var _ = Describe("Update", func() { mockVersion.EXPECT().Version().Return(v) mockGit.On("Update", mock.Anything, v, selfupdate.ParseSlug(cmd.GitHubRepo)).Return(v, "", nil) + err := c.SelfUpdate() Expect(err).NotTo(HaveOccurred()) mockGit.AssertExpectations(GinkgoT()) @@ -52,8 +53,10 @@ var _ = Describe("Update", func() { It("Updates when a newer version exists", func() { current := "0.0.0" latest := "0.0.42" + mockVersion.EXPECT().Version().Return(current) mockGit.On("Update", mock.Anything, current, selfupdate.ParseSlug(cmd.GitHubRepo)).Return(latest, "notes", nil) + err := c.SelfUpdate() Expect(err).NotTo(HaveOccurred()) mockGit.AssertExpectations(GinkgoT()) diff --git a/cli/cmd/util/cmd_util.go b/cli/cmd/util/cmd_util.go index 120706bd..709f33d5 100644 --- a/cli/cmd/util/cmd_util.go +++ b/cli/cmd/util/cmd_util.go @@ -11,6 +11,7 @@ func AddCmd(parent *cobra.Command, cmd *cobra.Command) { if cmd.Args == nil { cmd.Args = parent.Args } + parent.AddCommand(cmd) } diff --git a/cli/cmd/util/example_helpers.go b/cli/cmd/util/example_helpers.go index 4bead073..db5b4a4d 100644 --- a/cli/cmd/util/example_helpers.go +++ b/cli/cmd/util/example_helpers.go @@ -14,25 +14,32 @@ import ( // it prefixes commands with a stable binary name (e.g. "oms") instead of temporary go-build paths func FormatExamples(cmdName string, examples []io.Example) string { var b strings.Builder + for i, ex := range examples { if ex.Desc != "" { b.WriteString("# ") b.WriteString(ex.Desc) b.WriteString("\n") } + b.WriteString("$ ") + build := version.Build{} b.WriteString(build.BinName()) b.WriteString(" ") b.WriteString(cmdName) + if ex.Cmd != "" { b.WriteString(" ") b.WriteString(ex.Cmd) } + b.WriteString("\n") + if i < len(examples)-1 { b.WriteString("\n") } } + return b.String() } diff --git a/hack/gendocs/main.go b/hack/gendocs/main.go index 624ee095..889f3f84 100644 --- a/hack/gendocs/main.go +++ b/hack/gendocs/main.go @@ -19,6 +19,7 @@ func main() { identity := func(s string) string { return s } emptyStr := func(s string) string { return "" } + err := doc.GenMarkdownTreeCustom(root, "docs", emptyStr, identity) if err != nil { log.Fatal(err) diff --git a/internal/bootstrap/bootstrap_stepper.go b/internal/bootstrap/bootstrap_stepper.go index 7e05ec61..1925ee6a 100644 --- a/internal/bootstrap/bootstrap_stepper.go +++ b/internal/bootstrap/bootstrap_stepper.go @@ -39,6 +39,7 @@ func (b *StepLogger) Step(name string, fn func() error) error { b.currentStep = name fmt.Printf("%s%s%s...", LINE_RESET, RESET_TEXT, name) + err := fn() if err != nil { fmt.Printf("%s%s%s failed: %v%s\n", LINE_RESET, RED_TEXT, name, err, RESET_TEXT) @@ -46,8 +47,10 @@ func (b *StepLogger) Step(name string, fn func() error) error { for i := 0; i < b.subSteps; i++ { fmt.Printf("%s", MOVE_UP_CLEAR_LINE) } + fmt.Printf("%s%s%s %s✓%s\n", LINE_RESET, RESET_TEXT, name, GREEN_TEXT, RESET_TEXT) } + return err } @@ -60,12 +63,14 @@ func (b *StepLogger) Substep(name string, fn func() error) error { b.currentStep = name fmt.Printf("%s%s %s...", LINE_RESET, RESET_TEXT, name) + err := fn() if err != nil { fmt.Printf("%s%s %s failed: %v%s\n", LINE_RESET, RED_TEXT, name, err, RESET_TEXT) } else { fmt.Printf("%s%s %s %s✓%s\n", LINE_RESET, RESET_TEXT, name, GREEN_TEXT, RESET_TEXT) } + return err } @@ -85,5 +90,6 @@ func (b *StepLogger) Logf(message string, args ...interface{}) { } b.subSteps += 1 + fmt.Printf("%s%s %s%s\n", LINE_RESET, RESET_TEXT, fmt.Sprintf(message, args...), RESET_TEXT) } diff --git a/internal/bootstrap/gcp/cleanup.go b/internal/bootstrap/gcp/cleanup.go index f30e59a7..c29a464b 100644 --- a/internal/bootstrap/gcp/cleanup.go +++ b/internal/bootstrap/gcp/cleanup.go @@ -56,20 +56,25 @@ func NewCleanupExecutor(opts *CleanupOpts, deps *CleanupDeps) (*CleanupExecutor, if err := exec.loadInfraFileIfNeeded(); err != nil { return nil, err } + if err := exec.resolveProjectID(); err != nil { return nil, err } + exec.resolveDNSSettings() + return exec, nil } // loadInfraFileIfNeeded loads the infra file when the project ID or DNS info is missing. func (e *CleanupExecutor) loadInfraFileIfNeeded() error { missingDNSProjectID := e.Opts.DNSProjectID == "" + missingDNSInfo := missingDNSProjectID if !e.Opts.SkipDNSCleanup { missingDNSInfo = missingDNSProjectID || e.Opts.BaseDomain == "" || e.Opts.DNSZoneName == "" } + if e.ProjectID != "" && !missingDNSInfo { return nil } @@ -79,13 +84,16 @@ func (e *CleanupExecutor) loadInfraFileIfNeeded() error { if e.ProjectID == "" { return fmt.Errorf("failed to load infra file: %w", err) } + log.Printf("Warning: %v", err) + return nil } if infraEnv.ProjectID != "" { e.InfraEnv = infraEnv e.InfraFileLoaded = true + return nil } @@ -104,6 +112,7 @@ func (e *CleanupExecutor) resolveProjectID() error { e.InfraEnv = CodesphereEnvironment{} e.InfraFileLoaded = false } + return nil } @@ -113,6 +122,7 @@ func (e *CleanupExecutor) resolveProjectID() error { e.ProjectID = e.InfraEnv.ProjectID log.Printf("Using project ID from infra file: %s", e.ProjectID) + return nil } @@ -122,14 +132,17 @@ func (e *CleanupExecutor) resolveDNSSettings() { if e.BaseDomain == "" { e.BaseDomain = e.InfraEnv.BaseDomain } + e.DNSZoneName = e.Opts.DNSZoneName if e.DNSZoneName == "" { e.DNSZoneName = e.InfraEnv.DNSZoneName } + e.DNSProjectID = e.Opts.DNSProjectID if e.DNSProjectID == "" { e.DNSProjectID = e.InfraEnv.DNSProjectID } + if e.DNSProjectID == "" { e.DNSProjectID = e.ProjectID } @@ -147,6 +160,7 @@ func (e *CleanupExecutor) VerifyAndConfirm() error { if err != nil { return fmt.Errorf("failed to verify project: %w", err) } + if !isOMSManaged { return fmt.Errorf("project %s was not bootstrapped by OMS (missing 'oms-managed' label). Use --force to override this check", e.ProjectID) } @@ -160,13 +174,16 @@ func (e *CleanupExecutor) confirmDeletion() error { log.Println("Type the project ID to confirm deletion: ") reader := bufio.NewReader(e.Deps.ConfirmReader) + confirmation, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } + if strings.TrimSpace(confirmation) != e.ProjectID { return fmt.Errorf("confirmation did not match project ID, aborting cleanup") } + return nil } @@ -176,10 +193,12 @@ func (e *CleanupExecutor) CleanupDNSRecords() error { if e.Opts.SkipDNSCleanup { return nil } + if e.BaseDomain == "" || e.DNSZoneName == "" { log.Printf("Skipping DNS cleanup: missing base domain or DNS zone name (provide --base-domain/--dns-zone-name or use --skip-dns-cleanup)") return nil } + return e.Deps.GCPClient.DeleteDNSRecordSets(e.DNSProjectID, e.DNSZoneName, e.BaseDomain) } @@ -189,6 +208,7 @@ func (e *CleanupExecutor) RemoveDNSIAMBinding() error { if e.DNSProjectID == "" || e.DNSProjectID == e.ProjectID { return nil } + return e.Deps.GCPClient.RemoveIAMRoleBinding(e.DNSProjectID, "cloud-controller", e.ProjectID, []string{"roles/dns.admin"}) } @@ -202,9 +222,11 @@ func (e *CleanupExecutor) RemoveLocalInfraFile() { if !e.InfraFileLoaded || e.InfraEnv.ProjectID != e.ProjectID { return } + if err := e.Deps.FileIO.Remove(e.Deps.InfraFilePath); err != nil { log.Printf("Warning: failed to remove local infra file: %v", err) return } + log.Printf("Removed local infra file: %s", e.Deps.InfraFilePath) } diff --git a/internal/bootstrap/gcp/errors.go b/internal/bootstrap/gcp/errors.go index 1c6f44e6..8e38f05a 100644 --- a/internal/bootstrap/gcp/errors.go +++ b/internal/bootstrap/gcp/errors.go @@ -18,13 +18,16 @@ func IsNotFoundError(err error) bool { if err == nil { return false } + if status.Code(err) == codes.NotFound { return true } + var apiErr *googleapi.Error if errors.As(err, &apiErr) { return apiErr.Code == 404 } + return false } @@ -33,10 +36,13 @@ func IsSpotCapacityError(err error) bool { if err == nil { return false } + if status.Code(err) == codes.ResourceExhausted { return true } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "zone_resource_pool_exhausted") || strings.Contains(errStr, "unsupported_operation") || strings.Contains(errStr, "stockout") || @@ -48,5 +54,6 @@ func IsAlreadyExistsError(err error) bool { if err == nil { return false } + return status.Code(err) == codes.AlreadyExists || strings.Contains(err.Error(), "already exists") } diff --git a/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index a7b7fc66..fabbfde3 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -43,6 +43,7 @@ func (b *GCPBootstrapper) validateVMProvisioningOptions() error { if b.Env.SpotVMs && b.Env.Preemptible { return fmt.Errorf("cannot specify both --spot-vms and --preemptible flags; use --spot-vms for the newer spot VM model") } + return nil } @@ -64,14 +65,17 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { wg.Add(1) go func(vm VMDef) { defer wg.Done() + result, err := b.ensureVM(vm, b.Env.RootDiskSize, logCh) if err != nil { errCh <- err return } + resultCh <- result }(vm) } + wg.Wait() close(errCh) @@ -86,6 +90,7 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { for err := range errCh { errs = append(errs, err) } + if len(errs) > 0 { return fmt.Errorf("error ensuring compute instances: %w", errors.Join(errs...)) } @@ -95,6 +100,7 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { NodeClient: b.NodeClient, FileIO: b.fw, } + for result := range resultCh { switch result.vmType { case "jumpbox": @@ -147,6 +153,7 @@ func (b *GCPBootstrapper) ensureVM(vm VMDef, rootDiskSize int64, logCh chan<- st if err != nil { return vmResult{}, err } + if err := b.CreateInstanceWithFallback(projectID, zone, instance, vm.Name, logCh); err != nil { return vmResult{}, err } @@ -158,6 +165,7 @@ func (b *GCPBootstrapper) ensureVM(vm VMDef, rootDiskSize int64, logCh chan<- st } internalIP, externalIP := ExtractInstanceIPs(readyInstance) + return vmResult{ vmType: vm.Tags[0], name: vm.Name, @@ -201,8 +209,10 @@ func (b *GCPBootstrapper) buildInstanceSpec(vm VMDef, rootDiskSize int64) (*comp } sshKeys := "" + if b.Env.GitHubPAT != "" && b.Env.GitHubTeamOrg != "" && b.Env.GitHubTeamSlug != "" { var err error + sshKeys, err = github.GetSSHKeysFromGitHubTeam(b.GitHubClient, b.Env.GitHubTeamOrg, b.Env.GitHubTeamSlug) if err != nil { return nil, fmt.Errorf("failed to get SSH keys from GitHub team: %w", err) @@ -267,6 +277,7 @@ func ExtractInstanceIPs(inst *computepb.Instance) (internalIP, externalIP string externalIP = inst.GetNetworkInterfaces()[0].GetAccessConfigs()[0].GetNatIP() } } + return } @@ -276,13 +287,16 @@ func IsInstanceReady(inst *computepb.Instance, needsExternalIP bool) bool { if inst.GetStatus() != "RUNNING" || len(inst.GetNetworkInterfaces()) == 0 { return false } + ni := inst.GetNetworkInterfaces()[0] if ni.GetNetworkIP() == "" { return false } + if needsExternalIP && (len(ni.GetAccessConfigs()) == 0 || ni.GetAccessConfigs()[0].GetNatIP() == "") { return false } + return true } @@ -296,6 +310,7 @@ func (b *GCPBootstrapper) BuildSchedulingConfig() *computepb.Scheduling { InstanceTerminationAction: protoString("STOP"), } } + if b.Env.Preemptible { return &computepb.Scheduling{ Preemptible: protoBool(true), @@ -319,11 +334,14 @@ func (b *GCPBootstrapper) CreateInstanceWithFallback(projectID, zone string, ins if b.Env.SpotVMs && IsSpotCapacityError(err) { logCh <- fmt.Sprintf("Spot capacity unavailable for %s, falling back to standard VM", vmName) + instance.Scheduling = &computepb.Scheduling{} + err = b.GCPClient.CreateInstance(projectID, zone, instance) if err != nil && !IsAlreadyExistsError(err) { return fmt.Errorf("failed to create instance %s (fallback to standard VM): %w", vmName, err) } + return nil } @@ -345,8 +363,10 @@ func (b *GCPBootstrapper) waitForInstanceRunning(projectID, zone, name string, n if attempt < maxAttempts-1 { b.Time.Sleep(pollInterval) } + continue } + return nil, fmt.Errorf("failed to poll instance %s: %w", name, err) } @@ -358,6 +378,7 @@ func (b *GCPBootstrapper) waitForInstanceRunning(projectID, zone, name string, n b.Time.Sleep(pollInterval) } } + return nil, fmt.Errorf("timed out waiting for instance %s to be RUNNING with IPs assigned after %s", name, pollInterval*time.Duration(maxAttempts)) } @@ -369,6 +390,7 @@ func findVMDef(name string) *VMDef { return &vm } } + return nil } @@ -378,6 +400,7 @@ func validVMNames() []string { for i, vm := range vmDefs { names[i] = vm.Name } + return names } @@ -396,6 +419,7 @@ func (b *GCPBootstrapper) RestartVM(name string) error { if IsNotFoundError(err) { return fmt.Errorf("instance %s does not exist in project %s / zone %s; did you run bootstrap first?", name, projectID, zone) } + return fmt.Errorf("failed to get instance %s: %w", name, err) } @@ -405,6 +429,7 @@ func (b *GCPBootstrapper) RestartVM(name string) error { return nil case "TERMINATED", "STOPPED": log.Printf("Starting stopped instance %s...", name) + if err := b.GCPClient.StartInstance(projectID, zone, name); err != nil { return fmt.Errorf("failed to start instance %s: %w", name, err) } @@ -421,34 +446,41 @@ func (b *GCPBootstrapper) RestartVM(name string) error { internalIP, externalIP := ExtractInstanceIPs(readyInstance) log.Printf("Instance %s is now running (internal=%s, external=%s)", name, internalIP, externalIP) + return nil } // RestartVMs restarts all stopped or terminated VMs defined in vmDefs. func (b *GCPBootstrapper) RestartVMs() error { var errs []error + for _, vm := range vmDefs { if err := b.RestartVM(vm.Name); err != nil { errs = append(errs, err) } } + if len(errs) > 0 { return fmt.Errorf("errors restarting VMs: %w", errors.Join(errs...)) } + return nil } // ReadSSHKey reads an SSH key file, expanding ~ in the path func (b *GCPBootstrapper) ReadSSHKey(path string) (string, error) { realPath := util.ExpandPath(path) + data, err := b.fw.ReadFile(realPath) if err != nil { return "", fmt.Errorf("error reading SSH key from %s: %w", realPath, err) } + key := strings.TrimSpace(string(data)) if key == "" { return "", fmt.Errorf("SSH key at %s is empty", realPath) } + return key, nil } diff --git a/internal/bootstrap/gcp/gce_test.go b/internal/bootstrap/gcp/gce_test.go index 4d6a1f5f..b4130663 100644 --- a/internal/bootstrap/gcp/gce_test.go +++ b/internal/bootstrap/gcp/gce_test.go @@ -22,7 +22,6 @@ import ( ) var _ = Describe("GCE", func() { - Describe("IsNotFoundError", func() { Context("when error is nil", func() { It("should return false", func() { @@ -296,6 +295,7 @@ var _ = Describe("GCE", func() { DescribeTable("falls back to standard VM on capacity errors", func(capacityErr error) { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything).Return(capacityErr).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything).Return(nil).Once() @@ -311,6 +311,7 @@ var _ = Describe("GCE", func() { It("clears scheduling config on fallback", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.MatchedBy(func(inst *computepb.Instance) bool { @@ -324,6 +325,7 @@ var _ = Describe("GCE", func() { It("returns error with context when fallback also fails", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). @@ -337,6 +339,7 @@ var _ = Describe("GCE", func() { It("does NOT fall back on non-capacity errors", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("permission denied")).Once() @@ -349,6 +352,7 @@ var _ = Describe("GCE", func() { It("succeeds when fallback retry returns AlreadyExists", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(grpcstatus.Errorf(codes.ResourceExhausted, "exhausted")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). @@ -506,6 +510,7 @@ var _ = Describe("GCE", func() { It("reads and trims SSH key", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAAA... \n"), nil) + key, err := bs.ReadSSHKey("~/.ssh/id_rsa.pub") Expect(err).NotTo(HaveOccurred()) Expect(key).To(Equal("ssh-rsa AAAA...")) @@ -513,6 +518,7 @@ var _ = Describe("GCE", func() { It("returns error when file read fails", func() { fw.EXPECT().ReadFile(mock.Anything).Return(nil, fmt.Errorf("no such file")) + _, err := bs.ReadSSHKey("~/.ssh/missing.pub") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("error reading SSH key")) @@ -520,6 +526,7 @@ var _ = Describe("GCE", func() { It("returns error when key file is empty", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte(" \n "), nil) + _, err := bs.ReadSSHKey("~/.ssh/empty.pub") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("is empty")) @@ -568,8 +575,10 @@ var _ = Describe("GCE", func() { It("Sets the root disk size", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(8) + allRootDiskSizesCorrect := true mu := sync.Mutex{} + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn( // Testing the disk size like this instead of a matcher // to avoid the test to panic in case of a mismatch in the parallel go funcs @@ -579,6 +588,7 @@ var _ = Describe("GCE", func() { allRootDiskSizesCorrect = false mu.Unlock() } + return nil }, ).Times(8) @@ -592,6 +602,7 @@ var _ = Describe("GCE", func() { It("creates all instances", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(8) gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(8) + ipResp := makeRunningInstance("10.0.0.x", "1.2.3.x") mockGetInstanceNotFoundThenRunning(gc, csEnv.ProjectID, csEnv.Zone, ipResp, 8) @@ -625,20 +636,24 @@ var _ = Describe("GCE", func() { It("fetches GitHub team keys", func() { mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return([]*gh.User{{Login: gh.Ptr("alice")}}, nil).Maybe() mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return([]*gh.Key{{Key: gh.Ptr("ssh-rsa AAALICE...")}}, nil).Maybe() + ipResp := makeRunningInstance("10.0.0.x", "1.2.3.x") mockGetInstanceNotFoundThenRunning(gc, csEnv.ProjectID, csEnv.Zone, ipResp, 8) fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(8) gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone string, instance *computepb.Instance) error { sshMetadata := "" + for _, item := range instance.GetMetadata().GetItems() { if item.GetKey() == "ssh-keys" { sshMetadata = item.GetValue() } } + if !strings.Contains(sshMetadata, "AAALICE...") { return fmt.Errorf("expected ssh metadata to include team user key") } + return nil }).Times(8) @@ -682,15 +697,19 @@ var _ = Describe("GCE", func() { It("fails when GetInstance fails after creation", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn( func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { return nil, notFoundErr } + return nil, fmt.Errorf("get error") }, ).Maybe() @@ -732,15 +751,20 @@ var _ = Describe("GCE", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(8) createCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone string, instance *computepb.Instance) error { mu.Lock() defer mu.Unlock() + name := *instance.Name + createCalls[name]++ if createCalls[name] == 1 { return fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED") } + return nil }).Times(16) @@ -750,12 +774,16 @@ var _ = Describe("GCE", func() { It("restarts stopped VMs instead of creating new ones", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + stoppedResp := makeStoppedInstance("10.0.0.x", "1.2.3.x") runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { // First call, VM exists but is stopped @@ -782,12 +810,16 @@ var _ = Describe("GCE", func() { It("handles VMs in intermediate states (STAGING/PROVISIONING)", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + stagingResp := makeInstance("STAGING", "10.0.0.x", "1.2.3.x") runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { // First call: instance exists but is still staging @@ -956,11 +988,13 @@ var _ = Describe("GCE", func() { runningInst := makeRunningInstance("10.0.0.1", "1.2.3.4") callCounts := map[string]int{} + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(_, _, name string) (*computepb.Instance, error) { callCounts[name]++ if callCounts[name] == 1 { return stoppedInst, nil } + return runningInst, nil }).Times(16) gc.EXPECT().StartInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(8) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 19eb5c30..febdf9ae 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -42,7 +42,9 @@ func CheckOMSManagedLabel(labels map[string]string) bool { if labels == nil { return false } + value, exists := labels[OMSManagedLabel] + return exists && value == "true" } @@ -385,10 +387,12 @@ func (b *GCPBootstrapper) createTestUser() error { if b.Env.InstallConfig == nil { return fmt.Errorf("install config not found in bootstrap environment") } + pgPasswordSecret := b.icg.GetVault().GetSecret(files.SecretPostgresPassword) if pgPasswordSecret == nil || pgPasswordSecret.Fields == nil { return fmt.Errorf("postgres admin password not found in vault") } + pgPassword := pgPasswordSecret.Fields.Password result, err := testuser.CreateTestUser(testuser.CreateTestUserOpts{ @@ -405,6 +409,7 @@ func (b *GCPBootstrapper) createTestUser() error { } testuser.LogAndPersistResult(result, b.Env.OmsWorkdir) + return nil } func (b *GCPBootstrapper) ValidateInput() error { @@ -474,6 +479,7 @@ func (b *GCPBootstrapper) validateClusterAdminEmail() error { if err != nil { return fmt.Errorf("invalid cluster admin email: %w", err) } + b.Env.ClusterAdminEmail = email return nil @@ -485,14 +491,18 @@ func (b *GCPBootstrapper) validateInstallVersion() error { if b.Env.InstallVersion != "" || b.Env.InstallHash != "" { return fmt.Errorf("cannot specify both install-local and install-version/install-hash") } + if !b.fw.Exists(b.Env.InstallLocal) { return fmt.Errorf("local installer package not found at path: %s", b.Env.InstallLocal) } + return nil } + if b.Env.InstallVersion == "" { return nil } + build, err := b.PortalClient.GetBuild(portal.CodesphereProduct, b.Env.InstallVersion, b.Env.InstallHash) if err != nil { return fmt.Errorf("failed to get codesphere package: %w", err) @@ -506,6 +516,7 @@ func (b *GCPBootstrapper) validateInstallVersion() error { if b.Env.RegistryType == RegistryTypeGitHub { requiredFilename = "installer-lite.tar.gz" } + filenames := []string{} // Validate required file exists in package artifacts for _, artifact := range build.Artifacts { @@ -553,6 +564,7 @@ func (b *GCPBootstrapper) validateGitProviderParams() error { if p.id != "" && p.secret == "" { return fmt.Errorf("%s client ID is set but client secret is missing", p.name) } + if p.secret != "" && p.id == "" { return fmt.Errorf("%s client secret is set but client ID is missing", p.name) } @@ -587,9 +599,11 @@ func (b *GCPBootstrapper) validatePrometheusRemoteWriteParams() error { if b.Env.PrometheusRemoteWriteURL != "" && (b.Env.PrometheusRemoteWriteUser == "" || b.Env.PrometheusRemoteWritePassword == "") { return fmt.Errorf("prometheus remote write username and password must both be set when remote write URL is specified") } + if (b.Env.PrometheusRemoteWriteUser != "" || b.Env.PrometheusRemoteWritePassword != "") && b.Env.PrometheusRemoteWriteURL == "" { return fmt.Errorf("prometheus remote write URL is required when remote write username or password is set") } + return nil } @@ -601,6 +615,7 @@ func (b *GCPBootstrapper) validateTelemetryExportParams() error { if b.Env.CentralOtelUsername != "" && b.Env.CentralOtelPassword == "" { return fmt.Errorf("central OTel username is set but password is missing") } + if b.Env.CentralOtelPassword != "" && b.Env.CentralOtelUsername == "" { return fmt.Errorf("central OTel password is set but username is missing") } @@ -630,10 +645,12 @@ func (b *GCPBootstrapper) ensureDnsPermissions() error { if b.Env.DNSProjectID == "" { dnsProject = b.Env.ProjectID } + err := b.ensureIAMRoleWithRetry(dnsProject, "cloud-controller", b.Env.ProjectID, []string{"roles/dns.admin"}) if err != nil { return err } + return nil } @@ -671,6 +688,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { TargetTags: []string{"ssh"}, Description: protoString("Allow external SSH to Jumpbox"), } + err := b.GCPClient.CreateFirewallRule(b.Env.ProjectID, sshRule) if err != nil { return fmt.Errorf("failed to create jumpbox ssh firewall rule: %w", err) @@ -688,6 +706,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { SourceRanges: []string{"10.10.0.0/20"}, Description: protoString("Allow all internal traffic"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, internalRule) if err != nil { return fmt.Errorf("failed to create internal firewall rule: %w", err) @@ -705,6 +724,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { DestinationRanges: []string{"0.0.0.0/0"}, Description: protoString("Allow all egress"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, egressRule) if err != nil { return fmt.Errorf("failed to create egress firewall rule: %w", err) @@ -722,6 +742,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { SourceRanges: []string{"0.0.0.0/0"}, Description: protoString("Allow HTTP/HTTPS ingress"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, webRule) if err != nil { return fmt.Errorf("failed to create web firewall rule: %w", err) @@ -740,6 +761,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { TargetTags: []string{"postgres"}, Description: protoString("Allow external access to PostgreSQL"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, postgresRule) if err != nil { return fmt.Errorf("failed to create postgres firewall rule: %w", err) @@ -752,14 +774,17 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { // controllers of the cluster (gateway and public gateway) and the SSH workspace proxy. func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { var err error + b.Env.GatewayIP, err = b.EnsureExternalIP("gateway") if err != nil { return fmt.Errorf("failed to ensure gateway IP: %w", err) } + b.Env.PublicGatewayIP, err = b.EnsureExternalIP("public-gateway") if err != nil { return fmt.Errorf("failed to ensure public gateway IP: %w", err) } + b.Env.SshProxyIP, err = b.EnsureExternalIP("ssh-proxy") if err != nil { return fmt.Errorf("failed to ensure ssh proxy IP: %w", err) @@ -836,9 +861,11 @@ func (b *GCPBootstrapper) ensureRootLoginEnabledInNode(node *node.Node) error { if err == nil { break } + if i == 2 { return fmt.Errorf("failed to enable root login on %s: %w", node.GetName(), err) } + b.stlog.LogRetry() b.Time.Sleep(10 * time.Second) } @@ -878,6 +905,7 @@ func (b *GCPBootstrapper) EnsureOmsInstalled() (err error) { if err != nil { return fmt.Errorf("failed to make local OMS binary executable on jumpbox: %w", err) } + return nil } @@ -904,6 +932,7 @@ func (b *GCPBootstrapper) EnsureHostsConfigured() error { return fmt.Errorf("failed to configure inotify watches on %s: %w", node.GetName(), err) } } + if !node.HasMemoryMapConfigured() { err := node.ConfigureMemoryMap() if err != nil { @@ -921,16 +950,20 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { // Figure out if registry is already running b.stlog.Logf("Checking if local container registry is already running on postgres node") + checkCommand := `test "$(podman ps --filter 'name=registry' --format '{{.Names}}' | wc -l)" -eq "1"` err := b.Env.PostgreSQLNode.RunSSHCommand("root", checkCommand) registryUsername := "" registryPassword := "" + if s := b.icg.GetVault().GetSecret(files.SecretRegistryUsername); s != nil && s.Fields != nil { registryUsername = s.Fields.Password } + if s := b.icg.GetVault().GetSecret(files.SecretRegistryPassword); s != nil && s.Fields != nil { registryPassword = s.Fields.Password } + if err == nil && b.Env.InstallConfig.Registry != nil && b.Env.InstallConfig.Registry.Server == localRegistryServer && registryUsername != "" && registryPassword != "" { b.stlog.Logf("Local container registry already running on postgres node") @@ -940,6 +973,7 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { b.Env.InstallConfig.Registry.Server = localRegistryServer registryUsername = "custom-registry" registryPassword = shortuuid.New() + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: registryUsername}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: registryPassword}}) @@ -966,6 +1000,7 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { } for _, cmd := range commands { b.stlog.Logf("Running command on postgres node: %s", util.Truncate(cmd, 12)) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", cmd) if err != nil { return fmt.Errorf("failed to run command on postgres node: %w", err) @@ -975,14 +1010,17 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { allNodes := append(b.Env.ControlPlaneNodes, b.Env.CephNodes...) for _, node := range allNodes { b.stlog.Logf("Configuring node '%s' to trust local registry certificate", node.GetName()) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", "scp -o StrictHostKeyChecking=no /root/registry.crt root@"+node.GetInternalIP()+":/usr/local/share/ca-certificates/registry.crt") if err != nil { return fmt.Errorf("failed to copy registry certificate to node %s: %w", node.GetInternalIP(), err) } + err = node.RunSSHCommand("root", "update-ca-certificates") if err != nil { return fmt.Errorf("failed to update CA certificates on node %s: %w", node.GetInternalIP(), err) } + err = node.RunSSHCommand("root", "systemctl restart docker.service || true") // docker is probably not yet installed if err != nil { return fmt.Errorf("failed to restart docker service on node %s: %w", node.GetInternalIP(), err) @@ -996,11 +1034,13 @@ func (b *GCPBootstrapper) EnsureGitHubAccessConfigured() error { if b.Env.GitHubPAT == "" { return fmt.Errorf("GitHub PAT is not set") } + b.Env.InstallConfig.Registry.Server = "ghcr.io" b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUser}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: b.Env.GitHubPAT}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false b.Env.InstallConfig.Registry.LoadContainerImages = false + return nil } @@ -1011,6 +1051,7 @@ func (b *GCPBootstrapper) EnsureDNSRecords() error { } zoneName := b.Env.DNSZoneName + err := b.GCPClient.EnsureDNSManagedZone(gcpProject, zoneName, b.Env.BaseDomain+".", "Codesphere DNS zone") if err != nil { return fmt.Errorf("failed to ensure DNS managed zone: %w", err) @@ -1079,11 +1120,14 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { if b.Env.InstallLocal != "" { b.stlog.Logf("Copying local package %s to jumpbox...", b.Env.InstallLocal) + fullPackageFilename := fmt.Sprintf("local-%s", packageFilename) + err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.InstallLocal, "/root/"+fullPackageFilename) if err != nil { return "", fmt.Errorf("failed to copy local install package to jumpbox: %w", err) } + return fullPackageFilename, nil } @@ -1095,9 +1139,11 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { if b.Env.InstallHash == "" { return "", fmt.Errorf("install hash must be set when install version is set") } + b.stlog.Logf("Downloading Codesphere package...") downloadCmd := fmt.Sprintf("oms download package -f %s -H %s %s", packageFilename, b.Env.InstallHash, b.Env.InstallVersion) + err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd) if err != nil { return "", fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) @@ -1110,6 +1156,7 @@ func (b *GCPBootstrapper) runInstallCommand(packageFilename string) error { b.stlog.Logf("Installing Codesphere...") installCmd := fmt.Sprintf("oms install codesphere -c /etc/codesphere/config.yaml -k %s/age_key.txt --vault %s -p %s%s", b.Env.SecretsDir, filepath.Join(b.Env.SecretsDir, "prod.vault.yaml"), packageFilename, b.generateSkipStepsArg()) + return b.Env.Jumpbox.RunSSHCommand("root", installCmd) } @@ -1118,6 +1165,7 @@ func (b *GCPBootstrapper) generateSkipStepsArg() string { if b.Env.RegistryType == RegistryTypeGitHub { skipSteps = append(skipSteps, "load-container-images") } + if len(skipSteps) == 0 { return "" } @@ -1213,14 +1261,17 @@ systemctl restart k0scontroller if err != nil { return fmt.Errorf("failed to write configure-k0s.sh: %w", err) } + err = b.Env.ControlPlaneNodes[0].NodeClient.CopyFile(b.Env.ControlPlaneNodes[0], "configure-k0s.sh", "/root/configure-k0s.sh") if err != nil { return fmt.Errorf("failed to copy configure-k0s.sh to control plane node: %w", err) } + err = b.Env.ControlPlaneNodes[0].RunSSHCommand("root", "chmod +x /root/configure-k0s.sh") if err != nil { return fmt.Errorf("failed to make configure-k0s.sh executable on control plane node: %w", err) } + return nil } diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index 7b764de3..1d179924 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -101,6 +101,7 @@ func (c *GCPClient) GetProjectByName(folderID string, displayName string) (*reso // No more results found return nil, fmt.Errorf("project not found: %s", displayName) } + if err != nil { return nil, fmt.Errorf("error iterating projects: %w", err) } @@ -231,10 +232,12 @@ func (c *GCPClient) GetBillingInfo(projectID string) (*cloudbilling.ProjectBilli } projectName := getProjectResourceName(projectID) + billingInfo, err := billingService.Projects.GetBillingInfo(projectName).Do() if err != nil { return nil, err } + return billingInfo, nil } @@ -250,6 +253,7 @@ func (c *GCPClient) EnableBilling(projectID, billingAccount string) error { BillingAccountName: fmt.Sprintf("billingAccounts/%s", billingAccount), } _, err = billingService.Projects.UpdateBillingInfo(projectName, billingInfo).Context(c.ctx).Do() + return err } @@ -262,13 +266,16 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { defer util.IgnoreError(client.Close) // enable APIs in parallel wg := sync.WaitGroup{} + errCh := make(chan error, len(apis)) for _, api := range apis { serviceName := fmt.Sprintf("projects/%s/services/%s", projectID, api) + wg.Add(1) go func(serviceName, api string) { defer wg.Done() + c.st.Logf("Enabling API %s", api) op, err := client.EnableService(c.ctx, &serviceusagepb.EnableServiceRequest{Name: serviceName}) @@ -276,10 +283,12 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { c.st.Logf("API %s already enabled", api) return } + if err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) return } + if _, err := op.Wait(c.ctx); err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) return @@ -291,13 +300,16 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { wg.Wait() close(errCh) + errStr := "" for err := range errCh { errStr += err.Error() + "; " } + if len(errStr) > 0 { return fmt.Errorf("errors occurred while enabling APIs: %s", errStr) } + return nil } @@ -318,11 +330,14 @@ func (c *GCPClient) CreateArtifactRegistry(projectID, region, repoName string) ( Description: "Codesphere managed registry", }, } + op, err := client.CreateRepository(c.ctx, repoReq) if err != nil && !strings.Contains(err.Error(), "already exists") { return nil, err } + var repo *artifactpb.Repository + if err == nil { _, err = op.Wait(c.ctx) if err != nil { @@ -348,6 +363,7 @@ func (c *GCPClient) GetArtifactRegistry(projectID, region, repoName string) (*ar defer util.IgnoreError(client.Close) fullRepoName := fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, region, repoName) + repo, err := client.GetRepository(c.ctx, &artifactpb.GetRepositoryRequest{ Name: fullRepoName, }) @@ -363,6 +379,7 @@ func (c *GCPClient) GetArtifactRegistry(projectID, region, repoName string) (*ar // and an error if any occurred during the process. func (c *GCPClient) CreateServiceAccount(projectID, name, displayName string) (string, bool, error) { saMail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, projectID) + iamService, err := iam.NewService(c.ctx) if err != nil { return saMail, false, err @@ -374,10 +391,12 @@ func (c *GCPClient) CreateServiceAccount(projectID, name, displayName string) (s DisplayName: displayName, }, } + _, err = iamService.Projects.ServiceAccounts.Create(fmt.Sprintf("projects/%s", projectID), saReq).Context(c.ctx).Do() if err != nil && !strings.Contains(err.Error(), "already exists") { return saMail, false, err } + if err != nil && strings.Contains(err.Error(), "already exists") { return saMail, false, nil } @@ -395,6 +414,7 @@ func (c *GCPClient) CreateServiceAccountKey(projectID, saEmail string) (string, keyReq := &iam.CreateServiceAccountKeyRequest{} saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) + key, err := iamService.Projects.ServiceAccounts.Keys.Create(saName, keyReq).Context(c.ctx).Do() if err != nil { return "", err @@ -408,6 +428,7 @@ func (c *GCPClient) AssignIAMRole(projectID, saName string, saProjectID string, saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, saProjectID) member := fmt.Sprintf("serviceAccount:%s", saEmail) resource := fmt.Sprintf("projects/%s", projectID) + return c.addRoleBindingToProject(member, roles, resource) } @@ -429,18 +450,23 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou // Add role bindings to policy updated := false + for _, role := range roles { bindingExists := false + for _, binding := range policy.Bindings { if binding.Role == role { if !slices.Contains(binding.Members, member) { binding.Members = append(binding.Members, member) updated = true } + bindingExists = true + break } } + if bindingExists { continue } @@ -462,6 +488,7 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou Policy: policy, } _, err = client.SetIamPolicy(c.ctx, setReq) + return err } @@ -470,6 +497,7 @@ func (c *GCPClient) RemoveIAMRoleBinding(projectID, saName string, saProjectID s saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, saProjectID) member := fmt.Sprintf("serviceAccount:%s", saEmail) resource := fmt.Sprintf("projects/%s", projectID) + return c.removeRoleBindingFromProject(member, roles, resource) } @@ -486,18 +514,22 @@ func (c *GCPClient) removeRoleBindingFromProject(member string, roles []string, } updated := false + for _, role := range roles { for i, binding := range policy.Bindings { if binding.Role != role { continue } + before := len(binding.Members) + policy.Bindings[i].Members = slices.DeleteFunc(binding.Members, func(m string) bool { return m == member }) if len(policy.Bindings[i].Members) != before { updated = true } + break } } @@ -507,17 +539,20 @@ func (c *GCPClient) removeRoleBindingFromProject(member string, roles []string, } var validBindings []*iampb.Binding + for _, b := range policy.Bindings { if len(b.Members) > 0 { validBindings = append(validBindings, b) } } + policy.Bindings = validBindings _, err = client.SetIamPolicy(c.ctx, &iampb.SetIamPolicyRequest{ Resource: resource, Policy: policy, }) + return err } @@ -534,6 +569,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Name: &networkName, AutoCreateSubnetworks: protoBool(false), } + op, err := networksClient.Insert(c.ctx, &computepb.InsertNetworkRequest{ Project: projectID, NetworkResource: network, @@ -541,6 +577,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !strings.Contains(err.Error(), "already exists") { return err } + if err == nil { if err := op.Wait(c.ctx); err != nil { return err @@ -562,6 +599,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Region: ®ion, Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), } + op, err = subnetsClient.Insert(c.ctx, &computepb.InsertSubnetworkRequest{ Project: projectID, Region: region, @@ -570,6 +608,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !strings.Contains(err.Error(), "already exists") { return err } + if err == nil { if err := op.Wait(c.ctx); err != nil { return err @@ -590,6 +629,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Region: ®ion, Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), } + op, err = routersClient.Insert(c.ctx, &computepb.InsertRouterRequest{ Project: projectID, Region: region, @@ -598,6 +638,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !IsAlreadyExistsError(err) { return fmt.Errorf("failed to create router: %w", err) } + if err == nil { if err := op.Wait(c.ctx); err != nil { return fmt.Errorf("failed to wait for router creation: %w", err) @@ -731,6 +772,7 @@ func (c *GCPClient) CreateAddress(projectID, region string, address *computepb.A if err != nil { return "", err } + if err = op.Wait(c.ctx); err != nil { return "", err } @@ -783,6 +825,7 @@ func (c *GCPClient) EnsureDNSManagedZone(projectID, zoneName, dnsName, descripti DnsName: dnsName, Description: description, } + _, err = service.ManagedZones.Create(projectID, zone).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to create DNS zone: %w", err) @@ -811,6 +854,7 @@ func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*d delChange := &dns.Change{ Deletions: deletions, } + _, err = service.Changes.Create(projectID, zoneName, delChange).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to delete existing DNS records: %w", err) @@ -820,6 +864,7 @@ func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*d change := &dns.Change{ Additions: records, } + _, err = service.Changes.Create(projectID, zoneName, change).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to create DNS records: %w", err) @@ -836,14 +881,17 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) } var deletions []*dns.ResourceRecordSet + for _, record := range GetDNSRecordNames(baseDomain) { existing, err := service.ResourceRecordSets.Get(projectID, zoneName, record.Name, record.Rtype).Context(c.ctx).Do() if IsNotFoundError(err) { continue } + if err != nil { return fmt.Errorf("failed to get DNS record %s: %w", record.Name, err) } + deletions = append(deletions, existing) } @@ -854,6 +902,7 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) if _, err = service.Changes.Create(projectID, zoneName, &dns.Change{Deletions: deletions}).Context(c.ctx).Do(); err != nil { return fmt.Errorf("failed to delete DNS records: %w", err) } + return nil } @@ -866,11 +915,14 @@ func (c *GCPClient) CreatePublicCAExternalAccountKey(projectID string) (string, if err != nil { return "", "", fmt.Errorf("failed to create publicca client: %w", err) } + parent := fmt.Sprintf("projects/%s/locations/global", projectID) + key, err := svc.Projects.Locations.ExternalAccountKeys.Create(parent, &publicca.ExternalAccountKey{}).Context(c.ctx).Do() if err != nil { return "", "", fmt.Errorf("failed to create public CA external account key: %w", err) } + return key.KeyId, key.B64MacKey, nil } diff --git a/internal/bootstrap/gcp/gcp_client_cleanup_test.go b/internal/bootstrap/gcp/gcp_client_cleanup_test.go index 10cf52bf..5657751b 100644 --- a/internal/bootstrap/gcp/gcp_client_cleanup_test.go +++ b/internal/bootstrap/gcp/gcp_client_cleanup_test.go @@ -123,6 +123,7 @@ var _ = Describe("GCP Client Cleanup Methods", func() { records := gcp.GetDNSRecordNames(baseDomain) Expect(records).To(HaveLen(5)) + for _, record := range records { Expect(record.Name).To(ContainSubstring("internal.codesphere.com")) Expect(record.Name).To(HaveSuffix(".")) @@ -149,5 +150,4 @@ var _ = Describe("GCP Client Cleanup Methods", func() { }) }) }) - }) diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 49601814..bdf4dd4c 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -50,6 +50,7 @@ var _ = Describe("GCP Bootstrapper", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -168,6 +169,7 @@ var _ = Describe("GCP Bootstrapper", func() { icg.EXPECT().GetInstallConfig().RunAndReturn(func() *files.RootConfig { realIcm := installer.NewInstallConfigManager() _ = realIcm.ApplyProfile("minimal") + return realIcm.GetInstallConfig() }) @@ -300,6 +302,7 @@ var _ = Describe("GCP Bootstrapper", func() { Describe("ValidateInput", func() { var artifacts []portal.Artifact + Context("When GitHub team and org is set", func() { BeforeEach(func() { csEnv.GitHubTeamOrg = "codesphere-cloud" @@ -887,6 +890,7 @@ var _ = Describe("GCP Bootstrapper", func() { icg = installer.NewMockInstallConfigManager(GinkgoT()) icg.EXPECT().GetVault().Return(&files.InstallVault{}) + gc = gcp.NewMockGCPClientManager(GinkgoT()) fw = util.NewMockFileIO(GinkgoT()) }) @@ -1380,6 +1384,7 @@ var _ = Describe("GCP Bootstrapper", func() { BeforeEach(func() { csEnv.InstallVersion = "v1.2.3" csEnv.InstallHash = "abc1234567890" + icg.EXPECT().GetSecretFilePath().Return("/etc/codesphere/secrets/prod.vault.yaml").Maybe() }) Describe("Valid InstallCodesphere", func() { diff --git a/internal/bootstrap/gcp/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index 4e16e51e..da2e6283 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -145,6 +145,7 @@ func (b *GCPBootstrapper) EnsureBilling() error { if err != nil { return fmt.Errorf("failed to get billing info: %w", err) } + if bi.BillingEnabled && bi.BillingAccountName == b.Env.BillingAccount { return nil } @@ -195,6 +196,7 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { if s := b.icg.GetVault().GetSecret(files.SecretRegistryPassword); s != nil && s.Fields != nil { existingRegPwd = s.Fields.Password } + if !newSa && existingRegPwd != "" { return nil } @@ -206,8 +208,10 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { if retries > 3 { return fmt.Errorf("failed to create service account key: %w", err) } + b.stlog.LogRetry() b.Time.Sleep(5 * time.Second) + continue } diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index ee37a846..681ae3be 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -43,6 +43,7 @@ var _ = Describe("IAM & Admin", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -130,6 +131,7 @@ var _ = Describe("IAM & Admin", func() { Describe("Invalid cases", func() { It("returns error when GetProjectByName fails unexpectedly", func() { gc.EXPECT().GetProjectByName("", csEnv.ProjectName).Return(nil, fmt.Errorf("api error")) + err := bs.EnsureProject() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get project")) @@ -166,6 +168,7 @@ var _ = Describe("IAM & Admin", func() { BillingAccountName: csEnv.BillingAccount, } gc.EXPECT().GetBillingInfo(csEnv.ProjectID).Return(bi, nil) + err := bs.EnsureBilling() Expect(err).NotTo(HaveOccurred()) }) @@ -260,6 +263,7 @@ var _ = Describe("IAM & Admin", func() { gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "cloud-controller", "cloud-controller").Return("email@sa", false, nil) gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "artifact-registry-writer", "artifact-registry-writer").Return("writer@sa", true, nil) gc.EXPECT().CreateServiceAccountKey(csEnv.ProjectID, "writer@sa").Return("key-content", nil) + err := bs.EnsureServiceAccounts() Expect(err).NotTo(HaveOccurred()) Expect(vault.GetSecret(files.SecretRegistryPassword).Fields.Password).To(Equal("key-content")) @@ -317,5 +321,4 @@ var _ = Describe("IAM & Admin", func() { }) }) }) - }) diff --git a/internal/bootstrap/gcp/iam_admin_unexported_test.go b/internal/bootstrap/gcp/iam_admin_unexported_test.go index 49f810d4..b191a60c 100644 --- a/internal/bootstrap/gcp/iam_admin_unexported_test.go +++ b/internal/bootstrap/gcp/iam_admin_unexported_test.go @@ -12,8 +12,10 @@ import ( var _ = Describe("IAM & Admin - Unexported", func() { Describe("calculateProjectExpiryLabel", func() { - const customDateFormat string = "2006-01-02_15-04-05_utc" - const customDateFormatRegex string = `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_utc$` + const ( + customDateFormat string = "2006-01-02_15-04-05_utc" + customDateFormatRegex string = `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_utc$` + ) type validTestCase struct { inputTTL string diff --git a/internal/bootstrap/gcp/infrafile.go b/internal/bootstrap/gcp/infrafile.go index 72378f9f..3e005a4b 100644 --- a/internal/bootstrap/gcp/infrafile.go +++ b/internal/bootstrap/gcp/infrafile.go @@ -46,12 +46,14 @@ func (b *GCPBootstrapper) WriteInfraFile() error { } workdir := env.NewEnv().GetOmsWorkdir() + err = b.fw.MkdirAll(workdir, 0755) if err != nil { return fmt.Errorf("failed to create workdir: %w", err) } infraFilePath := GetInfraFilePath() + err = b.fw.WriteFile(infraFilePath, envBytes, 0644) if err != nil { return fmt.Errorf("failed to write gcp bootstrap env file: %w", err) diff --git a/internal/bootstrap/gcp/infrafile_test.go b/internal/bootstrap/gcp/infrafile_test.go index d633bb7f..5ff90525 100644 --- a/internal/bootstrap/gcp/infrafile_test.go +++ b/internal/bootstrap/gcp/infrafile_test.go @@ -39,6 +39,7 @@ var _ = Describe("Infrafile", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 2775de59..2d8a1efd 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -71,12 +71,14 @@ func (b *GCPBootstrapper) recoverConfig() error { if err != nil { return fmt.Errorf("failed to find gcp project for config recovery: %w", err) } + b.Env.ProjectID = existingProject.ProjectId jumpbox, err := b.GetNodeByName("jumpbox") if err != nil { return fmt.Errorf("failed to find jumpbox node for config recovery: %w", err) } + b.Env.Jumpbox = jumpbox err = b.Env.Jumpbox.NodeClient.DownloadFile(jumpbox, remoteInstallConfigPath, b.Env.InstallConfigPath) @@ -121,9 +123,11 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.DatacenterName == "" { b.Env.DatacenterName = "dev" } + b.Env.InstallConfig.Datacenter.Name = b.Env.DatacenterName b.Env.InstallConfig.Datacenter.City = "Karlsruhe" b.Env.InstallConfig.Datacenter.CountryCode = "DE" + b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir if b.Env.RegistryType != RegistryTypeGitHub { b.Env.InstallConfig.Registry.ReplaceImagesInBom = true @@ -216,6 +220,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.DNSProjectID == "" { dnsProject = b.Env.ProjectID } + b.Env.InstallConfig.Cluster.Certificates.Override = map[string]interface{}{ "issuers": map[string]interface{}{ "letsEncryptHttp": map[string]interface{}{ @@ -232,10 +237,12 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { }, }, } + acmeServer := "https://acme-v02.api.letsencrypt.org/directory" if b.Env.ACMEStaging { acmeServer = "https://acme-staging-v02.api.letsencrypt.org/directory" } + acmeConfig := &files.ACMEConfig{ Enabled: true, Email: "oms-testing@" + b.Env.BaseDomain, @@ -246,10 +253,13 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if err != nil { return fmt.Errorf("failed to obtain Google Public CA EAB credentials: %w", err) } + acmeConfig.Server = "https://dv.acme-v02.api.pki.goog/directory" acmeConfig.EABKeyID = keyID + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAcmeEabMacKey, Fields: &files.SecretFields{Password: b64MacKey}}) } + b.Env.InstallConfig.Codesphere.CertIssuer = &files.CertIssuerConfig{ Type: "acme", Acme: acmeConfig, @@ -285,6 +295,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGithubAppsClientId, Fields: &files.SecretFields{Password: b.Env.GitHubAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGithubAppsClientSecret, Fields: &files.SecretFields{Password: b.Env.GitHubAppClientSecret}}) } + if b.Env.GitLabAppClientID != "" && b.Env.GitLabAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.GitLab = &files.GitProviderConfig{ Enabled: true, @@ -303,6 +314,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGitlabAppClientId, Fields: &files.SecretFields{Password: b.Env.GitLabAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGitlabAppClientSecret, Fields: &files.SecretFields{Password: b.Env.GitLabAppClientSecret}}) } + if b.Env.BitbucketAppClientID != "" && b.Env.BitbucketAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.Bitbucket = &files.GitProviderConfig{ Enabled: true, @@ -321,6 +333,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretBitbucketAppsClientId, Fields: &files.SecretFields{Password: b.Env.BitbucketAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretBitbucketAppsClientSecret, Fields: &files.SecretFields{Password: b.Env.BitbucketAppClientSecret}}) } + if b.Env.AzureDevOpsAppClientID != "" && b.Env.AzureDevOpsAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.AzureDevOps = &files.GitProviderConfig{ Enabled: true, @@ -340,11 +353,13 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAzureDevOpsAppClientId, Fields: &files.SecretFields{Password: b.Env.AzureDevOpsAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAzureDevOpsAppClientSecret, Fields: &files.SecretFields{Password: b.Env.AzureDevOpsAppClientSecret}}) } + if b.Env.OidcIssuerURL != "" && b.Env.OidcClientID != "" && b.Env.OidcClientSecret != "" { name := b.Env.OidcProviderName if name == "" { name = "OIDC" } + b.Env.InstallConfig.Codesphere.OAuth = &files.OAuthProvidersConfig{ Oidc: &files.OidcOAuthProvider{ Type: "oidc", @@ -375,6 +390,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.ClusterAdminEmail != "" { b.Env.InstallConfig.Codesphere.ClusterAdminEmail = b.Env.ClusterAdminEmail } + b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() @@ -393,6 +409,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + b.Env.InstallConfig.Cluster.Monitoring.CentralOtelExport = &files.CentralOtelConfig{ Enabled: true, Username: b.Env.CentralOtelUsername, @@ -459,6 +476,7 @@ func (b *GCPBootstrapper) applyExternalLokiConfig() { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.GrafanaAlloy == nil { b.Env.InstallConfig.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{} } @@ -482,9 +500,11 @@ func (b *GCPBootstrapper) applyPrometheusRemoteWriteConfig() { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.Prometheus == nil { b.Env.InstallConfig.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.Prometheus.RemoteWrite == nil { b.Env.InstallConfig.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{} } @@ -511,6 +531,7 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + primaryKeyPEM, primaryCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, b.Env.InstallConfig.Postgres.CACertPem, @@ -519,12 +540,16 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if err != nil { return fmt.Errorf("failed to generate primary server certificate: %w", err) } + if err := secrets.ValidateCertKeyPair(primaryCertPEM, primaryKeyPEM); err != nil { return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: primaryKeyPEM}}) + b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem = primaryCertPEM } + if b.Env.InstallConfig.Postgres.Replica != nil { replicaKeySecret := vault.GetSecret(files.SecretPostgresReplicaServerKeyPem) if replicaKeySecret == nil || replicaKeySecret.File == nil { @@ -532,6 +557,7 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + replicaKeyPEM, replicaCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, b.Env.InstallConfig.Postgres.CACertPem, @@ -540,13 +566,17 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if err != nil { return fmt.Errorf("failed to generate replica server certificate: %w", err) } + if err := secrets.ValidateCertKeyPair(replicaCertPEM, replicaKeyPEM); err != nil { return fmt.Errorf("replica PostgreSQL cert/key validation failed: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresReplicaServerKeyPem, File: &files.SecretFile{Name: "replica.key", Content: replicaKeyPEM}}) + b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem = replicaCertPEM } } + return nil } @@ -571,7 +601,9 @@ func (b *GCPBootstrapper) EnsureSecrets() error { return fmt.Errorf("failed to load vault file: %w", err) } } + b.Env.Secrets = b.icg.GetVault() + return nil } diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index e5d0306d..d65b98f8 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -45,6 +45,7 @@ var _ = Describe("Installconfig & Secrets", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -313,9 +314,11 @@ var _ = Describe("Installconfig & Secrets", func() { Describe("UpdateInstallConfig", func() { var vault *files.InstallVault + BeforeEach(func() { vault = &files.InstallVault{} icg.EXPECT().GetVault().Return(vault).Maybe() + csEnv.GitHubAppName = "fake-app-name" }) Describe("Valid UpdateInstallConfig", func() { @@ -1086,6 +1089,7 @@ var _ = Describe("Installconfig & Secrets", func() { vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: key}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" @@ -1119,6 +1123,7 @@ var _ = Describe("Installconfig & Secrets", func() { vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: key}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.99" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" @@ -1156,6 +1161,7 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(err).NotTo(HaveOccurred()) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" diff --git a/internal/bootstrap/gcp/test_helpers_test.go b/internal/bootstrap/gcp/test_helpers_test.go index e7131a4b..b64ffb54 100644 --- a/internal/bootstrap/gcp/test_helpers_test.go +++ b/internal/bootstrap/gcp/test_helpers_test.go @@ -39,6 +39,7 @@ func makeInstance(status, internalIP, externalIP string) *computepb.Instance { {NatIP: protoString(externalIP)}, } } + return inst } @@ -57,14 +58,18 @@ func makeStoppedInstance(internalIP, externalIP string) *computepb.Instance { // Uses .Times(numVMs * 2) to expect exactly 2 calls per VM (initial check + poll after create). func mockGetInstanceNotFoundThenRunning(gc *gcp.MockGCPClientManager, projectID, zone string, runningResp *computepb.Instance, numVMs int) { instanceCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().GetInstance(projectID, zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { return nil, status.Errorf(codes.NotFound, "not found") } + return runningResp, nil }).Times(numVMs * 2) } @@ -100,5 +105,6 @@ func newTestBootstrapperAll(csEnv *gcp.CodesphereEnvironment, gc gcp.GCPClientMa if err != nil { panic("newTestBootstrapperAll: " + err.Error()) } + return bs } diff --git a/internal/bootstrap/local/ceph.go b/internal/bootstrap/local/ceph.go index dc526118..bace8088 100644 --- a/internal/bootstrap/local/ceph.go +++ b/internal/bootstrap/local/ceph.go @@ -113,6 +113,7 @@ func (b *LocalBootstrapper) DeployCephFilesystem() error { Resources: corev1.ResourceRequirements{}, }, } + return nil }) if err != nil { @@ -135,6 +136,7 @@ func (b *LocalBootstrapper) DeployCephFilesystemSubVolumeGroup() error { svg.Spec = rookcephv1.CephFilesystemSubVolumeGroupSpec{ FilesystemName: cephFilesystemName, } + return nil }) if err != nil { @@ -184,6 +186,7 @@ func (b *LocalBootstrapper) EnsureCephUsers() (*CephCredentials, error) { cc.Spec = rookcephv1.ClientSpec{ Caps: def.caps, } + return nil }) if err != nil { @@ -193,22 +196,26 @@ func (b *LocalBootstrapper) EnsureCephUsers() (*CephCredentials, error) { if err := b.waitForCephClientReady(def.name); err != nil { return nil, err } + b.stlog.Logf("CephClient %q is ready", def.name) } b.stlog.Logf("Reading Ceph cluster FSID") + fsid, err := b.readCephFSID() if err != nil { return nil, err } b.stlog.Logf("Ensuring RGW admin user %q", rgwAdminUserName) + rgwAdmin, err := b.EnsureRGWAdminUser() if err != nil { return nil, err } b.stlog.Logf("Reading Ceph client secrets") + cephfsAdmin, err := b.readCephClientSecret("cephfs-admin-blue") if err != nil { return nil, err @@ -220,6 +227,7 @@ func (b *LocalBootstrapper) EnsureCephUsers() (*CephCredentials, error) { } b.stlog.Logf("Reading Rook CSI secrets") + csiRBDNode, err := b.readCSISecret("rook-csi-rbd-node", "userID", "userKey") if err != nil { return nil, err @@ -241,6 +249,7 @@ func (b *LocalBootstrapper) EnsureCephUsers() (*CephCredentials, error) { } b.stlog.Logf("Ceph users and credentials are ready") + return &CephCredentials{ FSID: fsid, CephfsAdmin: *cephfsAdmin, @@ -257,6 +266,7 @@ func (b *LocalBootstrapper) EnsureCephUsers() (*CephCredentials, error) { // insecure RGW endpoints into Ceph hosts for the internal install config. func (b *LocalBootstrapper) ReadCephMonHosts() ([]files.CephHost, error) { store := &rookcephv1.CephObjectStore{} + key := client.ObjectKey{Name: rgwObjectStoreName, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, store); err != nil { return nil, fmt.Errorf("failed to get CephObjectStore %q: %w", key.Name, err) @@ -315,12 +325,15 @@ func (b *LocalBootstrapper) DeployRGWGateway() error { if err := b.deployRGWRealm(); err != nil { return err } + if err := b.deployRGWZoneGroup(); err != nil { return err } + if err := b.deployRGWZone(); err != nil { return err } + return b.deployRGWObjectStore() } @@ -336,11 +349,13 @@ func (b *LocalBootstrapper) deployRGWRealm() error { realm.Spec = rookcephv1.ObjectRealmSpec{ DefaultRealm: true, } + return nil }) if err != nil { return fmt.Errorf("failed to create or update CephObjectRealm %q: %w", rgwRealmName, err) } + return nil } @@ -356,11 +371,13 @@ func (b *LocalBootstrapper) deployRGWZoneGroup() error { zoneGroup.Spec = rookcephv1.ObjectZoneGroupSpec{ Realm: rgwRealmName, } + return nil }) if err != nil { return fmt.Errorf("failed to create or update CephObjectZoneGroup %q: %w", rgwZoneGroupName, err) } + return nil } @@ -391,11 +408,13 @@ func (b *LocalBootstrapper) deployRGWZone() error { }, PreservePoolsOnDelete: true, } + return nil }) if err != nil { return fmt.Errorf("failed to create or update CephObjectZone %q: %w", rgwZoneName, err) } + return nil } @@ -432,6 +451,7 @@ func (b *LocalBootstrapper) deployRGWObjectStore() error { Name: rgwZoneName, }, } + return nil }) if err != nil { @@ -463,22 +483,26 @@ func (b *LocalBootstrapper) EnsureRGWAdminUser() (*RGWUserCredentials, error) { "--rgw-zone", rgwZoneName, "--format", "json", } + createArgs, err := b.appendCephMonitorArgs(createArgs) if err != nil { return nil, err } + stdout, stderr, err := b.execRadosGWAdmin(createArgs) if err != nil { errorText := strings.ToLower(stderr + "\n" + err.Error()) if !strings.Contains(errorText, "exist") { return nil, fmt.Errorf("failed to create RGW admin user %q: %w: %s", rgwAdminUserName, err, strings.TrimSpace(stderr)) } + b.stlog.Logf("RGW admin user %q already exists, reading credentials", rgwAdminUserName) } else { creds, parseErr := rgwUserCredentialsFromAdminJSON(stdout) if parseErr == nil { return creds, nil } + b.stlog.Logf("Failed to parse RGW admin create output for %q, falling back to user info: %v", rgwAdminUserName, parseErr) } @@ -490,10 +514,12 @@ func (b *LocalBootstrapper) EnsureRGWAdminUser() (*RGWUserCredentials, error) { "--rgw-zone", rgwZoneName, "--format", "json", } + infoArgs, err = b.appendCephMonitorArgs(infoArgs) if err != nil { return nil, err } + stdout, stderr, err = b.execRadosGWAdmin(infoArgs) if err != nil { return nil, fmt.Errorf("failed to read RGW admin user %q: %w: %s", rgwAdminUserName, err, strings.TrimSpace(stderr)) @@ -503,6 +529,7 @@ func (b *LocalBootstrapper) EnsureRGWAdminUser() (*RGWUserCredentials, error) { if err != nil { return nil, fmt.Errorf("failed to parse RGW admin user %q info output: %w", rgwAdminUserName, err) } + return creds, nil } @@ -511,10 +538,12 @@ func (b *LocalBootstrapper) appendCephMonitorArgs(args []string) ([]string, erro if err != nil { return nil, err } + adminUser, adminSecret, err := b.readCephAdminAuth() if err != nil { return nil, err } + return append([]string{ "--mon-host", monHosts, "--no-mon-config", @@ -525,6 +554,7 @@ func (b *LocalBootstrapper) appendCephMonitorArgs(args []string) ([]string, erro func (b *LocalBootstrapper) readCephMonitorHosts() (string, error) { cm := &corev1.ConfigMap{} + key := client.ObjectKey{Name: cephMonEndpointsConfigMap, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, cm); err != nil { return "", fmt.Errorf("failed to get Ceph monitor endpoints ConfigMap %q: %w", cephMonEndpointsConfigMap, err) @@ -536,16 +566,20 @@ func (b *LocalBootstrapper) readCephMonitorHosts() (string, error) { } var monHosts []string + seen := map[string]struct{}{} + for _, entry := range util.SplitMonitorEndpointEntries(rawEndpoints) { monHost, err := util.ParseMonitorEndpointHost(entry) if err != nil { b.stlog.Logf("Skipping invalid Ceph monitor endpoint entry %q: %v", entry, err) continue } + if _, ok := seen[monHost]; ok { continue } + seen[monHost] = struct{}{} monHosts = append(monHosts, monHost) } @@ -559,6 +593,7 @@ func (b *LocalBootstrapper) readCephMonitorHosts() (string, error) { func (b *LocalBootstrapper) readCephAdminAuth() (string, string, error) { secret := &corev1.Secret{} + key := client.ObjectKey{Name: cephMonSecretName, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, secret); err != nil { return "", "", fmt.Errorf("failed to get Ceph monitor secret %q: %w", cephMonSecretName, err) @@ -601,6 +636,7 @@ func (b *LocalBootstrapper) retryWithBackoff(timeout time.Duration, timeoutMsg s if err := ctx.Err(); err != nil { return err } + return fn(ctx) }) if err == nil { @@ -625,9 +661,12 @@ func (b *LocalBootstrapper) waitForRGWPod() (*corev1.Pod, error) { if isRetryableWaitError(err) { return err } + return &retryableWaitError{err: err} } + pod = currentPod + return nil }, ) @@ -640,11 +679,13 @@ func (b *LocalBootstrapper) waitForRGWPod() (*corev1.Pod, error) { func (b *LocalBootstrapper) getRGWPod() (*corev1.Pod, error) { serviceName := "rook-ceph-rgw-" + rgwObjectStoreName + service := &corev1.Service{} if err := b.kubeClient.Get(b.ctx, client.ObjectKey{Name: serviceName, Namespace: rookNamespace}, service); err != nil { if apierrors.IsNotFound(err) { return nil, &retryableWaitError{err: fmt.Errorf("RGW service %q not found yet", serviceName)} } + return nil, fmt.Errorf("failed to get RGW service %q: %w", serviceName, err) } @@ -662,9 +703,11 @@ func (b *LocalBootstrapper) getRGWPod() (*corev1.Pod, error) { if pod.Status.Phase != corev1.PodRunning { continue } + if len(pod.Spec.Containers) == 0 { continue } + return pod, nil } @@ -678,6 +721,7 @@ func (b *LocalBootstrapper) execRadosGWAdmin(args []string) (string, string, err } command := append([]string{"radosgw-admin"}, args...) + return b.execInPod(pod.Namespace, pod.Name, pod.Spec.Containers[0].Name, command) } @@ -706,10 +750,12 @@ func (b *LocalBootstrapper) execInPod(namespace, podName, containerName string, } var stdout, stderr bytes.Buffer + err = executor.StreamWithContext(b.ctx, remotecommand.StreamOptions{ Stdout: &stdout, Stderr: &stderr, }) + return stdout.String(), stderr.String(), err } @@ -718,6 +764,7 @@ func rgwUserCredentialsFromAdminJSON(raw string) (*RGWUserCredentials, error) { AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` } + type rgwAdminUserInfo struct { Keys []rgwAdminKey `json:"keys"` } @@ -726,9 +773,11 @@ func rgwUserCredentialsFromAdminJSON(raw string) (*RGWUserCredentials, error) { if err := json.Unmarshal([]byte(raw), &info); err != nil { return nil, fmt.Errorf("failed to unmarshal RGW admin JSON: %w", err) } + if len(info.Keys) == 0 { return nil, fmt.Errorf("RGW admin JSON does not contain any keys") } + if info.Keys[0].AccessKey == "" || info.Keys[0].SecretKey == "" { return nil, fmt.Errorf("RGW admin JSON does not contain a complete access/secret key pair") } @@ -778,6 +827,7 @@ func (b *LocalBootstrapper) waitForCephObjectStoreReady(name string) error { if apierrors.IsNotFound(err) { return &retryableWaitError{err: fmt.Errorf("CephObjectStore %q not found yet", name)} } + return err } @@ -796,6 +846,7 @@ func (b *LocalBootstrapper) waitForCephObjectStoreReady(name string) error { // readCephFSID reads the Ceph FSID from the CephCluster status. func (b *LocalBootstrapper) readCephFSID() (string, error) { cluster := &rookcephv1.CephCluster{} + key := client.ObjectKey{Name: rookClusterName, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, cluster); err != nil { return "", fmt.Errorf("failed to get CephCluster %q: %w", rookClusterName, err) @@ -813,6 +864,7 @@ func (b *LocalBootstrapper) readCephFSID() (string, error) { func (b *LocalBootstrapper) readCephClientSecret(name string) (*CephUserCredentials, error) { secretName := "rook-ceph-client-" + name secret := &corev1.Secret{} + key := client.ObjectKey{Name: secretName, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, secret); err != nil { return nil, fmt.Errorf("failed to get CephClient secret %q: %w", secretName, err) @@ -832,6 +884,7 @@ func (b *LocalBootstrapper) readCephClientSecret(name string) (*CephUserCredenti // readCSISecret reads a Rook-managed CSI secret from the rook-ceph namespace. func (b *LocalBootstrapper) readCSISecret(secretName, idKey, keyKey string) (*CephUserCredentials, error) { secret := &corev1.Secret{} + key := client.ObjectKey{Name: secretName, Namespace: rookNamespace} if err := b.kubeClient.Get(b.ctx, key, secret); err != nil { return nil, fmt.Errorf("failed to get CSI secret %q: %w", secretName, err) @@ -866,6 +919,7 @@ func (b *LocalBootstrapper) waitForCephFilesystemReady() error { if apierrors.IsNotFound(err) { return &retryableWaitError{err: fmt.Errorf("CephFilesystem %q not found yet", cephFilesystemName)} } + return err } @@ -897,6 +951,7 @@ func (b *LocalBootstrapper) waitForCephClientReady(name string) error { if apierrors.IsNotFound(err) { return &retryableWaitError{err: fmt.Errorf("CephClient %q not found yet", name)} } + return err } diff --git a/internal/bootstrap/local/installer.go b/internal/bootstrap/local/installer.go index 40e42dd7..5cf66e47 100644 --- a/internal/bootstrap/local/installer.go +++ b/internal/bootstrap/local/installer.go @@ -44,6 +44,7 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { if version == "" { return "", fmt.Errorf("install version is required to download from the portal") } + if hash == "" { return "", fmt.Errorf("install hash must be set when install version is set") } @@ -56,6 +57,7 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { if err != nil { return "", fmt.Errorf("failed to get build from portal: %w", err) } + fullFilename := build.BuildPackageFilename(installerArtifactFilename) destPath := filepath.Join(b.Env.InstallDir, fullFilename) @@ -80,6 +82,7 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { defer util.CloseFileIgnoreError(out) fileSize := 0 + fileInfo, err := out.Stat() if err == nil { fileSize = int(fileInfo.Size()) @@ -119,6 +122,7 @@ func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { if err != nil { return "", err } + bundlePath = downloaded case b.Env.InstallLocal != "": @@ -145,6 +149,7 @@ func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { } destDir := strings.TrimSuffix(strings.TrimSuffix(bundlePath, ".gz"), ".tar") + destDir = strings.TrimSuffix(destDir, ".tgz") if destDir == bundlePath { destDir = bundlePath + "-unpacked" @@ -156,6 +161,7 @@ func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { } log.Printf("Extracting installer bundle %s → %s", bundlePath, destDir) + if err := util.ExtractTarGz(b.fw, bundlePath, destDir); err != nil { return "", fmt.Errorf("failed to extract installer bundle: %w", err) } @@ -239,11 +245,13 @@ func symlinkBinary(name, target string) error { } log.Printf("Symlinked %s → %s", target, localPath) + return nil } func (b *LocalBootstrapper) createTemporaryPostgresNodePortEndpoint() (string, int32, func(), error) { masterdataSvc := &corev1.Service{} + masterdataSvcKey := types.NamespacedName{Name: "masterdata-rw", Namespace: codesphereNamespace} if err := b.kubeClient.Get(b.ctx, masterdataSvcKey, masterdataSvc); err != nil { return "", 0, nil, fmt.Errorf("failed to get PostgreSQL service %s/%s: %w", codesphereNamespace, "masterdata-rw", err) @@ -320,9 +328,11 @@ func getPostgresServicePort(svc *corev1.Service) (corev1.ServicePort, error) { if port.TargetPort.Type == intstr.Int && port.TargetPort.IntValue() == 0 { port.TargetPort = intstr.FromInt(5432) } + if port.TargetPort.Type == intstr.String && port.TargetPort.String() == "" { port.TargetPort = intstr.FromInt(5432) } + return port, nil } } @@ -335,6 +345,7 @@ func getPostgresServicePort(svc *corev1.Service) (corev1.ServicePort, error) { if port.TargetPort.Type == intstr.Int && port.TargetPort.IntValue() == 0 { port.TargetPort = intstr.FromInt(int(port.Port)) } + if port.TargetPort.Type == intstr.String && port.TargetPort.String() == "" { port.TargetPort = intstr.FromInt(int(port.Port)) } @@ -358,6 +369,7 @@ func (b *LocalBootstrapper) resolveNodeIPForNodePort() (string, error) { return addr.Address, nil } } + for _, addr := range node.Status.Addresses { if addr.Type == corev1.NodeExternalIP && addr.Address != "" { return addr.Address, nil @@ -389,6 +401,7 @@ func (b *LocalBootstrapper) configurePostgresForMigration(host string, port int3 if err := b.icg.WriteInstallConfig(b.Env.InstallConfigPath, true); err != nil { return fmt.Errorf("failed to restore install config after installer run: %w", err) } + return nil }, nil } @@ -423,6 +436,7 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { log.Printf("deps directory already exists at %s, skipping extraction", depsDir) } else { log.Printf("Extracting deps.tar.gz → %s", depsDir) + if err := util.ExtractTarGz(b.fw, archivePath, depsDir); err != nil { return fmt.Errorf("failed to extract deps.tar.gz: %w", err) } @@ -432,11 +446,13 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { if b.argoCDAndAppsInstall == nil { return fmt.Errorf("ArgoCD and apps installer is not initialized") } + if err := b.stlog.Substep("Sync vault secret", func() error { return b.argoCDAndAppsInstall.SyncVaultSecret(b.ctx) }); err != nil { return err } + if err := b.stlog.Substep("Register pc-apps app-of-apps", func() error { return b.argoCDAndAppsInstall.InstallPCApps(b.ctx, filepath.Join(depsDir, "bom.json")) }); err != nil { @@ -469,6 +485,7 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { if privKeyPath == "" { return fmt.Errorf("age key path is not set; cannot pass private key to installer") } + privKeyPath, err = filepath.Abs(privKeyPath) if err != nil { return fmt.Errorf("failed to resolve absolute key path: %w", err) @@ -518,5 +535,6 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { } log.Println("Codesphere installer finished successfully.") + return nil } diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index 4dad4255..5a9f5ba6 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -176,7 +176,9 @@ func (b *LocalBootstrapper) Bootstrap() error { if err != nil { return err } + b.cephCredentials = creds + return nil }) if err != nil { @@ -242,6 +244,7 @@ func (b *LocalBootstrapper) newArgoCDAndAppsInstall() (*argocd.AppInstaller, err if err != nil { return nil, fmt.Errorf("failed to initialize ArgoCD installer: %w", err) } + return argocd.NewAppInstaller(argocd.AppInstallerConfig{ Config: *b.Env.InstallConfig, Vault: b.icg.GetVault(), @@ -256,7 +259,9 @@ func (b *LocalBootstrapper) BootstrapArgoCD() error { if err != nil { return err } + b.argoCDAndAppsInstall = install + return install.InstallArgoCD() } @@ -332,12 +337,14 @@ func (b *LocalBootstrapper) CreateCephAdminSecrets() error { Namespace: ns, }, } + _, err := controllerutil.CreateOrUpdate(b.ctx, b.kubeClient, secret, func() error { secret.Type = corev1.SecretTypeOpaque secret.StringData = map[string]string{ "ceph-username": b.cephCredentials.CephfsAdmin.Entity, "ceph-secret": b.cephCredentials.CephfsAdmin.Key, } + return nil }) if err != nil { @@ -353,6 +360,7 @@ func (b *LocalBootstrapper) CreateCephAdminSecrets() error { // CSI plugins and other consumers can discover the Ceph monitor addresses. func (b *LocalBootstrapper) SyncCephMonEndpoints() error { source := &corev1.ConfigMap{} + key := client.ObjectKey{Namespace: "rook-ceph", Name: "rook-ceph-mon-endpoints"} if err := b.kubeClient.Get(b.ctx, key, source); err != nil { return fmt.Errorf("failed to read rook-ceph-mon-endpoints ConfigMap from rook-ceph namespace: %w", err) @@ -365,6 +373,7 @@ func (b *LocalBootstrapper) SyncCephMonEndpoints() error { Namespace: ns, }, } + _, err := controllerutil.CreateOrUpdate(b.ctx, b.kubeClient, cm, func() error { cm.Data = source.Data return nil @@ -405,6 +414,7 @@ func (b *LocalBootstrapper) ReadClusterCIDRs() (podCIDR string, serviceCIDR stri err = fmt.Errorf("failed to determine service CIDR: %w", err) } } + return } @@ -414,13 +424,16 @@ func (b *LocalBootstrapper) readPodCIDR() (string, error) { if err := b.kubeClient.List(b.ctx, nodeList); err != nil { return "", fmt.Errorf("failed to list nodes: %w", err) } + if len(nodeList.Items) == 0 { return "", fmt.Errorf("no nodes found in cluster") } + podCIDR := nodeList.Items[0].Spec.PodCIDR if podCIDR == "" { return "", fmt.Errorf("node %q does not have a podCIDR set", nodeList.Items[0].Name) } + return podCIDR, nil } @@ -430,10 +443,13 @@ func (b *LocalBootstrapper) readServiceCIDRFromK8s() (serviceCIDR string, err er if err := b.kubeClient.List(b.ctx, nodeList); err != nil { return "", fmt.Errorf("failed to list nodes: %w", err) } + if len(nodeList.Items) == 0 { return "", fmt.Errorf("no nodes found in cluster") } + apiServerPod := &corev1.Pod{} + key := client.ObjectKey{Name: "kube-apiserver-" + nodeList.Items[0].Name, Namespace: "kube-system"} if err = b.kubeClient.Get(b.ctx, key, apiServerPod); err != nil { return "", fmt.Errorf("failed to get kube-apiserver pod: %w", err) @@ -446,6 +462,7 @@ func (b *LocalBootstrapper) readServiceCIDRFromK8s() (serviceCIDR string, err er break } } + if serviceCIDR != "" { break } @@ -465,10 +482,12 @@ func (b *LocalBootstrapper) readServiceCIDRFromProc() (serviceCIDR string, err e matches, _ := filepath.Glob("/proc/*/cmdline") for _, path := range matches { var content []byte + content, err = os.ReadFile(path) if err != nil { return "", fmt.Errorf("failed to read cmdline from proc FS: %w", err) } + cmdline := string(content) if strings.Contains(cmdline, "kube-apiserver") { @@ -482,6 +501,7 @@ func (b *LocalBootstrapper) readServiceCIDRFromProc() (serviceCIDR string, err e } } } + return "", errors.New("can't find service CIDR") } @@ -498,6 +518,7 @@ func (b *LocalBootstrapper) EnsureInstallConfig() error { b.Env.ExistingConfigUsed = true } + err := b.icg.ApplyProfile(b.Env.Profile) if err != nil { return fmt.Errorf("failed to apply profile: %w", err) @@ -540,11 +561,14 @@ func (b *LocalBootstrapper) ResolveAgeKey() error { if err != nil { return fmt.Errorf("failed to resolve age key: %w", err) } + b.ageRecipient = recipient + b.ageKeyPath = keyPath if keyPath != "" { fmt.Printf("Using age key: %s\n", keyPath) } + return nil } @@ -553,12 +577,14 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { if err := os.MkdirAll(b.Env.InstallConfig.Secrets.BaseDir, 0700); err != nil { return fmt.Errorf("failed to create secrets base directory: %w", err) } + if err := b.EnsureGitHubAccessConfigured(); err != nil { return fmt.Errorf("failed to ensure GitHub access is configured: %w", err) } b.Env.InstallConfig.Postgres.Mode = "external" b.Env.InstallConfig.Postgres.Database = cnpgDatabaseName + b.Env.InstallConfig.Postgres.CACertPem, err = b.ReadPostgresCA() if err != nil { return fmt.Errorf("failed to read PostgreSQL CA: %w", err) @@ -568,10 +594,12 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { b.Env.InstallConfig.Postgres.Port = 5432 b.Env.InstallConfig.Postgres.Primary = nil b.Env.InstallConfig.Postgres.Replica = nil + pgPassword, err := b.ReadPostgresSuperuserPassword() if err != nil { return fmt.Errorf("failed to read PostgreSQL superuser password: %w", err) } + b.Env.Vault.SetSecret(files.SecretEntry{ Name: "postgresPassword", Fields: &files.SecretFields{ @@ -585,6 +613,7 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { if err != nil { return fmt.Errorf("failed to read kubeconfig: %w", err) } + b.Env.Vault.SetSecret(files.SecretEntry{ Name: "kubeConfig", File: &files.SecretFile{ @@ -605,10 +634,12 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { b.Env.InstallConfig.Cluster.RgwLoadBalancer = &files.RgwLoadBalancerConfig{ Enabled: true, } + cephMonHosts, err := b.ReadCephMonHosts() if err != nil { return fmt.Errorf("failed to read Ceph monitor hosts: %w", err) } + b.Env.InstallConfig.Ceph = files.CephConfig{ Hosts: cephMonHosts, } @@ -624,6 +655,7 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { if err != nil { return fmt.Errorf("failed to read cluster CIDRs: %w. Use --service-cidr and --pod-cidr to specify them", err) } + b.Env.InstallConfig.Kubernetes.PodCIDR = podCIDR b.Env.InstallConfig.Kubernetes.ServiceCIDR = serviceCIDR b.Env.InstallConfig.Cluster.Gateway.ServiceType = "LoadBalancer" @@ -655,6 +687,7 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { } } } + b.Env.InstallConfig.Codesphere.Plans = bootstrap.DefaultCodespherePlans() b.Env.InstallConfig.Codesphere.Internal = b.Env.InternalFlags @@ -672,6 +705,7 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { if err := b.icg.WriteUnencryptedVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } + if err := vault.EncryptFileWithSOPS(b.Env.SecretsFilePath, filepath.Join(b.Env.InstallConfig.Secrets.BaseDir, "prod.vault.yaml"), b.ageRecipient); err != nil { return fmt.Errorf("failed to encrypt vault file: %w", err) } @@ -683,11 +717,13 @@ func (b *LocalBootstrapper) EnsureGitHubAccessConfigured() error { if b.Env.RegistryPassword == "" { return fmt.Errorf("registry password is not set") } + b.Env.InstallConfig.Registry.Server = "ghcr.io" b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUser}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: b.Env.RegistryPassword}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false b.Env.InstallConfig.Registry.LoadContainerImages = false + return nil } @@ -720,25 +756,31 @@ func (b *LocalBootstrapper) getKubeConfig() (string, error) { cluster := clientcmdapi.NewCluster() cluster.Server = cfg.Host + cluster.CertificateAuthorityData = cfg.CAData if cfg.CAFile != "" && len(cluster.CertificateAuthorityData) == 0 { cluster.CertificateAuthority = cfg.CAFile } + cluster.InsecureSkipTLSVerify = cfg.Insecure authInfo := clientcmdapi.NewAuthInfo() + authInfo.ClientCertificateData = cfg.CertData if cfg.CertFile != "" && len(authInfo.ClientCertificateData) == 0 { authInfo.ClientCertificate = cfg.CertFile } + authInfo.ClientKeyData = cfg.KeyData if cfg.KeyFile != "" && len(authInfo.ClientKeyData) == 0 { authInfo.ClientKey = cfg.KeyFile } + authInfo.Token = cfg.BearerToken if cfg.BearerTokenFile != "" && authInfo.Token == "" { authInfo.TokenFile = cfg.BearerTokenFile } + if cfg.Username != "" { authInfo.Username = cfg.Username authInfo.Password = cfg.Password diff --git a/internal/bootstrap/local/postgres.go b/internal/bootstrap/local/postgres.go index d7747af7..893e5d98 100644 --- a/internal/bootstrap/local/postgres.go +++ b/internal/bootstrap/local/postgres.go @@ -80,6 +80,7 @@ func (b *LocalBootstrapper) DeployPostgresDatabase() error { }, EnableSuperuserAccess: ptr.To(true), } + return nil }) if err != nil { @@ -124,6 +125,7 @@ func (b *LocalBootstrapper) WaitForPostgresDatabaseReady() error { } cluster := &cnpgv1.Cluster{} + err := b.kubeClient.Get(ctx, clusterKey, cluster) if err != nil { if apierrors.IsNotFound(err) { @@ -192,6 +194,7 @@ func (b *LocalBootstrapper) ReadPostgresSuperuserPassword() (string, error) { if !ok { return "", fmt.Errorf("PostgreSQL superuser secret %q does not contain key %q", secretName, cnpgSecretPasswordKey) } + if len(passwordBytes) == 0 { return "", fmt.Errorf("PostgreSQL superuser secret %q contains an empty %q value", secretName, cnpgSecretPasswordKey) } @@ -215,6 +218,7 @@ func (b *LocalBootstrapper) ReadPostgresCA() (string, error) { if !ok { return "", fmt.Errorf("PostgreSQL CA secret %q does not contain key %q", secretName, "ca.crt") } + if len(caCert) == 0 { return "", fmt.Errorf("PostgreSQL CA secret %q contains an empty %q value", secretName, "ca.crt") } diff --git a/internal/bootstrap/local/rook.go b/internal/bootstrap/local/rook.go index fc2330b7..a8e3baa7 100644 --- a/internal/bootstrap/local/rook.go +++ b/internal/bootstrap/local/rook.go @@ -63,6 +63,7 @@ func (b *LocalBootstrapper) buildRookHelmValues() (map[string]interface{}, error if err != nil { return "", err } + return string(b), nil } @@ -228,6 +229,7 @@ func (b *LocalBootstrapper) DeployTestCephCluster() error { "cleanup": {}, }, } + return nil }) if err != nil { @@ -272,6 +274,7 @@ func (b *LocalBootstrapper) WaitForTestCephClusterReady() error { } cluster := &rookcephv1.CephCluster{} + err := b.kubeClient.Get(ctx, clusterKey, cluster) if err != nil { if apierrors.IsNotFound(err) { @@ -333,6 +336,7 @@ func (b *LocalBootstrapper) DeployCephBlockPoolAndStorageClass() error { }, }, } + return nil }) if err != nil { @@ -366,6 +370,7 @@ func (b *LocalBootstrapper) DeployCephBlockPoolAndStorageClass() error { storageClass.ReclaimPolicy = &reclaimPolicy storageClass.VolumeBindingMode = &volumeBindingMode storageClass.AllowVolumeExpansion = ptr.To(true) + return nil }) if err != nil { diff --git a/internal/clusteradmin/clusteradmin.go b/internal/clusteradmin/clusteradmin.go index 70780b26..95cb5341 100644 --- a/internal/clusteradmin/clusteradmin.go +++ b/internal/clusteradmin/clusteradmin.go @@ -52,6 +52,7 @@ func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts O if strings.TrimSpace(opts.Namespace) == "" { return fmt.Errorf("namespace must not be empty") } + if strings.TrimSpace(opts.SecretName) == "" { return fmt.Errorf("secret name must not be empty") } @@ -79,9 +80,12 @@ func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts O if _, err := secrets.Create(ctx, secret, metav1.CreateOptions{}); err != nil { return fmt.Errorf("creating secret %s/%s: %w", opts.Namespace, opts.SecretName, err) } + log.Printf("Created secret '%s' in namespace '%s' with cluster admin email '%s'", opts.SecretName, opts.Namespace, email) + return nil } + if err != nil { return fmt.Errorf("reading secret %s/%s: %w", opts.Namespace, opts.SecretName, err) } @@ -89,16 +93,20 @@ func AddClusterAdmin(ctx context.Context, clientset kubernetes.Interface, opts O if existing.Data == nil { existing.Data = map[string][]byte{} } + if string(existing.Data[EmailKey]) == email { log.Printf("Cluster admin email '%s' already set in secret '%s/%s', nothing to do", email, opts.Namespace, opts.SecretName) return nil } + existing.Data[EmailKey] = []byte(email) if _, err := secrets.Update(ctx, existing, metav1.UpdateOptions{}); err != nil { return fmt.Errorf("updating secret %s/%s: %w", opts.Namespace, opts.SecretName, err) } + log.Printf("Set cluster admin email '%s' in secret '%s/%s'", email, opts.Namespace, opts.SecretName) + return nil } @@ -110,6 +118,7 @@ func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namesp 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 } @@ -119,9 +128,11 @@ func NormalizeEmail(raw string) (string, error) { if trimmed == "" { return "", fmt.Errorf("email must not be empty") } + addr, err := mail.ParseAddress(trimmed) if err != nil { return "", fmt.Errorf("invalid email %q: %w", raw, err) } + return strings.ToLower(addr.Address), nil } diff --git a/internal/clusteradmin/clusteradmin_test.go b/internal/clusteradmin/clusteradmin_test.go index 3e3c9dd3..b5668fa4 100644 --- a/internal/clusteradmin/clusteradmin_test.go +++ b/internal/clusteradmin/clusteradmin_test.go @@ -34,6 +34,7 @@ var _ = Describe("AddClusterAdmin", func() { getEmail := func() string { secret, err := clientset.CoreV1().Secrets(opts.Namespace).Get(ctx, opts.SecretName, metav1.GetOptions{}) Expect(err).ToNot(HaveOccurred()) + return string(secret.Data[clusteradmin.EmailKey]) } diff --git a/internal/codesphere/codesphere.go b/internal/codesphere/codesphere.go index 3e45c885..9e6fddf3 100644 --- a/internal/codesphere/codesphere.go +++ b/internal/codesphere/codesphere.go @@ -37,6 +37,7 @@ func NewClient(baseURL, token string) (*APIClient, error) { if baseURL == "" { return nil, fmt.Errorf("baseURL is required") } + if token == "" { return nil, fmt.Errorf("token is required") } @@ -69,16 +70,19 @@ func (c *APIClient) CreateWorkspace(teamID, planID int, name string, repoURL *st if err != nil { return 0, fmt.Errorf("failed to create workspace: %w", err) } + return workspace.Id, nil } // SetEnvVar sets an environment variable in the workspace func (c *APIClient) SetEnvVar(workspaceID int, key, value string) error { envVars := map[string]string{key: value} + err := c.client.SetEnvVarOnWorkspace(workspaceID, envVars) if err != nil { return fmt.Errorf("failed to set environment variable: %w", err) } + return nil } @@ -88,6 +92,7 @@ func (c *APIClient) ExecuteCommand(workspaceID int, command string) error { if err != nil { return fmt.Errorf("failed to execute command: %w", err) } + return nil } @@ -97,6 +102,7 @@ func (c *APIClient) SyncLandscape(workspaceID int, profile string) error { if err != nil { return fmt.Errorf("failed to sync landscape: %w", err) } + return nil } @@ -106,6 +112,7 @@ func (c *APIClient) StartPipeline(workspaceID int, profile, stage string) error if err != nil { return fmt.Errorf("failed to start pipeline: %w", err) } + return nil } @@ -115,6 +122,7 @@ func (c *APIClient) GetPipelineState(workspaceID int, stage string) ([]api.Pipel if err != nil { return nil, fmt.Errorf("failed to get pipeline state: %w", err) } + return states, nil } @@ -124,6 +132,7 @@ func (c *APIClient) DeleteWorkspace(workspaceID int) error { if err != nil { return fmt.Errorf("failed to delete workspace: %w", err) } + return nil } @@ -133,6 +142,7 @@ func (c *APIClient) ListTeams(orgId string) ([]api.Team, error) { if err != nil { return nil, fmt.Errorf("failed to list teams: %w", err) } + return teams, nil } @@ -142,5 +152,6 @@ func (c *APIClient) ListWorkspacePlans() ([]api.WorkspacePlan, error) { if err != nil { return nil, fmt.Errorf("failed to list workspace plans: %w", err) } + return plans, nil } diff --git a/internal/codesphere/postgres.go b/internal/codesphere/postgres.go index 08fe2fd6..44a2abff 100644 --- a/internal/codesphere/postgres.go +++ b/internal/codesphere/postgres.go @@ -18,6 +18,7 @@ func (s PostgresService) DBUsername() string { if s.username != "" { return s.username + blueUserSuffix } + return s.Name + blueUserSuffix } diff --git a/internal/codesphere/teststeps/csgo.go b/internal/codesphere/teststeps/csgo.go index 10857a2f..4263e145 100644 --- a/internal/codesphere/teststeps/csgo.go +++ b/internal/codesphere/teststeps/csgo.go @@ -69,18 +69,22 @@ func getTeamID(c *SmoketestCodesphereOpts) (int, error) { if c.TeamID != "" { return strconv.Atoi(c.TeamID) } + teams, err := c.Client.ListTeams("") if err != nil { return 0, fmt.Errorf("failed to get teams: %w", err) } + if len(teams) == 0 { return 0, fmt.Errorf("no teams available") } + for _, team := range teams { if team.IsFirst != nil && *team.IsFirst { return team.Id, nil } } + return teams[0].Id, nil } @@ -90,13 +94,16 @@ func getPlanID(c *SmoketestCodesphereOpts) (int, error) { if c.PlanID != "" { return strconv.Atoi(c.PlanID) } + plans, err := c.Client.ListWorkspacePlans() if err != nil { return 0, fmt.Errorf("failed to get plans: %w", err) } + if len(plans) == 0 { return 0, fmt.Errorf("no workspace plans available") } + return plans[0].Id, nil } @@ -109,20 +116,26 @@ func (s *CreateWorkspaceStep) Run(ctx context.Context, c *SmoketestCodesphereOpt if parseErr != nil { return fmt.Errorf("failed to determine team-id: %w", parseErr) } + planID, parseErr := getPlanID(c) if parseErr != nil { return fmt.Errorf("failed to determine plan-id: %w", parseErr) } + workspaceName := fmt.Sprintf("smoketest-%s", time.Now().Format("20060102-150405")) c.logStep(fmt.Sprintf("Creating empty workspace '%s'", workspaceName)) + id, err := c.Client.CreateWorkspace(teamID, planID, workspaceName, nil) if err != nil { c.logFailure() return fmt.Errorf("failed to create workspace: %w", err) } + *workspaceID = id + c.logSuccess() + return nil } @@ -132,11 +145,14 @@ func (s *SetEnvVarStep) Name() string { return stepNameSetEnvVar } func (s *SetEnvVarStep) Run(ctx context.Context, c *SmoketestCodesphereOpts, workspaceID *int) error { c.logStep(fmt.Sprintf("Setting environment variable %s=%s", smoketestEnvVarKey, smoketestEnvVarValue)) + if err := c.Client.SetEnvVar(*workspaceID, smoketestEnvVarKey, smoketestEnvVarValue); err != nil { c.logFailure() return fmt.Errorf("failed to set environment variable: %w", err) } + c.logSuccess() + return nil } @@ -146,22 +162,29 @@ func (s *CreateFilesStep) Name() string { return stepNameCreateFiles } func (s *CreateFilesStep) Run(ctx context.Context, c *SmoketestCodesphereOpts, workspaceID *int) error { c.logStep("Creating ci.yml file") + ciYmlCmd := fmt.Sprintf(`echo '%s' > ci.yml`, ciYmlContent) + err := c.Client.ExecuteCommand(*workspaceID, ciYmlCmd) if err != nil { c.logFailure() return fmt.Errorf("failed to create ci.yml: %w", err) } + c.logSuccess() c.logStep("Creating index.html file") + indexHtmlCmd := fmt.Sprintf(`echo '%s' > index.html`, indexHtmlContent) + err = c.Client.ExecuteCommand(*workspaceID, indexHtmlCmd) if err != nil { c.logFailure() return fmt.Errorf("failed to create index.html: %w", err) } + c.logSuccess() + return nil } @@ -171,11 +194,14 @@ func (s *SyncLandscapeStep) Name() string { return stepNameSyncLandscape } func (s *SyncLandscapeStep) Run(ctx context.Context, c *SmoketestCodesphereOpts, workspaceID *int) error { c.logStep(fmt.Sprintf("Syncing landscape with profile '%s'", c.Profile)) + if err := c.Client.SyncLandscape(*workspaceID, c.Profile); err != nil { c.logFailure() return fmt.Errorf("failed to sync landscape: %w", err) } + c.logSuccess() + return nil } @@ -185,18 +211,23 @@ func (s *ExecuteRunStageStep) Name() string { return stepNameExecuteRunStage } func (s *ExecuteRunStageStep) Run(ctx context.Context, c *SmoketestCodesphereOpts, workspaceID *int) error { c.logStep(fmt.Sprintf("Executing '%s' pipeline stage", smoketestPipelineStage)) + if err := c.Client.StartPipeline(*workspaceID, c.Profile, smoketestPipelineStage); err != nil { c.logFailure() return fmt.Errorf("failed to start pipeline: %w", err) } + var lastErr error + for { select { case <-ctx.Done(): c.logFailure() + if lastErr != nil { return fmt.Errorf("timed out waiting for workspace to be running: %w", lastErr) } + return fmt.Errorf("timed out waiting for workspace to be running") default: } @@ -205,6 +236,7 @@ func (s *ExecuteRunStageStep) Run(ctx context.Context, c *SmoketestCodesphereOpt if err != nil { lastErr = err log.Printf("failed to get pipeline state, retrying: %s", err) + select { case <-ctx.Done(): c.logFailure() @@ -223,16 +255,20 @@ func (s *ExecuteRunStageStep) Run(ctx context.Context, c *SmoketestCodesphereOpt hasNonIdeServer := false allNonIdeRunning := true + for _, st := range states { if st.Server == ideServer { continue } + hasNonIdeServer = true + if st.State != pipelineStateRunning { allNonIdeRunning = false break } } + if hasNonIdeServer && allNonIdeRunning { c.logSuccess() return nil @@ -253,11 +289,14 @@ func (s *DeleteWorkspaceStep) Name() string { return stepNameDeleteWorkspace } func (s *DeleteWorkspaceStep) Run(ctx context.Context, c *SmoketestCodesphereOpts, workspaceID *int) error { c.logStep(fmt.Sprintf("\nDeleting workspace %d", *workspaceID)) + deleteErr := c.Client.DeleteWorkspace(*workspaceID) if deleteErr != nil { c.logFailure() return fmt.Errorf("failed to delete workspace: %w", deleteErr) } + c.logSuccess() + return nil } diff --git a/internal/configtemplating/config_template.go b/internal/configtemplating/config_template.go index 8183f440..e41b7892 100644 --- a/internal/configtemplating/config_template.go +++ b/internal/configtemplating/config_template.go @@ -26,6 +26,7 @@ func RenderInstallConfigTemplate(data []byte, store SecretStore) ([]byte, error) if store == nil { return "", fmt.Errorf("secret store is required to render config template") } + return store.LookupSecret(name, selector...) }, }). @@ -60,6 +61,7 @@ func RenderConfigFileToTemp(configPath string, store SecretStore) (string, func( if err != nil { return "", nil, fmt.Errorf("failed to create temporary rendered config: %w", err) } + tmpPath := tmp.Name() cleanup := func() { _ = os.Remove(tmpPath) @@ -67,14 +69,20 @@ func RenderConfigFileToTemp(configPath string, store SecretStore) (string, func( if err := tmp.Chmod(0600); err != nil { _ = tmp.Close() + cleanup() + return "", nil, fmt.Errorf("failed to restrict temporary rendered config permissions: %w", err) } + if _, err := tmp.Write(rendered); err != nil { _ = tmp.Close() + cleanup() + return "", nil, fmt.Errorf("failed to write temporary rendered config: %w", err) } + if err := tmp.Close(); err != nil { cleanup() return "", nil, fmt.Errorf("failed to close temporary rendered config: %w", err) diff --git a/internal/env/env.go b/internal/env/env.go index 22fd4153..19b5ebfd 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -29,6 +29,7 @@ func (e *Environment) GetOmsPortalApiKey() (string, error) { if apiToken == "" { return "", errors.New("OMS_PORTAL_API_KEY env var required, but not set") } + return apiToken, nil } @@ -37,6 +38,7 @@ func (e *Environment) GetOmsWorkdir() string { if workdir == "" { return "./oms-workdir" } + return workdir } @@ -45,6 +47,7 @@ func (e *Environment) GetOmsCacheDir() (string, error) { if err != nil { return "", err } + return filepath.Join(cacheDir, "oms"), nil } @@ -53,5 +56,6 @@ func (e *Environment) GetOmsPortalApi() string { if apiUrl == "" { return "https://oms-portal.codesphere.com/api" } + return apiUrl } diff --git a/internal/github/github.go b/internal/github/github.go index be6cc4b0..d9781625 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -15,6 +15,7 @@ func GetSSHKeysFromGitHubTeam(client GitHubClient, org, teamSlug string) (string if org == "" || teamSlug == "" { return "", fmt.Errorf("GitHub team slug and org must be specified to fetch SSH keys from GitHub team") } + allKeys := "" allMembers, err := listAllGitHubTeamMembers(client, org, teamSlug) @@ -26,6 +27,7 @@ func GetSSHKeysFromGitHubTeam(client GitHubClient, org, teamSlug string) (string for _, user := range allMembers { username := user.GetLogin() + keys, err := client.ListUserKeys(context.Background(), username) if err != nil { fmt.Printf("Could not fetch keys for %s: %v\n", username, err) @@ -44,6 +46,7 @@ func GetSSHKeysFromGitHubTeam(client GitHubClient, org, teamSlug string) (string func listAllGitHubTeamMembers(client GitHubClient, org string, teamSlug string) ([]*github.User, error) { perPage := 100 page := 1 + var allMembers []*github.User for { diff --git a/internal/github/github_client.go b/internal/github/github_client.go index 0e25e8a8..cbc47930 100644 --- a/internal/github/github_client.go +++ b/internal/github/github_client.go @@ -26,6 +26,7 @@ type RealGitHubClient struct { func NewGitHubClient(ctx context.Context, token string) *RealGitHubClient { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) tc := oauth2.NewClient(ctx, ts) + return &RealGitHubClient{client: github.NewClient(tc)} } diff --git a/internal/github/github_test.go b/internal/github/github_test.go index f4357c84..0985ff75 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -75,6 +75,7 @@ var _ = Describe("Github", func() { for i := 0; i < 100; i++ { membersPage1[i] = &gh.User{Login: gh.Ptr(fmt.Sprintf("user%d", i+1))} } + membersPage2 := make([]*gh.User, 50) for i := 0; i < 50; i++ { membersPage2[i] = &gh.User{Login: gh.Ptr(fmt.Sprintf("user%d", i+101))} @@ -89,6 +90,7 @@ var _ = Describe("Github", func() { keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).ToNot(HaveOccurred()) + for i := 1; i <= 150; i++ { Expect(keys).To(ContainSubstring(fmt.Sprintf("root:ssh-rsa AAAUSER%d... user%d", i, i))) Expect(keys).To(ContainSubstring(fmt.Sprintf("ubuntu:ssh-rsa AAAUSER%d... user%d", i, i))) @@ -110,6 +112,5 @@ var _ = Describe("Github", func() { Expect(keys).To(BeEmpty()) }) }) - }) }) diff --git a/internal/installer/argocd/argocd_resources.go b/internal/installer/argocd/argocd_resources.go index dc307be6..a2f557b7 100644 --- a/internal/installer/argocd/argocd_resources.go +++ b/internal/installer/argocd/argocd_resources.go @@ -73,6 +73,7 @@ func (a *argoCDResources) ApplyAll(ctx context.Context) error { func (a *argoCDResources) applyLocalCluster(ctx context.Context) error { log.Println("Applying local cluster secret... ") + rendered, err := k8s.RenderTemplate(localClusterTpl, map[string]string{ "DC_NUMBER": a.DatacenterId, }) @@ -85,6 +86,7 @@ func (a *argoCDResources) applyLocalCluster(ctx context.Context) error { func (a *argoCDResources) applyHelmRegistrySecret(ctx context.Context) error { log.Println("Applying helm registry secret... ") + rendered, err := k8s.RenderTemplate(helmRegistryTpl, map[string]string{ "SECRET_CODESPHERE_OCI_READ": a.OciPassword, "OCI_REGISTRY_URL": a.OciRegistryURL, @@ -98,6 +100,7 @@ func (a *argoCDResources) applyHelmRegistrySecret(ctx context.Context) error { func (a *argoCDResources) applyGitRepoSecret(ctx context.Context) error { log.Println("Applying git repo secret... ") + rendered, err := k8s.RenderTemplate(gitRepoTpl, map[string]string{ "SECRET_CODESPHERE_REPOS_READ": a.GitPassword, }) diff --git a/internal/installer/argocd/install_and_apps.go b/internal/installer/argocd/install_and_apps.go index 035612f4..7e41929a 100644 --- a/internal/installer/argocd/install_and_apps.go +++ b/internal/installer/argocd/install_and_apps.go @@ -47,9 +47,11 @@ func (i *AppInstaller) InstallArgoCD() error { if i.cfg.Installer == nil { return fmt.Errorf("ArgoCD installer is required") } + if err := i.cfg.Installer.Install(); err != nil { return fmt.Errorf("failed to install ArgoCD: %w", err) } + return nil } @@ -59,10 +61,12 @@ func (i *AppInstaller) SyncVaultSecret(ctx context.Context) error { if err := secrets.EnsureServiceAccountTokens(i.cfg.Vault); err != nil { return fmt.Errorf("failed to ensure service account tokens: %w", err) } + creator := vault.NewVaultSecretCreator(i.cfg.KubeClient) if err := creator.CreateSecretFromVault(ctx, i.cfg.Vault, vault.VaultSecretNamespace, vault.VaultSecretName); err != nil { return fmt.Errorf("failed to sync vault secret: %w", err) } + return nil } @@ -79,8 +83,10 @@ func (i *AppInstaller) InstallPCApps(ctx context.Context, bomPath string) error if err != nil { return fmt.Errorf("failed to initialize pc-apps installer: %w", err) } + if err := pcApps.Install(ctx); err != nil { return fmt.Errorf("failed to install pc-apps: %w", err) } + return nil } diff --git a/internal/installer/argocd/install_and_apps_test.go b/internal/installer/argocd/install_and_apps_test.go index 66bbec08..01caaf92 100644 --- a/internal/installer/argocd/install_and_apps_test.go +++ b/internal/installer/argocd/install_and_apps_test.go @@ -88,6 +88,7 @@ users: } vaultYAML, err := installVault.Marshal() Expect(err).ToNot(HaveOccurred()) + plaintextVaultPath := filepath.Join(secretsDir, "prod.vault.plain.yaml") Expect(os.WriteFile(plaintextVaultPath, vaultYAML, 0600)).To(Succeed()) diff --git a/internal/installer/argocd/installer.go b/internal/installer/argocd/installer.go index be03780c..2bf1a27a 100644 --- a/internal/installer/argocd/installer.go +++ b/internal/installer/argocd/installer.go @@ -56,24 +56,29 @@ func NewInstaller(cfg InstallerConfig) (*Installer, error) { if err != nil { return nil, fmt.Errorf("creating kubernetes clients: %w", err) } + resources, err := NewArgoCDResources(clientset, cfg.DatacenterId, cfg.OciPassword, cfg.OciRegistryURL, cfg.GitPassword) if err != nil { return nil, fmt.Errorf("init argocd resources client failed: %w", err) } + return &Installer{ InstallerConfig: cfg, Helm: helm, Resources: resources, }, nil } + helm, err := installer.NewHelmClient(DefaultNamespace) if err != nil { return nil, fmt.Errorf("init helm client failed: %w", err) } + clientset, _, err := k8s.NewClients() if err != nil { return nil, fmt.Errorf("creating kubernetes clients: %w", err) } + resources, err := NewArgoCDResources(clientset, cfg.DatacenterId, cfg.OciPassword, cfg.OciRegistryURL, cfg.GitPassword) if err != nil { return nil, fmt.Errorf("init argocd resources client failed: %w", err) @@ -164,6 +169,7 @@ func (a *Installer) install(ctx context.Context, cfg installer.ChartConfig) erro } else { fmt.Println("Successfully installed Argo CD (latest chart version)") } + return nil } @@ -176,6 +182,7 @@ func (a *Installer) upgrade(ctx context.Context, cfg installer.ChartConfig, exis if err != nil { return fmt.Errorf("failed to parse installed version %q: %w", existing.InstalledVersion, err) } + requestedSemver, err := semver.NewVersion(a.Version) if err != nil { return fmt.Errorf("failed to parse requested version %q: %w", a.Version, err) @@ -187,6 +194,7 @@ func (a *Installer) upgrade(ctx context.Context, cfg installer.ChartConfig, exis a.Version, existing.InstalledVersion, ) } + log.Printf("Upgrading ArgoCD from %s to %s\n", existing.InstalledVersion, a.Version) } else { log.Printf("Upgrading ArgoCD from %s to latest\n", existing.InstalledVersion) @@ -201,6 +209,7 @@ func (a *Installer) upgrade(ctx context.Context, cfg installer.ChartConfig, exis } else { fmt.Println("Successfully upgraded Argo CD to the latest chart version") } + return nil } @@ -208,11 +217,13 @@ func (a *Installer) validateRepoURL() error { if a.RepoURL == "" { return nil } + for _, prefix := range []string{"http://", "https://", "oci://"} { if strings.HasPrefix(a.RepoURL, prefix) { return nil } } + return fmt.Errorf("invalid repo URL %q: must start with http://, https://, or oci://", a.RepoURL) } @@ -221,9 +232,11 @@ func (a *Installer) resolveChartRef(chartName string) (string, string) { if repoURL == "" { repoURL = DefaultRepoURL } + if strings.HasPrefix(repoURL, "oci://") { return strings.TrimRight(repoURL, "/") + "/" + chartName, "" } + return chartName, repoURL } diff --git a/internal/installer/argocd/installer_test.go b/internal/installer/argocd/installer_test.go index 631f4682..f14db10a 100644 --- a/internal/installer/argocd/installer_test.go +++ b/internal/installer/argocd/installer_test.go @@ -18,6 +18,7 @@ import ( func writeValuesFile(content string) string { path := filepath.Join(GinkgoT().TempDir(), "values.yaml") Expect(os.WriteFile(path, []byte(content), 0o600)).To(Succeed()) + return path } @@ -200,7 +201,9 @@ var _ = Describe("Installer.Install", func() { if !ok || dex["enabled"] != false { return false } + server, ok := cfg.Values["server"].(map[string]interface{}) + return ok && server["replicas"] == float64(2) }), mock.Anything).Return(nil) @@ -219,7 +222,9 @@ var _ = Describe("Installer.Install", func() { if !ok || dex["enabled"] != true { return false } + server, ok := cfg.Values["server"].(map[string]interface{}) + return ok && server["replicas"] == float64(3) }), mock.Anything).Return(nil) @@ -269,6 +274,7 @@ var _ = Describe("Installer.Install", func() { It("installs extra ArgoCD resources when FullInstall option in true", func() { argoCDResourcesMock.EXPECT().ApplyAll(mock.Anything).Return(nil) + a.FullInstall = true err := a.Install() diff --git a/internal/installer/bom/bom.go b/internal/installer/bom/bom.go index a532acb7..5a8fc0e6 100644 --- a/internal/installer/bom/bom.go +++ b/internal/installer/bom/bom.go @@ -57,10 +57,12 @@ func Parse(filePath string) (*Config, error) { if err != nil { return nil, fmt.Errorf("failed to read BOM file: %w", err) } + var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to parse JSON BOM: %w", err) } + return &cfg, nil } @@ -74,18 +76,22 @@ func (b *Config) GetPCApps() (reference.Tagged, bool) { if !ok { return nil, false } + chart, ok := comp.Files["chart"] if !ok || chart.OciRef == "" { return nil, false } + ref, err := reference.ParseNormalizedNamed(chart.OciRef) if err != nil { return nil, false } + tagged, ok := ref.(reference.Tagged) if !ok { return nil, false } + return tagged, true } @@ -94,9 +100,11 @@ func (b *Config) GetCodesphereContainerImages() (map[string]string, error) { if b.Components == nil { return nil, fmt.Errorf("codesphere component not found in BOM") } + comp, exists := b.Components["codesphere"] if !exists { return nil, fmt.Errorf("codesphere component not found in BOM") } + return comp.ContainerImages, nil } diff --git a/internal/installer/bom/bom_test.go b/internal/installer/bom/bom_test.go index 0576388b..872af70c 100644 --- a/internal/installer/bom/bom_test.go +++ b/internal/installer/bom/bom_test.go @@ -23,6 +23,7 @@ var _ = Describe("Bom", func() { BeforeEach(func() { var err error + tempDir, err = os.MkdirTemp("", "bom_test") Expect(err).NotTo(HaveOccurred()) diff --git a/internal/installer/cluster_admin.go b/internal/installer/cluster_admin.go index 448d07a6..feb403ea 100644 --- a/internal/installer/cluster_admin.go +++ b/internal/installer/cluster_admin.go @@ -85,6 +85,7 @@ func EnsureClusterAdminSecret(ctx context.Context, vaultPath, privKey string, cf if err != nil { return err } + clientset, _, err := util.NewClientsFromRESTConfig(restConfig) if err != nil { return fmt.Errorf("failed to create kubernetes client: %w", err) diff --git a/internal/installer/codesphere.go b/internal/installer/codesphere.go index c61b1fa7..942bae6c 100644 --- a/internal/installer/codesphere.go +++ b/internal/installer/codesphere.go @@ -96,6 +96,7 @@ func (ci *CodesphereInstaller) Install(pm PackageManager, cm ConfigManager, im s if err != nil { return err } + if !ci.HasExecutableSteps(config) { log.Println("No executable installer steps remain after applying skip configuration. Skipping installer run.") return nil @@ -117,25 +118,30 @@ func (ci *CodesphereInstaller) loadConfig(cm ConfigManager) (files.RootConfig, e if err != nil { return files.RootConfig{}, fmt.Errorf("failed to extract config.yaml: %w", err) } + if err := validatePostgresServerAddress(config.Postgres); err != nil { return files.RootConfig{}, fmt.Errorf("invalid postgres configuration: %w", err) } ci.warnIfVaultDirDiffersFromSecretsDir(config) + return config, nil } // IsStepSkipped reports whether step is present in persisted or CLI skip steps. func IsStepSkipped(config files.RootConfig, skipSteps []string, step string) bool { skippedSteps := map[string]bool{} + if config.Operations != nil { for _, skippedStep := range config.Operations.Skip { skippedSteps[skippedStep] = true } } + for _, skippedStep := range skipSteps { skippedSteps[skippedStep] = true } + return skippedSteps[step] } @@ -191,9 +197,11 @@ func (ci *CodesphereInstaller) ExtractAndValidatePackage(pm PackageManager) erro if !slices.Contains(foundFiles, "deps.tar.gz") { return fmt.Errorf("deps.tar.gz not found in package") } + if !slices.Contains(foundFiles, "private-cloud-installer.js") { return fmt.Errorf("private-cloud-installer.js not found in package") } + if !slices.Contains(foundFiles, "node") { return fmt.Errorf("node executable not found in package") } @@ -217,7 +225,9 @@ func (ci *CodesphereInstaller) listPackageFiles(pm PackageManager) ([]string, er } log.Printf("Listing contents of %s", packageDir) + foundFiles := []string{} + for _, entry := range entries { filename := entry.Name() log.Printf("- %s", filename) @@ -289,6 +299,7 @@ func (ci *CodesphereInstaller) buildWorkspaceImage( } log.Printf("Pushing image to %s", buildTag) + if err := im.PushImage(buildTag); err != nil { return fmt.Errorf("failed to push image %s: %w", buildTag, err) } @@ -304,6 +315,7 @@ func splitImageTag(fullImageTag string) (string, string, error) { imageNameAndPath := parts[0] version := parts[1] + return path.Base(imageNameAndPath), version, nil } @@ -319,6 +331,7 @@ func (ci *CodesphereInstaller) extractAndLoadRootImage(pm PackageManager, im sys } log.Printf("Loaded root image '%s'", extractedImagePath) + return nil } @@ -330,6 +343,7 @@ func updateDockerfileFromStatement(pm PackageManager, dockerfile, fullImageTag s defer util.CloseFileIgnoreError(dockerfileFile) dockerfileManager := util.NewDockerfileManager() + updatedContent, err := dockerfileManager.UpdateFromStatement(dockerfileFile, fullImageTag) if err != nil { return fmt.Errorf("failed to update FROM statement: %w", err) @@ -340,6 +354,7 @@ func updateDockerfileFromStatement(pm PackageManager, dockerfile, fullImageTag s } log.Printf("Successfully updated FROM statement in %s to use %s", dockerfile, fullImageTag) + return nil } @@ -374,6 +389,7 @@ func (ci *CodesphereInstaller) runInstaller(pm PackageManager, config files.Root } log.Println("Private cloud installer script finished.") + return nil } @@ -391,6 +407,7 @@ func (ci *CodesphereInstaller) installerCommandArgs(pm PackageManager, config fi } executedSteps := []string{} + for step, executed := range executableSteps { if !executed { cmdArgs = append(cmdArgs, "--skipStep", step) @@ -402,6 +419,7 @@ func (ci *CodesphereInstaller) installerCommandArgs(pm PackageManager, config fi sort.Strings(executedSteps) prompt := NewPrompter(!ci.AutoApprove) + msg := fmt.Sprintf("The following steps will be executed: %s. Type \"yes\" to continue.", strings.Join(executedSteps, ", ")) if prompt.String(msg, "yes") != "yes" { return nil, fmt.Errorf("installation aborted") @@ -438,6 +456,7 @@ func (ci *CodesphereInstaller) executableInstallerSteps(config files.RootConfig) // ExecutableSteps returns the sorted installer steps that remain after allowlist and skip filtering. func (ci *CodesphereInstaller) ExecutableSteps(config files.RootConfig) []string { executableSteps := ci.executableInstallerSteps(config) + steps := make([]string, 0, len(executableSteps)) for _, step := range KnownInstallerSteps { if executableSteps[step] { diff --git a/internal/installer/codesphere_test.go b/internal/installer/codesphere_test.go index 9828d9d3..cf2302ee 100644 --- a/internal/installer/codesphere_test.go +++ b/internal/installer/codesphere_test.go @@ -20,6 +20,7 @@ var _ = Describe("Codesphere installer", func() { } configManager := installer.NewMockConfigManager(GinkgoT()) configManager.EXPECT().ParseConfigYaml("config.yaml").Return(config, nil) + packageManager := installer.NewMockPackageManager(GinkgoT()) ci := &installer.CodesphereInstaller{ConfigPath: "config.yaml"} diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 095ab86c..80e5358d 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -47,6 +47,7 @@ func k8sNodesToStringSlice(nodes []files.K8sNode) []string { for i, node := range nodes { ips[i] = node.IPAddress } + return ips } @@ -55,11 +56,13 @@ func stringSliceToK8sNodes(ips []string) []files.K8sNode { for i, ip := range ips { nodes[i] = files.K8sNode{IPAddress: ip} } + return nodes } func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { log.Println("=== Datacenter Configuration ===") + g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) g.Config.Datacenter.Name = g.collectString(prompter, "Datacenter name", g.Config.Datacenter.Name) g.Config.Datacenter.City = g.collectString(prompter, "Datacenter city", g.Config.Datacenter.City) @@ -69,6 +72,7 @@ func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { log.Println("\n=== Container Registry Configuration ===") + g.Config.Registry.Server = g.collectString(prompter, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") if g.Config.Registry.Server != "" { g.Config.Registry.ReplaceImagesInBom = prompter.Bool("Replace images in BOM", g.Config.Registry.ReplaceImagesInBom) @@ -78,20 +82,24 @@ func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { log.Println("\n=== PostgreSQL Configuration ===") + g.Config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") if g.Config.Postgres.Mode == "install" { if g.Config.Postgres.Primary == nil { g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} } + defaultPrimaryIP := g.Config.Postgres.Primary.IP if defaultPrimaryIP == "" { defaultPrimaryIP = "10.50.0.2" } + defaultPrimaryHostname := g.Config.Postgres.Primary.Hostname if defaultPrimaryHostname == "" { defaultPrimaryHostname = "pg-primary-node" } + g.Config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", defaultPrimaryIP) g.Config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", defaultPrimaryHostname) @@ -100,6 +108,7 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { if g.Config.Postgres.Replica == nil { g.Config.Postgres.Replica = &files.PostgresReplicaConfig{} } + g.Config.Postgres.Replica.IP = g.collectString(prompter, "Replica PostgreSQL server IP", "10.50.0.3") g.Config.Postgres.Replica.Name = g.collectString(prompter, "Replica name (lowercase alphanumeric + underscore only)", "replica1") } else { @@ -112,10 +121,12 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { func (g *InstallConfig) collectCephConfig(prompter *Prompter) { log.Println("\n=== Ceph Configuration ===") + g.Config.Ceph.NodesSubnet = g.collectString(prompter, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") if len(g.Config.Ceph.Hosts) == 0 { numHosts := prompter.Int("Number of Ceph hosts", 3) + g.Config.Ceph.Hosts = make([]files.CephHost, numHosts) for i := 0; i < numHosts; i++ { log.Printf("\nCeph Host %d:\n", i+1) @@ -125,6 +136,7 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter) { } } else { existingHosts := g.Config.Ceph.Hosts + g.Config.Ceph.Hosts = make([]files.CephHost, len(existingHosts)) for i, host := range existingHosts { g.Config.Ceph.Hosts[i] = files.CephHost(host) @@ -134,6 +146,7 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter) { func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { log.Println("\n=== Kubernetes Configuration ===") + g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) if g.Config.Kubernetes.ManagedByCodesphere { @@ -141,12 +154,14 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { if defaultAPIServerHost == "" { defaultAPIServerHost = "10.50.0.2" } + g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", defaultAPIServerHost) defaultControlPlanes := k8sNodesToStringSlice(g.Config.Kubernetes.ControlPlanes) if len(defaultControlPlanes) == 0 { defaultControlPlanes = []string{"10.50.0.2"} } + defaultWorkers := k8sNodesToStringSlice(g.Config.Kubernetes.Workers) controlPlaneIPs := g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", defaultControlPlanes) @@ -159,12 +174,14 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { g.Config.Kubernetes.PodCIDR = g.collectString(prompter, "Pod CIDR of external cluster", "100.96.0.0/11") g.Config.Kubernetes.ServiceCIDR = g.collectString(prompter, "Service CIDR of external cluster", "100.64.0.0/13") g.Config.Kubernetes.NeedsKubeConfig = true + log.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") } } func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { log.Println("\n=== Cluster Gateway Configuration ===") + g.Config.Cluster.Gateway.ServiceType = g.collectChoice(prompter, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") if g.Config.Cluster.Gateway.ServiceType == "ExternalIP" { g.Config.Cluster.Gateway.IPAddresses = g.collectStringSlice(prompter, "Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) @@ -186,6 +203,7 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { if defaultNumPools == 0 { defaultNumPools = 1 } + numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) g.Config.MetalLB.Pools = make([]files.MetalLBPoolDef, numPools) @@ -193,11 +211,14 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { log.Printf("\nMetalLB Pool %d:\n", i+1) defaultName := fmt.Sprintf("pool-%d", i+1) + var defaultIPs []string + if i < len(g.Config.MetalLB.Pools) { defaultName = g.Config.MetalLB.Pools[i].Name defaultIPs = g.Config.MetalLB.Pools[i].IPAddresses } + if len(defaultIPs) == 0 { defaultIPs = []string{"10.10.10.100-10.10.10.200"} } @@ -228,52 +249,62 @@ func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { if !certIssuer.Acme.Enabled { certIssuer.Acme = nil certIssuer.Type = files.CertIssuerTypeSelfSigned + return } + certIssuer.Type = files.CertIssuerTypeACME defaultIssuerName := certIssuer.Acme.Name if defaultIssuerName == "" { defaultIssuerName = "acme-issuer" } + certIssuer.Acme.Name = g.collectString(prompter, "ACME issuer name", defaultIssuerName) defaultEmail := certIssuer.Acme.Email if defaultEmail == "" { defaultEmail = "admin@example.com" } + certIssuer.Acme.Email = g.collectString(prompter, "Email address for ACME account registration", defaultEmail) defaultServer := certIssuer.Acme.Server if defaultServer == "" { defaultServer = "https://acme-v02.api.letsencrypt.org/directory" } + certIssuer.Acme.Server = g.collectString(prompter, "ACME server URL", defaultServer) // External Account Binding (EAB) log.Println("\n--- External Account Binding (Optional) ---") + hasEAB := prompter.Bool("Configure External Account Binding (required by some ACME CAs)", certIssuer.Acme.EABKeyID != "") certIssuer.Acme.EABKeyID = "" if hasEAB { certIssuer.Acme.EABKeyID = g.collectString(prompter, "EAB Key ID", certIssuer.Acme.EABKeyID) existingEabKey := "" + if g.Vault != nil { if s := g.Vault.GetSecret(files.SecretAcmeEabMacKey); s != nil && s.Fields != nil { existingEabKey = s.Fields.Password } } + newEabKey := g.collectString(prompter, "EAB MAC Key", existingEabKey) if newEabKey != "" { if g.Vault == nil { g.Vault = &files.InstallVault{} } + g.Vault.SetSecret(files.SecretEntry{Name: files.SecretAcmeEabMacKey, Fields: &files.SecretFields{Password: newEabKey}}) } } // DNS-01 Challenge Configuration log.Println("\n--- DNS-01 Challenge Configuration (Optional) ---") + if certIssuer.Acme.Solver.DNS01 == nil { certIssuer.Acme.Solver.DNS01 = &files.ACMEDNS01Solver{} } @@ -283,34 +314,43 @@ func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { certIssuer.Acme.Solver.DNS01 = nil return } + providerOptions := []string{"route53", "cloudflare", "azure", "gcp", "other"} + defaultProvider := certIssuer.Acme.Solver.DNS01.Provider if defaultProvider == "" { defaultProvider = "cloudflare" } + certIssuer.Acme.Solver.DNS01.Provider = g.collectChoice(prompter, "DNS provider", providerOptions, defaultProvider) + log.Println("Note: Additional DNS provider configuration will need to be added to the vault file.") log.Println("Provider config and secrets should be added manually after generation.") } func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { log.Println("\n=== Codesphere Application Configuration ===") + defaultDomain := g.Config.Codesphere.Domain if defaultDomain == "" { defaultDomain = "codesphere.yourcompany.com" } + defaultWorkspaceDomain := g.Config.Codesphere.WorkspaceHostingBaseDomain if defaultWorkspaceDomain == "" { defaultWorkspaceDomain = "ws.yourcompany.com" } + defaultCustomDomain := g.Config.Codesphere.CustomDomains.CNameBaseDomain if defaultCustomDomain == "" { defaultCustomDomain = "custom.yourcompany.com" } + defaultDNSServers := g.Config.Codesphere.DNSServers if len(defaultDNSServers) == 0 { defaultDNSServers = []string{"1.1.1.1", "8.8.8.8"} } + g.Config.Codesphere.Domain = g.collectString(prompter, "Main Codesphere domain", defaultDomain) g.Config.Codesphere.WorkspaceHostingBaseDomain = g.collectString(prompter, "Workspace base domain (*.domain should point to public gateway)", defaultWorkspaceDomain) g.Config.Codesphere.PublicIP = g.collectString(prompter, "Primary public IP for workspaces", "") @@ -322,6 +362,7 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { if g.Config.Codesphere.WorkspaceImages == nil { g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} } + if g.Config.Codesphere.WorkspaceImages.Agent == nil { g.Config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{} } @@ -330,6 +371,7 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { if defaultBomRef == "" { defaultBomRef = "workspace-agent-24.04" } + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = g.collectString(prompter, "Workspace agent image BOM reference", defaultBomRef) hostingPlan := files.HostingPlan{} hostingPlan.CPUTenth = g.collectInt(prompter, "Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) @@ -342,14 +384,17 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { } defaultWorkspacePlanName := "Standard Developer" defaultMaxReplicas := 3 + if existingPlan, ok := g.Config.Codesphere.Plans.WorkspacePlans[1]; ok { if existingPlan.Name != "" { defaultWorkspacePlanName = existingPlan.Name } + if existingPlan.MaxReplicas > 0 { defaultMaxReplicas = existingPlan.MaxReplicas } } + workspacePlan.Name = g.collectString(prompter, "Workspace plan name", defaultWorkspacePlanName) workspacePlan.MaxReplicas = g.collectInt(prompter, "Max replicas per workspace", defaultMaxReplicas) @@ -367,6 +412,7 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { func (g *InstallConfig) collectOpenBaoConfig(prompter *Prompter) { log.Println("\n=== OpenBao Configuration (Optional) ===") + hasOpenBao := prompter.Bool("Configure OpenBao integration", g.Config.Codesphere.OpenBao != nil && g.Config.Codesphere.OpenBao.URI != "") if !hasOpenBao { g.Config.Codesphere.OpenBao = nil @@ -380,11 +426,13 @@ func (g *InstallConfig) collectOpenBaoConfig(prompter *Prompter) { g.Config.Codesphere.OpenBao.URI = g.collectString(prompter, "OpenBao URI (e.g., https://openbao.example.com)", "") g.Config.Codesphere.OpenBao.Engine = g.collectString(prompter, "OpenBao engine name", "cs-secrets-engine") g.Config.Codesphere.OpenBao.User = g.collectString(prompter, "OpenBao username", "admin") + openBaoPassword := g.collectString(prompter, "OpenBao password", "") if openBaoPassword != "" { if g.Vault == nil { g.Vault = &files.InstallVault{} } + g.Vault.SetSecret(files.SecretEntry{Name: files.SecretOpenBaoPassword, Fields: &files.SecretFields{Password: openBaoPassword}}) } } diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 59f0e8ee..0db12d7a 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -72,6 +72,7 @@ func (g *InstallConfig) encryptVault(src, target, recipient string) error { if g.vaultEncryptor == nil { return fmt.Errorf("vault encryptor is not configured") } + return g.vaultEncryptor.Encrypt(src, target, recipient) } @@ -79,11 +80,13 @@ func (g *InstallConfig) resolveAgeKey(explicitKeyFile, fallbackDir string) (reci if g.ageKeyResolver == nil { return "", "", fmt.Errorf("age key resolver is not configured") } + return g.ageKeyResolver.Resolve(explicitKeyFile, fallbackDir) } func NewInstallConfigManager() InstallConfigManager { config := files.NewRootConfig() + return &InstallConfig{ fileIO: &util.FilesystemWriter{}, vaultEncryptor: vault.SOPSEncryptor{}, @@ -100,6 +103,7 @@ func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { } store := vault.NewVaultTemplatingSecretStore(g.Vault) + data, err = configtemplating.RenderInstallConfigTemplate(data, store) if err != nil { return err @@ -111,6 +115,7 @@ func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { } g.Config = &config + return nil } @@ -123,6 +128,7 @@ func (g *InstallConfig) LoadVaultFromFile(vaultPath string) error { } g.Vault = vault + return nil } @@ -134,6 +140,7 @@ func (g *InstallConfig) LoadVaultFromUnecryptedFile(vaultPath string) error { } g.Vault = vault + return nil } @@ -147,6 +154,7 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if g.Config.Datacenter.ID == 0 { errors = append(errors, "datacenter ID is required") } + if g.Config.Datacenter.Name == "" { errors = append(errors, "datacenter name is required") } @@ -162,12 +170,14 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if err := validatePostgresServerAddress(g.Config.Postgres); err != nil { errors = append(errors, err.Error()) } + if g.Config.Postgres.Primary == nil { errors = append(errors, "postgres primary configuration is required when mode is 'install'") } else { if g.Config.Postgres.Primary.IP == "" { errors = append(errors, "postgres primary IP is required") } + if g.Config.Postgres.Primary.Hostname == "" { errors = append(errors, "postgres primary hostname is required") } @@ -181,6 +191,7 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if len(g.Config.Ceph.Hosts) == 0 { errors = append(errors, "at least one Ceph host is required") } + for _, host := range g.Config.Ceph.Hosts { if !IsValidIP(host.IPAddress) { errors = append(errors, fmt.Sprintf("invalid Ceph host IP: %s", host.IPAddress)) @@ -195,6 +206,7 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if g.Config.Kubernetes.PodCIDR == "" { errors = append(errors, "pod CIDR is required for external Kubernetes") } + if g.Config.Kubernetes.ServiceCIDR == "" { errors = append(errors, "service CIDR is required for external Kubernetes") } @@ -208,12 +220,15 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if g.Config.Codesphere.OpenBao.URI == "" { errors = append(errors, "OpenBao URI is required when OpenBao integration is enabled") } + if _, err := url.ParseRequestURI(g.Config.Codesphere.OpenBao.URI); err != nil { errors = append(errors, "OpenBao URI must be a valid URL") } + if g.Config.Codesphere.OpenBao.Engine == "" { errors = append(errors, "OpenBao engine name is required when OpenBao integration is enabled") } + if g.Config.Codesphere.OpenBao.User == "" { errors = append(errors, "OpenBao username is required when OpenBao integration is enabled") } @@ -223,6 +238,7 @@ func (g *InstallConfig) ValidateInstallConfig() []string { if ob.DestinationPath == "" { errors = append(errors, "openfga backups destinationPath is required when openfgaBackups is enabled") } + if ob.EndpointURL == "" { errors = append(errors, "openfga backups endpointURL is required when openfgaBackups is enabled") } @@ -335,6 +351,7 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { defer func() { _ = g.fileIO.Remove(plainPath) }() + if err := g.fileIO.WriteFile(plainPath, vaultYAML, 0600); err != nil { return fmt.Errorf("failed to write temporary plaintext vault: %w", err) } @@ -354,6 +371,7 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { if err := g.fileIO.Chmod(encryptedPath, 0600); err != nil { return fmt.Errorf("failed to set encrypted vault permissions: %w", err) } + if err := g.fileIO.Rename(encryptedPath, vaultPath); err != nil { return fmt.Errorf("failed to replace encrypted vault: %w", err) } @@ -365,6 +383,7 @@ func (g *InstallConfig) marshalVault(vaultPath string, withComments bool) ([]byt if g.Config == nil { return nil, fmt.Errorf("no configuration provided - config is nil") } + if g.Vault == nil { g.Vault = &files.InstallVault{} } @@ -391,6 +410,7 @@ func AddConfigComments(yamlData []byte) []byte { # For more information, see the installation documentation. ` + return append([]byte(header), yamlData...) } @@ -414,6 +434,7 @@ func AddVaultComments(yamlData []byte) []byte { # sops prod.vault.yaml ` + return append([]byte(header), yamlData...) } @@ -429,5 +450,6 @@ func (g *InstallConfig) ApplyProfile(profile string) error { case PROFILE_MINIMAL: return g.applyProfileMinimal() } + return fmt.Errorf("unknown profile: %s, available profiles: dev, prod, minimal", profile) } diff --git a/internal/installer/config_manager_ansible.go b/internal/installer/config_manager_ansible.go index 6ec43f17..1c45c678 100644 --- a/internal/installer/config_manager_ansible.go +++ b/internal/installer/config_manager_ansible.go @@ -98,6 +98,7 @@ func (i *ansibleInventory) fetchCephHosts() ([]files.CephHost, error) { } count := 0 + for _, key := range getSortedHostsGroupKeys(i.Ceph.Hosts) { hostVars := i.Ceph.Hosts[key] diff --git a/internal/installer/config_manager_ansible_test.go b/internal/installer/config_manager_ansible_test.go index 7d9d374f..e8f92005 100644 --- a/internal/installer/config_manager_ansible_test.go +++ b/internal/installer/config_manager_ansible_test.go @@ -41,6 +41,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() _, err = file.Write([]byte("")) @@ -56,6 +57,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() _, err = file.Write([]byte("{")) @@ -71,6 +73,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error for missing hosts block", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `ceph: @@ -90,6 +93,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error for missing host variables", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `ceph: @@ -109,6 +113,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error for typo in internal_ip", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `ceph: @@ -129,6 +134,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("returns an error for empty internal_ip entry", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `ceph: @@ -151,6 +157,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("creates a host list in the config", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventory := `ceph: @@ -191,6 +198,7 @@ var _ = Describe("ConfigManagerAnsible", func() { actualK8sCPHosts := manager.GetInstallConfig().Kubernetes.ControlPlanes Expect(actualK8sCPHosts).To(BeEmpty()) + actualK8sWorkers := manager.GetInstallConfig().Kubernetes.Workers Expect(actualK8sWorkers).To(BeEmpty()) }) @@ -198,6 +206,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("converts any value into string", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `ceph: @@ -216,6 +225,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("keeps pre-existing control plan config, if inventory has none", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventory := `ceph: @@ -289,6 +299,7 @@ var _ = Describe("ConfigManagerAnsible", func() { It("creates a host list in the config", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventory := `k8s-cp: @@ -347,6 +358,7 @@ k8s-workers: It("overwrites previously set kubernetes nodes (from profiles) with inventory values", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventory := `k8s-cp: @@ -416,6 +428,7 @@ k8s-workers: It("returns an error for missing hosts block", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `k8s-cp: @@ -435,6 +448,7 @@ k8s-workers: It("returns an error for missing hosts block", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `k8s-workers: @@ -454,6 +468,7 @@ k8s-workers: It("returns an error for missing host variables", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `k8s-cp: @@ -473,6 +488,7 @@ k8s-workers: It("returns an error for typo in internal_ip", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `k8s-cp: @@ -493,6 +509,7 @@ k8s-workers: It("returns an error for empty internal_ip entry", func() { file, err := os.Create(inventoryFilePath) Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(inventoryFilePath) }() inputInventoryYaml := `k8s-cp: diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index ec4f0b25..1160dc18 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -25,9 +25,11 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Datacenter.ID == 0 { g.Config.Datacenter.ID = 1 } + if g.Config.Datacenter.City == "" { g.Config.Datacenter.City = "Karlsruhe" } + if g.Config.Datacenter.CountryCode == "" { g.Config.Datacenter.CountryCode = "DE" } @@ -35,9 +37,11 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Ceph.NodesSubnet == "" { g.Config.Ceph.NodesSubnet = "127.0.0.1/32" } + if g.Config.Ceph.Hosts == nil { g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} } + if g.Config.Ceph.OSDs == nil { g.Config.Ceph.OSDs = []files.CephOSD{ { @@ -60,6 +64,7 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Postgres.Mode == "" { g.Config.Postgres.Mode = "install" } + if g.Config.Postgres.Primary == nil { g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{ IP: "127.0.0.1", @@ -68,13 +73,16 @@ func (g *InstallConfig) applyCommonProperties() { } g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.NeedsKubeConfig = false if g.Config.Kubernetes.APIServerHost == "" { g.Config.Kubernetes.APIServerHost = "127.0.0.1" } + if g.Config.Kubernetes.ControlPlanes == nil { g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} } + if g.Config.Kubernetes.Workers == nil { g.Config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "127.0.0.1"}} } @@ -87,18 +95,22 @@ func (g *InstallConfig) applyCommonProperties() { }, } } + if g.Config.Cluster.Gateway.ServiceType == "" { g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} } + if g.Config.Cluster.PublicGateway.ServiceType == "" { g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} } + if g.Config.MetalLB == nil { g.Config.MetalLB = &files.MetalLBConfig{ Enabled: false, Pools: []files.MetalLBPoolDef{}, } } + if g.Config.Registry == nil { g.Config.Registry = &files.RegistryConfig{} } @@ -106,24 +118,31 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Codesphere.Domain == "" { g.Config.Codesphere.Domain = "codesphere.local" } + if g.Config.Codesphere.WorkspaceHostingBaseDomain == "" { g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" } + if g.Config.Codesphere.CustomDomains.CNameBaseDomain == "" { g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" } + if g.Config.Codesphere.DNSServers == nil { g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} } + 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{ Agent: &files.ImageRef{ @@ -135,6 +154,7 @@ func (g *InstallConfig) applyCommonProperties() { BomRef: "workspace-agent-24.04", } } + if g.Config.Codesphere.DeployConfig.Images == nil { g.Config.Codesphere.DeployConfig = files.DeployConfig{ Images: map[string]files.ImageConfig{ @@ -153,6 +173,7 @@ func (g *InstallConfig) applyCommonProperties() { }, } } + if g.Config.Codesphere.Plans.HostingPlans == nil { g.Config.Codesphere.Plans.HostingPlans = map[int]files.HostingPlan{ 1: { @@ -164,6 +185,7 @@ func (g *InstallConfig) applyCommonProperties() { }, } } + if g.Config.Codesphere.Plans.WorkspacePlans == nil { g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ 1: { @@ -174,6 +196,7 @@ func (g *InstallConfig) applyCommonProperties() { }, } } + if g.Config.ManagedServiceBackends == nil { g.Config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ Postgres: &files.PgManagedServiceConfig{}, @@ -181,6 +204,7 @@ func (g *InstallConfig) applyCommonProperties() { } else if g.Config.ManagedServiceBackends.Postgres == nil { g.Config.ManagedServiceBackends.Postgres = &files.PgManagedServiceConfig{} } + if g.Config.Codesphere.ManagedServices == nil { g.Config.Codesphere.ManagedServices = []files.ManagedServiceConfig{ {Name: "postgres", Version: "v1"}, @@ -190,6 +214,7 @@ func (g *InstallConfig) applyCommonProperties() { {Name: "ferretdb", Version: "v0"}, } } + if g.Config.Secrets.BaseDir == "" { g.Config.Secrets.BaseDir = "/root/secrets" } @@ -199,30 +224,38 @@ func (g *InstallConfig) applyProfileDev() error { if g.Config.Datacenter.Name == "" { g.Config.Datacenter.Name = "dev" } + if g.Config.Cluster.Monitoring == nil { g.Config.Cluster.Monitoring = &files.MonitoringConfig{} } + if g.Config.Cluster.Monitoring.Prometheus == nil { g.Config.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} } + if g.Config.Cluster.Monitoring.Prometheus.RemoteWrite == nil { g.Config.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{ Enabled: false, ClusterName: "dev", } } + if g.Config.Cluster.Monitoring.Loki == nil { g.Config.Cluster.Monitoring.Loki = &files.LokiConfig{Enabled: false} } + if g.Config.Cluster.Monitoring.Grafana == nil { g.Config.Cluster.Monitoring.Grafana = &files.GrafanaConfig{Enabled: false} } + if g.Config.Cluster.Monitoring.GrafanaAlloy == nil { g.Config.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{Enabled: false} } + if err := ApplyResourceProfile(g.Config, ResourceProfileNoRequests); err != nil { return fmt.Errorf("applying resource profile: %w", err) } + return nil } @@ -230,27 +263,34 @@ func (g *InstallConfig) applyProfileMinimal() error { if g.Config.Datacenter.Name == "" { g.Config.Datacenter.Name = "dev" } + if g.Config.Cluster.Monitoring == nil { g.Config.Cluster.Monitoring = &files.MonitoringConfig{} } + if g.Config.Cluster.Monitoring.Prometheus == nil { g.Config.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} } + if g.Config.Cluster.Monitoring.Prometheus.RemoteWrite == nil { g.Config.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{ Enabled: false, ClusterName: "dev", } } + if g.Config.Cluster.Monitoring.Loki == nil { g.Config.Cluster.Monitoring.Loki = &files.LokiConfig{Enabled: true} } + if g.Config.Cluster.Monitoring.Grafana == nil { g.Config.Cluster.Monitoring.Grafana = &files.GrafanaConfig{Enabled: true} } + if g.Config.Cluster.Monitoring.GrafanaAlloy == nil { g.Config.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{Enabled: true} } + if g.Config.Codesphere.Plans.WorkspacePlans == nil { g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ 1: { @@ -261,16 +301,19 @@ func (g *InstallConfig) applyProfileMinimal() error { }, } } + if g.Config.Cluster.BarmanCloudPlugin == nil { g.Config.Cluster.BarmanCloudPlugin = &files.BarmanCloudPluginConfig{ Enabled: true, } } + if g.Config.Cluster.PgOperator == nil { g.Config.Cluster.PgOperator = &files.PgOperatorConfig{ Enabled: true, } } + if g.Config.Cluster.RgwLoadBalancer == nil { g.Config.Cluster.RgwLoadBalancer = &files.RgwLoadBalancerConfig{ Enabled: true, @@ -280,6 +323,7 @@ func (g *InstallConfig) applyProfileMinimal() error { if err := ApplyResourceProfile(g.Config, ResourceProfileNoRequests); err != nil { return fmt.Errorf("applying resource profile: %w", err) } + return nil } @@ -287,6 +331,7 @@ func (g *InstallConfig) applyProfileProd() error { if g.Config.Datacenter.Name == "" { g.Config.Datacenter.Name = "production" } + if g.Config.Codesphere.Plans.WorkspacePlans == nil { g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ 1: { @@ -297,6 +342,7 @@ func (g *InstallConfig) applyProfileProd() error { }, } } + g.Config.Cluster.Monitoring = &files.MonitoringConfig{ Prometheus: &files.PrometheusConfig{ RemoteWrite: &files.RemoteWriteConfig{ @@ -308,5 +354,6 @@ func (g *InstallConfig) applyProfileProd() error { Grafana: &files.GrafanaConfig{Enabled: true}, GrafanaAlloy: &files.GrafanaAlloyConfig{Enabled: true}, } + return nil } diff --git a/internal/installer/config_manager_secrets.go b/internal/installer/config_manager_secrets.go index ff00dcc1..909e87b4 100644 --- a/internal/installer/config_manager_secrets.go +++ b/internal/installer/config_manager_secrets.go @@ -12,5 +12,6 @@ func (g *InstallConfig) GenerateSecrets() error { if g.Vault == nil { g.Vault = &files.InstallVault{} } + return secrets.EnsureSecrets(g.Vault, g.Config) } diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go index 993fb4f2..d8cbb204 100644 --- a/internal/installer/config_manager_secrets_test.go +++ b/internal/installer/config_manager_secrets_test.go @@ -33,6 +33,7 @@ var _ = Describe("GenerateSecrets", func() { tokenPriv := mgr.Vault.GetSecret("tokenPrivateKey") tokenPub := mgr.Vault.GetSecret("tokenPublicKey") + Expect(tokenPriv).NotTo(BeNil()) Expect(tokenPub).NotTo(BeNil()) Expect(tokenPriv.File.Content).To(ContainSubstring("BEGIN PRIVATE KEY")) @@ -44,6 +45,7 @@ var _ = Describe("GenerateSecrets", func() { priv := mgr.Vault.GetSecret("domainAuthPrivateKey") pub := mgr.Vault.GetSecret("domainAuthPublicKey") + Expect(priv).NotTo(BeNil()) Expect(pub).NotTo(BeNil()) Expect(priv.File.Content).To(ContainSubstring("BEGIN EC PRIVATE KEY")) @@ -102,6 +104,7 @@ var _ = Describe("GenerateSecrets", func() { admin := mgr.Vault.GetSecret("postgresPassword") replica := mgr.Vault.GetSecret("postgresReplicaPassword") + Expect(admin).NotTo(BeNil()) Expect(replica).NotTo(BeNil()) Expect(admin.Fields.Password).To(HaveLen(32)) @@ -170,7 +173,6 @@ var _ = Describe("GenerateSecrets", func() { Expect(mgr.Vault.GetSecret("tokenPrivateKey").File.Content).To(Equal(firstKey)) Expect(mgr.Vault.GetSecret("selfSignedCaKeyPem").File.Content).To(Equal(firstCA)) }) - }) Context("idempotency with postgres", func() { @@ -182,7 +184,6 @@ var _ = Describe("GenerateSecrets", func() { }, } }) - }) Context("uniqueness", func() { @@ -191,6 +192,7 @@ var _ = Describe("GenerateSecrets", func() { Config: &files.RootConfig{}, Vault: &files.InstallVault{}, } + Expect(mgr.GenerateSecrets()).To(Succeed()) Expect(mgr2.GenerateSecrets()).To(Succeed()) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 3b6830b8..f83fad21 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -38,6 +38,7 @@ func (m *MockFileIO) Create(filename string) (*os.File, error) { if m.createError != nil { return nil, m.createError } + return nil, nil } @@ -45,19 +46,23 @@ func (m *MockFileIO) CreateAndWrite(filePath string, data []byte, fileType strin if m.writeError != nil { return m.writeError } + m.files[filePath] = data + return nil } func (m *MockFileIO) CreateTemp(dir, pattern string) (string, error) { path := filepath.Join(dir, pattern+"mock") m.files[path] = nil + return path, nil } func (m *MockFileIO) Rename(oldPath, newPath string) error { m.files[newPath] = m.files[oldPath] delete(m.files, oldPath) + return nil } @@ -65,6 +70,7 @@ func (m *MockFileIO) Open(filename string) (*os.File, error) { if m.openError != nil { return nil, m.openError } + return nil, nil } @@ -93,7 +99,9 @@ func (m *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) e if m.writeError != nil { return m.writeError } + m.files[filename] = data + return nil } @@ -105,6 +113,7 @@ func (m *MockFileIO) ReadFile(filename string) ([]byte, error) { if data, ok := m.files[filename]; ok { return data, nil } + return nil, os.ErrNotExist } @@ -266,7 +275,6 @@ var _ = Describe("ConfigManager", func() { Expect(errors).To(ContainElement(ContainSubstring("postgres server address is required"))) }) }) - }) Context("openBao validation", func() { @@ -566,6 +574,7 @@ var _ = Describe("ConfigManager", func() { } ageKeyResolver := vault.NewMockAgeKeyResolver(GinkgoT()) ageKeyResolver.EXPECT().Resolve("", ".").Return("recipient", "", nil) + encryptor := vault.NewMockEncryptor(GinkgoT()) encryptor.EXPECT().Encrypt( ".prod.vault.yaml.plaintext-*mock", @@ -630,6 +639,7 @@ var _ = Describe("ConfigManager", func() { vault := &files.InstallVault{} err = vault.Unmarshal(firstVaultBytes) Expect(err).ToNot(HaveOccurred()) + configManager.Vault = vault // Re-write vault (simulating a second run) @@ -666,6 +676,7 @@ var _ = Describe("ConfigManager", func() { // Save the original cert and key for later comparison origCert := configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem origKey := primaryKeySecret.File.Content + Expect(origCert).ToNot(BeEmpty()) Expect(origKey).ToNot(BeEmpty()) @@ -682,9 +693,11 @@ var _ = Describe("ConfigManager", func() { // Reload config from written YAML configBytes := mockIO.GetFileContent("/tmp/config.yaml") Expect(configBytes).ToNot(BeNil()) + config2 := files.NewRootConfig() err = config2.Unmarshal(configBytes) Expect(err).ToNot(HaveOccurred()) + configManager2.Config = &config2 Expect(configManager2.Config.Postgres.Primary.SSLConfig.ServerCertPem).To(Equal(origCert), @@ -693,9 +706,11 @@ var _ = Describe("ConfigManager", func() { // Reload vault from written YAML vaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") Expect(vaultBytes).ToNot(BeNil()) + vault2 := &files.InstallVault{} err = vault2.Unmarshal(vaultBytes) Expect(err).ToNot(HaveOccurred()) + configManager2.Vault = vault2 // Private key lives in vault, not config @@ -725,17 +740,20 @@ var _ = Describe("ConfigManager", func() { for _, secret := range vault3.Secrets { nameCount[secret.Name]++ } + for name, count := range nameCount { Expect(count).To(Equal(1), "secret '%s' has %d entries (expected 1) — duplication bug!", name, count) } // Verify the key in re-written vault still matches the cert var rewrittenKey string + for _, secret := range vault3.Secrets { if secret.Name == "postgresPrimaryServerKeyPem" && secret.File != nil { rewrittenKey = secret.File.Content } } + Expect(rewrittenKey).To(Equal(origKey), "re-written vault should contain the same key") err = secrets.ValidateCertKeyPair( @@ -745,6 +763,5 @@ var _ = Describe("ConfigManager", func() { Expect(err).ToNot(HaveOccurred(), "cert/key should match in re-written vault") }) }) - }) }) diff --git a/internal/installer/config_template_test.go b/internal/installer/config_template_test.go index c314a8a6..f222471f 100644 --- a/internal/installer/config_template_test.go +++ b/internal/installer/config_template_test.go @@ -22,9 +22,11 @@ func sopsAndAgeAvailable() bool { if _, err := exec.LookPath("sops"); err != nil { return false } + if _, err := exec.LookPath("age-keygen"); err != nil { return false } + return true } @@ -100,6 +102,7 @@ codesphere: apiToken: "{{ secret "apiToken" }}" `, tempDir) Expect(os.WriteFile(configPath, []byte(configYaml), 0644)).To(Succeed()) + vaultYaml, err := installVault.Marshal() Expect(err).NotTo(HaveOccurred()) Expect(os.WriteFile(plaintextVaultPath, vaultYaml, 0600)).To(Succeed()) @@ -113,6 +116,7 @@ codesphere: vault.NewLazyVaultTemplatingSecretStore(vaultPath, ageKeyPath), ) defer cleanup() + Expect(err).NotTo(HaveOccurred()) rendered, err := os.ReadFile(renderedPath) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index 80f613de..a1a81bb8 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -25,6 +25,7 @@ func (v *InstallVault) GetSecret(name string) *SecretEntry { return &v.Secrets[i] } } + return nil } @@ -36,6 +37,7 @@ func (v *InstallVault) SetSecret(entry SecretEntry) { return } } + v.Secrets = append(v.Secrets, entry) } @@ -446,8 +448,10 @@ func (i *ImageRef) UnmarshalYAML(node *yaml.Node) error { if err := node.Decode(&ref); err != nil { return err } + i.BomRef = ref.BomRef i.Dockerfile = ref.Dockerfile + return nil } @@ -466,6 +470,7 @@ func (i *ImageRef) GetImageReference() string { if i.ImageName != "" { return i.ImageName } + return i.BomRef } @@ -642,6 +647,7 @@ type S3ManagedServiceConfig struct { func (c *RootConfig) Marshal() ([]byte, error) { c.buildACMEOverride() c.buildOpenfgaBackupValues() + return yaml.Marshal(c) } @@ -650,7 +656,9 @@ func (c *RootConfig) Unmarshal(data []byte) error { if err := yaml.Unmarshal(data, c); err != nil { return err } + c.extractACMESolverFromOverride() + return nil } @@ -667,11 +675,13 @@ func (c *CodesphereConfig) EnsureCertIssuer() *CertIssuerConfig { if c.CertIssuer == nil { c.CertIssuer = &CertIssuerConfig{} } + return c.CertIssuer } func (c *RootConfig) ExtractBomRefs() []string { var bomRefs []string + for _, imageConfig := range c.Codesphere.DeployConfig.Images { for _, flavor := range imageConfig.Flavors { if flavor.Image.BomRef != "" { @@ -687,7 +697,9 @@ func Capitalize(s string) string { if s == "" { return "" } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] } @@ -706,11 +718,13 @@ func (c *RootConfig) buildACMEOverride() { // Build dnsSolver section if dns01.Provider != "" { solverConfig := map[string]interface{}{} + if dns01.Config != nil { for k, v := range dns01.Config { solverConfig[k] = v } } + acmeOverride["dnsSolver"] = map[string]interface{}{ dns01.Provider: solverConfig, } @@ -730,6 +744,7 @@ func (c *RootConfig) buildACMEOverride() { if !ok { existingAcme = map[string]interface{}{} } + for k, v := range acmeOverride { existingAcme[k] = v } @@ -756,12 +771,15 @@ func (c *RootConfig) buildOpenfgaBackupValues() { if ob.Schedule != "" { backup["schedule"] = ob.Schedule } + if ob.DestinationPath != "" { backup["destinationPath"] = ob.DestinationPath } + if ob.EndpointURL != "" { backup["endpointURL"] = ob.EndpointURL } + if ob.RetentionPolicy != "" { backup["retentionPolicy"] = ob.RetentionPolicy } @@ -790,6 +808,7 @@ func (c *RootConfig) buildOpenfgaBackupValues() { if !ok { valuesObject = map[string]interface{}{} } + postgres, ok := valuesObject["postgres"].(map[string]interface{}) if !ok { postgres = map[string]interface{}{} @@ -837,7 +856,9 @@ func (c *RootConfig) extractACMESolverFromOverride() { if cfgMap, ok := cfg.(map[string]interface{}); ok && len(cfgMap) > 0 { solver.Config = cfgMap } + c.Codesphere.CertIssuer.Acme.Solver.DNS01 = solver + break // only one provider expected } } diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go index b3280a9c..fd1f3887 100644 --- a/internal/installer/files/config_yaml_test.go +++ b/internal/installer/files/config_yaml_test.go @@ -26,6 +26,7 @@ var _ = Describe("ConfigYaml", func() { rootConfig = files.NewRootConfig() var err error + tempDir, err = os.MkdirTemp("", "config_yaml_test") Expect(err).NotTo(HaveOccurred()) @@ -369,7 +370,9 @@ cluster: api-token: fake-api-token name: acme-solver ` + var parsed files.RootConfig + err := parsed.Unmarshal([]byte(acmeYaml)) Expect(err).NotTo(HaveOccurred()) @@ -384,7 +387,6 @@ cluster: Expect(parsed.Codesphere.CertIssuer.Acme.Solver.DNS01).NotTo(BeNil()) Expect(parsed.Codesphere.CertIssuer.Acme.Solver.DNS01.Provider).To(Equal("cloudflare")) }) - }) Describe("OpenFGA backup config structure", func() { diff --git a/internal/installer/files/oci_image_index.go b/internal/installer/files/oci_image_index.go index bb9aa5de..b1747a28 100644 --- a/internal/installer/files/oci_image_index.go +++ b/internal/installer/files/oci_image_index.go @@ -29,6 +29,7 @@ type ManifestEntry struct { func (o *OCIImageIndex) ParseOCIImageConfig(filePath string) error { indexfile := filepath.Join(filepath.Dir(filePath), "index.json") + file, err := os.Open(indexfile) if err != nil { return fmt.Errorf("failed to open file %s: %w", indexfile, err) @@ -36,6 +37,7 @@ func (o *OCIImageIndex) ParseOCIImageConfig(filePath string) error { defer util.CloseFileIgnoreError(file) decoder := json.NewDecoder(file) + err = decoder.Decode(o) if err != nil { return fmt.Errorf("failed to decode file %s: %w", indexfile, err) @@ -47,12 +49,15 @@ func (o *OCIImageIndex) ParseOCIImageConfig(filePath string) error { // ExtractImageNames extracts the image names from the OCI image index file. func (o *OCIImageIndex) ExtractImageNames() ([]string, error) { var names []string + for _, manifest := range o.Manifests { name := manifest.Annotations["io.containerd.image.name"] if name == "" { continue } + names = append(names, name) } + return names, nil } diff --git a/internal/installer/files/oci_image_index_test.go b/internal/installer/files/oci_image_index_test.go index a46ede12..497489a0 100644 --- a/internal/installer/files/oci_image_index_test.go +++ b/internal/installer/files/oci_image_index_test.go @@ -26,6 +26,7 @@ var _ = Describe("OciImageIndex", func() { ociIndex = &files.OCIImageIndex{} var err error + tempDir, err = os.MkdirTemp("", "oci_index_test") Expect(err).NotTo(HaveOccurred()) diff --git a/internal/installer/helm_client.go b/internal/installer/helm_client.go index be018a94..b2fa829e 100644 --- a/internal/installer/helm_client.go +++ b/internal/installer/helm_client.go @@ -120,6 +120,7 @@ func (h *helmClient) LoginRegistry(_ context.Context, host, username, password s } h.registryClient = registryClient + return nil } @@ -135,6 +136,7 @@ func (h *helmClient) newHelmEnv(namespace string) (*helmEnv, error) { if namespace == "" { namespace = h.defaultNamespace } + if namespace == "" { return nil, fmt.Errorf("helm namespace is required") } @@ -168,6 +170,7 @@ func (h *helmClient) newHelmEnv(namespace string) (*helmEnv, error) { if err != nil { return nil, fmt.Errorf("helm registry client init failed: %w", err) } + actionConfig.RegistryClient = registryClient } @@ -201,6 +204,7 @@ func (g *restConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterfa if err != nil { return nil, err } + return memory.NewMemCacheClient(clientset.Discovery()), nil } @@ -209,6 +213,7 @@ func (g *restConfigGetter) ToRESTMapper() (meta.RESTMapper, error) { if err != nil { return nil, err } + return restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient), nil } @@ -222,6 +227,7 @@ func (g *restConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { } config.Clusters["in-memory"] = &clientcmdapi.Cluster{Server: g.config.Host} config.AuthInfos["in-memory"] = &clientcmdapi.AuthInfo{} + return clientcmd.NewDefaultClientConfig(*config, &clientcmd.ConfigOverrides{}) } @@ -229,6 +235,7 @@ func waitStrategy(s kube.WaitStrategy) kube.WaitStrategy { if s == "" { return kube.StatusWatcherStrategy } + return s } @@ -256,6 +263,7 @@ func (h *helmClient) FindRelease(namespace, releaseName string) (*ReleaseInfo, e if err != nil { continue } + if acc.Name() != releaseName { continue } @@ -264,6 +272,7 @@ func (h *helmClient) FindRelease(namespace, releaseName string) (*ReleaseInfo, e if err != nil { return nil, fmt.Errorf("failed to access chart metadata: %w", err) } + metadata := chartAcc.MetadataAsMap() version, _ := metadata["Version"].(string) @@ -317,6 +326,7 @@ func (h *helmClient) UpgradeChart(ctx context.Context, cfg ChartConfig, opts Upg if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return err } + if rel == nil { return h.InstallChart(ctx, cfg, InstallChartOptions{ForceConflicts: opts.ForceConflicts}) } diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 6ba3bf97..e5993ea5 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -66,6 +66,7 @@ func (k *K0s) Download(version string, force bool, quiet bool) (string, error) { } downloadURL := fmt.Sprintf("https://github.com/k0sproject/k0s/releases/download/%s/k0s-%s-%s", version, version, k.Goarch) + path, err := downloadBinary(k.FileWriter, k.Http, cacheDir, "k0s", downloadURL, force, quiet) if err != nil { return "", err diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index 99d6cc59..0213221b 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -88,6 +88,7 @@ func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { for _, cp := range installConfig.Kubernetes.ControlPlanes { sans = append(sans, cp.IPAddress) } + if installConfig.Kubernetes.APIServerHost != "" { sans = append(sans, installConfig.Kubernetes.APIServerHost) } @@ -135,6 +136,7 @@ func defaultIfEmpty(value, defaultValue string) string { if value != "" { return value } + return defaultValue } diff --git a/internal/installer/k0s_config_test.go b/internal/installer/k0s_config_test.go index 41ee8b46..1e2df3d0 100644 --- a/internal/installer/k0s_config_test.go +++ b/internal/installer/k0s_config_test.go @@ -79,6 +79,7 @@ var _ = Describe("K0sConfig", func() { // Verify it can be unmarshalled back var parsedConfig installer.K0sConfig + err = yaml.Unmarshal(yamlData, &parsedConfig) Expect(err).ToNot(HaveOccurred()) Expect(parsedConfig.Metadata.Name).To(Equal("codesphere-test-dc")) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index 48a5a0a1..5436192f 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -148,6 +148,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) @@ -164,6 +165,7 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) }) @@ -187,6 +189,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) @@ -203,6 +206,7 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) @@ -221,6 +225,7 @@ var _ = Describe("K0s", func() { // Create a mock file for the test mockFile, err := os.CreateTemp("", "k0s-test") Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(mockFile.Name()) }() @@ -242,6 +247,7 @@ var _ = Describe("K0s", func() { realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) @@ -257,6 +263,7 @@ var _ = Describe("K0s", func() { Context("URL construction", func() { BeforeEach(func() { k0sImpl.Goos = "linux" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) }) @@ -273,6 +280,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index 93f17e6c..3645fecc 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -48,6 +48,7 @@ type githubRelease struct { func (k *K0sctl) GetLatestVersion() (string, error) { releaseURL := "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" + responseBody, err := k.Http.Get(releaseURL) if err != nil { return "", fmt.Errorf("failed to fetch latest k0sctl release: %w", err) @@ -68,10 +69,12 @@ func (k *K0sctl) GetLatestVersion() (string, error) { func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error) { if version == "" { var err error + version, err = k.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest version: %w", err) } + if !quiet { log.Printf("Using latest k0sctl version: %s", version) } @@ -112,9 +115,11 @@ func (k *K0sctl) requireBinaryAndConfig(configPath, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return fmt.Errorf("k0sctl binary does not exist at '%s', please download first", k0sctlPath) } + if !k.FileWriter.Exists(configPath) { return fmt.Errorf("k0sctl config does not exist at '%s'", configPath) } + return nil } @@ -140,6 +145,7 @@ func (k *K0sctl) Apply(configPath string, k0sctlPath string, force bool) error { } log.Println("k0sctl apply completed successfully") + return nil } @@ -147,6 +153,7 @@ func (k *K0sctl) Reset(configPath string, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return nil } + if err := k.requireBinaryAndConfig(configPath, k0sctlPath); err != nil { return err } @@ -161,6 +168,7 @@ func (k *K0sctl) Reset(configPath string, k0sctlPath string) error { } log.Println("k0sctl reset completed successfully") + return nil } @@ -172,6 +180,7 @@ func (k *K0sctl) GetKubeconfig(configPath string, k0sctlPath string) (string, er args := []string{"kubeconfig", "--config", configPath} log.Println("Retrieving kubeconfig from k0sctl...") + output, err := util.RunCommandWithOutput(k0sctlPath, args, "") if err != nil { return "", fmt.Errorf("k0sctl kubeconfig failed: %w", err) diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index b7a6dc88..88d0fe24 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -138,6 +138,7 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, ss if addedIPs[worker.IPAddress] { continue } + host := createK0sctlHost(worker, "worker", nil, sshKeyPath, k0sBinaryPath) k0sctlConfig.Spec.Hosts = append(k0sctlConfig.Spec.Hosts, host) addedIPs[worker.IPAddress] = true diff --git a/internal/installer/k0sctl_config_test.go b/internal/installer/k0sctl_config_test.go index 72654d4f..1d1c96c5 100644 --- a/internal/installer/k0sctl_config_test.go +++ b/internal/installer/k0sctl_config_test.go @@ -148,6 +148,7 @@ var _ = Describe("K0sctlConfig", func() { // Verify it can be unmarshalled back var parsedConfig installer.K0sctlConfig + err = yaml.Unmarshal(yamlData, &parsedConfig) Expect(err).ToNot(HaveOccurred()) Expect(parsedConfig.Metadata.Name).To(Equal("codesphere-test-dc")) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 74ec1f45..d55717e5 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -59,8 +59,11 @@ func NewSSHNodeClient(quiet bool) *SSHNodeClient { } func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) error { - var jumpboxIp string - var ip string + var ( + jumpboxIp string + ip string + ) + if n.Jumpbox != nil { jumpboxIp = n.Jumpbox.ExternalIP ip = n.InternalIP @@ -68,6 +71,7 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err jumpboxIp = "" ip = n.ExternalIP } + client, err := n.getOrCreateClient(jumpboxIp, ip, username) if err != nil { return fmt.Errorf("failed to get client: %w", err) @@ -78,10 +82,12 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err if err != nil { // Connection might be stale, try to reconnect n.invalidateClient(username) + client, err = n.getOrCreateClient(jumpboxIp, ip, username) if err != nil { return fmt.Errorf("failed to reconnect client: %w", err) } + session, err = client.NewSession() if err != nil { return fmt.Errorf("failed to create session: %v", err) @@ -94,6 +100,7 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf if !r.Quiet { session.Stdout = os.Stdout @@ -109,8 +116,10 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err if r.Quiet && stderrBuf.Len() > 0 { return fmt.Errorf("command failed: %w\n%s", err, stderrBuf.String()) } + return fmt.Errorf("command failed: %w", err) } + return nil } @@ -160,11 +169,13 @@ func (n *Node) GetName() string { func (c *SSHNodeClient) WaitReady(node *Node, timeout time.Duration) error { start := time.Now() jumpboxIp := "" + nodeIp := node.ExternalIP if node.Jumpbox != nil { jumpboxIp = node.Jumpbox.ExternalIP nodeIp = node.InternalIP } + for { // Try to get or create a cached client _, err := node.getOrCreateClient(jumpboxIp, nodeIp, jumpboxUser) @@ -172,9 +183,11 @@ func (c *SSHNodeClient) WaitReady(node *Node, timeout time.Duration) error { // Connection successful and cached return nil } + if time.Since(start) > timeout { return fmt.Errorf("timeout: %w", err) } + time.Sleep(5 * time.Second) } } @@ -189,11 +202,13 @@ func (n *Node) RunSSHCommand(username string, command string) error { // HasCommand checks if a command exists on the remote node via SSH func (n *Node) HasCommand(command string) bool { checkCommand := fmt.Sprintf("command -v %s >/dev/null 2>&1", command) + err := n.RunSSHCommand("root", checkCommand) if err != nil { // If the command returns a non-zero exit status, it means the command is not found return false } + return true } @@ -209,6 +224,7 @@ func (n *Node) InstallOms() error { return fmt.Errorf("failed to run remote command '%s': %w", cmd, err) } } + return nil } @@ -233,21 +249,25 @@ func (n *Node) EnsureOmsDependencies() error { if n.HasCommand(dependency.command) { continue } + if err := n.RunSSHCommand("root", dependency.install); err != nil { return fmt.Errorf("failed to install OMS dependency %s: %w", dependency.command, err) } } + return nil } // HasAcceptEnvConfigured checks if AcceptEnv is configured func (n *Node) HasAcceptEnvConfigured() bool { checkCommand := "sudo grep -qxF 'AcceptEnv OMS_PORTAL_API_KEY OMS_PORTAL_API' /etc/ssh/sshd_config >/dev/null 2>&1" + err := n.RunSSHCommand("ubuntu", checkCommand) if err != nil { // If the command returns a NON-zero exit status, it means AcceptEnv is not configured return false } + return true } @@ -263,23 +283,28 @@ func (n *Node) ConfigureAcceptEnv() error { return fmt.Errorf("failed to run command '%s': %w", cmd, err) } } + return nil } // HasRootLoginEnabled checks if root login is enabled on the remote node via SSH func (n *Node) HasRootLoginEnabled() bool { checkCommandPermit := "sudo grep -E '^PermitRootLogin yes' /etc/ssh/sshd_config >/dev/null 2>&1" + err := n.RunSSHCommand("ubuntu", checkCommandPermit) if err != nil { // If the command returns a NON-zero exit status, it means root login is not permitted return false } + checkCommandAuthorizedKeys := "sudo grep -E '^no-port-forwarding' /root/.ssh/authorized_keys >/dev/null 2>&1" + err = n.RunSSHCommand("ubuntu", checkCommandAuthorizedKeys) if err == nil { // If the command returns a ZERO exit status, it means root login is prevented return false } + return true } @@ -296,6 +321,7 @@ func (n *Node) EnableRootLogin() error { return fmt.Errorf("failed to run command '%s': %w", cmd, err) } } + return nil } @@ -311,6 +337,7 @@ func (n *Node) ConfigureInotifyWatches() error { "fs.inotify.max_user_watches=1048576", "fs.inotify.max_user_instances=8192", } + return n.configureSysctlLines(lines) } @@ -325,17 +352,20 @@ func (n *Node) ConfigureMemoryMap() error { // HasFile checks if a file exists on the remote node via SSH func (c *SSHNodeClient) HasFile(n *Node, filePath string) bool { checkCommand := fmt.Sprintf("test -f '%s'", filePath) + err := n.RunSSHCommand("ubuntu", checkCommand) if err != nil { // If the command returns a non-zero exit status, it means the file does not exist return false } + return true } // CopyFile copies a file from the local system to the remote node via SFTP func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { jumpBoxIP := "" + nodeIP := n.ExternalIP if n.Jumpbox != nil { jumpBoxIP = n.Jumpbox.ExternalIP @@ -353,6 +383,7 @@ func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { // DownloadFile downloads a file from the remote node to the local system via SFTP func (c *SSHNodeClient) DownloadFile(n *Node, src, dst string) error { jumpBoxIP := "" + nodeIP := n.ExternalIP if n.Jumpbox != nil { jumpBoxIP = n.Jumpbox.ExternalIP @@ -367,17 +398,20 @@ func (c *SSHNodeClient) DownloadFile(n *Node, src, dst string) error { // hasSysctlLine checks if a specific line exists in /etc/sysctl.conf on the remote node via SSH func (n *Node) hasSysctlLine(line string) bool { checkCommand := fmt.Sprintf("sudo grep -E '^%s' /etc/sysctl.conf >/dev/null 2>&1", line) + err := n.RunSSHCommand("root", checkCommand) if err != nil { // If the command returns a NON-zero exit status, it means the setting is not configured return false } + return true } func (n *Node) isSysctlActive(key, expected string) bool { checkCommand := fmt.Sprintf("sudo sysctl -n %s | grep -q '^%s$'", key, expected) err := n.RunSSHCommand("root", checkCommand) + return err == nil } @@ -405,6 +439,7 @@ func (n *Node) getOrCreateClient(jumpboxIp string, ip string, username string) ( if n.clientCache == nil { n.clientCache = make(map[string]*ssh.Client) } + n.clientMu.Lock() defer n.clientMu.Unlock() @@ -412,6 +447,7 @@ func (n *Node) getOrCreateClient(jumpboxIp string, ip string, username string) ( if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil { return client, nil } + util.IgnoreError(client.Close) delete(n.clientCache, username) } @@ -428,6 +464,7 @@ func (n *Node) getOrCreateClient(jumpboxIp string, ip string, username string) ( } n.clientCache[username] = client + return client, nil } @@ -468,10 +505,12 @@ func (n *Node) createClient(jumpboxIp string, ip string, username string) (*ssh. } finalAddr := fmt.Sprintf("%s:22", ip) + jbConn, err := jbClient.Dial("tcp", finalAddr) if err != nil { return nil, fmt.Errorf("failed to create connection through jumpbox: %v", err) } + finalClient, channels, requests, err := ssh.NewClientConn(jbConn, finalAddr, finalTargetConfig) if err != nil { return nil, fmt.Errorf("failed to perform SSH handshake through jumpbox: %v", err) @@ -492,10 +531,12 @@ func (n *Node) createClient(jumpboxIp string, ip string, username string) (*ssh. } addr := fmt.Sprintf("%s:22", ip) + client, err := ssh.Dial("tcp", addr, config) if err != nil { return nil, fmt.Errorf("failed to dial: %v", err) } + return client, nil } @@ -654,8 +695,11 @@ func (n *Node) loadPrivateKey() (ssh.Signer, error) { // Key is encrypted, prompt for passphrase log.Printf("Enter passphrase for key '%s': ", n.keyPath) + passphrase, err := term.ReadPassword(int(syscall.Stdin)) + log.Println() + if err != nil { return nil, fmt.Errorf("failed to read passphrase: %v", err) } @@ -665,6 +709,7 @@ func (n *Node) loadPrivateKey() (ssh.Signer, error) { for i := range passphrase { passphrase[i] = 0 } + if err != nil { return nil, fmt.Errorf("failed to parse private key with passphrase: %v", err) } diff --git a/internal/installer/openbao.go b/internal/installer/openbao.go index 88575089..16e92cff 100644 --- a/internal/installer/openbao.go +++ b/internal/installer/openbao.go @@ -154,19 +154,24 @@ func (o *OpenBaoInstaller) validateConfig() error { if o.Config.Namespace == "" { o.Config.Namespace = DefaultOpenBaoNamespace } + r := o.Config.Replicas if r < 1 { return fmt.Errorf("--replicas must be >= 1, got %d", r) } + if r > 1 && r%2 == 0 { return fmt.Errorf("--replicas=%d is invalid: Raft requires 1 (single-node) or an odd number >= 3 for HA", r) } + if o.Config.Timeout <= 0 { o.Config.Timeout = defaultTimeout } + if o.Config.ReadinessTimeoutPerReplica <= 0 { o.Config.ReadinessTimeoutPerReplica = defaultReadinessTimeoutPerReplica } + return nil } @@ -192,6 +197,7 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { if checkErr != nil { return fmt.Errorf("checking for existing deployment: %w", checkErr) } + if exists { if err := o.ConfirmFunc(); err != nil { return err @@ -256,6 +262,7 @@ func (o *OpenBaoInstaller) Install(ctx context.Context) error { } o.Logger.Logf("OpenBao bootstrap complete. DR backup saved to: %s", o.Config.DRBackupPath) + return nil } @@ -273,8 +280,10 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { if os.IsNotExist(err) { o.Logger.Logf("No existing DR backup found — proceeding with fresh initialization") o.drBackupExists = false + return nil } + return fmt.Errorf("checking DR backup file %s: %w", o.Config.DRBackupPath, err) } @@ -304,17 +313,21 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { if o.Config.Username != backup.Username { o.Logger.Logf("Warning: --bao-user=%q differs from DR backup username %q — using backup value", o.Config.Username, backup.Username) } + o.password = backup.Password o.Config.Username = backup.Username o.drBackupExists = true + return nil } // GeneratePassword generates a secure password and stores it on the installer. func (o *OpenBaoInstaller) GeneratePassword() error { var err error + o.password, err = GenerateSecurePassword(defaultPasswordLength) + return err } @@ -341,6 +354,7 @@ func (o *OpenBaoInstaller) DeployBankVaultsOperator() error { if err != nil { return err } + if exists { return o.Helm.UpgradeChart(o.ctx, cfg, UpgradeChartOptions{}) } @@ -352,6 +366,7 @@ func (o *OpenBaoInstaller) DeployBankVaultsOperator() error { if err != nil { return err } + if running { o.Logger.Logf("Bank-Vaults Operator already running in the cluster, skipping deployment") return nil @@ -379,13 +394,16 @@ func (o *OpenBaoInstaller) cleanOrphanedOperatorRBAC() error { if crErr != nil && !k8serrors.IsNotFound(crErr) { return fmt.Errorf("deleting orphaned %s ClusterRole: %w", operatorName, crErr) } + crbErr := o.Clientset.RbacV1().ClusterRoleBindings().Delete(o.ctx, operatorName, metav1.DeleteOptions{}) if crbErr != nil && !k8serrors.IsNotFound(crbErr) { return fmt.Errorf("deleting orphaned %s ClusterRoleBinding: %w", operatorName, crbErr) } + if crErr == nil || crbErr == nil { o.Logger.Logf("Removed orphaned %s cluster-scoped RBAC left by a prior install", operatorName) } + return nil } @@ -399,6 +417,7 @@ func (o *OpenBaoInstaller) releaseExistsInTargetNamespace(releaseName string) (b if k8serrors.IsNotFound(nsErr) { return false, nil } + return false, fmt.Errorf("checking namespace %s: %w", o.Config.Namespace, nsErr) } @@ -406,6 +425,7 @@ func (o *OpenBaoInstaller) releaseExistsInTargetNamespace(releaseName string) (b if err != nil { return false, fmt.Errorf("finding release %s in namespace %s: %w", releaseName, o.Config.Namespace, err) } + return rel != nil, nil } @@ -428,11 +448,13 @@ func (o *OpenBaoInstaller) operatorRunningClusterWide() (bool, error) { if err != nil { return false, fmt.Errorf("listing %s deployments: %w", operatorName, err) } + for i := range deps.Items { if deps.Items[i].Status.AvailableReplicas > 0 { return true, nil } } + return false, nil } @@ -458,6 +480,7 @@ func buildRetryJoinAddrs(replicas int, namespace string) []string { for i := 0; i < replicas; i++ { addrs = append(addrs, fmt.Sprintf("http://openbao-%d.%s.svc.cluster.local:8200", i, namespace)) } + return addrs } @@ -497,6 +520,7 @@ func (o *OpenBaoInstaller) ApplyVaultCR() error { if err != nil { return fmt.Errorf("resolving GVR for %s: %w", obj.GetKind(), err) } + if err := k8s.ApplyUnstructured(o.ctx, o.DynClient, gvr, obj); err != nil { return fmt.Errorf("applying vault CR: %w", err) } @@ -529,6 +553,7 @@ func (o *OpenBaoInstaller) WaitForInitialization() error { return false, createErr } } + return false, nil // Keep polling — sidecar hasn't confirmed unseal yet } @@ -546,6 +571,7 @@ func (o *OpenBaoInstaller) WaitForInitialization() error { return false, updateErr } } + return false, nil }) } @@ -566,10 +592,12 @@ func (o *OpenBaoInstaller) ensureUnsealSecret(secretsClient corev1client.SecretI }, Data: o.backupUnsealKeys, } + _, err = secretsClient.Create(o.ctx, secret, metav1.CreateOptions{}) if err == nil { return nil } + if !k8serrors.IsAlreadyExists(err) { return fmt.Errorf("creating unseal secret from backup: %w", err) } @@ -584,10 +612,12 @@ func (o *OpenBaoInstaller) ensureUnsealSecret(secretsClient corev1client.SecretI // Update existing secret — preserve metadata, only set Data existing.Data = o.backupUnsealKeys + _, err = secretsClient.Update(o.ctx, existing, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("updating unseal secret from backup: %w", err) } + return nil } @@ -614,17 +644,23 @@ func (o *OpenBaoInstaller) WaitForPodsReady() error { return false, fmt.Errorf("listing vault pods: %w", err) } - var activePods int - var readyCount int + var ( + activePods int + readyCount int + ) + for i := range list.Items { if list.Items[i].DeletionTimestamp != nil { continue // Skip terminating pods } + activePods++ + if isPodReady(&list.Items[i]) { readyCount++ } } + return activePods == expected && readyCount == expected, nil }) } @@ -634,11 +670,13 @@ func isPodReady(pod *corev1.Pod) bool { if pod.Status.Phase != corev1.PodRunning { return false } + for _, cond := range pod.Status.Conditions { if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { return true } } + return false } @@ -672,6 +710,7 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { if err != nil { return fmt.Errorf("creating temp backup file: %w", err) } + tmpPath := tmpFile.Name() defer func() { _ = os.Remove(tmpPath) }() // clean up temp file on failure or panic @@ -679,10 +718,12 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { _ = tmpFile.Close() return fmt.Errorf("setting temp file permissions: %w", err) } + if _, err := tmpFile.Write(plaintext); err != nil { _ = tmpFile.Close() return fmt.Errorf("writing temp backup file: %w", err) } + if err := tmpFile.Close(); err != nil { return fmt.Errorf("closing temp backup file: %w", err) } @@ -692,6 +733,7 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { } o.Logger.Logf("DR backup encrypted and saved to: %s", o.Config.DRBackupPath) + return nil } @@ -707,6 +749,7 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { // 4. Delete the unseal-keys Secret func (o *OpenBaoInstaller) CleanStaleInstallState() error { vaultGVR := k8s.VaultGVR() + var cleaned []string // Tolerates NotFound — this may be a first-time install with no prior Vault CR. @@ -716,6 +759,7 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { if delErr != nil && !k8serrors.IsNotFound(delErr) { return fmt.Errorf("deleting Vault CR: %w", delErr) } + if delErr == nil { cleaned = append(cleaned, "Vault CR") // Only wait for pods to terminate when we actually deleted a Vault CR — @@ -737,6 +781,7 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { // Namespace doesn't exist yet — no stale PVCs to clean. pvcList = &corev1.PersistentVolumeClaimList{} } + for i := range pvcList.Items { delErr = o.Clientset.CoreV1().PersistentVolumeClaims(o.Config.Namespace).Delete( o.ctx, pvcList.Items[i].Name, metav1.DeleteOptions{}, @@ -745,8 +790,10 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { return fmt.Errorf("deleting PVC %s: %w", pvcList.Items[i].Name, delErr) } } + if len(pvcList.Items) > 0 { cleaned = append(cleaned, fmt.Sprintf("%d PVC(s)", len(pvcList.Items))) + if err := o.waitForPVCsGone(); err != nil { return err } @@ -759,6 +806,7 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { if delErr != nil && !k8serrors.IsNotFound(delErr) { return fmt.Errorf("deleting stale unseal secret: %w", delErr) } + if delErr == nil { cleaned = append(cleaned, "unseal secret") } @@ -768,6 +816,7 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { } else { o.Logger.Logf("No stale install state found in namespace %q", o.Config.Namespace) } + return nil } @@ -777,12 +826,14 @@ func (o *OpenBaoInstaller) CleanStaleInstallState() error { // re-install where the user may have supplied the wrong DR backup path. func (o *OpenBaoInstaller) hasExistingDeployment() (bool, error) { vaultGVR := k8s.VaultGVR() + _, err := o.DynClient.Resource(vaultGVR).Namespace(o.Config.Namespace).Get( o.ctx, "openbao", metav1.GetOptions{}, ) if err == nil { return true, nil } + if !k8serrors.IsNotFound(err) { return false, fmt.Errorf("checking Vault CR: %w", err) } @@ -795,8 +846,10 @@ func (o *OpenBaoInstaller) hasExistingDeployment() (bool, error) { if k8serrors.IsNotFound(err) { return false, nil // Namespace doesn't exist — no prior deployment. } + return false, fmt.Errorf("listing PVCs: %w", err) } + return len(pvcList.Items) > 0, nil } @@ -813,8 +866,10 @@ func (o *OpenBaoInstaller) waitForVaultPodsGone() error { if k8serrors.IsNotFound(err) { return true, nil } + return false, fmt.Errorf("listing vault pods: %w", err) } + return len(list.Items) == 0, nil }) } @@ -833,8 +888,10 @@ func (o *OpenBaoInstaller) waitForPVCsGone() error { if k8serrors.IsNotFound(err) { return true, nil } + return false, fmt.Errorf("listing PVCs: %w", err) } + return len(list.Items) == 0, nil }) } @@ -862,16 +919,19 @@ func (o *OpenBaoInstaller) pollUntilTimeout(timeout time.Duration, timeoutMsg st if err != nil { return err } + if done { return nil } o.Logger.LogRetry() + select { case <-o.ctx.Done(): return o.ctx.Err() case <-time.After(interval): } + interval = min(interval*2, maxPollInterval) } } @@ -889,11 +949,13 @@ func (o *OpenBaoInstaller) ensureNamespace(ctx context.Context) error { if !k8serrors.IsNotFound(err) { return fmt.Errorf("checking namespace %s: %w", o.Config.Namespace, err) } + _, err = o.Clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) if err != nil && !k8serrors.IsAlreadyExists(err) { return fmt.Errorf("creating namespace %s: %w", o.Config.Namespace, err) } } + return nil } @@ -912,5 +974,6 @@ func GenerateSecurePassword(length int) (string, error) { if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generating random bytes: %w", err) } + return base64.RawURLEncoding.EncodeToString(b), nil } diff --git a/internal/installer/openbao_test.go b/internal/installer/openbao_test.go index bd469338..f5cf3253 100644 --- a/internal/installer/openbao_test.go +++ b/internal/installer/openbao_test.go @@ -46,6 +46,7 @@ var _ = Describe("OpenBaoInstaller", func() { clientset = fake.NewClientset() var err error + tmpDir, err = os.MkdirTemp("", "openbao-test-*") Expect(err).ToNot(HaveOccurred()) }) @@ -183,6 +184,7 @@ var _ = Describe("OpenBaoInstaller", func() { cr := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "vault-operator"}} _, err = clientset.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) Expect(err).ToNot(HaveOccurred()) + crb := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "vault-operator"}} _, err = clientset.RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}) Expect(err).ToNot(HaveOccurred()) @@ -302,6 +304,7 @@ var _ = Describe("OpenBaoInstaller", func() { err = inst.WaitForInitialization() Expect(err).ToNot(HaveOccurred()) + result := inst.GetUnsealSecret() Expect(result.Data).To(HaveKey("vault-unseal-0")) }) @@ -622,6 +625,7 @@ var _ = Describe("OpenBaoInstaller", func() { // Decrypt it back and verify contents cmd := exec.Command("sops", "--decrypt", backupPath) + cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+keyFile) decrypted, err := cmd.Output() Expect(err).ToNot(HaveOccurred()) @@ -685,16 +689,20 @@ var _ = Describe("OpenBaoInstaller", func() { // Decode multi-doc YAML into a slice of generic maps decoder := yaml.NewYAMLOrJSONDecoder(&buf, 4096) + var docs []map[string]interface{} + for { var doc map[string]interface{} if err := decoder.Decode(&doc); err != nil { break } + if doc != nil { docs = append(docs, doc) } } + return docs } @@ -704,6 +712,7 @@ var _ = Describe("OpenBaoInstaller", func() { return doc } } + return nil } @@ -848,15 +857,18 @@ var _ = Describe("OpenBaoInstaller", func() { Expect(containerSpec).To(HaveKey("env")) envVars := containerSpec["env"].([]interface{}) envNames := make([]string, 0, len(envVars)) + envValues := make(map[string]string, len(envVars)) for _, e := range envVars { m := e.(map[string]interface{}) name := m["name"].(string) + envNames = append(envNames, name) if v, ok := m["value"].(string); ok { envValues[name] = v } } + Expect(envNames).To(ContainElements("POD_NAME", "BAO_CLUSTER_ADDR", "BAO_API_ADDR")) Expect(envValues["BAO_CLUSTER_ADDR"]).To(Equal("http://$(POD_NAME).vault.svc.cluster.local:8201")) @@ -1098,6 +1110,7 @@ var _ = Describe("OpenBaoInstaller", func() { } inst.SetCtx(ctx) inst.SetBackupUnsealKeys(backup) + return inst } @@ -1264,5 +1277,6 @@ func extractAgeRecipient(output string) string { return strings.TrimPrefix(line, "Public key: ") } } + return "" } diff --git a/internal/installer/package.go b/internal/installer/package.go index 7713f700..b73806e2 100644 --- a/internal/installer/package.go +++ b/internal/installer/package.go @@ -68,10 +68,12 @@ func (p *Package) alreadyExtracted(dir string) (bool, error) { if !p.fileIO.Exists(dir) { return false, nil } + isDir, err := p.fileIO.IsDirectory(dir) if err != nil { return false, fmt.Errorf("failed to determine if %s is a folder: %w", dir, err) } + return isDir, nil } @@ -79,6 +81,7 @@ func (p *Package) alreadyExtracted(dir string) (bool, error) { // If force is true, it will overwrite existing files. func (p *Package) Extract(force bool) error { workDir := p.GetWorkDir() + err := os.MkdirAll(p.OmsWorkdir, 0755) if err != nil { return fmt.Errorf("failed to ensure workdir exists: %w", err) @@ -102,6 +105,7 @@ func (p *Package) Extract(force bool) error { depsArchivePath := path.Join(workDir, depsTar) if p.fileIO.Exists(depsArchivePath) { depsTargetDir := path.Join(workDir, depsDir) + err = util.ExtractTarGz(p.fileIO, depsArchivePath, depsTargetDir) if err != nil { return fmt.Errorf("failed to extract deps.tar.gz to %s: %w", depsTargetDir, err) @@ -117,6 +121,7 @@ func (p *Package) ExtractDependency(file string, force bool) error { if err != nil { return fmt.Errorf("failed to extract package: %w", err) } + workDir := p.GetWorkDir() if p.fileIO.Exists(p.GetDependencyPath(file)) && !force { @@ -135,6 +140,7 @@ func (p *Package) ExtractDependency(file string, force bool) error { // ExtractOciImageIndex extracts and parses the OCI image index from the given image file path. func (p *Package) ExtractOciImageIndex(imagefile string) (files.OCIImageIndex, error) { var ociImageIndex files.OCIImageIndex + err := util.ExtractTarSingleFile(p.fileIO, imagefile, "index.json", filepath.Dir(imagefile)) if err != nil { return ociImageIndex, fmt.Errorf("failed to extract index.json: %w", err) @@ -183,6 +189,7 @@ func (p *Package) GetBaseimagePath(baseimage string, force bool) (string, error) } baseImageTarPath := path.Join(baseimagePath, baseimage) + err := p.ExtractDependency(baseImageTarPath, force) if err != nil { return "", fmt.Errorf("failed to extract package to workdir: %w", err) @@ -205,6 +212,7 @@ func (p *Package) GetCodesphereVersion() (string, error) { } containerImage := "" + for _, image := range containerImages { if strings.Contains(image, ":codesphere") { containerImage = image diff --git a/internal/installer/package_test.go b/internal/installer/package_test.go index afcf404e..8f41c4f4 100644 --- a/internal/installer/package_test.go +++ b/internal/installer/package_test.go @@ -90,6 +90,7 @@ var _ = Describe("Package", func() { }, }) Expect(err).ToNot(HaveOccurred()) + pkg.Filename = packagePath }) @@ -172,6 +173,7 @@ var _ = Describe("Package", func() { }, }) Expect(err).ToNot(HaveOccurred()) + pkg.Filename = packagePath }) @@ -519,6 +521,7 @@ var _ = Describe("Package GetBaseimagePath", func() { }, }) Expect(err).NotTo(HaveOccurred()) + pkg.Filename = packagePath }) @@ -729,6 +732,7 @@ func createTar(tarName string, fileName string, fileContent string) error { if err := tw.WriteHeader(header); err != nil { return err } + if _, err := tw.Write([]byte(fileContent)); err != nil { return err } @@ -739,6 +743,7 @@ func createTar(tarName string, fileName string, fileContent string) error { // createTarGz creates a deps.tar.gz archive content in memory func createTarGz(files map[string]string) ([]byte, error) { var buf []byte + gzw := gzip.NewWriter(&bytesBuffer{data: &buf}) tw := tar.NewWriter(gzw) @@ -751,6 +756,7 @@ func createTarGz(files map[string]string) ([]byte, error) { if err := tw.WriteHeader(header); err != nil { return nil, err } + if _, err := tw.Write([]byte(content)); err != nil { return nil, err } @@ -759,6 +765,7 @@ func createTarGz(files map[string]string) ([]byte, error) { if err := tw.Close(); err != nil { return nil, err } + if err := gzw.Close(); err != nil { return nil, err } @@ -790,6 +797,7 @@ func createTestPackage(filename string, files PackageFiles) error { if err := tw.WriteHeader(header); err != nil { return err } + if _, err := tw.Write([]byte(content)); err != nil { return err } @@ -810,6 +818,7 @@ func createTestPackage(filename string, files PackageFiles) error { if err := tw.WriteHeader(depsHeader); err != nil { return err } + if _, err := tw.Write(depsContent); err != nil { return err } diff --git a/internal/installer/pc_apps.go b/internal/installer/pc_apps.go index a8d42de6..1d5b9813 100644 --- a/internal/installer/pc_apps.go +++ b/internal/installer/pc_apps.go @@ -63,9 +63,11 @@ func NewPCApps(c client.Client, version, namespace string, valuesFiles []string, if version == "" { return nil, errors.New("version is required") } + if namespace == "" { return nil, errors.New("namespace is required") } + if err := checkArgoCDScheme(c); err != nil { return nil, err } @@ -111,12 +113,14 @@ func checkArgoCDScheme(c client.Client) error { if c == nil || c.Scheme() == nil { return errors.New("kubernetes client is required") } + if !c.Scheme().Recognizes(argov1alpha1.ApplicationSchemaGroupVersionKind) { return fmt.Errorf( "kubernetes client scheme does not recognize %s; register it with argov1alpha1.AddToScheme", argov1alpha1.ApplicationSchemaGroupVersionKind, ) } + return nil } @@ -126,6 +130,7 @@ func checkArgoCDScheme(c client.Client) error { // credentials to the repository. func (p *PCApps) resolveRepoURL(ctx context.Context) (string, error) { secret := &corev1.Secret{} + key := client.ObjectKey{Name: ociCredentialSecretName, Namespace: ociCredentialNamespace} if err := p.client.Get(ctx, key, secret); err != nil { return "", fmt.Errorf( @@ -144,6 +149,7 @@ func (p *PCApps) resolveRepoURL(ctx context.Context) (string, error) { } log.Printf("Using OCI registry %q from K8s secret %q\n", baseURL, ociCredentialSecretName) + return baseURL, nil } @@ -153,11 +159,13 @@ func (p *PCApps) createApplication(repoURL string, vals map[string]interface{}) helm := &argov1alpha1.ApplicationSourceHelm{ ReleaseName: pcAppsAppName, } + if len(vals) > 0 { raw, err := json.Marshal(vals) if err != nil { return nil, fmt.Errorf("marshaling helm values: %w", err) } + helm.ValuesObject = &runtime.RawExtension{Raw: raw} } @@ -200,10 +208,12 @@ func (p *PCApps) createApplication(repoURL string, vals map[string]interface{}) func (p *PCApps) Install(ctx context.Context) error { // Validate values files before any cluster calls so local errors fail fast. valueOpts := values.Options{ValueFiles: p.valuesFiles} + fileVals, err := valueOpts.MergeValues(getter.All(cli.New())) if err != nil { return fmt.Errorf("loading values files: %w", err) } + vals := util.DeepMergeMaps(map[string]any{}, p.valuesOverride) vals = util.DeepMergeMaps(vals, fileVals) @@ -219,6 +229,7 @@ func (p *PCApps) Install(ctx context.Context) error { log.Printf("Applying ArgoCD Application %q (chart %s, version %s) in namespace %s\n", pcAppsAppName, pcAppsChartName, p.version, ociCredentialNamespace) + current := &argov1alpha1.Application{ ObjectMeta: metav1.ObjectMeta{ Name: app.Name, @@ -228,12 +239,14 @@ func (p *PCApps) Install(ctx context.Context) error { if _, err := controllerutil.CreateOrUpdate(ctx, p.client, current, func() error { current.TypeMeta = app.TypeMeta current.Spec = app.Spec + return nil }); err != nil { return fmt.Errorf("applying ArgoCD Application %q failed: %w", pcAppsAppName, err) } log.Printf("Successfully applied ArgoCD Application %q; ArgoCD will sync the chart\n", pcAppsAppName) + return nil } diff --git a/internal/installer/pc_apps_test.go b/internal/installer/pc_apps_test.go index e3c1960e..ff6776d5 100644 --- a/internal/installer/pc_apps_test.go +++ b/internal/installer/pc_apps_test.go @@ -55,9 +55,11 @@ var _ = Describe("PCApps.Install", func() { // getApp reads back the Application the installer applied. getApp := func() *argov1alpha1.Application { GinkgoHelper() + app := &argov1alpha1.Application{} err := fakeClient.Get(context.Background(), client.ObjectKey{Name: "pc-applications", Namespace: "argocd"}, app) Expect(err).ToNot(HaveOccurred()) + return app } @@ -67,8 +69,10 @@ var _ = Describe("PCApps.Install", func() { Expect(app.Spec.Source).ToNot(BeNil()) Expect(app.Spec.Source.Helm).ToNot(BeNil()) Expect(app.Spec.Source.Helm.ValuesObject).ToNot(BeNil()) + vals := map[string]interface{}{} Expect(json.Unmarshal(app.Spec.Source.Helm.ValuesObject.Raw, &vals)).To(Succeed()) + return vals } @@ -192,6 +196,7 @@ var _ = Describe("PCApps.Install", func() { BeforeEach(func() { var err error + tmpDir, err = os.MkdirTemp("", "pc-apps-test-*") Expect(err).ToNot(HaveOccurred()) diff --git a/internal/installer/prompt.go b/internal/installer/prompt.go index 31d360d1..a8f3ee24 100644 --- a/internal/installer/prompt.go +++ b/internal/installer/prompt.go @@ -40,6 +40,7 @@ func (p *Prompter) String(prompt, defaultValue string) string { if input == "" { return defaultValue } + return input } @@ -62,6 +63,7 @@ func (p *Prompter) Int(prompt string, defaultValue int) int { log.Printf("Invalid number, using default: %d\n", defaultValue) return defaultValue } + return value } @@ -85,6 +87,7 @@ func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { } parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) @@ -96,6 +99,7 @@ func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { if len(result) == 0 { return defaultValue } + return result } @@ -108,6 +112,7 @@ func (p *Prompter) Bool(prompt string, defaultValue bool) bool { if defaultValue { defaultStr = "y" } + log.Printf("%s (y/n, default: %s): ", prompt, defaultStr) input, _ := p.reader.ReadString('\n') @@ -141,5 +146,6 @@ func (p *Prompter) Choice(prompt string, choices []string, defaultValue string) } log.Printf("Invalid choice, using default: %s\n", defaultValue) + return defaultValue } diff --git a/internal/installer/resource_profiles.go b/internal/installer/resource_profiles.go index a8c4b3a0..9a917614 100644 --- a/internal/installer/resource_profiles.go +++ b/internal/installer/resource_profiles.go @@ -36,6 +36,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.CertManager == nil { config.Cluster.CertManager = &files.CertManagerConfig{} } + config.Cluster.CertManager.Override = util.DeepMergeMaps(config.Cluster.CertManager.Override, map[string]any{ "cert-manager": map[string]any{ "resources": map[string]any{ @@ -63,6 +64,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.TrustManager == nil { config.Cluster.TrustManager = &files.TrustManagerConfig{} } + config.Cluster.TrustManager.Override = util.DeepMergeMaps(config.Cluster.TrustManager.Override, map[string]any{ "trust-manager": map[string]any{ "resources": map[string]any{ @@ -74,9 +76,11 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.Monitoring == nil { config.Cluster.Monitoring = &files.MonitoringConfig{} } + if config.Cluster.Monitoring.Prometheus == nil { config.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} } + config.Cluster.Monitoring.Prometheus.Override = util.DeepMergeMaps(config.Cluster.Monitoring.Prometheus.Override, map[string]any{ "kube-prometheus-stack": map[string]any{ "prometheusOperator": map[string]any{ @@ -107,6 +111,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.Monitoring.BlackboxExporter == nil { config.Cluster.Monitoring.BlackboxExporter = &files.BlackboxExporterConfig{} } + config.Cluster.Monitoring.BlackboxExporter.Override = util.DeepMergeMaps(config.Cluster.Monitoring.BlackboxExporter.Override, map[string]any{ "prometheus-blackbox-exporter": map[string]any{ "replicas": 1, @@ -119,6 +124,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.Monitoring.Loki == nil { config.Cluster.Monitoring.Loki = &files.LokiConfig{} } + config.Cluster.Monitoring.Loki.Override = util.DeepMergeMaps(config.Cluster.Monitoring.Loki.Override, map[string]any{ "loki": map[string]any{ "read": minimalResourceValues(), @@ -134,6 +140,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.Monitoring.PushGateway == nil { config.Cluster.Monitoring.PushGateway = &files.PushGatewayConfig{} } + config.Cluster.Monitoring.PushGateway.Override = util.DeepMergeMaps(config.Cluster.Monitoring.PushGateway.Override, map[string]any{ "prometheus-pushgateway": map[string]any{ "resources": map[string]any{ @@ -173,6 +180,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.PgOperator == nil { config.Cluster.PgOperator = &files.PgOperatorConfig{} } + config.Cluster.PgOperator.Override = util.DeepMergeMaps(config.Cluster.PgOperator.Override, map[string]any{ "cloudnative-pg": map[string]any{ "resources": map[string]any{ @@ -184,6 +192,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.BarmanCloudPlugin == nil { config.Cluster.BarmanCloudPlugin = &files.BarmanCloudPluginConfig{} } + config.Cluster.BarmanCloudPlugin.Override = util.DeepMergeMaps(config.Cluster.BarmanCloudPlugin.Override, map[string]any{ "plugin-barman-cloud": map[string]any{ "resources": map[string]any{ @@ -195,6 +204,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.Cluster.RgwLoadBalancer == nil { config.Cluster.RgwLoadBalancer = &files.RgwLoadBalancerConfig{} } + config.Cluster.RgwLoadBalancer.Override = util.DeepMergeMaps(config.Cluster.RgwLoadBalancer.Override, map[string]any{ "replicas": 1, }) @@ -202,9 +212,11 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.ManagedServiceBackends == nil { config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{} } + if config.ManagedServiceBackends.Postgres == nil { config.ManagedServiceBackends.Postgres = &files.PgManagedServiceConfig{} } + config.ManagedServiceBackends.Postgres.Override = util.DeepMergeMaps(config.ManagedServiceBackends.Postgres.Override, map[string]any{ "replicas": 1, "resources": map[string]any{ @@ -215,6 +227,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { if config.ManagedServiceBackends.S3 == nil { config.ManagedServiceBackends.S3 = &files.S3ManagedServiceConfig{} } + config.ManagedServiceBackends.S3.Override = util.DeepMergeMaps(config.ManagedServiceBackends.S3.Override, map[string]any{ "replicas": 1, "resources": map[string]any{ @@ -240,6 +253,7 @@ func applyNoRequestsProfile(config *files.RootConfig) { "requests": zeroRequests(), } } + serviceProfiles["deployment_service"].(map[string]any)["replicas"] = 2 serviceProfiles["public_api_service"].(map[string]any)["replicas"] = 2 serviceProfiles["team_service"].(map[string]any)["replicas"] = 2 diff --git a/internal/installer/resource_profiles_test.go b/internal/installer/resource_profiles_test.go index 449db6ef..df7ecbdd 100644 --- a/internal/installer/resource_profiles_test.go +++ b/internal/installer/resource_profiles_test.go @@ -63,12 +63,16 @@ var _ = Describe("ApplyResourceProfile", func() { deployService := MustMap[any](MustMap[any](MustMap[any](config.Codesphere.Override["global"])["services"])["deployment_service"]) AssertZeroRequests(deployService["requests"]) Expect(deployService["replicas"]).To(Equal(2)) + authService := MustMap[any](MustMap[any](MustMap[any](config.Codesphere.Override["global"])["services"])["auth_service"]) Expect(authService["replicas"]).To(Equal(2)) + publicAPIService := MustMap[any](MustMap[any](MustMap[any](config.Codesphere.Override["global"])["services"])["public_api_service"]) Expect(publicAPIService["replicas"]).To(Equal(2)) + workspaceService := MustMap[any](MustMap[any](MustMap[any](config.Codesphere.Override["global"])["services"])["workspace_service"]) Expect(workspaceService["replicas"]).To(Equal(2)) + underprovisionFactors := MustMap[string](MustMap[any](config.Codesphere.Override["global"])["underprovisionFactors"]) Expect(underprovisionFactors["cpu"]).To(Equal("0.01")) Expect(underprovisionFactors["memory"]).To(Equal("0.01")) diff --git a/internal/installer/secrets/crypto.go b/internal/installer/secrets/crypto.go index 2a6800bf..6ac4384f 100644 --- a/internal/installer/secrets/crypto.go +++ b/internal/installer/secrets/crypto.go @@ -36,6 +36,7 @@ func GenerateSSHKeyPair() (privateKey string, publicKey string, err error) { if err != nil { return "", "", err } + pubKeySSH := string(ssh.MarshalAuthorizedKey(sshPubKey)) return string(privKeyPEM), pubKeySSH, nil @@ -56,6 +57,7 @@ func GenerateECDSAKeyPair() (privateKey string, publicKey string, err error) { if err != nil { return "", "", err } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ Type: "PUBLIC KEY", Bytes: pubBytes, @@ -100,7 +102,9 @@ func GenerateCA(cn, country, locality, org string) (keyPEM, certPEM string, err if err != nil { return "", "", err } + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + return keyPEM, certPEM, nil } @@ -117,6 +121,7 @@ func GenerateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []str if caCertBlock == nil { return "", "", fmt.Errorf("decode CA cert PEM: empty block") } + caCert, err := x509.ParseCertificate(caCertBlock.Bytes) if err != nil { return "", "", fmt.Errorf("parse CA cert: %w", err) @@ -144,6 +149,7 @@ func GenerateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []str ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } + for _, ip := range ipAddresses { if parsed := net.ParseIP(ip); parsed != nil { tmpl.IPAddresses = append(tmpl.IPAddresses, parsed) @@ -159,7 +165,9 @@ func GenerateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []str if err != nil { return "", "", err } + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + return keyPEM, certPEM, nil } @@ -172,6 +180,7 @@ func encodePEMKey(key interface{}, keyType string) (string, error) { if !ok { return "", fmt.Errorf("invalid RSA key type") } + pemBytes = pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), @@ -181,10 +190,12 @@ func encodePEMKey(key interface{}, keyType string) (string, error) { if !ok { return "", fmt.Errorf("invalid EC key type") } + ecBytes, err := x509.MarshalECPrivateKey(ecKey) if err != nil { return "", err } + pemBytes = pem.EncodeToMemory(&pem.Block{ Type: "EC PRIVATE KEY", Bytes: ecBytes, @@ -203,16 +214,19 @@ func ParseRSAPrivateKey(keyPEM string) (*rsa.PrivateKey, error) { if block == nil { return nil, fmt.Errorf("empty PEM block") } + switch block.Type { case "PRIVATE KEY": raw, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("parse PKCS8: %w", err) } + key, ok := raw.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("PKCS8 key is not RSA") } + return key, nil case "RSA PRIVATE KEY": return x509.ParsePKCS1PrivateKey(block.Bytes) @@ -226,6 +240,7 @@ func GeneratePassword(length int) (string, error) { if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("read random bytes for password: %w", err) } + return base64.StdEncoding.EncodeToString(b)[:length], nil } diff --git a/internal/installer/secrets/secrets.go b/internal/installer/secrets/secrets.go index b44b2117..3f40296a 100644 --- a/internal/installer/secrets/secrets.go +++ b/internal/installer/secrets/secrets.go @@ -29,29 +29,37 @@ func EnsureSecrets(vault *files.InstallVault, config *files.RootConfig) error { if err := EnsureAuthKeys(vault); err != nil { return fmt.Errorf("ensure auth keys: %w", err) } + if err := EnsureIngressCA(vault, &config.Cluster); err != nil { return fmt.Errorf("ensure ingress CA: %w", err) } + if err := EnsureCephSSHKeys(vault, &config.Ceph); err != nil { return fmt.Errorf("ensure ceph SSH keys: %w", err) } + if err := EnsureSshWorkspaceProxyHostKey(vault); err != nil { return fmt.Errorf("ensure ssh workspace proxy host key: %w", err) } + if config.Postgres.Primary != nil { if err := EnsurePostgresSecrets(vault, &config.Postgres); err != nil { return fmt.Errorf("ensure postgres secrets: %w", err) } } + if err := EnsurePostgresUsers(vault); err != nil { return fmt.Errorf("ensure postgres users: %w", err) } + if err := EnsureMounterHmacSecret(vault); err != nil { return fmt.Errorf("ensure hmac secret: %w", err) } + if err := EnsureDefaultSecrets(vault); err != nil { return fmt.Errorf("ensure default secrets: %w", err) } + return nil } @@ -109,15 +117,18 @@ func EnsureServiceAccountTokens(vault *files.InstallVault) error { "exp": expiresAt.Unix(), "iat": time.Now().Unix(), } + token, err := jwt.NewWithClaims(jwt.SigningMethodRS512, claims).SignedString(rsaKey) if err != nil { return fmt.Errorf("sign token for %s: %w", su.tokenName, err) } + vault.SetSecret(files.SecretEntry{ Name: su.tokenName, Fields: &files.SecretFields{Password: token}, }) } + return nil } @@ -129,6 +140,7 @@ func EnsureAuthKeys(vault *files.InstallVault) error { if err != nil { return fmt.Errorf("generate token key pair: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretTokenPrivateKey, File: &files.SecretFile{Name: "key.pem", Content: tokenPriv}}) vault.SetSecret(files.SecretEntry{Name: files.SecretTokenPublicKey, File: &files.SecretFile{Name: "key.pub", Content: tokenPub}}) } @@ -138,6 +150,7 @@ func EnsureAuthKeys(vault *files.InstallVault) error { if err != nil { return fmt.Errorf("generate domain auth key pair: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretDomainAuthPrivateKey, File: &files.SecretFile{Name: "key.pem", Content: domainPriv}}) vault.SetSecret(files.SecretEntry{Name: files.SecretDomainAuthPublicKey, File: &files.SecretFile{Name: "key.pub", Content: domainPub}}) } @@ -158,6 +171,7 @@ func EnsureMounterHmacSecret(vault *files.InstallVault) error { Name: files.SecretMounterHmacSecret, Fields: &files.SecretFields{Password: old.Fields.Password}, }) + return nil } @@ -165,10 +179,12 @@ func EnsureMounterHmacSecret(vault *files.InstallVault) error { if _, err := rand.Read(b); err != nil { return fmt.Errorf("read random bytes: %w", err) } + vault.SetSecret(files.SecretEntry{ Name: files.SecretMounterHmacSecret, Fields: &files.SecretFields{Password: hex.EncodeToString(b)}, }) + return nil } @@ -183,6 +199,7 @@ func EnsureNixSigningKeys(vault *files.InstallVault, host string) error { if err != nil { return fmt.Errorf("generate ed25519 key pair: %w", err) } + vault.SetSecret(files.SecretEntry{ Name: files.SecretPrivNixSigningKey, Fields: &files.SecretFields{Password: fmt.Sprintf("%s:%s", host, hex.EncodeToString(priv.Seed()))}, @@ -191,6 +208,7 @@ func EnsureNixSigningKeys(vault *files.InstallVault, host string) error { Name: files.SecretPubNixSigningKey, Fields: &files.SecretFields{Password: fmt.Sprintf("%s:%s", host, hex.EncodeToString(pub))}, }) + return nil } @@ -211,6 +229,7 @@ func EnsureDefaultSecrets(vault *files.InstallVault) error { if _, err := rand.Read(b); err != nil { return fmt.Errorf("generate mongodb encryption key: %w", err) } + setPassword(vault, files.SecretMongoDbPasswordEncryptionKey, base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(b)))) } @@ -276,6 +295,7 @@ func setPasswordIfAbsent(vault *files.InstallVault, name, password string) { if vault.GetSecret(name) != nil { return } + setPassword(vault, name, password) } @@ -284,16 +304,21 @@ func generateRSAPKCS8KeyPair(bits int) (privatePEM, publicPEM string, err error) if err != nil { return "", "", err } + pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return "", "", err } + privatePEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8Bytes})) + spkiBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) if err != nil { return "", "", err } + publicPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spkiBytes})) + return privatePEM, publicPEM, nil } @@ -303,15 +328,19 @@ func EnsureIngressCA(vault *files.InstallVault, cluster *files.ClusterConfig) er if vault.GetSecret(files.SecretSelfSignedCaKeyPem) != nil { return nil } + keyPEM, certPEM, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") if err != nil { return fmt.Errorf("generate ingress CA: %w", err) } + vault.SetSecret(files.SecretEntry{ Name: files.SecretSelfSignedCaKeyPem, File: &files.SecretFile{Name: "key.pem", Content: keyPEM}, }) + cluster.Certificates.CA.CertPem = certPEM + return nil } @@ -321,15 +350,19 @@ func EnsureCephSSHKeys(vault *files.InstallVault, ceph *files.CephConfig) error if vault.GetSecret(files.SecretCephSshPrivateKey) != nil { return nil } + privKey, pubKey, err := GenerateSSHKeyPair() if err != nil { return fmt.Errorf("generate ceph SSH keys: %w", err) } + vault.SetSecret(files.SecretEntry{ Name: files.SecretCephSshPrivateKey, File: &files.SecretFile{Name: "id_rsa", Content: privKey}, }) + ceph.CephAdmSSHKey.PublicKey = pubKey + return nil } @@ -340,14 +373,17 @@ func EnsureSshWorkspaceProxyHostKey(vault *files.InstallVault) error { if vault.GetSecret(files.SecretSshWorkspaceProxyHostKey) != nil { return nil } + privKey, _, err := GenerateSSHKeyPair() if err != nil { return fmt.Errorf("generate ssh workspace proxy host key: %w", err) } + vault.SetSecret(files.SecretEntry{ Name: files.SecretSshWorkspaceProxyHostKey, File: &files.SecretFile{Name: "key.pem", Content: privKey}, }) + return nil } @@ -372,6 +408,7 @@ func EnsurePostgresSecrets(vault *files.InstallVault, postgres *files.PostgresCo if err != nil { return fmt.Errorf("generate postgres primary cert: %w", err) } + if err := ValidateCertKeyPair(primaryCertPEM, primaryKeyPEM); err != nil { return fmt.Errorf("validate postgres primary cert/key: %w", err) } @@ -380,10 +417,12 @@ func EnsurePostgresSecrets(vault *files.InstallVault, postgres *files.PostgresCo if err != nil { return fmt.Errorf("generate postgres admin password: %w", err) } + replicaPwd, err := GeneratePassword(32) if err != nil { return fmt.Errorf("generate postgres replica password: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKeyPEM}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPassword, Fields: &files.SecretFields{Password: adminPwd}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresReplicaPassword, Fields: &files.SecretFields{Password: replicaPwd}}) @@ -401,10 +440,13 @@ func EnsurePostgresSecrets(vault *files.InstallVault, postgres *files.PostgresCo if err != nil { return fmt.Errorf("generate postgres replica cert: %w", err) } + if err := ValidateCertKeyPair(replicaCertPEM, replicaKeyPEM); err != nil { return fmt.Errorf("validate postgres replica cert/key: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresReplicaServerKeyPem, File: &files.SecretFile{Name: "replica.key", Content: replicaKeyPEM}}) + postgres.Replica.SSLConfig.ServerCertPem = replicaCertPEM } else { // Still set a dummy value to satisfy the private cloud installer @@ -420,8 +462,10 @@ func EnsurePostgresUsers(vault *files.InstallVault) error { if err != nil { return fmt.Errorf("generate postgres password for %s: %w", svc.Name, err) } + setPasswordIfAbsent(vault, fmt.Sprintf("postgresUser%s", files.Capitalize(svc.Name)), svc.DBUsername()) setPasswordIfAbsent(vault, fmt.Sprintf("postgresPassword%s", files.Capitalize(svc.Name)), svcPwd) } + return nil } diff --git a/internal/installer/secrets/secrets_test.go b/internal/installer/secrets/secrets_test.go index 3d46c264..2e59440d 100644 --- a/internal/installer/secrets/secrets_test.go +++ b/internal/installer/secrets/secrets_test.go @@ -132,6 +132,7 @@ var _ = Describe("EnsureNixSigningKeys", func() { priv := vault.GetSecret("privNixSigningKey") pub := vault.GetSecret("pubNixSigningKey") + Expect(priv).NotTo(BeNil()) Expect(pub).NotTo(BeNil()) @@ -140,6 +141,7 @@ var _ = Describe("EnsureNixSigningKeys", func() { privHex := strings.TrimPrefix(priv.Fields.Password, "myhost:") pubHex := strings.TrimPrefix(pub.Fields.Password, "myhost:") + Expect(privHex).To(MatchRegexp("^[0-9a-f]{64}$")) Expect(pubHex).To(MatchRegexp("^[0-9a-f]{64}$")) }) @@ -338,6 +340,7 @@ var _ = Describe("EnsurePostgresSecrets", func() { admin := vault.GetSecret("postgresPassword") replica := vault.GetSecret("postgresReplicaPassword") + Expect(admin.Fields.Password).To(HaveLen(32)) Expect(replica.Fields.Password).To(HaveLen(32)) Expect(admin.Fields.Password).NotTo(Equal(replica.Fields.Password)) @@ -476,6 +479,7 @@ var _ = Describe("EnsureServiceAccountTokens", func() { // whose content contains the given PEM header. func assertFileSecret(vault *files.InstallVault, name, pemHeader string) { GinkgoHelper() + secret := vault.GetSecret(name) Expect(secret).NotTo(BeNil(), "vault entry %q not found", name) Expect(secret.File).NotTo(BeNil(), "vault entry %q has no file content", name) diff --git a/internal/installer/test_helpers_test.go b/internal/installer/test_helpers_test.go index d64b13a3..d5170640 100644 --- a/internal/installer/test_helpers_test.go +++ b/internal/installer/test_helpers_test.go @@ -15,6 +15,7 @@ func newTestConfig(name string, managed bool, ips ...string) *files.RootConfig { nodes[i] = files.K8sNode{IPAddress: ip} } } + return &files.RootConfig{ Datacenter: files.DatacenterConfig{ ID: 1, diff --git a/internal/installer/vault/vault_encryption.go b/internal/installer/vault/vault_encryption.go index b82be612..6a79c0cb 100644 --- a/internal/installer/vault/vault_encryption.go +++ b/internal/installer/vault/vault_encryption.go @@ -69,6 +69,7 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa if err != nil { return "", "", fmt.Errorf("failed to read age key from %s: %w", explicitKeyFile, err) } + return recipient, explicitKeyFile, nil } @@ -78,6 +79,7 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa if err != nil { return "", "", fmt.Errorf("failed to parse age key from SOPS_AGE_KEY environment variable: %w", err) } + return recipient, "", nil } @@ -87,6 +89,7 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa if err != nil { return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) } + return recipient, keyFile, nil } @@ -94,10 +97,12 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa defaultPath, configErr := getUserConfigDir() if configErr == nil { defaultPath = filepath.Join(defaultPath, sopsage.SopsAgeKeyUserConfigPath) + recipient, err = readRecipientFromFile(defaultPath) if err == nil { return recipient, defaultPath, nil } + if !os.IsNotExist(err) { return "", "", fmt.Errorf("failed to read age key from default location %s: %w", defaultPath, err) } @@ -105,17 +110,21 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa // 4. Generate a new key. keyPath = filepath.Join(fallbackDir, "age_key.txt") + recipient, err = readRecipientFromFile(keyPath) if err != nil { if !os.IsNotExist(err) { return "", "", fmt.Errorf("failed to read age key from fallback location %s: %w", keyPath, err) } + recipient, err = generateAgeKey(keyPath) if err != nil { return "", "", fmt.Errorf("failed to generate age key: %w", err) } + return recipient, keyPath, nil } + return recipient, keyPath, nil } @@ -125,12 +134,15 @@ func parseAgeRecipient(reader io.Reader) (string, error) { if err != nil { return "", fmt.Errorf("failed to parse age identities from file: %w", err) } + if len(ids) == 0 { return "", fmt.Errorf("no age identities found in file") } + if len(ids) > 1 { return "", fmt.Errorf("multiple age identities found in file, expected only one") } + id := ids[0] switch id := id.(type) { case *age.X25519Identity: @@ -151,6 +163,7 @@ func readRecipientFromFile(path string) (recipient string, err error) { defer func() { err = file.Close() }() + return parseAgeRecipient(file) } @@ -160,6 +173,7 @@ func getUserConfigDir() (string, error) { return userConfigDir, nil } } + return os.UserConfigDir() } @@ -171,6 +185,7 @@ func generateAgeKey(keyPath string) (string, error) { } cmd := exec.Command("age-keygen", "-o", keyPath) + out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("age-keygen failed: %w: %s", err, out) @@ -180,16 +195,19 @@ func generateAgeKey(keyPath string) (string, error) { if err != nil { return "", fmt.Errorf("failed to read generated age key: %w", err) } + return recipient, nil } // EncryptFileWithSOPS encrypts src with SOPS+age and writes ciphertext to target. func EncryptFileWithSOPS(src, target, recipient string) error { 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) } + return nil } @@ -206,6 +224,7 @@ func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) { if exitErr, ok := err.(*exec.ExitError); ok { return nil, fmt.Errorf("sops decrypt failed: %s", string(exitErr.Stderr)) } + return nil, fmt.Errorf("sops decrypt failed: %w", err) } @@ -220,14 +239,18 @@ func unwrapSOPSData(data []byte) []byte { 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 diff --git a/internal/installer/vault/vault_encryption_test.go b/internal/installer/vault/vault_encryption_test.go index ba392b56..ab63ddee 100644 --- a/internal/installer/vault/vault_encryption_test.go +++ b/internal/installer/vault/vault_encryption_test.go @@ -18,9 +18,11 @@ func sopsAndAgeAvailable() bool { if _, err := exec.LookPath("sops"); err != nil { return false } + if _, err := exec.LookPath("age-keygen"); err != nil { return false } + return true } @@ -36,12 +38,14 @@ var _ = Describe("VaultEncryption", func() { BeforeEach(func() { var err error + tmpDir, err = os.MkdirTemp("", "age-test-*") Expect(err).ToNot(HaveOccurred()) // Save and clear env vars to isolate tests. origAgeKey, hasOrigAgeKey = os.LookupEnv("SOPS_AGE_KEY") origAgeKeyFile, hasOrigKeyFile = os.LookupEnv("SOPS_AGE_KEY_FILE") + Expect(os.Unsetenv("SOPS_AGE_KEY")).To(Succeed()) Expect(os.Unsetenv("SOPS_AGE_KEY_FILE")).To(Succeed()) }) @@ -54,6 +58,7 @@ var _ = Describe("VaultEncryption", func() { } else { Expect(os.Unsetenv("SOPS_AGE_KEY")).To(Succeed()) } + if hasOrigKeyFile { Expect(os.Setenv("SOPS_AGE_KEY_FILE", origAgeKeyFile)).To(Succeed()) } else { @@ -66,6 +71,7 @@ var _ = Describe("VaultEncryption", func() { if !sopsAndAgeAvailable() { Skip("age-keygen not available") } + keyFile := filepath.Join(tmpDir, "explicit.txt") out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput() Expect(err).ToNot(HaveOccurred(), string(out)) @@ -101,12 +107,14 @@ var _ = Describe("VaultEncryption", func() { // Extract just the private key line (no comments). var privKeyLine string + for _, line := range splitLines(string(data)) { if len(line) > 0 && line[0] != '#' { privKeyLine = line break } } + Expect(privKeyLine).ToNot(BeEmpty()) Expect(os.Setenv("SOPS_AGE_KEY", privKeyLine)).To(Succeed()) @@ -123,6 +131,7 @@ var _ = Describe("VaultEncryption", func() { if !sopsAndAgeAvailable() { Skip("age-keygen not available") } + keyFile := filepath.Join(tmpDir, "keys.txt") out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput() Expect(err).ToNot(HaveOccurred(), string(out)) @@ -166,6 +175,7 @@ var _ = Describe("VaultEncryption", func() { BeforeEach(func() { var err error + tmpDir, err = os.MkdirTemp("", "sops-detect-test-*") Expect(err).ToNot(HaveOccurred()) }) @@ -213,6 +223,7 @@ var _ = Describe("VaultEncryption", func() { BeforeEach(func() { var err error + tmpDir, err = os.MkdirTemp("", "load-vault-test-*") Expect(err).ToNot(HaveOccurred()) }) @@ -288,15 +299,19 @@ var _ = Describe("VaultEncryption", func() { func splitLines(s string) []string { var lines []string + start := 0 + for i := 0; i < len(s); i++ { if s[i] == '\n' { lines = append(lines, s[start:i]) start = i + 1 } } + if start < len(s) { lines = append(lines, s[start:]) } + return lines } diff --git a/internal/installer/vault/vault_secret_creator.go b/internal/installer/vault/vault_secret_creator.go index 67951a5a..f0ac3701 100644 --- a/internal/installer/vault/vault_secret_creator.go +++ b/internal/installer/vault/vault_secret_creator.go @@ -75,6 +75,7 @@ func (v *VaultSecretCreator) CreateSecretFromVault(ctx context.Context, vault *f _, err = controllerutil.CreateOrUpdate(ctx, v.client, secret, func() error { secret.Type = corev1.SecretTypeOpaque secret.Data = secretData + return nil }) if err != nil { @@ -82,6 +83,7 @@ func (v *VaultSecretCreator) CreateSecretFromVault(ctx context.Context, vault *f } log.Printf("Successfully created secret '%s' in namespace '%s' with %d entries", secretName, namespace, len(secretData)) + return nil } @@ -90,6 +92,7 @@ func (v *VaultSecretCreator) CreateSecretFromVault(ctx context.Context, vault *f // Field entries produce "entryName.password" and, when a username is present, "entryName.username". func vaultToSecretData(vault *files.InstallVault) (map[string][]byte, error) { data := make(map[string][]byte) + for _, entry := range vault.Secrets { if entry.File != nil { data[entry.Name] = []byte(entry.File.Content) @@ -100,8 +103,10 @@ func vaultToSecretData(vault *files.InstallVault) (map[string][]byte, error) { } } } + if len(data) == 0 { return nil, fmt.Errorf("no secrets found in vault file") } + return data, nil } diff --git a/internal/installer/vault/vault_templating_secret_store.go b/internal/installer/vault/vault_templating_secret_store.go index 9a086505..b3b2a262 100644 --- a/internal/installer/vault/vault_templating_secret_store.go +++ b/internal/installer/vault/vault_templating_secret_store.go @@ -42,6 +42,7 @@ func NewVaultTemplatingSecretStoreFromFile(vaultPath, ageKeyPath string) (*Vault if err != nil { return nil, err } + return NewVaultTemplatingSecretStore(vault), nil } @@ -68,14 +69,18 @@ func (s *VaultTemplatingSecretStore) ensureVault() error { if s.vault != nil { return nil } + if s.vaultPath == "" { return errors.New("vaultPath not set") } + vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) if err != nil { return err } + s.vault = vault + return nil } @@ -90,6 +95,7 @@ func selectVaultSecretValue(entry files.SecretEntry, selector ...string) (string if entry.File != nil { return entry.File.Content, nil } + if entry.Fields != nil { return entry.Fields.Password, nil } @@ -174,6 +180,7 @@ func IsSOPSEncryptedFile(path string) (bool, error) { if err != nil { return false, err } + return isSOPSEncryptedYAML(data) } @@ -185,6 +192,7 @@ func isSOPSEncryptedYAML(data []byte) (bool, error) { if err := yaml.Unmarshal(data, &doc); err != nil { return false, err } + if len(doc.Content) == 0 { return false, nil } @@ -212,5 +220,6 @@ func parseVaultData(data []byte) (*files.InstallVault, error) { if err := vault.Unmarshal(data); err != nil { return nil, err } + return vault, nil } diff --git a/internal/portal/http.go b/internal/portal/http.go index 018c155d..dca57e31 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -87,5 +87,6 @@ func (c *HttpWrapper) Download(url string, file io.Writer, quiet bool) error { } log.Println("Download finished successfully.") + return nil } diff --git a/internal/portal/http_test.go b/internal/portal/http_test.go index 2a1d5491..bf9019a3 100644 --- a/internal/portal/http_test.go +++ b/internal/portal/http_test.go @@ -238,7 +238,9 @@ var _ = Describe("HttpWrapper", func() { It("downloads content and shows progress", func() { // Capture log output to verify progress is shown var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) defer log.SetOutput(prev) @@ -253,7 +255,9 @@ var _ = Describe("HttpWrapper", func() { quiet = true // Set quiet to true to suppress progress output var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) defer log.SetOutput(prev) @@ -352,7 +356,6 @@ var _ = Describe("HttpWrapper", func() { }) }) }) - }) // Helper types for testing diff --git a/internal/portal/package.go b/internal/portal/package.go index 1a8e22f6..5fa99830 100644 --- a/internal/portal/package.go +++ b/internal/portal/package.go @@ -38,6 +38,7 @@ func (b *Build) GetBuildForDownload(filename string) (Build, error) { build.Artifacts = []Artifact{ a, } + return build, nil } diff --git a/internal/portal/package_test.go b/internal/portal/package_test.go index 5590a771..139aca83 100644 --- a/internal/portal/package_test.go +++ b/internal/portal/package_test.go @@ -12,7 +12,6 @@ import ( var _ = Describe("GetBuildForDownload", func() { It("Extracts a build with a single matching artifact", func() { - build := portal.Build{ Artifacts: []portal.Artifact{ {Filename: "a.txt"}, @@ -31,7 +30,6 @@ var _ = Describe("GetBuildForDownload", func() { Expect(err).NotTo(HaveOccurred()) Expect(res).To(Equal(expectedBuild)) }) - }) var _ = Describe("BuildPackageFilename", func() { diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 7b337e7d..f9aa4720 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -111,6 +111,7 @@ func (c *PortalClient) isOKResponseStatus(resp *http.Response) error { respBody, _ := io.ReadAll(resp.Body) healthyPortalLog = fmt.Sprintf("%s, Body: %s", healthyPortalLog, string(respBody)) } + log.Println(healthyPortalLog) return fmt.Errorf("%s", healthyPortalLog) @@ -122,6 +123,7 @@ func (c *PortalClient) isOKResponseStatus(resp *http.Response) error { // HttpRequest sends an unauthorized HTTP request to the portal API with the specified method, path, and body. func (c *PortalClient) HttpRequest(method string, path string, body []byte) (*http.Response, error) { requestBody := bytes.NewBuffer(body) + url, err := url.JoinPath(c.Env.GetOmsPortalApi(), path) if err != nil { return nil, fmt.Errorf("failed to get generate URL: %w", err) @@ -132,6 +134,7 @@ func (c *PortalClient) HttpRequest(method string, path string, body []byte) (*ht log.Fatalf("failed to create request: %v", err) return nil, err } + if len(body) > 0 { req.Header.Set("Content-Type", "application/json") } @@ -168,10 +171,12 @@ func (c *PortalClient) ListBuilds(product Product, sort string) (Builds, error) if err != nil { return Builds{}, fmt.Errorf("failed to generate URL: %w", err) } + u, parseErr := url.Parse(requestUrl) if parseErr != nil { return Builds{}, fmt.Errorf("failed to parse URL: %w", parseErr) } + q := u.Query() q.Set("sort", sort) u.RawQuery = q.Encode() @@ -187,6 +192,7 @@ func (c *PortalClient) ListBuilds(product Product, sort string) (Builds, error) if resp != nil && resp.Body != nil { _ = resp.Body.Close() } + return Builds{}, fmt.Errorf("failed to list packages: %w", err) } defer func() { _ = resp.Body.Close() }() @@ -221,6 +227,7 @@ func (c *PortalClient) GetBuild(product Product, version string, hash string) (B } matchingPackages := []Build{} + for _, build := range packages.Builds { if build.Version == version { if len(hash) == 0 || strings.HasPrefix(hash, build.Hash) { @@ -248,11 +255,14 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file if err != nil { return fmt.Errorf("failed to get generate URL: %w", err) } + bodyReader := bytes.NewBuffer(reqBody) + req, err := http.NewRequest(http.MethodGet, url, bodyReader) if err != nil { return fmt.Errorf("failed to create GET request to download build: %w", err) } + if startByte > 0 { log.Printf("Resuming download of existing file at byte %d\n", startByte) req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startByte)) @@ -260,10 +270,12 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file // Download the file from startByte to allow resuming req.Header.Set("Content-Type", "application/json") + resp, err := c.AuthorizedHttpRequest(req) if err != nil { return fmt.Errorf("GET request to download build failed: %w", err) } + defer func() { _ = resp.Body.Close() }() // Create a WriteCounter to wrap the output file and report progress, unless quiet is requested. @@ -279,6 +291,7 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file } log.Println("Download finished successfully.") + return nil } @@ -339,6 +352,7 @@ func (c *PortalClient) RegisterAPIKey(owner string, organization string, role st } newKey := &ApiKey{} + err = json.Unmarshal(responseBody, newKey) if err != nil { return nil, fmt.Errorf("failed to decode response body: %w", err) @@ -393,6 +407,7 @@ func (c *PortalClient) UpdateAPIKey(key string, expiresAt time.Time) error { defer func() { _ = resp.Body.Close() }() log.Println("API key updated successfully") + return nil } diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index 8797da67..f5588147 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -49,6 +49,7 @@ var _ = Describe("PortalClient", func() { apiKey string apiKeyErr error ) + BeforeEach(func() { apiKey = "fake-api-key" apiKeyErr = nil @@ -127,6 +128,7 @@ var _ = Describe("PortalClient", func() { if strings.Contains(req.URL.Path, "health") { headers := http.Header{} headers.Add("X-Service-Name", "oms-portal") + return &http.Response{ StatusCode: http.StatusOK, Header: headers, @@ -180,6 +182,7 @@ var _ = Describe("PortalClient", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { getUrl = *req.URL + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte{})), @@ -199,6 +202,7 @@ var _ = Describe("PortalClient", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { getUrl = *req.URL + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte{})), @@ -244,6 +248,7 @@ var _ = Describe("PortalClient", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { getUrl = *req.URL + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(responseBody)), @@ -281,6 +286,7 @@ var _ = Describe("PortalClient", func() { func(req *http.Request) (*http.Response, error) { getUrl = *req.URL headers = req.Header + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("fake-file-contents"))), @@ -308,9 +314,11 @@ var _ = Describe("PortalClient", func() { }) Describe("VerifyBuildArtifactDownload", func() { - var testfilePath string - var testfile *os.File - var testfileMd5Sum string + var ( + testfilePath string + testfile *os.File + testfileMd5Sum string + ) BeforeEach(func() { var err error @@ -392,7 +400,6 @@ var _ = Describe("PortalClient", func() { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to compute checksum")) }) - }) Describe("GetLatestOmsBuild", func() { @@ -417,6 +424,7 @@ var _ = Describe("PortalClient", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { getUrl = *req.URL + return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(responseBody)), diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go index f458a076..4686b19a 100644 --- a/internal/portal/write_counter.go +++ b/internal/portal/write_counter.go @@ -42,6 +42,7 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { if err != nil { log.Printf("error writing progress: %v", err) } + wc.LastUpdate = time.Now() } @@ -54,16 +55,19 @@ func byteCountToHumanReadable(b int64) string { if b < unit { return fmt.Sprintf("%d B", b) } + div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } func (wc *WriteCounter) animate() byte { anim := "/-\\|" wc.currentAnim = (wc.currentAnim + 1) % len(anim) + return anim[wc.currentAnim] } diff --git a/internal/portal/write_counter_test.go b/internal/portal/write_counter_test.go index dad493b0..97778589 100644 --- a/internal/portal/write_counter_test.go +++ b/internal/portal/write_counter_test.go @@ -18,11 +18,14 @@ var _ = Describe("WriteCounter", func() { It("emits progress logs on write", func() { // capture log output var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) defer log.SetOutput(prev) var underlying bytes.Buffer + wc := portal.NewWriteCounter(&underlying) // force an update by setting LastUpdate sufficiently in the past diff --git a/internal/system/image.go b/internal/system/image.go index 01dc423c..1e6a38d3 100644 --- a/internal/system/image.go +++ b/internal/system/image.go @@ -32,6 +32,7 @@ func isCommandAvailable(name string) bool { if err := cmd.Run(); err != nil { return false } + return true } @@ -40,6 +41,7 @@ func (i *Image) LoadImage(imageTarPath string) error { if err != nil { return fmt.Errorf("load failed: %w", err) } + return nil } @@ -48,6 +50,7 @@ func (i *Image) BuildImage(dockerfile string, tag string, buildContext string) e if err != nil { return fmt.Errorf("build failed: %w", err) } + return nil } @@ -56,6 +59,7 @@ func (i *Image) PushImage(tag string) error { if err != nil { return fmt.Errorf("push failed: %w", err) } + return nil } diff --git a/internal/testuser/hash.go b/internal/testuser/hash.go index 4be3b1fa..40c47795 100644 --- a/internal/testuser/hash.go +++ b/internal/testuser/hash.go @@ -16,5 +16,6 @@ func HashAPIToken(apiToken string) string { func hashSecret(secret string) string { hasher := sha256.New() _, _ = hasher.Write([]byte(secret)) + return hex.EncodeToString(hasher.Sum(nil)) } diff --git a/internal/testuser/testuser.go b/internal/testuser/testuser.go index 5b837cd7..9a450259 100644 --- a/internal/testuser/testuser.go +++ b/internal/testuser/testuser.go @@ -61,21 +61,27 @@ func New(opts CreateTestUserOpts) (*TestUserCreator, error) { if opts.Port == 0 { opts.Port = DefaultPort } + if opts.User == "" { opts.User = DefaultUser } + if opts.DBName == "" { opts.DBName = DefaultDBName } + if opts.SSLMode == "" { opts.SSLMode = DefaultSSLMode } + if opts.DatacenterID == 0 { opts.DatacenterID = DefaultDatacenterID } + if opts.Host == "" { return nil, fmt.Errorf("host is required") } + if opts.Password == "" { return nil, fmt.Errorf("password is required") } @@ -105,6 +111,7 @@ func New(opts CreateTestUserOpts) (*TestUserCreator, error) { _ = db.Close() return nil, fmt.Errorf("failed to generate email suffix: %w", err) } + email := fmt.Sprintf("test+%s@codesphere.com", suffix) return &TestUserCreator{opts: opts, db: db, email: email}, nil @@ -115,6 +122,7 @@ func (c *TestUserCreator) close() error { if c.db != nil { return c.db.Close() } + return nil } @@ -124,6 +132,7 @@ func (c *TestUserCreator) Create() (*TestUserResult, error) { if plaintextPassword == "" { return nil, fmt.Errorf("OMS_CS_TEST_USER_PASSWORD environment variable is not set") } + hashedPassword := os.Getenv("OMS_CS_TEST_USER_PASSWORD_HASHED") if hashedPassword == "" { return nil, fmt.Errorf("OMS_CS_TEST_USER_PASSWORD_HASHED environment variable is not set") @@ -154,6 +163,7 @@ func CreateTestUser(opts CreateTestUserOpts) (*TestUserResult, error) { return nil, err } defer func() { _ = creator.close() }() + return creator.Create() } @@ -163,6 +173,7 @@ func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestU if err != nil { return nil, err } + if exists { return nil, fmt.Errorf("test user %s already exists", c.email) } @@ -206,15 +217,18 @@ func (c *TestUserCreator) createInDB(hashedPassword, hashedToken string) (*TestU func (c *TestUserCreator) userExists() (bool, error) { var exists bool + err := c.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM authservice.credentials WHERE email = $1)`, c.email).Scan(&exists) if err != nil { return false, fmt.Errorf("failed to check for existing test user: %w", err) } + return exists, nil } func (c *TestUserCreator) insertCredentials(tx *sql.Tx, hashedPassword string) (int, error) { var userID int + err := tx.QueryRow(` INSERT INTO authservice.credentials (user_id, email, password_hash, authentication_method, signed_up, banned) @@ -225,6 +239,7 @@ func (c *TestUserCreator) insertCredentials(tx *sql.Tx, hashedPassword string) ( if err != nil { return 0, fmt.Errorf("failed to insert credentials: %w", err) } + return userID, nil } @@ -238,15 +253,18 @@ func (c *TestUserCreator) insertEmailConfirmation(tx *sql.Tx) error { if err != nil { return fmt.Errorf("failed to insert email confirmation: %w", err) } + return nil } func (c *TestUserCreator) insertTeam(tx *sql.Tx) (int, error) { var teamID int + datacenterID := c.opts.DatacenterID if datacenterID == 0 { datacenterID = DefaultDatacenterID } + err := tx.QueryRow(` INSERT INTO "teamService".teams (id, "name", description, first_team, default_data_center_id, deleted, deletion_pending, created_at) @@ -257,6 +275,7 @@ func (c *TestUserCreator) insertTeam(tx *sql.Tx) (int, error) { if err != nil { return 0, fmt.Errorf("failed to insert team: %w", err) } + return teamID, nil } @@ -270,6 +289,7 @@ func (c *TestUserCreator) insertTeamMember(tx *sql.Tx, userID, teamID int) error if err != nil { return fmt.Errorf("failed to insert team member: %w", err) } + return nil } @@ -283,6 +303,7 @@ func (c *TestUserCreator) insertAPIToken(tx *sql.Tx, hashedToken string, userID if err != nil { return fmt.Errorf("failed to insert API token: %w", err) } + return nil } @@ -324,6 +345,7 @@ func generateAPIToken() (string, error) { if _, err := rand.Read(b); err != nil { return "", err } + return tokenPrefix + hex.EncodeToString(b), nil } @@ -332,5 +354,6 @@ func generateEmailSuffix() (string, error) { if _, err := rand.Read(b); err != nil { return "", err } + return hex.EncodeToString(b), nil } diff --git a/internal/testuser/testuser_test.go b/internal/testuser/testuser_test.go index bef18b84..39abda58 100644 --- a/internal/testuser/testuser_test.go +++ b/internal/testuser/testuser_test.go @@ -43,6 +43,7 @@ var _ = Describe("generateAPIToken", func() { It("contains only hex characters after the prefix", func() { token, err := generateAPIToken() Expect(err).NotTo(HaveOccurred()) + hexPart := token[len(tokenPrefix):] Expect(hexPart).To(MatchRegexp("^[0-9a-f]{32}$")) }) @@ -65,6 +66,7 @@ var _ = Describe("WriteResultToFile", func() { Expect(err).NotTo(HaveOccurred()) var loaded TestUserResult + err = json.Unmarshal(data, &loaded) Expect(err).NotTo(HaveOccurred()) Expect(loaded.Email).To(Equal("test@example.com")) @@ -111,6 +113,7 @@ var _ = Describe("createInDB", func() { It("creates a test user successfully", func() { sqlDB, m, err := sqlmock.New() Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() // Expect: check if user exists @@ -158,6 +161,7 @@ var _ = Describe("createInDB", func() { It("returns an error when the test user already exists", func() { sqlDB, m, err := sqlmock.New() Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() m.ExpectQuery(`SELECT EXISTS`). @@ -173,6 +177,7 @@ var _ = Describe("createInDB", func() { It("rolls back the transaction on credential insert failure", func() { sqlDB, m, err := sqlmock.New() Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() m.ExpectQuery(`SELECT EXISTS`). @@ -194,6 +199,7 @@ var _ = Describe("createInDB", func() { It("rolls back the transaction on team insert failure", func() { sqlDB, m, err := sqlmock.New() Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() m.ExpectQuery(`SELECT EXISTS`). @@ -221,6 +227,7 @@ var _ = Describe("createInDB", func() { It("uses a custom datacenter ID for the created team", func() { sqlDB, m, err := sqlmock.New() Expect(err).NotTo(HaveOccurred()) + defer func() { _ = sqlDB.Close() }() m.ExpectQuery(`SELECT EXISTS`). diff --git a/internal/tmpl/generate_dockerfile_test.go b/internal/tmpl/generate_dockerfile_test.go index b926dd2e..5fefeabb 100644 --- a/internal/tmpl/generate_dockerfile_test.go +++ b/internal/tmpl/generate_dockerfile_test.go @@ -21,6 +21,7 @@ var _ = Describe("GenerateDockerfile", func() { BeforeEach(func() { var err error + tempDir, err = os.MkdirTemp("", "tmpl-test-*") Expect(err).To(BeNil()) @@ -33,6 +34,7 @@ var _ = Describe("GenerateDockerfile", func() { _ = os.RemoveAll(tempDir) }() } + if mockFileIO != nil { mockFileIO.AssertExpectations(GinkgoT()) } diff --git a/internal/util/command.go b/internal/util/command.go index 0b80dc6d..f838aafa 100644 --- a/internal/util/command.go +++ b/internal/util/command.go @@ -19,6 +19,7 @@ func RunCommand(command string, args []string, cmdDir string) error { if err := cmd.Run(); err != nil { return fmt.Errorf("command failed with exit status: %w", err) } + return nil } @@ -28,12 +29,14 @@ func RunCommandWithOutput(command string, args []string, cmdDir string) (string, cmd := newCommand(command, args, cmdDir) var stdout bytes.Buffer + cmd.Stdout = &stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("command failed with exit status: %w", err) } + return stdout.String(), nil } @@ -43,5 +46,6 @@ func newCommand(command string, args []string, cmdDir string) *exec.Cmd { if cmdDir != "" { cmd.Dir = cmdDir } + return cmd } diff --git a/internal/util/docker.go b/internal/util/docker.go index f489f6dd..83ca44c2 100644 --- a/internal/util/docker.go +++ b/internal/util/docker.go @@ -33,6 +33,7 @@ func (dm *Dockerfile) UpdateFromStatement(dockerfile io.Reader, baseImage string fromRegex := regexp.MustCompile(`(?i)(.*FROM\s+).*workspace-agent[^\s]*(.*)`) updated := false + lines := strings.Split(string(content), "\n") for i, line := range lines { if fromRegex.MatchString(line) { diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index b9380483..9ff7064e 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -50,6 +50,7 @@ func (fs *FilesystemWriter) CreateAndWrite(filePath string, data []byte, fileTyp } log.Printf("\n%s file created: %s", fileType, filePath) + return nil } @@ -58,11 +59,13 @@ func (fs *FilesystemWriter) CreateTemp(dir, pattern string) (string, error) { if err != nil { return "", err } + path := file.Name() if err := file.Close(); err != nil { _ = os.Remove(path) return "", err } + return path, nil } @@ -84,6 +87,7 @@ func (fs *FilesystemWriter) Exists(path string) bool { // stat failed, assume file doesn't exist return false } + return true } @@ -92,6 +96,7 @@ func (fs *FilesystemWriter) IsDirectory(path string) (bool, error) { if err != nil { return false, err } + return fileInfo.IsDir(), err } diff --git a/internal/util/k8s.go b/internal/util/k8s.go index a456010c..c0316165 100644 --- a/internal/util/k8s.go +++ b/internal/util/k8s.go @@ -28,17 +28,21 @@ func DecodeMultiDocYAML(data []byte) ([]*unstructured.Unstructured, error) { var objects []*unstructured.Unstructured reader := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + for { obj := &unstructured.Unstructured{} if err := reader.Decode(obj); err != nil { if err == io.EOF { break } + return nil, fmt.Errorf("decoding yaml document: %w", err) } + if obj.Object == nil { continue } + objects = append(objects, obj) } @@ -51,6 +55,7 @@ func RenderTemplate(raw []byte, vars map[string]string) ([]byte, error) { for key, val := range vars { content = strings.ReplaceAll(content, "${"+key+"}", val) } + return []byte(content), nil } @@ -85,6 +90,7 @@ func GvrForUnstructured(obj *unstructured.Unstructured) (schema.GroupVersionReso if !ok { return schema.GroupVersionResource{}, fmt.Errorf("no GVR mapping for %s — add an entry to gvrMappings", gvk) } + return schema.GroupVersionResource{ Group: gvk.Group, Version: gvk.Version, @@ -104,14 +110,17 @@ func ApplyUnstructured(ctx context.Context, dynClient dynamic.Interface, gvr sch if err != nil { return fmt.Errorf("creating %s %s/%s: %w", gvr.Resource, ns, name, err) } + return nil } obj.SetResourceVersion(existing.GetResourceVersion()) + _, err = resource.Update(ctx, obj, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("updating %s %s/%s: %w", gvr.Resource, ns, name, err) } + return nil } @@ -130,14 +139,17 @@ func ApplySecretFromYAML(ctx context.Context, clientset kubernetes.Interface, da if err != nil { return fmt.Errorf("creating secret %s/%s: %w", secret.Namespace, secret.Name, err) } + return nil } secret.ResourceVersion = existing.ResourceVersion + _, err = secretsClient.Update(ctx, secret, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("updating secret %s/%s: %w", secret.Namespace, secret.Name, err) } + return nil } diff --git a/internal/util/map.go b/internal/util/map.go index e8835d03..290e7ba5 100644 --- a/internal/util/map.go +++ b/internal/util/map.go @@ -10,10 +10,12 @@ 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 } diff --git a/internal/util/path.go b/internal/util/path.go index 42dc6f59..faab9de0 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -16,5 +16,6 @@ func ExpandPath(path string) string { return filepath.Join(home, path[2:]) } } + return path } diff --git a/internal/util/string.go b/internal/util/string.go index bcabcbfd..62a5f93a 100644 --- a/internal/util/string.go +++ b/internal/util/string.go @@ -8,5 +8,6 @@ func Truncate(s string, max int) string { if len(runes) <= max { return s } + return string(runes[:max-3]) + "..." } diff --git a/internal/util/table.go b/internal/util/table.go index c98a3330..ca337b79 100644 --- a/internal/util/table.go +++ b/internal/util/table.go @@ -20,5 +20,6 @@ func GetTableWriter() table.Writer { t := table.NewWriter() t.SetStyle(table.StyleDefault) t.SetOutputMirror(os.Stdout) + return t } diff --git a/internal/util/tar.go b/internal/util/tar.go index e60bae77..443bbab1 100644 --- a/internal/util/tar.go +++ b/internal/util/tar.go @@ -31,29 +31,35 @@ func getCleanTargetPath(destDir string, header *tar.Header) (string, error) { if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("failed to extract %s: target directory outside destination directory %s", header.Name, destDir) } + return targetPath, nil } // openTar opens a .tar file and returns a tar.Reader to read its contents. func openTar(filename string, fileIo FileIO) (*tar.Reader, error) { log.Printf("Opening archive: %s", filename) + file, err := fileIo.Open(filename) if err != nil { return nil, fmt.Errorf("failed to open archive: %w", err) } + bufferedFile := bufio.NewReader(file) tr := tar.NewReader(bufferedFile) + return tr, nil } // openTarGz opens a .tar.gz file and returns a tar.Reader to read its contents. func openTarGz(filename string, fileIo FileIO) (*tar.Reader, error) { log.Printf("Opening archive: %s", filename) + file, err := fileIo.Open(filename) if err != nil { return nil, fmt.Errorf("failed to open archive: %w", err) } + bufferedFile := bufio.NewReader(file) gzr, err := gzip.NewReader(bufferedFile) @@ -62,6 +68,7 @@ func openTarGz(filename string, fileIo FileIO) (*tar.Reader, error) { } tr := tar.NewReader(gzr) + return tr, nil } @@ -77,6 +84,7 @@ func extractEntry(header *tar.Header, targetPath string, fileIo FileIO, tr *tar. if err := fileIo.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", targetPath, err) } + outFile, err := fileIo.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)) if err != nil { return fmt.Errorf("failed to create file %s: %w", targetPath, err) @@ -101,26 +109,31 @@ func extractEntry(header *tar.Header, targetPath string, fileIo FileIO, tr *tar. default: log.Printf("Ignoring unsupported header type flag %c for %s", header.Typeflag, header.Name) } + return nil } // ExtractTarGzSingleFile extracts a single specified file from a .tar.gz archive to the destination directory. func ExtractTarGzSingleFile(fileIo FileIO, archiveFile, fileToExtract, destDir string) error { destDir = filepath.Clean(destDir) + tr, err := openTarGz(archiveFile, fileIo) if err != nil { return err } + return extractTarSingleFile(fileIo, tr, fileToExtract, destDir) } // ExtractTarSingleFile extracts a single specified file from a .tar archive to the destination directory. func ExtractTarSingleFile(fileIo FileIO, archiveFile, fileToExtract, destDir string) error { destDir = filepath.Clean(destDir) + tr, err := openTar(archiveFile, fileIo) if err != nil { return err } + return extractTarSingleFile(fileIo, tr, fileToExtract, destDir) } @@ -135,6 +148,7 @@ func extractTarSingleFile(fileIo FileIO, tr *tar.Reader, fileToExtract, destDir if err == io.EOF { break } + if err != nil { return fmt.Errorf("failed to read next tar entry: %w", err) } @@ -159,9 +173,11 @@ func extractTarSingleFile(fileIo FileIO, tr *tar.Reader, fileToExtract, destDir return nil } } + if fileToExtract != "" { return fmt.Errorf("file %s not found in archive", fileToExtract) } + return nil } @@ -187,9 +203,11 @@ func streamFileFromArchive(tarReader *tar.Reader, filename string) (*tar.Reader, if err == io.EOF { return nil, fmt.Errorf("file %s not found in archive", filename) } + if err != nil { return nil, fmt.Errorf("failed reading tar archive: %w", err) } + if header.FileInfo().Name() == filename { return tarReader, nil } diff --git a/internal/util/tar_test.go b/internal/util/tar_test.go index 2a4b7078..acf0c8cf 100644 --- a/internal/util/tar_test.go +++ b/internal/util/tar_test.go @@ -26,9 +26,11 @@ var _ = Describe("Tar", func() { var ( archiveIn io.Reader ) + BeforeEach(func() { // Create an in-memory tar.gz containing the embedded files. var buf bytes.Buffer + gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) @@ -36,6 +38,7 @@ var _ = Describe("Tar", func() { add := func(name, key string) { dataStr, ok := fileContents[key] Expect(ok).To(BeTrue(), "missing test data for %s", key) + data := []byte(dataStr) hdr := &tar.Header{ Name: name, diff --git a/internal/util/testing/utils.go b/internal/util/testing/utils.go index 036f5032..c6d5abd6 100644 --- a/internal/util/testing/utils.go +++ b/internal/util/testing/utils.go @@ -10,6 +10,7 @@ import ( func MustMap[T any](value any) map[string]T { result, ok := value.(map[string]T) gomega.Expect(ok).To(gomega.BeTrue(), "expected map[string]%T, got %T", *new(T), value) + return result }