diff --git a/.mockery.yml b/.mockery.yml index fba11d22..8e543a70 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -14,6 +14,10 @@ require-template-schema-exists: true template: testify template-schema: "{{.Template}}.schema.json" packages: + github.com/codesphere-cloud/oms/internal/bootstrap/gcp: + config: + all: true + interfaces: github.com/codesphere-cloud/oms/internal/env: config: all: true @@ -22,6 +26,10 @@ packages: config: all: true interfaces: + github.com/codesphere-cloud/oms/internal/installer/node: + config: + all: true + interfaces: github.com/codesphere-cloud/oms/internal/portal: config: all: true diff --git a/NOTICE b/NOTICE index 7bbe52e5..641c2d98 100644 --- a/NOTICE +++ b/NOTICE @@ -41,9 +41,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.5.3/iam/L ---------- Module: cloud.google.com/go/longrunning -Version: v0.7.0 +Version: v0.8.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.7.0/longrunning/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.8.0/longrunning/LICENSE ---------- Module: cloud.google.com/go/resourcemanager @@ -89,15 +89,15 @@ License URL: https://github.com/clipperhouse/stringish/blob/v0.1.1/LICENSE ---------- Module: github.com/clipperhouse/uax29/v2 -Version: v2.3.0 +Version: v2.4.0 License: MIT -License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE +License URL: https://github.com/clipperhouse/uax29/blob/v2.4.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/io -Version: v0.16.1 +Version: v0.16.2 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.1/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.2/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -275,9 +275,9 @@ License URL: https://github.com/ulikunitz/xz/blob/v0.5.15/LICENSE ---------- Module: gitlab.com/gitlab-org/api/client-go -Version: v1.11.0 +Version: v1.24.0 License: Apache-2.0 -License URL: https://gitlab.com/gitlab-org/api/client-go/-/blob/v1.11.0/LICENSE +License URL: https://gitlab.com/gitlab-org/api/client-go/-/blob/v1.24.0/LICENSE ---------- Module: go.opentelemetry.io/auto/sdk @@ -401,33 +401,33 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE ---------- Module: google.golang.org/api -Version: v0.263.0 +Version: v0.264.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.263.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.263.0 +Version: v0.264.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.263.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis -Version: v0.0.0-20251222181119-0a764e51fe1b +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/0a764e51fe1b/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20251222181119-0a764e51fe1b +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/0a764e51fe1b/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260122232226-8e98ce8d340d +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8e98ce8d340d/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 9dd476b6..301b461a 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -13,21 +13,23 @@ import ( "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/util" ) type BootstrapGcpCmd struct { - cmd *cobra.Command - Opts *GlobalOptions - Env env.Env - CodesphereEnv *bootstrap.CodesphereEnvironment - + cmd *cobra.Command + Opts *GlobalOptions + Env env.Env + CodesphereEnv *gcp.CodesphereEnvironment InputRegistryType string + SSHQuiet bool } func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { - err := c.BootstrapGcp() if err != nil { return fmt.Errorf("failed to bootstrap: %w", err) @@ -49,7 +51,7 @@ func AddBootstrapGcpCmd(root *cobra.Command, opts *GlobalOptions) { }, Opts: opts, Env: env.NewEnv(), - CodesphereEnv: &bootstrap.CodesphereEnvironment{}, + CodesphereEnv: &gcp.CodesphereEnvironment{}, } flags := bootstrapGcpCmd.cmd.Flags() @@ -65,8 +67,8 @@ func AddBootstrapGcpCmd(root *cobra.Command, opts *GlobalOptions) { flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.Preemptible, "preemptible", false, "Use preemptible VMs for Codesphere infrastructure (default: false)") flags.IntVar(&bootstrapGcpCmd.CodesphereEnv.DatacenterID, "datacenter-id", 1, "Datacenter ID (default: 1)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.CustomPgIP, "custom-pg-ip", "", "Custom PostgreSQL IP (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfig, "install-config", "config.yaml", "Path to install config file (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFile, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Region, "region", "europe-west4", "GCP Region (default: europe-west4)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Zone, "zone", "europe-west4-a", "GCP Zone (default: europe-west4-a)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSProjectID, "dns-project-id", "", "GCP Project ID for Cloud DNS (optional)") @@ -74,6 +76,7 @@ func AddBootstrapGcpCmd(root *cobra.Command, opts *GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallCodesphereVersion, "install-codesphere-version", "", "Codesphere version to install (default: none)") flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") + flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", true, "Suppress SSH command output (default: true)") util.MarkFlagRequired(bootstrapGcpCmd.cmd, "project-name") util.MarkFlagRequired(bootstrapGcpCmd.cmd, "billing-account") @@ -84,33 +87,37 @@ func AddBootstrapGcpCmd(root *cobra.Command, opts *GlobalOptions) { } func (c *BootstrapGcpCmd) BootstrapGcp() error { - c.CodesphereEnv.RegistryType = bootstrap.RegistryType(c.InputRegistryType) - - gcpClient := bootstrap.NewGCPClient(os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) - bootstrapper, err := bootstrap.NewGCPBootstrapper(c.Env, c.CodesphereEnv, gcpClient) + ctx := c.cmd.Context() + stlog := bootstrap.NewStepLogger(false) + icg := installer.NewInstallConfigManager() + gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) + fw := util.NewFilesystemWriter() + nm := node.NewNode(fw, c.CodesphereEnv.SSHPrivateKeyPath, c.SSHQuiet) + + bs, err := gcp.NewGCPBootstrapper(ctx, c.Env, stlog, c.CodesphereEnv, icg, gcpClient, nm, fw) if err != nil { return err } - env, err := bootstrapper.Bootstrap() - envBytes, err2 := json.MarshalIndent(env, "", " ") + c.CodesphereEnv.RegistryType = gcp.RegistryType(c.InputRegistryType) + + err = bs.Bootstrap() + envBytes, err2 := json.MarshalIndent(bs.Env, "", " ") envString := string(envBytes) if err2 != nil { envString = "" } if err != nil { - if env.Jumpbox.ExternalIP != "" { - log.Printf("To debug on the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", env.Jumpbox.ExternalIP) + if bs.Env.Jumpbox != nil && bs.Env.Jumpbox.GetExternalIP() != "" { + log.Printf("To debug on the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", bs.Env.Jumpbox.GetExternalIP()) } return fmt.Errorf("failed to bootstrap GCP: %w, env: %s", err, envString) } - log.Println("GCP infrastructure bootstrapped:") + log.Println("\nšŸŽ‰šŸŽ‰šŸŽ‰ GCP infrastructure bootstrapped successfully!") log.Println(envString) - - log.Printf("Start the Codesphere installation using OMS from the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", env.Jumpbox.ExternalIP) - - log.Printf("When the installation is done, run the k0s configuration script generated at the k0s-1 host %s /root/configure-k0s.sh.", env.ControlPlaneNodes[0].InternalIP) + log.Printf("Start the Codesphere installation using OMS from the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", bs.Env.Jumpbox.GetExternalIP()) + log.Printf("When the installation is done, run the k0s configuration script generated at the k0s-1 host %s /root/configure-k0s.sh.", bs.Env.ControlPlaneNodes[0].GetInternalIP()) return err } diff --git a/docs/oms-cli_beta_bootstrap-gcp.md b/docs/oms-cli_beta_bootstrap-gcp.md index 1984b14f..ecf06c0f 100644 --- a/docs/oms-cli_beta_bootstrap-gcp.md +++ b/docs/oms-cli_beta_bootstrap-gcp.md @@ -37,6 +37,7 @@ oms-cli beta bootstrap-gcp [flags] --secrets-file string Path to secrets files (optional) (default "prod.vault.yaml") --ssh-private-key-path string SSH Private Key Path (default: ~/.ssh/id_rsa) (default "~/.ssh/id_rsa") --ssh-public-key-path string SSH Public Key Path (default: ~/.ssh/id_rsa.pub) (default "~/.ssh/id_rsa.pub") + --ssh-quiet Suppress SSH command output (default: true) (default true) --write-config Write generated install config to file (default: true) (default true) --zone string GCP Zone (default: europe-west4-a) (default "europe-west4-a") ``` diff --git a/go.mod b/go.mod index b4c0456b..3ca1a2a6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( cloud.google.com/go/iam v1.5.3 cloud.google.com/go/resourcemanager v1.10.7 cloud.google.com/go/serviceusage v1.9.7 - github.com/codesphere-cloud/cs-go v0.16.1 + github.com/codesphere-cloud/cs-go v0.16.2 github.com/creativeprojects/go-selfupdate v1.5.2 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/lithammer/shortuuid v3.0.0+incompatible @@ -34,8 +34,8 @@ require ( cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/kms v1.23.2 // indirect - cloud.google.com/go/longrunning v0.7.0 // indirect + cloud.google.com/go/kms v1.25.0 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.58.0 // indirect code.gitea.io/sdk/gitea v0.22.1 // indirect @@ -177,7 +177,6 @@ require ( github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dghubble/go-twitter v0.0.0-20221104224141-912508c3888b // indirect github.com/dghubble/oauth1 v0.7.3 // indirect github.com/dghubble/sling v1.4.2 // indirect @@ -258,7 +257,6 @@ require ( github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect github.com/golangci/golangci-lint/v2 v2.8.0 // indirect github.com/golangci/golines v0.14.0 // indirect github.com/golangci/misspell v0.7.0 // indirect @@ -471,7 +469,7 @@ require ( github.com/ykadowak/zerologlint v0.1.5 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect - gitlab.com/gitlab-org/api/client-go v1.11.0 // indirect + gitlab.com/gitlab-org/api/client-go v1.24.0 // indirect go-simpler.org/musttag v0.14.0 // indirect go-simpler.org/sloglint v0.11.1 // indirect go.augendre.info/arangolint v0.3.1 // indirect @@ -496,9 +494,9 @@ require ( golang.org/x/exp/typeparams v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect @@ -514,11 +512,13 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.4.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect diff --git a/go.sum b/go.sum index 5545ef98..6d05fc94 100644 --- a/go.sum +++ b/go.sum @@ -22,12 +22,12 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k= -cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= +cloud.google.com/go/kms v1.25.0 h1:gVqvGGUmz0nYCmtoxWmdc1wli2L1apgP8U4fghPGSbQ= +cloud.google.com/go/kms v1.25.0/go.mod h1:XIdHkzfj0bUO3E+LvwPg+oc7s58/Ns8Nd8Sdtljihbk= cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= -cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= -cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/resourcemanager v1.10.7 h1:oPZKIdjyVTuag+D4HF7HO0mnSqcqgjcuA18xblwA0V0= @@ -352,16 +352,16 @@ github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= +github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/codesphere-cloud/cs-go v0.16.1 h1:Pa3UzfeU7G1JMNoVMMd2uiIYX4NyYY2bJM0EdT1Xncw= -github.com/codesphere-cloud/cs-go v0.16.1/go.mod h1:2jJuJ5hYdbdeHlm7Ks0xkd8qvFt1gLAbpVTx2LFTIyQ= +github.com/codesphere-cloud/cs-go v0.16.2 h1:AtS4HKPngpYfB4uj28vo/eq+qPWXICjLmx9R0G0a2rQ= +github.com/codesphere-cloud/cs-go v0.16.2/go.mod h1:VvEsEx7dOlLOCqRFexl0ovuoDB9/N/fQQmMhg5wyE4Q= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -1312,8 +1312,8 @@ gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= -gitlab.com/gitlab-org/api/client-go v1.11.0 h1:L+qzw4kiCf3jKdKHQAwiqYKITvzBrW/tl8ampxNLlv0= -gitlab.com/gitlab-org/api/client-go v1.11.0/go.mod h1:adtVJ4zSTEJ2fP5Pb1zF4Ox1OKFg0MH43yxpb0T0248= +gitlab.com/gitlab-org/api/client-go v1.24.0 h1:lTTuEw2oA3Ac5UOqLJ80tSusDclgq97zuqCTnXqlfHw= +gitlab.com/gitlab-org/api/client-go v1.24.0/go.mod h1:ctGKgv9bErQHO0NOrfhoyFtKMAkBhUE7y53F2xHFAkE= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= @@ -1547,12 +1547,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM= google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8= -google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY= -google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/bootstrap/bootstrap_stepper.go b/internal/bootstrap/bootstrap_stepper.go new file mode 100644 index 00000000..2e53802a --- /dev/null +++ b/internal/bootstrap/bootstrap_stepper.go @@ -0,0 +1,87 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package bootstrap + +import ( + "fmt" +) + +const ( + LINE_RESET = "\r\033[2K" + MOVE_UP = "\033[1A" + MOVE_UP_CLEAR_LINE = "\033[1A\033[K" + RESET_TEXT = "\033[0m" + RED_TEXT = "\033[31m" + GREEN_TEXT = "\033[32m" +) + +type StepLogger struct { + silent bool + subSteps int + currentStep string +} + +func NewStepLogger(silent bool) *StepLogger { + return &StepLogger{ + silent: silent, + } +} + +func (b *StepLogger) Step(name string, fn func() error) error { + if b.silent { + return fn() + } + + b.subSteps = 0 + b.currentStep = name + + fmt.Printf("%s%s%s...", LINE_RESET, RESET_TEXT, name) + err := fn() + if err != nil { + fmt.Printf("%s%s%s failed: %v%s\n", LINE_RESET, RED_TEXT, name, err, RESET_TEXT) + } else { + for i := 0; i < b.subSteps; i++ { + fmt.Printf("%s", MOVE_UP_CLEAR_LINE) + } + fmt.Printf("%s%s%s %sāœ“%s\n", LINE_RESET, RESET_TEXT, name, GREEN_TEXT, RESET_TEXT) + } + return err +} + +func (b *StepLogger) Substep(name string, fn func() error) error { + if b.silent { + return fn() + } + + b.subSteps += 1 + b.currentStep = name + + fmt.Printf("%s%s %s...", LINE_RESET, RESET_TEXT, name) + err := fn() + if err != nil { + fmt.Printf("%s%s %s failed: %v%s\n", LINE_RESET, RED_TEXT, name, err, RESET_TEXT) + } else { + fmt.Printf("%s%s %s %sāœ“%s\n", LINE_RESET, RESET_TEXT, name, GREEN_TEXT, RESET_TEXT) + } + return err +} + +// LogRetry prints a retry message for the current step. +func (b *StepLogger) LogRetry() { + if b.subSteps > 0 { + fmt.Printf("%s%s Retrying: %s...%s", LINE_RESET, RESET_TEXT, b.currentStep, RESET_TEXT) + } else { + fmt.Printf("%s%sRetrying: %s...%s", LINE_RESET, RESET_TEXT, b.currentStep, RESET_TEXT) + } +} + +// Logf prints a log message for the current step. +func (b *StepLogger) Logf(message string, args ...interface{}) { + if b.silent { + return + } + + b.subSteps += 1 + fmt.Printf("%s%s %s%s\n", LINE_RESET, RESET_TEXT, fmt.Sprintf(message, args...), RESET_TEXT) +} diff --git a/internal/bootstrap/gcp.go b/internal/bootstrap/gcp.go deleted file mode 100644 index 802a60e9..00000000 --- a/internal/bootstrap/gcp.go +++ /dev/null @@ -1,1429 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package bootstrap - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - compute "cloud.google.com/go/compute/apiv1" - "cloud.google.com/go/compute/apiv1/computepb" - "github.com/codesphere-cloud/oms/internal/env" - "github.com/codesphere-cloud/oms/internal/installer" - "github.com/codesphere-cloud/oms/internal/installer/files" - "github.com/codesphere-cloud/oms/internal/installer/node" - "github.com/codesphere-cloud/oms/internal/util" - "github.com/lithammer/shortuuid" - "google.golang.org/api/dns/v1" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type RegistryType string - -const ( - RegistryTypeLocalContainer RegistryType = "local-container" - RegistryTypeArtifactRegistry RegistryType = "artifact-registry" -) - -type GCPBootstrapper struct { - ctx context.Context - env *CodesphereEnvironment - InstallConfig *files.RootConfig - Secrets *files.InstallVault - icg installer.InstallConfigManager - NodeManager *node.NodeManager - GCPClient GCPClient -} - -type CodesphereEnvironment struct { - ProjectID string `json:"project_id"` - ProjectName string `json:"project_name"` - DNSProjectID string `json:"dns_project_id"` - PostgreSQLNode node.Node `json:"postgresql_node"` - ControlPlaneNodes []node.Node `json:"control_plane_nodes"` - CephNodes []node.Node `json:"ceph_nodes"` - Jumpbox node.Node `json:"jumpbox"` - ContainerRegistryURL string `json:"container_registry_url"` - ExistingConfigUsed bool `json:"existing_config_used"` - InstallCodesphereVersion string `json:"install_codesphere_version"` - Preemptible bool `json:"preemptible"` - WriteConfig bool `json:"write_config"` - GatewayIP string `json:"gateway_ip"` - PublicGatewayIP string `json:"public_gateway_ip"` - RegistryType RegistryType `json:"registry_type"` - - ProjectDisplayName string - BillingAccount string - BaseDomain string - GithubAppClientID string - GithubAppClientSecret string - SecretsDir string - FolderID string - SSHPublicKeyPath string - SSHPrivateKeyPath string - DatacenterID int - CustomPgIP string - InstallConfig string - SecretsFile string - Region string - Zone string - DNSZoneName string -} - -func NewGCPBootstrapper(env env.Env, CodesphereEnv *CodesphereEnvironment, gcpClient GCPClient) (*GCPBootstrapper, error) { - ctx := context.Background() - fw := util.NewFilesystemWriter() - icg := installer.NewInstallConfigManager() - nm := &node.NodeManager{ - FileIO: fw, - KeyPath: expandPath(CodesphereEnv.SSHPrivateKeyPath), - } - - if fw.Exists(CodesphereEnv.InstallConfig) { - log.Printf("Reading install config file: %s", CodesphereEnv.InstallConfig) - err := icg.LoadInstallConfigFromFile(CodesphereEnv.InstallConfig) - if err != nil { - return nil, fmt.Errorf("failed to load config file: %w", err) - } - - CodesphereEnv.ExistingConfigUsed = true - } else { - err := icg.ApplyProfile("dev") - if err != nil { - return nil, fmt.Errorf("failed to apply profile: %w", err) - } - } - - if fw.Exists(CodesphereEnv.SecretsFile) { - log.Printf("Reading vault file: %s", CodesphereEnv.SecretsFile) - err := icg.LoadVaultFromFile(CodesphereEnv.SecretsFile) - if err != nil { - return nil, fmt.Errorf("failed to load vault file: %w", err) - } - - log.Println("Merging vault secrets into configuration...") - err = icg.MergeVaultIntoConfig() - if err != nil { - return nil, fmt.Errorf("failed to merge vault into config: %w", err) - } - } - - return &GCPBootstrapper{ - env: CodesphereEnv, - InstallConfig: icg.GetInstallConfig(), - NodeManager: nm, - Secrets: icg.GetVault(), - ctx: ctx, - icg: icg, - GCPClient: gcpClient, - }, nil -} - -func (b *GCPBootstrapper) Bootstrap() (*CodesphereEnvironment, error) { - err := b.EnsureProject() - if err != nil { - return b.env, fmt.Errorf("failed to ensure GCP project: %w", err) - } - - err = b.EnsureBilling() - if err != nil { - return b.env, fmt.Errorf("failed to ensure billing is enabled: %w", err) - } - - err = b.EnsureAPIsEnabled() - if err != nil { - return b.env, fmt.Errorf("failed to enable required APIs: %w", err) - } - - if b.env.RegistryType == RegistryTypeArtifactRegistry { - err = b.EnsureArtifactRegistry() - if err != nil { - return b.env, fmt.Errorf("failed to ensure artifact registry: %w", err) - } - } - - err = b.EnsureServiceAccounts() - if err != nil { - return b.env, fmt.Errorf("failed to ensure service accounts: %w", err) - } - - err = b.EnsureIAMRoles() - if err != nil { - return b.env, fmt.Errorf("failed to ensure IAM roles: %w", err) - } - - err = b.EnsureVPC() - if err != nil { - return b.env, fmt.Errorf("failed to ensure VPC: %w", err) - } - - err = b.EnsureFirewallRules() - if err != nil { - return b.env, fmt.Errorf("failed to ensure firewall rules: %w", err) - } - - err = b.EnsureComputeInstances() - if err != nil { - return b.env, fmt.Errorf("failed to ensure compute instances: %w", err) - } - - err = b.EnsureGatewayIPAddresses() - if err != nil { - return b.env, fmt.Errorf("failed to ensure external IP addresses: %w", err) - } - - err = b.EnsureRootLoginEnabled() - if err != nil { - return b.env, fmt.Errorf("failed to ensure root login is enabled: %w", err) - } - - err = b.EnsureJumpboxConfigured() - if err != nil { - return b.env, fmt.Errorf("failed to ensure jumpbox is configured: %w", err) - } - - err = b.EnsureHostsConfigured() - if err != nil { - return b.env, fmt.Errorf("failed to ensure hosts are configured: %w", err) - } - - if b.env.RegistryType == RegistryTypeLocalContainer { - err = b.EnsureLocalContainerRegistry() - if err != nil { - return b.env, fmt.Errorf("failed to ensure local container registry: %w", err) - } - } - - if b.env.WriteConfig { - err = b.UpdateInstallConfig() - if err != nil { - return b.env, fmt.Errorf("failed to update install config: %w", err) - } - - err = b.EnsureAgeKey() - if err != nil { - return b.env, fmt.Errorf("failed to ensure age key: %w", err) - } - - err = b.EncryptVault() - if err != nil { - return b.env, fmt.Errorf("failed to encrypt vault: %w", err) - } - } - - err = b.EnsureDNSRecords() - if err != nil { - return b.env, fmt.Errorf("failed to ensure DNS records: %w", err) - } - - if b.env.InstallCodesphereVersion != "" { - err = b.InstallCodesphere() - if err != nil { - return b.env, fmt.Errorf("failed to install Codesphere: %w", err) - } - } - - err = b.GenerateK0sConfigScript() - if err != nil { - return b.env, fmt.Errorf("failed to generate k0s config script: %w", err) - } - - return b.env, nil -} - -func (b *GCPBootstrapper) EnsureProject() error { - parent := "" - if b.env.FolderID != "" { - parent = fmt.Sprintf("folders/%s", b.env.FolderID) - } - - // Generate a unique project ID - projectGuid := strings.ToLower(shortuuid.New()[:8]) - projectId := b.env.ProjectName + "-" + projectGuid - - existingProject, err := b.GCPClient.GetProjectByName(b.ctx, b.env.FolderID, b.env.ProjectName) - if err == nil { - b.env.ProjectID = existingProject.ProjectId - b.env.ProjectName = existingProject.Name - return nil - } - if err.Error() == fmt.Sprintf("project not found: %s", b.env.ProjectName) { - _, err := b.GCPClient.CreateProject(b.ctx, parent, projectId, b.env.ProjectName) - if err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - b.env.ProjectID = projectId - return nil - } - return fmt.Errorf("failed to get project: %w", err) -} - -func (b *GCPBootstrapper) EnsureBilling() error { - bi, err := b.GCPClient.GetBillingInfo(b.env.ProjectID) - if err != nil { - return fmt.Errorf("failed to get billing info: %w", err) - } - if bi.BillingEnabled && bi.BillingAccountName == b.env.BillingAccount { - return nil - } - - err = b.GCPClient.EnableBilling(b.ctx, b.env.ProjectID, b.env.BillingAccount) - if err != nil { - return fmt.Errorf("failed to enable billing: %w", err) - } - log.Printf("Billing enabled for project %s with account %s", b.env.ProjectID, b.env.BillingAccount) - - return nil -} - -func (b *GCPBootstrapper) EnsureAPIsEnabled() error { - apis := []string{ - "compute.googleapis.com", - "serviceusage.googleapis.com", - "artifactregistry.googleapis.com", - "dns.googleapis.com", - } - - err := b.GCPClient.EnableAPIs(b.ctx, b.env.ProjectID, apis) - if err != nil { - return fmt.Errorf("failed to enable APIs: %w", err) - } - - log.Printf("Required APIs enabled for project %s", b.env.ProjectID) - - return nil -} - -func (b *GCPBootstrapper) EnsureArtifactRegistry() error { - repoName := "codesphere-registry" - - repo, err := b.GCPClient.GetArtifactRegistry(b.ctx, b.env.ProjectID, b.env.Region, repoName) - if err != nil && status.Code(err) != codes.NotFound { - return fmt.Errorf("failed to get artifact registry: %w", err) - } - - // Create the repository if it doesn't exist - if repo == nil { - repo, err = b.GCPClient.CreateArtifactRegistry(b.ctx, b.env.ProjectID, b.env.Region, repoName) - if err != nil || repo == nil { - return fmt.Errorf("failed to create artifact registry: %w, repo: %v", err, repo) - } - } - - b.InstallConfig.Registry.Server = repo.GetRegistryUri() - - log.Printf("Artifact Registry repository %s ensured", b.InstallConfig.Registry.Server) - - return nil -} - -// Installs a docker registry on the postgres node to speed up image loading time -func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { - localRegistryServer := b.env.PostgreSQLNode.InternalIP + ":5000" - - // Figure out if registry is already running - checkCommand := `test "$(podman ps --filter 'name=registry' --format '{{.Names}}' | wc -l)" -eq "1"` - err := b.env.PostgreSQLNode.RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", checkCommand) - if err == nil && b.InstallConfig.Registry != nil && b.InstallConfig.Registry.Server == localRegistryServer && - b.InstallConfig.Registry.Username != "" && b.InstallConfig.Registry.Password != "" { - log.Println("Local container registry already running on postgres node") - return nil - } - - log.Println("Installing registry") - - b.InstallConfig.Registry.Server = localRegistryServer - b.InstallConfig.Registry.Username = "custom-registry" - b.InstallConfig.Registry.Password = shortuuid.New() - - commands := []string{ - "apt-get update", - "apt-get install -y podman apache2-utils", - "htpasswd -bBc /root/registry.password " + b.InstallConfig.Registry.Username + " " + b.InstallConfig.Registry.Password, - "openssl req -newkey rsa:4096 -nodes -sha256 -keyout /root/registry.key -x509 -days 365 -out /root/registry.crt -subj \"/C=DE/ST=BW/L=Karlsruhe/O=Codesphere/CN=" + b.env.PostgreSQLNode.InternalIP + "\" -addext \"subjectAltName = DNS:postgres,IP:" + b.env.PostgreSQLNode.InternalIP + "\"", - "podman rm -f registry || true", - `podman run -d \ - --restart=always --name registry --net=host\ - --env REGISTRY_HTTP_ADDR=0.0.0.0:5000 \ - --env REGISTRY_AUTH=htpasswd \ - --env REGISTRY_AUTH_HTPASSWD_REALM='Registry Realm' \ - --env REGISTRY_AUTH_HTPASSWD_PATH=/auth/registry.password \ - -v /root/registry.password:/auth/registry.password \ - --env REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \ - --env REGISTRY_HTTP_TLS_KEY=/certs/registry.key \ - -v /root/registry.crt:/certs/registry.crt \ - -v /root/registry.key:/certs/registry.key \ - registry:2`, - `mkdir -p /etc/docker/certs.d/` + b.InstallConfig.Registry.Server, - `cp /root/registry.crt /etc/docker/certs.d/` + b.InstallConfig.Registry.Server + `/ca.crt`, - } - for _, cmd := range commands { - err := b.env.PostgreSQLNode.RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", cmd) - if err != nil { - return fmt.Errorf("failed to run command on postgres node: %w", err) - } - } - - allNodes := append(b.env.ControlPlaneNodes, b.env.CephNodes...) - for _, node := range allNodes { - err := b.env.PostgreSQLNode.RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", "scp -o StrictHostKeyChecking=no /root/registry.crt root@"+node.InternalIP+":/usr/local/share/ca-certificates/registry.crt") - if err != nil { - return fmt.Errorf("failed to copy registry certificate to node %s: %w", node.InternalIP, err) - } - err = node.RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", "update-ca-certificates") - if err != nil { - return fmt.Errorf("failed to update CA certificates on node %s: %w", node.InternalIP, err) - } - err = node.RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", "systemctl restart docker.service || true") // docker is probably not yet installed - if err != nil { - return fmt.Errorf("failed to restart docker service on node %s: %w", node.InternalIP, err) - } - } - - return nil -} - -func (b *GCPBootstrapper) EnsureServiceAccounts() error { - _, _, err := b.EnsureServiceAccount("cloud-controller") - if err != nil { - return err - } - - if b.env.RegistryType == RegistryTypeArtifactRegistry { - sa, newSa, err := b.EnsureServiceAccount("artifact-registry-writer") - if err != nil { - return err - } - - if !newSa && b.InstallConfig.Registry.Password != "" { - return nil - } - - for retries := range 5 { - privateKey, err := b.GCPClient.CreateServiceAccountKey(b.ctx, b.env.ProjectID, sa) - - if err != nil && status.Code(err) != codes.AlreadyExists { - if retries > 3 { - return fmt.Errorf("failed to create service account key: %w", err) - } - log.Printf("got response %d trying to create service account key for %s, retrying...", status.Code(err), sa) - time.Sleep(5 * time.Second) - continue - } - log.Printf("Service account key for %s ensured", sa) - b.InstallConfig.Registry.Password = string(privateKey) - b.InstallConfig.Registry.Username = "_json_key_base64" - break - } - } - - return nil -} - -func (b *GCPBootstrapper) EnsureServiceAccount(name string) (string, bool, error) { - return b.GCPClient.CreateServiceAccount(b.ctx, b.env.ProjectID, name, name) -} - -func (b *GCPBootstrapper) EnsureIAMRoles() error { - err := b.GCPClient.AssignIAMRole(b.ctx, b.env.ProjectID, "cloud-controller", "roles/compute.admin") - if err != nil { - return err - } - - if b.env.RegistryType != RegistryTypeArtifactRegistry { - return nil - } - - err = b.GCPClient.AssignIAMRole(b.ctx, b.env.ProjectID, "artifact-registry-writer", "roles/artifactregistry.writer") - return err -} - -func (b *GCPBootstrapper) EnsureVPC() error { - networkName := fmt.Sprintf("%s-vpc", b.env.ProjectID) - subnetName := fmt.Sprintf("%s-%s-subnet", b.env.ProjectID, b.env.Region) - routerName := fmt.Sprintf("%s-router", b.env.ProjectID) - natName := fmt.Sprintf("%s-nat-gateway", b.env.ProjectID) - - // Create VPC - err := b.GCPClient.CreateVPC(b.ctx, b.env.ProjectID, b.env.Region, networkName, subnetName, routerName, natName) - if err != nil { - return fmt.Errorf("failed to ensure VPC: %w", err) - } - - log.Printf("VPC %s ensured", networkName) - - return nil -} - -func (b *GCPBootstrapper) EnsureFirewallRules() error { - networkName := fmt.Sprintf("%s-vpc", b.env.ProjectID) - - // Allow external SSH to Jumpbox - sshRule := &computepb.Firewall{ - Name: protoString("allow-ssh-ext"), - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.env.ProjectID, networkName)), - Direction: protoString("INGRESS"), - Priority: protoInt32(1000), - Allowed: []*computepb.Allowed{ - { - IPProtocol: protoString("tcp"), - Ports: []string{"22"}, - }, - }, - SourceRanges: []string{"0.0.0.0/0"}, - TargetTags: []string{"ssh"}, - Description: protoString("Allow external SSH to Jumpbox"), - } - err := b.GCPClient.CreateFirewallRule(b.ctx, b.env.ProjectID, sshRule) - if err != nil { - return fmt.Errorf("failed to create jumpbox ssh firewall rule: %w", err) - } - - // Allow all internal traffic - internalRule := &computepb.Firewall{ - Name: protoString("allow-internal"), - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.env.ProjectID, networkName)), - Direction: protoString("INGRESS"), - Priority: protoInt32(1000), - Allowed: []*computepb.Allowed{ - {IPProtocol: protoString("all")}, - }, - SourceRanges: []string{"10.10.0.0/20"}, - Description: protoString("Allow all internal traffic"), - } - err = b.GCPClient.CreateFirewallRule(b.ctx, b.env.ProjectID, internalRule) - if err != nil { - return fmt.Errorf("failed to create internal firewall rule: %w", err) - } - - // Allow all egress - egressRule := &computepb.Firewall{ - Name: protoString("allow-all-egress"), - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.env.ProjectID, networkName)), - Direction: protoString("EGRESS"), - Priority: protoInt32(1000), - Allowed: []*computepb.Allowed{ - {IPProtocol: protoString("all")}, - }, - DestinationRanges: []string{"0.0.0.0/0"}, - Description: protoString("Allow all egress"), - } - err = b.GCPClient.CreateFirewallRule(b.ctx, b.env.ProjectID, egressRule) - if err != nil { - return fmt.Errorf("failed to create egress firewall rule: %w", err) - } - - // Allow ingress for web (HTTP/HTTPS) - webRule := &computepb.Firewall{ - Name: protoString("allow-ingress-web"), - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.env.ProjectID, networkName)), - Direction: protoString("INGRESS"), - Priority: protoInt32(1000), - Allowed: []*computepb.Allowed{ - {IPProtocol: protoString("tcp"), Ports: []string{"80", "443"}}, - }, - SourceRanges: []string{"0.0.0.0/0"}, - Description: protoString("Allow HTTP/HTTPS ingress"), - } - err = b.GCPClient.CreateFirewallRule(b.ctx, b.env.ProjectID, webRule) - if err != nil { - return fmt.Errorf("failed to create web firewall rule: %w", err) - } - - // Allow ingress for PostgreSQL - postgresRule := &computepb.Firewall{ - Name: protoString("allow-ingress-postgres"), - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.env.ProjectID, networkName)), - Direction: protoString("INGRESS"), - Priority: protoInt32(1000), - Allowed: []*computepb.Allowed{ - {IPProtocol: protoString("tcp"), Ports: []string{"5432"}}, - }, - SourceRanges: []string{"0.0.0.0/0"}, - TargetTags: []string{"postgres"}, - Description: protoString("Allow external access to PostgreSQL"), - } - err = b.GCPClient.CreateFirewallRule(b.ctx, b.env.ProjectID, postgresRule) - if err != nil { - return fmt.Errorf("failed to create postgres firewall rule: %w", err) - } - - log.Println("Firewall rules ensured") - return nil -} - -type VMDef struct { - Name string - MachineType string - Tags []string - AdditionalDisks []int64 - ExternalIP bool -} - -func (b *GCPBootstrapper) EnsureComputeInstances() error { - projectID := b.env.ProjectID - region := b.env.Region - zone := b.env.Zone - ctx := b.ctx - - instancesClient, err := compute.NewInstancesRESTClient(ctx) - if err != nil { - return fmt.Errorf("failed to create instances client: %w", err) - } - defer util.IgnoreError(instancesClient.Close) - - // Example VM definitions (expand as needed) - - vmDefs := []VMDef{ - {"jumpbox", "e2-medium", []string{"jumpbox", "ssh"}, []int64{}, true}, - {"postgres", "e2-standard-8", []string{"postgres"}, []int64{}, true}, - {"ceph-1", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, - {"ceph-2", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, - {"ceph-3", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, - {"ceph-4", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, - {"k0s-1", "e2-standard-16", []string{"k0s"}, []int64{}, false}, - {"k0s-2", "e2-standard-16", []string{"k0s"}, []int64{}, false}, - {"k0s-3", "e2-standard-16", []string{"k0s"}, []int64{}, false}, - } - - network := fmt.Sprintf("projects/%s/global/networks/%s-vpc", projectID, projectID) - subnetwork := fmt.Sprintf("projects/%s/regions/%s/subnetworks/%s-%s-subnet", projectID, region, projectID, region) - diskType := fmt.Sprintf("projects/%s/zones/%s/diskTypes/pd-ssd", projectID, zone) - - // Create VMs in parallel - wg := sync.WaitGroup{} - errCh := make(chan error, len(vmDefs)) - mu := sync.Mutex{} - for _, vm := range vmDefs { - wg.Add(1) - go func(vm VMDef) { - defer wg.Done() - disks := []*computepb.AttachedDisk{ - { - Boot: protoBool(true), - AutoDelete: protoBool(true), - Type: protoString("PERSISTENT"), - InitializeParams: &computepb.AttachedDiskInitializeParams{ - DiskType: &diskType, - DiskSizeGb: protoInt64(200), - SourceImage: protoString("projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts"), - }, - }, - } - for _, diskSize := range vm.AdditionalDisks { - disks = append(disks, &computepb.AttachedDisk{ - Boot: protoBool(false), - AutoDelete: protoBool(true), - Type: protoString("PERSISTENT"), - InitializeParams: &computepb.AttachedDiskInitializeParams{ - DiskSizeGb: protoInt64(diskSize), - DiskType: &diskType, - }, - }) - } - - serviceAccount := fmt.Sprintf("cloud-controller@%s.iam.gserviceaccount.com", projectID) - - pubKey, err := readSSHKey(b.env.SSHPublicKeyPath) - if err != nil { - errCh <- fmt.Errorf("failed to read SSH public key: %w", err) - return - } - - instance := &computepb.Instance{ - Name: protoString(vm.Name), - ServiceAccounts: []*computepb.ServiceAccount{ - { - Email: protoString(serviceAccount), - Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, - }, - }, - MachineType: protoString(fmt.Sprintf("zones/%s/machineTypes/%s", zone, vm.MachineType)), - Tags: &computepb.Tags{ - Items: vm.Tags, - }, - Scheduling: &computepb.Scheduling{ - Preemptible: &b.env.Preemptible, - }, - NetworkInterfaces: []*computepb.NetworkInterface{ - { - Network: protoString(network), - Subnetwork: protoString(subnetwork), - }, - }, - Disks: disks, - Metadata: &computepb.Metadata{ - Items: []*computepb.Items{ - { - Key: protoString("ssh-keys"), - Value: protoString(fmt.Sprintf("root:%s\nubuntu:%s", pubKey+"root", pubKey+"ubuntu")), - }, - }, - }, - } - - // Configure external IP if needed - if vm.ExternalIP { - instance.NetworkInterfaces[0].AccessConfigs = []*computepb.AccessConfig{ - { - Name: protoString("External NAT"), - Type: protoString("ONE_TO_ONE_NAT"), - }, - } - } - - op, err := instancesClient.Insert(ctx, &computepb.InsertInstanceRequest{ - Project: projectID, - Zone: zone, - InstanceResource: instance, - }) - if err != nil && !isAlreadyExistsError(err) { - errCh <- fmt.Errorf("failed to create instance %s: %w", vm.Name, err) - } - if err == nil { - if err := op.Wait(ctx); err != nil { - errCh <- fmt.Errorf("failed to wait for instance %s creation: %w", vm.Name, err) - } - } - log.Printf("Instance %s ensured", vm.Name) - - //find out the IP addresses of the created instance - resp, err := instancesClient.Get(ctx, &computepb.GetInstanceRequest{ - Project: projectID, - Zone: zone, - Instance: vm.Name, - }) - if err != nil { - errCh <- fmt.Errorf("failed to get instance %s: %w", vm.Name, err) - } - - externalIP := "" - internalIP := "" - if len(resp.GetNetworkInterfaces()) > 0 { - internalIP = resp.GetNetworkInterfaces()[0].GetNetworkIP() - if len(resp.GetNetworkInterfaces()[0].GetAccessConfigs()) > 0 { - externalIP = resp.GetNetworkInterfaces()[0].GetAccessConfigs()[0].GetNatIP() - } - } - - node := node.Node{ - ExternalIP: externalIP, - InternalIP: internalIP, - Name: vm.Name, - } - - mu.Lock() - switch vm.Tags[0] { - case "jumpbox": - b.env.Jumpbox = node - case "postgres": - b.env.PostgreSQLNode = node - case "ceph": - b.env.CephNodes = append(b.env.CephNodes, node) - case "k0s": - b.env.ControlPlaneNodes = append(b.env.ControlPlaneNodes, node) - } - mu.Unlock() - }(vm) - } - wg.Wait() - - close(errCh) - errStr := "" - for err := range errCh { - errStr += err.Error() + "; " - } - if errStr != "" { - return fmt.Errorf("error ensuring compute instances: %s", errStr) - } - - //sort ceph nodes by name to ensure consistent ordering - sort.Slice(b.env.CephNodes, func(i, j int) bool { - return b.env.CephNodes[i].Name < b.env.CephNodes[j].Name - }) - - //sort control plane nodes by name to ensure consistent ordering - sort.Slice(b.env.ControlPlaneNodes, func(i, j int) bool { - return b.env.ControlPlaneNodes[i].Name < b.env.ControlPlaneNodes[j].Name - }) - return nil -} - -// EnsureGatewayIPAddresses reserves 2 static external IP addresses for the ingress -// controllers of the cluster. -func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { - var err error - b.env.GatewayIP, err = b.EnsureExternalIP("gateway") - if err != nil { - return fmt.Errorf("failed to ensure gateway IP: %w", err) - } - b.env.PublicGatewayIP, err = b.EnsureExternalIP("public-gateway") - if err != nil { - return fmt.Errorf("failed to ensure public gateway IP: %w", err) - } - return nil -} - -func (b *GCPBootstrapper) EnsureExternalIP(name string) (string, error) { - addressesClient, err := compute.NewAddressesRESTClient(b.ctx) - if err != nil { - return "", fmt.Errorf("failed to create addresses client: %w", err) - } - defer util.IgnoreError(addressesClient.Close) - - desiredAddress := &computepb.Address{ - Name: &name, - AddressType: protoString("EXTERNAL"), - Region: &b.env.Region, - } - - // Figure out if address already exists and get IP - req := &computepb.GetAddressRequest{ - Project: b.env.ProjectID, - Region: b.env.Region, - Address: *desiredAddress.Name, - } - - address, err := addressesClient.Get(b.ctx, req) - - if err == nil && address != nil { - log.Printf("Address %s already exists", name) - - return address.GetAddress(), nil - } - - op, err := addressesClient.Insert(b.ctx, &computepb.InsertAddressRequest{ - Project: b.env.ProjectID, - Region: b.env.Region, - AddressResource: desiredAddress, - }) - if err != nil { - return "", fmt.Errorf("failed to create address %s: %w", name, err) - } - if err := op.Wait(b.ctx); err != nil { - return "", fmt.Errorf("failed to wait for address %s creation: %w", name, err) - } - log.Printf("Address %s ensured", name) - - address, err = addressesClient.Get(b.ctx, req) - - if err == nil && address != nil { - return address.GetAddress(), nil - } - return "", fmt.Errorf("failed to get address %s after creation", name) -} - -func (b *GCPBootstrapper) EnsureRootLoginEnabled() error { - // wait for SSH service to be available on jumpbox - err := b.env.Jumpbox.WaitForSSH(nil, b.NodeManager, 30*time.Second) - if err != nil { - return fmt.Errorf("timed out waiting for SSH service to start on jumpbox: %w", err) - } - log.Printf("SSH service available on jumpbox '%s'", b.env.Jumpbox.Name) - - hasRootLogin := b.env.Jumpbox.HasRootLoginEnabled(nil, b.NodeManager) - if !hasRootLogin { - err := b.env.Jumpbox.EnableRootLogin(nil, b.NodeManager) - if err != nil { - return fmt.Errorf("failed to enable root login on %s: %w", b.env.Jumpbox.Name, err) - } - log.Printf("Root login enabled on %s", b.env.Jumpbox.Name) - } - - allNodes := append(b.env.ControlPlaneNodes, b.env.PostgreSQLNode) - allNodes = append(allNodes, b.env.CephNodes...) - - for _, node := range allNodes { - err = node.WaitForSSH(&b.env.Jumpbox, b.NodeManager, 30*time.Second) - if err != nil { - return fmt.Errorf("timed out waiting for SSH service to start on %s: %w", node.Name, err) - } - hasRootLogin := node.HasRootLoginEnabled(&b.env.Jumpbox, b.NodeManager) - if hasRootLogin { - log.Printf("Root login already enabled on %s", node.Name) - - continue - } - for i := range 3 { - err := node.EnableRootLogin(&b.env.Jumpbox, b.NodeManager) - if err == nil { - break - } - if i == 2 { - return fmt.Errorf("failed to enable root login on %s: %w", node.Name, err) - } - log.Printf("cannot enable root login on %s yet, retrying in 10 seconds: %v", node.Name, err) - time.Sleep(10 * time.Second) - } - - log.Printf("Root login enabled on %s", node.Name) - } - return nil -} - -func (b *GCPBootstrapper) EnsureJumpboxConfigured() error { - if !b.env.Jumpbox.HasAcceptEnvConfigured(nil, b.NodeManager) { - err := b.env.Jumpbox.ConfigureAcceptEnv(nil, b.NodeManager) - if err != nil { - return fmt.Errorf("failed to configure AcceptEnv on jumpbox: %w", err) - } - } - hasOms := b.env.Jumpbox.HasCommand(b.NodeManager, "oms-cli") - if hasOms { - log.Println("OMS already installed on jumpbox") - return nil - } - err := b.env.Jumpbox.InstallOms(b.NodeManager) - if err != nil { - return fmt.Errorf("failed to install OMS on jumpbox: %w", err) - } - - log.Println("OMS installed on jumpbox") - return nil -} - -func (b *GCPBootstrapper) EnsureHostsConfigured() error { - allNodes := append(b.env.ControlPlaneNodes, b.env.PostgreSQLNode) - allNodes = append(allNodes, b.env.CephNodes...) - - for _, node := range allNodes { - if !node.HasInotifyWatchesConfigured(&b.env.Jumpbox, b.NodeManager) { - err := node.ConfigureInotifyWatches(&b.env.Jumpbox, b.NodeManager) - if err != nil { - return fmt.Errorf("failed to configure inotify watches on %s: %w", node.Name, err) - } - } - - if !node.HasMemoryMapConfigured(&b.env.Jumpbox, b.NodeManager) { - err := node.ConfigureMemoryMap(&b.env.Jumpbox, b.NodeManager) - if err != nil { - return fmt.Errorf("failed to configure memory map on %s: %w", node.Name, err) - } - } - log.Printf("Host %s configured", node.Name) - } - return nil -} - -func (b *GCPBootstrapper) UpdateInstallConfig() error { - - // Update install config with necessary values - b.InstallConfig.Datacenter.ID = b.env.DatacenterID - b.InstallConfig.Datacenter.City = "Karlsruhe" - b.InstallConfig.Datacenter.CountryCode = "DE" - b.InstallConfig.Secrets.BaseDir = b.env.SecretsDir - b.InstallConfig.Registry.ReplaceImagesInBom = true - b.InstallConfig.Registry.LoadContainerImages = true - - if b.InstallConfig.Postgres.Primary == nil { - b.InstallConfig.Postgres.Primary = &files.PostgresPrimaryConfig{ - Hostname: b.env.PostgreSQLNode.Name, - } - } - b.InstallConfig.Postgres.Primary.IP = b.env.PostgreSQLNode.InternalIP - - b.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" - b.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" - b.InstallConfig.Ceph.Hosts = []files.CephHost{ - { - Hostname: b.env.CephNodes[0].Name, - IsMaster: true, - IPAddress: b.env.CephNodes[0].InternalIP, - }, - { - Hostname: b.env.CephNodes[1].Name, - IPAddress: b.env.CephNodes[1].InternalIP, - }, - { - Hostname: b.env.CephNodes[2].Name, - IPAddress: b.env.CephNodes[2].InternalIP, - }, - { - Hostname: b.env.CephNodes[3].Name, - IPAddress: b.env.CephNodes[3].InternalIP, - }, - } - b.InstallConfig.Ceph.OSDs = []files.CephOSD{ - { - SpecID: "default", - Placement: files.CephPlacement{ - HostPattern: "*", - }, - DataDevices: files.CephDataDevices{ - Size: "100G:", - Limit: 1, - }, - DBDevices: files.CephDBDevices{ - Size: "10G:500G", - Limit: 1, - }, - }, - } - - b.InstallConfig.Kubernetes = files.KubernetesConfig{ - ManagedByCodesphere: true, - APIServerHost: b.env.ControlPlaneNodes[0].InternalIP, - ControlPlanes: []files.K8sNode{ - { - IPAddress: b.env.ControlPlaneNodes[0].InternalIP, - }, - }, - Workers: []files.K8sNode{ - { - IPAddress: b.env.ControlPlaneNodes[0].InternalIP, - }, - - { - IPAddress: b.env.ControlPlaneNodes[1].InternalIP, - }, - { - IPAddress: b.env.ControlPlaneNodes[2].InternalIP, - }, - }, - } - b.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{ - Prometheus: &files.PrometheusConfig{ - RemoteWrite: &files.RemoteWriteConfig{ - Enabled: false, - ClusterName: "GCP-test", - }, - }, - } - b.InstallConfig.Cluster.Gateway = files.GatewayConfig{ - ServiceType: "LoadBalancer", - //IPAddresses: []string{b.env.ControlPlaneNodes[0].ExternalIP}, - Annotations: map[string]string{ - "cloud.google.com/load-balancer-ipv4": b.env.GatewayIP, - }, - } - b.InstallConfig.Cluster.PublicGateway = files.GatewayConfig{ - ServiceType: "LoadBalancer", - Annotations: map[string]string{ - "cloud.google.com/load-balancer-ipv4": b.env.PublicGatewayIP, - }, - //IPAddresses: []string{b.env.ControlPlaneNodes[1].ExternalIP}, - } - - b.InstallConfig.Codesphere.Domain = "cs." + b.env.BaseDomain - b.InstallConfig.Codesphere.WorkspaceHostingBaseDomain = "ws." + b.env.BaseDomain - b.InstallConfig.Codesphere.PublicIP = b.env.ControlPlaneNodes[1].ExternalIP - b.InstallConfig.Codesphere.CustomDomains = files.CustomDomainsConfig{ - CNameBaseDomain: "ws." + b.env.BaseDomain, - } - b.InstallConfig.Codesphere.DNSServers = []string{"8.8.8.8"} - b.InstallConfig.Codesphere.Experiments = []string{} - b.InstallConfig.Codesphere.DeployConfig = files.DeployConfig{ - Images: map[string]files.ImageConfig{ - "ubuntu-24.04": { - Name: "Ubuntu 24.04", - SupportedUntil: "2028-05-31", - Flavors: map[string]files.FlavorConfig{ - "default": { - Image: files.ImageRef{ - BomRef: "workspace-agent-24.04", - }, - Pool: map[int]int{ - 1: 1, - 2: 1, - 3: 0, - 4: 0, - }, - }, - }, - }, - }, - } - b.InstallConfig.Codesphere.Plans = files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: 10, - GPUParts: 0, - MemoryMb: 2048, - StorageMb: 20480, - TempStorageMb: 1024, - }, - 2: { - CPUTenth: 20, - GPUParts: 0, - MemoryMb: 4096, - StorageMb: 20480, - TempStorageMb: 1024, - }, - 3: { - CPUTenth: 40, - GPUParts: 0, - MemoryMb: 8192, - StorageMb: 40960, - TempStorageMb: 1024, - }, - 4: { - CPUTenth: 80, - GPUParts: 0, - MemoryMb: 16384, - StorageMb: 40960, - TempStorageMb: 1024, - }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: "Micro", - HostingPlanID: 1, - MaxReplicas: 3, - OnDemand: true, - }, - 2: { - Name: "Standard", - HostingPlanID: 2, - MaxReplicas: 3, - OnDemand: true, - }, - 3: { - Name: "Big", - HostingPlanID: 3, - MaxReplicas: 3, - OnDemand: true, - }, - 4: { - Name: "Pro", - HostingPlanID: 4, - MaxReplicas: 3, - OnDemand: true, - }, - }, - } - b.InstallConfig.Codesphere.GitProviders = &files.GitProvidersConfig{ - GitHub: &files.GitProviderConfig{ - Enabled: true, - URL: "https://github.com", - API: files.APIConfig{ - BaseURL: "https://api.github.com", - }, - OAuth: files.OAuthConfig{ - Issuer: "https://github.com", - AuthorizationEndpoint: "https://github.com/login/oauth/authorize", - TokenEndpoint: "https://github.com/login/oauth/access_token", - - ClientID: b.env.GithubAppClientID, - ClientSecret: b.env.GithubAppClientSecret, - }, - }, - } - b.InstallConfig.Codesphere.ManagedServices = []files.ManagedServiceConfig{} - - if !b.env.ExistingConfigUsed { - err := b.icg.GenerateSecrets() - if err != nil { - return fmt.Errorf("failed to generate secrets: %w", err) - } - } else { - var err error - b.InstallConfig.Postgres.Primary.PrivateKey, b.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( - b.InstallConfig.Postgres.CaCertPrivateKey, - b.InstallConfig.Postgres.CACertPem, - b.InstallConfig.Postgres.Primary.Hostname, - []string{b.InstallConfig.Postgres.Primary.IP}) - if err != nil { - return fmt.Errorf("failed to generate primary server certificate: %w", err) - } - if b.InstallConfig.Postgres.Replica != nil { - b.InstallConfig.Postgres.ReplicaPrivateKey, b.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( - b.InstallConfig.Postgres.CaCertPrivateKey, - b.InstallConfig.Postgres.CACertPem, - b.InstallConfig.Postgres.Replica.Name, - []string{b.InstallConfig.Postgres.Replica.IP}) - if err != nil { - return fmt.Errorf("failed to generate replica server certificate: %w", err) - } - } - } - - if err := b.icg.WriteInstallConfig(b.env.InstallConfig, true); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - if err := b.icg.WriteVault(b.env.SecretsFile, true); err != nil { - return fmt.Errorf("failed to write vault file: %w", err) - } - - err := b.env.Jumpbox.CopyFile(nil, b.NodeManager, b.env.InstallConfig, "/etc/codesphere/config.yaml") - if err != nil { - return fmt.Errorf("failed to copy install config to jumpbox: %w", err) - } - - err = b.env.Jumpbox.CopyFile(nil, b.NodeManager, b.env.SecretsFile, b.env.SecretsDir+"/prod.vault.yaml") - if err != nil { - return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) - } - return nil -} - -func (b *GCPBootstrapper) EnsureAgeKey() error { - hasKey := b.env.Jumpbox.HasFile(nil, b.NodeManager, b.env.SecretsDir+"/age_key.txt") - if hasKey { - log.Println("Age key already present on jumpbox") - return nil - } - - err := b.env.Jumpbox.RunSSHCommand(nil, b.NodeManager, "root", fmt.Sprintf("mkdir -p %s; age-keygen -o %s/age_key.txt", b.env.SecretsDir, b.env.SecretsDir)) - if err != nil { - return fmt.Errorf("failed to generate age key on jumpbox: %w", err) - } - - log.Println("Age key generated on jumpbox") - return nil -} - -func (b *GCPBootstrapper) EncryptVault() error { - err := b.env.Jumpbox.RunSSHCommand(nil, b.NodeManager, "root", "cp "+b.env.SecretsDir+"/prod.vault.yaml{,.bak}") - if err != nil { - return fmt.Errorf("failed backup vault on jumpbox: %w", err) - } - - err = b.env.Jumpbox.RunSSHCommand(nil, b.NodeManager, "root", "sops --encrypt --in-place --age $(age-keygen -y "+b.env.SecretsDir+"/age_key.txt) "+b.env.SecretsDir+"/prod.vault.yaml") - if err != nil { - return fmt.Errorf("failed to encrypt vault on jumpbox: %w", err) - } - - log.Println("Vault encrypted on jumpbox") - return nil -} - -func (b *GCPBootstrapper) EnsureDNSRecords() error { - ctx := context.Background() - gcpProject := b.env.DNSProjectID - if b.env.DNSProjectID == "" { - gcpProject = b.env.ProjectID - } - - dnsService, err := dns.NewService(ctx) - if err != nil { - return fmt.Errorf("failed to create DNS service: %w", err) - } - - zoneName := b.env.DNSZoneName - // Check if zone exists, otherwise create - _, err = dnsService.ManagedZones.Get(gcpProject, zoneName).Context(ctx).Do() - if err != nil { - zone := &dns.ManagedZone{ - Name: zoneName, - DnsName: b.env.BaseDomain + ".", - Description: "Codesphere DNS zone", - } - _, err = dnsService.ManagedZones.Create(gcpProject, zone).Context(ctx).Do() - if err != nil { - return fmt.Errorf("failed to create DNS zone: %w", err) - } - } - - records := []*dns.ResourceRecordSet{ - { - Name: fmt.Sprintf("cs.%s.", b.env.BaseDomain), - Type: "A", - Ttl: 300, - Rrdatas: []string{b.env.GatewayIP}, - }, - { - Name: fmt.Sprintf("*.cs.%s.", b.env.BaseDomain), - Type: "A", - Ttl: 300, - Rrdatas: []string{b.env.GatewayIP}, - }, - { - Name: fmt.Sprintf("*.ws.%s.", b.env.BaseDomain), - Type: "A", - Ttl: 300, - Rrdatas: []string{b.env.PublicGatewayIP}, - }, - { - Name: fmt.Sprintf("ws.%s.", b.env.BaseDomain), - Type: "A", - Ttl: 300, - Rrdatas: []string{b.env.PublicGatewayIP}, - }, - } - - deletions := []*dns.ResourceRecordSet{} - // Clean up existing records - for _, record := range records { - existingRecord, err := dnsService.ResourceRecordSets.Get(gcpProject, zoneName, record.Name, record.Type).Context(ctx).Do() - if err == nil && existingRecord != nil { - deletions = append(deletions, existingRecord) - } - } - - if len(deletions) > 0 { - delChange := &dns.Change{ - Deletions: deletions, - } - _, err = dnsService.Changes.Create(gcpProject, zoneName, delChange).Context(ctx).Do() - if err != nil { - return fmt.Errorf("failed to delete DNS records: %w", err) - } - } - - change := &dns.Change{ - Additions: records, - } - - _, err = dnsService.Changes.Create(gcpProject, zoneName, change).Context(ctx).Do() - if err != nil { - return fmt.Errorf("failed to create DNS records: %w", err) - } - - log.Printf("DNS records created in project %s zone %s", gcpProject, zoneName) - return nil -} - -func (b *GCPBootstrapper) InstallCodesphere() error { - err := b.env.Jumpbox.RunSSHCommand(nil, b.NodeManager, "root", "oms-cli download package "+b.env.InstallCodesphereVersion) - if err != nil { - return fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) - } - - err = b.env.Jumpbox.RunSSHCommand(nil, b.NodeManager, "root", "oms-cli install codesphere -c /etc/codesphere/config.yaml -k "+b.env.SecretsDir+"/age_key.txt -p "+b.env.InstallCodesphereVersion+".tar.gz") - if err != nil { - return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) - } - - log.Println("Codesphere installed from jumpbox") - return nil -} - -func (b *GCPBootstrapper) GenerateK0sConfigScript() error { - script := `#!/bin/bash - -cat < cloud.conf -[Global] -project-id = "$PROJECT_ID" -EOF - -cat <> cc-deployment.yaml -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: cloud-controller-manager - namespace: kube-system - labels: - component: cloud-controller-manager -spec: - selector: - matchLabels: - component: cloud-controller-manager - template: - metadata: - labels: - component: cloud-controller-manager - spec: - serviceAccountName: cloud-controller-manager - containers: - - name: cloud-controller-manager - image: k8scloudprovidergcp/cloud-controller-manager:latest - command: - - /usr/local/bin/cloud-controller-manager - args: - - --v=5 - - --cloud-provider=gce - - --cloud-config=/etc/gce/cloud.conf - - --leader-elect-resource-name=k0s-gcp-ccm - - --use-service-account-credentials=true - - --controllers=cloud-node,cloud-node-lifecycle,service - - --allocate-node-cidrs=false - - --configure-cloud-routes=false - volumeMounts: - - name: cloud-config-volume - mountPath: /etc/gce - readOnly: true - volumes: - - name: cloud-config-volume - configMap: - name: cloud-config - tolerations: - - key: node.cloudprovider.kubernetes.io/uninitialized - value: "true" - effect: NoSchedule - - key: node-role.kubernetes.io/master - effect: NoSchedule - - key: node-role.kubernetes.io/control-plane - effect: NoSchedule -EOF - -KUBECTL="/etc/codesphere/deps/kubernetes/files/k0s kubectl" -$KUBECTL create configmap cloud-config --from-file=cloud.conf -n kube-system -echo alias kubectl=\"$KUBECTL\" >> /root/.bashrc -echo alias k=\"$KUBECTL\" >> /root/.bashrc - -$KUBECTL apply -f https://raw.githubusercontent.com/kubernetes/cloud-provider-gcp/refs/tags/providers/v0.28.2/deploy/packages/default/manifest.yaml - -$KUBECTL apply -f cc-deployment.yaml - -# set loadBalancerIP for public-gateway-controller and gateway-controller -$KUBECTL patch svc public-gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.env.PublicGatewayIP + `'"}}' -$KUBECTL patch svc gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.env.GatewayIP + `'"}}' - -sed -i 's/k0scontroller/k0scontroller --enable-cloud-provider/g' /etc/systemd/system/k0scontroller.service - -ssh root@` + b.env.ControlPlaneNodes[1].InternalIP + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" - -ssh root@` + b.env.ControlPlaneNodes[2].InternalIP + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" - -systemctl daemon-reload -systemctl restart k0scontroller -` - // Probably we need to enable the cloud provider plugin in k0s configuration. - // --enable-cloud-provider on worker nodes systemd file /etc/systemd/system/k0sworker.service - // in addition on the first node: /etc/systemd/system/k0scontroller.service the flag --enable-cloud-provider - - err := os.WriteFile("configure-k0s.sh", []byte(script), 0755) - if err != nil { - return fmt.Errorf("failed to write configure-k0s.sh: %w", err) - } - err = b.env.ControlPlaneNodes[0].CopyFile(&b.env.Jumpbox, b.NodeManager, "configure-k0s.sh", "/root/configure-k0s.sh") - if err != nil { - return fmt.Errorf("failed to copy configure-k0s.sh to control plane node: %w", err) - } - err = b.env.ControlPlaneNodes[0].RunSSHCommand(&b.env.Jumpbox, b.NodeManager, "root", "chmod +x /root/configure-k0s.sh") - if err != nil { - return fmt.Errorf("failed to make configure-k0s.sh executable on control plane node: %w", err) - } - return nil -} - -// Helper functions -func protoInt32(i int32) *int32 { return &i } -func protoInt64(i int64) *int64 { return &i } -func isAlreadyExistsError(err error) bool { - return status.Code(err) == codes.AlreadyExists || strings.Contains(err.Error(), "already exists") -} - -// expandPath expands ~ to the user's home directory -func expandPath(path string) string { - if strings.HasPrefix(path, "~/") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, path[2:]) - } - } - return path -} - -// readSSHKey reads an SSH key file, expanding ~ in the path -func readSSHKey(path string) (string, error) { - realPath := expandPath(path) - data, err := os.ReadFile(realPath) - if err != nil { - return "", fmt.Errorf("error reading SSH key from %s: %w", realPath, err) - } - key := strings.TrimSpace(string(data)) - if key == "" { - return "", fmt.Errorf("SSH key at %s is empty", realPath) - } - return key, nil -} diff --git a/internal/bootstrap/gcp/bootstrap_suite_test.go b/internal/bootstrap/gcp/bootstrap_suite_test.go new file mode 100644 index 00000000..bc03cf06 --- /dev/null +++ b/internal/bootstrap/gcp/bootstrap_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBootstrap(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Bootstrap Suite") +} diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go new file mode 100644 index 00000000..431ecba0 --- /dev/null +++ b/internal/bootstrap/gcp/gcp.go @@ -0,0 +1,1348 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "cloud.google.com/go/compute/apiv1/computepb" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/lithammer/shortuuid" + "google.golang.org/api/dns/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type RegistryType string + +const ( + RegistryTypeLocalContainer RegistryType = "local-container" + RegistryTypeArtifactRegistry RegistryType = "artifact-registry" +) + +type VMDef struct { + Name string + MachineType string + Tags []string + AdditionalDisks []int64 + ExternalIP bool +} + +// Example VM definitions (expand as needed) +var vmDefs = []VMDef{ + {"jumpbox", "e2-medium", []string{"jumpbox", "ssh"}, []int64{}, true}, + {"postgres", "e2-standard-8", []string{"postgres"}, []int64{}, true}, + {"ceph-1", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, + {"ceph-2", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, + {"ceph-3", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, + {"ceph-4", "e2-standard-8", []string{"ceph"}, []int64{20, 200}, false}, + {"k0s-1", "e2-standard-16", []string{"k0s"}, []int64{}, false}, + {"k0s-2", "e2-standard-16", []string{"k0s"}, []int64{}, false}, + {"k0s-3", "e2-standard-16", []string{"k0s"}, []int64{}, false}, +} + +type GCPBootstrapper struct { + ctx context.Context + stlog *bootstrap.StepLogger + fw util.FileIO + icg installer.InstallConfigManager + NodeManager node.NodeManager + GCPClient GCPClientManager + // Environment + Env *CodesphereEnvironment + // SSH options + sshQuiet bool +} + +type CodesphereEnvironment struct { + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + DNSProjectID string `json:"dns_project_id"` + Jumpbox node.NodeManager `json:"jumpbox"` + PostgreSQLNode node.NodeManager `json:"postgresql_node"` + ControlPlaneNodes []node.NodeManager `json:"control_plane_nodes"` + CephNodes []node.NodeManager `json:"ceph_nodes"` + ContainerRegistryURL string `json:"-"` + ExistingConfigUsed bool `json:"-"` + InstallCodesphereVersion string `json:"install_codesphere_version"` + Preemptible bool `json:"preemptible"` + WriteConfig bool `json:"-"` + GatewayIP string `json:"gateway_ip"` + PublicGatewayIP string `json:"public_gateway_ip"` + RegistryType RegistryType `json:"registry_type"` + + // Config + InstallConfigPath string `json:"-"` + SecretsFilePath string `json:"-"` + InstallConfig *files.RootConfig `json:"-"` + Secrets *files.InstallVault `json:"-"` + + // GCP Specific + ProjectDisplayName string `json:"project_display_name"` + BillingAccount string `json:"billing_account"` + BaseDomain string `json:"base_domain"` + GithubAppClientID string `json:"-"` + GithubAppClientSecret string `json:"-"` + SecretsDir string `json:"secrets_dir"` + FolderID string `json:"folder_id"` + SSHPublicKeyPath string `json:"-"` + SSHPrivateKeyPath string `json:"-"` + DatacenterID int `json:"-"` + CustomPgIP string `json:"custom_pg_ip"` + Region string `json:"region"` + Zone string `json:"zone"` + DNSZoneName string `json:"dns_zone_name"` +} + +func NewGCPBootstrapper(ctx context.Context, env env.Env, stlog *bootstrap.StepLogger, CodesphereEnv *CodesphereEnvironment, icg installer.InstallConfigManager, gcpClient GCPClientManager, nm node.NodeManager, fw util.FileIO) (*GCPBootstrapper, error) { + return &GCPBootstrapper{ + ctx: ctx, + stlog: stlog, + fw: fw, + icg: icg, + GCPClient: gcpClient, + NodeManager: nm, + Env: CodesphereEnv, + sshQuiet: true, + }, nil +} + +func (b *GCPBootstrapper) Bootstrap() error { + err := b.stlog.Step("Ensure install config", b.EnsureInstallConfig) + if err != nil { + return fmt.Errorf("failed to ensure install config: %w", err) + } + + err = b.stlog.Step("Ensure secrets", b.EnsureSecrets) + if err != nil { + return fmt.Errorf("failed to ensure secrets: %w", err) + } + + err = b.stlog.Step("Ensure project", b.EnsureProject) + if err != nil { + return fmt.Errorf("failed to ensure GCP project: %w", err) + } + + err = b.stlog.Step("Ensure billing", b.EnsureBilling) + if err != nil { + return fmt.Errorf("failed to ensure billing is enabled: %w", err) + } + + err = b.stlog.Step("Ensure APIs enabled", b.EnsureAPIsEnabled) + if err != nil { + return fmt.Errorf("failed to enable required APIs: %w", err) + } + + if b.Env.RegistryType == RegistryTypeArtifactRegistry { + err = b.stlog.Step("Ensure artifact registry", b.EnsureArtifactRegistry) + if err != nil { + return fmt.Errorf("failed to ensure artifact registry: %w", err) + } + } + + err = b.stlog.Step("Ensure service accounts", b.EnsureServiceAccounts) + if err != nil { + return fmt.Errorf("failed to ensure service accounts: %w", err) + } + + err = b.stlog.Step("Ensure IAM roles", b.EnsureIAMRoles) + if err != nil { + return fmt.Errorf("failed to ensure IAM roles: %w", err) + } + + err = b.stlog.Step("Ensure VPC", b.EnsureVPC) + if err != nil { + return fmt.Errorf("failed to ensure VPC: %w", err) + } + + err = b.stlog.Step("Ensure firewall rules", b.EnsureFirewallRules) + if err != nil { + return fmt.Errorf("failed to ensure firewall rules: %w", err) + } + + err = b.stlog.Step("Ensure compute instances", b.EnsureComputeInstances) + if err != nil { + return fmt.Errorf("failed to ensure compute instances: %w", err) + } + + err = b.stlog.Step("Ensure gateway IP addresses", b.EnsureGatewayIPAddresses) + if err != nil { + return fmt.Errorf("failed to ensure external IP addresses: %w", err) + } + + err = b.stlog.Step("Ensure root login enabled", b.EnsureRootLoginEnabled) + if err != nil { + return fmt.Errorf("failed to ensure root login is enabled: %w", err) + } + + err = b.stlog.Step("Ensure jumpbox configured", b.EnsureJumpboxConfigured) + if err != nil { + return fmt.Errorf("failed to ensure jumpbox is configured: %w", err) + } + + err = b.stlog.Step("Ensure hosts are configured", b.EnsureHostsConfigured) + if err != nil { + return fmt.Errorf("failed to ensure hosts are configured: %w", err) + } + + if b.Env.RegistryType == RegistryTypeLocalContainer { + err = b.stlog.Step("Ensure local container registry", b.EnsureLocalContainerRegistry) + if err != nil { + return fmt.Errorf("failed to ensure local container registry: %w", err) + } + } + + if b.Env.WriteConfig { + err = b.stlog.Step("Update install config", b.UpdateInstallConfig) + if err != nil { + return fmt.Errorf("failed to update install config: %w", err) + } + + err = b.stlog.Step("Ensure age key", b.EnsureAgeKey) + if err != nil { + return fmt.Errorf("failed to ensure age key: %w", err) + } + + err = b.stlog.Step("Encrypt vault", b.EncryptVault) + if err != nil { + return fmt.Errorf("failed to encrypt vault: %w", err) + } + } + + err = b.stlog.Step("Ensure DNS records", b.EnsureDNSRecords) + if err != nil { + return fmt.Errorf("failed to ensure DNS records: %w", err) + } + + if b.Env.InstallCodesphereVersion != "" { + err = b.stlog.Step("Install Codesphere", b.InstallCodesphere) + if err != nil { + return fmt.Errorf("failed to install Codesphere: %w", err) + } + } + + err = b.stlog.Step("Generate k0s config script", b.GenerateK0sConfigScript) + if err != nil { + return fmt.Errorf("failed to generate k0s config script: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureInstallConfig() error { + if b.fw.Exists(b.Env.InstallConfigPath) { + err := b.icg.LoadInstallConfigFromFile(b.Env.InstallConfigPath) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } + + b.Env.ExistingConfigUsed = true + } else { + err := b.icg.ApplyProfile("dev") + if err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + } + + b.Env.InstallConfig = b.icg.GetInstallConfig() + + return nil +} + +func (b *GCPBootstrapper) EnsureSecrets() error { + if b.fw.Exists(b.Env.SecretsFilePath) { + err := b.icg.LoadVaultFromFile(b.Env.SecretsFilePath) + if err != nil { + return fmt.Errorf("failed to load vault file: %w", err) + } + err = b.icg.MergeVaultIntoConfig() + if err != nil { + return fmt.Errorf("failed to merge vault into config: %w", err) + } + } + + b.Env.Secrets = b.icg.GetVault() + + return nil +} + +func (b *GCPBootstrapper) EnsureProject() error { + parent := "" + if b.Env.FolderID != "" { + parent = fmt.Sprintf("folders/%s", b.Env.FolderID) + } + + existingProject, err := b.GCPClient.GetProjectByName(b.Env.FolderID, b.Env.ProjectName) + if err == nil { + b.Env.ProjectID = existingProject.ProjectId + b.Env.ProjectName = existingProject.Name + return nil + } + if err.Error() == fmt.Sprintf("project not found: %s", b.Env.ProjectName) { + projectId := b.GCPClient.CreateProjectID(b.Env.ProjectName) + _, err = b.GCPClient.CreateProject(parent, projectId, b.Env.ProjectName) + if err != nil { + return fmt.Errorf("failed to create project: %w", err) + } + + b.Env.ProjectID = projectId + return nil + } + + return fmt.Errorf("failed to get project: %w", err) +} + +func (b *GCPBootstrapper) EnsureBilling() error { + bi, err := b.GCPClient.GetBillingInfo(b.Env.ProjectID) + if err != nil { + return fmt.Errorf("failed to get billing info: %w", err) + } + if bi.BillingEnabled && bi.BillingAccountName == b.Env.BillingAccount { + return nil + } + + err = b.GCPClient.EnableBilling(b.Env.ProjectID, b.Env.BillingAccount) + if err != nil { + return fmt.Errorf("failed to enable billing: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureAPIsEnabled() error { + apis := []string{ + "compute.googleapis.com", + "serviceusage.googleapis.com", + "artifactregistry.googleapis.com", + "dns.googleapis.com", + } + + err := b.GCPClient.EnableAPIs(b.Env.ProjectID, apis) + if err != nil { + return fmt.Errorf("failed to enable APIs: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureArtifactRegistry() error { + repoName := "codesphere-registry" + + repo, err := b.GCPClient.GetArtifactRegistry(b.Env.ProjectID, b.Env.Region, repoName) + if err == nil && repo != nil { + b.Env.InstallConfig.Registry.Server = repo.GetRegistryUri() + return nil + } + + repo, err = b.GCPClient.CreateArtifactRegistry(b.Env.ProjectID, b.Env.Region, repoName) + if err != nil || repo == nil { + return fmt.Errorf("failed to create artifact registry: %w, repo: %v", err, repo) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureServiceAccounts() error { + _, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, "cloud-controller", "cloud-controller") + if err != nil { + return err + } + + if b.Env.RegistryType == RegistryTypeArtifactRegistry { + sa, newSa, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, "artifact-registry-writer", "artifact-registry-writer") + if err != nil { + return err + } + + if !newSa && b.Env.InstallConfig.Registry.Password != "" { + return nil + } + + for retries := range 5 { + privateKey, err := b.GCPClient.CreateServiceAccountKey(b.Env.ProjectID, sa) + + if err != nil && status.Code(err) != codes.AlreadyExists { + if retries > 3 { + return fmt.Errorf("failed to create service account key: %w", err) + } + b.stlog.LogRetry() + time.Sleep(5 * time.Second) + continue + } + + b.Env.InstallConfig.Registry.Password = string(privateKey) + b.Env.InstallConfig.Registry.Username = "_json_key_base64" + + break + } + } + + return nil +} + +func (b *GCPBootstrapper) EnsureIAMRoles() error { + err := b.GCPClient.AssignIAMRole(b.Env.ProjectID, "cloud-controller", "roles/compute.admin") + if err != nil { + return err + } + + if b.Env.RegistryType != RegistryTypeArtifactRegistry { + return nil + } + + err = b.GCPClient.AssignIAMRole(b.Env.ProjectID, "artifact-registry-writer", "roles/artifactregistry.writer") + return err +} + +func (b *GCPBootstrapper) EnsureVPC() error { + networkName := fmt.Sprintf("%s-vpc", b.Env.ProjectID) + subnetName := fmt.Sprintf("%s-%s-subnet", b.Env.ProjectID, b.Env.Region) + routerName := fmt.Sprintf("%s-router", b.Env.ProjectID) + natName := fmt.Sprintf("%s-nat-gateway", b.Env.ProjectID) + + // Create VPC + err := b.GCPClient.CreateVPC(b.Env.ProjectID, b.Env.Region, networkName, subnetName, routerName, natName) + if err != nil { + return fmt.Errorf("failed to ensure VPC: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureFirewallRules() error { + networkName := fmt.Sprintf("%s-vpc", b.Env.ProjectID) + + // Allow external SSH to Jumpbox + sshRule := &computepb.Firewall{ + Name: protoString("allow-ssh-ext"), + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.Env.ProjectID, networkName)), + Direction: protoString("INGRESS"), + Priority: protoInt32(1000), + Allowed: []*computepb.Allowed{ + { + IPProtocol: protoString("tcp"), + Ports: []string{"22"}, + }, + }, + SourceRanges: []string{"0.0.0.0/0"}, + TargetTags: []string{"ssh"}, + Description: protoString("Allow external SSH to Jumpbox"), + } + err := b.GCPClient.CreateFirewallRule(b.Env.ProjectID, sshRule) + if err != nil { + return fmt.Errorf("failed to create jumpbox ssh firewall rule: %w", err) + } + + // Allow all internal traffic + internalRule := &computepb.Firewall{ + Name: protoString("allow-internal"), + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.Env.ProjectID, networkName)), + Direction: protoString("INGRESS"), + Priority: protoInt32(1000), + Allowed: []*computepb.Allowed{ + {IPProtocol: protoString("all")}, + }, + SourceRanges: []string{"10.10.0.0/20"}, + Description: protoString("Allow all internal traffic"), + } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, internalRule) + if err != nil { + return fmt.Errorf("failed to create internal firewall rule: %w", err) + } + + // Allow all egress + egressRule := &computepb.Firewall{ + Name: protoString("allow-all-egress"), + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.Env.ProjectID, networkName)), + Direction: protoString("EGRESS"), + Priority: protoInt32(1000), + Allowed: []*computepb.Allowed{ + {IPProtocol: protoString("all")}, + }, + DestinationRanges: []string{"0.0.0.0/0"}, + Description: protoString("Allow all egress"), + } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, egressRule) + if err != nil { + return fmt.Errorf("failed to create egress firewall rule: %w", err) + } + + // Allow ingress for web (HTTP/HTTPS) + webRule := &computepb.Firewall{ + Name: protoString("allow-ingress-web"), + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.Env.ProjectID, networkName)), + Direction: protoString("INGRESS"), + Priority: protoInt32(1000), + Allowed: []*computepb.Allowed{ + {IPProtocol: protoString("tcp"), Ports: []string{"80", "443"}}, + }, + SourceRanges: []string{"0.0.0.0/0"}, + Description: protoString("Allow HTTP/HTTPS ingress"), + } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, webRule) + if err != nil { + return fmt.Errorf("failed to create web firewall rule: %w", err) + } + + // Allow ingress for PostgreSQL + postgresRule := &computepb.Firewall{ + Name: protoString("allow-ingress-postgres"), + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", b.Env.ProjectID, networkName)), + Direction: protoString("INGRESS"), + Priority: protoInt32(1000), + Allowed: []*computepb.Allowed{ + {IPProtocol: protoString("tcp"), Ports: []string{"5432"}}, + }, + SourceRanges: []string{"0.0.0.0/0"}, + TargetTags: []string{"postgres"}, + Description: protoString("Allow external access to PostgreSQL"), + } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, postgresRule) + if err != nil { + return fmt.Errorf("failed to create postgres firewall rule: %w", err) + } + + return nil +} + +type vmResult struct { + vmType string // jumpbox, postgres, ceph, k0s + name string + externalIP string + internalIP string +} + +func (b *GCPBootstrapper) EnsureComputeInstances() error { + projectID := b.Env.ProjectID + region := b.Env.Region + zone := b.Env.Zone + + network := fmt.Sprintf("projects/%s/global/networks/%s-vpc", projectID, projectID) + subnetwork := fmt.Sprintf("projects/%s/regions/%s/subnetworks/%s-%s-subnet", projectID, region, projectID, region) + diskType := fmt.Sprintf("projects/%s/zones/%s/diskTypes/pd-ssd", projectID, zone) + + // Create VMs in parallel + wg := sync.WaitGroup{} + errCh := make(chan error, len(vmDefs)) + resultCh := make(chan vmResult, len(vmDefs)) + for _, vm := range vmDefs { + wg.Add(1) + go func(vm VMDef) { + defer wg.Done() + disks := []*computepb.AttachedDisk{ + { + Boot: protoBool(true), + AutoDelete: protoBool(true), + Type: protoString("PERSISTENT"), + InitializeParams: &computepb.AttachedDiskInitializeParams{ + DiskType: &diskType, + DiskSizeGb: protoInt64(200), + SourceImage: protoString("projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts"), + }, + }, + } + for _, diskSize := range vm.AdditionalDisks { + disks = append(disks, &computepb.AttachedDisk{ + Boot: protoBool(false), + AutoDelete: protoBool(true), + Type: protoString("PERSISTENT"), + InitializeParams: &computepb.AttachedDiskInitializeParams{ + DiskSizeGb: protoInt64(diskSize), + DiskType: &diskType, + }, + }) + } + + pubKey, err := b.readSSHKey(b.Env.SSHPublicKeyPath) + if err != nil { + errCh <- fmt.Errorf("failed to read SSH public key: %w", err) + return + } + + serviceAccount := fmt.Sprintf("cloud-controller@%s.iam.gserviceaccount.com", projectID) + instance := &computepb.Instance{ + Name: protoString(vm.Name), + ServiceAccounts: []*computepb.ServiceAccount{ + { + Email: protoString(serviceAccount), + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + }, + }, + MachineType: protoString(fmt.Sprintf("zones/%s/machineTypes/%s", zone, vm.MachineType)), + Tags: &computepb.Tags{ + Items: vm.Tags, + }, + Scheduling: &computepb.Scheduling{ + Preemptible: &b.Env.Preemptible, + }, + NetworkInterfaces: []*computepb.NetworkInterface{ + { + Network: protoString(network), + Subnetwork: protoString(subnetwork), + }, + }, + Disks: disks, + Metadata: &computepb.Metadata{ + Items: []*computepb.Items{ + { + Key: protoString("ssh-keys"), + Value: protoString(fmt.Sprintf("root:%s\nubuntu:%s", pubKey+"root", pubKey+"ubuntu")), + }, + }, + }, + } + + // Configure external IP if needed + if vm.ExternalIP { + instance.NetworkInterfaces[0].AccessConfigs = []*computepb.AccessConfig{ + { + Name: protoString("External NAT"), + Type: protoString("ONE_TO_ONE_NAT"), + }, + } + } + + err = b.GCPClient.CreateInstance(projectID, zone, instance) + if err != nil && !isAlreadyExistsError(err) { + errCh <- fmt.Errorf("failed to create instance %s: %w", vm.Name, err) + return + } + + // Find out the IP addresses of the created instance + resp, err := b.GCPClient.GetInstance(projectID, zone, vm.Name) + if err != nil { + errCh <- fmt.Errorf("failed to get instance %s: %w", vm.Name, err) + return + } + + externalIP := "" + internalIP := "" + if len(resp.GetNetworkInterfaces()) > 0 { + internalIP = resp.GetNetworkInterfaces()[0].GetNetworkIP() + if len(resp.GetNetworkInterfaces()[0].GetAccessConfigs()) > 0 { + externalIP = resp.GetNetworkInterfaces()[0].GetAccessConfigs()[0].GetNatIP() + } + } + + // Send result through channel instead of creating nodes in goroutine + resultCh <- vmResult{ + vmType: vm.Tags[0], + name: vm.Name, + externalIP: externalIP, + internalIP: internalIP, + } + }(vm) + } + wg.Wait() + + close(errCh) + close(resultCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + if len(errs) > 0 { + return fmt.Errorf("error ensuring compute instances: %w", errors.Join(errs...)) + } + + // Create nodes from results (in main goroutine, not in spawned goroutines) + for result := range resultCh { + switch result.vmType { + case "jumpbox": + b.NodeManager.UpdateNode(result.name, result.externalIP, result.internalIP) + b.Env.Jumpbox = b.NodeManager + case "postgres": + b.Env.PostgreSQLNode = b.NodeManager.CreateSubNode(result.name, result.externalIP, result.internalIP) + case "ceph": + node := b.NodeManager.CreateSubNode(result.name, result.externalIP, result.internalIP) + b.Env.CephNodes = append(b.Env.CephNodes, node) + case "k0s": + node := b.NodeManager.CreateSubNode(result.name, result.externalIP, result.internalIP) + b.Env.ControlPlaneNodes = append(b.Env.ControlPlaneNodes, node) + } + } + + //sort ceph nodes by name to ensure consistent ordering + sort.Slice(b.Env.CephNodes, func(i, j int) bool { + return b.Env.CephNodes[i].GetName() < b.Env.CephNodes[j].GetName() + }) + //sort control plane nodes by name to ensure consistent ordering + sort.Slice(b.Env.ControlPlaneNodes, func(i, j int) bool { + return b.Env.ControlPlaneNodes[i].GetName() < b.Env.ControlPlaneNodes[j].GetName() + }) + + return nil +} + +// EnsureGatewayIPAddresses reserves 2 static external IP addresses for the ingress +// controllers of the cluster. +func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { + var err error + b.Env.GatewayIP, err = b.EnsureExternalIP("gateway") + if err != nil { + return fmt.Errorf("failed to ensure gateway IP: %w", err) + } + b.Env.PublicGatewayIP, err = b.EnsureExternalIP("public-gateway") + if err != nil { + return fmt.Errorf("failed to ensure public gateway IP: %w", err) + } + return nil +} + +// EnsureExternalIP ensures that a static external IP address with the given name exists. +func (b *GCPBootstrapper) EnsureExternalIP(name string) (string, error) { + desiredAddress := &computepb.Address{ + Name: &name, + AddressType: protoString("EXTERNAL"), + Region: &b.Env.Region, + } + + // Figure out if address already exists and get IP + address, err := b.GCPClient.GetAddress(b.Env.ProjectID, b.Env.Region, name) + if err == nil && address != nil { + return address.GetAddress(), nil + } + + createdIP, err := b.GCPClient.CreateAddress(b.Env.ProjectID, b.Env.Region, desiredAddress) + if err != nil && !isAlreadyExistsError(err) { + return "", fmt.Errorf("failed to create address %s: %w", name, err) + } + + if createdIP != "" { + return createdIP, nil + } + + address, err = b.GCPClient.GetAddress(b.Env.ProjectID, b.Env.Region, name) + + if err == nil && address != nil { + return address.GetAddress(), nil + } + return "", fmt.Errorf("failed to get address %s after creation", name) +} + +func (b *GCPBootstrapper) EnsureRootLoginEnabled() error { + allNodes := []node.NodeManager{ + b.Env.Jumpbox, + } + allNodes = append(allNodes, b.Env.ControlPlaneNodes...) + allNodes = append(allNodes, b.Env.PostgreSQLNode) + allNodes = append(allNodes, b.Env.CephNodes...) + + for _, node := range allNodes { + err := b.stlog.Substep(fmt.Sprintf("Ensuring root login enabled on %s", node.GetName()), func() error { + return b.ensureRootLoginEnabledInNode(node) + }) + if err != nil { + return err + } + } + + return nil +} + +func (b *GCPBootstrapper) ensureRootLoginEnabledInNode(node node.NodeManager) error { + err := node.WaitForSSH(30 * time.Second) + if err != nil { + return fmt.Errorf("timed out waiting for SSH service to start on %s: %w", node.GetName(), err) + } + + hasRootLogin := node.HasRootLoginEnabled() + if hasRootLogin { + return nil + } + + for i := range 3 { + err := node.EnableRootLogin() + if err == nil { + break + } + if i == 2 { + return fmt.Errorf("failed to enable root login on %s: %w", node.GetName(), err) + } + b.stlog.LogRetry() + time.Sleep(10 * time.Second) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureJumpboxConfigured() error { + if !b.Env.Jumpbox.HasAcceptEnvConfigured() { + err := b.Env.Jumpbox.ConfigureAcceptEnv() + if err != nil { + return fmt.Errorf("failed to configure AcceptEnv on jumpbox: %w", err) + } + } + + hasOms := b.Env.Jumpbox.HasCommand("oms-cli") + if hasOms { + return nil + } + + err := b.Env.Jumpbox.InstallOms() + if err != nil { + return fmt.Errorf("failed to install OMS on jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureHostsConfigured() error { + allNodes := append(b.Env.ControlPlaneNodes, b.Env.PostgreSQLNode) + allNodes = append(allNodes, b.Env.CephNodes...) + + for _, node := range allNodes { + if !node.HasInotifyWatchesConfigured() { + err := node.ConfigureInotifyWatches() + if err != nil { + return fmt.Errorf("failed to configure inotify watches on %s: %w", node.GetName(), err) + } + } + if !node.HasMemoryMapConfigured() { + err := node.ConfigureMemoryMap() + if err != nil { + return fmt.Errorf("failed to configure memory map on %s: %w", node.GetName(), err) + } + } + } + + return nil +} + +// EnsureLocalContainerRegistry installs a docker registry on the postgres node to speed up image loading time +func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { + localRegistryServer := b.Env.PostgreSQLNode.GetInternalIP() + ":5000" + + // Figure out if registry is already running + b.stlog.Logf("Checking if local container registry is already running on postgres node") + checkCommand := `test "$(podman ps --filter 'name=registry' --format '{{.Names}}' | wc -l)" -eq "1"` + err := b.Env.PostgreSQLNode.RunSSHCommand("root", checkCommand, b.sshQuiet) + if err == nil && b.Env.InstallConfig.Registry != nil && b.Env.InstallConfig.Registry.Server == localRegistryServer && + b.Env.InstallConfig.Registry.Username != "" && b.Env.InstallConfig.Registry.Password != "" { + b.stlog.Logf("Local container registry already running on postgres node") + return nil + } + + b.Env.InstallConfig.Registry.Server = localRegistryServer + b.Env.InstallConfig.Registry.Username = "custom-registry" + b.Env.InstallConfig.Registry.Password = shortuuid.New() + + commands := []string{ + "apt-get update", + "apt-get install -y podman apache2-utils", + "htpasswd -bBc /root/registry.password " + b.Env.InstallConfig.Registry.Username + " " + b.Env.InstallConfig.Registry.Password, + "openssl req -newkey rsa:4096 -nodes -sha256 -keyout /root/registry.key -x509 -days 365 -out /root/registry.crt -subj \"/C=DE/ST=BW/L=Karlsruhe/O=Codesphere/CN=" + b.Env.PostgreSQLNode.GetInternalIP() + "\" -addext \"subjectAltName = DNS:postgres,IP:" + b.Env.PostgreSQLNode.GetInternalIP() + "\"", + "podman rm -f registry || true", + `podman run -d \ + --restart=always --name registry --net=host\ + --env REGISTRY_HTTP_ADDR=0.0.0.0:5000 \ + --env REGISTRY_AUTH=htpasswd \ + --env REGISTRY_AUTH_HTPASSWD_REALM='Registry Realm' \ + --env REGISTRY_AUTH_HTPASSWD_PATH=/auth/registry.password \ + -v /root/registry.password:/auth/registry.password \ + --env REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \ + --env REGISTRY_HTTP_TLS_KEY=/certs/registry.key \ + -v /root/registry.crt:/certs/registry.crt \ + -v /root/registry.key:/certs/registry.key \ + registry:2`, + `mkdir -p /etc/docker/certs.d/` + b.Env.InstallConfig.Registry.Server, + `cp /root/registry.crt /etc/docker/certs.d/` + b.Env.InstallConfig.Registry.Server + `/ca.crt`, + } + for _, cmd := range commands { + b.stlog.Logf("Running command on postgres node: %s", util.Truncate(cmd, 12)) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", cmd, b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to run command on postgres node: %w", err) + } + } + + allNodes := append(b.Env.ControlPlaneNodes, b.Env.CephNodes...) + for _, node := range allNodes { + b.stlog.Logf("Configuring node '%s' to trust local registry certificate", node.GetName()) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", "scp -o StrictHostKeyChecking=no /root/registry.crt root@"+node.GetInternalIP()+":/usr/local/share/ca-certificates/registry.crt", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to copy registry certificate to node %s: %w", node.GetInternalIP(), err) + } + err = node.RunSSHCommand("root", "update-ca-certificates", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to update CA certificates on node %s: %w", node.GetInternalIP(), err) + } + err = node.RunSSHCommand("root", "systemctl restart docker.service || true", true) // docker is probably not yet installed + if err != nil { + return fmt.Errorf("failed to restart docker service on node %s: %w", node.GetInternalIP(), err) + } + } + + return nil +} + +func (b *GCPBootstrapper) UpdateInstallConfig() error { + // Update install config with necessary values + b.Env.InstallConfig.Datacenter.ID = b.Env.DatacenterID + b.Env.InstallConfig.Datacenter.City = "Karlsruhe" + b.Env.InstallConfig.Datacenter.CountryCode = "DE" + b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir + b.Env.InstallConfig.Registry.ReplaceImagesInBom = true + b.Env.InstallConfig.Registry.LoadContainerImages = true + + if b.Env.InstallConfig.Postgres.Primary == nil { + b.Env.InstallConfig.Postgres.Primary = &files.PostgresPrimaryConfig{ + Hostname: b.Env.PostgreSQLNode.GetName(), + } + } + b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() + + b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" + b.Env.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" + b.Env.InstallConfig.Ceph.Hosts = []files.CephHost{ + { + Hostname: b.Env.CephNodes[0].GetName(), + IsMaster: true, + IPAddress: b.Env.CephNodes[0].GetInternalIP(), + }, + { + Hostname: b.Env.CephNodes[1].GetName(), + IPAddress: b.Env.CephNodes[1].GetInternalIP(), + }, + { + Hostname: b.Env.CephNodes[2].GetName(), + IPAddress: b.Env.CephNodes[2].GetInternalIP(), + }, + { + Hostname: b.Env.CephNodes[3].GetName(), + IPAddress: b.Env.CephNodes[3].GetInternalIP(), + }, + } + b.Env.InstallConfig.Ceph.OSDs = []files.CephOSD{ + { + SpecID: "default", + Placement: files.CephPlacement{ + HostPattern: "*", + }, + DataDevices: files.CephDataDevices{ + Size: "100G:", + Limit: 1, + }, + DBDevices: files.CephDBDevices{ + Size: "10G:500G", + Limit: 1, + }, + }, + } + + b.Env.InstallConfig.Kubernetes = files.KubernetesConfig{ + ManagedByCodesphere: true, + APIServerHost: b.Env.ControlPlaneNodes[0].GetInternalIP(), + ControlPlanes: []files.K8sNode{ + { + IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + }, + }, + Workers: []files.K8sNode{ + { + IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + }, + + { + IPAddress: b.Env.ControlPlaneNodes[1].GetInternalIP(), + }, + { + IPAddress: b.Env.ControlPlaneNodes[2].GetInternalIP(), + }, + }, + } + b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{ + Prometheus: &files.PrometheusConfig{ + RemoteWrite: &files.RemoteWriteConfig{ + Enabled: false, + ClusterName: "GCP-test", + }, + }, + } + b.Env.InstallConfig.Cluster.Gateway = files.GatewayConfig{ + ServiceType: "LoadBalancer", + //IPAddresses: []string{b.Env.ControlPlaneNodes[0].ExternalIP}, + Annotations: map[string]string{ + "cloud.google.com/load-balancer-ipv4": b.Env.GatewayIP, + }, + } + b.Env.InstallConfig.Cluster.PublicGateway = files.GatewayConfig{ + ServiceType: "LoadBalancer", + Annotations: map[string]string{ + "cloud.google.com/load-balancer-ipv4": b.Env.PublicGatewayIP, + }, + //IPAddresses: []string{b.Env.ControlPlaneNodes[1].ExternalIP}, + } + + b.Env.InstallConfig.Codesphere.Domain = "cs." + b.Env.BaseDomain + b.Env.InstallConfig.Codesphere.WorkspaceHostingBaseDomain = "ws." + b.Env.BaseDomain + b.Env.InstallConfig.Codesphere.PublicIP = b.Env.ControlPlaneNodes[1].GetExternalIP() + b.Env.InstallConfig.Codesphere.CustomDomains = files.CustomDomainsConfig{ + CNameBaseDomain: "ws." + b.Env.BaseDomain, + } + b.Env.InstallConfig.Codesphere.DNSServers = []string{"8.8.8.8"} + b.Env.InstallConfig.Codesphere.Experiments = []string{} + b.Env.InstallConfig.Codesphere.DeployConfig = files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "workspace-agent-24.04", + }, + Pool: map[int]int{ + 1: 1, + 2: 1, + 3: 0, + 4: 0, + }, + }, + }, + }, + }, + } + b.Env.InstallConfig.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + 2: { + CPUTenth: 20, + GPUParts: 0, + MemoryMb: 4096, + StorageMb: 20480, + TempStorageMb: 1024, + }, + 3: { + CPUTenth: 40, + GPUParts: 0, + MemoryMb: 8192, + StorageMb: 40960, + TempStorageMb: 1024, + }, + 4: { + CPUTenth: 80, + GPUParts: 0, + MemoryMb: 16384, + StorageMb: 40960, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Micro", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + 2: { + Name: "Standard", + HostingPlanID: 2, + MaxReplicas: 3, + OnDemand: true, + }, + 3: { + Name: "Big", + HostingPlanID: 3, + MaxReplicas: 3, + OnDemand: true, + }, + 4: { + Name: "Pro", + HostingPlanID: 4, + MaxReplicas: 3, + OnDemand: true, + }, + }, + } + b.Env.InstallConfig.Codesphere.GitProviders = &files.GitProvidersConfig{ + GitHub: &files.GitProviderConfig{ + Enabled: true, + URL: "https://github.com", + API: files.APIConfig{ + BaseURL: "https://api.github.com", + }, + OAuth: files.OAuthConfig{ + Issuer: "https://github.com", + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + + ClientID: b.Env.GithubAppClientID, + ClientSecret: b.Env.GithubAppClientSecret, + }, + }, + } + b.Env.InstallConfig.Codesphere.ManagedServices = []files.ManagedServiceConfig{} + + if !b.Env.ExistingConfigUsed { + err := b.icg.GenerateSecrets() + if err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + } else { + var err error + b.Env.InstallConfig.Postgres.Primary.PrivateKey, b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( + b.Env.InstallConfig.Postgres.CaCertPrivateKey, + b.Env.InstallConfig.Postgres.CACertPem, + b.Env.InstallConfig.Postgres.Primary.Hostname, + []string{b.Env.InstallConfig.Postgres.Primary.IP}) + if err != nil { + return fmt.Errorf("failed to generate primary server certificate: %w", err) + } + if b.Env.InstallConfig.Postgres.Replica != nil { + b.Env.InstallConfig.Postgres.ReplicaPrivateKey, b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( + b.Env.InstallConfig.Postgres.CaCertPrivateKey, + b.Env.InstallConfig.Postgres.CACertPem, + b.Env.InstallConfig.Postgres.Replica.Name, + []string{b.Env.InstallConfig.Postgres.Replica.IP}) + if err != nil { + return fmt.Errorf("failed to generate replica server certificate: %w", err) + } + } + } + + if err := b.icg.WriteInstallConfig(b.Env.InstallConfigPath, true); err != nil { + return fmt.Errorf("failed to write config file: %w", 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.CopyFile(b.Env.InstallConfigPath, "/etc/codesphere/config.yaml") + if err != nil { + return fmt.Errorf("failed to copy install config to jumpbox: %w", err) + } + + err = b.Env.Jumpbox.CopyFile(b.Env.SecretsFilePath, b.Env.SecretsDir+"/prod.vault.yaml") + if err != nil { + return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) + } + return nil +} + +func (b *GCPBootstrapper) EnsureAgeKey() error { + hasKey := b.Env.Jumpbox.HasFile(b.Env.SecretsDir + "/age_key.txt") + if hasKey { + return nil + } + + err := b.Env.Jumpbox.RunSSHCommand("root", fmt.Sprintf("mkdir -p %s; age-keygen -o %s/age_key.txt", b.Env.SecretsDir, b.Env.SecretsDir), b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to generate age key on jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EncryptVault() error { + err := b.Env.Jumpbox.RunSSHCommand("root", "cp "+b.Env.SecretsDir+"/prod.vault.yaml{,.bak}", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed backup vault on jumpbox: %w", err) + } + + err = b.Env.Jumpbox.RunSSHCommand("root", "sops --encrypt --in-place --age $(age-keygen -y "+b.Env.SecretsDir+"/age_key.txt) "+b.Env.SecretsDir+"/prod.vault.yaml", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to encrypt vault on jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureDNSRecords() error { + gcpProject := b.Env.DNSProjectID + if b.Env.DNSProjectID == "" { + gcpProject = b.Env.ProjectID + } + + zoneName := b.Env.DNSZoneName + err := b.GCPClient.EnsureDNSManagedZone(gcpProject, zoneName, b.Env.BaseDomain+".", "Codesphere DNS zone") + if err != nil { + return fmt.Errorf("failed to ensure DNS managed zone: %w", err) + } + + records := []*dns.ResourceRecordSet{ + { + Name: fmt.Sprintf("cs.%s.", b.Env.BaseDomain), + Type: "A", + Ttl: 300, + Rrdatas: []string{b.Env.GatewayIP}, + }, + { + Name: fmt.Sprintf("*.cs.%s.", b.Env.BaseDomain), + Type: "A", + Ttl: 300, + Rrdatas: []string{b.Env.GatewayIP}, + }, + { + Name: fmt.Sprintf("*.ws.%s.", b.Env.BaseDomain), + Type: "A", + Ttl: 300, + Rrdatas: []string{b.Env.PublicGatewayIP}, + }, + { + Name: fmt.Sprintf("ws.%s.", b.Env.BaseDomain), + Type: "A", + Ttl: 300, + Rrdatas: []string{b.Env.PublicGatewayIP}, + }, + } + + err = b.GCPClient.EnsureDNSRecordSets(gcpProject, zoneName, records) + if err != nil { + return fmt.Errorf("failed to ensure DNS record sets: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) InstallCodesphere() error { + err := b.Env.Jumpbox.RunSSHCommand("root", "oms-cli download package "+b.Env.InstallCodesphereVersion, b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) + } + + err = b.Env.Jumpbox.RunSSHCommand("root", "oms-cli install codesphere -c /etc/codesphere/config.yaml -k "+b.Env.SecretsDir+"/age_key.txt -p "+b.Env.InstallCodesphereVersion+".tar.gz", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) GenerateK0sConfigScript() error { + script := `#!/bin/bash + +cat < cloud.conf +[Global] +project-id = "$PROJECT_ID" +EOF + +cat <> cc-deployment.yaml +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: cloud-controller-manager + namespace: kube-system + labels: + component: cloud-controller-manager +spec: + selector: + matchLabels: + component: cloud-controller-manager + template: + metadata: + labels: + component: cloud-controller-manager + spec: + serviceAccountName: cloud-controller-manager + containers: + - name: cloud-controller-manager + image: k8scloudprovidergcp/cloud-controller-manager:latest + command: + - /usr/local/bin/cloud-controller-manager + args: + - --v=5 + - --cloud-provider=gce + - --cloud-config=/etc/gce/cloud.conf + - --leader-elect-resource-name=k0s-gcp-ccm + - --use-service-account-credentials=true + - --controllers=cloud-node,cloud-node-lifecycle,service + - --allocate-node-cidrs=false + - --configure-cloud-routes=false + volumeMounts: + - name: cloud-config-volume + mountPath: /etc/gce + readOnly: true + volumes: + - name: cloud-config-volume + configMap: + name: cloud-config + tolerations: + - key: node.cloudprovider.kubernetes.io/uninitialized + value: "true" + effect: NoSchedule + - key: node-role.kubernetes.io/master + effect: NoSchedule + - key: node-role.kubernetes.io/control-plane + effect: NoSchedule +EOF + +KUBECTL="/etc/codesphere/deps/kubernetes/files/k0s kubectl" +$KUBECTL create configmap cloud-config --from-file=cloud.conf -n kube-system +echo alias kubectl=\"$KUBECTL\" >> /root/.bashrc +echo alias k=\"$KUBECTL\" >> /root/.bashrc + +$KUBECTL apply -f https://raw.githubusercontent.com/kubernetes/cloud-provider-gcp/refs/tags/providers/v0.28.2/deploy/packages/default/manifest.yaml + +$KUBECTL apply -f cc-deployment.yaml + +# set loadBalancerIP for public-gateway-controller and gateway-controller +$KUBECTL patch svc public-gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.Env.PublicGatewayIP + `'"}}' +$KUBECTL patch svc gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.Env.GatewayIP + `'"}}' + +sed -i 's/k0scontroller/k0scontroller --enable-cloud-provider/g' /etc/systemd/system/k0scontroller.service + +ssh root@` + b.Env.ControlPlaneNodes[1].GetInternalIP() + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" + +ssh root@` + b.Env.ControlPlaneNodes[2].GetInternalIP() + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" + +systemctl daemon-reload +systemctl restart k0scontroller +` + // Probably we need to enable the cloud provider plugin in k0s configuration. + // --enable-cloud-provider on worker nodes systemd file /etc/systemd/system/k0sworker.service + // in addition on the first node: /etc/systemd/system/k0scontroller.service the flag --enable-cloud-provider + + err := b.fw.WriteFile("configure-k0s.sh", []byte(script), 0755) + if err != nil { + return fmt.Errorf("failed to write configure-k0s.sh: %w", err) + } + err = b.Env.ControlPlaneNodes[0].CopyFile("configure-k0s.sh", "/root/configure-k0s.sh") + if err != nil { + return fmt.Errorf("failed to copy configure-k0s.sh to control plane node: %w", err) + } + err = b.Env.ControlPlaneNodes[0].RunSSHCommand("root", "chmod +x /root/configure-k0s.sh", b.sshQuiet) + if err != nil { + return fmt.Errorf("failed to make configure-k0s.sh executable on control plane node: %w", err) + } + return nil +} + +// Helper functions +func isAlreadyExistsError(err error) bool { + return status.Code(err) == codes.AlreadyExists || strings.Contains(err.Error(), "already exists") +} + +// readSSHKey reads an SSH key file, expanding ~ in the path +func (b *GCPBootstrapper) readSSHKey(path string) (string, error) { + realPath := util.ExpandPath(path) + data, err := b.fw.ReadFile(realPath) + if err != nil { + return "", fmt.Errorf("error reading SSH key from %s: %w", realPath, err) + } + key := strings.TrimSpace(string(data)) + if key == "" { + return "", fmt.Errorf("SSH key at %s is empty", realPath) + } + return key, nil +} diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go new file mode 100644 index 00000000..320bbd9a --- /dev/null +++ b/internal/bootstrap/gcp/gcp_client.go @@ -0,0 +1,649 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "context" + "fmt" + "strings" + "sync" + + "slices" + + artifact "cloud.google.com/go/artifactregistry/apiv1" + artifactpb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "cloud.google.com/go/iam/apiv1/iampb" + resourcemanager "cloud.google.com/go/resourcemanager/apiv3" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + serviceusage "cloud.google.com/go/serviceusage/apiv1" + "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/lithammer/shortuuid" + "google.golang.org/api/cloudbilling/v1" + "google.golang.org/api/dns/v1" + "google.golang.org/api/iam/v1" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Interface for high-level GCP operations +type GCPClientManager interface { + GetProjectByName(folderID string, displayName string) (*resourcemanagerpb.Project, error) + CreateProjectID(projectName string) string + CreateProject(parent, projectName, displayName string) (string, error) + GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) + EnableBilling(projectID, billingAccount string) error + EnableAPIs(projectID string, apis []string) error + GetArtifactRegistry(projectID, region, repoName string) (*artifactpb.Repository, error) + CreateArtifactRegistry(projectID, region, repoName string) (*artifactpb.Repository, error) + CreateServiceAccount(projectID, name, displayName string) (string, bool, error) + CreateServiceAccountKey(projectID, saEmail string) (string, error) + AssignIAMRole(projectID, saEmail, role string) error + CreateVPC(projectID, region, networkName, subnetName, routerName, natName string) error + CreateFirewallRule(projectID string, rule *computepb.Firewall) error + CreateInstance(projectID, zone string, instance *computepb.Instance) error + GetInstance(projectID, zone, instanceName string) (*computepb.Instance, error) + CreateAddress(projectID, region string, address *computepb.Address) (string, error) + GetAddress(projectID, region, addressName string) (*computepb.Address, error) + EnsureDNSManagedZone(projectID, zoneName, dnsName, description string) error + EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error +} + +// Concrete implementation +type GCPClient struct { + ctx context.Context + st *bootstrap.StepLogger + CredentialsFile string +} + +func NewGCPClient(ctx context.Context, st *bootstrap.StepLogger, credentialsFile string) *GCPClient { + return &GCPClient{ + ctx: ctx, + st: st, + CredentialsFile: credentialsFile, + } +} + +// GetProjectByName retrieves a GCP project by its display name within the specified folder. +func (c *GCPClient) GetProjectByName(folderID string, displayName string) (*resourcemanagerpb.Project, error) { + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return nil, err + } + defer util.IgnoreError(client.Close) + + req := &resourcemanagerpb.ListProjectsRequest{ + Parent: fmt.Sprintf("folders/%s", folderID), + ShowDeleted: false, + } + + it := client.ListProjects(c.ctx, req) + + for { + project, err := it.Next() + if err == iterator.Done { + // No more results found + return nil, fmt.Errorf("project not found: %s", displayName) + } + if err != nil { + return nil, fmt.Errorf("error iterating projects: %w", err) + } + + // Because the filter is a prefix search on the display name, + // we should perform an exact match check here to be sure. + if project.GetDisplayName() == displayName { + return project, nil + } + } +} + +// CreateProjectID generates a unique project ID based on the given project name. +func (c *GCPClient) CreateProjectID(projectName string) string { + projectGuid := strings.ToLower(shortuuid.New()[:8]) + return projectName + "-" + projectGuid +} + +// CreateProject creates a new GCP project under the specified parent (folder or organization). +// It returns the project ID of the newly created project. +func (c *GCPClient) CreateProject(parent, projectID, displayName string) (string, error) { + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return "", err + } + defer util.IgnoreError(client.Close) + + project := &resourcemanagerpb.Project{ + ProjectId: projectID, + DisplayName: displayName, + Parent: parent, + } + op, err := client.CreateProject(c.ctx, &resourcemanagerpb.CreateProjectRequest{Project: project}) + if err != nil { + return "", err + } + resp, err := op.Wait(c.ctx) + if err != nil { + return "", err + } + + return resp.ProjectId, nil +} + +func getProjectResourceName(projectID string) string { + return fmt.Sprintf("projects/%s", projectID) +} + +// GetBillingInfo retrieves the billing information for the given project. +func (c *GCPClient) GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) { + billingService, err := cloudbilling.NewService(context.Background()) + if err != nil { + return nil, err + } + + projectName := getProjectResourceName(projectID) + billingInfo, err := billingService.Projects.GetBillingInfo(projectName).Do() + if err != nil { + return nil, err + } + return billingInfo, nil +} + +// EnableBilling enables billing for the given project using the specified billing account. +func (c *GCPClient) EnableBilling(projectID, billingAccount string) error { + billingService, err := cloudbilling.NewService(c.ctx) + if err != nil { + return err + } + + projectName := getProjectResourceName(projectID) + billingInfo := &cloudbilling.ProjectBillingInfo{ + BillingAccountName: fmt.Sprintf("billingAccounts/%s", billingAccount), + } + _, err = billingService.Projects.UpdateBillingInfo(projectName, billingInfo).Context(c.ctx).Do() + return err +} + +// EnableAPIs enables the specified APIs for the given project. +func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { + client, err := serviceusage.NewClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(client.Close) + // enable APIs in parallel + wg := sync.WaitGroup{} + errCh := make(chan error, len(apis)) + for _, api := range apis { + serviceName := fmt.Sprintf("projects/%s/services/%s", projectID, api) + wg.Add(1) + + go func(serviceName, api string) { + defer wg.Done() + c.st.Logf("Enabling API %s", api) + + op, err := client.EnableService(c.ctx, &serviceusagepb.EnableServiceRequest{Name: serviceName}) + if status.Code(err) == codes.AlreadyExists { + c.st.Logf("API %s already enabled", api) + return + } + if err != nil { + errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) + } + if _, err := op.Wait(c.ctx); err != nil { + errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) + } + + c.st.Logf("API %s enabled", api) + }(serviceName, api) + } + + wg.Wait() + close(errCh) + errStr := "" + for err := range errCh { + errStr += err.Error() + "; " + } + if len(errStr) > 0 { + return fmt.Errorf("errors occurred while enabling APIs: %s", errStr) + } + return nil +} + +// CreateArtifactRegistry creates and returns an Artifact Registry repository by its name. +func (c *GCPClient) CreateArtifactRegistry(projectID, region, repoName string) (*artifactpb.Repository, error) { + client, err := artifact.NewClient(c.ctx) + if err != nil { + return nil, err + } + defer util.IgnoreError(client.Close) + + parent := fmt.Sprintf("projects/%s/locations/%s", projectID, region) + repoReq := &artifactpb.CreateRepositoryRequest{ + Parent: parent, + RepositoryId: repoName, + Repository: &artifactpb.Repository{ + Format: artifactpb.Repository_DOCKER, + Description: "Codesphere managed registry", + }, + } + op, err := client.CreateRepository(c.ctx, repoReq) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return nil, err + } + var repo *artifactpb.Repository + if err == nil { + _, err = op.Wait(c.ctx) + if err != nil { + return nil, err + } + } + + // get repo again to ensure all infos are stored, else e.g. uri would be missing + repo, err = c.GetArtifactRegistry(projectID, region, repoName) + if err != nil { + return nil, fmt.Errorf("failed to get newly created artifact registry: %w", err) + } + + return repo, nil +} + +// GetArtifactRegistry retrieves an existing Artifact Registry repository by its name. +func (c *GCPClient) GetArtifactRegistry(projectID, region, repoName string) (*artifactpb.Repository, error) { + client, err := artifact.NewClient(c.ctx) + if err != nil { + return nil, err + } + defer util.IgnoreError(client.Close) + + fullRepoName := fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, region, repoName) + repo, err := client.GetRepository(c.ctx, &artifactpb.GetRepositoryRequest{ + Name: fullRepoName, + }) + if err != nil { + return nil, fmt.Errorf("failed to get artifact registry repository: %w", err) + } + + return repo, nil +} + +// CreateServiceAccount creates a new service account with the given name and display name. +// It returns the email of the created service account, a boolean indicating whether the account was newly created, +// and an error if any occurred during the process. +func (c *GCPClient) CreateServiceAccount(projectID, name, displayName string) (string, bool, error) { + saMail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, projectID) + iamService, err := iam.NewService(c.ctx) + if err != nil { + return saMail, false, err + } + + saReq := &iam.CreateServiceAccountRequest{ + AccountId: name, + ServiceAccount: &iam.ServiceAccount{ + DisplayName: displayName, + }, + } + _, err = iamService.Projects.ServiceAccounts.Create(fmt.Sprintf("projects/%s", projectID), saReq).Context(c.ctx).Do() + if err != nil && !strings.Contains(err.Error(), "already exists") { + return saMail, false, err + } + if err != nil && strings.Contains(err.Error(), "already exists") { + return saMail, false, nil + } + + return saMail, true, nil +} + +// CreateServiceAccountKey creates a new key for the specified service account. +// It returns the private key data in PEM format and an error if any occurred during the process. +func (c *GCPClient) CreateServiceAccountKey(projectID, saEmail string) (string, error) { + iamService, err := iam.NewService(c.ctx) + if err != nil { + return "", err + } + + keyReq := &iam.CreateServiceAccountKeyRequest{} + saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) + key, err := iamService.Projects.ServiceAccounts.Keys.Create(saName, keyReq).Context(c.ctx).Do() + if err != nil { + return "", err + } + + return string(key.PrivateKeyData), nil +} + +// AssignIAMRole assigns the specified IAM role to the given service account in the project. +func (c *GCPClient) AssignIAMRole(projectID, saName, role string) error { + saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, projectID) + client, err := resourcemanager.NewProjectsClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(client.Close) + getReq := &iampb.GetIamPolicyRequest{ + Resource: fmt.Sprintf("projects/%s", projectID), + } + + policy, err := client.GetIamPolicy(c.ctx, getReq) + if err != nil { + return err + } + + // Check if already assigned + member := fmt.Sprintf("serviceAccount:%s", saEmail) + for _, binding := range policy.Bindings { + if binding.Role == role { + if slices.Contains(binding.Members, member) { + return nil + } + } + } + + policy.Bindings = append(policy.Bindings, &iampb.Binding{ + Role: role, + Members: []string{member}, + }) + setReq := &iampb.SetIamPolicyRequest{ + Resource: fmt.Sprintf("projects/%s", projectID), + Policy: policy, + } + _, err = client.SetIamPolicy(c.ctx, setReq) + return err +} + +// CreateVPC creates a VPC network with the specified subnet, router, and NAT gateway. +func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, routerName, natName string) error { + // Create Network + networksClient, err := compute.NewNetworksRESTClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(networksClient.Close) + + network := &computepb.Network{ + Name: &networkName, + AutoCreateSubnetworks: protoBool(false), + } + op, err := networksClient.Insert(c.ctx, &computepb.InsertNetworkRequest{ + Project: projectID, + NetworkResource: network, + }) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return err + } + if err == nil { + if err := op.Wait(c.ctx); err != nil { + return err + } + } + + c.st.Logf("Network %s ensured", networkName) + + // Create Subnet + subnetsClient, err := compute.NewSubnetworksRESTClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(subnetsClient.Close) + + subnet := &computepb.Subnetwork{ + Name: &subnetName, + IpCidrRange: protoString("10.10.0.0/20"), + Region: ®ion, + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), + } + op, err = subnetsClient.Insert(c.ctx, &computepb.InsertSubnetworkRequest{ + Project: projectID, + Region: region, + SubnetworkResource: subnet, + }) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return err + } + if err == nil { + if err := op.Wait(c.ctx); err != nil { + return err + } + } + + c.st.Logf("Subnetwork %s ensured", subnetName) + + // Create Router + routersClient, err := compute.NewRoutersRESTClient(c.ctx) + if err != nil { + return fmt.Errorf("failed to create routers client: %w", err) + } + defer util.IgnoreError(routersClient.Close) + + router := &computepb.Router{ + Name: &routerName, + Region: ®ion, + Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), + } + op, err = routersClient.Insert(c.ctx, &computepb.InsertRouterRequest{ + Project: projectID, + Region: region, + RouterResource: router, + }) + if err != nil && !isAlreadyExistsError(err) { + return fmt.Errorf("failed to create router: %w", err) + } + if err == nil { + if err := op.Wait(c.ctx); err != nil { + return fmt.Errorf("failed to wait for router creation: %w", err) + } + } + + c.st.Logf("Router %s ensured", routerName) + + // Create NAT Gateway + natsClient, err := compute.NewRoutersRESTClient(c.ctx) + if err != nil { + return fmt.Errorf("failed to create routers client for NAT: %w", err) + } + defer util.IgnoreError(natsClient.Close) + + nat := &computepb.RouterNat{ + Name: &natName, + SourceSubnetworkIpRangesToNat: protoString("ALL_SUBNETWORKS_ALL_IP_RANGES"), + NatIpAllocateOption: protoString("AUTO_ONLY"), + LogConfig: &computepb.RouterNatLogConfig{ + Enable: protoBool(false), + Filter: protoString("ERRORS_ONLY"), + }, + } + // Patch NAT config to router + _, err = routersClient.Patch(c.ctx, &computepb.PatchRouterRequest{ + Project: projectID, + Region: region, + Router: routerName, + RouterResource: &computepb.Router{ + Name: &routerName, + Nats: []*computepb.RouterNat{nat}, + }, + }) + if err != nil && !isAlreadyExistsError(err) { + return fmt.Errorf("failed to create NAT gateway: %w", err) + } + + c.st.Logf("NAT gateway %s ensured", natName) + + return nil +} + +// CreateFirewallRule creates a firewall rule in the specified project. +func (c *GCPClient) CreateFirewallRule(projectID string, rule *computepb.Firewall) error { + firewallsClient, err := compute.NewFirewallsRESTClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(firewallsClient.Close) + + _, err = firewallsClient.Insert(c.ctx, &computepb.InsertFirewallRequest{ + Project: projectID, + FirewallResource: rule, + }) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return err + } + + return nil +} + +// CreateInstance creates a new Compute Engine instance in the specified project and zone. +func (c *GCPClient) CreateInstance(projectID, zone string, instance *computepb.Instance) error { + client, err := compute.NewInstancesRESTClient(c.ctx) + if err != nil { + return err + } + defer util.IgnoreError(client.Close) + + op, err := client.Insert(c.ctx, &computepb.InsertInstanceRequest{ + Project: projectID, + Zone: zone, + InstanceResource: instance, + }) + if err != nil { + return err + } + + return op.Wait(c.ctx) +} + +// GetInstance retrieves a Compute Engine instance by its name in the specified project and zone. +func (c *GCPClient) GetInstance(projectID, zone, instanceName string) (*computepb.Instance, error) { + client, err := compute.NewInstancesRESTClient(c.ctx) + if err != nil { + return nil, err + } + defer util.IgnoreError(client.Close) + + return client.Get(c.ctx, &computepb.GetInstanceRequest{ + Project: projectID, + Zone: zone, + Instance: instanceName, + }) +} + +// CreateAddress creates a new static IP address in the specified project and region. +func (c *GCPClient) CreateAddress(projectID, region string, address *computepb.Address) (string, error) { + client, err := compute.NewAddressesRESTClient(c.ctx) + if err != nil { + return "", err + } + defer util.IgnoreError(client.Close) + + op, err := client.Insert(c.ctx, &computepb.InsertAddressRequest{ + Project: projectID, + Region: region, + AddressResource: address, + }) + if err != nil { + return "", err + } + if err = op.Wait(c.ctx); err != nil { + return "", err + } + + // Fetch the created address to get the IP + createdAddress, err := client.Get(c.ctx, &computepb.GetAddressRequest{ + Project: projectID, + Region: region, + Address: *address.Name, + }) + if err != nil { + return "", err + } + + return *createdAddress.Address, nil +} + +// GetAddress retrieves a static IP address by its name in the specified project and region. +func (c *GCPClient) GetAddress(projectID, region, addressName string) (*computepb.Address, error) { + client, err := compute.NewAddressesRESTClient(c.ctx) + if err != nil { + return nil, err + } + defer util.IgnoreError(client.Close) + + return client.Get(c.ctx, &computepb.GetAddressRequest{ + Project: projectID, + Region: region, + Address: addressName, + }) +} + +// EnsureDNSManagedZone ensures that a DNS managed zone exists in the specified project. +func (c *GCPClient) EnsureDNSManagedZone(projectID, zoneName, dnsName, description string) error { + service, err := dns.NewService(c.ctx) + if err != nil { + return fmt.Errorf("failed to create DNS service: %w", err) + } + + // Check if zone exists + _, err = service.ManagedZones.Get(projectID, zoneName).Context(c.ctx).Do() + if err == nil { + // Zone exists + return nil + } + + // Create zone + zone := &dns.ManagedZone{ + Name: zoneName, + DnsName: dnsName, + Description: description, + } + _, err = service.ManagedZones.Create(projectID, zone).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to create DNS zone: %w", err) + } + + return nil +} + +// EnsureDNSRecordSets ensures that the specified DNS record sets exist in the given managed zone. +func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error { + service, err := dns.NewService(c.ctx) + if err != nil { + return fmt.Errorf("failed to create DNS service: %w", err) + } + + deletions := []*dns.ResourceRecordSet{} + // Clean up existing records + for _, record := range records { + existingRecord, err := service.ResourceRecordSets.Get(projectID, zoneName, record.Name, record.Type).Context(c.ctx).Do() + if err == nil && existingRecord != nil { + deletions = append(deletions, existingRecord) + } + } + + if len(deletions) > 0 { + delChange := &dns.Change{ + Deletions: deletions, + } + _, err = service.Changes.Create(projectID, zoneName, delChange).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to delete existing DNS records: %w", err) + } + } + + change := &dns.Change{ + Additions: records, + } + _, err = service.Changes.Create(projectID, zoneName, change).Context(c.ctx).Do() + if err != nil { + return fmt.Errorf("failed to create DNS records: %w", err) + } + + return nil +} + +// Helper functions +func protoString(s string) *string { return &s } +func protoBool(b bool) *bool { return &b } +func protoInt32(i int32) *int32 { return &i } +func protoInt64(i int64) *int64 { return &i } diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go new file mode 100644 index 00000000..0a2149e9 --- /dev/null +++ b/internal/bootstrap/gcp/gcp_test.go @@ -0,0 +1,2564 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "context" + "fmt" + "strings" + "time" + + "os" + + "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" + "cloud.google.com/go/compute/apiv1/computepb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + "google.golang.org/api/cloudbilling/v1" + "google.golang.org/api/dns/v1" +) + +func protoString(s string) *string { return &s } + +var _ = Describe("NewGCPBootstrapper", func() { + It("creates a valid GCPBootstrapper", func() { + env := env.NewEnv() + Expect(env).NotTo(BeNil()) + + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + Expect(bs).NotTo(BeNil()) + }) +}) + +var _ = Describe("Bootstrap", func() { + var ( + e env.Env + ctx context.Context + csEnv *gcp.CodesphereEnvironment + icg *installer.MockInstallConfigManager + gc *gcp.MockGCPClientManager + fw *util.MockFileIO + nm *node.MockNodeManager + bs *gcp.GCPBootstrapper + ) + + BeforeEach(func() { + e = env.NewEnv() + ctx = context.Background() + csEnv = &gcp.CodesphereEnvironment{ + InstallConfigPath: "fake-config-file", + SecretsFilePath: "fake-secret", + ProjectName: "test-project", + BillingAccount: "test-billing-account", + Region: "us-central1", + Zone: "us-central1-a", + BaseDomain: "example.com", + DNSProjectID: "dns-project", + DNSZoneName: "test-zone", + } + stlog := bootstrap.NewStepLogger(false) + + icg = installer.NewMockInstallConfigManager(GinkgoT()) + gc = gcp.NewMockGCPClientManager(GinkgoT()) + fw = util.NewMockFileIO(GinkgoT()) + nm = node.NewMockNodeManager(GinkgoT()) + + var err error + bs, err = gcp.NewGCPBootstrapper(ctx, e, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + }) + + It("runs bootstrap successfully", func() { + bs.Env.RegistryType = gcp.RegistryTypeArtifactRegistry + bs.Env.WriteConfig = true + bs.Env.SecretsDir = "/secrets" + + // 1. EnsureInstallConfig + fw.EXPECT().Exists("fake-config-file").Return(false) + icg.EXPECT().ApplyProfile("dev").Return(nil) + // Returning a real install config to avoid nil pointer dereferences later + icg.EXPECT().GetInstallConfig().RunAndReturn(func() *files.RootConfig { + realIcm := installer.NewInstallConfigManager() + _ = realIcm.ApplyProfile("dev") + return realIcm.GetInstallConfig() + }) + + // 2. EnsureSecrets + fw.EXPECT().Exists("fake-secret").Return(false) + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + // 3. EnsureProject + gc.EXPECT().GetProjectByName(mock.Anything, "test-project").Return(nil, fmt.Errorf("project not found: test-project")) + gc.EXPECT().CreateProjectID("test-project").Return("test-project-id") + gc.EXPECT().CreateProject(mock.Anything, mock.Anything, "test-project").Return(mock.Anything, nil) + + // 4. EnsureBilling + gc.EXPECT().GetBillingInfo("test-project-id").Return(&cloudbilling.ProjectBillingInfo{BillingEnabled: false}, nil) + gc.EXPECT().EnableBilling("test-project-id", "test-billing-account").Return(nil) + + // 5. EnsureAPIsEnabled + gc.EXPECT().EnableAPIs("test-project-id", mock.Anything).Return(nil) + + // 6. EnsureArtifactRegistry + gc.EXPECT().GetArtifactRegistry("test-project-id", "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateArtifactRegistry("test-project-id", "us-central1", "codesphere-registry").Return(&artifactregistrypb.Repository{Name: "codesphere-registry"}, nil) + + // 7. EnsureServiceAccounts + gc.EXPECT().CreateServiceAccount("test-project-id", "cloud-controller", "cloud-controller").Return("cloud-controller@p.iam.gserviceaccount.com", false, nil) + gc.EXPECT().CreateServiceAccount("test-project-id", "artifact-registry-writer", "artifact-registry-writer").Return("writer@p.iam.gserviceaccount.com", true, nil) + gc.EXPECT().CreateServiceAccountKey("test-project-id", "writer@p.iam.gserviceaccount.com").Return("fake-key", nil) + + // 8. EnsureIAMRoles + gc.EXPECT().AssignIAMRole("test-project-id", "artifact-registry-writer", "roles/artifactregistry.writer").Return(nil) + gc.EXPECT().AssignIAMRole("test-project-id", "cloud-controller", "roles/compute.admin").Return(nil) + + // 9. EnsureVPC + gc.EXPECT().CreateVPC("test-project-id", "us-central1", "test-project-id-vpc", "test-project-id-us-central1-subnet", "test-project-id-router", "test-project-id-nat-gateway").Return(nil) + + // 10. EnsureFirewallRules (5 times) + gc.EXPECT().CreateFirewallRule("test-project-id", mock.Anything).Return(nil).Times(5) + + // 11. EnsureComputeInstances + gc.EXPECT().CreateInstance("test-project-id", "us-central1-a", mock.Anything).Return(nil).Times(9) + // GetInstance calls to retrieve IPs + ipResp := &computepb.Instance{ + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: protoString("10.0.0.1"), + AccessConfigs: []*computepb.AccessConfig{ + {NatIP: protoString("1.2.3.4")}, + }, + }, + }, + } + gc.EXPECT().GetInstance("test-project-id", "us-central1-a", mock.Anything).Return(ipResp, nil).Times(9) + fw.EXPECT().ReadFile(mock.Anything).Return([]byte("fake-key"), nil).Times(9) + // UpdateNode is called once for the jumpbox to set its name and IPs + nm.EXPECT().UpdateNode("jumpbox", "1.2.3.4", "10.0.0.1") + // CreateSubNode is called 8 times for the other nodes + nm.EXPECT().CreateSubNode("postgres", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("ceph-1", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("ceph-2", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("ceph-3", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("ceph-4", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("k0s-1", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("k0s-2", "1.2.3.4", "10.0.0.1").Return(nm) + nm.EXPECT().CreateSubNode("k0s-3", "1.2.3.4", "10.0.0.1").Return(nm) + + nm.EXPECT().GetName().Return("mocknode").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("1.2.3.4").Maybe() + + // 12. EnsureGatewayIPAddresses + gc.EXPECT().GetAddress("test-project-id", "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("test-project-id", "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "gateway" })).Return("1.1.1.1", nil) + gc.EXPECT().GetAddress("test-project-id", "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().GetAddress("test-project-id", "us-central1", "public-gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("test-project-id", "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "public-gateway" })).Return("2.2.2.2", nil) + gc.EXPECT().GetAddress("test-project-id", "us-central1", "public-gateway").Return(&computepb.Address{Address: protoString("2.2.2.2")}, nil) + + // 13. EnsureRootLoginEnabled + nm.EXPECT().WaitForSSH(30 * time.Second).Return(nil).Times(9) + nm.EXPECT().HasRootLoginEnabled().Return(false).Times(9) + nm.EXPECT().EnableRootLogin().Return(nil).Times(9) + + // 14. EnsureJumpboxConfigured + nm.EXPECT().HasAcceptEnvConfigured().Return(false) + nm.EXPECT().ConfigureAcceptEnv().Return(nil) + nm.EXPECT().HasCommand("oms-cli").Return(false) + nm.EXPECT().InstallOms().Return(nil) + + // 15. EnsureInotifyWatches + nm.EXPECT().HasInotifyWatchesConfigured().Return(false) + nm.EXPECT().ConfigureInotifyWatches().Return(nil) + nm.EXPECT().HasMemoryMapConfigured().Return(false) + nm.EXPECT().ConfigureMemoryMap().Return(nil) + + // 16. UpdateInstallConfig + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + nm.EXPECT().CopyFile("fake-config-file", "/etc/codesphere/config.yaml").Return(nil) + nm.EXPECT().CopyFile("fake-secret", "/secrets/prod.vault.yaml").Return(nil) + + // 17. EnsureAgeKey + nm.EXPECT().HasFile("/secrets/age_key.txt").Return(false) + nm.EXPECT().RunSSHCommand("root", "mkdir -p /secrets; age-keygen -o /secrets/age_key.txt", true).Return(nil) + + // 18. EncryptVault + nm.EXPECT().RunSSHCommand("root", "cp /secrets/prod.vault.yaml{,.bak}", true).Return(nil) + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "sops --encrypt") + }), true).Return(nil) + + // 19. EnsureDNSRecords + gc.EXPECT().EnsureDNSManagedZone("dns-project", "test-zone", "example.com.", mock.Anything).Return(nil) + gc.EXPECT().EnsureDNSRecordSets("dns-project", "test-zone", mock.MatchedBy(func(records []*dns.ResourceRecordSet) bool { + return len(records) == 4 + })).Return(nil) + + // 20. GenerateK0sConfigScript + fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(nil) + nm.EXPECT().CopyFile("configure-k0s.sh", "/root/configure-k0s.sh").Return(nil) + nm.EXPECT().RunSSHCommand("root", "chmod +x /root/configure-k0s.sh", true).Return(nil) + + err := bs.Bootstrap() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env).NotTo(BeNil()) + Expect(bs.Env.ProjectID).To(HavePrefix("test-project-")) + + // Verify nodes are properly set in the environment + Expect(bs.Env.Jumpbox).NotTo(BeNil(), "Jumpbox should be created") + Expect(bs.Env.PostgreSQLNode).NotTo(BeNil(), "PostgreSQL node should be created") + Expect(bs.Env.CephNodes).To(HaveLen(4), "Should have 4 Ceph nodes") + Expect(bs.Env.ControlPlaneNodes).To(HaveLen(3), "Should have 3 K0s control plane nodes") + + // Verify mock returns expected values + Expect(bs.Env.Jumpbox.GetName()).To(Equal("mocknode")) + Expect(bs.Env.Jumpbox.GetExternalIP()).To(Equal("1.2.3.4")) + Expect(bs.Env.Jumpbox.GetInternalIP()).To(Equal("10.0.0.1")) + + Expect(bs.Env.PostgreSQLNode.GetName()).To(Equal("mocknode")) + Expect(bs.Env.PostgreSQLNode.GetExternalIP()).To(Equal("1.2.3.4")) + Expect(bs.Env.PostgreSQLNode.GetInternalIP()).To(Equal("10.0.0.1")) + + for _, cephNode := range bs.Env.CephNodes { + Expect(cephNode.GetName()).To(Equal("mocknode")) + Expect(cephNode.GetExternalIP()).To(Equal("1.2.3.4")) + Expect(cephNode.GetInternalIP()).To(Equal("10.0.0.1")) + } + + for _, cpNode := range bs.Env.ControlPlaneNodes { + Expect(cpNode.GetName()).To(Equal("mocknode")) + Expect(cpNode.GetExternalIP()).To(Equal("1.2.3.4")) + Expect(cpNode.GetInternalIP()).To(Equal("10.0.0.1")) + } + }) +}) + +var _ = Describe("EnsureInstallConfig", func() { + Describe("Valid EnsureInstallConfig", func() { + It("uses existing when config file exists", func() { + env := env.NewEnv() + Expect(env).NotTo(BeNil()) + + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallConfigPath: "existing-config-file", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("existing-config-file").Return(true) + icg.EXPECT().LoadInstallConfigFromFile("existing-config-file").Return(nil) + icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) + + err = bs.EnsureInstallConfig() + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates install config when missing", func() { + env := env.NewEnv() + Expect(env).NotTo(BeNil()) + + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallConfigPath: "nonexistent-config-file", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("nonexistent-config-file").Return(false) + icg.EXPECT().ApplyProfile("dev").Return(nil) + icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) + + err = bs.EnsureInstallConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.InstallConfigPath).To(Equal("nonexistent-config-file")) + Expect(bs.Env.InstallConfig).NotTo(BeNil()) + }) + }) + + Describe("Invalid cases", func() { + It("returns error when config file exists but fails to load", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallConfigPath: "existing-bad-config", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("existing-bad-config").Return(true) + icg.EXPECT().LoadInstallConfigFromFile("existing-bad-config").Return(fmt.Errorf("bad format")) + + err = bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load config file")) + Expect(err.Error()).To(ContainSubstring("bad format")) + }) + + It("returns error when config file missing and applying profile fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallConfigPath: "missing-config", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("missing-config").Return(false) + icg.EXPECT().ApplyProfile("dev").Return(fmt.Errorf("profile error")) + + err = bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to apply profile")) + Expect(err.Error()).To(ContainSubstring("profile error")) + }) + }) +}) + +var _ = Describe("EnsureSecrets", func() { + Describe("Valid EnsureSecrets", func() { + It("loads existing secrets file", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsFilePath: "existing-secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("existing-secrets").Return(true) + icg.EXPECT().LoadVaultFromFile("existing-secrets").Return(nil) + icg.EXPECT().MergeVaultIntoConfig().Return(nil) + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + err = bs.EnsureSecrets() + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips when secrets file missing", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsFilePath: "missing-secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("missing-secrets").Return(false) + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + err = bs.EnsureSecrets() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("returns error when secrets file load fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsFilePath: "bad-secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("bad-secrets").Return(true) + icg.EXPECT().LoadVaultFromFile("bad-secrets").Return(fmt.Errorf("load error")) + + err = bs.EnsureSecrets() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load vault file")) + Expect(err.Error()).To(ContainSubstring("load error")) + }) + + It("returns error when merge fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsFilePath: "merr-secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().Exists("merr-secrets").Return(true) + icg.EXPECT().LoadVaultFromFile("merr-secrets").Return(nil) + icg.EXPECT().MergeVaultIntoConfig().Return(fmt.Errorf("merge error")) + + err = bs.EnsureSecrets() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to merge vault into config")) + Expect(err.Error()).To(ContainSubstring("merge error")) + }) + }) +}) + +var _ = Describe("EnsureProject", func() { + Describe("Valid EnsureProject", func() { + It("uses existing project", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectName: "existing-proj", + FolderID: "123", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetProjectByName("123", "existing-proj").Return(&resourcemanagerpb.Project{ProjectId: "existing-id", Name: "existing-proj"}, nil) + + err = bs.EnsureProject() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.ProjectID).To(Equal("existing-id")) + }) + + It("creates project when missing", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectName: "new-proj", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetProjectByName("", "new-proj").Return(nil, fmt.Errorf("project not found: new-proj")) + gc.EXPECT().CreateProjectID("new-proj").Return("new-proj-id") + gc.EXPECT().CreateProject("", "new-proj-id", "new-proj").Return("", nil) + + err = bs.EnsureProject() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.ProjectID).To(Equal("new-proj-id")) + }) + }) + + Describe("Invalid cases", func() { + It("returns error when GetProjectByName fails unexpectedly", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectName: "error-proj", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetProjectByName("", "error-proj").Return(nil, fmt.Errorf("api error")) + + err = bs.EnsureProject() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get project")) + Expect(err.Error()).To(ContainSubstring("api error")) + }) + + It("returns error when CreateProject fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectName: "fail-create-proj", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetProjectByName("", "fail-create-proj").Return(nil, fmt.Errorf("project not found: fail-create-proj")) + gc.EXPECT().CreateProjectID("fail-create-proj").Return("fail-create-proj-id") + gc.EXPECT().CreateProject("", "fail-create-proj-id", "fail-create-proj").Return("", fmt.Errorf("create error")) + + err = bs.EnsureProject() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create project")) + Expect(err.Error()).To(ContainSubstring("create error")) + }) + }) +}) + +var _ = Describe("EnsureBilling", func() { + Describe("Valid EnsureBilling", func() { + It("does nothing if billing already enabled correctly", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + BillingAccount: "billing-123", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bi := &cloudbilling.ProjectBillingInfo{ + BillingEnabled: true, + BillingAccountName: "billing-123", + } + gc.EXPECT().GetBillingInfo("pid").Return(bi, nil) + + err = bs.EnsureBilling() + Expect(err).NotTo(HaveOccurred()) + }) + + It("enables billing if not enabled", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + BillingAccount: "billing-123", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bi := &cloudbilling.ProjectBillingInfo{ + BillingEnabled: false, + } + gc.EXPECT().GetBillingInfo("pid").Return(bi, nil) + gc.EXPECT().EnableBilling("pid", "billing-123").Return(nil) + + err = bs.EnsureBilling() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when GetBillingInfo fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetBillingInfo("pid").Return(nil, fmt.Errorf("billing info error")) + + err = bs.EnsureBilling() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get billing info")) + Expect(err.Error()).To(ContainSubstring("billing info error")) + }) + + It("fails when EnableBilling fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + BillingAccount: "acc", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bi := &cloudbilling.ProjectBillingInfo{ + BillingEnabled: false, + } + gc.EXPECT().GetBillingInfo("pid").Return(bi, nil) + gc.EXPECT().EnableBilling("pid", "acc").Return(fmt.Errorf("enable error")) + + err = bs.EnsureBilling() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to enable billing")) + Expect(err.Error()).To(ContainSubstring("enable error")) + }) + }) +}) + +var _ = Describe("EnsureAPIsEnabled", func() { + Describe("Valid EnsureAPIsEnabled", func() { + It("enables default APIs", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().EnableAPIs("pid", []string{ + "compute.googleapis.com", + "serviceusage.googleapis.com", + "artifactregistry.googleapis.com", + "dns.googleapis.com", + }).Return(nil) + + err = bs.EnsureAPIsEnabled() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when EnableAPIs fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().EnableAPIs("pid", mock.Anything).Return(fmt.Errorf("api error")) + + err = bs.EnsureAPIsEnabled() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to enable APIs")) + Expect(err.Error()).To(ContainSubstring("api error")) + }) + }) +}) + +var _ = Describe("EnsureArtifactRegistry", func() { + Describe("Valid EnsureArtifactRegistry", func() { + It("uses existing registry if present", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + repo := &artifactregistrypb.Repository{Name: "projects/pid/locations/us-central1/repositories/codesphere-registry"} + gc.EXPECT().GetArtifactRegistry("pid", "us-central1", "codesphere-registry").Return(repo, nil) + + err = bs.EnsureArtifactRegistry() + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates registry if missing", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetArtifactRegistry("pid", "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("not found")) + + createdRepo := &artifactregistrypb.Repository{Name: "projects/pid/locations/us-central1/repositories/codesphere-registry"} + gc.EXPECT().CreateArtifactRegistry("pid", "us-central1", "codesphere-registry").Return(createdRepo, nil) + + err = bs.EnsureArtifactRegistry() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when CreateArtifactRegistry fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetArtifactRegistry("pid", "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateArtifactRegistry("pid", "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("create error")) + + err = bs.EnsureArtifactRegistry() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create artifact registry")) + Expect(err.Error()).To(ContainSubstring("create error")) + }) + }) +}) + +var _ = Describe("EnsureLocalContainerRegistry", func() { + Describe("Valid EnsureLocalContainerRegistry", func() { + It("installs local registry", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Setup mocked node + bs.Env.PostgreSQLNode = nm + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + + // Check if running - return error to simulate not running + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "podman ps") + }), true).Return(fmt.Errorf("not running")) + + // Install commands (8 commands) + scp/update-ca/docker commands (3 per 4 nodes = 12) + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(nil).Times(8 + 12) + + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm} + bs.Env.CephNodes = []node.NodeManager{nm, nm} + + nm.EXPECT().GetName().Return("mocknode").Maybe() + + err = bs.EnsureLocalContainerRegistry() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.InstallConfig.Registry.Username).To(Equal("custom-registry")) + }) + }) + + Describe("Invalid cases", func() { + var ( + e env.Env + ctx context.Context + csEnv *gcp.CodesphereEnvironment + icg *installer.MockInstallConfigManager + gc *gcp.MockGCPClientManager + fw *util.MockFileIO + nm *node.MockNodeManager + bs *gcp.GCPBootstrapper + ) + + BeforeEach(func() { + e = env.NewEnv() + ctx = context.Background() + csEnv = &gcp.CodesphereEnvironment{ + ProjectID: "pid", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg = installer.NewMockInstallConfigManager(GinkgoT()) + gc = gcp.NewMockGCPClientManager(GinkgoT()) + fw = util.NewMockFileIO(GinkgoT()) + nm = node.NewMockNodeManager(GinkgoT()) + + var err error + bs, err = gcp.NewGCPBootstrapper(ctx, e, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm} + bs.Env.CephNodes = []node.NodeManager{nm, nm} + + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + }) + + It("fails when the 8th install command fails", func() { + // First check - registry not running + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "podman ps") + }), true).Return(fmt.Errorf("not running")) + + // First 7 install commands succeed + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(nil).Times(7) + + // 8th install command fails + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(fmt.Errorf("ssh error")).Once() + + err := bs.EnsureLocalContainerRegistry() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ssh error")) + }) + + It("fails when the first scp command fails", func() { + // GetName is called in Logf + nm.EXPECT().GetName().Return("mocknode").Maybe() + + // First check - registry not running + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "podman ps") + }), true).Return(fmt.Errorf("not running")) + + // All 8 install commands succeed + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(nil).Times(8) + + // First scp command fails + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "scp ") + }), true).Return(fmt.Errorf("scp error")).Once() + + err := bs.EnsureLocalContainerRegistry() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy registry certificate")) + }) + + It("fails when update-ca-certificates fails", func() { + // Override node setup for this test + node1 := node.NewMockNodeManager(GinkgoT()) + bs.Env.ControlPlaneNodes = []node.NodeManager{node1} + bs.Env.CephNodes = []node.NodeManager{} + + node1.EXPECT().GetInternalIP().Return("10.0.0.2").Maybe() + node1.EXPECT().GetName().Return("node1").Maybe() + + // First check - registry not running + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "podman ps") + }), true).Return(fmt.Errorf("not running")) + + // All 8 install commands succeed + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(nil).Times(8) + + // scp succeeds + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "scp ") + }), true).Return(nil).Once() + + // update-ca-certificates fails + node1.EXPECT().RunSSHCommand("root", "update-ca-certificates", true).Return(fmt.Errorf("ca update error")).Once() + + err := bs.EnsureLocalContainerRegistry() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to update CA certificates")) + }) + + It("fails when docker restart fails", func() { + // Override node setup for this test + node1 := node.NewMockNodeManager(GinkgoT()) + bs.Env.ControlPlaneNodes = []node.NodeManager{node1} + bs.Env.CephNodes = []node.NodeManager{} + + node1.EXPECT().GetInternalIP().Return("10.0.0.2").Maybe() + node1.EXPECT().GetName().Return("node1").Maybe() + + // First check - registry not running + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "podman ps") + }), true).Return(fmt.Errorf("not running")) + + // All 8 install commands succeed + nm.EXPECT().RunSSHCommand("root", mock.Anything, true).Return(nil).Times(8) + + // scp succeeds + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "scp ") + }), true).Return(nil).Once() + + // update-ca-certificates succeeds + node1.EXPECT().RunSSHCommand("root", "update-ca-certificates", true).Return(nil).Once() + + // docker restart fails + node1.EXPECT().RunSSHCommand("root", "systemctl restart docker.service || true", true).Return(fmt.Errorf("docker restart error")).Once() + + err := bs.EnsureLocalContainerRegistry() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to restart docker service")) + }) + }) +}) + +var _ = Describe("EnsureServiceAccounts", func() { + Describe("Valid EnsureServiceAccounts", func() { + It("creates cloud-controller and skips writer if not artifact registry", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + RegistryType: gcp.RegistryTypeLocalContainer, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateServiceAccount("pid", "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + + err = bs.EnsureServiceAccounts() + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates both accounts for artifact registry", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + RegistryType: gcp.RegistryTypeArtifactRegistry, + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateServiceAccount("pid", "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + gc.EXPECT().CreateServiceAccount("pid", "artifact-registry-writer", "artifact-registry-writer").Return("writer@sa", true, nil) + gc.EXPECT().CreateServiceAccountKey("pid", "writer@sa").Return("key-content", nil) + + err = bs.EnsureServiceAccounts() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.InstallConfig.Registry.Password).To(Equal("key-content")) + }) + }) + + Describe("Invalid cases", func() { + It("fails when cloud-controller creation fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateServiceAccount("pid", "cloud-controller", "cloud-controller").Return("", false, fmt.Errorf("create error")) + + err = bs.EnsureServiceAccounts() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("create error")) + }) + }) +}) + +var _ = Describe("EnsureIAMRoles", func() { + Describe("Valid EnsureIAMRoles", func() { + It("assigns roles correctly", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + RegistryType: gcp.RegistryTypeArtifactRegistry, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().AssignIAMRole("pid", "cloud-controller", "roles/compute.admin").Return(nil) + gc.EXPECT().AssignIAMRole("pid", "artifact-registry-writer", "roles/artifactregistry.writer").Return(nil) + + err = bs.EnsureIAMRoles() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when AssignIAMRole fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().AssignIAMRole("pid", "cloud-controller", "roles/compute.admin").Return(fmt.Errorf("iam error")) + + err = bs.EnsureIAMRoles() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("iam error")) + }) + }) +}) + +var _ = Describe("EnsureVPC", func() { + Describe("Valid EnsureVPC", func() { + It("creates VPC, subnet, router, and nat", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateVPC("pid", "us-central1", "pid-vpc", "pid-us-central1-subnet", "pid-router", "pid-nat-gateway").Return(nil) + + err = bs.EnsureVPC() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when CreateVPC fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateVPC("pid", "us-central1", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("vpc error")) + + err = bs.EnsureVPC() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure VPC")) + Expect(err.Error()).To(ContainSubstring("vpc error")) + }) + }) +}) + +var _ = Describe("EnsureFirewallRules", func() { + Describe("Valid EnsureFirewallRules", func() { + It("creates required firewall rules", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Expect 4 rules: allow-ssh-ext, allow-internal, allow-all-egress, allow-ingress-web, allow-ingress-postgres + // Wait, code showed 5 blocks? ssh, internal, egress, web, postgres. + gc.EXPECT().CreateFirewallRule("pid", mock.MatchedBy(func(r *computepb.Firewall) bool { + return *r.Name == "allow-ssh-ext" + })).Return(nil) + gc.EXPECT().CreateFirewallRule("pid", mock.MatchedBy(func(r *computepb.Firewall) bool { + return *r.Name == "allow-internal" + })).Return(nil) + gc.EXPECT().CreateFirewallRule("pid", mock.MatchedBy(func(r *computepb.Firewall) bool { + return *r.Name == "allow-all-egress" + })).Return(nil) + gc.EXPECT().CreateFirewallRule("pid", mock.MatchedBy(func(r *computepb.Firewall) bool { + return *r.Name == "allow-ingress-web" + })).Return(nil) + gc.EXPECT().CreateFirewallRule("pid", mock.MatchedBy(func(r *computepb.Firewall) bool { + return *r.Name == "allow-ingress-postgres" + })).Return(nil) + + err = bs.EnsureFirewallRules() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when first firewall rule creation fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().CreateFirewallRule("pid", mock.Anything).Return(fmt.Errorf("firewall error")).Once() + + err = bs.EnsureFirewallRules() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create jumpbox ssh firewall rule")) + }) + }) +}) + +var _ = Describe("EnsureComputeInstances", func() { + Describe("Valid EnsureComputeInstances", func() { + It("creates all instances", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + Zone: "us-central1-a", + SSHPublicKeyPath: "key.pub", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Mock ReadFile for SSH key (called 9 times in parallel) + fw.EXPECT().ReadFile("key.pub").Return([]byte("ssh-rsa AAA..."), nil).Times(9) + + // Mock CreateInstance (9 times) + gc.EXPECT().CreateInstance("pid", "us-central1-a", mock.Anything).Return(nil).Times(9) + + // Mock GetInstance (9 times) + ipResp := &computepb.Instance{ + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: protoString("10.0.0.x"), + AccessConfigs: []*computepb.AccessConfig{ + {NatIP: protoString("1.2.3.x")}, + }, + }, + }, + } + gc.EXPECT().GetInstance("pid", "us-central1-a", mock.Anything).Return(ipResp, nil).Times(9) + + // Mock UpdateNode (called once for jumpbox to update the original NodeManager) + nm.EXPECT().UpdateNode(mock.Anything, mock.Anything, mock.Anything).Once() + + // Mock CreateSubNode (8 times for postgres, ceph, k0s - now in main goroutine after channel) + nm.EXPECT().CreateSubNode(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(name, extIP, intIP string) node.NodeManager { + m := node.NewMockNodeManager(GinkgoT()) + m.EXPECT().GetName().Return(name).Maybe() // Allow Name() calls for sorting + return m + }).Times(8) + + err = bs.EnsureComputeInstances() + Expect(err).NotTo(HaveOccurred()) + Expect(len(bs.Env.ControlPlaneNodes)).To(Equal(3)) + Expect(len(bs.Env.CephNodes)).To(Equal(4)) + Expect(bs.Env.PostgreSQLNode).NotTo(BeNil()) + Expect(bs.Env.Jumpbox).NotTo(BeNil()) // Jumpbox is now the NodeManager itself after UpdateNode + }) + }) + + Describe("Invalid cases", func() { + It("fails when SSH key read fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + Zone: "us-central1-a", + SSHPublicKeyPath: "key.pub", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().ReadFile("key.pub").Return(nil, fmt.Errorf("read error")).Maybe() + + err = bs.EnsureComputeInstances() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error ensuring compute instances")) + }) + + It("fails when CreateInstance fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + Zone: "us-central1-a", + SSHPublicKeyPath: "key.pub", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().ReadFile("key.pub").Return([]byte("ssh-rsa AAA..."), nil).Maybe() + gc.EXPECT().CreateInstance("pid", "us-central1-a", mock.Anything).Return(fmt.Errorf("create error")).Maybe() + + err = bs.EnsureComputeInstances() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error ensuring compute instances")) + }) + + It("fails when GetInstance fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + Zone: "us-central1-a", + SSHPublicKeyPath: "key.pub", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + fw.EXPECT().ReadFile("key.pub").Return([]byte("ssh-rsa AAA..."), nil).Maybe() + gc.EXPECT().CreateInstance("pid", "us-central1-a", mock.Anything).Return(nil).Maybe() + gc.EXPECT().GetInstance("pid", "us-central1-a", mock.Anything).Return(nil, fmt.Errorf("get error")).Maybe() + + err = bs.EnsureComputeInstances() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error ensuring compute instances")) + }) + }) +}) + +var _ = Describe("EnsureGatewayIPAddresses", func() { + Describe("Valid EnsureGatewayIPAddresses", func() { + It("creates two addresses", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Gateway + gc.EXPECT().GetAddress("pid", "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("pid", "us-central1", mock.MatchedBy(func(a *computepb.Address) bool { + return *a.Name == "gateway" + })).Return("1.1.1.1", nil) + + // Public Gateway + gc.EXPECT().GetAddress("pid", "us-central1", "public-gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("pid", "us-central1", mock.MatchedBy(func(a *computepb.Address) bool { + return *a.Name == "public-gateway" + })).Return("2.2.2.2", nil) + + err = bs.EnsureGatewayIPAddresses() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.GatewayIP).To(Equal("1.1.1.1")) + Expect(bs.Env.PublicGatewayIP).To(Equal("2.2.2.2")) + }) + }) + + Describe("Invalid cases", func() { + It("fails when gateway IP creation fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetAddress("pid", "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("pid", "us-central1", mock.Anything).Return("", fmt.Errorf("create error")) + + err = bs.EnsureGatewayIPAddresses() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure gateway IP")) + }) + + It("fails when public gateway IP creation fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + ProjectID: "pid", + Region: "us-central1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().GetAddress("pid", "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("pid", "us-central1", mock.MatchedBy(func(a *computepb.Address) bool { + return *a.Name == "gateway" + })).Return("1.1.1.1", nil) + gc.EXPECT().GetAddress("pid", "us-central1", "public-gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress("pid", "us-central1", mock.MatchedBy(func(a *computepb.Address) bool { + return *a.Name == "public-gateway" + })).Return("", fmt.Errorf("create error")) + + err = bs.EnsureGatewayIPAddresses() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure public gateway IP")) + }) + }) +}) + +var _ = Describe("EnsureRootLoginEnabled", func() { + Describe("Valid EnsureRootLoginEnabled", func() { + It("enables root login on all nodes", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + // Setup nodes + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Use the same mock for all for simplicity, but expect multiple calls + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{nm} + bs.Env.CephNodes = []node.NodeManager{nm} + + // Total nodes: 1 (jumpbox) + 1 (pg) + 1 (cp) + 1 (ceph) = 4 + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().WaitForSSH(mock.Anything).Return(nil).Times(4) + nm.EXPECT().HasRootLoginEnabled().Return(false).Times(4) + nm.EXPECT().EnableRootLogin().Return(nil).Times(4) + + err = bs.EnsureRootLoginEnabled() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when WaitForSSH times out", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{} + bs.Env.CephNodes = []node.NodeManager{} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().WaitForSSH(mock.Anything).Return(fmt.Errorf("timeout")).Once() + + err = bs.EnsureRootLoginEnabled() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timed out waiting for SSH service")) + }) + + It("fails when EnableRootLogin fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{} + bs.Env.CephNodes = []node.NodeManager{} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().WaitForSSH(mock.Anything).Return(nil).Once() + nm.EXPECT().HasRootLoginEnabled().Return(false).Once() + nm.EXPECT().EnableRootLogin().Return(fmt.Errorf("enable error")).Times(3) + + err = bs.EnsureRootLoginEnabled() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to enable root login")) + }) + }) +}) + +var _ = Describe("EnsureJumpboxConfigured", func() { + Describe("Valid EnsureJumpboxConfigured", func() { + It("configures jumpbox", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + + nm.EXPECT().HasAcceptEnvConfigured().Return(false) + nm.EXPECT().ConfigureAcceptEnv().Return(nil) + nm.EXPECT().HasCommand("oms-cli").Return(false) + nm.EXPECT().InstallOms().Return(nil) + + err = bs.EnsureJumpboxConfigured() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when ConfigureAcceptEnv fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + + nm.EXPECT().HasAcceptEnvConfigured().Return(false) + nm.EXPECT().ConfigureAcceptEnv().Return(fmt.Errorf("config error")) + + err = bs.EnsureJumpboxConfigured() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to configure AcceptEnv")) + }) + + It("fails when InstallOms fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + + nm.EXPECT().HasAcceptEnvConfigured().Return(true) + nm.EXPECT().HasCommand("oms-cli").Return(false) + nm.EXPECT().InstallOms().Return(fmt.Errorf("install error")) + + err = bs.EnsureJumpboxConfigured() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install OMS")) + }) + }) +}) + +var _ = Describe("EnsureHostsConfigured", func() { + Describe("Valid EnsureHostsConfigured", func() { + It("configures hosts", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Setup nodes + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{nm} + bs.Env.CephNodes = []node.NodeManager{} // Empty to reduce calls + + // Total nodes: 1 (pg) + 1 (cp) = 2 + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().HasInotifyWatchesConfigured().Return(false).Times(2) + nm.EXPECT().ConfigureInotifyWatches().Return(nil).Times(2) + nm.EXPECT().HasMemoryMapConfigured().Return(false).Times(2) + nm.EXPECT().ConfigureMemoryMap().Return(nil).Times(2) + + err = bs.EnsureHostsConfigured() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when ConfigureInotifyWatches fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{} + bs.Env.CephNodes = []node.NodeManager{} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().HasInotifyWatchesConfigured().Return(false) + nm.EXPECT().ConfigureInotifyWatches().Return(fmt.Errorf("inotify error")) + + err = bs.EnsureHostsConfigured() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to configure inotify watches")) + }) + + It("fails when ConfigureMemoryMap fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{} + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.PostgreSQLNode = nm + bs.Env.ControlPlaneNodes = []node.NodeManager{} + bs.Env.CephNodes = []node.NodeManager{} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().HasInotifyWatchesConfigured().Return(true) + nm.EXPECT().HasMemoryMapConfigured().Return(false) + nm.EXPECT().ConfigureMemoryMap().Return(fmt.Errorf("memory map error")) + + err = bs.EnsureHostsConfigured() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to configure memory map")) + }) + }) +}) + +var _ = Describe("UpdateInstallConfig", func() { + Describe("Valid UpdateInstallConfig", func() { + It("updates config and writes files", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Setup Nodes + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() // For PublicIP + + // Expectations + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("config.yaml", true).Return(nil) + icg.EXPECT().WriteVault("secrets.yaml", true).Return(nil) + + nm.EXPECT().CopyFile("config.yaml", "/etc/codesphere/config.yaml").Return(nil) + nm.EXPECT().CopyFile("secrets.yaml", "/secrets/prod.vault.yaml").Return(nil) + + err = bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Datacenter.ID).To(Equal(1)) + Expect(bs.Env.InstallConfig.Codesphere.Domain).To(Equal("cs.example.com")) + }) + }) + + Describe("Invalid cases", func() { + It("fails when GenerateSecrets fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() + + icg.EXPECT().GenerateSecrets().Return(fmt.Errorf("generate error")) + + err = bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to generate secrets")) + }) + + It("fails when WriteInstallConfig fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("config.yaml", true).Return(fmt.Errorf("write error")) + + err = bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write config file")) + }) + + It("fails when WriteVault fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("config.yaml", true).Return(nil) + icg.EXPECT().WriteVault("secrets.yaml", true).Return(fmt.Errorf("vault write error")) + + err = bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write vault file")) + }) + + It("fails when CopyFile config fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("config.yaml", true).Return(nil) + icg.EXPECT().WriteVault("secrets.yaml", true).Return(nil) + nm.EXPECT().CopyFile("config.yaml", "/etc/codesphere/config.yaml").Return(fmt.Errorf("copy error")) + + err = bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy install config to jumpbox")) + }) + + It("fails when CopyFile secrets fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "secrets.yaml", + DatacenterID: 1, + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + GithubAppClientID: "gh-id", + GithubAppClientSecret: "gh-secret", + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.Jumpbox = nm + bs.Env.PostgreSQLNode = nm + bs.Env.CephNodes = []node.NodeManager{nm, nm, nm, nm} + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetName().Return("mock-node").Maybe() + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + nm.EXPECT().GetExternalIP().Return("8.8.8.8").Maybe() + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("config.yaml", true).Return(nil) + icg.EXPECT().WriteVault("secrets.yaml", true).Return(nil) + nm.EXPECT().CopyFile("config.yaml", "/etc/codesphere/config.yaml").Return(nil) + nm.EXPECT().CopyFile("secrets.yaml", "/secrets/prod.vault.yaml").Return(fmt.Errorf("copy error")) + + err = bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy secrets file to jumpbox")) + }) + }) +}) + +var _ = Describe("EnsureAgeKey", func() { + Describe("Valid EnsureAgeKey", func() { + It("generates key if missing", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().HasFile("/secrets/age_key.txt").Return(false) + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "age-keygen") + }), true).Return(nil) + + err = bs.EnsureAgeKey() + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips if key exists", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().HasFile("/secrets/age_key.txt").Return(true) + // No SSH command expected + + err = bs.EnsureAgeKey() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when age-keygen command fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().HasFile("/secrets/age_key.txt").Return(false) + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "age-keygen") + }), true).Return(fmt.Errorf("keygen error")) + + err = bs.EnsureAgeKey() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to generate age key on jumpbox")) + }) + }) +}) + +var _ = Describe("EncryptVault", func() { + Describe("Valid EncryptVault", func() { + It("encrypts vault using sops", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + }), true).Return(nil) + + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "sops --encrypt") + }), true).Return(nil) + + err = bs.EncryptVault() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when backup vault command fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + }), true).Return(fmt.Errorf("backup error")) + + err = bs.EncryptVault() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed backup vault on jumpbox")) + }) + + It("fails when sops encrypt command fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + }), true).Return(nil) + + nm.EXPECT().RunSSHCommand("root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "sops --encrypt") + }), true).Return(fmt.Errorf("encrypt error")) + + err = bs.EncryptVault() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to encrypt vault on jumpbox")) + }) + }) +}) + +var _ = Describe("EnsureDNSRecords", func() { + Describe("Valid EnsureDNSRecords", func() { + It("ensures DNS records", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + DNSProjectID: "dns-proj", + DNSZoneName: "zone", + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().EnsureDNSManagedZone("dns-proj", "zone", "example.com.", mock.Anything).Return(nil) + gc.EXPECT().EnsureDNSRecordSets("dns-proj", "zone", mock.MatchedBy(func(records []*dns.ResourceRecordSet) bool { + // Expect 4 records: *.ws, *.cs, cs, ws + return len(records) == 4 + })).Return(nil) + + err = bs.EnsureDNSRecords() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when EnsureDNSManagedZone fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + DNSProjectID: "dns-proj", + DNSZoneName: "zone", + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().EnsureDNSManagedZone("dns-proj", "zone", "example.com.", mock.Anything).Return(fmt.Errorf("zone error")) + + err = bs.EnsureDNSRecords() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure DNS managed zone")) + }) + + It("fails when EnsureDNSRecordSets fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + DNSProjectID: "dns-proj", + DNSZoneName: "zone", + BaseDomain: "example.com", + GatewayIP: "1.1.1.1", + PublicGatewayIP: "2.2.2.2", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + gc.EXPECT().EnsureDNSManagedZone("dns-proj", "zone", "example.com.", mock.Anything).Return(nil) + gc.EXPECT().EnsureDNSRecordSets("dns-proj", "zone", mock.Anything).Return(fmt.Errorf("record error")) + + err = bs.EnsureDNSRecords() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure DNS record sets")) + }) + }) +}) + +var _ = Describe("InstallCodesphere", func() { + Describe("Valid InstallCodesphere", func() { + It("downloads and installs codesphere", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallCodesphereVersion: "v1.2.3", + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + // Expect download package + nm.EXPECT().RunSSHCommand("root", "oms-cli download package v1.2.3", true).Return(nil) + + // Expect install codesphere + nm.EXPECT().RunSSHCommand("root", "oms-cli install codesphere -c /etc/codesphere/config.yaml -k /secrets/age_key.txt -p v1.2.3.tar.gz", true).Return(nil) + + err = bs.InstallCodesphere() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when download package fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallCodesphereVersion: "v1.2.3", + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().RunSSHCommand("root", "oms-cli download package v1.2.3", true).Return(fmt.Errorf("download error")) + + err = bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download Codesphere package from jumpbox")) + }) + + It("fails when install codesphere fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + InstallCodesphereVersion: "v1.2.3", + SecretsDir: "/secrets", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + bs.Env.Jumpbox = nm + + nm.EXPECT().RunSSHCommand("root", "oms-cli download package v1.2.3", true).Return(nil) + nm.EXPECT().RunSSHCommand("root", "oms-cli install codesphere -c /etc/codesphere/config.yaml -k /secrets/age_key.txt -p v1.2.3.tar.gz", true).Return(fmt.Errorf("install error")) + + err = bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install Codesphere from jumpbox")) + }) + }) +}) + +var _ = Describe("GenerateK0sConfigScript", func() { + Describe("Valid GenerateK0sConfigScript", func() { + It("generates script", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + PublicGatewayIP: "2.2.2.2", + GatewayIP: "1.1.1.1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + // Setup required nodes (indices 0, 1, 2 accessed) + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + + fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(nil) + nm.EXPECT().CopyFile("configure-k0s.sh", "/root/configure-k0s.sh").Return(nil) + nm.EXPECT().RunSSHCommand("root", "chmod +x /root/configure-k0s.sh", true).Return(nil) + + err = bs.GenerateK0sConfigScript() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when WriteFile fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + PublicGatewayIP: "2.2.2.2", + GatewayIP: "1.1.1.1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + + fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(fmt.Errorf("write error")) + + err = bs.GenerateK0sConfigScript() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write configure-k0s.sh")) + }) + + It("fails when CopyFile fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + PublicGatewayIP: "2.2.2.2", + GatewayIP: "1.1.1.1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + + fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(nil) + nm.EXPECT().CopyFile("configure-k0s.sh", "/root/configure-k0s.sh").Return(fmt.Errorf("copy error")) + + err = bs.GenerateK0sConfigScript() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy configure-k0s.sh to control plane node")) + }) + + It("fails when RunSSHCommand chmod fails", func() { + env := env.NewEnv() + ctx := context.Background() + csEnv := &gcp.CodesphereEnvironment{ + PublicGatewayIP: "2.2.2.2", + GatewayIP: "1.1.1.1", + } + stlog := bootstrap.NewStepLogger(false) + + icg := installer.NewMockInstallConfigManager(GinkgoT()) + gc := gcp.NewMockGCPClientManager(GinkgoT()) + fw := util.NewMockFileIO(GinkgoT()) + nm := node.NewMockNodeManager(GinkgoT()) + + bs, err := gcp.NewGCPBootstrapper(ctx, env, stlog, csEnv, icg, gc, nm, fw) + Expect(err).NotTo(HaveOccurred()) + + bs.Env.ControlPlaneNodes = []node.NodeManager{nm, nm, nm} + + nm.EXPECT().GetInternalIP().Return("10.0.0.1").Maybe() + + fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(nil) + nm.EXPECT().CopyFile("configure-k0s.sh", "/root/configure-k0s.sh").Return(nil) + nm.EXPECT().RunSSHCommand("root", "chmod +x /root/configure-k0s.sh", true).Return(fmt.Errorf("chmod error")) + + err = bs.GenerateK0sConfigScript() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make configure-k0s.sh executable")) + }) + }) +}) diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go new file mode 100644 index 00000000..03416a48 --- /dev/null +++ b/internal/bootstrap/gcp/mocks.go @@ -0,0 +1,1316 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package gcp + +import ( + "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" + "cloud.google.com/go/compute/apiv1/computepb" + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + mock "github.com/stretchr/testify/mock" + "google.golang.org/api/cloudbilling/v1" + "google.golang.org/api/dns/v1" +) + +// NewMockGCPClientManager creates a new instance of MockGCPClientManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGCPClientManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGCPClientManager { + mock := &MockGCPClientManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockGCPClientManager is an autogenerated mock type for the GCPClientManager type +type MockGCPClientManager struct { + mock.Mock +} + +type MockGCPClientManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGCPClientManager) EXPECT() *MockGCPClientManager_Expecter { + return &MockGCPClientManager_Expecter{mock: &_m.Mock} +} + +// AssignIAMRole provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) AssignIAMRole(projectID string, saEmail string, role string) error { + ret := _mock.Called(projectID, saEmail, role) + + if len(ret) == 0 { + panic("no return value specified for AssignIAMRole") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(projectID, saEmail, role) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_AssignIAMRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssignIAMRole' +type MockGCPClientManager_AssignIAMRole_Call struct { + *mock.Call +} + +// AssignIAMRole is a helper method to define mock.On call +// - projectID string +// - saEmail string +// - role string +func (_e *MockGCPClientManager_Expecter) AssignIAMRole(projectID interface{}, saEmail interface{}, role interface{}) *MockGCPClientManager_AssignIAMRole_Call { + return &MockGCPClientManager_AssignIAMRole_Call{Call: _e.mock.On("AssignIAMRole", projectID, saEmail, role)} +} + +func (_c *MockGCPClientManager_AssignIAMRole_Call) Run(run func(projectID string, saEmail string, role string)) *MockGCPClientManager_AssignIAMRole_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_AssignIAMRole_Call) Return(err error) *MockGCPClientManager_AssignIAMRole_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_AssignIAMRole_Call) RunAndReturn(run func(projectID string, saEmail string, role string) error) *MockGCPClientManager_AssignIAMRole_Call { + _c.Call.Return(run) + return _c +} + +// CreateAddress provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateAddress(projectID string, region string, address *computepb.Address) (string, error) { + ret := _mock.Called(projectID, region, address) + + if len(ret) == 0 { + panic("no return value specified for CreateAddress") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, *computepb.Address) (string, error)); ok { + return returnFunc(projectID, region, address) + } + if returnFunc, ok := ret.Get(0).(func(string, string, *computepb.Address) string); ok { + r0 = returnFunc(projectID, region, address) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string, *computepb.Address) error); ok { + r1 = returnFunc(projectID, region, address) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_CreateAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAddress' +type MockGCPClientManager_CreateAddress_Call struct { + *mock.Call +} + +// CreateAddress is a helper method to define mock.On call +// - projectID string +// - region string +// - address *computepb.Address +func (_e *MockGCPClientManager_Expecter) CreateAddress(projectID interface{}, region interface{}, address interface{}) *MockGCPClientManager_CreateAddress_Call { + return &MockGCPClientManager_CreateAddress_Call{Call: _e.mock.On("CreateAddress", projectID, region, address)} +} + +func (_c *MockGCPClientManager_CreateAddress_Call) Run(run func(projectID string, region string, address *computepb.Address)) *MockGCPClientManager_CreateAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 *computepb.Address + if args[2] != nil { + arg2 = args[2].(*computepb.Address) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateAddress_Call) Return(s string, err error) *MockGCPClientManager_CreateAddress_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockGCPClientManager_CreateAddress_Call) RunAndReturn(run func(projectID string, region string, address *computepb.Address) (string, error)) *MockGCPClientManager_CreateAddress_Call { + _c.Call.Return(run) + return _c +} + +// CreateArtifactRegistry provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateArtifactRegistry(projectID string, region string, repoName string) (*artifactregistrypb.Repository, error) { + ret := _mock.Called(projectID, region, repoName) + + if len(ret) == 0 { + panic("no return value specified for CreateArtifactRegistry") + } + + var r0 *artifactregistrypb.Repository + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (*artifactregistrypb.Repository, error)); ok { + return returnFunc(projectID, region, repoName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) *artifactregistrypb.Repository); ok { + r0 = returnFunc(projectID, region, repoName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*artifactregistrypb.Repository) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = returnFunc(projectID, region, repoName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_CreateArtifactRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateArtifactRegistry' +type MockGCPClientManager_CreateArtifactRegistry_Call struct { + *mock.Call +} + +// CreateArtifactRegistry is a helper method to define mock.On call +// - projectID string +// - region string +// - repoName string +func (_e *MockGCPClientManager_Expecter) CreateArtifactRegistry(projectID interface{}, region interface{}, repoName interface{}) *MockGCPClientManager_CreateArtifactRegistry_Call { + return &MockGCPClientManager_CreateArtifactRegistry_Call{Call: _e.mock.On("CreateArtifactRegistry", projectID, region, repoName)} +} + +func (_c *MockGCPClientManager_CreateArtifactRegistry_Call) Run(run func(projectID string, region string, repoName string)) *MockGCPClientManager_CreateArtifactRegistry_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateArtifactRegistry_Call) Return(repository *artifactregistrypb.Repository, err error) *MockGCPClientManager_CreateArtifactRegistry_Call { + _c.Call.Return(repository, err) + return _c +} + +func (_c *MockGCPClientManager_CreateArtifactRegistry_Call) RunAndReturn(run func(projectID string, region string, repoName string) (*artifactregistrypb.Repository, error)) *MockGCPClientManager_CreateArtifactRegistry_Call { + _c.Call.Return(run) + return _c +} + +// CreateFirewallRule provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateFirewallRule(projectID string, rule *computepb.Firewall) error { + ret := _mock.Called(projectID, rule) + + if len(ret) == 0 { + panic("no return value specified for CreateFirewallRule") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, *computepb.Firewall) error); ok { + r0 = returnFunc(projectID, rule) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_CreateFirewallRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateFirewallRule' +type MockGCPClientManager_CreateFirewallRule_Call struct { + *mock.Call +} + +// CreateFirewallRule is a helper method to define mock.On call +// - projectID string +// - rule *computepb.Firewall +func (_e *MockGCPClientManager_Expecter) CreateFirewallRule(projectID interface{}, rule interface{}) *MockGCPClientManager_CreateFirewallRule_Call { + return &MockGCPClientManager_CreateFirewallRule_Call{Call: _e.mock.On("CreateFirewallRule", projectID, rule)} +} + +func (_c *MockGCPClientManager_CreateFirewallRule_Call) Run(run func(projectID string, rule *computepb.Firewall)) *MockGCPClientManager_CreateFirewallRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 *computepb.Firewall + if args[1] != nil { + arg1 = args[1].(*computepb.Firewall) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateFirewallRule_Call) Return(err error) *MockGCPClientManager_CreateFirewallRule_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_CreateFirewallRule_Call) RunAndReturn(run func(projectID string, rule *computepb.Firewall) error) *MockGCPClientManager_CreateFirewallRule_Call { + _c.Call.Return(run) + return _c +} + +// CreateInstance provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateInstance(projectID string, zone string, instance *computepb.Instance) error { + ret := _mock.Called(projectID, zone, instance) + + if len(ret) == 0 { + panic("no return value specified for CreateInstance") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, *computepb.Instance) error); ok { + r0 = returnFunc(projectID, zone, instance) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_CreateInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateInstance' +type MockGCPClientManager_CreateInstance_Call struct { + *mock.Call +} + +// CreateInstance is a helper method to define mock.On call +// - projectID string +// - zone string +// - instance *computepb.Instance +func (_e *MockGCPClientManager_Expecter) CreateInstance(projectID interface{}, zone interface{}, instance interface{}) *MockGCPClientManager_CreateInstance_Call { + return &MockGCPClientManager_CreateInstance_Call{Call: _e.mock.On("CreateInstance", projectID, zone, instance)} +} + +func (_c *MockGCPClientManager_CreateInstance_Call) Run(run func(projectID string, zone string, instance *computepb.Instance)) *MockGCPClientManager_CreateInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 *computepb.Instance + if args[2] != nil { + arg2 = args[2].(*computepb.Instance) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateInstance_Call) Return(err error) *MockGCPClientManager_CreateInstance_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_CreateInstance_Call) RunAndReturn(run func(projectID string, zone string, instance *computepb.Instance) error) *MockGCPClientManager_CreateInstance_Call { + _c.Call.Return(run) + return _c +} + +// CreateProject provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateProject(parent string, projectName string, displayName string) (string, error) { + ret := _mock.Called(parent, projectName, displayName) + + if len(ret) == 0 { + panic("no return value specified for CreateProject") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (string, error)); ok { + return returnFunc(parent, projectName, displayName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) string); ok { + r0 = returnFunc(parent, projectName, displayName) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = returnFunc(parent, projectName, displayName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_CreateProject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateProject' +type MockGCPClientManager_CreateProject_Call struct { + *mock.Call +} + +// CreateProject is a helper method to define mock.On call +// - parent string +// - projectName string +// - displayName string +func (_e *MockGCPClientManager_Expecter) CreateProject(parent interface{}, projectName interface{}, displayName interface{}) *MockGCPClientManager_CreateProject_Call { + return &MockGCPClientManager_CreateProject_Call{Call: _e.mock.On("CreateProject", parent, projectName, displayName)} +} + +func (_c *MockGCPClientManager_CreateProject_Call) Run(run func(parent string, projectName string, displayName string)) *MockGCPClientManager_CreateProject_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateProject_Call) Return(s string, err error) *MockGCPClientManager_CreateProject_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockGCPClientManager_CreateProject_Call) RunAndReturn(run func(parent string, projectName string, displayName string) (string, error)) *MockGCPClientManager_CreateProject_Call { + _c.Call.Return(run) + return _c +} + +// CreateProjectID provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateProjectID(projectName string) string { + ret := _mock.Called(projectName) + + if len(ret) == 0 { + panic("no return value specified for CreateProjectID") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string) string); ok { + r0 = returnFunc(projectName) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockGCPClientManager_CreateProjectID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateProjectID' +type MockGCPClientManager_CreateProjectID_Call struct { + *mock.Call +} + +// CreateProjectID is a helper method to define mock.On call +// - projectName string +func (_e *MockGCPClientManager_Expecter) CreateProjectID(projectName interface{}) *MockGCPClientManager_CreateProjectID_Call { + return &MockGCPClientManager_CreateProjectID_Call{Call: _e.mock.On("CreateProjectID", projectName)} +} + +func (_c *MockGCPClientManager_CreateProjectID_Call) Run(run func(projectName string)) *MockGCPClientManager_CreateProjectID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateProjectID_Call) Return(s string) *MockGCPClientManager_CreateProjectID_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockGCPClientManager_CreateProjectID_Call) RunAndReturn(run func(projectName string) string) *MockGCPClientManager_CreateProjectID_Call { + _c.Call.Return(run) + return _c +} + +// CreateServiceAccount provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateServiceAccount(projectID string, name string, displayName string) (string, bool, error) { + ret := _mock.Called(projectID, name, displayName) + + if len(ret) == 0 { + panic("no return value specified for CreateServiceAccount") + } + + var r0 string + var r1 bool + var r2 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (string, bool, error)); ok { + return returnFunc(projectID, name, displayName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) string); ok { + r0 = returnFunc(projectID, name, displayName) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) bool); ok { + r1 = returnFunc(projectID, name, displayName) + } else { + r1 = ret.Get(1).(bool) + } + if returnFunc, ok := ret.Get(2).(func(string, string, string) error); ok { + r2 = returnFunc(projectID, name, displayName) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockGCPClientManager_CreateServiceAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateServiceAccount' +type MockGCPClientManager_CreateServiceAccount_Call struct { + *mock.Call +} + +// CreateServiceAccount is a helper method to define mock.On call +// - projectID string +// - name string +// - displayName string +func (_e *MockGCPClientManager_Expecter) CreateServiceAccount(projectID interface{}, name interface{}, displayName interface{}) *MockGCPClientManager_CreateServiceAccount_Call { + return &MockGCPClientManager_CreateServiceAccount_Call{Call: _e.mock.On("CreateServiceAccount", projectID, name, displayName)} +} + +func (_c *MockGCPClientManager_CreateServiceAccount_Call) Run(run func(projectID string, name string, displayName string)) *MockGCPClientManager_CreateServiceAccount_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateServiceAccount_Call) Return(s string, b bool, err error) *MockGCPClientManager_CreateServiceAccount_Call { + _c.Call.Return(s, b, err) + return _c +} + +func (_c *MockGCPClientManager_CreateServiceAccount_Call) RunAndReturn(run func(projectID string, name string, displayName string) (string, bool, error)) *MockGCPClientManager_CreateServiceAccount_Call { + _c.Call.Return(run) + return _c +} + +// CreateServiceAccountKey provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateServiceAccountKey(projectID string, saEmail string) (string, error) { + ret := _mock.Called(projectID, saEmail) + + if len(ret) == 0 { + panic("no return value specified for CreateServiceAccountKey") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, error)); ok { + return returnFunc(projectID, saEmail) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(projectID, saEmail) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) error); ok { + r1 = returnFunc(projectID, saEmail) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_CreateServiceAccountKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateServiceAccountKey' +type MockGCPClientManager_CreateServiceAccountKey_Call struct { + *mock.Call +} + +// CreateServiceAccountKey is a helper method to define mock.On call +// - projectID string +// - saEmail string +func (_e *MockGCPClientManager_Expecter) CreateServiceAccountKey(projectID interface{}, saEmail interface{}) *MockGCPClientManager_CreateServiceAccountKey_Call { + return &MockGCPClientManager_CreateServiceAccountKey_Call{Call: _e.mock.On("CreateServiceAccountKey", projectID, saEmail)} +} + +func (_c *MockGCPClientManager_CreateServiceAccountKey_Call) Run(run func(projectID string, saEmail string)) *MockGCPClientManager_CreateServiceAccountKey_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateServiceAccountKey_Call) Return(s string, err error) *MockGCPClientManager_CreateServiceAccountKey_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockGCPClientManager_CreateServiceAccountKey_Call) RunAndReturn(run func(projectID string, saEmail string) (string, error)) *MockGCPClientManager_CreateServiceAccountKey_Call { + _c.Call.Return(run) + return _c +} + +// CreateVPC provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateVPC(projectID string, region string, networkName string, subnetName string, routerName string, natName string) error { + ret := _mock.Called(projectID, region, networkName, subnetName, routerName, natName) + + if len(ret) == 0 { + panic("no return value specified for CreateVPC") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok { + r0 = returnFunc(projectID, region, networkName, subnetName, routerName, natName) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_CreateVPC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateVPC' +type MockGCPClientManager_CreateVPC_Call struct { + *mock.Call +} + +// CreateVPC is a helper method to define mock.On call +// - projectID string +// - region string +// - networkName string +// - subnetName string +// - routerName string +// - natName string +func (_e *MockGCPClientManager_Expecter) CreateVPC(projectID interface{}, region interface{}, networkName interface{}, subnetName interface{}, routerName interface{}, natName interface{}) *MockGCPClientManager_CreateVPC_Call { + return &MockGCPClientManager_CreateVPC_Call{Call: _e.mock.On("CreateVPC", projectID, region, networkName, subnetName, routerName, natName)} +} + +func (_c *MockGCPClientManager_CreateVPC_Call) Run(run func(projectID string, region string, networkName string, subnetName string, routerName string, natName string)) *MockGCPClientManager_CreateVPC_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + var arg5 string + if args[5] != nil { + arg5 = args[5].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateVPC_Call) Return(err error) *MockGCPClientManager_CreateVPC_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_CreateVPC_Call) RunAndReturn(run func(projectID string, region string, networkName string, subnetName string, routerName string, natName string) error) *MockGCPClientManager_CreateVPC_Call { + _c.Call.Return(run) + return _c +} + +// EnableAPIs provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnableAPIs(projectID string, apis []string) error { + ret := _mock.Called(projectID, apis) + + if len(ret) == 0 { + panic("no return value specified for EnableAPIs") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, []string) error); ok { + r0 = returnFunc(projectID, apis) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnableAPIs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableAPIs' +type MockGCPClientManager_EnableAPIs_Call struct { + *mock.Call +} + +// EnableAPIs is a helper method to define mock.On call +// - projectID string +// - apis []string +func (_e *MockGCPClientManager_Expecter) EnableAPIs(projectID interface{}, apis interface{}) *MockGCPClientManager_EnableAPIs_Call { + return &MockGCPClientManager_EnableAPIs_Call{Call: _e.mock.On("EnableAPIs", projectID, apis)} +} + +func (_c *MockGCPClientManager_EnableAPIs_Call) Run(run func(projectID string, apis []string)) *MockGCPClientManager_EnableAPIs_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnableAPIs_Call) Return(err error) *MockGCPClientManager_EnableAPIs_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnableAPIs_Call) RunAndReturn(run func(projectID string, apis []string) error) *MockGCPClientManager_EnableAPIs_Call { + _c.Call.Return(run) + return _c +} + +// EnableBilling provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnableBilling(projectID string, billingAccount string) error { + ret := _mock.Called(projectID, billingAccount) + + if len(ret) == 0 { + panic("no return value specified for EnableBilling") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { + r0 = returnFunc(projectID, billingAccount) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnableBilling_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableBilling' +type MockGCPClientManager_EnableBilling_Call struct { + *mock.Call +} + +// EnableBilling is a helper method to define mock.On call +// - projectID string +// - billingAccount string +func (_e *MockGCPClientManager_Expecter) EnableBilling(projectID interface{}, billingAccount interface{}) *MockGCPClientManager_EnableBilling_Call { + return &MockGCPClientManager_EnableBilling_Call{Call: _e.mock.On("EnableBilling", projectID, billingAccount)} +} + +func (_c *MockGCPClientManager_EnableBilling_Call) Run(run func(projectID string, billingAccount string)) *MockGCPClientManager_EnableBilling_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnableBilling_Call) Return(err error) *MockGCPClientManager_EnableBilling_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnableBilling_Call) RunAndReturn(run func(projectID string, billingAccount string) error) *MockGCPClientManager_EnableBilling_Call { + _c.Call.Return(run) + return _c +} + +// EnsureDNSManagedZone provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnsureDNSManagedZone(projectID string, zoneName string, dnsName string, description string) error { + ret := _mock.Called(projectID, zoneName, dnsName, description) + + if len(ret) == 0 { + panic("no return value specified for EnsureDNSManagedZone") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string, string) error); ok { + r0 = returnFunc(projectID, zoneName, dnsName, description) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnsureDNSManagedZone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureDNSManagedZone' +type MockGCPClientManager_EnsureDNSManagedZone_Call struct { + *mock.Call +} + +// EnsureDNSManagedZone is a helper method to define mock.On call +// - projectID string +// - zoneName string +// - dnsName string +// - description string +func (_e *MockGCPClientManager_Expecter) EnsureDNSManagedZone(projectID interface{}, zoneName interface{}, dnsName interface{}, description interface{}) *MockGCPClientManager_EnsureDNSManagedZone_Call { + return &MockGCPClientManager_EnsureDNSManagedZone_Call{Call: _e.mock.On("EnsureDNSManagedZone", projectID, zoneName, dnsName, description)} +} + +func (_c *MockGCPClientManager_EnsureDNSManagedZone_Call) Run(run func(projectID string, zoneName string, dnsName string, description string)) *MockGCPClientManager_EnsureDNSManagedZone_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnsureDNSManagedZone_Call) Return(err error) *MockGCPClientManager_EnsureDNSManagedZone_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnsureDNSManagedZone_Call) RunAndReturn(run func(projectID string, zoneName string, dnsName string, description string) error) *MockGCPClientManager_EnsureDNSManagedZone_Call { + _c.Call.Return(run) + return _c +} + +// EnsureDNSRecordSets provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnsureDNSRecordSets(projectID string, zoneName string, records []*dns.ResourceRecordSet) error { + ret := _mock.Called(projectID, zoneName, records) + + if len(ret) == 0 { + panic("no return value specified for EnsureDNSRecordSets") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, []*dns.ResourceRecordSet) error); ok { + r0 = returnFunc(projectID, zoneName, records) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnsureDNSRecordSets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureDNSRecordSets' +type MockGCPClientManager_EnsureDNSRecordSets_Call struct { + *mock.Call +} + +// EnsureDNSRecordSets is a helper method to define mock.On call +// - projectID string +// - zoneName string +// - records []*dns.ResourceRecordSet +func (_e *MockGCPClientManager_Expecter) EnsureDNSRecordSets(projectID interface{}, zoneName interface{}, records interface{}) *MockGCPClientManager_EnsureDNSRecordSets_Call { + return &MockGCPClientManager_EnsureDNSRecordSets_Call{Call: _e.mock.On("EnsureDNSRecordSets", projectID, zoneName, records)} +} + +func (_c *MockGCPClientManager_EnsureDNSRecordSets_Call) Run(run func(projectID string, zoneName string, records []*dns.ResourceRecordSet)) *MockGCPClientManager_EnsureDNSRecordSets_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []*dns.ResourceRecordSet + if args[2] != nil { + arg2 = args[2].([]*dns.ResourceRecordSet) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnsureDNSRecordSets_Call) Return(err error) *MockGCPClientManager_EnsureDNSRecordSets_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnsureDNSRecordSets_Call) RunAndReturn(run func(projectID string, zoneName string, records []*dns.ResourceRecordSet) error) *MockGCPClientManager_EnsureDNSRecordSets_Call { + _c.Call.Return(run) + return _c +} + +// GetAddress provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GetAddress(projectID string, region string, addressName string) (*computepb.Address, error) { + ret := _mock.Called(projectID, region, addressName) + + if len(ret) == 0 { + panic("no return value specified for GetAddress") + } + + var r0 *computepb.Address + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (*computepb.Address, error)); ok { + return returnFunc(projectID, region, addressName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) *computepb.Address); ok { + r0 = returnFunc(projectID, region, addressName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*computepb.Address) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = returnFunc(projectID, region, addressName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_GetAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAddress' +type MockGCPClientManager_GetAddress_Call struct { + *mock.Call +} + +// GetAddress is a helper method to define mock.On call +// - projectID string +// - region string +// - addressName string +func (_e *MockGCPClientManager_Expecter) GetAddress(projectID interface{}, region interface{}, addressName interface{}) *MockGCPClientManager_GetAddress_Call { + return &MockGCPClientManager_GetAddress_Call{Call: _e.mock.On("GetAddress", projectID, region, addressName)} +} + +func (_c *MockGCPClientManager_GetAddress_Call) Run(run func(projectID string, region string, addressName string)) *MockGCPClientManager_GetAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GetAddress_Call) Return(address *computepb.Address, err error) *MockGCPClientManager_GetAddress_Call { + _c.Call.Return(address, err) + return _c +} + +func (_c *MockGCPClientManager_GetAddress_Call) RunAndReturn(run func(projectID string, region string, addressName string) (*computepb.Address, error)) *MockGCPClientManager_GetAddress_Call { + _c.Call.Return(run) + return _c +} + +// GetArtifactRegistry provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GetArtifactRegistry(projectID string, region string, repoName string) (*artifactregistrypb.Repository, error) { + ret := _mock.Called(projectID, region, repoName) + + if len(ret) == 0 { + panic("no return value specified for GetArtifactRegistry") + } + + var r0 *artifactregistrypb.Repository + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (*artifactregistrypb.Repository, error)); ok { + return returnFunc(projectID, region, repoName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) *artifactregistrypb.Repository); ok { + r0 = returnFunc(projectID, region, repoName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*artifactregistrypb.Repository) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = returnFunc(projectID, region, repoName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_GetArtifactRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetArtifactRegistry' +type MockGCPClientManager_GetArtifactRegistry_Call struct { + *mock.Call +} + +// GetArtifactRegistry is a helper method to define mock.On call +// - projectID string +// - region string +// - repoName string +func (_e *MockGCPClientManager_Expecter) GetArtifactRegistry(projectID interface{}, region interface{}, repoName interface{}) *MockGCPClientManager_GetArtifactRegistry_Call { + return &MockGCPClientManager_GetArtifactRegistry_Call{Call: _e.mock.On("GetArtifactRegistry", projectID, region, repoName)} +} + +func (_c *MockGCPClientManager_GetArtifactRegistry_Call) Run(run func(projectID string, region string, repoName string)) *MockGCPClientManager_GetArtifactRegistry_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GetArtifactRegistry_Call) Return(repository *artifactregistrypb.Repository, err error) *MockGCPClientManager_GetArtifactRegistry_Call { + _c.Call.Return(repository, err) + return _c +} + +func (_c *MockGCPClientManager_GetArtifactRegistry_Call) RunAndReturn(run func(projectID string, region string, repoName string) (*artifactregistrypb.Repository, error)) *MockGCPClientManager_GetArtifactRegistry_Call { + _c.Call.Return(run) + return _c +} + +// GetBillingInfo provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) { + ret := _mock.Called(projectID) + + if len(ret) == 0 { + panic("no return value specified for GetBillingInfo") + } + + var r0 *cloudbilling.ProjectBillingInfo + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (*cloudbilling.ProjectBillingInfo, error)); ok { + return returnFunc(projectID) + } + if returnFunc, ok := ret.Get(0).(func(string) *cloudbilling.ProjectBillingInfo); ok { + r0 = returnFunc(projectID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*cloudbilling.ProjectBillingInfo) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(projectID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_GetBillingInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBillingInfo' +type MockGCPClientManager_GetBillingInfo_Call struct { + *mock.Call +} + +// GetBillingInfo is a helper method to define mock.On call +// - projectID string +func (_e *MockGCPClientManager_Expecter) GetBillingInfo(projectID interface{}) *MockGCPClientManager_GetBillingInfo_Call { + return &MockGCPClientManager_GetBillingInfo_Call{Call: _e.mock.On("GetBillingInfo", projectID)} +} + +func (_c *MockGCPClientManager_GetBillingInfo_Call) Run(run func(projectID string)) *MockGCPClientManager_GetBillingInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GetBillingInfo_Call) Return(projectBillingInfo *cloudbilling.ProjectBillingInfo, err error) *MockGCPClientManager_GetBillingInfo_Call { + _c.Call.Return(projectBillingInfo, err) + return _c +} + +func (_c *MockGCPClientManager_GetBillingInfo_Call) RunAndReturn(run func(projectID string) (*cloudbilling.ProjectBillingInfo, error)) *MockGCPClientManager_GetBillingInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetInstance provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GetInstance(projectID string, zone string, instanceName string) (*computepb.Instance, error) { + ret := _mock.Called(projectID, zone, instanceName) + + if len(ret) == 0 { + panic("no return value specified for GetInstance") + } + + var r0 *computepb.Instance + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) (*computepb.Instance, error)); ok { + return returnFunc(projectID, zone, instanceName) + } + if returnFunc, ok := ret.Get(0).(func(string, string, string) *computepb.Instance); ok { + r0 = returnFunc(projectID, zone, instanceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*computepb.Instance) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = returnFunc(projectID, zone, instanceName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_GetInstance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstance' +type MockGCPClientManager_GetInstance_Call struct { + *mock.Call +} + +// GetInstance is a helper method to define mock.On call +// - projectID string +// - zone string +// - instanceName string +func (_e *MockGCPClientManager_Expecter) GetInstance(projectID interface{}, zone interface{}, instanceName interface{}) *MockGCPClientManager_GetInstance_Call { + return &MockGCPClientManager_GetInstance_Call{Call: _e.mock.On("GetInstance", projectID, zone, instanceName)} +} + +func (_c *MockGCPClientManager_GetInstance_Call) Run(run func(projectID string, zone string, instanceName string)) *MockGCPClientManager_GetInstance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GetInstance_Call) Return(instance *computepb.Instance, err error) *MockGCPClientManager_GetInstance_Call { + _c.Call.Return(instance, err) + return _c +} + +func (_c *MockGCPClientManager_GetInstance_Call) RunAndReturn(run func(projectID string, zone string, instanceName string) (*computepb.Instance, error)) *MockGCPClientManager_GetInstance_Call { + _c.Call.Return(run) + return _c +} + +// GetProjectByName provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) GetProjectByName(folderID string, displayName string) (*resourcemanagerpb.Project, error) { + ret := _mock.Called(folderID, displayName) + + if len(ret) == 0 { + panic("no return value specified for GetProjectByName") + } + + var r0 *resourcemanagerpb.Project + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string) (*resourcemanagerpb.Project, error)); ok { + return returnFunc(folderID, displayName) + } + if returnFunc, ok := ret.Get(0).(func(string, string) *resourcemanagerpb.Project); ok { + r0 = returnFunc(folderID, displayName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*resourcemanagerpb.Project) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string) error); ok { + r1 = returnFunc(folderID, displayName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockGCPClientManager_GetProjectByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProjectByName' +type MockGCPClientManager_GetProjectByName_Call struct { + *mock.Call +} + +// GetProjectByName is a helper method to define mock.On call +// - folderID string +// - displayName string +func (_e *MockGCPClientManager_Expecter) GetProjectByName(folderID interface{}, displayName interface{}) *MockGCPClientManager_GetProjectByName_Call { + return &MockGCPClientManager_GetProjectByName_Call{Call: _e.mock.On("GetProjectByName", folderID, displayName)} +} + +func (_c *MockGCPClientManager_GetProjectByName_Call) Run(run func(folderID string, displayName string)) *MockGCPClientManager_GetProjectByName_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_GetProjectByName_Call) Return(project *resourcemanagerpb.Project, err error) *MockGCPClientManager_GetProjectByName_Call { + _c.Call.Return(project, err) + return _c +} + +func (_c *MockGCPClientManager_GetProjectByName_Call) RunAndReturn(run func(folderID string, displayName string) (*resourcemanagerpb.Project, error)) *MockGCPClientManager_GetProjectByName_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/bootstrap/gcp_client.go b/internal/bootstrap/gcp_client.go deleted file mode 100644 index 9e93e032..00000000 --- a/internal/bootstrap/gcp_client.go +++ /dev/null @@ -1,440 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package bootstrap - -import ( - "context" - "fmt" - "log" - "strings" - "sync" - - "slices" - - artifact "cloud.google.com/go/artifactregistry/apiv1" - artifactpb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" - compute "cloud.google.com/go/compute/apiv1" - "cloud.google.com/go/compute/apiv1/computepb" - "cloud.google.com/go/iam/apiv1/iampb" - resourcemanager "cloud.google.com/go/resourcemanager/apiv3" - "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" - serviceusage "cloud.google.com/go/serviceusage/apiv1" - "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" - "github.com/codesphere-cloud/oms/internal/util" - "google.golang.org/api/cloudbilling/v1" - "google.golang.org/api/iam/v1" - "google.golang.org/api/iterator" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// Interface for high-level GCP operations -type GCPClient interface { - GetProjectByName(ctx context.Context, folderID string, displayName string) (*resourcemanagerpb.Project, error) - CreateProject(ctx context.Context, parent, projectName, displayName string) (string, error) - GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) - EnableBilling(ctx context.Context, projectID, billingAccount string) error - EnableAPIs(ctx context.Context, projectID string, apis []string) error - GetArtifactRegistry(ctx context.Context, projectID, region, repoName string) (*artifactpb.Repository, error) - CreateArtifactRegistry(ctx context.Context, projectID, region, repoName string) (*artifactpb.Repository, error) - CreateServiceAccount(ctx context.Context, projectID, name, displayName string) (string, bool, error) - CreateServiceAccountKey(ctx context.Context, projectID, saEmail string) (string, error) - AssignIAMRole(ctx context.Context, projectID, saEmail, role string) error - CreateVPC(ctx context.Context, projectID, region, networkName, subnetName, routerName, natName string) error - CreateFirewallRule(ctx context.Context, projectID string, rule *computepb.Firewall) error -} - -// Concrete implementation -type RealGCPClient struct { - CredentialsFile string -} - -func NewGCPClient(credentialsFile string) *RealGCPClient { - return &RealGCPClient{ - CredentialsFile: credentialsFile, - } -} - -func (c *RealGCPClient) GetProjectByName(ctx context.Context, folderID string, displayName string) (*resourcemanagerpb.Project, error) { - client, err := resourcemanager.NewProjectsClient(ctx) - if err != nil { - return nil, err - } - defer util.IgnoreError(client.Close) - req := &resourcemanagerpb.ListProjectsRequest{ - Parent: fmt.Sprintf("folders/%s", folderID), - ShowDeleted: false, - } - - it := client.ListProjects(ctx, req) - - for { - project, err := it.Next() - if err == iterator.Done { - // No more results found - return nil, fmt.Errorf("project not found: %s", displayName) - } - if err != nil { - return nil, fmt.Errorf("error iterating projects: %w", err) - } - - // Because the filter is a prefix search on the display name, - // we should perform an exact match check here to be sure. - if project.GetDisplayName() == displayName { - return project, nil - } - } -} - -func (c *RealGCPClient) CreateProject(ctx context.Context, parent, projectID, displayName string) (string, error) { - client, err := resourcemanager.NewProjectsClient(ctx) - if err != nil { - return "", err - } - defer util.IgnoreError(client.Close) - project := &resourcemanagerpb.Project{ - ProjectId: projectID, - DisplayName: displayName, - Parent: parent, - } - op, err := client.CreateProject(ctx, &resourcemanagerpb.CreateProjectRequest{Project: project}) - if err != nil { - return "", err - } - resp, err := op.Wait(ctx) - if err != nil { - return "", err - } - return resp.ProjectId, nil -} - -func getProjectResourceName(projectID string) string { - return fmt.Sprintf("projects/%s", projectID) -} - -func (c *RealGCPClient) GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error) { - projectName := getProjectResourceName(projectID) - billingService, err := cloudbilling.NewService(context.Background()) - if err != nil { - return nil, err - } - billingInfo, err := billingService.Projects.GetBillingInfo(projectName).Do() - if err != nil { - return nil, err - } - return billingInfo, nil -} - -func (c *RealGCPClient) EnableBilling(ctx context.Context, projectID, billingAccount string) error { - billingService, err := cloudbilling.NewService(ctx) - if err != nil { - return err - } - projectName := getProjectResourceName(projectID) - billingInfo := &cloudbilling.ProjectBillingInfo{ - BillingAccountName: fmt.Sprintf("billingAccounts/%s", billingAccount), - } - _, err = billingService.Projects.UpdateBillingInfo(projectName, billingInfo).Context(ctx).Do() - return err -} - -func (c *RealGCPClient) EnableAPIs(ctx context.Context, projectID string, apis []string) error { - client, err := serviceusage.NewClient(ctx) - if err != nil { - return err - } - defer util.IgnoreError(client.Close) - // enable APIs in parallel - wg := sync.WaitGroup{} - errCh := make(chan error, len(apis)) - for _, api := range apis { - serviceName := fmt.Sprintf("projects/%s/services/%s", projectID, api) - wg.Add(1) - - go func(serviceName, api string) { - defer wg.Done() - - log.Printf("Enabling API %s", api) - - op, err := client.EnableService(ctx, &serviceusagepb.EnableServiceRequest{Name: serviceName}) - if status.Code(err) == codes.AlreadyExists { - log.Printf("API %s already enabled", api) - - return - } - if err != nil { - errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) - } - if _, err := op.Wait(ctx); err != nil { - errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) - } - - log.Printf("API %s enabled", api) - }(serviceName, api) - } - - wg.Wait() - close(errCh) - errStr := "" - for err := range errCh { - errStr += err.Error() + "; " - } - if len(errStr) > 0 { - return fmt.Errorf("errors occurred while enabling APIs: %s", errStr) - } - return nil -} - -func (c *RealGCPClient) CreateArtifactRegistry(ctx context.Context, projectID, region, repoName string) (*artifactpb.Repository, error) { - client, err := artifact.NewClient(ctx) - if err != nil { - return nil, err - } - defer util.IgnoreError(client.Close) - - parent := fmt.Sprintf("projects/%s/locations/%s", projectID, region) - repoReq := &artifactpb.CreateRepositoryRequest{ - Parent: parent, - RepositoryId: repoName, - Repository: &artifactpb.Repository{ - Format: artifactpb.Repository_DOCKER, - Description: "Codesphere managed registry", - }, - } - - op, err := client.CreateRepository(ctx, repoReq) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return nil, err - } - - _, err = op.Wait(ctx) - if err != nil { - return nil, err - } - - // get repo again to ensure all infos are stored, else e.g. uri would be missing - repo, err := c.GetArtifactRegistry(ctx, projectID, region, repoName) - if err != nil { - return nil, fmt.Errorf("failed to get newly created artifact registry: %w", err) - } - - return repo, nil -} - -func (c *RealGCPClient) GetArtifactRegistry(ctx context.Context, projectID, region, repoName string) (*artifactpb.Repository, error) { - fullRepoName := fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, region, repoName) - client, err := artifact.NewClient(ctx) - if err != nil { - return nil, err - } - defer util.IgnoreError(client.Close) - repo, err := client.GetRepository(ctx, &artifactpb.GetRepositoryRequest{ - Name: fullRepoName, - }) - if err != nil { - return nil, fmt.Errorf("failed to get artifact registry repository: %w", err) - } - return repo, nil -} - -func (c *RealGCPClient) CreateServiceAccount(ctx context.Context, projectID, name, displayName string) (string, bool, error) { - saMail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, projectID) - iamService, err := iam.NewService(ctx) - if err != nil { - return saMail, false, err - } - saReq := &iam.CreateServiceAccountRequest{ - AccountId: name, - ServiceAccount: &iam.ServiceAccount{ - DisplayName: displayName, - }, - } - _, err = iamService.Projects.ServiceAccounts.Create(fmt.Sprintf("projects/%s", projectID), saReq).Context(ctx).Do() - if err != nil && !strings.Contains(err.Error(), "already exists") { - return saMail, false, err - } - if err != nil && strings.Contains(err.Error(), "already exists") { - return saMail, false, nil - } - return saMail, true, nil -} - -func (c *RealGCPClient) CreateServiceAccountKey(ctx context.Context, projectID, saEmail string) (string, error) { - iamService, err := iam.NewService(ctx) - if err != nil { - return "", err - } - keyReq := &iam.CreateServiceAccountKeyRequest{} - saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) - key, err := iamService.Projects.ServiceAccounts.Keys.Create(saName, keyReq).Context(ctx).Do() - if err != nil { - return "", err - } - return string(key.PrivateKeyData), nil -} - -func (c *RealGCPClient) AssignIAMRole(ctx context.Context, projectID, saName, role string) error { - saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, projectID) - client, err := resourcemanager.NewProjectsClient(ctx) - if err != nil { - return err - } - defer util.IgnoreError(client.Close) - getReq := &iampb.GetIamPolicyRequest{ - Resource: fmt.Sprintf("projects/%s", projectID), - } - policy, err := client.GetIamPolicy(ctx, getReq) - if err != nil { - return err - } - member := fmt.Sprintf("serviceAccount:%s", saEmail) - // Check if already assigned - for _, binding := range policy.Bindings { - if binding.Role == role { - if slices.Contains(binding.Members, member) { - return nil - } - } - } - policy.Bindings = append(policy.Bindings, &iampb.Binding{ - Role: role, - Members: []string{member}, - }) - setReq := &iampb.SetIamPolicyRequest{ - Resource: fmt.Sprintf("projects/%s", projectID), - Policy: policy, - } - _, err = client.SetIamPolicy(ctx, setReq) - return err -} - -func (c *RealGCPClient) CreateVPC(ctx context.Context, projectID, region, networkName, subnetName, routerName, natName string) error { - networksClient, err := compute.NewNetworksRESTClient(ctx) - if err != nil { - return err - } - defer util.IgnoreError(networksClient.Close) - network := &computepb.Network{ - Name: &networkName, - AutoCreateSubnetworks: protoBool(false), - } - op, err := networksClient.Insert(ctx, &computepb.InsertNetworkRequest{ - Project: projectID, - NetworkResource: network, - }) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return err - } - if err == nil { - if err := op.Wait(ctx); err != nil { - return err - } - } - subnetsClient, err := compute.NewSubnetworksRESTClient(ctx) - if err != nil { - return err - } - defer util.IgnoreError(subnetsClient.Close) - subnet := &computepb.Subnetwork{ - Name: &subnetName, - IpCidrRange: protoString("10.10.0.0/20"), - Region: ®ion, - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), - } - op, err = subnetsClient.Insert(ctx, &computepb.InsertSubnetworkRequest{ - Project: projectID, - Region: region, - SubnetworkResource: subnet, - }) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return err - } - if err == nil { - if err := op.Wait(ctx); err != nil { - return err - } - } - - // Create Router - routersClient, err := compute.NewRoutersRESTClient(ctx) - if err != nil { - return fmt.Errorf("failed to create routers client: %w", err) - } - defer util.IgnoreError(routersClient.Close) - - router := &computepb.Router{ - Name: &routerName, - Region: ®ion, - Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), - } - op, err = routersClient.Insert(ctx, &computepb.InsertRouterRequest{ - Project: projectID, - Region: region, - RouterResource: router, - }) - if err != nil && !isAlreadyExistsError(err) { - return fmt.Errorf("failed to create router: %w", err) - } - if err == nil { - if err := op.Wait(ctx); err != nil { - return fmt.Errorf("failed to wait for router creation: %w", err) - } - } - - log.Printf("Router %s ensured", routerName) - - // Create NAT Gateway - natsClient, err := compute.NewRoutersRESTClient(ctx) - if err != nil { - return fmt.Errorf("failed to create routers client for NAT: %w", err) - } - defer util.IgnoreError(natsClient.Close) - - nat := &computepb.RouterNat{ - Name: &natName, - SourceSubnetworkIpRangesToNat: protoString("ALL_SUBNETWORKS_ALL_IP_RANGES"), - NatIpAllocateOption: protoString("AUTO_ONLY"), - LogConfig: &computepb.RouterNatLogConfig{ - Enable: protoBool(false), - Filter: protoString("ERRORS_ONLY"), - }, - } - // Patch NAT config to router - _, err = routersClient.Patch(ctx, &computepb.PatchRouterRequest{ - Project: projectID, - Region: region, - Router: routerName, - RouterResource: &computepb.Router{ - Name: &routerName, - Nats: []*computepb.RouterNat{nat}, - }, - }) - if err != nil && !isAlreadyExistsError(err) { - return fmt.Errorf("failed to create NAT gateway: %w", err) - } - - log.Printf("NAT gateway %s ensured", natName) - - return nil -} - -func (c *RealGCPClient) CreateFirewallRule(ctx context.Context, projectID string, rule *computepb.Firewall) error { - firewallsClient, err := compute.NewFirewallsRESTClient(ctx) - if err != nil { - return err - } - defer util.IgnoreError(firewallsClient.Close) - _, err = firewallsClient.Insert(ctx, &computepb.InsertFirewallRequest{ - Project: projectID, - FirewallResource: rule, - }) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return err - } - return nil -} - -// Helper functions -func protoString(s string) *string { return &s } -func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/ovh/ovh.go b/internal/bootstrap/ovh/ovh.go new file mode 100644 index 00000000..08936af4 --- /dev/null +++ b/internal/bootstrap/ovh/ovh.go @@ -0,0 +1,15 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ovh + +import "log" + +type OVHBootstrapper struct { + // Implementation details would go here +} + +func NewOVHBootstrapper() *OVHBootstrapper { + log.Println("OVH Bootstrapper not yet implemented") + return &OVHBootstrapper{} +} diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index fea5c44a..2709f6a9 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -5,7 +5,6 @@ package installer import ( "fmt" - "log" "github.com/codesphere-cloud/oms/internal/installer/files" ) @@ -121,7 +120,6 @@ func (g *InstallConfig) ApplyProfile(profile string) error { g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} - log.Println("Applied 'dev' profile: single-node development setup") case PROFILE_PROD, PROFILE_PRODUCTION: g.Config.Datacenter.Name = "production" @@ -160,7 +158,6 @@ func (g *InstallConfig) ApplyProfile(profile string) error { OnDemand: true, }, } - log.Println("Applied 'production' profile: HA multi-node setup") case PROFILE_MINIMAL: g.Config.Datacenter.Name = "minimal" @@ -184,7 +181,6 @@ func (g *InstallConfig) ApplyProfile(profile string) error { OnDemand: true, }, } - log.Println("Applied 'minimal' profile: minimal single-node setup") default: return fmt.Errorf("unknown profile: %s, available profiles: dev, prod, minimal", profile) diff --git a/internal/installer/node/mocks.go b/internal/installer/node/mocks.go new file mode 100644 index 00000000..6b28e4fb --- /dev/null +++ b/internal/installer/node/mocks.go @@ -0,0 +1,955 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package node + +import ( + mock "github.com/stretchr/testify/mock" + "time" +) + +// NewMockNodeManager creates a new instance of MockNodeManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockNodeManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockNodeManager { + mock := &MockNodeManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockNodeManager is an autogenerated mock type for the NodeManager type +type MockNodeManager struct { + mock.Mock +} + +type MockNodeManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockNodeManager) EXPECT() *MockNodeManager_Expecter { + return &MockNodeManager_Expecter{mock: &_m.Mock} +} + +// ConfigureAcceptEnv provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) ConfigureAcceptEnv() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ConfigureAcceptEnv") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_ConfigureAcceptEnv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigureAcceptEnv' +type MockNodeManager_ConfigureAcceptEnv_Call struct { + *mock.Call +} + +// ConfigureAcceptEnv is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) ConfigureAcceptEnv() *MockNodeManager_ConfigureAcceptEnv_Call { + return &MockNodeManager_ConfigureAcceptEnv_Call{Call: _e.mock.On("ConfigureAcceptEnv")} +} + +func (_c *MockNodeManager_ConfigureAcceptEnv_Call) Run(run func()) *MockNodeManager_ConfigureAcceptEnv_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_ConfigureAcceptEnv_Call) Return(err error) *MockNodeManager_ConfigureAcceptEnv_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_ConfigureAcceptEnv_Call) RunAndReturn(run func() error) *MockNodeManager_ConfigureAcceptEnv_Call { + _c.Call.Return(run) + return _c +} + +// ConfigureInotifyWatches provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) ConfigureInotifyWatches() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ConfigureInotifyWatches") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_ConfigureInotifyWatches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigureInotifyWatches' +type MockNodeManager_ConfigureInotifyWatches_Call struct { + *mock.Call +} + +// ConfigureInotifyWatches is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) ConfigureInotifyWatches() *MockNodeManager_ConfigureInotifyWatches_Call { + return &MockNodeManager_ConfigureInotifyWatches_Call{Call: _e.mock.On("ConfigureInotifyWatches")} +} + +func (_c *MockNodeManager_ConfigureInotifyWatches_Call) Run(run func()) *MockNodeManager_ConfigureInotifyWatches_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_ConfigureInotifyWatches_Call) Return(err error) *MockNodeManager_ConfigureInotifyWatches_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_ConfigureInotifyWatches_Call) RunAndReturn(run func() error) *MockNodeManager_ConfigureInotifyWatches_Call { + _c.Call.Return(run) + return _c +} + +// ConfigureMemoryMap provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) ConfigureMemoryMap() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ConfigureMemoryMap") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_ConfigureMemoryMap_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigureMemoryMap' +type MockNodeManager_ConfigureMemoryMap_Call struct { + *mock.Call +} + +// ConfigureMemoryMap is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) ConfigureMemoryMap() *MockNodeManager_ConfigureMemoryMap_Call { + return &MockNodeManager_ConfigureMemoryMap_Call{Call: _e.mock.On("ConfigureMemoryMap")} +} + +func (_c *MockNodeManager_ConfigureMemoryMap_Call) Run(run func()) *MockNodeManager_ConfigureMemoryMap_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_ConfigureMemoryMap_Call) Return(err error) *MockNodeManager_ConfigureMemoryMap_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_ConfigureMemoryMap_Call) RunAndReturn(run func() error) *MockNodeManager_ConfigureMemoryMap_Call { + _c.Call.Return(run) + return _c +} + +// CopyFile provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) CopyFile(src string, dst string) error { + ret := _mock.Called(src, dst) + + if len(ret) == 0 { + panic("no return value specified for CopyFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { + r0 = returnFunc(src, dst) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_CopyFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CopyFile' +type MockNodeManager_CopyFile_Call struct { + *mock.Call +} + +// CopyFile is a helper method to define mock.On call +// - src string +// - dst string +func (_e *MockNodeManager_Expecter) CopyFile(src interface{}, dst interface{}) *MockNodeManager_CopyFile_Call { + return &MockNodeManager_CopyFile_Call{Call: _e.mock.On("CopyFile", src, dst)} +} + +func (_c *MockNodeManager_CopyFile_Call) Run(run func(src string, dst string)) *MockNodeManager_CopyFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockNodeManager_CopyFile_Call) Return(err error) *MockNodeManager_CopyFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_CopyFile_Call) RunAndReturn(run func(src string, dst string) error) *MockNodeManager_CopyFile_Call { + _c.Call.Return(run) + return _c +} + +// CreateSubNode provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) CreateSubNode(name string, externalIP string, internalIP string) NodeManager { + ret := _mock.Called(name, externalIP, internalIP) + + if len(ret) == 0 { + panic("no return value specified for CreateSubNode") + } + + var r0 NodeManager + if returnFunc, ok := ret.Get(0).(func(string, string, string) NodeManager); ok { + r0 = returnFunc(name, externalIP, internalIP) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(NodeManager) + } + } + return r0 +} + +// MockNodeManager_CreateSubNode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSubNode' +type MockNodeManager_CreateSubNode_Call struct { + *mock.Call +} + +// CreateSubNode is a helper method to define mock.On call +// - name string +// - externalIP string +// - internalIP string +func (_e *MockNodeManager_Expecter) CreateSubNode(name interface{}, externalIP interface{}, internalIP interface{}) *MockNodeManager_CreateSubNode_Call { + return &MockNodeManager_CreateSubNode_Call{Call: _e.mock.On("CreateSubNode", name, externalIP, internalIP)} +} + +func (_c *MockNodeManager_CreateSubNode_Call) Run(run func(name string, externalIP string, internalIP string)) *MockNodeManager_CreateSubNode_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockNodeManager_CreateSubNode_Call) Return(nodeManager NodeManager) *MockNodeManager_CreateSubNode_Call { + _c.Call.Return(nodeManager) + return _c +} + +func (_c *MockNodeManager_CreateSubNode_Call) RunAndReturn(run func(name string, externalIP string, internalIP string) NodeManager) *MockNodeManager_CreateSubNode_Call { + _c.Call.Return(run) + return _c +} + +// EnableRootLogin provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) EnableRootLogin() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for EnableRootLogin") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_EnableRootLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableRootLogin' +type MockNodeManager_EnableRootLogin_Call struct { + *mock.Call +} + +// EnableRootLogin is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) EnableRootLogin() *MockNodeManager_EnableRootLogin_Call { + return &MockNodeManager_EnableRootLogin_Call{Call: _e.mock.On("EnableRootLogin")} +} + +func (_c *MockNodeManager_EnableRootLogin_Call) Run(run func()) *MockNodeManager_EnableRootLogin_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_EnableRootLogin_Call) Return(err error) *MockNodeManager_EnableRootLogin_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_EnableRootLogin_Call) RunAndReturn(run func() error) *MockNodeManager_EnableRootLogin_Call { + _c.Call.Return(run) + return _c +} + +// GetExternalIP provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) GetExternalIP() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetExternalIP") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockNodeManager_GetExternalIP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalIP' +type MockNodeManager_GetExternalIP_Call struct { + *mock.Call +} + +// GetExternalIP is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) GetExternalIP() *MockNodeManager_GetExternalIP_Call { + return &MockNodeManager_GetExternalIP_Call{Call: _e.mock.On("GetExternalIP")} +} + +func (_c *MockNodeManager_GetExternalIP_Call) Run(run func()) *MockNodeManager_GetExternalIP_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_GetExternalIP_Call) Return(s string) *MockNodeManager_GetExternalIP_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockNodeManager_GetExternalIP_Call) RunAndReturn(run func() string) *MockNodeManager_GetExternalIP_Call { + _c.Call.Return(run) + return _c +} + +// GetInternalIP provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) GetInternalIP() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetInternalIP") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockNodeManager_GetInternalIP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInternalIP' +type MockNodeManager_GetInternalIP_Call struct { + *mock.Call +} + +// GetInternalIP is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) GetInternalIP() *MockNodeManager_GetInternalIP_Call { + return &MockNodeManager_GetInternalIP_Call{Call: _e.mock.On("GetInternalIP")} +} + +func (_c *MockNodeManager_GetInternalIP_Call) Run(run func()) *MockNodeManager_GetInternalIP_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_GetInternalIP_Call) Return(s string) *MockNodeManager_GetInternalIP_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockNodeManager_GetInternalIP_Call) RunAndReturn(run func() string) *MockNodeManager_GetInternalIP_Call { + _c.Call.Return(run) + return _c +} + +// GetName provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) GetName() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetName") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockNodeManager_GetName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetName' +type MockNodeManager_GetName_Call struct { + *mock.Call +} + +// GetName is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) GetName() *MockNodeManager_GetName_Call { + return &MockNodeManager_GetName_Call{Call: _e.mock.On("GetName")} +} + +func (_c *MockNodeManager_GetName_Call) Run(run func()) *MockNodeManager_GetName_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_GetName_Call) Return(s string) *MockNodeManager_GetName_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockNodeManager_GetName_Call) RunAndReturn(run func() string) *MockNodeManager_GetName_Call { + _c.Call.Return(run) + return _c +} + +// HasAcceptEnvConfigured provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasAcceptEnvConfigured() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for HasAcceptEnvConfigured") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasAcceptEnvConfigured_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasAcceptEnvConfigured' +type MockNodeManager_HasAcceptEnvConfigured_Call struct { + *mock.Call +} + +// HasAcceptEnvConfigured is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) HasAcceptEnvConfigured() *MockNodeManager_HasAcceptEnvConfigured_Call { + return &MockNodeManager_HasAcceptEnvConfigured_Call{Call: _e.mock.On("HasAcceptEnvConfigured")} +} + +func (_c *MockNodeManager_HasAcceptEnvConfigured_Call) Run(run func()) *MockNodeManager_HasAcceptEnvConfigured_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_HasAcceptEnvConfigured_Call) Return(b bool) *MockNodeManager_HasAcceptEnvConfigured_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasAcceptEnvConfigured_Call) RunAndReturn(run func() bool) *MockNodeManager_HasAcceptEnvConfigured_Call { + _c.Call.Return(run) + return _c +} + +// HasCommand provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasCommand(command string) bool { + ret := _mock.Called(command) + + if len(ret) == 0 { + panic("no return value specified for HasCommand") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string) bool); ok { + r0 = returnFunc(command) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasCommand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasCommand' +type MockNodeManager_HasCommand_Call struct { + *mock.Call +} + +// HasCommand is a helper method to define mock.On call +// - command string +func (_e *MockNodeManager_Expecter) HasCommand(command interface{}) *MockNodeManager_HasCommand_Call { + return &MockNodeManager_HasCommand_Call{Call: _e.mock.On("HasCommand", command)} +} + +func (_c *MockNodeManager_HasCommand_Call) Run(run func(command string)) *MockNodeManager_HasCommand_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockNodeManager_HasCommand_Call) Return(b bool) *MockNodeManager_HasCommand_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasCommand_Call) RunAndReturn(run func(command string) bool) *MockNodeManager_HasCommand_Call { + _c.Call.Return(run) + return _c +} + +// HasFile provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasFile(filePath string) bool { + ret := _mock.Called(filePath) + + if len(ret) == 0 { + panic("no return value specified for HasFile") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string) bool); ok { + r0 = returnFunc(filePath) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasFile' +type MockNodeManager_HasFile_Call struct { + *mock.Call +} + +// HasFile is a helper method to define mock.On call +// - filePath string +func (_e *MockNodeManager_Expecter) HasFile(filePath interface{}) *MockNodeManager_HasFile_Call { + return &MockNodeManager_HasFile_Call{Call: _e.mock.On("HasFile", filePath)} +} + +func (_c *MockNodeManager_HasFile_Call) Run(run func(filePath string)) *MockNodeManager_HasFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockNodeManager_HasFile_Call) Return(b bool) *MockNodeManager_HasFile_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasFile_Call) RunAndReturn(run func(filePath string) bool) *MockNodeManager_HasFile_Call { + _c.Call.Return(run) + return _c +} + +// HasInotifyWatchesConfigured provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasInotifyWatchesConfigured() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for HasInotifyWatchesConfigured") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasInotifyWatchesConfigured_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasInotifyWatchesConfigured' +type MockNodeManager_HasInotifyWatchesConfigured_Call struct { + *mock.Call +} + +// HasInotifyWatchesConfigured is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) HasInotifyWatchesConfigured() *MockNodeManager_HasInotifyWatchesConfigured_Call { + return &MockNodeManager_HasInotifyWatchesConfigured_Call{Call: _e.mock.On("HasInotifyWatchesConfigured")} +} + +func (_c *MockNodeManager_HasInotifyWatchesConfigured_Call) Run(run func()) *MockNodeManager_HasInotifyWatchesConfigured_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_HasInotifyWatchesConfigured_Call) Return(b bool) *MockNodeManager_HasInotifyWatchesConfigured_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasInotifyWatchesConfigured_Call) RunAndReturn(run func() bool) *MockNodeManager_HasInotifyWatchesConfigured_Call { + _c.Call.Return(run) + return _c +} + +// HasMemoryMapConfigured provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasMemoryMapConfigured() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for HasMemoryMapConfigured") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasMemoryMapConfigured_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasMemoryMapConfigured' +type MockNodeManager_HasMemoryMapConfigured_Call struct { + *mock.Call +} + +// HasMemoryMapConfigured is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) HasMemoryMapConfigured() *MockNodeManager_HasMemoryMapConfigured_Call { + return &MockNodeManager_HasMemoryMapConfigured_Call{Call: _e.mock.On("HasMemoryMapConfigured")} +} + +func (_c *MockNodeManager_HasMemoryMapConfigured_Call) Run(run func()) *MockNodeManager_HasMemoryMapConfigured_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_HasMemoryMapConfigured_Call) Return(b bool) *MockNodeManager_HasMemoryMapConfigured_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasMemoryMapConfigured_Call) RunAndReturn(run func() bool) *MockNodeManager_HasMemoryMapConfigured_Call { + _c.Call.Return(run) + return _c +} + +// HasRootLoginEnabled provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) HasRootLoginEnabled() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for HasRootLoginEnabled") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockNodeManager_HasRootLoginEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasRootLoginEnabled' +type MockNodeManager_HasRootLoginEnabled_Call struct { + *mock.Call +} + +// HasRootLoginEnabled is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) HasRootLoginEnabled() *MockNodeManager_HasRootLoginEnabled_Call { + return &MockNodeManager_HasRootLoginEnabled_Call{Call: _e.mock.On("HasRootLoginEnabled")} +} + +func (_c *MockNodeManager_HasRootLoginEnabled_Call) Run(run func()) *MockNodeManager_HasRootLoginEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_HasRootLoginEnabled_Call) Return(b bool) *MockNodeManager_HasRootLoginEnabled_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockNodeManager_HasRootLoginEnabled_Call) RunAndReturn(run func() bool) *MockNodeManager_HasRootLoginEnabled_Call { + _c.Call.Return(run) + return _c +} + +// InstallOms provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) InstallOms() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for InstallOms") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_InstallOms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InstallOms' +type MockNodeManager_InstallOms_Call struct { + *mock.Call +} + +// InstallOms is a helper method to define mock.On call +func (_e *MockNodeManager_Expecter) InstallOms() *MockNodeManager_InstallOms_Call { + return &MockNodeManager_InstallOms_Call{Call: _e.mock.On("InstallOms")} +} + +func (_c *MockNodeManager_InstallOms_Call) Run(run func()) *MockNodeManager_InstallOms_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNodeManager_InstallOms_Call) Return(err error) *MockNodeManager_InstallOms_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_InstallOms_Call) RunAndReturn(run func() error) *MockNodeManager_InstallOms_Call { + _c.Call.Return(run) + return _c +} + +// RunSSHCommand provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) RunSSHCommand(username string, command string, quiet bool) error { + ret := _mock.Called(username, command, quiet) + + if len(ret) == 0 { + panic("no return value specified for RunSSHCommand") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = returnFunc(username, command, quiet) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_RunSSHCommand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunSSHCommand' +type MockNodeManager_RunSSHCommand_Call struct { + *mock.Call +} + +// RunSSHCommand is a helper method to define mock.On call +// - username string +// - command string +// - quiet bool +func (_e *MockNodeManager_Expecter) RunSSHCommand(username interface{}, command interface{}, quiet interface{}) *MockNodeManager_RunSSHCommand_Call { + return &MockNodeManager_RunSSHCommand_Call{Call: _e.mock.On("RunSSHCommand", username, command, quiet)} +} + +func (_c *MockNodeManager_RunSSHCommand_Call) Run(run func(username string, command string, quiet bool)) *MockNodeManager_RunSSHCommand_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockNodeManager_RunSSHCommand_Call) Return(err error) *MockNodeManager_RunSSHCommand_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_RunSSHCommand_Call) RunAndReturn(run func(username string, command string, quiet bool) error) *MockNodeManager_RunSSHCommand_Call { + _c.Call.Return(run) + return _c +} + +// UpdateNode provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) UpdateNode(name string, externalIP string, internalIP string) { + _mock.Called(name, externalIP, internalIP) + return +} + +// MockNodeManager_UpdateNode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateNode' +type MockNodeManager_UpdateNode_Call struct { + *mock.Call +} + +// UpdateNode is a helper method to define mock.On call +// - name string +// - externalIP string +// - internalIP string +func (_e *MockNodeManager_Expecter) UpdateNode(name interface{}, externalIP interface{}, internalIP interface{}) *MockNodeManager_UpdateNode_Call { + return &MockNodeManager_UpdateNode_Call{Call: _e.mock.On("UpdateNode", name, externalIP, internalIP)} +} + +func (_c *MockNodeManager_UpdateNode_Call) Run(run func(name string, externalIP string, internalIP string)) *MockNodeManager_UpdateNode_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockNodeManager_UpdateNode_Call) Return() *MockNodeManager_UpdateNode_Call { + _c.Call.Return() + return _c +} + +func (_c *MockNodeManager_UpdateNode_Call) RunAndReturn(run func(name string, externalIP string, internalIP string)) *MockNodeManager_UpdateNode_Call { + _c.Run(run) + return _c +} + +// WaitForSSH provides a mock function for the type MockNodeManager +func (_mock *MockNodeManager) WaitForSSH(timeout time.Duration) error { + ret := _mock.Called(timeout) + + if len(ret) == 0 { + panic("no return value specified for WaitForSSH") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(time.Duration) error); ok { + r0 = returnFunc(timeout) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeManager_WaitForSSH_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitForSSH' +type MockNodeManager_WaitForSSH_Call struct { + *mock.Call +} + +// WaitForSSH is a helper method to define mock.On call +// - timeout time.Duration +func (_e *MockNodeManager_Expecter) WaitForSSH(timeout interface{}) *MockNodeManager_WaitForSSH_Call { + return &MockNodeManager_WaitForSSH_Call{Call: _e.mock.On("WaitForSSH", timeout)} +} + +func (_c *MockNodeManager_WaitForSSH_Call) Run(run func(timeout time.Duration)) *MockNodeManager_WaitForSSH_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 time.Duration + if args[0] != nil { + arg0 = args[0].(time.Duration) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockNodeManager_WaitForSSH_Call) Return(err error) *MockNodeManager_WaitForSSH_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeManager_WaitForSSH_Call) RunAndReturn(run func(timeout time.Duration) error) *MockNodeManager_WaitForSSH_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 0edf691b..8223416f 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -9,6 +9,7 @@ import ( "net" "os" "path/filepath" + "sync" "syscall" "time" @@ -19,219 +20,399 @@ import ( "golang.org/x/term" ) -type Node struct { - Name string `json:"name"` - ExternalIP string `json:"external_ip"` - InternalIP string `json:"internal_ip"` +type NodeManager interface { + // Node + CreateSubNode(name string, externalIP string, internalIP string) NodeManager + UpdateNode(name string, externalIP string, internalIP string) + GetExternalIP() string + GetInternalIP() string + GetName() string + // SSH + WaitForSSH(timeout time.Duration) error + RunSSHCommand(username string, command string, quiet bool) error + // OMS + HasCommand(command string) bool + InstallOms() error + // AcceptEnv + HasAcceptEnvConfigured() bool + ConfigureAcceptEnv() error + // Root Login + HasRootLoginEnabled() bool + EnableRootLogin() error + // Host Config + HasInotifyWatchesConfigured() bool + ConfigureInotifyWatches() error + HasMemoryMapConfigured() bool + ConfigureMemoryMap() error + // Files + HasFile(filePath string) bool + CopyFile(src string, dst string) error } -type NodeManager struct { - FileIO util.FileIO - KeyPath string - cachedSigner ssh.Signer // cached signer to avoid repeated passphrase prompts +type Node struct { + FileIO util.FileIO `json:"-"` + // If connecting via the Jumpbox + Jumpbox *Node `json:"-"` + // Config + keyPath string `json:"-"` + Name string `json:"name"` + ExternalIP string `json:"external_ip"` + InternalIP string `json:"internal_ip"` + cachedSigner ssh.Signer `json:"-"` + sshQuiet bool `json:"-"` + // SSH client cache: map[username]*ssh.Client + clientCache map[string]*ssh.Client + clientMu sync.Mutex } -// getAuthMethods constructs a slice of ssh.AuthMethod, prioritizing the SSH agent. -func (n *NodeManager) getAuthMethods() ([]ssh.AuthMethod, error) { - var signers []ssh.Signer +const jumpboxUser = "ubuntu" - // 1. Get Agent Signers - if authSocket := os.Getenv("SSH_AUTH_SOCK"); authSocket != "" { - if conn, err := net.Dial("unix", authSocket); err == nil { - agentClient := agent.NewClient(conn) - if s, err := agentClient.Signers(); err == nil { - signers = append(signers, s...) - } - } +// NewNode creates a new Node with the given FileIO and SSH key path +func NewNode(fileIO util.FileIO, keyPath string, sshQuiet bool) *Node { + return &Node{ + FileIO: fileIO, + keyPath: util.ExpandPath(keyPath), + clientCache: make(map[string]*ssh.Client), + sshQuiet: sshQuiet, } +} - // 2. Add Private Key (File) if needed - if n.KeyPath != "" { - shouldLoad := true +// CreateSubNode creates a Node object representing a node behind a jumpbox +func (n *Node) CreateSubNode(name string, externalIP string, internalIP string) NodeManager { + return &Node{ + // Inherited from jumpbox + FileIO: n.FileIO, + Jumpbox: n, + keyPath: util.ExpandPath(n.keyPath), + sshQuiet: n.sshQuiet, + // Custom + Name: name, + ExternalIP: externalIP, + InternalIP: internalIP, + clientCache: make(map[string]*ssh.Client), + } +} - // Use cached signer if available - if n.cachedSigner != nil { - signers = append(signers, n.cachedSigner) - shouldLoad = false - } +// UpdateNode updates the node's name and IP addresses +func (n *Node) UpdateNode(name string, externalIP string, internalIP string) { + n.Name = name + n.ExternalIP = externalIP + n.InternalIP = internalIP +} - // Check if key is already in agent (requires .pub file) - if shouldLoad && len(signers) > 0 { - if pubBytes, err := n.FileIO.ReadFile(n.KeyPath + ".pub"); err == nil { - if targetPub, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes); err == nil { - targetMarshaled := string(targetPub.Marshal()) - for _, s := range signers { - if string(s.PublicKey().Marshal()) == targetMarshaled { - shouldLoad = false - break - } - } - } - } - } +// GetExternalIP returns the external IP of the node +func (n *Node) GetExternalIP() string { + return n.ExternalIP +} - // Else load from file with passphrase prompt if needed - if shouldLoad { - if signer, err := n.loadPrivateKey(); err == nil { - n.cachedSigner = signer - signers = append(signers, signer) - } else { - log.Printf("Warning: failed to load private key: %v\n", err) - } +// GetInternalIP returns the internal IP of the node +func (n *Node) GetInternalIP() string { + return n.InternalIP +} + +// GetName returns the name of the node +func (n *Node) GetName() string { + return n.Name +} + +// WaitForSSH tries to connect to the node via SSH until timeout. +// Once successful, the connection is cached for reuse. +func (n *Node) WaitForSSH(timeout time.Duration) error { + start := time.Now() + jumpboxIp := "" + nodeIp := n.ExternalIP + if n.Jumpbox != nil { + jumpboxIp = n.Jumpbox.ExternalIP + nodeIp = n.InternalIP + } + for { + // Try to get or create a cached client + _, err := n.getOrCreateClient(jumpboxIp, nodeIp, jumpboxUser) + if err == nil { + // Connection successful and cached + return nil + } + if time.Since(start) > timeout { + return fmt.Errorf("timeout: %w", err) } + time.Sleep(5 * time.Second) } +} - if len(signers) == 0 { - return nil, fmt.Errorf("no valid authentication methods configured. Check SSH_AUTH_SOCK and private key path") +// RunSSHCommand connects to the node, executes a command and streams the output. +// If quiet is true, command output is not printed to stdout/stderr. +// The SSH client connection is cached and reused for subsequent commands. +func (n *Node) RunSSHCommand(username string, command string, quiet bool) error { + var jumpboxIp string + var ip string + if n.Jumpbox != nil { + jumpboxIp = n.Jumpbox.ExternalIP + ip = n.InternalIP + } else { + jumpboxIp = "" + ip = n.ExternalIP } - return []ssh.AuthMethod{ssh.PublicKeys(signers...)}, nil -} + client, err := n.getOrCreateClient(jumpboxIp, ip, username) + if err != nil { + return fmt.Errorf("failed to get client: %w", err) + } + // Don't close the client - it's cached for reuse -// loadPrivateKey reads and parses the private key, prompting for passphrase if needed. -func (n *NodeManager) loadPrivateKey() (ssh.Signer, error) { - key, err := n.FileIO.ReadFile(n.KeyPath) + session, err := client.NewSession() if err != nil { - return nil, fmt.Errorf("failed to read private key file %s: %v", n.KeyPath, err) + // Connection might be stale, try to reconnect + n.invalidateClient(username) + client, err = n.getOrCreateClient(jumpboxIp, ip, username) + if err != nil { + return fmt.Errorf("failed to reconnect client: %w", err) + } + session, err = client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %v", err) + } } + defer util.IgnoreError(session.Close) - signer, err := ssh.ParsePrivateKey(key) - if err == nil { - return signer, nil + _ = session.Setenv("OMS_PORTAL_API_KEY", os.Getenv("OMS_PORTAL_API_KEY")) + _ = agent.RequestAgentForwarding(session) // Best effort, ignore errors + + if !quiet { + session.Stdout = os.Stdout + session.Stderr = os.Stderr + } + // Start the command + if err := session.Start(command); err != nil { + return fmt.Errorf("failed to start command: %v", err) } - if _, ok := err.(*ssh.PassphraseMissingError); !ok { - return nil, fmt.Errorf("failed to parse private key: %v", err) + if err := session.Wait(); err != nil { + // A non-zero exit status from the remote command is also considered an error + return fmt.Errorf("command failed: %w", err) } - // Key is encrypted, prompt for passphrase - log.Printf("Enter passphrase for key '%s': ", n.KeyPath) - passphrase, err := term.ReadPassword(int(syscall.Stdin)) - log.Println() + return nil +} + +// HasCommand checks if a command exists on the remote node via SSH +func (n *Node) HasCommand(command string) bool { + checkCommand := fmt.Sprintf("command -v %s >/dev/null 2>&1", command) + err := n.RunSSHCommand("root", checkCommand, n.sshQuiet) if err != nil { - return nil, fmt.Errorf("failed to read passphrase: %v", err) + // If the command returns a non-zero exit status, it means the command is not found + return false } + return true +} - signer, err = ssh.ParsePrivateKeyWithPassphrase(key, passphrase) - // Clear passphrase from memory - for i := range passphrase { - passphrase[i] = 0 +// InstallOms installs the OMS CLI on the remote node via SSH +func (n *Node) InstallOms() error { + remoteCommands := []string{ + "wget -qO- 'https://api.github.com/repos/codesphere-cloud/oms/releases/latest' | jq -r '.assets[] | select(.name | match(\"oms-cli.*linux_amd64\")) | .browser_download_url' | xargs wget -O oms-cli", + "chmod +x oms-cli; sudo mv oms-cli /usr/local/bin/", + "curl -LO https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64; sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops; sudo chmod +x /usr/local/bin/sops", + "wget https://dl.filippo.io/age/latest?for=linux/amd64 -O age.tar.gz; tar -xvf age.tar.gz; sudo mv age/age* /usr/local/bin/", } - if err != nil { - return nil, fmt.Errorf("failed to parse private key with passphrase: %v", err) + for _, cmd := range remoteCommands { + err := n.RunSSHCommand("root", cmd, n.sshQuiet) + if err != nil { + return fmt.Errorf("failed to run remote command '%s': %w", cmd, err) + } } - - return signer, nil + return nil } -func (n *NodeManager) connectToJumpbox(ip, username string) (*ssh.Client, error) { - authMethods, err := n.getAuthMethods() +// HasAcceptEnvConfigured checks if AcceptEnv is configured +func (n *Node) HasAcceptEnvConfigured() bool { + checkCommand := "sudo grep -E '^AcceptEnv OMS_PORTAL_API_KEY' /etc/ssh/sshd_config >/dev/null 2>&1" + err := n.RunSSHCommand("ubuntu", checkCommand, n.sshQuiet) if err != nil { - return nil, fmt.Errorf("jumpbox authentication setup failed: %v", err) + // If the command returns a NON-zero exit status, it means AcceptEnv is not configured + return false } + return true +} - config := &ssh.ClientConfig{ - User: username, - Auth: authMethods, - Timeout: 10 * time.Second, - // WARNING: Still using InsecureIgnoreHostKey for simplicity. Use known_hosts in production. - HostKeyCallback: ssh.InsecureIgnoreHostKey(), +// ConfigureAcceptEnv configures AcceptEnv for OMS_PORTAL_API_KEY +func (n *Node) ConfigureAcceptEnv() error { + cmds := []string{ + "sudo sed -i 's/^#\\?AcceptEnv.*/AcceptEnv OMS_PORTAL_API_KEY/' /etc/ssh/sshd_config", + "sudo systemctl restart sshd", } + for _, cmd := range cmds { + err := n.RunSSHCommand("ubuntu", cmd, n.sshQuiet) + if err != nil { + return fmt.Errorf("failed to run command '%s': %w", cmd, err) + } + } + return nil +} - addr := fmt.Sprintf("%s:22", ip) - jumpboxClient, err := ssh.Dial("tcp", addr, config) +// HasRootLoginEnabled checks if root login is enabled on the remote node via SSH +func (n *Node) HasRootLoginEnabled() bool { + checkCommandPermit := "sudo grep -E '^PermitRootLogin yes' /etc/ssh/sshd_config >/dev/null 2>&1" + err := n.RunSSHCommand("ubuntu", checkCommandPermit, n.sshQuiet) if err != nil { - return nil, fmt.Errorf("failed to dial jumpbox %s: %v", addr, err) + // If the command returns a NON-zero exit status, it means root login is not permitted + return false } - - // Enable Agent Forwarding on the jumpbox connection - if err := n.forwardAgent(jumpboxClient, nil); err != nil { - log.Printf(" Warning: Agent forwarding setup failed on jumpbox: %v\n", err) + checkCommandAuthorizedKeys := "sudo grep -E '^no-port-forwarding' /root/.ssh/authorized_keys >/dev/null 2>&1" + err = n.RunSSHCommand("ubuntu", checkCommandAuthorizedKeys, n.sshQuiet) + if err == nil { + // If the command returns a ZERO exit status, it means root login is prevented + return false } - - return jumpboxClient, nil + return true } -func (n *NodeManager) forwardAgent(client *ssh.Client, session *ssh.Session) error { - authSocket := os.Getenv("SSH_AUTH_SOCK") - if authSocket == "" { - log.Printf("SSH_AUTH_SOCK not set. Cannot perform agent forwarding") - } else { - // Connect to the local SSH Agent socket - conn, err := net.Dial("unix", authSocket) +// EnableRootLogin enables root login on the remote node via SSH +func (n *Node) EnableRootLogin() error { + cmds := []string{ + "sudo sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config", + "sudo sed -i 's/no-port-forwarding.*$//g' /root/.ssh/authorized_keys", + "sudo systemctl restart sshd", + } + for _, cmd := range cmds { + err := n.RunSSHCommand("ubuntu", cmd, n.sshQuiet) if err != nil { - log.Printf("failed to dial SSH agent socket: %v", err) - } else { - // Create an agent client for the local agent - ag := agent.NewClient(conn) - // This tells the remote server to proxy authentication requests back to us. - if err := agent.ForwardToAgent(client, ag); err != nil { - log.Printf("failed to forward agent to remote client: %v", err) - } - if session != nil { - if err := agent.RequestAgentForwarding(session); err != nil { - log.Printf("failed to request agent forwarding on session: %v", err) - } - } + return fmt.Errorf("failed to run command '%s': %w", cmd, err) } - } return nil } -const jumpboxUser = "ubuntu" +func (n *Node) HasInotifyWatchesConfigured() bool { + return n.hasSysctlLine("fs.inotify.max_user_watches=1048576") +} + +func (n *Node) ConfigureInotifyWatches() error { + return n.configureSysctlLine("fs.inotify.max_user_watches=1048576") +} + +func (n *Node) HasMemoryMapConfigured() bool { + return n.hasSysctlLine("vm.max_map_count=262144") +} -// RunSSHCommand connects to the node, executes a command and streams the output -func (n *NodeManager) RunSSHCommand(jumpboxIp string, ip string, username string, command string) error { - client, err := n.GetClient(jumpboxIp, ip, username) +func (n *Node) ConfigureMemoryMap() error { + return n.configureSysctlLine("vm.max_map_count=262144") +} + +// HasFile checks if a file exists on the remote node via SSH +func (n *Node) HasFile(filePath string) bool { + checkCommand := fmt.Sprintf("test -f '%s'", filePath) + err := n.RunSSHCommand("ubuntu", checkCommand, n.sshQuiet) if err != nil { - return fmt.Errorf("failed to get client: %w", err) + // If the command returns a non-zero exit status, it means the file does not exist + return false } - defer util.IgnoreError(client.Close) - session, err := client.NewSession() + return true +} + +// CopyFile copies a file from the local system to the remote node via SFTP +func (n *Node) CopyFile(src string, dst string) error { + if n.Jumpbox == nil { + err := n.ensureDirectoryExists("root", filepath.Dir(dst)) + if err != nil { + return fmt.Errorf("failed to ensure directory exists: %w", err) + } + return n.copyFile("", n.ExternalIP, "root", src, dst) + } + err := n.ensureDirectoryExists("root", filepath.Dir(dst)) if err != nil { - return fmt.Errorf("failed to create session on jumpbox: %v", err) + return fmt.Errorf("failed to ensure directory exists: %w", err) } - defer util.IgnoreError(session.Close) - - _ = session.Setenv("OMS_PORTAL_API_KEY", os.Getenv("OMS_PORTAL_API_KEY")) + return n.copyFile(n.Jumpbox.ExternalIP, n.InternalIP, "root", src, dst) +} - err = n.forwardAgent(client, session) +// Helper functions +// hasSysctlLine checks if a specific line exists in /etc/sysctl.conf on the remote node via SSH +func (n *Node) hasSysctlLine(line string) bool { + checkCommand := fmt.Sprintf("sudo grep -E '^%s' /etc/sysctl.conf >/dev/null 2>&1", line) + err := n.RunSSHCommand("root", checkCommand, n.sshQuiet) if err != nil { - log.Printf(" Warning: Agent forwarding setup failed on session: %v\n", err) + // If the command returns a NON-zero exit status, it means the setting is not configured + return false + } + return true +} + +// configureSysctlLine appends a specific line to /etc/sysctl.conf and applies the settings on the remote node via SSH +func (n *Node) configureSysctlLine(line string) error { + cmds := []string{ + fmt.Sprintf("echo '%s' | sudo tee -a /etc/sysctl.conf", line), + "sudo sysctl -p", } + for _, cmd := range cmds { + err := n.RunSSHCommand("root", cmd, n.sshQuiet) + if err != nil { + return fmt.Errorf("failed to run command '%s': %w", cmd, err) + } + } + return nil +} - session.Stdout = os.Stdout - session.Stderr = os.Stderr - // Start the command - if err := session.Start(command); err != nil { - return fmt.Errorf("failed to start command: %v", err) +// getOrCreateClient returns a cached SSH client or creates a new one if not cached. +func (n *Node) getOrCreateClient(jumpboxIp string, ip string, username string) (*ssh.Client, error) { + n.clientMu.Lock() + defer n.clientMu.Unlock() + + if client, ok := n.clientCache[username]; ok { + if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil { + return client, nil + } + util.IgnoreError(client.Close) + delete(n.clientCache, username) } - if err := session.Wait(); err != nil { - // A non-zero exit status from the remote command is also considered an error - return fmt.Errorf("command failed: %w", err) + client, err := n.createClient(jumpboxIp, ip, username) + if err != nil { + return nil, err } - return nil + // Set up agent forwarding (best effort, ignore errors) + err = n.setupAgentForwarding(client) + if err != nil { + log.Printf("Warning: failed to set up agent forwarding: %v", err) + } + + n.clientCache[username] = client + return client, nil } -func (n *NodeManager) GetClient(jumpboxIp string, ip string, username string) (*ssh.Client, error) { +// invalidateClient removes a cached client for the given username +func (n *Node) invalidateClient(username string) { + n.clientMu.Lock() + defer n.clientMu.Unlock() + + if client, ok := n.clientCache[username]; ok { + util.IgnoreError(client.Close) + delete(n.clientCache, username) + } +} + +// createClient creates and returns a new SSH client connected to the node (internal, no caching) +func (n *Node) createClient(jumpboxIp string, ip string, username string) (*ssh.Client, error) { authMethods, err := n.getAuthMethods() if err != nil { return nil, fmt.Errorf("failed to get authentication methods: %w", err) } + if jumpboxIp != "" { - jbClient, err := n.connectToJumpbox(jumpboxIp, jumpboxUser) + // Use the Jumpbox's cached client if available + jbClient, err := n.Jumpbox.getOrCreateClient("", jumpboxIp, jumpboxUser) if err != nil { return nil, fmt.Errorf("failed to connect to jumpbox: %v", err) } finalTargetConfig := &ssh.ClientConfig{ - User: username, - Auth: authMethods, - Timeout: 10 * time.Second, + User: username, + Auth: authMethods, + Timeout: 10 * time.Second, + // WARNING: This is INSECURE for production! + // It tells the client to accept any host key. + // For production, you should implement a proper HostKeyCallback + // to verify the remote server's identity. HostKeyCallback: ssh.InsecureIgnoreHostKey(), } @@ -267,26 +448,31 @@ func (n *NodeManager) GetClient(jumpboxIp string, ip string, username string) (* return client, nil } -func (n *NodeManager) GetSFTPClient(jumpboxIp string, ip string, username string) (*sftp.Client, error) { - client, err := n.GetClient(jumpboxIp, ip, username) +// getSFTPClient creates and returns an SFTP client connected to the node. +// Uses the cached SSH client for the connection. +func (n *Node) getSFTPClient(jumpboxIp string, ip string, username string) (*sftp.Client, error) { + client, err := n.getOrCreateClient(jumpboxIp, ip, username) if err != nil { return nil, fmt.Errorf("failed to get SSH client: %v", err) } + sftpClient, err := sftp.NewClient(client) if err != nil { return nil, fmt.Errorf("failed to create SFTP client: %v", err) } + return sftpClient, nil } -// EnsureDirectoryExists creates the directory on the remote node via SSH if it does not exist. -func (nm *NodeManager) EnsureDirectoryExists(jumpboxIp string, ip string, username string, dir string) error { +// ensureDirectoryExists creates the directory on the remote node via SSH if it does not exist. +func (n *Node) ensureDirectoryExists(username string, dir string) error { cmd := fmt.Sprintf("mkdir -p '%s'", dir) - return nm.RunSSHCommand(jumpboxIp, ip, username, cmd) + return n.RunSSHCommand(username, cmd, n.sshQuiet) } -func (n *NodeManager) CopyFile(jumpboxIp string, ip string, username string, src string, dst string) error { - client, err := n.GetSFTPClient(jumpboxIp, ip, username) +// copyFile copies a file from the local system to the remote node via SFTP. +func (n *Node) copyFile(jumpboxIp string, ip string, username string, src string, dst string) error { + client, err := n.getSFTPClient(jumpboxIp, ip, username) if err != nil { return fmt.Errorf("failed to get SSH client: %v", err) } @@ -312,178 +498,110 @@ func (n *NodeManager) CopyFile(jumpboxIp string, ip string, username string, src return nil } -func (n *Node) HasCommand(nm *NodeManager, command string) bool { - checkCommand := fmt.Sprintf("command -v %s >/dev/null 2>&1", command) - err := nm.RunSSHCommand("", n.ExternalIP, "root", checkCommand) - if err != nil { - // If the command returns a non-zero exit status, it means the command is not found - return false - } - return true -} +// getAuthMethods constructs a slice of ssh.AuthMethod, prioritizing the SSH agent. +func (n *Node) getAuthMethods() ([]ssh.AuthMethod, error) { + var signers []ssh.Signer -func (n *Node) InstallOms(nm *NodeManager) error { - remoteCommands := []string{ - "wget -qO- 'https://api.github.com/repos/codesphere-cloud/oms/releases/latest' | jq -r '.assets[] | select(.name | match(\"oms-cli.*linux_amd64\")) | .browser_download_url' | xargs wget -O oms-cli", - "chmod +x oms-cli; sudo mv oms-cli /usr/local/bin/", - "curl -LO https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64; sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops; sudo chmod +x /usr/local/bin/sops", - "wget https://dl.filippo.io/age/latest?for=linux/amd64 -O age.tar.gz; tar -xvf age.tar.gz; sudo mv age/age* /usr/local/bin/", - } - for _, cmd := range remoteCommands { - err := nm.RunSSHCommand("", n.ExternalIP, "root", cmd) - if err != nil { - return fmt.Errorf("failed to run remote command '%s': %w", cmd, err) + // 1. Get Agent Signers + if authSocket := os.Getenv("SSH_AUTH_SOCK"); authSocket != "" { + if conn, err := net.Dial("unix", authSocket); err == nil { + agentClient := agent.NewClient(conn) + if s, err := agentClient.Signers(); err == nil { + signers = append(signers, s...) + } } } - return nil -} -func (n *Node) CopyFile(jumpbox *Node, nm *NodeManager, src string, dst string) error { - if jumpbox == nil { - err := nm.EnsureDirectoryExists("", n.ExternalIP, "root", filepath.Dir(dst)) - if err != nil { - return fmt.Errorf("failed to ensure directory exists: %w", err) + // 2. Add Private Key (File) if needed + if n.keyPath != "" { + shouldLoad := true + + // Use cached signer if available + if n.cachedSigner != nil { + signers = append(signers, n.cachedSigner) + shouldLoad = false } - return nm.CopyFile("", n.ExternalIP, "root", src, dst) - } - err := nm.EnsureDirectoryExists(jumpbox.ExternalIP, n.InternalIP, "root", filepath.Dir(dst)) - if err != nil { - return fmt.Errorf("failed to ensure directory exists: %w", err) - } - return nm.CopyFile(jumpbox.ExternalIP, n.InternalIP, "root", src, dst) -} -// HasAcceptEnvConfigured checks if AcceptEnv is configured -func (n *Node) HasAcceptEnvConfigured(jumpbox *Node, nm *NodeManager) bool { - checkCommand := "sudo grep -E '^AcceptEnv OMS_PORTAL_API_KEY' /etc/ssh/sshd_config >/dev/null 2>&1" - err := n.RunSSHCommand(jumpbox, nm, "ubuntu", checkCommand) - if err != nil { - // If the command returns a NON-zero exit status, it means AcceptEnv is not configured - return false - } - return true -} + // Check if key is already in agent (requires .pub file) + if shouldLoad && len(signers) > 0 { + if pubBytes, err := n.FileIO.ReadFile(n.keyPath + ".pub"); err == nil { + if targetPub, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes); err == nil { + targetMarshaled := string(targetPub.Marshal()) + for _, s := range signers { + if string(s.PublicKey().Marshal()) == targetMarshaled { + shouldLoad = false + break + } + } + } + } + } -func (n *Node) ConfigureAcceptEnv(jumpbox *Node, nm *NodeManager) error { - cmds := []string{ - "sudo sed -i 's/^#\\?AcceptEnv.*/AcceptEnv OMS_PORTAL_API_KEY/' /etc/ssh/sshd_config", - "sudo systemctl restart sshd", - } - for _, cmd := range cmds { - err := n.RunSSHCommand(jumpbox, nm, "ubuntu", cmd) - if err != nil { - return fmt.Errorf("failed to run command '%s': %w", cmd, err) + // Else load from file with passphrase prompt if needed + if shouldLoad { + if signer, err := n.loadPrivateKey(); err == nil { + n.cachedSigner = signer + signers = append(signers, signer) + } else { + log.Printf("Warning: failed to load private key: %v\n", err) + } } } - return nil -} -func (n *Node) HasRootLoginEnabled(jumpbox *Node, nm *NodeManager) bool { - checkCommandPermit := "sudo grep -E '^PermitRootLogin yes' /etc/ssh/sshd_config >/dev/null 2>&1" - err := n.RunSSHCommand(jumpbox, nm, "ubuntu", checkCommandPermit) - if err != nil { - // If the command returns a NON-zero exit status, it means root login is not permitted - return false - } - checkCommandAuthorizedKeys := "sudo grep -E '^no-port-forwarding' /root/.ssh/authorized_keys >/dev/null 2>&1" - err = n.RunSSHCommand(jumpbox, nm, "ubuntu", checkCommandAuthorizedKeys) - if err == nil { - // If the command returns a ZERO exit status, it means root login is prevented - return false + if len(signers) == 0 { + return nil, fmt.Errorf("no valid authentication methods configured. Check SSH_AUTH_SOCK and private key path") } - return true + + return []ssh.AuthMethod{ssh.PublicKeys(signers...)}, nil } -func (n *Node) HasFile(jumpbox *Node, nm *NodeManager, filePath string) bool { - checkCommand := fmt.Sprintf("test -f '%s'", filePath) - err := n.RunSSHCommand(jumpbox, nm, "ubuntu", checkCommand) +// loadPrivateKey reads and parses the private key, prompting for passphrase if needed. +func (n *Node) loadPrivateKey() (ssh.Signer, error) { + key, err := n.FileIO.ReadFile(n.keyPath) if err != nil { - // If the command returns a non-zero exit status, it means the file does not exist - return false + return nil, fmt.Errorf("failed to read private key file %s: %v", n.keyPath, err) } - return true -} -func (n *Node) RunSSHCommand(jumpbox *Node, nm *NodeManager, username string, command string) error { - if jumpbox == nil { - return nm.RunSSHCommand("", n.ExternalIP, username, command) + signer, err := ssh.ParsePrivateKey(key) + if err == nil { + return signer, nil } - return nm.RunSSHCommand(jumpbox.ExternalIP, n.InternalIP, username, command) -} - -func (n *Node) EnableRootLogin(jumpbox *Node, nm *NodeManager) error { - cmds := []string{ - "sudo sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config", - "sudo sed -i 's/no-port-forwarding.*$//g' /root/.ssh/authorized_keys", - "sudo systemctl restart sshd", + if _, ok := err.(*ssh.PassphraseMissingError); !ok { + return nil, fmt.Errorf("failed to parse private key: %v", err) } - for _, cmd := range cmds { - err := n.RunSSHCommand(jumpbox, nm, "ubuntu", cmd) - if err != nil { - return fmt.Errorf("failed to run command '%s': %w", cmd, err) - } + + // Key is encrypted, prompt for passphrase + log.Printf("Enter passphrase for key '%s': ", n.keyPath) + passphrase, err := term.ReadPassword(int(syscall.Stdin)) + log.Println() + if err != nil { + return nil, fmt.Errorf("failed to read passphrase: %v", err) } - return nil -} -func (n *Node) WaitForSSH(jumpbox *Node, nm *NodeManager, timeout time.Duration) error { - start := time.Now() - jumpboxIp := "" - nodeIp := n.ExternalIP - if jumpbox != nil { - jumpboxIp = jumpbox.ExternalIP - nodeIp = n.InternalIP + signer, err = ssh.ParsePrivateKeyWithPassphrase(key, passphrase) + // Clear passphrase from memory + for i := range passphrase { + passphrase[i] = 0 } - for { - client, err := nm.GetClient(jumpboxIp, nodeIp, jumpboxUser) - if err == nil { - _ = client.Close() - return nil - } - if time.Since(start) > timeout { - return fmt.Errorf("timeout waiting for SSH on node %s (%s)", n.Name, n.ExternalIP) - } - time.Sleep(5 * time.Second) + if err != nil { + return nil, fmt.Errorf("failed to parse private key with passphrase: %v", err) } -} -func (n *Node) HasInotifyWatchesConfigured(jumpbox *Node, nm *NodeManager) bool { - return n.HasSysctlLine(jumpbox, "fs.inotify.max_user_watches=1048576", nm) -} - -func (n *Node) ConfigureInotifyWatches(jumpbox *Node, nm *NodeManager) error { - return n.ConfigureSysctlLine(jumpbox, "fs.inotify.max_user_watches=1048576", nm) -} - -func (n *Node) HasMemoryMapConfigured(jumpbox *Node, nm *NodeManager) bool { - return n.HasSysctlLine(jumpbox, "vm.max_map_count=262144", nm) + return signer, nil } -func (n *Node) ConfigureMemoryMap(jumpbox *Node, nm *NodeManager) error { - return n.ConfigureSysctlLine(jumpbox, "vm.max_map_count=262144", nm) -} +// setupAgentForwarding sets up SSH agent forwarding on the client (best effort) +func (n *Node) setupAgentForwarding(client *ssh.Client) error { + authSocket := os.Getenv("SSH_AUTH_SOCK") + if authSocket == "" { + return nil + } -func (n *Node) HasSysctlLine(jumpbox *Node, line string, nm *NodeManager) bool { - checkCommand := fmt.Sprintf("sudo grep -E '^%s' /etc/sysctl.conf >/dev/null 2>&1", line) - err := n.RunSSHCommand(jumpbox, nm, "root", checkCommand) + conn, err := net.Dial("unix", authSocket) if err != nil { - // If the command returns a NON-zero exit status, it means the setting is not configured - return false + return fmt.Errorf("failed to connect to SSH agent: %v", err) } - return true -} -func (n *Node) ConfigureSysctlLine(jumpbox *Node, line string, nm *NodeManager) error { - cmds := []string{ - fmt.Sprintf("echo '%s' | sudo tee -a /etc/sysctl.conf", line), - "sudo sysctl -p", - } - for _, cmd := range cmds { - err := n.RunSSHCommand(jumpbox, nm, "root", cmd) - if err != nil { - return fmt.Errorf("failed to run command '%s': %w", cmd, err) - } - } - return nil + return agent.ForwardToAgent(client, agent.NewClient(conn)) } diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 7bbe52e5..641c2d98 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -41,9 +41,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.5.3/iam/L ---------- Module: cloud.google.com/go/longrunning -Version: v0.7.0 +Version: v0.8.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.7.0/longrunning/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.8.0/longrunning/LICENSE ---------- Module: cloud.google.com/go/resourcemanager @@ -89,15 +89,15 @@ License URL: https://github.com/clipperhouse/stringish/blob/v0.1.1/LICENSE ---------- Module: github.com/clipperhouse/uax29/v2 -Version: v2.3.0 +Version: v2.4.0 License: MIT -License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE +License URL: https://github.com/clipperhouse/uax29/blob/v2.4.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/io -Version: v0.16.1 +Version: v0.16.2 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.1/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.2/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -275,9 +275,9 @@ License URL: https://github.com/ulikunitz/xz/blob/v0.5.15/LICENSE ---------- Module: gitlab.com/gitlab-org/api/client-go -Version: v1.11.0 +Version: v1.24.0 License: Apache-2.0 -License URL: https://gitlab.com/gitlab-org/api/client-go/-/blob/v1.11.0/LICENSE +License URL: https://gitlab.com/gitlab-org/api/client-go/-/blob/v1.24.0/LICENSE ---------- Module: go.opentelemetry.io/auto/sdk @@ -401,33 +401,33 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE ---------- Module: google.golang.org/api -Version: v0.263.0 +Version: v0.264.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.263.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.263.0 +Version: v0.264.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.263.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis -Version: v0.0.0-20251222181119-0a764e51fe1b +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/0a764e51fe1b/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20251222181119-0a764e51fe1b +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/0a764e51fe1b/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260122232226-8e98ce8d340d +Version: v0.0.0-20260128011058-8636f8732409 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8e98ce8d340d/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc diff --git a/internal/util/path.go b/internal/util/path.go new file mode 100644 index 00000000..42dc6f59 --- /dev/null +++ b/internal/util/path.go @@ -0,0 +1,20 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "os" + "path/filepath" + "strings" +) + +// ExpandPath expands ~ to the user's home directory +func ExpandPath(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} diff --git a/internal/util/string.go b/internal/util/string.go new file mode 100644 index 00000000..bcabcbfd --- /dev/null +++ b/internal/util/string.go @@ -0,0 +1,12 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +func Truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-3]) + "..." +}