diff --git a/.gitignore b/.gitignore index eb2c4d4b..7a746f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ internal/util/testdata/ # Generated config files config.yaml +config-lts-1_77_2.yaml +codesphere-lts-1_77_2.yaml prod.vault.yaml configure-k0s.sh diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index db75be56..4c60e5b0 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -388,7 +388,11 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va if len(c.Opts.CephHosts) > 0 { cephHosts := []files.CephHost{} for _, hostCfg := range c.Opts.CephHosts { - cephHosts = append(cephHosts, files.CephHost(hostCfg)) + cephHosts = append(cephHosts, files.CephHost{ + Hostname: hostCfg.Hostname, + IPAddress: hostCfg.IPAddress, + IsMaster: hostCfg.IsMaster, + }) } config.Ceph.Hosts = cephHosts } diff --git a/cli/cmd/install_codesphere_platform.go b/cli/cmd/install_codesphere_platform.go index 578bd805..018444d3 100644 --- a/cli/cmd/install_codesphere_platform.go +++ b/cli/cmd/install_codesphere_platform.go @@ -45,7 +45,6 @@ func installCodespherePlatform(opts *InstallCodesphereOpts, env env.Env) error { Force: opts.Force, SkipSteps: opts.SkipSteps, AllowedSteps: installer.PlatformSteps, - CodesphereOnly: true, DirectConnection: opts.DirectConnection, AutoApprove: opts.AutoApprove, } diff --git a/cli/cmd/update_dockerfile_test.go b/cli/cmd/update_dockerfile_test.go index 9c852223..0af6798c 100644 --- a/cli/cmd/update_dockerfile_test.go +++ b/cli/cmd/update_dockerfile_test.go @@ -139,17 +139,12 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - // Create a temporary file for the Dockerfile - tempFile, err := os.CreateTemp("", "dockerfile-test-*") + pr, pw, err := os.Pipe() Expect(err).To(BeNil()) - DeferCleanup(func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }) - _, err = tempFile.WriteString(sampleDockerfileContent) + _, err = pw.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) - // Reset file position to beginning - _, _ = tempFile.Seek(0, 0) + Expect(pw.Close()).To(Succeed()) + DeferCleanup(func() { _ = pr.Close() }) c.Opts.Dockerfile = "Dockerfile" c.Opts.Baseimage = "" @@ -160,7 +155,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) mockPackageManager.EXPECT().FileIO().Return(mockFileIO) - mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().Open("Dockerfile").Return(pr, nil) mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(errors.New("write failed")) err = c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) @@ -173,17 +168,12 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - // Create a temporary file for the Dockerfile - tempFile, err := os.CreateTemp("", "dockerfile-test-*") + pr, pw, err := os.Pipe() Expect(err).To(BeNil()) - DeferCleanup(func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }) - _, err = tempFile.WriteString(sampleDockerfileContent) + _, err = pw.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) - // Reset file position to beginning - _, _ = tempFile.Seek(0, 0) + Expect(pw.Close()).To(Succeed()) + DeferCleanup(func() { _ = pr.Close() }) c.Opts.Dockerfile = "Dockerfile" c.Opts.Baseimage = "" @@ -193,7 +183,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockPackageManager.EXPECT().GetFullImageTag("").Return("ubuntu:24.04", nil) mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) mockPackageManager.EXPECT().FileIO().Return(mockFileIO) - mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().Open("Dockerfile").Return(pr, nil) mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) @@ -206,17 +196,12 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - // Create a temporary file for the Dockerfile - tempFile, err := os.CreateTemp("", "dockerfile-test-*") + pr, pw, err := os.Pipe() Expect(err).To(BeNil()) - DeferCleanup(func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }) - _, err = tempFile.WriteString(sampleDockerfileContent) + _, err = pw.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) - // Reset file position to beginning - _, _ = tempFile.Seek(0, 0) + Expect(pw.Close()).To(Succeed()) + DeferCleanup(func() { _ = pr.Close() }) c.Opts.Dockerfile = "Dockerfile" c.Opts.Baseimage = "workspace-agent-20.04.tar" @@ -226,7 +211,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockPackageManager.EXPECT().GetFullImageTag("workspace-agent-20.04.tar").Return("ubuntu:20.04", nil) mockPackageManager.EXPECT().GetBaseimagePath("workspace-agent-20.04.tar", true).Return("/test/workdir/deps/codesphere/images/workspace-agent-20.04.tar", nil) mockPackageManager.EXPECT().FileIO().Return(mockFileIO) - mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().Open("Dockerfile").Return(pr, nil) mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-20.04.tar").Return(nil) @@ -239,17 +224,12 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockImageManager := system.NewMockImageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - // Create a temporary file for the Dockerfile - tempFile, err := os.CreateTemp("", "dockerfile-test-*") + pr, pw, err := os.Pipe() Expect(err).To(BeNil()) - DeferCleanup(func() { - _ = tempFile.Close() - _ = os.Remove(tempFile.Name()) - }) - _, err = tempFile.WriteString(sampleDockerfileContent) + _, err = pw.WriteString(sampleDockerfileContent) Expect(err).To(BeNil()) - // Reset file position to beginning - _, _ = tempFile.Seek(0, 0) + Expect(pw.Close()).To(Succeed()) + DeferCleanup(func() { _ = pr.Close() }) c.Opts.Dockerfile = "custom/Dockerfile" c.Opts.Baseimage = "workspace-agent-24.04.tar" @@ -259,7 +239,7 @@ var _ = Describe("UpdateDockerfileCmd", func() { mockPackageManager.EXPECT().GetFullImageTag("workspace-agent-24.04.tar").Return("registry.example.com/workspace-agent:24.04", nil) mockPackageManager.EXPECT().GetBaseimagePath("workspace-agent-24.04.tar", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) mockPackageManager.EXPECT().FileIO().Return(mockFileIO) - mockFileIO.EXPECT().Open("custom/Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().Open("custom/Dockerfile").Return(pr, nil) mockFileIO.EXPECT().WriteFile("custom/Dockerfile", []byte("FROM registry.example.com/workspace-agent:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index e4810693..20e76aec 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log" + "os" "path/filepath" "slices" "strings" @@ -20,10 +21,12 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/installer/secrets" "github.com/codesphere-cloud/oms/internal/portal" "github.com/codesphere-cloud/oms/internal/testuser" "github.com/codesphere-cloud/oms/internal/util" "github.com/lithammer/shortuuid" + "go.yaml.in/yaml/v3" "google.golang.org/api/dns/v1" ) @@ -80,9 +83,10 @@ type GCPBootstrapper struct { // Environment Env *CodesphereEnvironment // SSH command runner - NodeClient node.NodeClient - PortalClient portal.Portal - GitHubClient github.GitHubClient + NodeClient node.NodeClient + PortalClient portal.Portal + GitHubClient github.GitHubClient + OmsBinaryBuilder func() (string, func(), error) } type CodesphereEnvironment struct { @@ -189,16 +193,17 @@ func NewGCPBootstrapper( gitHubClient github.GitHubClient, ) (*GCPBootstrapper, error) { return &GCPBootstrapper{ - ctx: ctx, - stlog: stlog, - fw: fw, - icg: icg, - GCPClient: gcpClient, - Env: CodesphereEnv, - NodeClient: sshRunner, - PortalClient: portalClient, - Time: time, - GitHubClient: gitHubClient, + ctx: ctx, + stlog: stlog, + fw: fw, + icg: icg, + GCPClient: gcpClient, + Env: CodesphereEnv, + NodeClient: sshRunner, + PortalClient: portalClient, + Time: time, + GitHubClient: gitHubClient, + OmsBinaryBuilder: BuildOmsLinuxBinary, }, nil } @@ -975,6 +980,19 @@ func (b *GCPBootstrapper) InstallCodesphere() error { return fmt.Errorf("failed to ensure Codesphere package on jumpbox: %w", err) } + if ltsSpec := FindLTSSpec(b.Env.InstallVersion); ltsSpec != nil { + if ltsSpec.RequiresOmsBinaryUpdate { + if err := b.ensureNewOmsBinaryOnJumpbox(); err != nil { + return fmt.Errorf("failed to update OMS binary on jumpbox for %s: %w", b.Env.InstallVersion, err) + } + } + if ltsSpec.RequiresCephMasterWatcher { + b.startLTSCephMasterWatcher() + defer b.stopLTSCephMasterWatcher() + } + return b.runLTSInstallPhases(fullPackageFilename, ltsSpec) + } + err = b.runInstallCommand(fullPackageFilename) if err != nil { return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) @@ -983,6 +1001,612 @@ func (b *GCPBootstrapper) InstallCodesphere() error { return nil } +// runLTSInstallPhases runs the three install phases separately for LTS versions. +// Phases 1 (infra) and 2 (dependencies) run without inter-node SSH; steps that +// need SSH (set-up-cluster, ms-backends, codesphere) are skipped. An SSH key +// is then copied to the jumpbox, and Phase 3 (platform) runs with codesphere +// included so the platform is deployed with inter-node SSH available. +func (b *GCPBootstrapper) runLTSInstallPhases(packageFilename string, ltsSpec *LTSSpec) error { + ltsSkips := []string{"set-up-cluster", "ms-backends"} + if ltsSpec.SkipPcApps { + ltsSkips = append(ltsSkips, "argocd") + } + + // Phase 1: Infrastructure (docker, postgres, ceph, kubernetes) — no SSH needed. + b.stlog.Logf("Running infrastructure phase (Phase 1)...") + infraSkips := append([]string{"codesphere"}, ltsSkips...) + if err := b.runInstallPhase(packageFilename, "infra", infraSkips); err != nil { + return fmt.Errorf("infra phase failed: %w", err) + } + + // Phase 2: Dependencies (copy/extract) — skip SSH-needing steps. + b.stlog.Logf("Running dependencies phase (Phase 2)...") + if err := b.runInstallPhase(packageFilename, "dependencies", ltsSkips); err != nil { + return fmt.Errorf("dependencies phase failed: %w", err) + } + + // Set up SSH key so the jumpbox can reach the postgres VM for + // database creation below. + if ltsSpec.RequiresSSHKeyOnJumpbox { + if err := b.ensureSSHKeyOnJumpbox(); err != nil { + return err + } + } + + // Phase 3: Deploy Codesphere via helm directly (bypasses the old LTS + // private-cloud-installer.js which can't handle the codesphere component). + b.stlog.Logf("Deploying Codesphere platform via helm (Phase 3)...") + if err := b.installCodesphereViaHelm(packageFilename); err != nil { + return fmt.Errorf("platform phase (helm) failed: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) installCodesphereViaHelm(packageFilename string) error { + if len(b.Env.ControlPlaneNodes) == 0 { + return fmt.Errorf("no control plane nodes available for kubeconfig") + } + cpIP := b.Env.ControlPlaneNodes[0].GetInternalIP() + + b.stlog.Logf("Copying kubeconfig from control plane (%s)...", cpIP) + mkdirCmd := "mkdir -p /var/lib/k0s/pki" + if err := b.Env.Jumpbox.RunSSHCommand("root", mkdirCmd); err != nil { + return fmt.Errorf("failed to create k0s dir on jumpbox: %w", err) + } + scpCmd := fmt.Sprintf("scp -o StrictHostKeyChecking=no root@%s:/var/lib/k0s/pki/admin.conf /var/lib/k0s/pki/admin.conf", cpIP) + if err := b.Env.Jumpbox.RunSSHCommand("root", scpCmd); err != nil { + return fmt.Errorf("failed to copy kubeconfig from control plane: %w", err) + } + sedCmd := fmt.Sprintf("sed -i 's|server: https://127.0.0.1:6443|server: https://%s:6443|; s|server: https://localhost:6443|server: https://%s:6443|' /var/lib/k0s/pki/admin.conf", cpIP, cpIP) + if err := b.Env.Jumpbox.RunSSHCommand("root", sedCmd); err != nil { + return fmt.Errorf("failed to update kubeconfig server address: %w", err) + } + + csValues, err := yaml.Marshal(b.Env.InstallConfig.Codesphere) + if err != nil { + return fmt.Errorf("failed to marshal codesphere config for helm: %w", err) + } + writeValuesCmd := fmt.Sprintf("cat > /etc/codesphere/codesphere-values.yaml << 'OMSEOF'\n%s\nOMSEOF", string(csValues)) + if err := b.Env.Jumpbox.RunSSHCommand("root", writeValuesCmd); err != nil { + return fmt.Errorf("failed to write codesphere values on jumpbox: %w", err) + } + + globalVals := b.buildGlobalHelmValues() + globalYAML, err := yaml.Marshal(map[string]interface{}{"global": globalVals}) + if err != nil { + return fmt.Errorf("failed to marshal global helm values: %w", err) + } + writeGlobalCmd := fmt.Sprintf("cat > /etc/codesphere/global-values.yaml << 'OMSEOF'\n%s\nOMSEOF", string(globalYAML)) + if err := b.Env.Jumpbox.RunSSHCommand("root", writeGlobalCmd); err != nil { + return fmt.Errorf("failed to write global values on jumpbox: %w", err) + } + + script := `set -e +export KUBECONFIG=/var/lib/k0s/pki/admin.conf + +KUBECTL=$(find /root/oms-workdir -name kubectl -type f 2>/dev/null | head -1) +HELM=$(find /root/oms-workdir -name "helm" -type f 2>/dev/null | head -1) +[ -z "$KUBECTL" ] && echo "ERROR: kubectl not found" && exit 1 +[ -z "$HELM" ] && echo "ERROR: helm binary not found" && exit 1 +chmod +x "$KUBECTL" "$HELM" 2>/dev/null || true + +CHART=$(find /root/oms-workdir -path "*/deps/codesphere/files/chart/Chart.yaml" 2>/dev/null | head -1 | xargs dirname) +[ -z "$CHART" ] && echo "ERROR: codesphere chart not found" && exit 1 + +VAULT_FILE=/etc/codesphere/secrets/prod.vault.yaml +AGE_KEY=/etc/codesphere/secrets/age_key.txt +SECRETS_VALUES=/etc/codesphere/secrets-values.yaml + +echo "Installing prerequisite CRDs..." +# cert-manager CRDs (needed for Certificate and Issuer resources) +"$KUBECTL" apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.12.0/cert-manager.crds.yaml +# Prometheus PodMonitor CRD +"$KUBECTL" apply -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/v0.68.0/example/prometheus-operator-crd/monitoring.coreos.com_podmonitors.yaml + +echo "Creating prerequisite namespaces and issuer..." +"$KUBECTL" create ns workspaces --dry-run=client -o yaml | "$KUBECTL" apply -f - +"$KUBECTL" create ns ws-o11y --dry-run=client -o yaml | "$KUBECTL" apply -f - +"$KUBECTL" apply -f - << 'ISSUER_EOF' +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: codesphere-issuer +spec: + selfSigned: {} +ISSUER_EOF + +# Decrypt vault and generate secrets values (maps vault secrets to global.*) +echo "Decrypting vault and generating helm secrets values..." +SOPS_AGE_KEY_FILE="$AGE_KEY" sops --decrypt "$VAULT_FILE" | python3 -c ' +import sys, yaml +vault = yaml.safe_load(sys.stdin) +result = {"global": {}} +for s in vault.get("secrets", []): + name = s["name"] + if "fields" in s and s["fields"]: + result["global"][name] = s["fields"].get("password", "") + elif "file" in s and s["file"]: + result["global"][name] = s["file"].get("content", "") +yaml.dump(result, sys.stdout, default_flow_style=False) +' > "$SECRETS_VALUES" +echo "Generated secrets values file." + +# Clean up any orphaned resources from previous failed install attempts. +# Helm refuses to adopt resources that lack its ownership labels. +"$HELM" uninstall codesphere -n codesphere 2>/dev/null || true +"$KUBECTL" delete svc,deploy,ingress,configmap,secret,netpol,certificate,issuer --all -n codesphere 2>/dev/null || true + +# Create GHCR pull secret for image pulling. +%s + +# Create postgres Service + Endpoints so codesphere pods can reach +# the external postgres VM, and create required databases. +# (Run after cleanup so the Service isn't deleted.) +PG_IP=$(python3 -c "import yaml; c=yaml.safe_load(open('/etc/codesphere/config.yaml')); print(c.get('postgres',{}).get('primary',{}).get('ip',''))") +if [ -n "$PG_IP" ]; then + "$KUBECTL" apply -f - << SERVICE_EOF +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: codesphere +spec: + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: postgres + namespace: codesphere +subsets: +- addresses: + - ip: $PG_IP + ports: + - port: 5432 +SERVICE_EOF + echo "Created postgres Service → $PG_IP" + ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@"$PG_IP" \ + "docker exec codesphere-postgres.service psql -U postgres -c 'CREATE DATABASE codesphere;' 2>/dev/null; \ + docker exec codesphere-postgres.service psql -U postgres -c 'CREATE DATABASE user_activity;' 2>/dev/null; \ + echo 'Databases ready.'" || echo "Warning: could not create databases on $PG_IP" +fi + +echo "Installing codesphere platform via helm..." +"$HELM" upgrade --install codesphere "$CHART" \ + --namespace codesphere --create-namespace \ + -f /etc/codesphere/config.yaml \ + -f /etc/codesphere/codesphere-values.yaml \ + -f /etc/codesphere/global-values.yaml \ + -f "$SECRETS_VALUES" \ + --timeout 10m +echo "Codesphere platform deployed successfully." +` + // Build the registry secret creation snippet. + regCredSnippet := "" + if b.Env.GitHubPAT != "" && b.Env.RegistryUser != "" { + regCredSnippet = fmt.Sprintf( + `"$KUBECTL" delete secret docker-regcred -n codesphere 2>/dev/null || true +"$KUBECTL" create secret docker-registry docker-regcred \ + --docker-server=ghcr.io \ + --docker-username=%s \ + --docker-password=%s \ + -n codesphere`, b.Env.RegistryUser, b.Env.GitHubPAT) + } + return b.Env.Jumpbox.RunSSHCommand("root", fmt.Sprintf(script, regCredSnippet)) +} + +// buildGlobalHelmValues constructs the global.* helm values from the OMS +// install config, generating token keys and service tokens where needed. +// This replicates what the old LTS private-cloud-installer.js normally does. +func (b *GCPBootstrapper) buildGlobalHelmValues() map[string]interface{} { + g := map[string]interface{}{} + + // --- Token keys + service account tokens --- + tokenVault := &files.InstallVault{} + // Errors are non-fatal here; we proceed with whatever is generated. + _ = secrets.EnsureAuthKeys(tokenVault) + _ = secrets.EnsureServiceAccountTokens(tokenVault) + for _, s := range tokenVault.Secrets { + if s.File != nil { + g[s.Name] = s.File.Content + } else if s.Fields != nil { + g[s.Name] = s.Fields.Password + } + } + + // --- Optional _secrets.tpl keys (empty defaults to avoid b64enc nil panics) --- + for _, k := range []string{ + "stripeWebhookEndpointSecret", "stripePublishableKey", "stripeSecretKey", + "sendGridApiKey", "digitalOceanApiToken", "mongoDbPasswordEncryptionKey", "mixpanelToken", + "facebookClientId", "facebookClientSecret", + "googleClientId", "googleClientSecret", + "gitHubClientId", "gitHubClientSecret", + "bitbucketClientId", "bitbucketClientSecret", + "gitlabClientId", "gitlabClientSecret", + "googleCloudAvatarPrivateKey", "googleCloudVmImagesPrivateKey", + "googleCloudAvatarBucket", "googleCloudAvatarClientEmail", "googleCloudAvatarProjectId", + "gitlabAppClientId", "gitlabAppClientSecret", + "bitbucketAppsClientId", "bitbucketAppsClientSecret", + "azureDevOpsAppClientId", "azureDevOpsAppClientSecret", + "recaptchaSecret", "recaptchaSecretV3", "recaptchaKey", "recaptchaKeyV3", + "postgresUserTeam", "postgresPasswordTeam", + "postgresUserUserActivity", "postgresPasswordUserActivity", + "postgresUserWorkspace", "postgresPasswordWorkspace", + "postgresUserPublicapi", "postgresPasswordPublicapi", + } { + if _, exists := g[k]; !exists { + g[k] = "" + } + } + + // --- Map config sections to global.* --- + cfg := b.Env.InstallConfig + cs := cfg.Codesphere + + dc := cfg.Datacenter + g["dataCenterId"] = dc.ID + g["defaultDataCenterId"] = dc.ID + g["dataCenters"] = []interface{}{map[string]interface{}{ + "id": dc.ID, "name": dc.Name, "city": dc.City, "countryCode": dc.CountryCode, + }} + g["env"] = "prod" + g["hostName"] = cs.Domain + g["domainName"] = cs.Domain + g["workspaceHostingBaseDomain"] = cs.WorkspaceHostingBaseDomain + g["dnsServers"] = cs.DNSServers + + // Experiments: chart expects {name: [envs]}, config has []string + expMap := map[string][]string{} + for _, e := range cs.Experiments { + expMap[e] = []string{"prod"} + } + g["experiments"] = expMap + + // Features: same transformation + featMap := map[string][]string{} + for k, v := range cs.Features { + if v { + featMap[k] = []string{"prod"} + } + } + g["features"] = featMap + + g["deployConfig"] = cs.DeployConfig + g["plans"] = cs.Plans + g["gitProviders"] = cs.GitProviders + if g["gitProviders"] == nil { + g["gitProviders"] = map[string]interface{}{} + } + // Chart expects oauth.providers.{name}.enabled, not OMS oauth.oidc format. + g["oauth"] = map[string]interface{}{ + "providers": map[string]interface{}{ + "github": map[string]interface{}{"enabled": false}, + "gitlab": map[string]interface{}{"enabled": false}, + "bitbucket": map[string]interface{}{"enabled": false}, + "google": map[string]interface{}{"enabled": false}, + "facebook": map[string]interface{}{"enabled": false}, + }, + "additionalProviders": map[string]interface{}{}, + } + g["customDomains"] = cs.CustomDomains + if cs.OpenBao != nil { + g["openBao"] = cs.OpenBao + } else { + g["openBao"] = map[string]interface{}{ + "engine": "cs-secrets-engine", + "user": "admin", + } + } + g["managedServices"] = cs.ManagedServices + g["extraCaPem"] = cs.ExtraCAPem + g["extraWorkspaceEnvVars"] = cs.ExtraWorkspaceEnvVars + g["extraWorkspaceFiles"] = cs.ExtraWorkspaceFiles + g["override"] = cs.Override + + // Postgres connection config (chart uses global.postgres.*, not top-level postgres.*) + g["postgres"] = map[string]interface{}{ + "host": "postgres", + "port": 5432, + "database": "postgres", + "userActivityDatabase": "user_activity", + "ssl": map[string]interface{}{"rejectUnauthorized": false}, + } + if cfg.Postgres.Primary != nil { + if cfg.Postgres.Primary.IP != "" { + g["postgres"].(map[string]interface{})["host"] = cfg.Postgres.Primary.IP + } + if cfg.Postgres.Primary.Hostname != "" { + g["postgres"].(map[string]interface{})["host"] = cfg.Postgres.Primary.Hostname + } + } + + // workspaceObservability defaults (chart references many nested fields) + g["workspaceObservability"] = map[string]interface{}{ + "kubeNamespace": "ws-o11y", + "caSecretName": "ws-o11y-ca", + "certIssuerName": "codesphere-issuer", + "certificatesLifetimeDays": 365, + "certificatesRenewBeforeDays": 30, + "openSearchImage": "", + "openSearchInitImage": "", + "openSearchStorageClass": "rook-ceph-block", + "openSearchVersion": "", + "otelCollectorImage": "", + "otelCollectorInitImage": "", + "opensearchUnderprovisionFactors": map[string]interface{}{"cpu": 100, "memory": 256}, + "otelCollectorUnderprovisionFactors": map[string]interface{}{"cpu": 100, "memory": 256}, + } + + // workspaceImages defaults + g["workspaceImages"] = map[string]interface{}{ + "server": map[string]interface{}{}, + "vpn": map[string]interface{}{}, + } + + // workspaceRouterImage defaults + g["workspaceRouterImage"] = map[string]interface{}{ + "name": "ghcr.io/codesphere-cloud/docker/workspace-router", + "tag": "latest", + } + + // workspacePriorityClass defaults + g["workspacePriorityClass"] = map[string]interface{}{ + "free": "workspace-free", + "paid": "workspace-paid", + } + + // publicIP from gateway + g["publicIP"] = b.Env.GatewayIP + + // metrics defaults + g["metrics"] = map[string]interface{}{ + "type": "prometheus", + "prometheus": map[string]interface{}{ + "jobName": "codesphere", + "pushGatewayUrl": "", + }, + } + + // ipService defaults + g["ipService"] = map[string]interface{}{ + "loadBalancerKind": "metallb", + "addressPools": []string{}, + } + + // publicApi defaults + g["publicApi"] = map[string]interface{}{ + "rateLimitPerMin": 60, + } + + // Recaptcha defaults + g["recaptchaV3Threshold"] = 0.5 + g["showPromotions"] = false + g["sendGridListId"] = "" + g["twitterTrackingId"] = "" + g["googleTrackingId"] = "" + g["teamCleanupWhitelist"] = []string{} + + // workspace hosting + g["hosts"] = []interface{}{} + g["availableDataCenters"] = []interface{}{dc.ID} + g["namespace"] = "codesphere" + g["mounterHmacSecret"] = "" // filled by vault if present + + // Cert issuer from config + g["certIssuer"] = map[string]interface{}{ + "type": cs.CertIssuer.Type, + "acme": cs.CertIssuer.Acme, + } + g["deployCert"] = cfg.Cluster.Certificates.CA + + // Ceph + ceph := cfg.Ceph + g["ceph"] = map[string]interface{}{ + "mdsNamespace": "rook-ceph", + "credentialsSecretName": "rook-ceph", + "activeMds": 1, + "monEndpointsConfigMapName": "rook-ceph-mon-endpoints", + "storageClass": "rook-cephfs", + "cephAdmSshKey": ceph.CephAdmSSHKey, + "csiKubeletDir": ceph.CsiKubeletDir, + "nodesSubnet": ceph.NodesSubnet, + "hosts": ceph.Hosts, + "osds": ceph.OSDs, + } + + // Network policies + g["networkPolicies"] = map[string]interface{}{ + "workspace": map[string]interface{}{ + "namespace": "workspaces", + "podSubnet": "10.244.0.0/16", + "serviceSubnet": "10.96.0.0/12", + "cephIps": []string{}, + }, + "publicGateway": map[string]interface{}{"namespace": "codesphere", "name": "public-gateway"}, + "workspaceReverseProxy": map[string]interface{}{"namespace": "codesphere", "name": "workspace-reverse-proxy"}, + "gateway": map[string]interface{}{"namespace": "codesphere", "name": "gateway"}, + "restrictProxyIngress": false, + } + + // Frontend gateway + g["frontendGateway"] = map[string]interface{}{ + "redirectMarketingPages": map[string]interface{}{"enabled": false}, + "redirectToIde": true, + "cert": map[string]interface{}{"algorithm": "RSA", "size": 2048}, + "config": map[string]interface{}{"worker": map[string]interface{}{"worker_processes": "1"}}, + "image": map[string]interface{}{"name": "ghcr.io/codesphere-cloud/docker/nginx", "tag": "1.26.3"}, + "replicas": 1, + "requests": map[string]interface{}{"cpu": "1000m", "ephemeral-storage": "50M", "memory": "80Mi"}, + "issuer": "codesphere-issuer", + } + + // Branding + g["branding"] = map[string]interface{}{ + "desc": "Codesphere - Your zero-config cloud IDE", + "docsUrl": "https://codesphere.com/docs/en", + "pipelineExampleUrl": "https://github.com/codesphere-cloud/nodejs-template/blob/main/ci.yml", + "faviconHref": "/ide/assets/favicon-32x32.png", + "title": "Codesphere", + "tosUrl": "https://codesphere.com/terms", + } + + // Email + g["emailConfig"] = map[string]interface{}{ + "noReplyAddress": "noreply@codesphere.com", + "supportAddress": "support@codesphere.com", + "organizationName": "Codesphere", + "blogLink": "https://codesphere.com/blog", + "feedbackLink": "https://codesphere.com/feedback", + "tutorialLink": "https://codesphere.com/docs", + "topLogoUrl": "https://codesphere.com/logo.png", + } + + // Other hardcoded defaults from the chart + g["logAsJson"] = true + g["customDomainIngressClass"] = "nginx" + g["allowWorkspacesOnControlPlane"] = cfg.Kubernetes.ManagedByCodesphere + g["imageTag"] = cfg.GeneratedForVersion + if g["imageTag"] == "" { + g["imageTag"] = "v1.77.2" + } + g["freeWorkspaceTeamLimit"] = 5 + g["freeWorkspaceClusterLimit"] = 3 + g["freeGpuWsTeamLimit"] = 0 + g["gracePeriodDays"] = 21 + g["useDedicatedWorkspaceNodes"] = true + g["useUsageBasedBillingForNewCustomers"] = false + g["trustyThresholdCent"] = 1000 + + // Services defaults + g["services"] = map[string]interface{}{ + "priorityClass": "system-cluster-critical", + "marketplace": map[string]interface{}{"replicas": 1, "image": "ghcr.io/codesphere-cloud/docker/marketplace"}, + } + + // OTEL defaults + g["otel"] = map[string]interface{}{ + "config": map[string]interface{}{}, + "limits": map[string]interface{}{}, + "requests": map[string]interface{}{}, + "replicas": 1, + } + + return g +} + +// runInstallPhase runs a single install phase on the jumpbox with the given +// skip steps. It builds the full oms install codesphere command for the phase. +func (b *GCPBootstrapper) runInstallPhase(packageFilename, phase string, extraSkips []string) error { + skipSteps := append([]string{}, extraSkips...) + if b.Env.RegistryType == RegistryTypeGitHub { + skipSteps = append(skipSteps, "load-container-images") + } + + cmd := fmt.Sprintf("oms install codesphere %s -c /etc/codesphere/config.yaml -k %s/age_key.txt --vault %s -p %s", + phase, b.Env.SecretsDir, filepath.Join(b.Env.SecretsDir, "prod.vault.yaml"), packageFilename) + if len(skipSteps) > 0 { + cmd += " -s " + strings.Join(skipSteps, ",") + } + return b.Env.Jumpbox.RunSSHCommand("root", cmd) +} + +// ensureNewOmsBinaryOnJumpbox copies a freshly-built linux/amd64 OMS binary to +// the jumpbox, replacing the old installed version. +func (b *GCPBootstrapper) ensureNewOmsBinaryOnJumpbox() error { + b.stlog.Logf("Updating OMS binary on jumpbox for %s compatibility...", b.Env.InstallVersion) + + binaryPath, cleanup, err := b.OmsBinaryBuilder() + if err != nil { + return fmt.Errorf("failed to prepare OMS linux binary: %w", err) + } + defer cleanup() + + const remoteTmpPath = "/tmp/oms-new" + if err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, binaryPath, remoteTmpPath); err != nil { + return fmt.Errorf("failed to copy OMS binary to jumpbox: %w", err) + } + + if err := b.Env.Jumpbox.RunSSHCommand("root", fmt.Sprintf("chmod +x %s && mv %s /usr/local/bin/oms", remoteTmpPath, remoteTmpPath)); err != nil { + return fmt.Errorf("failed to install OMS binary on jumpbox: %w", err) + } + + return nil +} + +// ensureSSHKeyOnJumpbox copies the user's SSH private key to the jumpbox at +// /root/.ssh/id_rsa and writes an SSH config so that the LTS installer's +// private-cloud-installer.js can SSH to worker nodes via its internal SshClient. +func (b *GCPBootstrapper) ensureSSHKeyOnJumpbox() error { + b.stlog.Logf("Copying SSH private key to jumpbox for inter-node access...") + + srcPath := b.Env.SSHPrivateKeyPath + if srcPath == "" { + return fmt.Errorf("SSH private key path not set (use --ssh-private-key-path)") + } + + keyBytes, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("failed to read SSH private key from %s: %w", srcPath, err) + } + + // Set up .ssh directory with correct permissions (SSH is strict: 700 for dir). + setupCmd := "mkdir -p /root/.ssh && chmod 700 /root/.ssh" + if err := b.Env.Jumpbox.RunSSHCommand("root", setupCmd); err != nil { + return fmt.Errorf("failed to create .ssh directory on jumpbox: %w", err) + } + + // Write the key via heredoc. + writeKeyCmd := fmt.Sprintf("cat > /root/.ssh/id_rsa << 'OMSEOF'\n%s\nOMSEOF\nchmod 600 /root/.ssh/id_rsa", string(keyBytes)) + if err := b.Env.Jumpbox.RunSSHCommand("root", writeKeyCmd); err != nil { + return fmt.Errorf("failed to write SSH private key on jumpbox: %w", err) + } + + // Write an SSH config that explicitly uses this identity file so the + // LTS installer's ssh child process always finds it. + sshConfig := "Host *\n IdentityFile /root/.ssh/id_rsa\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n" + writeConfigCmd := fmt.Sprintf("cat > /root/.ssh/config << 'OMSEOF'\n%sOMSEOF\nchmod 600 /root/.ssh/config", sshConfig) + if err := b.Env.Jumpbox.RunSSHCommand("root", writeConfigCmd); err != nil { + return fmt.Errorf("failed to write SSH config on jumpbox: %w", err) + } + + return nil +} + +// startLTSCephMasterWatcher starts a background process on the ceph master node that continuously +// re-adds the master to the Ceph orchestrator host inventory. This is required for LTS versions +// because the installer's configureHosts step applies a declarative host spec containing only the +// non-master nodes, which removes the master from the inventory. The watcher restores it within +// seconds, before the subsequent configureMonitors step runs. +func (b *GCPBootstrapper) startLTSCephMasterWatcher() { + if len(b.Env.CephNodes) == 0 || len(b.Env.InstallConfig.Ceph.Hosts) == 0 { + return + } + masterHost := b.Env.InstallConfig.Ceph.Hosts[0] + // Use cephadm shell (same as the installer) so the command runs inside the ceph container, + // bypassing any standalone-binary or keyring availability issues on the host. + // The FSID is auto-detected from /var/lib/ceph/; all output is logged for diagnostics. + cmd := fmt.Sprintf( + `nohup bash -c "while true; do FSID=\$(ls /var/lib/ceph/ 2>/dev/null | head -1); [ -n \"\$FSID\" ] && [ -x /usr/local/bin/cephadm ] && /usr/local/bin/cephadm shell --fsid \"\$FSID\" -- ceph orch host add %s %s 2>&1; sleep 3; done" > /tmp/ceph-host-watcher.log 2>&1 & echo $! > /tmp/ceph-host-watcher.pid`, + masterHost.Hostname, + masterHost.IPAddress, + ) + if err := b.Env.CephNodes[0].RunSSHCommand("root", cmd); err != nil { + b.stlog.Logf("Note: could not start ceph master host watcher on %s: %v", masterHost.Hostname, err) + } +} + +// stopLTSCephMasterWatcher stops the background watcher started by startLTSCephMasterWatcher. +func (b *GCPBootstrapper) stopLTSCephMasterWatcher() { + if len(b.Env.CephNodes) == 0 || len(b.Env.InstallConfig.Ceph.Hosts) == 0 { + return + } + cmd := `kill $(cat /tmp/ceph-host-watcher.pid 2>/dev/null) 2>/dev/null; rm -f /tmp/ceph-host-watcher.pid /tmp/ceph-host-watcher.log` + _ = b.Env.CephNodes[0].RunSSHCommand("root", cmd) +} + func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { packageFilename := "installer.tar.gz" if b.Env.RegistryType == RegistryTypeGitHub { @@ -1010,8 +1634,7 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { 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 { + if err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd); err != nil { return "", fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) } @@ -1020,6 +1643,18 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { func (b *GCPBootstrapper) runInstallCommand(packageFilename string) error { b.stlog.Logf("Installing Codesphere...") + + // LTS packages whose bom.json predates the pc-applications component + // need the ArgoCD+pc-apps pre-step skipped to avoid a missing-BOM error. + if ltsSpec := FindLTSSpec(b.Env.InstallVersion); ltsSpec != nil { + if ltsSpec.SkipPcApps { + b.Env.InstallSkipSteps = append(b.Env.InstallSkipSteps, "argocd") + } + if ltsSpec.SkipSetupCluster { + b.Env.InstallSkipSteps = append(b.Env.InstallSkipSteps, "set-up-cluster", "ms-backends", "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) diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index dd3451a4..5029d65f 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -1326,6 +1326,87 @@ var _ = Describe("GCP Bootstrapper", func() { Expect(err).NotTo(HaveOccurred()) }) + Context("LTS 1.77.2", func() { + BeforeEach(func() { + csEnv.InstallVersion = "codesphere-lts-v1.77.2" + }) + JustBeforeEach(func() { + // Inject a stub binary builder so tests don't invoke `go build`. + bs.OmsBinaryBuilder = func() (string, func(), error) { + f, err := os.CreateTemp("", "oms-test-binary-*") + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + return f.Name(), func() { Expect(os.Remove(f.Name())).To(Succeed()) }, nil + } + // Create a fake SSH private key file for the jumpbox key copy. + sshKeyFile, err := os.CreateTemp("", "oms-test-ssh-key-*") + Expect(err).NotTo(HaveOccurred()) + _, err = sshKeyFile.WriteString("fake-ssh-private-key") + Expect(err).NotTo(HaveOccurred()) + Expect(sshKeyFile.Close()).To(Succeed()) + csEnv.SSHPrivateKeyPath = sshKeyFile.Name() + DeferCleanup(func() { Expect(os.Remove(sshKeyFile.Name())).To(Succeed()) }) + }) + It("downloads package, updates OMS binary, and installs codesphere", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms download package -f installer.tar.gz -H abc1234567890 codesphere-lts-v1.77.2").Return(nil) + nodeClient.EXPECT().CopyFile(mock.MatchedBy(jumpboxMatcher), mock.Anything, "/tmp/oms-new").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "chmod +x /tmp/oms-new && mv /tmp/oms-new /usr/local/bin/oms").Return(nil) + // Phase 1: Infra (skip codesphere + SSH-needing steps). + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms install codesphere infra -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml -p codesphere-lts-v1.77.2-abc1234567890-installer.tar.gz -s codesphere,set-up-cluster,ms-backends,argocd").Return(nil) + // Phase 2: Dependencies (skip SSH-needing steps). + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms install codesphere dependencies -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml -p codesphere-lts-v1.77.2-abc1234567890-installer.tar.gz -s set-up-cluster,ms-backends,argocd").Return(nil) + // SSH key setup for platform phase. + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "mkdir -p /root/.ssh && chmod 700 /root/.ssh").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "cat > /root/.ssh/id_rsa << 'OMSEOF'\nfake-ssh-private-key\nOMSEOF\nchmod 600 /root/.ssh/id_rsa").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "cat > /root/.ssh/config << 'OMSEOF'\nHost *\n IdentityFile /root/.ssh/id_rsa\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\nOMSEOF\nchmod 600 /root/.ssh/config").Return(nil) + // Phase 3: Copy kubeconfig, fix server address, then deploy via helm. + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "mkdir -p /var/lib/k0s/pki").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "scp -o StrictHostKeyChecking=no root@10.0.0.1:/var/lib/k0s/pki/admin.conf /var/lib/k0s/pki/admin.conf").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "sed -i 's|server: https://127.0.0.1:6443|server: https://10.0.0.1:6443|; s|server: https://localhost:6443|server: https://10.0.0.1:6443|' /var/lib/k0s/pki/admin.conf").Return(nil) + // Write codesphere values YAML for helm. + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + mock.Anything).Return(nil) + // Helm install. + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + mock.Anything).Return(nil) + + err := bs.InstallCodesphere() + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails when OmsBinaryBuilder fails", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms download package -f installer.tar.gz -H abc1234567890 codesphere-lts-v1.77.2").Return(nil) + bs.OmsBinaryBuilder = func() (string, func(), error) { + return "", func() {}, fmt.Errorf("build failed") + } + + err := bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to update OMS binary on jumpbox for codesphere-lts-v1.77.2")) + }) + + It("fails when copying binary to jumpbox fails", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms download package -f installer.tar.gz -H abc1234567890 codesphere-lts-v1.77.2").Return(nil) + nodeClient.EXPECT().CopyFile(mock.MatchedBy(jumpboxMatcher), mock.Anything, "/tmp/oms-new").Return(fmt.Errorf("copy failed")) + + err := bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to update OMS binary on jumpbox for codesphere-lts-v1.77.2")) + }) + }) + Context("with local package", func() { BeforeEach(func() { csEnv.InstallLocal = "fake-installer-lite.tar.gz" @@ -1388,7 +1469,8 @@ var _ = Describe("GCP Bootstrapper", func() { }) It("fails when download package fails", func() { - nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", "oms download package -f installer.tar.gz -H abc1234567890 v1.2.3").Return(fmt.Errorf("download error")) + downloadCmd := "oms download package -f installer.tar.gz -H abc1234567890 v1.2.3" + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", downloadCmd).Return(fmt.Errorf("download error")).Once() err := bs.InstallCodesphere() Expect(err).To(HaveOccurred()) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 93fc51e7..a61d8133 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -138,6 +138,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { previousPrimaryHostname := b.Env.InstallConfig.Postgres.Primary.Hostname b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() b.Env.InstallConfig.Postgres.Primary.Hostname = b.Env.PostgreSQLNode.GetName() + b.Env.InstallConfig.Postgres.Primary.SSHPort = 22 b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" b.Env.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" @@ -146,14 +147,17 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { Hostname: b.Env.CephNodes[0].GetName(), IsMaster: true, IPAddress: b.Env.CephNodes[0].GetInternalIP(), + SSHPort: 22, }, { Hostname: b.Env.CephNodes[1].GetName(), IPAddress: b.Env.CephNodes[1].GetInternalIP(), + SSHPort: 22, }, { Hostname: b.Env.CephNodes[2].GetName(), IPAddress: b.Env.CephNodes[2].GetInternalIP(), + SSHPort: 22, }, } b.Env.InstallConfig.Ceph.OSDs = []files.CephOSD{ @@ -179,18 +183,21 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { ControlPlanes: []files.K8sNode{ { IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + SSHPort: 22, }, }, Workers: []files.K8sNode{ { IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + SSHPort: 22, }, - { IPAddress: b.Env.ControlPlaneNodes[1].GetInternalIP(), + SSHPort: 22, }, { IPAddress: b.Env.ControlPlaneNodes[2].GetInternalIP(), + SSHPort: 22, }, }, } @@ -362,9 +369,20 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.Env.InstallConfig.Codesphere.Experiments = b.Env.Experiments b.Env.InstallConfig.Codesphere.Features = b.Env.FeatureFlags + + if ltsSpec := FindLTSSpec(b.Env.InstallVersion); ltsSpec != nil { + if UserSpecifiedExperiments(b.Env.InstallConfig.Codesphere.Experiments) { + if err := ValidateExperiments(b.Env.InstallConfig.Codesphere.Experiments, ltsSpec.Experiments); err != nil { + return fmt.Errorf("unsupported experiments for %s: %w", b.Env.InstallVersion, err) + } + } + b.Env.InstallConfig.Codesphere.Experiments = FilterExperiments(b.Env.InstallConfig.Codesphere.Experiments, ltsSpec.Experiments) + } b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() + b.Env.InstallConfig.GeneratedForVersion = b.Env.InstallVersion + if !b.Env.ExistingConfigUsed { err := b.icg.GenerateSecrets() if err != nil { @@ -401,16 +419,24 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("failed to write config file: %w", err) } + jumpboxConfigLocalPath := b.Env.InstallConfigPath + if ltsSpec := FindLTSSpec(b.Env.InstallVersion); ltsSpec != nil && ltsSpec.RequiresJumpboxFiles { + var err error + jumpboxConfigLocalPath, err = b.writeLTSJumpboxFiles(ltsSpec) + if err != nil { + return err + } + } + if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } - err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.InstallConfigPath, remoteInstallConfigPath) - if err != nil { + if err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, jumpboxConfigLocalPath, remoteInstallConfigPath); err != nil { return fmt.Errorf("failed to copy install config to jumpbox: %w", err) } - err = b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.SecretsFilePath, b.Env.SecretsDir+"/prod.vault.yaml") + err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.SecretsFilePath, b.Env.SecretsDir+"/prod.vault.yaml") if err != nil { return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) } @@ -556,6 +582,22 @@ func (b *GCPBootstrapper) EncryptVault() error { return nil } +// writeLTSJumpboxFiles generates the LTS-versioned config-lts-.yaml locally +// and returns its path for copying to the jumpbox. +func (b *GCPBootstrapper) writeLTSJumpboxFiles(spec *LTSSpec) (jumpboxConfigLocalPath string, err error) { + jumpboxConfigBytes, err := GenerateLTSJumpboxFiles(b.Env.InstallConfig, spec) + if err != nil { + return "", fmt.Errorf("failed to prepare %s jumpbox config: %w", spec.InstallVersion, err) + } + + jumpboxConfigLocalPath = LocalLTSConfigPath(b.Env.InstallConfigPath, spec) + if err := b.fw.CreateAndWrite(jumpboxConfigLocalPath, jumpboxConfigBytes, "Jumpbox Config ("+spec.InstallVersion+")"); err != nil { + return "", fmt.Errorf("failed to write %s jumpbox config file: %w", spec.InstallVersion, err) + } + + return jumpboxConfigLocalPath, nil +} + // decryptVault creates an unencrypted copy of the vault in dst on the jumpbox // Make sure to delete the unencrypted file when not needed anymore. func (b *GCPBootstrapper) decryptVault(dst string) error { diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 340c47d2..ca4a955a 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -748,6 +748,76 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Codesphere.OpenBao.Engine).To(Equal("fake-engine")) }) }) + + Context("When install version is codesphere-lts-v1.77.2", func() { + BeforeEach(func() { + csEnv.InstallVersion = "codesphere-lts-v1.77.2" + csEnv.Experiments = []string{"managed-services", "custom-service-image", "ms-in-ls"} + csEnv.InstallConfig.Codesphere.ManagedServices = []files.ManagedServiceConfig{ + { + Name: "postgres", + Version: "v1", + Author: "Codesphere", + DisplayName: "PostgreSQL", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{Endpoint: "http://ms-backend:3000"}, + }, + }, + { + Name: "s3", + Version: "v2", + }, + } + }) + It("generates the LTS jumpbox files and copies them to the jumpbox", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + fw.EXPECT().CreateAndWrite("config-lts-1_77_2.yaml", mock.Anything, mock.Anything).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + }) + It("does not modify the in-memory codesphere config for LTS 1.77.2", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + fw.EXPECT().CreateAndWrite(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + // In-memory config must be filtered to only LTS-compatible experiments + Expect(bs.Env.InstallConfig.Codesphere.Experiments).To(ConsistOf("managed-services", "custom-service-image", "ms-in-ls")) + Expect(bs.Env.InstallConfig.CodesphereConfigPath).To(BeEmpty()) + + // Managed services are preserved for LTS 1.77.2 (they remain in the profile config) + services := bs.Env.InstallConfig.Codesphere.ManagedServices + Expect(services[0].Author).To(Equal("Codesphere")) + Expect(services[0].DisplayName).To(Equal("PostgreSQL")) + Expect(services[0].Backend.API.Endpoint).To(Equal("http://ms-backend:3000")) + }) + }) + + Context("When install version is not codesphere-lts-v1.77.2", func() { + BeforeEach(func() { + csEnv.InstallVersion = "master" + csEnv.Experiments = gcp.DefaultExperiments + }) + It("uses the regular config.yaml directly (inline codesphere object)", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + // config.yaml → /etc/codesphere/config.yaml, prod.vault.yaml → secrets dir + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + }) + }) + Context("When external Loki config is set", func() { BeforeEach(func() { csEnv.ExternalLokiEndpoint = "https://loki.example.com/loki/api/v1/push" diff --git a/internal/bootstrap/gcp/lts.go b/internal/bootstrap/gcp/lts.go new file mode 100644 index 00000000..30b1360b --- /dev/null +++ b/internal/bootstrap/gcp/lts.go @@ -0,0 +1,186 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +// LTSSpec describes the compatibility requirements for a specific LTS release. +// To add support for a new LTS version, add a new entry to ltsRegistry — no other +// files need to change. +type LTSSpec struct { + // InstallVersion is the exact install version string that identifies this LTS release. + InstallVersion string + // Experiments lists all experiments supported by this LTS release. + // Only these experiments are passed to the installer, setting any experiment not in + // this list will cause an error during ApplyLTSCompat. + Experiments []string + // ClearManagedServices instructs the compat layer to set ManagedServices to nil. + // Required when the LTS schema expects full provider definitions, not the abbreviated + // form stored in config.yaml. + ClearManagedServices bool + // RequiresJumpboxFiles instructs the bootstrap to generate LTS-versioned compat config files + // (e.g. config-lts-1_77_2.yaml) instead of using config.yaml directly. + // This is needed for LTS installers whose schema differs from the current config format. + RequiresJumpboxFiles bool + // RequiresOmsBinaryUpdate instructs the bootstrap to build a fresh linux/amd64 OMS binary + // and deploy it to the jumpbox before running the installer. + // This is needed when the LTS installer relies on OMS CLI features only present in the + // version of OMS that bootstraps the environment (not the OMS binary shipped with the LTS). + RequiresOmsBinaryUpdate bool + // RequiresCephMasterWatcher instructs the bootstrap to start a background watcher process + // on the Ceph master node that continuously re-adds the master to the Ceph orchestrator + // host inventory. This works around a bug in the LTS installer's configureHosts step + // that removes the master from inventory when applying a declarative host spec. + RequiresCephMasterWatcher bool + // SkipPcApps instructs the bootstrap to add "argocd" to the install skip-steps, + // skipping the ArgoCD+pc-apps pre-step. This is needed for LTS releases whose + // bom.json predates the pc-applications component (introduced after the LTS was cut). + SkipPcApps bool + // RequiresSSHKeyOnJumpbox instructs the bootstrap to copy the user's SSH private key + // to the jumpbox at /root/.ssh/id_rsa before running the installer. This is needed + // for LTS releases whose private-cloud-installer.js needs inter-node SSH (e.g. the + // set-up-cluster step) but no key exists on the jumpbox. + RequiresSSHKeyOnJumpbox bool + // SkipSetupCluster instructs the bootstrap to add "set-up-cluster" and "ms-backends" + // to the install skip-steps. Needed for LTS releases whose remote install-components.js + // commands fail on worker nodes. + SkipSetupCluster bool +} + +// ltsRegistry is the single source of truth for all known LTS versions and their quirks. +// Add a new LTSSpec entry here to support an additional LTS release. +var ltsRegistry = []LTSSpec{ + { + InstallVersion: "codesphere-lts-v1.77.2", + Experiments: []string{ + "managed-services", + "custom-service-image", + "ms-in-ls", + }, + ClearManagedServices: false, + RequiresJumpboxFiles: true, + RequiresOmsBinaryUpdate: true, + RequiresCephMasterWatcher: true, + SkipPcApps: true, + RequiresSSHKeyOnJumpbox: true, + SkipSetupCluster: true, + }, +} + +// FindLTSSpec returns the LTSSpec for the given installVersion, or nil if it is not a +// known LTS release that requires special handling. +func FindLTSSpec(installVersion string) *LTSSpec { + for i := range ltsRegistry { + if ltsRegistry[i].InstallVersion == installVersion { + return <sRegistry[i] + } + } + return nil +} + +// LTSConfigFileSuffix derives a filesystem-safe suffix from an LTS installVersion string. +// For example, "codesphere-lts-v1.77.2" -> "lts-1_77_2". +func LTSConfigFileSuffix(installVersion string) string { + s := strings.TrimPrefix(installVersion, "codesphere-") + s = strings.ReplaceAll(s, "v", "") + s = strings.ReplaceAll(s, ".", "_") + return s +} + +// LocalLTSConfigPath derives the local path for the LTS-specific jumpbox config from the +// main config path. For example, with installVersion "codesphere-lts-v1.77.2" and +// configPath "config.yaml" it returns "config-lts-1_77_2.yaml". +func LocalLTSConfigPath(configPath string, spec *LTSSpec) string { + return filepath.Join(filepath.Dir(configPath), "config-"+LTSConfigFileSuffix(spec.InstallVersion)+".yaml") +} + +// GenerateLTSJumpboxFiles generates the LTS-versioned config file needed on the jumpbox +// without modifying the original root config. It returns the bytes for +// config-lts-.yaml with compat applied (experiments filtered). +// The codesphere key is omitted from the output — the LTS 1.77.2 installer rejects +// both inline objects and file-path references; only the absent key is accepted. +func GenerateLTSJumpboxFiles(root *files.RootConfig, spec *LTSSpec) (jumpboxConfigBytes []byte, err error) { + csCopy := root.Codesphere + if err := ApplyLTSCompat(&csCopy, spec); err != nil { + return nil, fmt.Errorf("failed to apply LTS compat for %s: %w", spec.InstallVersion, err) + } + + rootCopy := *root + rootCopy.Codesphere = csCopy + rootCopy.CodesphereConfigPath = files.OmitCodesphereSentinel + rootCopy.GeneratedForVersion = "" + + jumpboxConfigBytes, err = rootCopy.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to marshal %s jumpbox config: %w", spec.InstallVersion, err) + } + + return jumpboxConfigBytes, nil +} + +// ApplyLTSCompat adjusts a CodesphereConfig to be compatible with the given LTS release: +// 1. Validates that no unsupported experiments are set. +// 2. Only experiments listed in the LTS spec are kept; all others are stripped. +// 3. ManagedServices is cleared when the LTS schema requires full provider definitions. +func ApplyLTSCompat(cfg *files.CodesphereConfig, spec *LTSSpec) error { + if err := ValidateExperiments(cfg.Experiments, spec.Experiments); err != nil { + return fmt.Errorf("invalid experiments for %s: %w", spec.InstallVersion, err) + } + cfg.Experiments = FilterExperiments(cfg.Experiments, spec.Experiments) + if spec.ClearManagedServices { + cfg.ManagedServices = nil + } + return nil +} + +// ValidateExperiments checks that all experiments in the given slice are present in the +// allowed list. Returns an error listing any unsupported experiments. +func ValidateExperiments(experiments, allowed []string) error { + allowedSet := make(map[string]struct{}, len(allowed)) + for _, a := range allowed { + allowedSet[a] = struct{}{} + } + + var unsupported []string + for _, exp := range experiments { + if _, ok := allowedSet[exp]; !ok { + unsupported = append(unsupported, exp) + } + } + + if len(unsupported) > 0 { + return fmt.Errorf("unsupported experiments: %v (supported by this version: %v)", unsupported, allowed) + } + return nil +} + +// FilterExperiments returns a new slice containing only the experiments present in the allowed list. +func FilterExperiments(experiments, allowed []string) []string { + allowedSet := make(map[string]struct{}, len(allowed)) + for _, a := range allowed { + allowedSet[a] = struct{}{} + } + + filtered := make([]string, 0, len(experiments)) + for _, exp := range experiments { + if _, ok := allowedSet[exp]; ok { + filtered = append(filtered, exp) + } + } + return filtered +} + +// UserSpecifiedExperiments checks whether the given experiments list differs from the +// default experiments. If the user explicitly passed --experiments flags, they +// differ from defaults and we should error on unsupported ones. +func UserSpecifiedExperiments(experiments []string) bool { + return !slices.Equal(experiments, DefaultExperiments) +} diff --git a/internal/bootstrap/gcp/lts_test.go b/internal/bootstrap/gcp/lts_test.go new file mode 100644 index 00000000..97eab2d5 --- /dev/null +++ b/internal/bootstrap/gcp/lts_test.go @@ -0,0 +1,266 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" + "github.com/codesphere-cloud/oms/internal/installer/files" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LTS Compatibility", func() { + Describe("FindLTSSpec", func() { + It("returns a spec for the LTS 1.77.2 version string", func() { + spec := gcp.FindLTSSpec("codesphere-lts-v1.77.2") + Expect(spec).NotTo(BeNil()) + Expect(spec.InstallVersion).To(Equal("codesphere-lts-v1.77.2")) + Expect(spec.RequiresJumpboxFiles).To(BeTrue()) + Expect(spec.RequiresOmsBinaryUpdate).To(BeTrue()) + Expect(spec.ClearManagedServices).To(BeFalse()) + Expect(spec.RequiresCephMasterWatcher).To(BeTrue()) + }) + + It("returns nil for another LTS version", func() { + Expect(gcp.FindLTSSpec("codesphere-lts-v1.80.0")).To(BeNil()) + }) + + It("returns nil for an empty string", func() { + Expect(gcp.FindLTSSpec("")).To(BeNil()) + }) + + It("returns nil for a non-LTS version", func() { + Expect(gcp.FindLTSSpec("master")).To(BeNil()) + }) + + It("returns nil for a partial match", func() { + Expect(gcp.FindLTSSpec("codesphere-lts-v1.77.2-extra")).To(BeNil()) + }) + }) + + Describe("FilterExperiments", func() { + It("keeps only allowed experiments", func() { + input := []string{"managed-services", "custom-service-image", "secret-management", "ms-in-ls", "sub-path-mount"} + allowed := []string{"managed-services", "custom-service-image", "ms-in-ls"} + result := gcp.FilterExperiments(input, allowed) + Expect(result).To(ConsistOf("managed-services", "custom-service-image", "ms-in-ls")) + }) + + It("returns all experiments when all are allowed", func() { + input := []string{"managed-services", "custom-service-image"} + result := gcp.FilterExperiments(input, input) + Expect(result).To(ConsistOf("managed-services", "custom-service-image")) + }) + + It("returns empty slice when no experiments are allowed", func() { + input := []string{"secret-management", "sub-path-mount"} + result := gcp.FilterExperiments(input, []string{}) + Expect(result).To(BeEmpty()) + }) + + It("handles empty input", func() { + result := gcp.FilterExperiments([]string{}, []string{"secret-management"}) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("ValidateExperiments", func() { + It("returns nil when all experiments are allowed", func() { + err := gcp.ValidateExperiments( + []string{"managed-services", "custom-service-image"}, + []string{"managed-services", "custom-service-image", "ms-in-ls"}, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns error for unsupported experiments", func() { + err := gcp.ValidateExperiments( + []string{"managed-services", "secret-management", "sub-path-mount"}, + []string{"managed-services", "custom-service-image", "ms-in-ls"}, + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported experiments")) + Expect(err.Error()).To(ContainSubstring("secret-management")) + Expect(err.Error()).To(ContainSubstring("sub-path-mount")) + }) + + It("returns nil for empty experiments", func() { + Expect(gcp.ValidateExperiments([]string{}, []string{"managed-services"})).NotTo(HaveOccurred()) + }) + + It("returns error when all experiments are unsupported", func() { + err := gcp.ValidateExperiments( + []string{"secret-management", "sub-path-mount"}, + []string{"managed-services"}, + ) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ApplyLTSCompat", func() { + var spec *gcp.LTSSpec + + BeforeEach(func() { + spec = gcp.FindLTSSpec("codesphere-lts-v1.77.2") + Expect(spec).NotTo(BeNil()) + }) + + It("keeps only supported experiments", func() { + cfg := &files.CodesphereConfig{ + Experiments: []string{"managed-services", "custom-service-image", "secret-management", "ms-in-ls", "sub-path-mount"}, + } + err := gcp.ApplyLTSCompat(cfg, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported experiments")) + Expect(err.Error()).To(ContainSubstring("secret-management")) + Expect(err.Error()).To(ContainSubstring("sub-path-mount")) + }) + + It("succeeds when all experiments are supported", func() { + cfg := &files.CodesphereConfig{ + Experiments: []string{"managed-services", "custom-service-image", "ms-in-ls"}, + } + err := gcp.ApplyLTSCompat(cfg, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Experiments).To(ConsistOf("managed-services", "custom-service-image", "ms-in-ls")) + }) + + It("preserves managed services (LTS 1.77.2 keeps them in separate codesphere config)", func() { + cfg := &files.CodesphereConfig{ + Experiments: []string{"managed-services"}, + ManagedServices: []files.ManagedServiceConfig{ + { + Name: "postgres", + Version: "v1", + Author: "Codesphere", + DisplayName: "PostgreSQL", + Description: "Open-source database", + Category: "Database", + Scope: "global", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://ms-backend-postgres.postgres-operator:3000/api/v1/postgres", + }, + }, + Plans: []files.ServicePlan{{ID: 0, Name: "Small"}}, + Capabilities: &files.ManagedServiceCapabilities{ + Pause: true, + Backups: true, + }, + }, + { + Name: "s3", + Version: "v1", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://localhost:8080", + }, + }, + }, + }, + } + + err := gcp.ApplyLTSCompat(cfg, spec) + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.ManagedServices).To(HaveLen(2)) + Expect(cfg.ManagedServices[0].Name).To(Equal("postgres")) + Expect(cfg.ManagedServices[0].Author).To(Equal("Codesphere")) + }) + + It("handles nil managed services slice", func() { + cfg := &files.CodesphereConfig{ + ManagedServices: nil, + Experiments: []string{"custom-service-image"}, + } + err := gcp.ApplyLTSCompat(cfg, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ManagedServices).To(BeEmpty()) + }) + + It("handles empty experiments slice", func() { + cfg := &files.CodesphereConfig{ + Experiments: []string{}, + } + err := gcp.ApplyLTSCompat(cfg, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Experiments).To(BeEmpty()) + }) + }) + + Describe("GenerateLTSJumpboxFiles", func() { + var ( + root *files.RootConfig + spec *gcp.LTSSpec + ) + + BeforeEach(func() { + spec = gcp.FindLTSSpec("codesphere-lts-v1.77.2") + Expect(spec).NotTo(BeNil()) + + root = &files.RootConfig{ + Codesphere: files.CodesphereConfig{ + Experiments: []string{"managed-services", "custom-service-image", "ms-in-ls"}, + ManagedServices: []files.ManagedServiceConfig{ + {Name: "postgres", Version: "v1", Author: "Codesphere"}, + {Name: "s3", Version: "v1"}, + }, + }, + } + }) + + It("does not modify the original root config", func() { + _, err := gcp.GenerateLTSJumpboxFiles(root, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(root.Codesphere.Experiments).To(ConsistOf("managed-services", "custom-service-image", "ms-in-ls")) + Expect(root.Codesphere.ManagedServices[0].Author).To(Equal("Codesphere")) + Expect(root.CodesphereConfigPath).To(BeEmpty()) + }) + + It("clears GeneratedForVersion in the LTS config so the old installer ignores it", func() { + root.GeneratedForVersion = "codesphere-lts-v1.77.2" + jumpboxBytes, err := gcp.GenerateLTSJumpboxFiles(root, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(string(jumpboxBytes)).NotTo(ContainSubstring("generatedForVersion")) + Expect(root.GeneratedForVersion).To(Equal("codesphere-lts-v1.77.2")) + }) + + It("omits the codesphere key entirely from the LTS config", func() { + jumpboxBytes, err := gcp.GenerateLTSJumpboxFiles(root, spec) + Expect(err).NotTo(HaveOccurred()) + Expect(jumpboxBytes).NotTo(BeEmpty()) + // LTS 1.77.2 installer rejects both inline objects and string-path refs; + // only the absent key is accepted. + Expect(string(jumpboxBytes)).NotTo(ContainSubstring("codesphere:")) + Expect(string(jumpboxBytes)).NotTo(ContainSubstring("managedServices")) + Expect(string(jumpboxBytes)).NotTo(ContainSubstring("managed-services")) + }) + + It("errors when unsupported experiments are in the root config", func() { + root.Codesphere.Experiments = []string{"managed-services", "unsupported-exp"} + _, err := gcp.GenerateLTSJumpboxFiles(root, spec) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported experiments")) + Expect(err.Error()).To(ContainSubstring("unsupported-exp")) + }) + }) + + Describe("LTSConfigFileSuffix", func() { + It("converts the LTS 1.77.2 version string to a filesystem-safe suffix", func() { + Expect(gcp.LTSConfigFileSuffix("codesphere-lts-v1.77.2")).To(Equal("lts-1_77_2")) + }) + }) + + Describe("LocalLTSConfigPath", func() { + It("returns config-lts-1_77_2.yaml in same directory as config.yaml", func() { + spec := gcp.FindLTSSpec("codesphere-lts-v1.77.2") + Expect(gcp.LocalLTSConfigPath("config.yaml", spec)).To(Equal("config-lts-1_77_2.yaml")) + }) + + It("uses the directory of the given config path", func() { + spec := gcp.FindLTSSpec("codesphere-lts-v1.77.2") + Expect(gcp.LocalLTSConfigPath("/etc/codesphere/config.yaml", spec)).To(Equal("/etc/codesphere/config-lts-1_77_2.yaml")) + }) + }) +}) diff --git a/internal/bootstrap/gcp/oms_binary.go b/internal/bootstrap/gcp/oms_binary.go new file mode 100644 index 00000000..af884b3d --- /dev/null +++ b/internal/bootstrap/gcp/oms_binary.go @@ -0,0 +1,50 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +// BuildOmsLinuxBinary returns the path to an OMS binary built for linux/amd64. +func BuildOmsLinuxBinary() (path string, cleanup func(), err error) { + noop := func() {} + + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + execPath, err := os.Executable() + if err != nil { + return "", noop, fmt.Errorf("failed to locate current OMS binary: %w", err) + } + return execPath, noop, nil + } + + // Cross-compile for linux/amd64 from the current working directory (project root). + cwd, err := os.Getwd() + if err != nil { + return "", noop, fmt.Errorf("failed to determine project directory: %w", err) + } + + outFile, err := os.CreateTemp("", "oms-linux-amd64-*") + if err != nil { + return "", noop, fmt.Errorf("failed to create temp file for OMS binary: %w", err) + } + if err = outFile.Close(); err != nil { + return "", noop, fmt.Errorf("failed to close temp file for OMS binary: %w", err) + } + outPath := outFile.Name() + rmCleanup := func() { _ = os.Remove(outPath) } + + cmd := exec.Command("go", "build", "-o", outPath, "./cli") + cmd.Dir = cwd + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64") + if output, cmdErr := cmd.CombinedOutput(); cmdErr != nil { + rmCleanup() + return "", noop, fmt.Errorf("failed to cross-compile OMS binary for linux/amd64: %w\n%s", cmdErr, output) + } + + return outPath, rmCleanup, nil +} diff --git a/internal/bootstrap/local/installer.go b/internal/bootstrap/local/installer.go index 3caf2bf4..ae19ba10 100644 --- a/internal/bootstrap/local/installer.go +++ b/internal/bootstrap/local/installer.go @@ -157,7 +157,7 @@ func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { return destDir, nil } - log.Printf("Extracting installer bundle %s → %s", bundlePath, destDir) + 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) } @@ -237,10 +237,10 @@ func symlinkBinary(name, target string) error { } if err := os.Symlink(localPath, target); err != nil { - return fmt.Errorf("failed to symlink %q → %q: %w", target, localPath, err) + return fmt.Errorf("failed to symlink %q -> %q: %w", target, localPath, err) } - log.Printf("Symlinked %s → %s", target, localPath) + log.Printf("Symlinked %s -> %s", target, localPath) return nil } @@ -424,7 +424,7 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { if b.fw.Exists(depsDir) { log.Printf("deps directory already exists at %s, skipping extraction", depsDir) } else { - log.Printf("Extracting deps.tar.gz → %s", depsDir) + 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) } diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index fa974ef9..e4673732 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -176,12 +176,454 @@ func (g *InstallConfig) applyCommonProperties() { g.Config.ManagedServiceBackends.Postgres = &files.PgManagedServiceConfig{} } if g.Config.Codesphere.ManagedServices == nil { + pgBackups := &files.ManagedServiceBackups{ + ConfigSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "endpointUrl": map[string]any{ + "type": "string", + "format": "uri", + "description": `S3-compatible endpoint URL for the backup storage, e.g. "http://rgw-load-balancer.rook-ceph.svc.cluster.local"`, + }, + "destinationPath": map[string]any{ + "type": "string", + "format": "uri", + "description": `S3 bucket URI where backups are stored. Must use the s3:// scheme, e.g. "s3://backup-test"`, + }, + "accessKeyId": map[string]any{ + "type": "string", + "description": "S3 access key for authentication. The associated user must have write access to the destination bucket.", + }, + }, + "required": []string{"endpointUrl", "destinationPath", "accessKeyId"}, + "additionalProperties": false, + }, + SecretsSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "secretKey": map[string]any{ + "type": "string", + "format": "password", + "description": "S3 secret key for authentication", + }, + }, + "required": []string{"secretKey"}, + "additionalProperties": false, + }, + } + postgresPlans := []files.ServicePlan{ + { + ID: 0, + Name: "Small", + Description: "0.5 vCPU / 500 MB Memory", + Parameters: map[string]files.PlanParam{ + "storage": {PricedAs: "storage-mib", Schema: map[string]interface{}{"type": "integer", "default": 10240, "readOnly": false, "minimum": 512, "x-update-constraint": "increase-only"}}, + "cpu": {PricedAs: "cpu-tenths", Schema: map[string]interface{}{"type": "number", "default": 5, "readOnly": true}}, + "memory": {PricedAs: "ram-mib", Schema: map[string]interface{}{"type": "integer", "default": 512, "readOnly": true}}, + }, + }, + { + ID: 1, + Name: "Medium", + Description: "1 vCPU / 1 GB Memory", + Parameters: map[string]files.PlanParam{ + "storage": {PricedAs: "storage-mib", Schema: map[string]interface{}{"type": "integer", "default": 25600, "readOnly": false, "minimum": 512}}, + "cpu": {PricedAs: "cpu-tenths", Schema: map[string]interface{}{"type": "number", "default": 10, "readOnly": true}}, + "memory": {PricedAs: "ram-mib", Schema: map[string]interface{}{"type": "integer", "default": 1024, "readOnly": true}}, + }, + }, + { + ID: 2, + Name: "Medium High-Mem", + Description: "1 vCPU / 2 GB Memory", + Parameters: map[string]files.PlanParam{ + "storage": {PricedAs: "storage-mib", Schema: map[string]interface{}{"type": "integer", "default": 25600, "readOnly": false, "minimum": 512}}, + "cpu": {PricedAs: "cpu-tenths", Schema: map[string]interface{}{"type": "number", "default": 10, "readOnly": true}}, + "memory": {PricedAs: "ram-mib", Schema: map[string]interface{}{"type": "integer", "default": 2048, "readOnly": true}}, + }, + }, + { + ID: 3, + Name: "Large", + Description: "2 vCPU / 4 GB Memory", + Parameters: map[string]files.PlanParam{ + "storage": {PricedAs: "storage-mib", Schema: map[string]interface{}{"type": "integer", "default": 51200, "readOnly": false, "minimum": 512}}, + "cpu": {PricedAs: "cpu-tenths", Schema: map[string]interface{}{"type": "number", "default": 20, "readOnly": true}}, + "memory": {PricedAs: "ram-mib", Schema: map[string]interface{}{"type": "integer", "default": 4096, "readOnly": true}}, + }, + }, + { + ID: 4, + Name: "Extra Large", + Description: "4 vCPU / 8 GB Memory", + Parameters: map[string]files.PlanParam{ + "storage": {PricedAs: "storage-mib", Schema: map[string]interface{}{"type": "integer", "default": 153600, "readOnly": false, "minimum": 512}}, + "cpu": {PricedAs: "cpu-tenths", Schema: map[string]interface{}{"type": "number", "default": 40, "readOnly": true}}, + "memory": {PricedAs: "ram-mib", Schema: map[string]interface{}{"type": "integer", "default": 8192, "readOnly": true}}, + }, + }, + } + ferretDbPlans := make([]files.ServicePlan, len(postgresPlans)) + for i, plan := range postgresPlans { + params := make(map[string]files.PlanParam, len(plan.Parameters)+2) + for k, v := range plan.Parameters { + params[k] = v + } + params["ferretdbCpu"] = files.PlanParam{ + PricedAs: "cpu-tenths", + Schema: map[string]interface{}{"type": "number", "default": 3, "readOnly": false, "minimum": 1, "maximum": 10}, + } + params["ferretdbMemory"] = files.PlanParam{ + PricedAs: "ram-mib", + Schema: map[string]interface{}{"type": "integer", "default": 128, "readOnly": false, "minimum": 128, "maximum": 1024}, + } + ferretDbPlans[i] = files.ServicePlan{ID: plan.ID, Name: plan.Name, Description: plan.Description, Parameters: params} + } g.Config.Codesphere.ManagedServices = []files.ManagedServiceConfig{ - {Name: "postgres", Version: "v1"}, - {Name: "babelfish", Version: "v1"}, - {Name: "s3", Version: "v1"}, - {Name: "virtual-k8s", Version: "v1"}, - {Name: "ferretdb", Version: "v0"}, + { + Name: "postgres", + Version: "v1", + Author: "Codesphere", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://ms-backend-postgres.postgres-operator:3000/api/v1/postgres", + }, + }, + Category: "Database", + Description: "Open-source database system tailored for efficient data management and scalability. Newest version of the Provider using the Cloud-Native Operator", + DisplayName: "PostgreSQL", + IconURL: "/ide/assets/managed-services/postgresql.svg", + Scope: "global", + Capabilities: &files.ManagedServiceCapabilities{ + Pause: true, + Backups: true, + PointInTimeRecovery: true, + }, + ConfigSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "version": map[string]interface{}{ + "type": "string", + "description": "Version of the Postgres DB. Includes pre-installed extensions compatible with this version. Extension versions are managed and cannot be customized.", + "enum": []string{"17.9", "17.6", "16.13", "16.10", "15.17", "15.14", "14.22", "14.19"}, + "default": "17.9", + "readOnly": false, + "x-update-constraint": "minor-upgrade-only", + }, + "userName": map[string]interface{}{ + "type": "string", + "default": "app", + "pattern": "^(?!postgres$)", + "description": `Cannot be "postgres" (reserved for the superuser).`, + "x-update-constraint": "immutable", + }, + "databaseName": map[string]interface{}{ + "type": "string", + "default": "app", + "x-update-constraint": "immutable", + }, + }, + "required": []string{}, + "additionalProperties": false, + }, + DetailsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "port": map[string]interface{}{"type": "integer"}, + "hostname": map[string]interface{}{"type": "string", "format": "hostname"}, + "dsn": map[string]interface{}{"type": "string", "format": "uri"}, + "ready": map[string]interface{}{"type": "boolean"}, + }, + "required": []string{"port", "hostname", "dsn", "ready"}, + "additionalProperties": false, + }, + SecretsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "userPassword": map[string]interface{}{"type": "string", "format": "password", "x-update-constraint": "immutable"}, + "superuserPassword": map[string]interface{}{"type": "string", "format": "password"}, + }, + "required": []string{"userPassword", "superuserPassword"}, + "additionalProperties": false, + }, + Backups: pgBackups, + Plans: postgresPlans, + }, + { + Name: "babelfish", + Version: "v1", + Author: "Codesphere", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://ms-backend-postgres.postgres-operator:3000/api/v1/babelfish", + }, + }, + Category: "Database", + Description: "PostgreSQL instance with Babelfish extension to support applications requiring Microsoft TDS compatibility", + DisplayName: "Babelfish (T-SQL compatible)", + IconURL: "https://codesphere.com/ide/assets/managed-services/babelfish.svg", + Scope: "global", + Capabilities: &files.ManagedServiceCapabilities{ + Pause: true, + Backups: true, + PointInTimeRecovery: true, + }, + ConfigSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "version": map[string]interface{}{ + "type": "string", + "description": "Version of the Postgres DB and the corresponding version of Babelfish", + "enum": []string{"17.6-5.3.0", "16.10-4.7.0"}, + "default": "17.6-5.3.0", + "readOnly": false, + "x-update-constraint": "minor-upgrade-only", + }, + }, + "required": []string{}, + "additionalProperties": false, + }, + DetailsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "port": map[string]interface{}{"type": "integer"}, + "hostname": map[string]interface{}{"type": "string", "format": "hostname"}, + "dsn": map[string]interface{}{"type": "string", "format": "uri", "description": "TDS connection string for the superuser and master database. Use this to connect with full administrative privileges."}, + "ready": map[string]interface{}{"type": "boolean"}, + }, + "required": []string{"port", "hostname", "dsn", "ready"}, + "additionalProperties": false, + }, + SecretsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "superuserPassword": map[string]interface{}{"type": "string", "format": "password"}, + }, + "required": []string{"superuserPassword"}, + "additionalProperties": false, + }, + Backups: pgBackups, + Plans: postgresPlans, + }, + { + Name: "ferretdb", + Version: "v0", + Author: "Codesphere", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://ms-backend-postgres.postgres-operator:3000/api/v1/ferretdb", + }, + }, + Category: "Database", + Description: "FerretDB based provider for MongoDB-compatible document database workloads. Powered by PostgreSQL.", + DisplayName: "Codesphere Document DB (MongoDB compatible)", + IconURL: "/ide/assets/managed-services/ferretdb.svg", + Scope: "global", + Capabilities: &files.ManagedServiceCapabilities{ + Pause: true, + }, + ConfigSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "version": map[string]interface{}{ + "type": "string", + "description": "Version of the Postgres / DocumentDB extension / FerretDB", + "enum": []string{"17-0.107.0-ferretdb-2.7.0"}, + "default": "17-0.107.0-ferretdb-2.7.0", + "readOnly": false, + "x-update-constraint": "minor-upgrade-only", + }, + }, + "required": []string{}, + "additionalProperties": false, + }, + DetailsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "port": map[string]interface{}{"type": "integer"}, + "hostname": map[string]interface{}{"type": "string", "format": "hostname"}, + "dsn": map[string]interface{}{"type": "string", "format": "uri", "description": "MongoDB connection string for the admin user and admin database. Use this to connect with full administrative privileges."}, + "ready": map[string]interface{}{"type": "boolean"}, + }, + "required": []string{"port", "hostname", "dsn", "ready"}, + "additionalProperties": false, + }, + SecretsSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "superuserPassword": map[string]interface{}{"type": "string", "format": "password"}, + }, + "required": []string{"superuserPassword"}, + "additionalProperties": false, + }, + Plans: ferretDbPlans, + }, + { + Name: "s3", + Version: "v1", + Author: "Codesphere", + Backend: files.ManagedServiceBackend{ + API: files.ManagedServiceAPI{ + Endpoint: "http://ms-backend-s3.rook-ceph:3000/api/v1/s3", + }, + }, + Category: "Storage", + Description: "S3-compatible object storage for persisting unstructured data artifacts", + DisplayName: "Object Storage", + IconURL: "/ide/assets/managed-services/s3-bucket.svg", + Scope: "global", + Capabilities: &files.ManagedServiceCapabilities{ + Pause: false, + Backups: false, + PointInTimeRecovery: false, + }, + Backups: &files.ManagedServiceBackups{ + ConfigSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "endpointUrl": map[string]any{ + "type": "string", + "format": "uri", + "description": `S3-compatible endpoint URL for the backup storage, e.g. "http://rgw-load-balancer.rook-ceph.svc.cluster.local"`, + }, + "accessKeyId": map[string]any{ + "type": "string", + "description": "S3 access key for authentication at the backup store.", + }, + "path": map[string]any{ + "type": "string", + "description": `S3 path (bucket name with optional subpath), without s3://, e.g. "my-bucket/backups"`, + }, + }, + "required": []string{"endpointUrl", "accessKeyId", "path"}, + "additionalProperties": false, + }, + SecretsSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "secretKey": map[string]any{ + "type": "string", + "format": "password", + "description": "S3 secret key for authentication at the backup store", + }, + }, + "required": []string{"secretKey"}, + "additionalProperties": false, + }, + }, + ConfigSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "accessKey": map[string]interface{}{ + "type": "string", + "pattern": "^[A-Z0-9]{20}$", + "description": "Has to be cluster-unique. Exactly 20 uppercase letters (A-Z) or digits (0-9).", + "x-update-constraint": "immutable", + }, + "userDisplayName": map[string]interface{}{ + "type": "string", + "readOnly": false, + "default": "My S3 User", + "x-update-constraint": "immutable", + }, + "initialBucketName": map[string]interface{}{ + "type": "string", + "pattern": `^(?!\.)(?!-)(?!.*\.-)(?!.*-\.)(?!.*\.\.)[a-z0-9][a-z0-9.-]{2,62}(? yaml.Marshal uses struct field tags and does NOT + // call this Marshal() method. + data, err := yaml.Marshal(c) + if err != nil { + return nil, err + } + + // Parse into a node tree so we can manipulate the codesphere value. + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, err + } + + if c.CodesphereConfigPath == OmitCodesphereSentinel { + if err := removeYAMLMappingKey(&root, "codesphere"); err != nil { + return nil, fmt.Errorf("failed to remove codesphere key: %w", err) + } + } else { + if err := replaceYAMLMappingValue(&root, "codesphere", c.CodesphereConfigPath); err != nil { + return nil, fmt.Errorf("failed to set codesphere path reference: %w", err) + } + } + + return yaml.Marshal(&root) +} + +// replaceYAMLMappingValue replaces the value of a top-level mapping key with a plain string scalar. +func replaceYAMLMappingValue(root *yaml.Node, key, value string) error { + mapping := root + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + mapping = root.Content[0] + } + if mapping.Kind != yaml.MappingNode { + return fmt.Errorf("expected a YAML mapping node, got kind %d", mapping.Kind) + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1] = &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } + return nil + } + } + return fmt.Errorf("key %q not found in YAML mapping", key) } -// Unmarshal deserializes YAML data into the RootConfig +// removeYAMLMappingKey removes a top-level key-value pair from a YAML mapping node. +func removeYAMLMappingKey(root *yaml.Node, key string) error { + mapping := root + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + mapping = root.Content[0] + } + if mapping.Kind != yaml.MappingNode { + return fmt.Errorf("expected a YAML mapping node, got kind %d", mapping.Kind) + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + return nil + } + } + return fmt.Errorf("key %q not found in YAML mapping", key) +} + +// Unmarshal deserializes YAML data into the RootConfig. func (c *RootConfig) Unmarshal(data []byte) error { - if err := yaml.Unmarshal(data, c); err != nil { + // Parse the document into a raw node first so we can inspect the codesphere field. + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + mapping := doc.Content[0] + if mapping.Kind == yaml.MappingNode { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == "codesphere" && mapping.Content[i+1].Kind == yaml.ScalarNode { + c.CodesphereConfigPath = mapping.Content[i+1].Value + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + break + } + } + } + } + if err := doc.Decode(c); err != nil { return err } c.extractACMESolverFromOverride() diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go index 5c764f7e..10a49628 100644 --- a/internal/installer/files/config_yaml_test.go +++ b/internal/installer/files/config_yaml_test.go @@ -198,6 +198,25 @@ codesphere: Expect(rootConfig.Registry.Server).To(Equal("minimal.registry.com")) Expect(rootConfig.Codesphere.DeployConfig.Images).To(BeEmpty()) }) + + It("should handle LTS 1.77.2 format where codesphere is a path string", func() { + lts177Yaml := `registry: + server: registry.example.com +codesphere: /etc/codesphere/codesphere.yaml +` + err := os.WriteFile(configFile, []byte(lts177Yaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) + Expect(err).NotTo(HaveOccurred()) + + Expect(rootConfig.Registry.Server).To(Equal("registry.example.com")) + Expect(rootConfig.CodesphereConfigPath).To(Equal("/etc/codesphere/codesphere.yaml")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(BeEmpty()) + }) }) Describe("ExtractBomRefs", func() { @@ -345,6 +364,18 @@ codesphere: Expect(certs["override"]).To(Equal(expectedOverride)) }) + It("omits the codesphere key when CodesphereConfigPath is set to OmitCodesphereSentinel", func() { + rootConfig.CodesphereConfigPath = files.OmitCodesphereSentinel + data, err := rootConfig.Marshal() + Expect(err).NotTo(HaveOccurred()) + + var raw map[string]interface{} + Expect(yaml.Unmarshal(data, &raw)).NotTo(HaveOccurred()) + + _, hasCodesphere := raw["codesphere"] + Expect(hasCodesphere).To(BeFalse(), "codesphere key must be absent when using OmitCodesphereSentinel") + }) + It("should unmarshal ACME config from upstream docs format and populate Solver", func() { acmeYaml := `codesphere: certIssuer: diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index b7a6dc88..e9c0a338 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -69,12 +69,20 @@ type K0sctlApplyHooks struct { } func createK0sctlHost(node files.K8sNode, role string, installFlags []string, sshKeyPath string, k0sBinaryPath string) K0sctlHost { + sshAddress := node.IPAddress + if node.SSHAddress != "" { + sshAddress = node.SSHAddress + } + sshPort := 22 + if node.SSHPort != 0 { + sshPort = node.SSHPort + } host := K0sctlHost{ Role: role, SSH: K0sctlSSH{ - Address: node.IPAddress, + Address: sshAddress, User: "root", - Port: 22, + Port: sshPort, KeyPath: sshKeyPath, }, InstallFlags: installFlags, diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 4646a5d0..3e214bc6 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -93,9 +93,12 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err _ = session.Setenv("OMS_PORTAL_API", os.Getenv("OMS_PORTAL_API")) _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors + var stdoutBuf bytes.Buffer var stderrBuf bytes.Buffer - session.Stderr = &stderrBuf - if !r.Quiet { + if r.Quiet { + session.Stdout = &stdoutBuf + session.Stderr = &stderrBuf + } else { session.Stdout = os.Stdout session.Stderr = os.Stderr } @@ -106,8 +109,17 @@ func (r *SSHNodeClient) RunCommand(n *Node, username string, command string) err if err := session.Wait(); err != nil { // A non-zero exit status from the remote command is also considered an error - if r.Quiet && stderrBuf.Len() > 0 { - return fmt.Errorf("command failed: %w\n%s", err, stderrBuf.String()) + if r.Quiet { + var extra string + if stdoutBuf.Len() > 0 { + extra += "\nstdout:\n" + stdoutBuf.String() + } + if stderrBuf.Len() > 0 { + extra += "\nstderr:\n" + stderrBuf.String() + } + if extra != "" { + return fmt.Errorf("command failed: %w%s", err, extra) + } } return fmt.Errorf("command failed: %w", err) } diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 7b337e7d..a368f90b 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -94,6 +94,10 @@ func (c *PortalClient) isOKResponseStatus(resp *http.Response) error { return errors.New("unauthorized: invalid API key") } + if resp.StatusCode == http.StatusTooManyRequests { + return fmt.Errorf("OMS-Portal rate limit exceeded (HTTP 429): please wait before retrying") + } + if resp.StatusCode >= 300 { log.Printf("Non-2xx response received from OMS-Portal (%s) - Status: %d", c.Env.GetOmsPortalApi(), resp.StatusCode) diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index 8797da67..b4444b86 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -115,6 +115,27 @@ var _ = Describe("PortalClient", func() { }) }) + Context("HTTP Request has Status: TooManyRequests", func() { + BeforeEach(func() { + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + }, nil + }) + }) + + It("returns a rate limit error without calling the health check", func() { + testRequest, err := http.NewRequest("GET", "fake", nil) + Expect(err).ToNot(HaveOccurred()) + + resp, err := client.AuthorizedHttpRequest(testRequest) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("rate limit exceeded")) + Expect(resp).To(BeNil()) + }) + }) + Context("HTTP Request has Non-OK Status", func() { BeforeEach(func() { mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl)