diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 3770c79e..b96a38a6 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -77,6 +77,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { 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)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSZoneName, "dns-zone-name", "oms-testing", "Cloud DNS Zone Name (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallLocal, "install-local", "", "Install Codesphere from local package (default: none)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallVersion, "install-version", "", "Codesphere version to install (default: none)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash to install (default: none)") flags.StringArrayVarP(&bootstrapGcpCmd.CodesphereEnv.InstallSkipSteps, "install-skip-steps", "s", []string{}, "Installation steps to skip during Codesphere installation (optional)") diff --git a/docs/oms-cli_beta_bootstrap-gcp.md b/docs/oms-cli_beta_bootstrap-gcp.md index 47f1b596..a447347d 100644 --- a/docs/oms-cli_beta_bootstrap-gcp.md +++ b/docs/oms-cli_beta_bootstrap-gcp.md @@ -33,6 +33,7 @@ oms-cli beta bootstrap-gcp [flags] -h, --help help for bootstrap-gcp --install-config string Path to install config file (optional) (default "config.yaml") --install-hash string Codesphere package hash to install (default: none) + --install-local string Install Codesphere from local package (default: none) -s, --install-skip-steps stringArray Installation steps to skip during Codesphere installation (optional) --install-version string Codesphere version to install (default: none) --openbao-engine string OpenBao engine name (default: cs-secrets-engine) (default "cs-secrets-engine") diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index bbcc6ab8..71373096 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -89,6 +89,7 @@ type CodesphereEnvironment struct { ContainerRegistryURL string `json:"-"` ExistingConfigUsed bool `json:"-"` InstallVersion string `json:"install_version"` + InstallLocal string `json:"install_local"` InstallHash string `json:"install_hash"` InstallSkipSteps []string `json:"install_skip_steps"` Preemptible bool `json:"preemptible"` @@ -160,14 +161,12 @@ func GetInfraFilePath() string { } func (b *GCPBootstrapper) Bootstrap() error { - if b.Env.InstallVersion != "" { - err := b.stlog.Step("Validate input", b.ValidateInput) - if err != nil { - return fmt.Errorf("invalid input: %w", err) - } - + err := b.stlog.Step("Validate input", b.ValidateInput) + if err != nil { + return fmt.Errorf("invalid input: %w", err) } - err := b.stlog.Step("Ensure install config", b.EnsureInstallConfig) + + err = b.stlog.Step("Ensure install config", b.EnsureInstallConfig) if err != nil { return fmt.Errorf("failed to ensure install config: %w", err) } @@ -285,7 +284,7 @@ func (b *GCPBootstrapper) Bootstrap() error { return fmt.Errorf("failed to generate k0s config script: %w", err) } - if b.Env.InstallVersion != "" { + if b.Env.InstallVersion != "" || b.Env.InstallLocal != "" { err = b.stlog.Step("Install Codesphere", b.InstallCodesphere) if err != nil { return fmt.Errorf("failed to install Codesphere: %w", err) @@ -300,7 +299,30 @@ func (b *GCPBootstrapper) Bootstrap() error { return nil } +// ValidateInput checks that the required input parameters are set and valid func (b *GCPBootstrapper) ValidateInput() error { + err := b.validateInstallVersion() + if err != nil { + return err + } + + return b.validateGithubParams() +} + +// validateInstallVersion checks if the specified install version exists and contains the required installer artifact +func (b *GCPBootstrapper) validateInstallVersion() error { + if b.Env.InstallLocal != "" { + if b.Env.InstallVersion != "" || b.Env.InstallHash != "" { + return fmt.Errorf("cannot specify both install-local and install-version/install-hash") + } + if !b.fw.Exists(b.Env.InstallLocal) { + return fmt.Errorf("local installer package not found at path: %s", b.Env.InstallLocal) + } + return nil + } + if b.Env.InstallVersion == "" { + return nil + } build, err := b.PortalClient.GetBuild(portal.CodesphereProduct, b.Env.InstallVersion, b.Env.InstallHash) if err != nil { return fmt.Errorf("failed to get codesphere package: %w", err) @@ -323,12 +345,17 @@ func (b *GCPBootstrapper) ValidateInput() error { } } + return fmt.Errorf("specified package does not contain required installer artifact %s. Existing artifacts: %s", requiredFilename, strings.Join(filenames, ", ")) +} + +// validateGithubParams checks if the GitHub credentials are fully specified if GitHub registry is selected +func (b *GCPBootstrapper) validateGithubParams() error { ghParams := []string{b.Env.GitHubAppName, b.Env.GithubAppClientID, b.Env.GithubAppClientSecret} if slices.Contains(ghParams, "") && strings.Join(ghParams, "") != "" { return fmt.Errorf("GitHub app credentials are not fully specified (all or none of GitHubAppName, GithubAppClientID, GithubAppClientSecret must be set)") } - return fmt.Errorf("specified package does not contain required installer artifact %s. Existing artifacts: %s", requiredFilename, strings.Join(filenames, ", ")) + return nil } func (b *GCPBootstrapper) EnsureInstallConfig() error { @@ -1401,36 +1428,70 @@ func (b *GCPBootstrapper) EnsureDNSRecords() error { } func (b *GCPBootstrapper) InstallCodesphere() error { - packageFile := "installer.tar.gz" - skipSteps := b.Env.InstallSkipSteps + fullPackageFilename, err := b.ensureCodespherePackageOnJumpbox() + if err != nil { + return fmt.Errorf("failed to ensure Codesphere package on jumpbox: %w", err) + } + + err = b.runInstallCommand(fullPackageFilename) + if err != nil { + return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() (string, error) { + packageFilename := "installer.tar.gz" if b.Env.RegistryType == RegistryTypeGitHub { - skipSteps = append(skipSteps, "load-container-images") - packageFile = "installer-lite.tar.gz" + packageFilename = "installer-lite.tar.gz" } - skipStepsArg := "" - if len(skipSteps) > 0 { - skipStepsArg = " -s " + strings.Join(skipSteps, ",") + + if b.Env.InstallLocal != "" { + b.stlog.Logf("Copying local package %s to jumpbox...", b.Env.InstallLocal) + fullPackageFilename := fmt.Sprintf("local-%s", packageFilename) + err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.InstallLocal, "/root/"+fullPackageFilename) + if err != nil { + return "", fmt.Errorf("failed to copy local install package to jumpbox: %w", err) + } + return fullPackageFilename, nil } - downloadCmd := "oms-cli download package -f " + packageFile - if b.Env.InstallHash != "" { - downloadCmd += " -H " + b.Env.InstallHash + if b.Env.InstallVersion == "" { + return "", errors.New("either install version or a local package must be specified to install Codesphere") } - downloadCmd += " " + b.Env.InstallVersion + + fullPackageFilename := portal.BuildPackageFilenameFromParts(b.Env.InstallVersion, b.Env.InstallHash, packageFilename) + if b.Env.InstallHash == "" { + return "", fmt.Errorf("install hash must be set when install version is set") + } + b.stlog.Logf("Downloading Codesphere package...") + downloadCmd := fmt.Sprintf("oms-cli download package -f %s -H %s %s", packageFilename, b.Env.InstallHash, b.Env.InstallVersion) err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd) if err != nil { - return fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) + return "", fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) } - fullPackageFilename := portal.BuildPackageFilenameFromParts(b.Env.InstallVersion, b.Env.InstallHash, packageFile) + return fullPackageFilename, nil +} + +func (b *GCPBootstrapper) runInstallCommand(packageFilename string) error { + b.stlog.Logf("Installing Codesphere...") installCmd := fmt.Sprintf("oms-cli install codesphere -c /etc/codesphere/config.yaml -k %s/age_key.txt -p %s%s", - b.Env.SecretsDir, fullPackageFilename, skipStepsArg) - err = b.Env.Jumpbox.RunSSHCommand("root", installCmd) - if err != nil { - return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) + b.Env.SecretsDir, packageFilename, b.generateSkipStepsArg()) + return b.Env.Jumpbox.RunSSHCommand("root", installCmd) +} + +func (b *GCPBootstrapper) generateSkipStepsArg() string { + skipSteps := b.Env.InstallSkipSteps + if b.Env.RegistryType == RegistryTypeGitHub { + skipSteps = append(skipSteps, "load-container-images") + } + if len(skipSteps) == 0 { + return "" } - return nil + return " -s " + strings.Join(skipSteps, ",") } func (b *GCPBootstrapper) GenerateK0sConfigScript() error { diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 1a231540..4fe6ccea 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -263,78 +263,127 @@ var _ = Describe("GCP Bootstrapper", func() { var ( artifacts []portal.Artifact ) - BeforeEach(func() { - mockPortalClient = portal.NewMockPortal(GinkgoT()) - csEnv.InstallVersion = "v1.2.3" - csEnv.InstallHash = "abc123" - artifacts = []portal.Artifact{ - {Filename: "installer-lite.tar.gz"}, - } - }) - JustBeforeEach(func() { - mockPortalClient.EXPECT().GetBuild(portal.CodesphereProduct, bs.Env.InstallVersion, bs.Env.InstallHash).Return(portal.Build{ - Artifacts: artifacts, - Hash: csEnv.InstallHash, - Version: csEnv.InstallVersion, - }, nil) - }) - - Context("when GitHub arguments are partially set", func() { + Context("When a version and hash are specified", func() { BeforeEach(func() { - csEnv.GitHubAppName = "" + mockPortalClient = portal.NewMockPortal(GinkgoT()) + csEnv.InstallVersion = "v1.2.3" + csEnv.InstallHash = "abc123" + artifacts = []portal.Artifact{ + {Filename: "installer-lite.tar.gz"}, + } }) - It("fails", func() { - err := bs.ValidateInput() - Expect(err).To(MatchError(MatchRegexp("GitHub app credentials are not fully specified"))) + JustBeforeEach(func() { + mockPortalClient.EXPECT().GetBuild(portal.CodesphereProduct, bs.Env.InstallVersion, bs.Env.InstallHash).Return(portal.Build{ + Artifacts: artifacts, + Hash: csEnv.InstallHash, + Version: csEnv.InstallVersion, + }, nil) }) - }) - Context("when GHCR registry is used", func() { - BeforeEach(func() { - csEnv.RegistryType = gcp.RegistryTypeGitHub - }) + Context("when GHCR registry is used", func() { + BeforeEach(func() { + csEnv.RegistryType = gcp.RegistryTypeGitHub + }) - It("succeeds when package exists and has the lite package", func() { - err := bs.ValidateInput() - Expect(err).NotTo(HaveOccurred()) + Context("when GitHub arguments are partially set", func() { + BeforeEach(func() { + csEnv.GitHubAppName = "" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("GitHub app credentials are not fully specified"))) + }) + }) + + It("succeeds when package exists and has the lite package", func() { + err := bs.ValidateInput() + Expect(err).NotTo(HaveOccurred()) + }) + + Context("when package exists but does not have the lite package", func() { + BeforeEach(func() { + artifacts[0].Filename = "installer.tar.gz" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("artifact installer-lite\\.tar\\.gz"))) + }) + }) }) - Context("when package exists but does not have the lite package", func() { + Context("when non-GHCR registry is used", func() { BeforeEach(func() { - artifacts[0].Filename = "installer.tar.gz" + csEnv.RegistryType = gcp.RegistryTypeArtifactRegistry }) - It("fails", func() { - err := bs.ValidateInput() - Expect(err).To(MatchError(MatchRegexp("artifact installer-lite\\.tar\\.gz"))) + + Context("when build exists and has the full package", func() { + BeforeEach(func() { + artifacts[0].Filename = "installer.tar.gz" + }) + It("succeeds", func() { + err := bs.ValidateInput() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when package exists but does not have the full package", func() { + BeforeEach(func() { + artifacts[0].Filename = "installer-lite.tar.gz" + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("artifact installer\\.tar\\.gz"))) + }) }) }) }) - Context("when non-GHCR registry is used", func() { + Context("When a local package is specified", func() { BeforeEach(func() { - csEnv.RegistryType = gcp.RegistryTypeArtifactRegistry + csEnv.InstallLocal = "fake-installer-lite.tar.gz" }) - Context("when build exists and has the full package", func() { + Context("when a version is also specified", func() { BeforeEach(func() { - artifacts[0].Filename = "installer.tar.gz" + csEnv.InstallVersion = "v1.2.3" }) - It("succeeds", func() { + It("fails", func() { err := bs.ValidateInput() - Expect(err).NotTo(HaveOccurred()) + Expect(err).To(MatchError(MatchRegexp("cannot specify both install-local and install-version/install-hash"))) }) }) - - Context("when package exists but does not have the full package", func() { + Context("when a hash is also specified", func() { BeforeEach(func() { - artifacts[0].Filename = "installer-lite.tar.gz" + csEnv.InstallHash = "abc123" }) It("fails", func() { err := bs.ValidateInput() - Expect(err).To(MatchError(MatchRegexp("artifact installer\\.tar\\.gz"))) + Expect(err).To(MatchError(MatchRegexp("cannot specify both install-local and install-version/install-hash"))) }) }) + Context("when no version or hash is specified", func() { + Context("when the local file does not exist", func() { + BeforeEach(func() { + fw.EXPECT().Exists(csEnv.InstallLocal).Return(false) + }) + It("fails", func() { + err := bs.ValidateInput() + Expect(err).To(MatchError(MatchRegexp("local installer package not found at path: " + csEnv.InstallLocal))) + }) + }) + Context("when the local file exists", func() { + BeforeEach(func() { + fw.EXPECT().Exists(csEnv.InstallLocal).Return(true) + }) + It("succeeds", func() { + err := bs.ValidateInput() + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) + }) + }) Describe("EnsureInstallConfig", func() { @@ -1432,9 +1481,68 @@ var _ = Describe("GCP Bootstrapper", func() { err := bs.InstallCodesphere() Expect(err).NotTo(HaveOccurred()) }) + + Context("with local package", func() { + BeforeEach(func() { + csEnv.InstallLocal = "fake-installer-lite.tar.gz" + csEnv.InstallVersion = "" + csEnv.InstallHash = "" + }) + Context("using the github registry", func() { + BeforeEach(func() { + csEnv.RegistryType = gcp.RegistryTypeGitHub + }) + It("installs codesphere from local package", func() { + nodeClient.EXPECT().CopyFile(mock.Anything, csEnv.InstallLocal, "/root/local-installer-lite.tar.gz").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpbboxMatcher), "root", + "oms-cli install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt -p local-installer-lite.tar.gz -s load-container-images").Return(nil) + + err := bs.InstallCodesphere() + Expect(err).NotTo(HaveOccurred()) + }) + }) + Context("using the local registry", func() { + BeforeEach(func() { + csEnv.RegistryType = gcp.RegistryTypeLocalContainer + csEnv.InstallLocal = "fake-installer-lite.tar.gz" + }) + It("installs codesphere from local package", func() { + nodeClient.EXPECT().CopyFile(mock.Anything, csEnv.InstallLocal, "/root/local-installer.tar.gz").Return(nil) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpbboxMatcher), "root", + "oms-cli install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt -p local-installer.tar.gz").Return(nil) + + err := bs.InstallCodesphere() + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) }) Describe("Invalid cases", func() { + Context("without explicit hash", func() { + BeforeEach(func() { + // Simulate that ValidateInput has not populated the hash + csEnv.InstallHash = "" + }) + It("fails", func() { + err := bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("install hash must be set when install version is set")) + }) + }) + + Context("neither local nor install version specified", func() { + BeforeEach(func() { + csEnv.InstallVersion = "" + csEnv.InstallHash = "" + }) + It("fails", func() { + err := bs.InstallCodesphere() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("either install version or a local package must be specified")) + }) + }) + It("fails when download package fails", func() { nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpbboxMatcher), "root", "oms-cli download package -f installer.tar.gz -H abc1234567890 v1.2.3").Return(fmt.Errorf("download error")) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 0262df3a..033fbd27 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -442,7 +442,9 @@ func (n *Node) getSFTPClient(jumpboxIp string, ip string, username string) (*sft return nil, fmt.Errorf("failed to get SSH client: %v", err) } - sftpClient, err := sftp.NewClient(client) + sftpClient, err := sftp.NewClient(client, + sftp.UseConcurrentWrites(true), + ) if err != nil { return nil, fmt.Errorf("failed to create SFTP client: %v", err) }