From 4bb5272aefe1ec85f675cd7125008b090df8d70b Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Fri, 4 Sep 2026 15:40:28 +0200 Subject: [PATCH 01/26] feat: k0s download flags for airgapped bundle --- cli/cmd/k0s/download_k0s.go | 13 +++--- cli/cmd/k0s/download_k0s_test.go | 4 ++ cli/cmd/k0s/install_k0s.go | 24 ++++++++++- cli/cmd/k0s/install_k0s_test.go | 14 +++++++ internal/installer/download.go | 17 ++++++-- internal/installer/k0s.go | 69 +++++++++++++++++++++++++++----- 6 files changed, 120 insertions(+), 21 deletions(-) diff --git a/cli/cmd/k0s/download_k0s.go b/cli/cmd/k0s/download_k0s.go index cff80a7fd..038c97cf6 100644 --- a/cli/cmd/k0s/download_k0s.go +++ b/cli/cmd/k0s/download_k0s.go @@ -27,9 +27,10 @@ type DownloadK0sCmd struct { type DownloadK0sOpts struct { *util.GlobalOptions - Version string - Force bool - Quiet bool + Version string + Force bool + Quiet bool + AirGapped bool } func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -66,6 +67,7 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", "", "Version of k0s to download") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force download even if k0s binary exists") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") + k0s.cmd.Flags().BoolVarP(&k0s.Opts.AirGapped, "airgapped", "a", false, "Downloads the airgapped bundle for that version") util.AddCmd(download, k0s.cmd) @@ -74,6 +76,7 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { version := c.Opts.Version + var err error if version == "" { version, err = k0s.GetLatestVersion() @@ -82,12 +85,12 @@ func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { } } - k0sPath, err := k0s.Download(version, c.Opts.Force, c.Opts.Quiet) + k0sPath, err := k0s.Download(version, c.Opts.Force, c.Opts.Quiet, c.Opts.AirGapped) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } - log.Printf("k0s binary downloaded successfully at '%s'", k0sPath) + log.Printf("k0s binary downloaded successfully to '%s'", k0sPath) return nil } diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index bdb3fc4a0..8a213c063 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -52,6 +52,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "" // Test auto-version detection + mockK0sManager.EXPECT().GetLatestVersion().Return("", errors.New("network error")) err := c.DownloadK0s(mockK0sManager) @@ -64,6 +65,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("", errors.New("download failed")) err := c.DownloadK0s(mockK0sManager) @@ -76,6 +78,7 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) @@ -88,6 +91,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "" // Test auto-version detection c.Opts.Force = true c.Opts.Quiet = true + mockK0sManager.EXPECT().GetLatestVersion().Return("v1.29.1+k0s.0", nil) mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true).Return("/test/workdir/k0s", nil) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index f67257fcd..87c0fa255 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -21,7 +21,7 @@ import ( intutil "github.com/codesphere-cloud/oms/internal/util" ) -// InstallK0sCmd represents the k0s download command +// InstallK0sCmd represents the k0s command type InstallK0sCmd struct { cmd *cobra.Command Opts InstallK0sOpts @@ -29,6 +29,7 @@ type InstallK0sCmd struct { FileWriter intutil.FileIO } +// InstallK0sOpts holds the flags of the k0s install command type InstallK0sOpts struct { *util.GlobalOptions Version string @@ -43,6 +44,7 @@ type InstallK0sOpts struct { VaultType string } +// RunE is the starting point for the k0s install command that executes the bootstrap logic. func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { hw := portal.NewHttpWrapper() env := c.Env @@ -53,6 +55,7 @@ func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { return c.InstallK0s(pm, k0s, k0sctl) } +// AddInstallCmd registers the k0s install command in the parent command func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s := InstallK0sCmd{ cmd: &cobra.Command{ @@ -79,6 +82,7 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { Env: env.NewEnv(), FileWriter: intutil.NewFilesystemWriter(), } + k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", installer.DefaultK0sVersion, "Version of k0s to install") k0s.cmd.Flags().StringVar(&k0s.Opts.K0sctlVersion, "k0sctl-version", "", "Version of k0sctl to use") k0s.cmd.Flags().StringVarP(&k0s.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from") @@ -103,6 +107,8 @@ const ( vaultSecretNameKubeconfig = "kubeConfig" ) +// InstallK0s generates a k0sctl config based on the input parameters and installs k0s +// Returns an error if installation fails func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0sManager, k0sctl installer.K0sctlManager) error { if err := c.FileWriter.MkdirAll(c.Env.GetOmsWorkdir(), 0755); err != nil { return fmt.Errorf("failed to create oms workdir: %w", err) @@ -163,12 +169,15 @@ func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, e k0sVersion := c.Opts.Version if k0sVersion == "" { var err error + k0sVersion, err = k0s.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest k0s version: %w", err) } + log.Printf("Using latest k0s version: %s", k0sVersion) } + return k0sVersion, nil } @@ -181,27 +190,32 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal if err := pm.ExtractDependency(defaultK0sPath, c.Opts.Force); err != nil { return "", fmt.Errorf("failed to extract k0s from package: %w", err) } + return pm.GetDependencyPath(defaultK0sPath), nil } - k0sBinaryPath, err := k0s.Download(k0sVersion, c.Opts.Force, false) + k0sBinaryPath, err := k0s.Download(k0sVersion, c.Opts.Force, false, false) if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } + return k0sBinaryPath, nil } func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, error) { log.Println("Downloading k0sctl...") + k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, c.Opts.Force, false) if err != nil { return "", fmt.Errorf("failed to download k0sctl: %w", err) } + return k0sctlPath, nil } func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string) (string, error) { log.Println("Generating k0sctl configuration from install-config...") + k0sctlConfig, err := installer.GenerateK0sctlConfig(config, k0sVersion, c.Opts.SSHKeyPath, k0sBinaryPath) if err != nil { return "", fmt.Errorf("failed to generate k0sctl config: %w", err) @@ -218,11 +232,13 @@ func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersio } log.Printf("Generated k0sctl configuration at %s", k0sctlConfigPath) + return k0sctlConfigPath, nil } func (c *InstallK0sCmd) deployK0sCluster(k0sctl installer.K0sctlManager, k0sctlPath string, k0sctlConfigPath string) error { log.Println("Applying k0sctl configuration to deploy k0s cluster...") + if err := k0sctl.Apply(k0sctlConfigPath, k0sctlPath, c.Opts.Force); err != nil { return fmt.Errorf("failed to apply k0sctl config: %w", err) } @@ -235,10 +251,12 @@ func (c *InstallK0sCmd) deployK0sCluster(k0sctl installer.K0sctlManager, k0sctlP func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0sctlConfigPath, k0sctlPath string) error { log.Println("Retrieving kubeconfig from k0sctl for vault...") + kubeconfigContent, err := k0sctl.GetKubeconfig(k0sctlConfigPath, k0sctlPath) if err != nil { return fmt.Errorf("failed to retrieve kubeconfig from k0sctl: %w", err) } + kubeconfigContent = strings.TrimRight(kubeconfigContent, "\n\r") vault, err := c.loadOrCreateVault() @@ -271,6 +289,7 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 } log.Printf("Saved kubeconfig to %s", c.Opts.Vault) + return nil } @@ -293,5 +312,6 @@ func (c *InstallK0sCmd) vaultStore() (vault.Vault, error) { if err != nil { return nil, fmt.Errorf("failed to load vault: %w", err) } + return vault, nil } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 7a4bf2c03..76b5655c0 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -64,6 +64,7 @@ var _ = Describe("InstallK0sCmd", func() { Context("RunE method", func() { It("fails when install-config is not provided", func() { c.Opts.InstallConfig = "" + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Times(2) mockFileWriter.EXPECT().MkdirAll("/test/workdir", os.FileMode(0755)).Return(nil) @@ -85,7 +86,9 @@ var _ = Describe("InstallK0sCmd", func() { mockPM = installer.NewMockPackageManager(GinkgoT()) mockK0s = installer.NewMockK0sManager(GinkgoT()) mockK0sctl = installer.NewMockK0sctlManager(GinkgoT()) + var err error + tempDir, err = os.MkdirTemp("", "install-k0s-test-*") Expect(err).NotTo(HaveOccurred()) }) @@ -94,6 +97,7 @@ var _ = Describe("InstallK0sCmd", func() { mockPM.AssertExpectations(GinkgoT()) mockK0s.AssertExpectations(GinkgoT()) mockK0sctl.AssertExpectations(GinkgoT()) + if tempDir != "" { _ = os.RemoveAll(tempDir) } @@ -134,6 +138,7 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred()) err = os.WriteFile(configPath, configData, 0644) Expect(err).NotTo(HaveOccurred()) + return configPath } @@ -254,6 +259,7 @@ var _ = Describe("InstallK0sCmd", func() { if !testutil.SopsAndAgeAvailable() { Skip("sops and age-keygen not available") } + c.FileWriter = intutil.NewFilesystemWriter() }) @@ -278,6 +284,7 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred()) loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config")) @@ -313,6 +320,7 @@ var _ = Describe("InstallK0sCmd", func() { vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := c.Opts.Vault + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -365,6 +373,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := c.Opts.Vault + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -382,6 +391,7 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred()) loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config\nnew: true")) @@ -411,6 +421,7 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred()) loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) + secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) Expect(secret.File.Content).To(Equal("apiVersion: v1\nkind: Config")) @@ -459,6 +470,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -513,6 +525,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) @@ -577,6 +590,7 @@ var _ = Describe("InstallK0sCmd", func() { } vaultYAML, err := existingVault.Marshal() Expect(err).NotTo(HaveOccurred()) + plainPath := vaultPath + ".plain" err = os.WriteFile(plainPath, vaultYAML, 0600) Expect(err).NotTo(HaveOccurred()) diff --git a/internal/installer/download.go b/internal/installer/download.go index bb46b3d92..94c12e58a 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -11,15 +11,24 @@ import ( "github.com/codesphere-cloud/oms/internal/util" ) -func downloadBinaryToPath(fw util.FileIO, http portal.Http, binaryPath, binaryName, downloadURL string, quiet bool) (string, error) { - dstFile, err := fw.Create(binaryPath) +func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, quiet bool) error { + dstFile, err := fw.Create(path) if err != nil { - return "", fmt.Errorf("failed to create %s binary file: %w", binaryName, err) + return fmt.Errorf("failed to create file: %s: %w", path, err) } defer util.CloseFileIgnoreError(dstFile) if err := http.Download(downloadURL, dstFile, quiet); err != nil { - return "", fmt.Errorf("failed to download %s binary: %w", binaryName, err) + return fmt.Errorf("failed to download %s: %w", path, err) + } + + return nil +} + +func downloadBinaryToPath(fw util.FileIO, http portal.Http, binaryPath, binaryName, downloadURL string, quiet bool) (string, error) { + err := downloadToPath(fw, http, binaryPath, downloadURL, quiet) + if err != nil { + return "", fmt.Errorf("failed to download: %w", err) } if err := fw.Chmod(binaryPath, 0755); err != nil { diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index ed052267b..aa8cf24cf 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -16,14 +16,21 @@ import ( "github.com/codesphere-cloud/oms/internal/util" ) -// DefaultK0sVersion is the currently verified k0s version -// Use of newer versions should work in most cases but can't be guaranteed -const DefaultK0sVersion = "v1.31.14+k0s.0" +const ( + // DefaultK0sVersion is the currently verified k0s version + // Use of newer versions should work in most cases but can't be guaranteed + DefaultK0sVersion = "v1.31.14+k0s.0" + + GitHubReleaseURL = "https://github.com/k0sproject/k0s/releases/download" + + BinaryName = "k0s" + AirgapBundleName = "k0s-airgap-bundle" +) //mockery:generate: true type K0sManager interface { GetLatestVersion() (string, error) - Download(version string, force bool, quiet bool) (string, error) + Download(version string, force bool, quiet bool, airgapped bool) (string, error) } type K0s struct { @@ -59,7 +66,7 @@ func (k *K0s) GetLatestVersion() (string, error) { } // Download downloads the k0s binary for the specified version and saves it to the OMS cache dir. -func (k *K0s) Download(version string, force bool, quiet bool) (string, error) { +func (k *K0s) Download(version string, force, quiet, airgapped bool) (string, error) { if k.Goos != "linux" || k.Goarch != "amd64" { return "", fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) } @@ -75,7 +82,29 @@ func (k *K0s) Download(version string, force bool, quiet bool) (string, error) { return "", fmt.Errorf("failed to create workdir: %w", err) } - cachePath := filepath.Join(cacheDir, "k0s") + path, err := k.downloadBinary(version, cacheDir, force, quiet) + if err != nil { + return "", fmt.Errorf("failed to download k0s binary: %w", err) + } + + if airgapped { + err = k.downloadAirgappedBundle(version, cacheDir, force, quiet) + if err != nil { + return "", fmt.Errorf("failed to download k0s-airgapped bundle: %w", err) + } + } + + log.Printf("k0s binary downloaded and made executable at '%s'", path) + + return path, nil +} + +// downloadBinary fetches the k0s binary for the given version from the k0s GitHub +// releases and stores it as "k0s" in cacheDir, returning the path to it. +// If a binary is already cached and force is false, the cached binary is reused as +// long as its version matches; otherwise it is replaced by a fresh download. +func (k *K0s) downloadBinary(version, cacheDir string, force, quiet bool) (string, error) { + cachePath := filepath.Join(cacheDir, BinaryName) if k.FileWriter.Exists(cachePath) && !force { cachedVersion, versionErr := localBinaryVersion(cachePath) if versionErr == nil && cachedVersion == version { @@ -91,14 +120,34 @@ func (k *K0s) Download(version string, force bool, quiet bool) (string, error) { io.Verbosef(!quiet, "Replacing existing k0s binary: %s", replaceReason) } - downloadURL := fmt.Sprintf("https://github.com/k0sproject/k0s/releases/download/%s/k0s-%s-%s", version, version, k.Goarch) + downloadURL := fmt.Sprintf("%s/%s/k0s-%s-%s", GitHubReleaseURL, version, version, k.Goarch) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, "k0s", downloadURL, quiet) + path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, BinaryName, downloadURL, quiet) if err != nil { return "", err } - log.Printf("k0s binary downloaded and made executable at '%s'", path) + return path, err +} - return path, nil +// downloadAirgappedBundle fetches the k0s airgap image bundle for the given version +// from the k0s GitHub releases and stores it as tar in cacheDir +// If a bundle for that version is already cached and force is false, the cached bundle +// is reused; otherwise it is replaced by a fresh download. +func (k *K0s) downloadAirgappedBundle(version, cacheDir string, force, quiet bool) error { + bundleName := fmt.Sprintf("%s-%s-%s-%s.tar", AirgapBundleName, version, k.Goos, k.Goarch) + cachePath := filepath.Join(cacheDir, bundleName) + if k.FileWriter.Exists(cachePath) && !force { + io.Verbosef(!quiet, "Using cached %s at %s", bundleName, cachePath) + return nil + } + + downloadURL := fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, bundleName) + + err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, quiet) + if err != nil { + return err + } + + return err } From 3c00db74f1c32c1b26cccfc88a0f2f0054fa632e Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:43:06 +0000 Subject: [PATCH 02/26] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- docs/oms_download_k0s.md | 1 + internal/installer/mocks.go | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/oms_download_k0s.md b/docs/oms_download_k0s.md index b3cbdfc9e..29e480c36 100644 --- a/docs/oms_download_k0s.md +++ b/docs/oms_download_k0s.md @@ -31,6 +31,7 @@ $ oms download k0s --force ### Options ``` + -a, --airgapped Downloads the airgapped bundle for that version -f, --force Force download even if k0s binary exists -h, --help help for k0s -q, --quiet Suppress progress output during download diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 6f36a2239..8c6ed992f 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -1208,8 +1208,8 @@ func (_m *MockK0sManager) EXPECT() *MockK0sManager_Expecter { } // Download provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) Download(version string, force bool, quiet bool) (string, error) { - ret := _mock.Called(version, force, quiet) +func (_mock *MockK0sManager) Download(version string, force bool, quiet bool, airgapped bool) (string, error) { + ret := _mock.Called(version, force, quiet, airgapped) if len(ret) == 0 { panic("no return value specified for Download") @@ -1217,16 +1217,16 @@ func (_mock *MockK0sManager) Download(version string, force bool, quiet bool) (s var r0 string var r1 error - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) (string, error)); ok { - return returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, bool, bool, bool) (string, error)); ok { + return returnFunc(version, force, quiet, airgapped) } - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) string); ok { - r0 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, bool, bool, bool) string); ok { + r0 = returnFunc(version, force, quiet, airgapped) } else { r0 = ret.Get(0).(string) } - if returnFunc, ok := ret.Get(1).(func(string, bool, bool) error); ok { - r1 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(1).(func(string, bool, bool, bool) error); ok { + r1 = returnFunc(version, force, quiet, airgapped) } else { r1 = ret.Error(1) } @@ -1242,11 +1242,12 @@ type MockK0sManager_Download_Call struct { // - version string // - force bool // - quiet bool -func (_e *MockK0sManager_Expecter) Download(version any, force any, quiet any) *MockK0sManager_Download_Call { - return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", version, force, quiet)} +// - airgapped bool +func (_e *MockK0sManager_Expecter) Download(version any, force any, quiet any, airgapped any) *MockK0sManager_Download_Call { + return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", version, force, quiet, airgapped)} } -func (_c *MockK0sManager_Download_Call) Run(run func(version string, force bool, quiet bool)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_Download_Call) Run(run func(version string, force bool, quiet bool, airgapped bool)) *MockK0sManager_Download_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { @@ -1260,10 +1261,15 @@ func (_c *MockK0sManager_Download_Call) Run(run func(version string, force bool, if args[2] != nil { arg2 = args[2].(bool) } + var arg3 bool + if args[3] != nil { + arg3 = args[3].(bool) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -1274,7 +1280,7 @@ func (_c *MockK0sManager_Download_Call) Return(s string, err error) *MockK0sMana return _c } -func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, force bool, quiet bool) (string, error)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, force bool, quiet bool, airgapped bool) (string, error)) *MockK0sManager_Download_Call { _c.Call.Return(run) return _c } From 37cf9bdd4a51ab3bc082a7c2da073db44d8ce445 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Fri, 4 Sep 2026 16:12:01 +0200 Subject: [PATCH 03/26] fix: tests for k0s --- cli/cmd/k0s/download_k0s_test.go | 6 ++--- cli/cmd/k0s/install_k0s_test.go | 4 +-- internal/installer/k0s.go | 1 + internal/installer/k0s_config.go | 2 ++ internal/installer/k0s_config_test.go | 1 + internal/installer/k0s_test.go | 32 +++++++++++++++--------- internal/installer/k0sctl.go | 9 +++++++ internal/installer/k0sctl_config.go | 1 + internal/installer/k0sctl_config_test.go | 1 + 9 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index 8a213c063..81456d4e0 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -66,7 +66,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "v1.29.1+k0s.0" - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("", errors.New("download failed")) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, false).Return("", errors.New("download failed")) err := c.DownloadK0s(mockK0sManager) Expect(err).To(HaveOccurred()) @@ -79,7 +79,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "v1.29.1+k0s.0" - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, false).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) @@ -93,7 +93,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Quiet = true mockK0sManager.EXPECT().GetLatestVersion().Return("v1.29.1+k0s.0", nil) - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true, false).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 76b5655c0..ce3737fe0 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -189,7 +189,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.29.0+k0s.0", false, false).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.29.0+k0s.0", false, false, false).Return("/downloaded/k0s", nil) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -205,7 +205,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false).Return("", os.ErrNotExist) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, false).Return("", os.ErrNotExist) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index aa8cf24cf..0f3648bbd 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -136,6 +136,7 @@ func (k *K0s) downloadBinary(version, cacheDir string, force, quiet bool) (strin // is reused; otherwise it is replaced by a fresh download. func (k *K0s) downloadAirgappedBundle(version, cacheDir string, force, quiet bool) error { bundleName := fmt.Sprintf("%s-%s-%s-%s.tar", AirgapBundleName, version, k.Goos, k.Goarch) + cachePath := filepath.Join(cacheDir, bundleName) if k.FileWriter.Exists(cachePath) && !force { io.Verbosef(!quiet, "Using cached %s at %s", bundleName, cachePath) diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index 99d6cc592..0213221b4 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -88,6 +88,7 @@ func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { for _, cp := range installConfig.Kubernetes.ControlPlanes { sans = append(sans, cp.IPAddress) } + if installConfig.Kubernetes.APIServerHost != "" { sans = append(sans, installConfig.Kubernetes.APIServerHost) } @@ -135,6 +136,7 @@ func defaultIfEmpty(value, defaultValue string) string { if value != "" { return value } + return defaultValue } diff --git a/internal/installer/k0s_config_test.go b/internal/installer/k0s_config_test.go index 41ee8b462..1e2df3d0d 100644 --- a/internal/installer/k0s_config_test.go +++ b/internal/installer/k0s_config_test.go @@ -79,6 +79,7 @@ var _ = Describe("K0sConfig", func() { // Verify it can be unmarshalled back var parsedConfig installer.K0sConfig + err = yaml.Unmarshal(yamlData, &parsedConfig) Expect(err).ToNot(HaveOccurred()) Expect(parsedConfig.Metadata.Name).To(Equal("codesphere-test-dc")) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index f59be7419..b4b20b84b 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -57,7 +57,7 @@ var _ = Describe("K0s", func() { }) It("implements K0sManager interface", func() { - var manager = installer.NewK0s(mockHttp, mockEnv, mockFileWriter) + manager := installer.NewK0s(mockHttp, mockEnv, mockFileWriter) Expect(manager).ToNot(BeNil()) }) }) @@ -113,7 +113,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - _, err := k0s.Download("v1.29.1+k0s.0", false, false) + _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("windows/amd64")) @@ -123,7 +123,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - _, err := k0s.Download("v1.29.1+k0s.0", false, false) + _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("linux/arm64")) @@ -148,13 +148,14 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -164,6 +165,7 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) }) @@ -175,7 +177,7 @@ var _ = Describe("K0s", func() { Expect(err).ToNot(HaveOccurred()) mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - path, err := k0s.Download("v1.29.1+k0s.0", false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -196,7 +198,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -211,13 +213,14 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", true, false) + path, err := k0s.Download("v1.29.1+k0s.0", true, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -227,6 +230,7 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) @@ -235,9 +239,9 @@ var _ = Describe("K0s", func() { It("should fail when file creation fails", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(nil, errors.New("permission denied")) - _, err := k0s.Download("v1.29.1+k0s.0", false, false) + _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to create k0s binary file")) + Expect(err.Error()).To(ContainSubstring("failed to download k0s binary")) Expect(err.Error()).To(ContainSubstring("permission denied")) }) @@ -245,6 +249,7 @@ var _ = Describe("K0s", func() { // Create a mock file for the test mockFile, err := os.CreateTemp("", "k0s-test") Expect(err).ToNot(HaveOccurred()) + defer func() { _ = os.Remove(mockFile.Name()) }() @@ -253,7 +258,7 @@ var _ = Describe("K0s", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(mockFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", mockFile, false).Return(errors.New("download failed")) - _, err = k0s.Download("v1.29.1+k0s.0", false, false) + _, err = k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to download k0s binary")) Expect(err.Error()).To(ContainSubstring("download failed")) @@ -266,13 +271,14 @@ var _ = Describe("K0s", func() { realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -281,6 +287,7 @@ var _ = Describe("K0s", func() { Context("URL construction", func() { BeforeEach(func() { k0sImpl.Goos = "linux" + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) }) @@ -297,13 +304,14 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index 7ff1e677e..a49ccd352 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -50,6 +50,7 @@ type githubRelease struct { func (k *K0sctl) GetLatestVersion() (string, error) { releaseURL := "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" + responseBody, err := k.Http.Get(releaseURL) if err != nil { return "", fmt.Errorf("failed to fetch latest k0sctl release: %w", err) @@ -79,10 +80,12 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error if version == "" { var err error + version, err = k.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest version: %w", err) } + io.Verbosef(!quiet, "Using latest k0sctl version: %s", version) } @@ -127,9 +130,11 @@ func (k *K0sctl) requireBinaryAndConfig(configPath, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return fmt.Errorf("k0sctl binary does not exist at '%s', please download first", k0sctlPath) } + if !k.FileWriter.Exists(configPath) { return fmt.Errorf("k0sctl config does not exist at '%s'", configPath) } + return nil } @@ -155,6 +160,7 @@ func (k *K0sctl) Apply(configPath string, k0sctlPath string, force bool) error { } log.Println("k0sctl apply completed successfully") + return nil } @@ -162,6 +168,7 @@ func (k *K0sctl) Reset(configPath string, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return nil } + if err := k.requireBinaryAndConfig(configPath, k0sctlPath); err != nil { return err } @@ -176,6 +183,7 @@ func (k *K0sctl) Reset(configPath string, k0sctlPath string) error { } log.Println("k0sctl reset completed successfully") + return nil } @@ -187,6 +195,7 @@ func (k *K0sctl) GetKubeconfig(configPath string, k0sctlPath string) (string, er args := []string{"kubeconfig", "--config", configPath} log.Println("Retrieving kubeconfig from k0sctl...") + output, err := util.RunCommandWithOutput(k0sctlPath, args, "") if err != nil { return "", fmt.Errorf("k0sctl kubeconfig failed: %w", err) diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 30791b0d6..69cb2c14a 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -75,6 +75,7 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal return } } + host := K0sctlHost{ Role: role, SSH: K0sctlSSH{ diff --git a/internal/installer/k0sctl_config_test.go b/internal/installer/k0sctl_config_test.go index 55ca856a3..97c099a2f 100644 --- a/internal/installer/k0sctl_config_test.go +++ b/internal/installer/k0sctl_config_test.go @@ -150,6 +150,7 @@ var _ = Describe("K0sctlConfig", func() { // Verify it can be unmarshalled back var parsedConfig installer.K0sctlConfig + err = yaml.Unmarshal(yamlData, &parsedConfig) Expect(err).ToNot(HaveOccurred()) Expect(parsedConfig.Metadata.Name).To(Equal("codesphere-test-dc")) From 0b6f30c5a1703a0cff5d953008298e8efb89d30f Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Fri, 4 Sep 2026 16:17:44 +0200 Subject: [PATCH 04/26] fix: tests for k0s --- internal/installer/k0s.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 0f3648bbd..b20712e9c 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -21,9 +21,13 @@ const ( // Use of newer versions should work in most cases but can't be guaranteed DefaultK0sVersion = "v1.31.14+k0s.0" + // GitHubReleaseURL is the github release page for k0s GitHubReleaseURL = "https://github.com/k0sproject/k0s/releases/download" - BinaryName = "k0s" + // BinaryName is the name of target binary for oms to download to + BinaryName = "k0s" + + // AirgapBundleName is the name of target airgap-bundle for oms to download to AirgapBundleName = "k0s-airgap-bundle" ) From f7865eec5c8858e5ef625423277760a02c0ff053 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:37:29 +0200 Subject: [PATCH 05/26] feat: Add support for airgapped installations of k0s --- cli/cmd/k0s/download_k0s_test.go | 12 ++ cli/cmd/k0s/install_k0s.go | 48 ++++++- cli/cmd/k0s/install_k0s_test.go | 97 +++++++++++++-- cli/cmd/k0s/k0s_suite_test.go | 16 +++ internal/installer/k0s.go | 141 ++++++++++++++++++--- internal/installer/k0s_config.go | 33 ++++- internal/installer/k0s_config_test.go | 19 +++ internal/installer/k0s_test.go | 152 +++++++++++++++++++++++ internal/installer/k0sctl_config.go | 49 ++++++-- internal/installer/k0sctl_config_test.go | 73 +++++++++++ internal/installer/mocks.go | 72 +++++++++++ 11 files changed, 668 insertions(+), 44 deletions(-) create mode 100644 cli/cmd/k0s/k0s_suite_test.go diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index 81456d4e0..1c55d6f09 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -85,6 +85,18 @@ var _ = Describe("DownloadK0sk0s", func() { Expect(err).ToNot(HaveOccurred()) }) + It("requests the airgap bundle when --airgapped is set", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "v1.29.1+k0s.0" + c.Opts.AirGapped = true + + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, true).Return("/test/workdir/k0s", nil) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) + It("succeeds when version is auto-detected and download works", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 87c0fa255..6acaefd93 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -39,6 +39,8 @@ type InstallK0sOpts struct { SSHKeyPath string Force bool NoDownload bool + AirGapped bool + AirgapBundle string Vault string VaultPrivKey string VaultType string @@ -76,6 +78,8 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { {Cmd: "--ssh-key-path ", Desc: "SSH private key path for remote installation"}, {Cmd: "--force", Desc: "Force new download and installation"}, {Cmd: "--no-download", Desc: "Skip downloading k0s binary (expects it to be on remote nodes)"}, + {Cmd: "--airgapped", Desc: "Install k0s without internet access using an airgap image bundle"}, + {Cmd: "--airgapped --airgap-bundle ", Desc: "Install k0s airgapped from a local airgap image bundle"}, }), }, Opts: InstallK0sOpts{GlobalOptions: opts}, @@ -90,6 +94,8 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVar(&k0s.Opts.SSHKeyPath, "ssh-key-path", "", "SSH private key path for remote installation") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") k0s.cmd.Flags().BoolVar(&k0s.Opts.NoDownload, "no-download", false, "Skip downloading k0s binary") + k0s.cmd.Flags().BoolVar(&k0s.Opts.AirGapped, "airgapped", false, "Install k0s without internet access by uploading the airgap image bundle to the workers") + k0s.cmd.Flags().StringVar(&k0s.Opts.AirgapBundle, "airgap-bundle", "", "Path to the k0s airgap image bundle to install from (requires --airgapped)") k0s.cmd.Flags().StringVar(&k0s.Opts.Vault, "vault", "", "Path to prod.vault.yaml to save the kubeconfig into (optional)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultPrivKey, "vault-priv-key", "", "Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults)") @@ -114,6 +120,10 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return fmt.Errorf("failed to create oms workdir: %w", err) } + if c.Opts.AirgapBundle != "" && !c.Opts.AirGapped { + return fmt.Errorf("--airgap-bundle requires --airgapped") + } + config, err := c.loadInstallConfig() if err != nil { return err @@ -129,12 +139,17 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return err } + airgapBundlePath, err := c.getAirgapBundlePath(k0s, k0sVersion) + if err != nil { + return err + } + k0sctlPath, err := c.downloadK0sctl(k0sctl) if err != nil { return err } - k0sctlConfigPath, err := c.generateK0sctlConfig(config, k0sVersion, k0sBinaryPath) + k0sctlConfigPath, err := c.generateK0sctlConfig(config, k0sVersion, k0sBinaryPath, airgapBundlePath) if err != nil { return err } @@ -194,7 +209,7 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal return pm.GetDependencyPath(defaultK0sPath), nil } - k0sBinaryPath, err := k0s.Download(k0sVersion, c.Opts.Force, false, false) + k0sBinaryPath, err := k0s.Download(k0sVersion, c.Opts.Force, false, c.Opts.AirGapped) if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } @@ -202,6 +217,29 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal return k0sBinaryPath, nil } +// getAirgapBundlePath returns the local airgap image bundle that k0sctl uploads to the +// worker nodes. It is empty for installations with internet access. +func (c *InstallK0sCmd) getAirgapBundlePath(k0s installer.K0sManager, k0sVersion string) (string, error) { + if !c.Opts.AirGapped { + return "", nil + } + + if c.Opts.AirgapBundle != "" { + if !c.FileWriter.Exists(c.Opts.AirgapBundle) { + return "", fmt.Errorf("airgap bundle '%s' does not exist", c.Opts.AirgapBundle) + } + + return c.Opts.AirgapBundle, nil + } + + bundlePath, err := k0s.EnsureAirgapBundle(k0sVersion, c.Opts.Force, false) + if err != nil { + return "", fmt.Errorf("failed to download k0s airgap bundle: %w", err) + } + + return bundlePath, nil +} + func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, error) { log.Println("Downloading k0sctl...") @@ -213,10 +251,12 @@ func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, return k0sctlPath, nil } -func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string) (string, error) { +func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string, airgapBundlePath string) (string, error) { log.Println("Generating k0sctl configuration from install-config...") - k0sctlConfig, err := installer.GenerateK0sctlConfig(config, k0sVersion, c.Opts.SSHKeyPath, k0sBinaryPath) + airgap := installer.AirgapOptions{Enabled: c.Opts.AirGapped, BundlePath: airgapBundlePath} + + k0sctlConfig, err := installer.GenerateK0sctlConfig(config, k0sVersion, c.Opts.SSHKeyPath, k0sBinaryPath, airgap) if err != nil { return "", fmt.Errorf("failed to generate k0sctl config: %w", err) } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index ce3737fe0..6546052fd 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -212,6 +212,83 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err.Error()).To(ContainSubstring("failed to download k0s")) }) + It("installs k0s airgapped from a local bundle", func() { + config := createTestConfig(true) + config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "192.168.1.101"}} + c.Opts.InstallConfig = writeTestConfig(config) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.AirGapped = true + c.Opts.AirgapBundle = filepath.Join(tempDir, "k0s-airgap-bundle-amd64") + + err := os.WriteFile(c.Opts.AirgapBundle, []byte("bundle"), 0644) + Expect(err).NotTo(HaveOccurred()) + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) + mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundle).Return(true) + mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + // The generated k0sctl config must upload the bundle to the workers and + // the embedded k0s config must stop pulling images. + mockFileWriter.EXPECT().WriteFile( + mock.Anything, + mock.MatchedBy(func(data []byte) bool { + return strings.Contains(string(data), "dstDir: /var/lib/k0s/images") && + strings.Contains(string(data), "default_pull_policy: Never") + }), + mock.Anything, + ).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) + + err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("resolves the airgap bundle from the cache when no path is given", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.AirGapped = true + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", false, false).Return("/cache/k0s-airgap-bundle-amd64", nil) + mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails when the airgap bundle path does not exist", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.AirGapped = true + c.Opts.AirgapBundle = "/nonexistent/bundle.tar" + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) + mockFileWriter.EXPECT().Exists("/nonexistent/bundle.tar").Return(false) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + + It("fails when an airgap bundle is given without --airgapped", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.AirgapBundle = "/cache/k0s-airgap-bundle-amd64" + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("--airgap-bundle requires --airgapped")) + }) + It("fails when k0sctl download fails", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "test-package.tar.gz" @@ -609,7 +686,8 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Vault = vaultPath c.Opts.VaultPrivKey = ageKeyPath - // Use mockFileWriter to simulate an I/O error during encryption. + // The vault itself is written through the real filesystem, so the + // failure is provoked on the filesystem instead of via the mock. c.FileWriter = mockFileWriter setupCommonMocks() @@ -621,28 +699,21 @@ var _ = Describe("InstallK0sCmd", func() { }), mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) mockK0sctl.EXPECT().GetKubeconfig(mock.Anything, "/tmp/k0sctl").Return("apiVersion: v1\nkind: Config\n", nil) - mockFileWriter.EXPECT().MkdirAll(mock.Anything, os.FileMode(0755)).Return(nil) - - // Vault exists and is encrypted. - mockFileWriter.EXPECT().Exists(vaultPath).Return(true) - // Simulate encryption failure: writing the temporary vault file fails. - mockFileWriter.EXPECT().WriteFile(vaultPath+".tmp", mock.Anything, os.FileMode(0600)). - Return(os.ErrPermission) + // Make the vault directory unwritable so the SOPS backend cannot create + // its temporary files while the existing vault file stays readable. + Expect(os.Chmod(tempDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(tempDir, 0755) }) err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to write temporary vault file")) + Expect(err.Error()).To(ContainSubstring("failed to create temporary plaintext vault")) // Verify the vault file is unchanged. currentData, err := os.ReadFile(vaultPath) Expect(err).NotTo(HaveOccurred()) Expect(string(currentData)).To(Equal(string(origData)), "vault should be untouched when encryption fails") - - // Verify no tmp file is left behind. - tmpPath := vaultPath + ".tmp" - Expect(tmpPath).NotTo(BeAnExistingFile()) }) }) }) diff --git a/cli/cmd/k0s/k0s_suite_test.go b/cli/cmd/k0s/k0s_suite_test.go new file mode 100644 index 000000000..1e5bd8f0c --- /dev/null +++ b/cli/cmd/k0s/k0s_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package k0s_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestK0s(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "K0s Command Suite") +} diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index b20712e9c..c9bc4c85d 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -4,6 +4,7 @@ package installer import ( + "encoding/json" "fmt" "log" "path/filepath" @@ -24,17 +25,33 @@ const ( // GitHubReleaseURL is the github release page for k0s GitHubReleaseURL = "https://github.com/k0sproject/k0s/releases/download" + // k0sReleaseAPIURL lists the assets of a k0s release + k0sReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0s/releases/tags" + // BinaryName is the name of target binary for oms to download to BinaryName = "k0s" // AirgapBundleName is the name of target airgap-bundle for oms to download to AirgapBundleName = "k0s-airgap-bundle" + + // AirgapImagesDir is the k0s data dir from which worker nodes import the + // images of an airgap bundle. Controllers that do not run a worker ignore it. + AirgapImagesDir = "/var/lib/k0s/images" ) //mockery:generate: true type K0sManager interface { GetLatestVersion() (string, error) Download(version string, force bool, quiet bool, airgapped bool) (string, error) + EnsureAirgapBundle(version string, force bool, quiet bool) (string, error) +} + +type k0sRelease struct { + Assets []k0sReleaseAsset `json:"assets"` +} + +type k0sReleaseAsset struct { + Name string `json:"name"` } type K0s struct { @@ -92,10 +109,12 @@ func (k *K0s) Download(version string, force, quiet, airgapped bool) (string, er } if airgapped { - err = k.downloadAirgappedBundle(version, cacheDir, force, quiet) - if err != nil { - return "", fmt.Errorf("failed to download k0s-airgapped bundle: %w", err) + bundlePath, bundleErr := k.ensureAirgapBundle(version, cacheDir, force, quiet) + if bundleErr != nil { + return "", bundleErr } + + log.Printf("k0s airgap bundle downloaded to '%s'", bundlePath) } log.Printf("k0s binary downloaded and made executable at '%s'", path) @@ -103,6 +122,21 @@ func (k *K0s) Download(version string, force, quiet, airgapped bool) (string, er return path, nil } +// EnsureAirgapBundle makes sure the airgap image bundle of the given version is +// available in the OMS cache dir and returns its path. +func (k *K0s) EnsureAirgapBundle(version string, force, quiet bool) (string, error) { + cacheDir, err := k.Env.GetOmsCacheDir() + if err != nil { + return "", fmt.Errorf("failed to determine cache directory: %w", err) + } + + if err := k.FileWriter.MkdirAll(cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create workdir: %w", err) + } + + return k.ensureAirgapBundle(version, cacheDir, force, quiet) +} + // downloadBinary fetches the k0s binary for the given version from the k0s GitHub // releases and stores it as "k0s" in cacheDir, returning the path to it. // If a binary is already cached and force is false, the cached binary is reused as @@ -134,25 +168,100 @@ func (k *K0s) downloadBinary(version, cacheDir string, force, quiet bool) (strin return path, err } -// downloadAirgappedBundle fetches the k0s airgap image bundle for the given version -// from the k0s GitHub releases and stores it as tar in cacheDir -// If a bundle for that version is already cached and force is false, the cached bundle -// is reused; otherwise it is replaced by a fresh download. -func (k *K0s) downloadAirgappedBundle(version, cacheDir string, force, quiet bool) error { - bundleName := fmt.Sprintf("%s-%s-%s-%s.tar", AirgapBundleName, version, k.Goos, k.Goarch) +// ensureAirgapBundle downloads the airgap image bundle into cacheDir unless a bundle +// of that version is already cached there, and returns its path. +func (k *K0s) ensureAirgapBundle(version, cacheDir string, force, quiet bool) (string, error) { + cachePath, err := k.airgapBundleCachePath(version, cacheDir) + if err != nil { + return "", err + } - cachePath := filepath.Join(cacheDir, bundleName) if k.FileWriter.Exists(cachePath) && !force { - io.Verbosef(!quiet, "Using cached %s at %s", bundleName, cachePath) - return nil + io.Verbosef(!quiet, "Using cached airgap bundle %s", cachePath) + + return cachePath, nil + } + + downloadURL := fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, filepath.Base(cachePath)) + io.Verbosef(!quiet, "Downloading k0s airgap bundle from %s", downloadURL) + + if err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, quiet); err != nil { + return "", err } - downloadURL := fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, bundleName) + return cachePath, nil +} - err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, quiet) +// airgapBundleCachePath returns the path of the airgap image bundle of the given +// version in cacheDir. An already cached bundle is preferred, so airgapped +// installations do not have to resolve the release metadata over the network. +func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { + if cachedPath, found := k.cachedAirgapBundlePath(version, cacheDir); found { + return cachedPath, nil + } + + assetName, err := k.resolveAirgapBundleAssetName(version) + if err != nil { + return "", err + } + + return filepath.Join(cacheDir, assetName), nil +} + +// cachedAirgapBundlePath looks for an already downloaded airgap bundle of the given +// version and architecture in cacheDir. +func (k *K0s) cachedAirgapBundlePath(version, cacheDir string) (string, bool) { + entries, err := k.FileWriter.ReadDir(cacheDir) + if err != nil { + return "", false + } + + for _, entry := range entries { + if k.isAirgapBundleFor(entry.Name(), version) { + return filepath.Join(cacheDir, entry.Name()), true + } + } + + return "", false +} + +// resolveAirgapBundleAssetName returns the release asset name of the airgap image +// bundle of the given version and architecture. +// +// k0s changed the asset naming scheme in v1.36, from +// "k0s-airgap-bundle--" to +// "k0s-airgap-bundle---.tar", so the name is resolved from the +// release metadata instead of being constructed. +func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { + responseBody, err := k.Http.Get(fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version)) if err != nil { - return err + return "", fmt.Errorf("failed to fetch k0s release %s: %w", version, err) } - return err + var release k0sRelease + if err := json.Unmarshal(responseBody, &release); err != nil { + return "", fmt.Errorf("failed to parse k0s release %s: %w", version, err) + } + + for _, asset := range release.Assets { + if k.isAirgapBundleFor(asset.Name, version) { + return asset.Name, nil + } + } + + return "", fmt.Errorf("no airgap bundle for %s/%s found in k0s release %s", k.Goos, k.Goarch, version) +} + +// isAirgapBundleFor reports whether name is the airgap image bundle asset of the +// given version that matches the configured OS and architecture. It accepts both the +// legacy and the current upstream naming scheme. +func (k *K0s) isAirgapBundleFor(name, version string) bool { + prefix := fmt.Sprintf("%s-%s-", AirgapBundleName, version) + if !strings.HasPrefix(name, prefix) { + return false + } + + platform := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".tar") + + return platform == k.Goarch || platform == fmt.Sprintf("%s-%s", k.Goos, k.Goarch) } diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index 0213221b4..9eeb1a24d 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -66,7 +66,26 @@ type K0sKonnectivity struct { AgentPort int `yaml:"agentPort,omitempty"` } -func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { +type AirgapOptions struct { + Enabled bool + BundlePath string +} + +// firstAirgapOption returns the optionally passed airgap options, or the zero value +// when no options were passed. +func firstAirgapOption(airgap []AirgapOptions) AirgapOptions { + if len(airgap) == 0 { + return AirgapOptions{} + } + + return airgap[0] +} + +// GenerateK0sConfig generates the k0s cluster configuration for a Codesphere +// install-config. Airgapped installations are requested through the optional +// airgap options and stop k0s from pulling images. +func GenerateK0sConfig(installConfig *files.RootConfig, airgap ...AirgapOptions) (*K0sConfig, error) { + options := firstAirgapOption(airgap) if installConfig == nil { return nil, fmt.Errorf("installConfig cannot be nil") } @@ -116,7 +135,7 @@ func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { } k0sConfig.Spec.Images = &K0sImages{ - DefaultPullPolicy: "IfNotPresent", + DefaultPullPolicy: pullPolicyFor(options), } k0sConfig.Spec.Telemetry = &K0sTelemetry{ @@ -132,6 +151,16 @@ func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { return k0sConfig, nil } +// pullPolicyFor returns the k0s image pull policy. Airgapped installations get their +// images from a pre-loaded bundle, so they must never pull from the internet. +func pullPolicyFor(options AirgapOptions) string { + if options.Enabled { + return "Never" + } + + return "IfNotPresent" +} + func defaultIfEmpty(value, defaultValue string) string { if value != "" { return value diff --git a/internal/installer/k0s_config_test.go b/internal/installer/k0s_config_test.go index 1e2df3d0d..db36cf596 100644 --- a/internal/installer/k0s_config_test.go +++ b/internal/installer/k0s_config_test.go @@ -65,6 +65,25 @@ var _ = Describe("K0sConfig", func() { Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-minimal")) }) + It("should set the pull policy to Never for airgapped installations", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{Enabled: true}) + Expect(err).ToNot(HaveOccurred()) + + // Airgapped nodes get their images from a pre-loaded bundle and must + // never pull from the internet. + Expect(k0sConfig.Spec.Images.DefaultPullPolicy).To(Equal("Never")) + }) + + It("should keep the default pull policy when airgapped is not enabled", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(k0sConfig.Spec.Images.DefaultPullPolicy).To(Equal("IfNotPresent")) + }) + It("should generate valid YAML", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index b4b20b84b..300585991 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -4,6 +4,7 @@ package installer_test import ( + "encoding/json" "errors" "os" "path/filepath" @@ -317,4 +318,155 @@ var _ = Describe("K0s", func() { }) }) }) + + Describe("EnsureAirgapBundle", func() { + const ( + legacyVersion = "v1.31.14+k0s.0" + modernVersion = "v1.36.3+k0s.2" + ) + + var ( + legacyAsset = "k0s-airgap-bundle-" + legacyVersion + "-amd64" + modernAsset = "k0s-airgap-bundle-" + modernVersion + "-linux-amd64.tar" + ) + + // releaseJSON renders a GitHub release API response listing the given assets. + releaseJSON := func(names ...string) []byte { + type asset struct { + Name string `json:"name"` + } + + assets := make([]asset, 0, len(names)) + for _, name := range names { + assets = append(assets, asset{Name: name}) + } + + data, err := json.Marshal(struct { + Assets []asset `json:"assets"` + }{Assets: assets}) + Expect(err).ToNot(HaveOccurred()) + + return data + } + + // expectAirgapDownload sets up the file and http mocks for a fresh download. + expectAirgapDownload := func(version, assetName string) string { + bundlePath := filepath.Join(workDir, assetName) + + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+version). + Return(releaseJSON(assetName), nil) + + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(bundlePath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Create(bundlePath).Return(realFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+version+"/"+assetName, realFile, false, + ).Return(nil) + + return bundlePath + } + + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) + mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) + }) + + It("resolves the legacy asset name used up to k0s v1.35", func() { + bundlePath := expectAirgapDownload(legacyVersion, legacyAsset) + + path, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("resolves the current asset name with OS and extension used since k0s v1.36", func() { + bundlePath := expectAirgapDownload(modernVersion, modernAsset) + + path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("ignores assets of other platforms", func() { + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+modernVersion). + Return(releaseJSON( + "k0s-airgap-bundle-"+modernVersion+"-linux-arm64.tar", + "k0s-airgap-bundle-"+modernVersion+"-windows2022-amd64.tar", + modernAsset, + ), nil) + + bundlePath := filepath.Join(workDir, modernAsset) + + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(bundlePath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Create(bundlePath).Return(realFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+modernVersion+"/"+modernAsset, realFile, false, + ).Return(nil) + + path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("reuses a cached bundle without contacting the release API", func() { + bundlePath := filepath.Join(workDir, modernAsset) + + mockFileWriter.EXPECT().ReadDir(workDir).Return([]os.DirEntry{fakeDirEntry{name: modernAsset}}, nil) + mockFileWriter.EXPECT().Exists(bundlePath).Return(true) + + path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("fails when the release metadata cannot be fetched", func() { + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+legacyVersion). + Return(nil, errors.New("network error")) + + _, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to fetch k0s release")) + }) + + It("fails when the release has no airgap bundle for the platform", func() { + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+legacyVersion). + Return(releaseJSON("k0s-v1.31.14+k0s.0-amd64"), nil) + + _, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no airgap bundle for linux/amd64")) + }) + }) }) + +// fakeDirEntry is a minimal os.DirEntry for cache lookups in tests. +type fakeDirEntry struct { + name string +} + +func (f fakeDirEntry) Name() string { return f.name } +func (f fakeDirEntry) IsDir() bool { return false } +func (f fakeDirEntry) Type() os.FileMode { return 0 } +func (f fakeDirEntry) Info() (os.FileInfo, error) { return nil, nil } diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 69cb2c14a..8ffc3eff9 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -37,9 +37,22 @@ type K0sctlHost struct { Environment map[string]string `yaml:"environment,omitempty"` UploadBinary bool `yaml:"uploadBinary,omitempty"` K0sBinaryPath string `yaml:"k0sBinaryPath,omitempty"` + Files []K0sctlFile `yaml:"files,omitempty"` Hooks *K0sctlHooks `yaml:"hooks,omitempty"` } +type K0sctlFile struct { + Src string `yaml:"src"` + DstDir string `yaml:"dstDir"` + Perm string `yaml:"perm,omitempty"` +} + +type k0sctlHostOptions struct { + SSHKeyPath string + K0sBinaryPath string + Airgap AirgapOptions +} + type K0sctlSSH struct { Address string `yaml:"address"` User string `yaml:"user"` @@ -69,7 +82,10 @@ type K0sctlApplyHooks struct { After []string `yaml:"after,omitempty"` } -func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, sshKeyPath string, k0sBinaryPath string) { +// addUniqueK0sctlHost appends a host to the cluster config unless its address is +// already present. isWorker marks hosts that run the k0s worker role, either +// dedicated workers or control planes installed with --enable-worker. +func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, isWorker bool, options k0sctlHostOptions) { for _, host := range k.Hosts { if host.PrivateAddress == node.IPAddress { return @@ -82,7 +98,7 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal Address: node.IPAddress, User: "root", Port: 22, - KeyPath: sshKeyPath, + KeyPath: options.SSHKeyPath, }, InstallFlags: installFlags, PrivateAddress: node.IPAddress, @@ -91,16 +107,24 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal }, } - if k0sBinaryPath != "" { + if options.K0sBinaryPath != "" { host.UploadBinary = true - host.K0sBinaryPath = k0sBinaryPath + host.K0sBinaryPath = options.K0sBinaryPath + } + + if isWorker && options.Airgap.Enabled && options.Airgap.BundlePath != "" { + host.Files = []K0sctlFile{{ + Src: options.Airgap.BundlePath, + DstDir: AirgapImagesDir, + Perm: "0644", + }} } k.Hosts = append(k.Hosts, host) } // GenerateK0sctlConfig generates a k0sctl configuration from a Codesphere install-config -func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, sshKeyPath string, k0sBinaryPath string) (*K0sctlConfig, error) { +func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, sshKeyPath string, k0sBinaryPath string, airgap ...AirgapOptions) (*K0sctlConfig, error) { if installConfig == nil { return nil, fmt.Errorf("installConfig cannot be nil") } @@ -109,8 +133,14 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, ss return nil, fmt.Errorf("k0sctl is only supported for Codesphere-managed Kubernetes") } + options := k0sctlHostOptions{ + SSHKeyPath: sshKeyPath, + K0sBinaryPath: k0sBinaryPath, + Airgap: firstAirgapOption(airgap), + } + // Generate k0s config that will be embedded in k0sctl config - k0sConfig, err := GenerateK0sConfig(installConfig) + k0sConfig, err := GenerateK0sConfig(installConfig, options.Airgap) if err != nil { return nil, fmt.Errorf("failed to generate k0s config: %w", err) } @@ -134,16 +164,17 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, ss for _, cp := range installConfig.Kubernetes.ControlPlanes { var installFlags []string // A node may intentionally be listed as both a control plane and a worker. - if slices.Contains(installConfig.Kubernetes.Workers, cp) { + isWorker := slices.Contains(installConfig.Kubernetes.Workers, cp) + if isWorker { installFlags = []string{"--enable-worker", "--no-taints=true"} } - k0sctlConfig.Spec.addUniqueK0sctlHost(cp, "controller", installFlags, sshKeyPath, k0sBinaryPath) + k0sctlConfig.Spec.addUniqueK0sctlHost(cp, "controller", installFlags, isWorker, options) } // Add dedicated worker nodes if present for _, worker := range installConfig.Kubernetes.Workers { - k0sctlConfig.Spec.addUniqueK0sctlHost(worker, "worker", nil, sshKeyPath, k0sBinaryPath) + k0sctlConfig.Spec.addUniqueK0sctlHost(worker, "worker", nil, true, options) } return k0sctlConfig, nil diff --git a/internal/installer/k0sctl_config_test.go b/internal/installer/k0sctl_config_test.go index 97c099a2f..84ea5a185 100644 --- a/internal/installer/k0sctl_config_test.go +++ b/internal/installer/k0sctl_config_test.go @@ -136,6 +136,79 @@ var _ = Describe("K0sctlConfig", func() { Expect(k0sctlConfig.Spec.Hosts[0].Environment).To(HaveKeyWithValue("KUBELET_EXTRA_ARGS", "--node-ip=10.0.1.10")) }) + It("should upload the airgap bundle to worker nodes when airgapped", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + installConfig.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.0.2.10"}, + } + + airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/k0s-airgap-bundle-amd64"} + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + Expect(err).ToNot(HaveOccurred()) + + // Controllers without the worker role do not import image bundles. + Expect(k0sctlConfig.Spec.Hosts[0].Role).To(Equal("controller")) + Expect(k0sctlConfig.Spec.Hosts[0].Files).To(BeEmpty()) + + Expect(k0sctlConfig.Spec.Hosts[1].Role).To(Equal("worker")) + Expect(k0sctlConfig.Spec.Hosts[1].Files).To(Equal([]installer.K0sctlFile{{ + Src: "/cache/k0s-airgap-bundle-amd64", + DstDir: "/var/lib/k0s/images", + Perm: "0644", + }})) + }) + + It("should upload the airgap bundle to control planes that also run a worker", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + installConfig.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.0.1.10"}, // Same node, so it also runs a worker + } + + airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + Expect(err).ToNot(HaveOccurred()) + + Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) + Expect(k0sctlConfig.Spec.Hosts[0].InstallFlags).To(Equal([]string{"--enable-worker", "--no-taints=true"})) + Expect(k0sctlConfig.Spec.Hosts[0].Files).To(HaveLen(1)) + }) + + It("should not upload files for installations with internet access", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + installConfig.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.0.2.10"}, + } + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + Expect(err).ToNot(HaveOccurred()) + + Expect(k0sctlConfig.Spec.Hosts[0].Files).To(BeEmpty()) + Expect(k0sctlConfig.Spec.Hosts[1].Files).To(BeEmpty()) + }) + + It("should marshal the airgap upload with k0sctl field names", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + installConfig.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.0.2.10"}, + } + + airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + Expect(err).ToNot(HaveOccurred()) + + yamlData, err := k0sctlConfig.Marshal() + Expect(err).ToNot(HaveOccurred()) + + yamlString := string(yamlData) + Expect(yamlString).To(ContainSubstring("files:")) + Expect(yamlString).To(ContainSubstring("src: /cache/bundle")) + Expect(yamlString).To(ContainSubstring("dstDir: /var/lib/k0s/images")) + Expect(yamlString).To(ContainSubstring("perm:")) + }) + It("should generate valid YAML", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 8c6ed992f..021d33b9a 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -1285,6 +1285,78 @@ func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, fo return _c } +// EnsureAirgapBundle provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) EnsureAirgapBundle(version string, force bool, quiet bool) (string, error) { + ret := _mock.Called(version, force, quiet) + + if len(ret) == 0 { + panic("no return value specified for EnsureAirgapBundle") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, bool, bool) (string, error)); ok { + return returnFunc(version, force, quiet) + } + if returnFunc, ok := ret.Get(0).(func(string, bool, bool) string); ok { + r0 = returnFunc(version, force, quiet) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, bool, bool) error); ok { + r1 = returnFunc(version, force, quiet) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockK0sManager_EnsureAirgapBundle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureAirgapBundle' +type MockK0sManager_EnsureAirgapBundle_Call struct { + *mock.Call +} + +// EnsureAirgapBundle is a helper method to define mock.On call +// - version string +// - force bool +// - quiet bool +func (_e *MockK0sManager_Expecter) EnsureAirgapBundle(version any, force any, quiet any) *MockK0sManager_EnsureAirgapBundle_Call { + return &MockK0sManager_EnsureAirgapBundle_Call{Call: _e.mock.On("EnsureAirgapBundle", version, force, quiet)} +} + +func (_c *MockK0sManager_EnsureAirgapBundle_Call) Run(run func(version string, force bool, quiet bool)) *MockK0sManager_EnsureAirgapBundle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 bool + if args[1] != nil { + arg1 = args[1].(bool) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockK0sManager_EnsureAirgapBundle_Call) Return(s string, err error) *MockK0sManager_EnsureAirgapBundle_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockK0sManager_EnsureAirgapBundle_Call) RunAndReturn(run func(version string, force bool, quiet bool) (string, error)) *MockK0sManager_EnsureAirgapBundle_Call { + _c.Call.Return(run) + return _c +} + // GetLatestVersion provides a mock function for the type MockK0sManager func (_mock *MockK0sManager) GetLatestVersion() (string, error) { ret := _mock.Called() From 223d6cbb04d67d6ad286ed4d1afe0a7ebc798488 Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:41:04 +0000 Subject: [PATCH 06/26] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- docs/oms_install_k0s.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/oms_install_k0s.md b/docs/oms_install_k0s.md index 73d8f8057..3f439e79e 100644 --- a/docs/oms_install_k0s.md +++ b/docs/oms_install_k0s.md @@ -40,11 +40,19 @@ $ oms install k0s --force # Skip downloading k0s binary (expects it to be on remote nodes) $ oms install k0s --no-download +# Install k0s without internet access using an airgap image bundle +$ oms install k0s --airgapped + +# Install k0s airgapped from a local airgap image bundle +$ oms install k0s --airgapped --airgap-bundle + ``` ### Options ``` + --airgap-bundle string Path to the k0s airgap image bundle to install from (requires --airgapped) + --airgapped Install k0s without internet access by uploading the airgap image bundle to the workers -f, --force Force new download and installation -h, --help help for k0s --install-config string Path to Codesphere install-config file (required) From 13a02021de6fd3c73f6b6be4cdeadae9abbd6f67 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:22:07 +0200 Subject: [PATCH 07/26] ref: k0sctl installation logic and configuration handling --- cli/cmd/k0s/download_k0s.go | 28 +-- cli/cmd/k0s/download_k0s_test.go | 26 ++- cli/cmd/k0s/install_k0s.go | 113 ++++++---- cli/cmd/k0s/install_k0s_integration_test.go | 32 +-- cli/cmd/k0s/install_k0s_test.go | 110 +++++++-- docs/oms_download_k0s.md | 3 + internal/installer/airgap.go | 139 ++++++++++++ internal/installer/download.go | 65 ++++++ internal/installer/k0s.go | 179 ++------------- internal/installer/k0s_config.go | 24 +- internal/installer/k0s_config_test.go | 41 ++-- internal/installer/k0s_test.go | 237 ++++++++++++++++---- internal/installer/k0sctl.go | 36 +-- internal/installer/k0sctl_config.go | 31 +-- internal/installer/k0sctl_config_test.go | 93 ++++++-- internal/installer/mocks.go | 78 +++---- 16 files changed, 797 insertions(+), 438 deletions(-) create mode 100644 internal/installer/airgap.go diff --git a/cli/cmd/k0s/download_k0s.go b/cli/cmd/k0s/download_k0s.go index 038c97cf6..a938bf300 100644 --- a/cli/cmd/k0s/download_k0s.go +++ b/cli/cmd/k0s/download_k0s.go @@ -27,10 +27,10 @@ type DownloadK0sCmd struct { type DownloadK0sOpts struct { *util.GlobalOptions - Version string - Force bool - Quiet bool - AirGapped bool + Version string + Force bool + Quiet bool + Airgap bool } func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -58,6 +58,7 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { {Cmd: "--version 1.22.0", Desc: "Download a specific version of k0s"}, {Cmd: "--quiet", Desc: "Download k0s with minimal output"}, {Cmd: "--force", Desc: "Force download even if k0s binary exists"}, + {Cmd: "--airgapped", Desc: "Also download the airgap image bundle for that version"}, }), }, Opts: DownloadK0sOpts{GlobalOptions: opts}, @@ -67,7 +68,7 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", "", "Version of k0s to download") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force download even if k0s binary exists") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") - k0s.cmd.Flags().BoolVarP(&k0s.Opts.AirGapped, "airgapped", "a", false, "Downloads the airgapped bundle for that version") + k0s.cmd.Flags().BoolVarP(&k0s.Opts.Airgap, "airgapped", "a", false, "Downloads the airgapped bundle for that version") util.AddCmd(download, k0s.cmd) @@ -75,17 +76,16 @@ func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { } func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { - version := c.Opts.Version - - var err error - if version == "" { - version, err = k0s.GetLatestVersion() - if err != nil { - return fmt.Errorf("failed to get latest k0s version: %w", err) - } + version, err := resolveK0sVersion(k0s, c.Opts.Version) + if err != nil { + return err } - k0sPath, err := k0s.Download(version, c.Opts.Force, c.Opts.Quiet, c.Opts.AirGapped) + k0sPath, err := k0s.Download(version, installer.DownloadOptions{ + Force: c.Opts.Force, + Quiet: c.Opts.Quiet, + Airgapped: c.Opts.Airgap, + }) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index 1c55d6f09..6ffb5b2ce 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -8,6 +8,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/spf13/cobra" "github.com/codesphere-cloud/oms/cli/cmd/k0s" "github.com/codesphere-cloud/oms/cli/cmd/util" @@ -66,7 +67,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "v1.29.1+k0s.0" - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, false).Return("", errors.New("download failed")) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{}).Return("", errors.New("download failed")) err := c.DownloadK0s(mockK0sManager) Expect(err).To(HaveOccurred()) @@ -79,7 +80,7 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "v1.29.1+k0s.0" - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, false).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{}).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) @@ -89,9 +90,9 @@ var _ = Describe("DownloadK0sk0s", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) c.Opts.Version = "v1.29.1+k0s.0" - c.Opts.AirGapped = true + c.Opts.Airgap = true - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false, true).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) @@ -105,10 +106,25 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Quiet = true mockK0sManager.EXPECT().GetLatestVersion().Return("v1.29.1+k0s.0", nil) - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true, false).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{Force: true, Quiet: true}).Return("/test/workdir/k0s", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) }) }) + + Context("AddDownloadCmd", func() { + It("registers --airgapped with the -a shorthand and default false", func() { + download := &cobra.Command{Use: "download"} + + k0s.AddDownloadCmd(download, &util.GlobalOptions{}) + + Expect(download.Commands()).To(HaveLen(1)) + + flag := download.Commands()[0].Flags().Lookup("airgapped") + Expect(flag).ToNot(BeNil()) + Expect(flag.Shorthand).To(Equal("a")) + Expect(flag.DefValue).To(Equal("false")) + }) + }) }) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 6acaefd93..8251aded8 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -32,18 +32,35 @@ type InstallK0sCmd struct { // InstallK0sOpts holds the flags of the k0s install command type InstallK0sOpts struct { *util.GlobalOptions - Version string - K0sctlVersion string - Package string - InstallConfig string - SSHKeyPath string - Force bool - NoDownload bool - AirGapped bool - AirgapBundle string - Vault string - VaultPrivKey string - VaultType string + Version string + K0sctlVersion string + Package string + InstallConfig string + SSHKeyPath string + Force bool + NoDownload bool + Airgap bool + AirgapBundlePath string + Vault string + VaultPrivKey string + VaultType string +} + +// resolveK0sVersion returns the requested k0s version, or the latest version when +// none was requested. +func resolveK0sVersion(k0s installer.K0sManager, version string) (string, error) { + if version != "" { + return version, nil + } + + latestVersion, err := k0s.GetLatestVersion() + if err != nil { + return "", fmt.Errorf("failed to get latest k0s version: %w", err) + } + + log.Printf("Using latest k0s version: %s", latestVersion) + + return latestVersion, nil } // RunE is the starting point for the k0s install command that executes the bootstrap logic. @@ -94,8 +111,8 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVar(&k0s.Opts.SSHKeyPath, "ssh-key-path", "", "SSH private key path for remote installation") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") k0s.cmd.Flags().BoolVar(&k0s.Opts.NoDownload, "no-download", false, "Skip downloading k0s binary") - k0s.cmd.Flags().BoolVar(&k0s.Opts.AirGapped, "airgapped", false, "Install k0s without internet access by uploading the airgap image bundle to the workers") - k0s.cmd.Flags().StringVar(&k0s.Opts.AirgapBundle, "airgap-bundle", "", "Path to the k0s airgap image bundle to install from (requires --airgapped)") + k0s.cmd.Flags().BoolVar(&k0s.Opts.Airgap, "airgapped", false, "Install k0s without internet access by uploading the airgap image bundle to the workers") + k0s.cmd.Flags().StringVar(&k0s.Opts.AirgapBundlePath, "airgap-bundle", "", "Path to the k0s airgap image bundle to install from (requires --airgapped)") k0s.cmd.Flags().StringVar(&k0s.Opts.Vault, "vault", "", "Path to prod.vault.yaml to save the kubeconfig into (optional)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultPrivKey, "vault-priv-key", "", "Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults)") @@ -120,10 +137,12 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return fmt.Errorf("failed to create oms workdir: %w", err) } - if c.Opts.AirgapBundle != "" && !c.Opts.AirGapped { - return fmt.Errorf("--airgap-bundle requires --airgapped") + if err := c.validateOptions(); err != nil { + return err } + c.warnAboutNetworkAccess() + config, err := c.loadInstallConfig() if err != nil { return err @@ -167,6 +186,31 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return nil } +// validateOptions checks that the provided flags can be combined. +func (c *InstallK0sCmd) validateOptions() error { + if c.Opts.AirgapBundlePath != "" && !c.Opts.Airgap { + return fmt.Errorf("--airgap-bundle requires --airgapped") + } + + return nil +} + +// warnAboutNetworkAccess logs the steps of an airgapped installation that still +// require internet access, so they can be prepared before going offline. +func (c *InstallK0sCmd) warnAboutNetworkAccess() { + if !c.Opts.Airgap { + return + } + + if c.Opts.AirgapBundlePath == "" { + log.Println("Warning: --airgapped without --airgap-bundle downloads the airgap bundle from the internet; pre-download it with 'oms download k0s --airgapped' or pass --airgap-bundle for a truly offline installation") + } + + if c.Opts.K0sctlVersion == "" { + log.Println("Warning: k0sctl is resolved and downloaded from the internet; pass --k0sctl-version and pre-cache k0sctl for a truly offline installation") + } +} + func (c *InstallK0sCmd) loadInstallConfig() (*files.RootConfig, error) { config, err := installer.NewConfig().ParseConfigYaml(c.Opts.InstallConfig) if err != nil { @@ -181,19 +225,7 @@ func (c *InstallK0sCmd) loadInstallConfig() (*files.RootConfig, error) { } func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, error) { - k0sVersion := c.Opts.Version - if k0sVersion == "" { - var err error - - k0sVersion, err = k0s.GetLatestVersion() - if err != nil { - return "", fmt.Errorf("failed to get latest k0s version: %w", err) - } - - log.Printf("Using latest k0s version: %s", k0sVersion) - } - - return k0sVersion, nil + return resolveK0sVersion(k0s, c.Opts.Version) } func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s installer.K0sManager, k0sVersion string) (string, error) { @@ -209,7 +241,7 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal return pm.GetDependencyPath(defaultK0sPath), nil } - k0sBinaryPath, err := k0s.Download(k0sVersion, c.Opts.Force, false, c.Opts.AirGapped) + k0sBinaryPath, err := k0s.Download(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force, Airgapped: c.Opts.Airgap}) if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } @@ -220,19 +252,19 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal // getAirgapBundlePath returns the local airgap image bundle that k0sctl uploads to the // worker nodes. It is empty for installations with internet access. func (c *InstallK0sCmd) getAirgapBundlePath(k0s installer.K0sManager, k0sVersion string) (string, error) { - if !c.Opts.AirGapped { + if !c.Opts.Airgap { return "", nil } - if c.Opts.AirgapBundle != "" { - if !c.FileWriter.Exists(c.Opts.AirgapBundle) { - return "", fmt.Errorf("airgap bundle '%s' does not exist", c.Opts.AirgapBundle) + if c.Opts.AirgapBundlePath != "" { + if !c.FileWriter.Exists(c.Opts.AirgapBundlePath) { + return "", fmt.Errorf("airgap bundle '%s' does not exist", c.Opts.AirgapBundlePath) } - return c.Opts.AirgapBundle, nil + return c.Opts.AirgapBundlePath, nil } - bundlePath, err := k0s.EnsureAirgapBundle(k0sVersion, c.Opts.Force, false) + bundlePath, err := k0s.EnsureAirgapBundle(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force}) if err != nil { return "", fmt.Errorf("failed to download k0s airgap bundle: %w", err) } @@ -254,9 +286,14 @@ func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string, airgapBundlePath string) (string, error) { log.Println("Generating k0sctl configuration from install-config...") - airgap := installer.AirgapOptions{Enabled: c.Opts.AirGapped, BundlePath: airgapBundlePath} + options := installer.K0sctlOptions{ + K0sVersion: k0sVersion, + SSHKeyPath: c.Opts.SSHKeyPath, + K0sBinaryPath: k0sBinaryPath, + Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, + } - k0sctlConfig, err := installer.GenerateK0sctlConfig(config, k0sVersion, c.Opts.SSHKeyPath, k0sBinaryPath, airgap) + k0sctlConfig, err := installer.GenerateK0sctlConfig(config, options) if err != nil { return "", fmt.Errorf("failed to generate k0sctl config: %w", err) } diff --git a/cli/cmd/k0s/install_k0s_integration_test.go b/cli/cmd/k0s/install_k0s_integration_test.go index 7e5ec40b3..1ce4c0f79 100644 --- a/cli/cmd/k0s/install_k0s_integration_test.go +++ b/cli/cmd/k0s/install_k0s_integration_test.go @@ -89,7 +89,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(loadedConfig.Kubernetes.ManagedByCodesphere).To(BeTrue()) // Generate k0s config - k0sConfig, err := installer.GenerateK0sConfig(loadedConfig) + k0sConfig, err := installer.GenerateK0sConfig(loadedConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig).NotTo(BeNil()) @@ -128,7 +128,7 @@ var _ = Describe("K0s Install-Config Integration", func() { } installConfig.Kubernetes.APIServerHost = "api.cluster.test" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) // Verify primary IP is used @@ -145,7 +145,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network).NotTo(BeNil()) @@ -156,7 +156,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should configure etcd storage correctly", func() { installConfig := createBaseConfig("storage-test", "192.168.1.100") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Storage).NotTo(BeNil()) @@ -168,7 +168,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should generate correct cluster name from datacenter", func() { installConfig := createBaseConfig("prod-us-east", "10.1.2.3") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-prod-us-east")) @@ -178,7 +178,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("empty-cp", "10.0.0.1") installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) // Should either handle gracefully or error if err == nil { Expect(k0sConfig).NotTo(BeNil()) @@ -190,7 +190,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should use default network values when not specified", func() { installConfig := createBaseConfig("defaults-test", "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) // Verify defaults are applied or fields are present @@ -215,7 +215,7 @@ var _ = Describe("K0s Install-Config Integration", func() { for _, tc := range testCases { installConfig := createBaseConfig(tc.name, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal(tc.expected)) } @@ -231,7 +231,7 @@ var _ = Describe("K0s Install-Config Integration", func() { } installConfig.Kubernetes.ControlPlanes = controlPlanes - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.Address).To(Equal("10.0.1.1")) Expect(len(k0sConfig.Spec.API.SANs)).To(BeNumerically(">=", 7)) @@ -242,7 +242,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.APIServerHost = "k8s.example.com" installConfig.Codesphere.Domain = "app.example.com" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.SANs).To(ContainElement("192.168.100.50")) @@ -260,7 +260,7 @@ var _ = Describe("K0s Install-Config Integration", func() { }) It("should fail when generating config from nil", func() { - _, err := installer.GenerateK0sConfig(nil) + _, err := installer.GenerateK0sConfig(nil, installer.AirgapOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cannot be nil")) }) @@ -291,7 +291,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("external-k8s", "10.0.0.1") installConfig.Kubernetes.ManagedByCodesphere = false - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig).NotTo(BeNil()) }) @@ -300,7 +300,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("missing-host", "10.0.0.1") installConfig.Kubernetes.APIServerHost = "" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) if err == nil { Expect(k0sConfig).NotTo(BeNil()) Expect(k0sConfig.Spec.API.Address).To(Equal("10.0.0.1")) @@ -312,7 +312,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should handle missing datacenter name", func() { installConfig := createBaseConfig("", "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) if err == nil { Expect(k0sConfig).NotTo(BeNil()) } else { @@ -343,7 +343,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - original, err := installer.GenerateK0sConfig(installConfig) + original, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) yamlData, err := original.Marshal() @@ -378,7 +378,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(err).NotTo(HaveOccurred()) // Generate and save k0s config - k0sConfig, err := installer.GenerateK0sConfig(originalConfig) + k0sConfig, err := installer.GenerateK0sConfig(originalConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) k0sData, err := k0sConfig.Marshal() Expect(err).NotTo(HaveOccurred()) diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 6546052fd..fd1f18b05 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -189,7 +189,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.29.0+k0s.0", false, false, false).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.29.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -205,7 +205,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, false).Return("", os.ErrNotExist) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("", os.ErrNotExist) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) @@ -217,16 +217,16 @@ var _ = Describe("InstallK0sCmd", func() { config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "192.168.1.101"}} c.Opts.InstallConfig = writeTestConfig(config) c.Opts.Version = "v1.30.0+k0s.0" - c.Opts.AirGapped = true - c.Opts.AirgapBundle = filepath.Join(tempDir, "k0s-airgap-bundle-amd64") + c.Opts.Airgap = true + c.Opts.AirgapBundlePath = filepath.Join(tempDir, "k0s-airgap-bundle-amd64") - err := os.WriteFile(c.Opts.AirgapBundle, []byte("bundle"), 0644) + err := os.WriteFile(c.Opts.AirgapBundlePath, []byte("bundle"), 0644) Expect(err).NotTo(HaveOccurred()) mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) - mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundle).Return(true) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundlePath).Return(true) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) // The generated k0sctl config must upload the bundle to the workers and // the embedded k0s config must stop pulling images. @@ -247,12 +247,12 @@ var _ = Describe("InstallK0sCmd", func() { It("resolves the airgap bundle from the cache when no path is given", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Version = "v1.30.0+k0s.0" - c.Opts.AirGapped = true + c.Opts.Airgap = true mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) - mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", false, false).Return("/cache/k0s-airgap-bundle-amd64", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/k0s-airgap-bundle-amd64", nil) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -264,12 +264,12 @@ var _ = Describe("InstallK0sCmd", func() { It("fails when the airgap bundle path does not exist", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Version = "v1.30.0+k0s.0" - c.Opts.AirGapped = true - c.Opts.AirgapBundle = "/nonexistent/bundle.tar" + c.Opts.Airgap = true + c.Opts.AirgapBundlePath = "/nonexistent/bundle.tar" mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", false, false, true).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/nonexistent/bundle.tar").Return(false) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) @@ -279,7 +279,7 @@ var _ = Describe("InstallK0sCmd", func() { It("fails when an airgap bundle is given without --airgapped", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) - c.Opts.AirgapBundle = "/cache/k0s-airgap-bundle-amd64" + c.Opts.AirgapBundlePath = "/cache/k0s-airgap-bundle-amd64" mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) @@ -289,6 +289,88 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err.Error()).To(ContainSubstring("--airgap-bundle requires --airgapped")) }) + It("installs k0s airgapped with the k0s binary already on the nodes", func() { + config := createTestConfig(true) + config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "192.168.1.100"}} + c.Opts.InstallConfig = writeTestConfig(config) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.Airgap = true + c.Opts.NoDownload = true + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) + mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + // The bundle must be uploaded although no k0s binary is uploaded. + mockFileWriter.EXPECT().WriteFile( + mock.Anything, + mock.MatchedBy(func(data []byte) bool { + return strings.Contains(string(data), "src: /cache/bundle") && + !strings.Contains(string(data), "k0sBinaryPath") + }), + mock.Anything, + ).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("takes the k0s binary from the package but still ensures the airgap bundle", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.Airgap = true + c.Opts.Package = "test-package.tar.gz" + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false).Return(nil) + mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") + mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) + mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("resolves the latest k0s version when airgapped without a version", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Airgap = true + c.Opts.AirgapBundlePath = "/cache/bundle" + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockFileWriter.EXPECT().Exists("/cache/bundle").Return(true) + mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("forces a fresh airgap bundle download with --force", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.Airgap = true + c.Opts.Force = true + + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Force: true, Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/cache/bundle", nil) + mockK0sctl.EXPECT().Download("", true, false).Return("/tmp/k0sctl", nil) + mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", true).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + It("fails when k0sctl download fails", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "test-package.tar.gz" diff --git a/docs/oms_download_k0s.md b/docs/oms_download_k0s.md index 29e480c36..33a882cec 100644 --- a/docs/oms_download_k0s.md +++ b/docs/oms_download_k0s.md @@ -26,6 +26,9 @@ $ oms download k0s --quiet # Force download even if k0s binary exists $ oms download k0s --force +# Also download the airgap image bundle for that version +$ oms download k0s --airgapped + ``` ### Options diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go new file mode 100644 index 000000000..68a44915b --- /dev/null +++ b/internal/installer/airgap.go @@ -0,0 +1,139 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/cs-go/pkg/io" +) + +const ( + // AirgapBundleName is the name of target airgap-bundle for oms to download to + AirgapBundleName = "k0s-airgap-bundle" + + // AirgapImagesDir is the k0s data dir from which worker nodes import the + // images of an airgap bundle. Controllers that do not run a worker ignore it. + AirgapImagesDir = "/var/lib/k0s/images" +) + +// AirgapOptions describes an airgapped installation. Enabled marks the installation +// as airgapped and BundlePath is the local airgap image bundle that k0sctl uploads +// to the worker nodes. +type AirgapOptions struct { + Enabled bool + BundlePath string +} + +// EnsureAirgapBundle makes sure the airgap image bundle of the given version is +// available in the OMS cache dir and returns its path. +func (k *K0s) EnsureAirgapBundle(version string, opts DownloadOptions) (string, error) { + cacheDir, err := ensureCacheDir(k.FileWriter, k.Env) + if err != nil { + return "", err + } + + return k.ensureAirgapBundle(version, cacheDir, opts) +} + +// ensureAirgapBundle downloads the airgap image bundle into cacheDir unless a bundle +// of that version is already cached there, and returns its path. +func (k *K0s) ensureAirgapBundle(version, cacheDir string, opts DownloadOptions) (string, error) { + cachePath, err := k.airgapBundleCachePath(version, cacheDir) + if err != nil { + return "", err + } + + if k.FileWriter.Exists(cachePath) && !opts.Force { + io.Verbosef(!opts.Quiet, "Using cached airgap bundle %s", cachePath) + + return cachePath, nil + } + + downloadURL := k.releaseAssetURL(version, filepath.Base(cachePath)) + io.Verbosef(!opts.Quiet, "Downloading k0s airgap bundle from %s", downloadURL) + + if err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, opts.Quiet); err != nil { + return "", err + } + + return cachePath, nil +} + +// airgapBundleCachePath returns the path of the airgap image bundle of the given +// version in cacheDir. An already cached bundle is preferred, so airgapped +// installations do not have to resolve the release metadata over the network. +func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { + if cachedPath, found := k.cachedAirgapBundlePath(version, cacheDir); found { + return cachedPath, nil + } + + assetName, err := k.resolveAirgapBundleAssetName(version) + if err != nil { + return "", err + } + + return filepath.Join(cacheDir, assetName), nil +} + +// cachedAirgapBundlePath looks for an already downloaded airgap bundle of the given +// version and architecture in cacheDir. +func (k *K0s) cachedAirgapBundlePath(version, cacheDir string) (string, bool) { + entries, err := k.FileWriter.ReadDir(cacheDir) + if err != nil { + return "", false + } + + for _, entry := range entries { + if k.isAirgapBundleFor(entry.Name(), version) { + return filepath.Join(cacheDir, entry.Name()), true + } + } + + return "", false +} + +// resolveAirgapBundleAssetName returns the release asset name of the airgap image +// bundle of the given version and architecture. +// +// k0s changed the asset naming scheme in v1.36, from +// "k0s-airgap-bundle--" to +// "k0s-airgap-bundle---.tar", so the name is resolved from the +// release metadata instead of being constructed. +func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { + responseBody, err := k.Http.Get(fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version)) + if err != nil { + return "", fmt.Errorf("failed to fetch k0s release %s: %w", version, err) + } + + var release githubRelease + if err := json.Unmarshal(responseBody, &release); err != nil { + return "", fmt.Errorf("failed to parse k0s release %s: %w", version, err) + } + + for _, asset := range release.Assets { + if k.isAirgapBundleFor(asset.Name, version) { + return asset.Name, nil + } + } + + return "", fmt.Errorf("no airgap bundle for %s/%s found in k0s release %s", k.Goos, k.Goarch, version) +} + +// isAirgapBundleFor reports whether name is the airgap image bundle asset of the +// given version that matches the configured OS and architecture. It accepts both the +// legacy and the current upstream naming scheme. +func (k *K0s) isAirgapBundleFor(name, version string) bool { + prefix := fmt.Sprintf("%s-%s-", AirgapBundleName, version) + if !strings.HasPrefix(name, prefix) { + return false + } + + platform := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".tar") + + return platform == k.Goarch || platform == fmt.Sprintf("%s-%s", k.Goos, k.Goarch) +} diff --git a/internal/installer/download.go b/internal/installer/download.go index 94c12e58a..7d1d21530 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -7,10 +7,75 @@ import ( "fmt" "strings" + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/portal" "github.com/codesphere-cloud/oms/internal/util" ) +// DownloadOptions configures how oms fetches an artifact from a remote release +// into the OMS cache. +type DownloadOptions struct { + // Force downloads the artifact even when a matching cached copy exists. + Force bool + // Quiet suppresses progress output. + Quiet bool + // Airgapped additionally ensures the k0s airgap image bundle. Downloads of + // airgap artifacts themselves ignore it. + Airgapped bool +} + +// githubReleaseAsset is a single downloadable asset of a GitHub release. +type githubReleaseAsset struct { + Name string `json:"name"` +} + +// githubRelease is the subset of the GitHub release API response that oms uses. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubReleaseAsset `json:"assets"` +} + +// ensureCacheDir returns the OMS cache dir and creates it when it does not exist yet. +func ensureCacheDir(fw util.FileIO, environment env.Env) (string, error) { + cacheDir, err := environment.GetOmsCacheDir() + if err != nil { + return "", fmt.Errorf("failed to determine cache directory: %w", err) + } + + if err := fw.MkdirAll(cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create workdir: %w", err) + } + + return cacheDir, nil +} + +// reuseCachedBinary returns the cached binary at cachePath when it exists and reports +// requestedVersion, unless force asks for a fresh download. The returned bool reports +// whether the cached binary can be reused. +func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, force, quiet bool) (string, bool) { + if !fw.Exists(cachePath) || force { + return "", false + } + + cachedVersion, versionErr := localBinaryVersion(cachePath) + if versionErr == nil && cachedVersion == requestedVersion { + io.Verbosef(!quiet, "Using cached %s %s at %s", name, requestedVersion, cachePath) + + return cachePath, true + } + + replaceReason := fmt.Sprintf("Cached %s version %s does not match requested version %s; replacing it", + name, cachedVersion, requestedVersion) + if versionErr != nil { + replaceReason = fmt.Sprintf("Cached %s version could not be determined: %v", name, versionErr) + } + + io.Verbosef(!quiet, "Replacing existing %s binary: %s", name, replaceReason) + + return "", false +} + func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, quiet bool) error { dstFile, err := fw.Create(path) if err != nil { diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index c9bc4c85d..5f83afad9 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -4,14 +4,12 @@ package installer import ( - "encoding/json" "fmt" "log" "path/filepath" "runtime" "strings" - "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/portal" "github.com/codesphere-cloud/oms/internal/util" @@ -30,28 +28,13 @@ const ( // BinaryName is the name of target binary for oms to download to BinaryName = "k0s" - - // AirgapBundleName is the name of target airgap-bundle for oms to download to - AirgapBundleName = "k0s-airgap-bundle" - - // AirgapImagesDir is the k0s data dir from which worker nodes import the - // images of an airgap bundle. Controllers that do not run a worker ignore it. - AirgapImagesDir = "/var/lib/k0s/images" ) //mockery:generate: true type K0sManager interface { GetLatestVersion() (string, error) - Download(version string, force bool, quiet bool, airgapped bool) (string, error) - EnsureAirgapBundle(version string, force bool, quiet bool) (string, error) -} - -type k0sRelease struct { - Assets []k0sReleaseAsset `json:"assets"` -} - -type k0sReleaseAsset struct { - Name string `json:"name"` + Download(version string, opts DownloadOptions) (string, error) + EnsureAirgapBundle(version string, opts DownloadOptions) (string, error) } type K0s struct { @@ -87,181 +70,59 @@ func (k *K0s) GetLatestVersion() (string, error) { } // Download downloads the k0s binary for the specified version and saves it to the OMS cache dir. -func (k *K0s) Download(version string, force, quiet, airgapped bool) (string, error) { +// When the airgap option is set, the airgap image bundle of that version is downloaded as well. +func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { if k.Goos != "linux" || k.Goarch != "amd64" { return "", fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) } log.Printf("Downloading k0s version %s", version) - cacheDir, err := k.Env.GetOmsCacheDir() + cacheDir, err := ensureCacheDir(k.FileWriter, k.Env) if err != nil { - return "", fmt.Errorf("failed to determine cache directory: %w", err) - } - - if err := k.FileWriter.MkdirAll(cacheDir, 0755); err != nil { - return "", fmt.Errorf("failed to create workdir: %w", err) + return "", err } - path, err := k.downloadBinary(version, cacheDir, force, quiet) + path, err := k.downloadBinary(version, cacheDir, opts) if err != nil { return "", fmt.Errorf("failed to download k0s binary: %w", err) } - if airgapped { - bundlePath, bundleErr := k.ensureAirgapBundle(version, cacheDir, force, quiet) + if opts.Airgapped { + bundlePath, bundleErr := k.ensureAirgapBundle(version, cacheDir, opts) if bundleErr != nil { - return "", bundleErr + return "", fmt.Errorf("failed to download k0s airgap bundle: %w", bundleErr) } log.Printf("k0s airgap bundle downloaded to '%s'", bundlePath) } - log.Printf("k0s binary downloaded and made executable at '%s'", path) - return path, nil } -// EnsureAirgapBundle makes sure the airgap image bundle of the given version is -// available in the OMS cache dir and returns its path. -func (k *K0s) EnsureAirgapBundle(version string, force, quiet bool) (string, error) { - cacheDir, err := k.Env.GetOmsCacheDir() - if err != nil { - return "", fmt.Errorf("failed to determine cache directory: %w", err) - } - - if err := k.FileWriter.MkdirAll(cacheDir, 0755); err != nil { - return "", fmt.Errorf("failed to create workdir: %w", err) - } - - return k.ensureAirgapBundle(version, cacheDir, force, quiet) -} - // downloadBinary fetches the k0s binary for the given version from the k0s GitHub // releases and stores it as "k0s" in cacheDir, returning the path to it. // If a binary is already cached and force is false, the cached binary is reused as // long as its version matches; otherwise it is replaced by a fresh download. -func (k *K0s) downloadBinary(version, cacheDir string, force, quiet bool) (string, error) { +func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (string, error) { cachePath := filepath.Join(cacheDir, BinaryName) - if k.FileWriter.Exists(cachePath) && !force { - cachedVersion, versionErr := localBinaryVersion(cachePath) - if versionErr == nil && cachedVersion == version { - io.Verbosef(!quiet, "Using cached k0s %s at %s", version, cachePath) - return cachePath, nil - } - - replaceReason := fmt.Sprintf("Cached k0s version %s does not match requested version %s; replacing it", cachedVersion, version) - if versionErr != nil { - replaceReason = "Cached k0s version could not be determined: " + versionErr.Error() - } - - io.Verbosef(!quiet, "Replacing existing k0s binary: %s", replaceReason) - } - - downloadURL := fmt.Sprintf("%s/%s/k0s-%s-%s", GitHubReleaseURL, version, version, k.Goarch) - - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, BinaryName, downloadURL, quiet) - if err != nil { - return "", err - } - - return path, err -} - -// ensureAirgapBundle downloads the airgap image bundle into cacheDir unless a bundle -// of that version is already cached there, and returns its path. -func (k *K0s) ensureAirgapBundle(version, cacheDir string, force, quiet bool) (string, error) { - cachePath, err := k.airgapBundleCachePath(version, cacheDir) - if err != nil { - return "", err - } - - if k.FileWriter.Exists(cachePath) && !force { - io.Verbosef(!quiet, "Using cached airgap bundle %s", cachePath) - - return cachePath, nil - } - - downloadURL := fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, filepath.Base(cachePath)) - io.Verbosef(!quiet, "Downloading k0s airgap bundle from %s", downloadURL) - - if err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, quiet); err != nil { - return "", err - } - - return cachePath, nil -} - -// airgapBundleCachePath returns the path of the airgap image bundle of the given -// version in cacheDir. An already cached bundle is preferred, so airgapped -// installations do not have to resolve the release metadata over the network. -func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { - if cachedPath, found := k.cachedAirgapBundlePath(version, cacheDir); found { + if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, BinaryName, opts.Force, opts.Quiet); cached { return cachedPath, nil } - assetName, err := k.resolveAirgapBundleAssetName(version) - if err != nil { - return "", err - } - - return filepath.Join(cacheDir, assetName), nil -} + downloadURL := k.releaseAssetURL(version, fmt.Sprintf("%s-%s-%s", BinaryName, version, k.Goarch)) -// cachedAirgapBundlePath looks for an already downloaded airgap bundle of the given -// version and architecture in cacheDir. -func (k *K0s) cachedAirgapBundlePath(version, cacheDir string) (string, bool) { - entries, err := k.FileWriter.ReadDir(cacheDir) + path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, BinaryName, downloadURL, opts.Quiet) if err != nil { - return "", false - } - - for _, entry := range entries { - if k.isAirgapBundleFor(entry.Name(), version) { - return filepath.Join(cacheDir, entry.Name()), true - } - } - - return "", false -} - -// resolveAirgapBundleAssetName returns the release asset name of the airgap image -// bundle of the given version and architecture. -// -// k0s changed the asset naming scheme in v1.36, from -// "k0s-airgap-bundle--" to -// "k0s-airgap-bundle---.tar", so the name is resolved from the -// release metadata instead of being constructed. -func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { - responseBody, err := k.Http.Get(fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version)) - if err != nil { - return "", fmt.Errorf("failed to fetch k0s release %s: %w", version, err) - } - - var release k0sRelease - if err := json.Unmarshal(responseBody, &release); err != nil { - return "", fmt.Errorf("failed to parse k0s release %s: %w", version, err) + return "", err } - for _, asset := range release.Assets { - if k.isAirgapBundleFor(asset.Name, version) { - return asset.Name, nil - } - } + log.Printf("k0s binary downloaded and made executable at '%s'", path) - return "", fmt.Errorf("no airgap bundle for %s/%s found in k0s release %s", k.Goos, k.Goarch, version) + return path, nil } -// isAirgapBundleFor reports whether name is the airgap image bundle asset of the -// given version that matches the configured OS and architecture. It accepts both the -// legacy and the current upstream naming scheme. -func (k *K0s) isAirgapBundleFor(name, version string) bool { - prefix := fmt.Sprintf("%s-%s-", AirgapBundleName, version) - if !strings.HasPrefix(name, prefix) { - return false - } - - platform := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".tar") - - return platform == k.Goarch || platform == fmt.Sprintf("%s-%s", k.Goos, k.Goarch) +// releaseAssetURL returns the download URL of a k0s release asset. +func (k *K0s) releaseAssetURL(version, assetName string) string { + return fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, assetName) } diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index 9eeb1a24d..f1c0e97b1 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -66,26 +66,10 @@ type K0sKonnectivity struct { AgentPort int `yaml:"agentPort,omitempty"` } -type AirgapOptions struct { - Enabled bool - BundlePath string -} - -// firstAirgapOption returns the optionally passed airgap options, or the zero value -// when no options were passed. -func firstAirgapOption(airgap []AirgapOptions) AirgapOptions { - if len(airgap) == 0 { - return AirgapOptions{} - } - - return airgap[0] -} - // GenerateK0sConfig generates the k0s cluster configuration for a Codesphere -// install-config. Airgapped installations are requested through the optional -// airgap options and stop k0s from pulling images. -func GenerateK0sConfig(installConfig *files.RootConfig, airgap ...AirgapOptions) (*K0sConfig, error) { - options := firstAirgapOption(airgap) +// install-config. Airgapped installations are requested through the airgap options +// and stop k0s from pulling images. +func GenerateK0sConfig(installConfig *files.RootConfig, airgap AirgapOptions) (*K0sConfig, error) { if installConfig == nil { return nil, fmt.Errorf("installConfig cannot be nil") } @@ -135,7 +119,7 @@ func GenerateK0sConfig(installConfig *files.RootConfig, airgap ...AirgapOptions) } k0sConfig.Spec.Images = &K0sImages{ - DefaultPullPolicy: pullPolicyFor(options), + DefaultPullPolicy: pullPolicyFor(airgap), } k0sConfig.Spec.Telemetry = &K0sTelemetry{ diff --git a/internal/installer/k0s_config_test.go b/internal/installer/k0s_config_test.go index db36cf596..985bf9349 100644 --- a/internal/installer/k0s_config_test.go +++ b/internal/installer/k0s_config_test.go @@ -21,7 +21,7 @@ var _ = Describe("K0sConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) @@ -59,7 +59,7 @@ var _ = Describe("K0sConfig", func() { It("should handle minimal configuration", func() { installConfig := newTestConfig("minimal", true, "192.168.1.100") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-minimal")) @@ -84,12 +84,23 @@ var _ = Describe("K0sConfig", func() { Expect(k0sConfig.Spec.Images.DefaultPullPolicy).To(Equal("IfNotPresent")) }) + It("should serialize the airgapped pull policy into the k0s config YAML", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{Enabled: true}) + Expect(err).ToNot(HaveOccurred()) + + yamlData, err := k0sConfig.Marshal() + Expect(err).ToNot(HaveOccurred()) + Expect(string(yamlData)).To(ContainSubstring("default_pull_policy: Never")) + }) + It("should generate valid YAML", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).ToNot(HaveOccurred()) yamlData, err := k0sConfig.Marshal() @@ -107,7 +118,7 @@ var _ = Describe("K0sConfig", func() { Context("with invalid input", func() { It("should return error for nil install-config", func() { - k0sConfig, err := installer.GenerateK0sConfig(nil) + k0sConfig, err := installer.GenerateK0sConfig(nil, installer.AirgapOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("installConfig cannot be nil")) Expect(k0sConfig).To(BeNil()) @@ -120,7 +131,7 @@ var _ = Describe("K0sConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) // Should still have basic structure but no specific config @@ -132,7 +143,7 @@ var _ = Describe("K0sConfig", func() { It("should handle empty datacenter name", func() { installConfig := newTestConfig("", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-")) }) @@ -141,7 +152,7 @@ var _ = Describe("K0sConfig", func() { installConfig := newTestConfig("test", true) installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) // Should have basic structure but no API/Storage config Expect(k0sConfig.Spec.API).To(BeNil()) @@ -151,7 +162,7 @@ var _ = Describe("K0sConfig", func() { It("should handle nil control plane addresses", func() { installConfig := newTestConfig("test", true) - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API).To(BeNil()) }) @@ -159,7 +170,7 @@ var _ = Describe("K0sConfig", func() { It("should handle missing APIServerHost", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.ExternalAddress).To(BeEmpty()) Expect(k0sConfig.Spec.API.SANs).To(ConsistOf("10.0.0.1")) @@ -168,7 +179,7 @@ var _ = Describe("K0sConfig", func() { It("should handle missing network CIDRs", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network).NotTo(BeNil()) Expect(k0sConfig.Spec.Network.Provider).To(Equal("calico")) @@ -177,7 +188,7 @@ var _ = Describe("K0sConfig", func() { It("should use default network provider", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network.Provider).To(Equal("calico")) }) @@ -186,7 +197,7 @@ var _ = Describe("K0sConfig", func() { installConfig := newTestConfig("test", true, "10.0.0.1") installConfig.Kubernetes.APIServerHost = "api.example.com" - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.SANs).To(HaveLen(2)) Expect(k0sConfig.Spec.API.SANs).To(ContainElements("10.0.0.1", "api.example.com")) @@ -195,7 +206,7 @@ var _ = Describe("K0sConfig", func() { It("should handle special characters in datacenter name", func() { installConfig := newTestConfig("test-dc_01.prod", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-test-dc_01.prod")) }) @@ -203,7 +214,7 @@ var _ = Describe("K0sConfig", func() { It("should set correct API port", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.Port).To(Equal(6443)) }) @@ -211,7 +222,7 @@ var _ = Describe("K0sConfig", func() { It("should configure etcd with first control plane IP", func() { installConfig := newTestConfig("test", true, "10.0.0.1", "10.0.0.2") - k0sConfig, err := installer.GenerateK0sConfig(installConfig) + k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Storage.Type).To(Equal("etcd")) Expect(k0sConfig.Spec.Storage.Etcd.PeerAddress).To(Equal("10.0.0.1")) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index 300585991..60dca69cf 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -114,7 +114,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + _, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("windows/amd64")) @@ -124,7 +124,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + _, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("linux/arm64")) @@ -156,7 +156,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -178,7 +178,7 @@ var _ = Describe("K0s", func() { Expect(err).ToNot(HaveOccurred()) mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -199,7 +199,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -221,7 +221,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", true, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{Force: true}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -240,7 +240,7 @@ var _ = Describe("K0s", func() { It("should fail when file creation fails", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(nil, errors.New("permission denied")) - _, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + _, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to download k0s binary")) Expect(err.Error()).To(ContainSubstring("permission denied")) @@ -259,7 +259,7 @@ var _ = Describe("K0s", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(mockFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", mockFile, false).Return(errors.New("download failed")) - _, err = k0s.Download("v1.29.1+k0s.0", false, false, false) + _, err = k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to download k0s binary")) Expect(err.Error()).To(ContainSubstring("download failed")) @@ -279,7 +279,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -312,7 +312,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - path, err := k0s.Download("v1.29.1+k0s.0", false, false, false) + path, err := k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(k0sPath)) }) @@ -330,32 +330,15 @@ var _ = Describe("K0s", func() { modernAsset = "k0s-airgap-bundle-" + modernVersion + "-linux-amd64.tar" ) - // releaseJSON renders a GitHub release API response listing the given assets. - releaseJSON := func(names ...string) []byte { - type asset struct { - Name string `json:"name"` - } - - assets := make([]asset, 0, len(names)) - for _, name := range names { - assets = append(assets, asset{Name: name}) - } - - data, err := json.Marshal(struct { - Assets []asset `json:"assets"` - }{Assets: assets}) - Expect(err).ToNot(HaveOccurred()) - - return data - } - - // expectAirgapDownload sets up the file and http mocks for a fresh download. - expectAirgapDownload := func(version, assetName string) string { + // expectAirgapDownload sets up the file and http mocks for a fresh download of + // assetName and returns the expected bundle path. otherAssets are listed by the + // release before assetName. + expectAirgapDownload := func(version, assetName string, otherAssets ...string) string { bundlePath := filepath.Join(workDir, assetName) mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+version). - Return(releaseJSON(assetName), nil) + Return(releaseJSON(append(otherAssets, assetName)...), nil) err := os.MkdirAll(workDir, 0755) Expect(err).ToNot(HaveOccurred()) @@ -385,7 +368,7 @@ var _ = Describe("K0s", func() { It("resolves the legacy asset name used up to k0s v1.35", func() { bundlePath := expectAirgapDownload(legacyVersion, legacyAsset) - path, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + path, err := k0s.EnsureAirgapBundle(legacyVersion, installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(bundlePath)) }) @@ -393,22 +376,48 @@ var _ = Describe("K0s", func() { It("resolves the current asset name with OS and extension used since k0s v1.36", func() { bundlePath := expectAirgapDownload(modernVersion, modernAsset) - path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + path, err := k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("accepts the legacy asset name with a tar extension", func() { + bundlePath := expectAirgapDownload(legacyVersion, legacyAsset+".tar") + + path, err := k0s.EnsureAirgapBundle(legacyVersion, installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(bundlePath)) }) It("ignores assets of other platforms", func() { - mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) - mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+modernVersion). - Return(releaseJSON( - "k0s-airgap-bundle-"+modernVersion+"-linux-arm64.tar", - "k0s-airgap-bundle-"+modernVersion+"-windows2022-amd64.tar", - modernAsset, - ), nil) + bundlePath := expectAirgapDownload( + modernVersion, + modernAsset, + "k0s-airgap-bundle-"+modernVersion+"-linux-arm64.tar", + "k0s-airgap-bundle-"+modernVersion+"-windows2022-amd64.tar", + ) + + path, err := k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("reuses a cached bundle without contacting the release API", func() { + bundlePath := filepath.Join(workDir, modernAsset) + + mockFileWriter.EXPECT().ReadDir(workDir).Return([]os.DirEntry{fakeDirEntry{name: modernAsset}}, nil) + mockFileWriter.EXPECT().Exists(bundlePath).Return(true) + path, err := k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(bundlePath)) + }) + + It("re-downloads a cached bundle when force is set", func() { bundlePath := filepath.Join(workDir, modernAsset) + mockFileWriter.EXPECT().ReadDir(workDir).Return([]os.DirEntry{fakeDirEntry{name: modernAsset}}, nil) + err := os.MkdirAll(workDir, 0755) Expect(err).ToNot(HaveOccurred()) @@ -417,24 +426,39 @@ var _ = Describe("K0s", func() { defer util.CloseFileIgnoreError(realFile) - mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Exists(bundlePath).Return(true) mockFileWriter.EXPECT().Create(bundlePath).Return(realFile, nil) mockHttp.EXPECT().Download( "https://github.com/k0sproject/k0s/releases/download/"+modernVersion+"/"+modernAsset, realFile, false, ).Return(nil) - path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + path, err := k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{Force: true}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(bundlePath)) }) - It("reuses a cached bundle without contacting the release API", func() { + It("falls back to the release API when the cache cannot be read", func() { bundlePath := filepath.Join(workDir, modernAsset) - mockFileWriter.EXPECT().ReadDir(workDir).Return([]os.DirEntry{fakeDirEntry{name: modernAsset}}, nil) - mockFileWriter.EXPECT().Exists(bundlePath).Return(true) + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, errors.New("permission denied")) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+modernVersion). + Return(releaseJSON(modernAsset), nil) + + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(bundlePath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(realFile) - path, err := k0s.EnsureAirgapBundle(modernVersion, false, false) + mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Create(bundlePath).Return(realFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+modernVersion+"/"+modernAsset, realFile, false, + ).Return(nil) + + path, err := k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal(bundlePath)) }) @@ -444,7 +468,7 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+legacyVersion). Return(nil, errors.New("network error")) - _, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + _, err := k0s.EnsureAirgapBundle(legacyVersion, installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to fetch k0s release")) }) @@ -454,11 +478,107 @@ var _ = Describe("K0s", func() { mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+legacyVersion). Return(releaseJSON("k0s-v1.31.14+k0s.0-amd64"), nil) - _, err := k0s.EnsureAirgapBundle(legacyVersion, false, false) + _, err := k0s.EnsureAirgapBundle(legacyVersion, installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no airgap bundle for linux/amd64")) }) }) + + Describe("cache failures", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + }) + + It("fails when the cache directory cannot be determined", func() { + mockEnv.EXPECT().GetOmsCacheDir().Return("", errors.New("no cache dir")) + + _, err := k0s.EnsureAirgapBundle("v1.31.14+k0s.0", installer.DownloadOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to determine cache directory")) + }) + + It("fails when the cache directory cannot be created", func() { + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) + mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(errors.New("permission denied")) + + _, err := k0s.EnsureAirgapBundle("v1.31.14+k0s.0", installer.DownloadOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create workdir")) + }) + }) + + Describe("Download with airgap", func() { + const airgapVersion = "v1.36.3+k0s.2" + + var airgapAsset = "k0s-airgap-bundle-" + airgapVersion + "-linux-amd64.tar" + + // expectBinaryDownload sets up the mocks for a fresh k0s binary download. + expectBinaryDownload := func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(k0sPath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+airgapVersion+"/k0s-"+airgapVersion+"-amd64", realFile, false, + ).Return(nil) + mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) + } + + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + + mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) + mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) + }) + + It("downloads the airgap bundle in addition to the binary", func() { + expectBinaryDownload() + + bundlePath := filepath.Join(workDir, airgapAsset) + + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+airgapVersion). + Return(releaseJSON(airgapAsset), nil) + + bundleFile, err := os.Create(bundlePath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(bundleFile) + + mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Create(bundlePath).Return(bundleFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+airgapVersion+"/"+airgapAsset, bundleFile, false, + ).Return(nil) + + path, err := k0s.Download(airgapVersion, installer.DownloadOptions{Airgapped: true}) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) + }) + + It("wraps airgap bundle download failures and returns no binary path", func() { + expectBinaryDownload() + + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+airgapVersion). + Return(nil, errors.New("network error")) + + path, err := k0s.Download(airgapVersion, installer.DownloadOptions{Airgapped: true}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s airgap bundle")) + Expect(err.Error()).To(ContainSubstring("network error")) + Expect(path).To(BeEmpty()) + }) + }) }) // fakeDirEntry is a minimal os.DirEntry for cache lookups in tests. @@ -470,3 +590,22 @@ func (f fakeDirEntry) Name() string { return f.name } func (f fakeDirEntry) IsDir() bool { return false } func (f fakeDirEntry) Type() os.FileMode { return 0 } func (f fakeDirEntry) Info() (os.FileInfo, error) { return nil, nil } + +// releaseJSON renders a GitHub release API response listing the given assets. +func releaseJSON(names ...string) []byte { + type asset struct { + Name string `json:"name"` + } + + assets := make([]asset, 0, len(names)) + for _, name := range names { + assets = append(assets, asset{Name: name}) + } + + data, err := json.Marshal(struct { + Assets []asset `json:"assets"` + }{Assets: assets}) + Expect(err).ToNot(HaveOccurred()) + + return data +} diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index a49ccd352..70a2ba79d 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -17,6 +17,9 @@ import ( "github.com/codesphere-cloud/oms/internal/util" ) +// k0sctlBinaryName is the name of the cached k0sctl binary. +const k0sctlBinaryName = "k0sctl" + //mockery:generate: true type K0sctlManager interface { GetLatestVersion() (string, error) @@ -44,10 +47,6 @@ func NewK0sctl(hw portal.Http, env env.Env, fw util.FileIO) *K0sctl { } } -type githubRelease struct { - TagName string `json:"tag_name"` -} - func (k *K0sctl) GetLatestVersion() (string, error) { releaseURL := "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" @@ -69,13 +68,9 @@ func (k *K0sctl) GetLatestVersion() (string, error) { } func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error) { - cacheDir, err := k.Env.GetOmsCacheDir() + cacheDir, err := ensureCacheDir(k.FileWriter, k.Env) if err != nil { - return "", fmt.Errorf("failed to determine cache directory: %w", err) - } - - if err := k.FileWriter.MkdirAll(cacheDir, 0755); err != nil { - return "", fmt.Errorf("failed to create workdir: %w", err) + return "", err } if version == "" { @@ -93,28 +88,17 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error version = "v" + version } - cachePath := filepath.Join(cacheDir, "k0sctl") - if k.FileWriter.Exists(cachePath) && !force { - cachedVersion, versionErr := localBinaryVersion(cachePath) - if versionErr == nil && cachedVersion == version { - io.Verbosef(!quiet, "Using cached k0sctl %s at %s", version, cachePath) - - return cachePath, nil - } - - if versionErr != nil { - io.Verbosef(!quiet, "Cached k0sctl version could not be determined; replacing it: %v", versionErr) - } else { - io.Verbosef(!quiet, "Cached k0sctl version %s does not match requested version %s; replacing it", cachedVersion, version) - } + cachePath := filepath.Join(cacheDir, k0sctlBinaryName) + if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sctlBinaryName, force, quiet); cached { + return cachedPath, nil } - binaryName := fmt.Sprintf("k0sctl-%s-%s", k.Goos, k.Goarch) + binaryName := fmt.Sprintf("%s-%s-%s", k0sctlBinaryName, k.Goos, k.Goarch) downloadURL := fmt.Sprintf("https://github.com/k0sproject/k0sctl/releases/download/%s/%s", version, binaryName) io.Verbosef(!quiet, "Downloading k0sctl %s from %s", version, downloadURL) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, "k0sctl", downloadURL, quiet) + path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sctlBinaryName, downloadURL, quiet) if err != nil { return "", err } diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 8ffc3eff9..da0eacc2c 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -47,10 +47,17 @@ type K0sctlFile struct { Perm string `yaml:"perm,omitempty"` } -type k0sctlHostOptions struct { - SSHKeyPath string +// K0sctlOptions configures the k0sctl cluster configuration that oms generates from +// an install-config. +type K0sctlOptions struct { + // K0sVersion is the version of k0s that k0sctl installs on the nodes. + K0sVersion string + // SSHKeyPath is the private key k0sctl uses to connect to the nodes. + SSHKeyPath string + // K0sBinaryPath is the local k0s binary that k0sctl uploads to the nodes. K0sBinaryPath string - Airgap AirgapOptions + // Airgap describes an airgapped installation. + Airgap AirgapOptions } type K0sctlSSH struct { @@ -85,7 +92,7 @@ type K0sctlApplyHooks struct { // addUniqueK0sctlHost appends a host to the cluster config unless its address is // already present. isWorker marks hosts that run the k0s worker role, either // dedicated workers or control planes installed with --enable-worker. -func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, isWorker bool, options k0sctlHostOptions) { +func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, isWorker bool, options K0sctlOptions) { for _, host := range k.Hosts { if host.PrivateAddress == node.IPAddress { return @@ -112,19 +119,19 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal host.K0sBinaryPath = options.K0sBinaryPath } - if isWorker && options.Airgap.Enabled && options.Airgap.BundlePath != "" { - host.Files = []K0sctlFile{{ + if isWorker && options.Airgap.Enabled { + host.Files = append(host.Files, K0sctlFile{ Src: options.Airgap.BundlePath, DstDir: AirgapImagesDir, Perm: "0644", - }} + }) } k.Hosts = append(k.Hosts, host) } // GenerateK0sctlConfig generates a k0sctl configuration from a Codesphere install-config -func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, sshKeyPath string, k0sBinaryPath string, airgap ...AirgapOptions) (*K0sctlConfig, error) { +func GenerateK0sctlConfig(installConfig *files.RootConfig, options K0sctlOptions) (*K0sctlConfig, error) { if installConfig == nil { return nil, fmt.Errorf("installConfig cannot be nil") } @@ -133,10 +140,8 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, ss return nil, fmt.Errorf("k0sctl is only supported for Codesphere-managed Kubernetes") } - options := k0sctlHostOptions{ - SSHKeyPath: sshKeyPath, - K0sBinaryPath: k0sBinaryPath, - Airgap: firstAirgapOption(airgap), + if options.Airgap.Enabled && options.Airgap.BundlePath == "" { + return nil, fmt.Errorf("airgapped installations require an airgap bundle path") } // Generate k0s config that will be embedded in k0sctl config @@ -154,7 +159,7 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, k0sVersion string, ss Spec: K0sctlSpec{ Hosts: []K0sctlHost{}, K0s: K0sctlK0s{ - Version: k0sVersion, + Version: options.K0sVersion, Config: k0sConfig, }, }, diff --git a/internal/installer/k0sctl_config_test.go b/internal/installer/k0sctl_config_test.go index 84ea5a185..72e88393c 100644 --- a/internal/installer/k0sctl_config_test.go +++ b/internal/installer/k0sctl_config_test.go @@ -21,7 +21,11 @@ var _ = Describe("K0sctlConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "/path/to/k0s") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, installer.K0sctlOptions{ + K0sVersion: "v1.30.0+k0s.0", + SSHKeyPath: "/path/to/key", + K0sBinaryPath: "/path/to/k0s", + }) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig).ToNot(BeNil()) @@ -40,7 +44,7 @@ var _ = Describe("K0sctlConfig", func() { It("should assign controller role to control plane nodes", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) @@ -55,7 +59,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.11"}, } - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(3)) @@ -76,7 +80,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, // Unique } - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) // The overlapping node is emitted once, but retains both roles in @@ -92,7 +96,7 @@ var _ = Describe("K0sctlConfig", func() { It("should enable UploadBinary when k0sBinaryPath is provided", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "/path/to/k0s") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "/path/to/k0s")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].UploadBinary).To(BeTrue()) @@ -102,7 +106,7 @@ var _ = Describe("K0sctlConfig", func() { It("should not enable UploadBinary when k0sBinaryPath is empty", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].UploadBinary).To(BeFalse()) @@ -112,7 +116,7 @@ var _ = Describe("K0sctlConfig", func() { It("should set SSH key path correctly", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/home/user/.ssh/id_rsa", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/home/user/.ssh/id_rsa", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].SSH.KeyPath).To(Equal("/home/user/.ssh/id_rsa")) @@ -121,7 +125,7 @@ var _ = Describe("K0sctlConfig", func() { It("should set SSH user to root", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].SSH.User).To(Equal("root")) @@ -130,7 +134,7 @@ var _ = Describe("K0sctlConfig", func() { It("should set KUBELET_EXTRA_ARGS environment variable", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].Environment).To(HaveKeyWithValue("KUBELET_EXTRA_ARGS", "--node-ip=10.0.1.10")) @@ -144,7 +148,7 @@ var _ = Describe("K0sctlConfig", func() { airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/k0s-airgap-bundle-amd64"} - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) Expect(err).ToNot(HaveOccurred()) // Controllers without the worker role do not import image bundles. @@ -167,7 +171,7 @@ var _ = Describe("K0sctlConfig", func() { airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) @@ -181,7 +185,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, } - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].Files).To(BeEmpty()) @@ -196,7 +200,7 @@ var _ = Describe("K0sctlConfig", func() { airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "", airgap) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) Expect(err).ToNot(HaveOccurred()) yamlData, err := k0sctlConfig.Marshal() @@ -214,7 +218,7 @@ var _ = Describe("K0sctlConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) yamlData, err := k0sctlConfig.Marshal() @@ -232,7 +236,7 @@ var _ = Describe("K0sctlConfig", func() { Context("with invalid input", func() { It("should return error for nil install-config", func() { - k0sctlConfig, err := installer.GenerateK0sctlConfig(nil, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(nil, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("installConfig cannot be nil")) Expect(k0sctlConfig).To(BeNil()) @@ -241,11 +245,21 @@ var _ = Describe("K0sctlConfig", func() { It("should return error for non-managed Kubernetes", func() { installConfig := newTestConfig("test-dc", false) - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("k0sctl is only supported for Codesphere-managed Kubernetes")) Expect(k0sctlConfig).To(BeNil()) }) + + It("should return error for airgapped installations without a bundle path", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, + k0sctlAirgapOptions("v1.30.0+k0s.0", installer.AirgapOptions{Enabled: true})) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("require an airgap bundle path")) + Expect(k0sctlConfig).To(BeNil()) + }) }) Context("edge cases", func() { @@ -253,7 +267,7 @@ var _ = Describe("K0sctlConfig", func() { installConfig := newTestConfig("test-dc", true) installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(BeEmpty()) }) @@ -261,7 +275,7 @@ var _ = Describe("K0sctlConfig", func() { It("should handle nil control plane list", func() { installConfig := newTestConfig("test-dc", true) - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(BeEmpty()) }) @@ -273,17 +287,36 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, } - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) // Workers should still be added even without control planes Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) Expect(k0sctlConfig.Spec.Hosts[0].Role).To(Equal("worker")) }) + It("should upload the airgap bundle to worker-only clusters", func() { + installConfig := newTestConfig("test-dc", true) + installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} + installConfig.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.0.2.10"}, + } + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, + k0sctlAirgapOptions("v1.30.0+k0s.0", installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"})) + Expect(err).ToNot(HaveOccurred()) + + Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) + Expect(k0sctlConfig.Spec.Hosts[0].Files).To(Equal([]installer.K0sctlFile{{ + Src: "/cache/bundle", + DstDir: "/var/lib/k0s/images", + Perm: "0644", + }})) + }) + It("should handle empty SSH key path", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "", "")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts[0].SSH.KeyPath).To(BeEmpty()) @@ -295,7 +328,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, } - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, "v1.30.0+k0s.0", "/path/to/key", "") + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) Expect(err).ToNot(HaveOccurred()) // Both hosts should have PrivateAddress set to the internal IP @@ -305,3 +338,21 @@ var _ = Describe("K0sctlConfig", func() { }) }) }) + +// k0sctlOptions builds k0sctl options for installations with internet access. +func k0sctlOptions(k0sVersion, sshKeyPath, k0sBinaryPath string) installer.K0sctlOptions { + return installer.K0sctlOptions{ + K0sVersion: k0sVersion, + SSHKeyPath: sshKeyPath, + K0sBinaryPath: k0sBinaryPath, + } +} + +// k0sctlAirgapOptions builds k0sctl options for airgapped installations. +func k0sctlAirgapOptions(k0sVersion string, airgap installer.AirgapOptions) installer.K0sctlOptions { + return installer.K0sctlOptions{ + K0sVersion: k0sVersion, + SSHKeyPath: "/path/to/key", + Airgap: airgap, + } +} diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 021d33b9a..76fb32ba2 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -1208,8 +1208,8 @@ func (_m *MockK0sManager) EXPECT() *MockK0sManager_Expecter { } // Download provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) Download(version string, force bool, quiet bool, airgapped bool) (string, error) { - ret := _mock.Called(version, force, quiet, airgapped) +func (_mock *MockK0sManager) Download(version string, opts DownloadOptions) (string, error) { + ret := _mock.Called(version, opts) if len(ret) == 0 { panic("no return value specified for Download") @@ -1217,16 +1217,16 @@ func (_mock *MockK0sManager) Download(version string, force bool, quiet bool, ai var r0 string var r1 error - if returnFunc, ok := ret.Get(0).(func(string, bool, bool, bool) (string, error)); ok { - return returnFunc(version, force, quiet, airgapped) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) (string, error)); ok { + return returnFunc(version, opts) } - if returnFunc, ok := ret.Get(0).(func(string, bool, bool, bool) string); ok { - r0 = returnFunc(version, force, quiet, airgapped) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) string); ok { + r0 = returnFunc(version, opts) } else { r0 = ret.Get(0).(string) } - if returnFunc, ok := ret.Get(1).(func(string, bool, bool, bool) error); ok { - r1 = returnFunc(version, force, quiet, airgapped) + if returnFunc, ok := ret.Get(1).(func(string, DownloadOptions) error); ok { + r1 = returnFunc(version, opts) } else { r1 = ret.Error(1) } @@ -1240,36 +1240,24 @@ type MockK0sManager_Download_Call struct { // Download is a helper method to define mock.On call // - version string -// - force bool -// - quiet bool -// - airgapped bool -func (_e *MockK0sManager_Expecter) Download(version any, force any, quiet any, airgapped any) *MockK0sManager_Download_Call { - return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", version, force, quiet, airgapped)} +// - opts DownloadOptions +func (_e *MockK0sManager_Expecter) Download(version any, opts any) *MockK0sManager_Download_Call { + return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", version, opts)} } -func (_c *MockK0sManager_Download_Call) Run(run func(version string, force bool, quiet bool, airgapped bool)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_Download_Call) Run(run func(version string, opts DownloadOptions)) *MockK0sManager_Download_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } - var arg1 bool + var arg1 DownloadOptions if args[1] != nil { - arg1 = args[1].(bool) - } - var arg2 bool - if args[2] != nil { - arg2 = args[2].(bool) - } - var arg3 bool - if args[3] != nil { - arg3 = args[3].(bool) + arg1 = args[1].(DownloadOptions) } run( arg0, arg1, - arg2, - arg3, ) }) return _c @@ -1280,14 +1268,14 @@ func (_c *MockK0sManager_Download_Call) Return(s string, err error) *MockK0sMana return _c } -func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, force bool, quiet bool, airgapped bool) (string, error)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, opts DownloadOptions) (string, error)) *MockK0sManager_Download_Call { _c.Call.Return(run) return _c } // EnsureAirgapBundle provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) EnsureAirgapBundle(version string, force bool, quiet bool) (string, error) { - ret := _mock.Called(version, force, quiet) +func (_mock *MockK0sManager) EnsureAirgapBundle(version string, opts DownloadOptions) (string, error) { + ret := _mock.Called(version, opts) if len(ret) == 0 { panic("no return value specified for EnsureAirgapBundle") @@ -1295,16 +1283,16 @@ func (_mock *MockK0sManager) EnsureAirgapBundle(version string, force bool, quie var r0 string var r1 error - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) (string, error)); ok { - return returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) (string, error)); ok { + return returnFunc(version, opts) } - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) string); ok { - r0 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) string); ok { + r0 = returnFunc(version, opts) } else { r0 = ret.Get(0).(string) } - if returnFunc, ok := ret.Get(1).(func(string, bool, bool) error); ok { - r1 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(1).(func(string, DownloadOptions) error); ok { + r1 = returnFunc(version, opts) } else { r1 = ret.Error(1) } @@ -1318,30 +1306,24 @@ type MockK0sManager_EnsureAirgapBundle_Call struct { // EnsureAirgapBundle is a helper method to define mock.On call // - version string -// - force bool -// - quiet bool -func (_e *MockK0sManager_Expecter) EnsureAirgapBundle(version any, force any, quiet any) *MockK0sManager_EnsureAirgapBundle_Call { - return &MockK0sManager_EnsureAirgapBundle_Call{Call: _e.mock.On("EnsureAirgapBundle", version, force, quiet)} +// - opts DownloadOptions +func (_e *MockK0sManager_Expecter) EnsureAirgapBundle(version any, opts any) *MockK0sManager_EnsureAirgapBundle_Call { + return &MockK0sManager_EnsureAirgapBundle_Call{Call: _e.mock.On("EnsureAirgapBundle", version, opts)} } -func (_c *MockK0sManager_EnsureAirgapBundle_Call) Run(run func(version string, force bool, quiet bool)) *MockK0sManager_EnsureAirgapBundle_Call { +func (_c *MockK0sManager_EnsureAirgapBundle_Call) Run(run func(version string, opts DownloadOptions)) *MockK0sManager_EnsureAirgapBundle_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } - var arg1 bool + var arg1 DownloadOptions if args[1] != nil { - arg1 = args[1].(bool) - } - var arg2 bool - if args[2] != nil { - arg2 = args[2].(bool) + arg1 = args[1].(DownloadOptions) } run( arg0, arg1, - arg2, ) }) return _c @@ -1352,7 +1334,7 @@ func (_c *MockK0sManager_EnsureAirgapBundle_Call) Return(s string, err error) *M return _c } -func (_c *MockK0sManager_EnsureAirgapBundle_Call) RunAndReturn(run func(version string, force bool, quiet bool) (string, error)) *MockK0sManager_EnsureAirgapBundle_Call { +func (_c *MockK0sManager_EnsureAirgapBundle_Call) RunAndReturn(run func(version string, opts DownloadOptions) (string, error)) *MockK0sManager_EnsureAirgapBundle_Call { _c.Call.Return(run) return _c } From 3fc99a595416412586d7ad1d4a6d1f3b9855e2c9 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:04:22 +0200 Subject: [PATCH 08/26] fix(lint): document exported K0sctlFile type Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- internal/installer/k0sctl_config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index da0eacc2c..9e76b5a7d 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -41,6 +41,8 @@ type K0sctlHost struct { Hooks *K0sctlHooks `yaml:"hooks,omitempty"` } +// K0sctlFile is a file that k0sctl uploads to a node before installing k0s, for +// example the airgap image bundle that worker nodes import. type K0sctlFile struct { Src string `yaml:"src"` DstDir string `yaml:"dstDir"` From 559e465bbec4cb8af62f8a73378e19eb1005985d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:20:15 +0200 Subject: [PATCH 09/26] fix(download): remove partial downloads on failure and update tests --- cli/cmd/k0s/install_k0s.go | 2 +- cli/cmd/k0s/install_k0s_test.go | 10 +++++----- internal/installer/download.go | 2 ++ internal/installer/k0s_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 20556af07..c24a0a7c0 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -241,7 +241,7 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal return pm.GetDependencyPath(defaultK0sPath), nil } - k0sBinaryPath, err := k0s.Download(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force, Airgapped: c.Opts.Airgap}) + k0sBinaryPath, err := k0s.Download(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force}) if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 4dfbcc00d..c755af5db 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -225,7 +225,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundlePath).Return(true) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) // The generated k0sctl config must upload the bundle to the workers and @@ -251,7 +251,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/k0s-airgap-bundle-amd64", nil) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) @@ -269,7 +269,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/nonexistent/bundle.tar").Return(false) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) @@ -343,7 +343,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/cache/bundle").Return(true) mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) @@ -361,7 +361,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) - mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Force: true, Airgapped: true}).Return("/downloaded/k0s", nil) + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/cache/bundle", nil) mockK0sctl.EXPECT().Download("", true, false).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) diff --git a/internal/installer/download.go b/internal/installer/download.go index 7d1d21530..eacde404c 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -84,6 +84,8 @@ func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, defer util.CloseFileIgnoreError(dstFile) if err := http.Download(downloadURL, dstFile, quiet); err != nil { + _ = fw.Remove(path) + return fmt.Errorf("failed to download %s: %w", path, err) } diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index 60dca69cf..3e215fd99 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -258,6 +258,8 @@ var _ = Describe("K0s", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(mockFile, nil) mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", mockFile, false).Return(errors.New("download failed")) + // The truncated destination must not stay behind as a reusable cache entry. + mockFileWriter.EXPECT().Remove(k0sPath).Return(nil) _, err = k0s.Download("v1.29.1+k0s.0", installer.DownloadOptions{}) Expect(err).To(HaveOccurred()) @@ -463,6 +465,34 @@ var _ = Describe("K0s", func() { Expect(path).To(Equal(bundlePath)) }) + It("removes a partial bundle when the transfer fails", func() { + bundlePath := filepath.Join(workDir, modernAsset) + + mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) + mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+modernVersion). + Return(releaseJSON(modernAsset), nil) + + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(bundlePath) + Expect(err).ToNot(HaveOccurred()) + + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Exists(bundlePath).Return(false) + mockFileWriter.EXPECT().Create(bundlePath).Return(realFile, nil) + mockHttp.EXPECT().Download( + "https://github.com/k0sproject/k0s/releases/download/"+modernVersion+"/"+modernAsset, realFile, false, + ).Return(errors.New("connection reset")) + // The cache only checks for existence, so the partial bundle must go. + mockFileWriter.EXPECT().Remove(bundlePath).Return(nil) + + _, err = k0s.EnsureAirgapBundle(modernVersion, installer.DownloadOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download")) + }) + It("fails when the release metadata cannot be fetched", func() { mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+legacyVersion). From 09409a3b72a7b5871ba39868c8f356c971cef4b0 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:40:17 +0200 Subject: [PATCH 10/26] ref: streamline k0s and k0sctl download logic and improve airgap handling --- cli/cmd/k0s/install_k0s.go | 31 +++++++++------------- internal/installer/airgap.go | 40 ++++++++++------------------- internal/installer/download.go | 24 ++++++++++------- internal/installer/k0s.go | 34 ++++++++---------------- internal/installer/k0s_config.go | 13 +++------- internal/installer/k0sctl.go | 24 ++++++++--------- internal/installer/k0sctl_config.go | 14 +++++----- 7 files changed, 71 insertions(+), 109 deletions(-) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index c24a0a7c0..858cd39a1 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -148,7 +148,7 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return err } - k0sVersion, err := c.determineK0sVersion(k0s) + k0sVersion, err := resolveK0sVersion(k0s, c.Opts.Version) if err != nil { return err } @@ -168,7 +168,14 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return err } - k0sctlConfigPath, err := c.generateK0sctlConfig(config, k0sVersion, k0sBinaryPath, airgapBundlePath) + k0sctlOptions := installer.K0sctlOptions{ + K0sVersion: k0sVersion, + SSHKeyPath: c.Opts.SSHKeyPath, + K0sBinaryPath: k0sBinaryPath, + Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, + } + + k0sctlConfigPath, err := c.generateK0sctlConfig(config, k0sctlOptions) if err != nil { return err } @@ -224,10 +231,6 @@ func (c *InstallK0sCmd) loadInstallConfig() (*files.RootConfig, error) { return &config, nil } -func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, error) { - return resolveK0sVersion(k0s, c.Opts.Version) -} - func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s installer.K0sManager, k0sVersion string) (string, error) { if c.Opts.NoDownload { return "", nil @@ -283,16 +286,9 @@ func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, return k0sctlPath, nil } -func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, k0sVersion string, k0sBinaryPath string, airgapBundlePath string) (string, error) { +func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, options installer.K0sctlOptions) (string, error) { log.Println("Generating k0sctl configuration from install-config...") - options := installer.K0sctlOptions{ - K0sVersion: k0sVersion, - SSHKeyPath: c.Opts.SSHKeyPath, - K0sBinaryPath: k0sBinaryPath, - Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, - } - k0sctlConfig, err := installer.GenerateK0sctlConfig(config, options) if err != nil { return "", fmt.Errorf("failed to generate k0sctl config: %w", err) @@ -341,11 +337,8 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 return fmt.Errorf("failed to load vault: %w", err) } - for _, s := range vault.Secrets { - if s.Name == vaultSecretNameKubeconfig { - log.Printf("Updating existing %s secret in vault", vaultSecretNameKubeconfig) - break - } + if vault.GetSecret(vaultSecretNameKubeconfig) != nil { + log.Printf("Updating existing %s secret in vault", vaultSecretNameKubeconfig) } vault.SetSecret(files.SecretEntry{ diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go index 68a44915b..7fac53bf4 100644 --- a/internal/installer/airgap.go +++ b/internal/installer/airgap.go @@ -54,7 +54,7 @@ func (k *K0s) ensureAirgapBundle(version, cacheDir string, opts DownloadOptions) return cachePath, nil } - downloadURL := k.releaseAssetURL(version, filepath.Base(cachePath)) + downloadURL := releaseAssetURL(k0sReleaseURL, version, filepath.Base(cachePath)) io.Verbosef(!opts.Quiet, "Downloading k0s airgap bundle from %s", downloadURL) if err := downloadToPath(k.FileWriter, k.Http, cachePath, downloadURL, opts.Quiet); err != nil { @@ -64,12 +64,15 @@ func (k *K0s) ensureAirgapBundle(version, cacheDir string, opts DownloadOptions) return cachePath, nil } -// airgapBundleCachePath returns the path of the airgap image bundle of the given -// version in cacheDir. An already cached bundle is preferred, so airgapped -// installations do not have to resolve the release metadata over the network. +// airgapBundleCachePath prefers an already cached bundle of the given version over +// resolving the release metadata, so airgapped installations can stay offline. func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { - if cachedPath, found := k.cachedAirgapBundlePath(version, cacheDir); found { - return cachedPath, nil + if entries, err := k.FileWriter.ReadDir(cacheDir); err == nil { + for _, entry := range entries { + if k.isAirgapBundleFor(entry.Name(), version) { + return filepath.Join(cacheDir, entry.Name()), nil + } + } } assetName, err := k.resolveAirgapBundleAssetName(version) @@ -80,23 +83,6 @@ func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { return filepath.Join(cacheDir, assetName), nil } -// cachedAirgapBundlePath looks for an already downloaded airgap bundle of the given -// version and architecture in cacheDir. -func (k *K0s) cachedAirgapBundlePath(version, cacheDir string) (string, bool) { - entries, err := k.FileWriter.ReadDir(cacheDir) - if err != nil { - return "", false - } - - for _, entry := range entries { - if k.isAirgapBundleFor(entry.Name(), version) { - return filepath.Join(cacheDir, entry.Name()), true - } - } - - return "", false -} - // resolveAirgapBundleAssetName returns the release asset name of the airgap image // bundle of the given version and architecture. // @@ -128,12 +114,12 @@ func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { // given version that matches the configured OS and architecture. It accepts both the // legacy and the current upstream naming scheme. func (k *K0s) isAirgapBundleFor(name, version string) bool { - prefix := fmt.Sprintf("%s-%s-", AirgapBundleName, version) - if !strings.HasPrefix(name, prefix) { + platform, ok := strings.CutPrefix(name, fmt.Sprintf("%s-%s-", AirgapBundleName, version)) + if !ok { return false } - platform := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".tar") + platform = strings.TrimSuffix(platform, ".tar") - return platform == k.Goarch || platform == fmt.Sprintf("%s-%s", k.Goos, k.Goarch) + return platform == k.Goarch || platform == k.Goos+"-"+k.Goarch } diff --git a/internal/installer/download.go b/internal/installer/download.go index eacde404c..c20467cc9 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -65,17 +65,22 @@ func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, return cachePath, true } - replaceReason := fmt.Sprintf("Cached %s version %s does not match requested version %s; replacing it", - name, cachedVersion, requestedVersion) + replaceReason := fmt.Sprintf("version %s does not match requested version %s; replacing it", + cachedVersion, requestedVersion) if versionErr != nil { - replaceReason = fmt.Sprintf("Cached %s version could not be determined: %v", name, versionErr) + replaceReason = fmt.Sprintf("version could not be determined: %v", versionErr) } - io.Verbosef(!quiet, "Replacing existing %s binary: %s", name, replaceReason) + io.Verbosef(!quiet, "Replacing existing %s binary: Cached %s %s", name, name, replaceReason) return "", false } +// releaseAssetURL returns the download URL of an asset of a GitHub release. +func releaseAssetURL(releaseURL, version, assetName string) string { + return fmt.Sprintf("%s/%s/%s", releaseURL, version, assetName) +} + func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, quiet bool) error { dstFile, err := fw.Create(path) if err != nil { @@ -93,8 +98,7 @@ func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, } func downloadBinaryToPath(fw util.FileIO, http portal.Http, binaryPath, binaryName, downloadURL string, quiet bool) (string, error) { - err := downloadToPath(fw, http, binaryPath, downloadURL, quiet) - if err != nil { + if err := downloadToPath(fw, http, binaryPath, downloadURL, quiet); err != nil { return "", fmt.Errorf("failed to download: %w", err) } @@ -113,13 +117,15 @@ func localBinaryVersion(binaryPath string) (string, error) { for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) + if line == "" { + continue + } + if version, found := strings.CutPrefix(line, "version:"); found { return strings.TrimSpace(version), nil } - if line != "" { - return line, nil - } + return line, nil } return "", fmt.Errorf("version output is empty") diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 5f83afad9..c22345285 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -16,18 +16,10 @@ import ( ) const ( - // DefaultK0sVersion is the currently verified k0s version - // Use of newer versions should work in most cases but can't be guaranteed DefaultK0sVersion = "v1.31.14+k0s.0" - - // GitHubReleaseURL is the github release page for k0s - GitHubReleaseURL = "https://github.com/k0sproject/k0s/releases/download" - - // k0sReleaseAPIURL lists the assets of a k0s release - k0sReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0s/releases/tags" - - // BinaryName is the name of target binary for oms to download to - BinaryName = "k0s" + k0sReleaseURL = "https://github.com/k0sproject/k0s/releases/download" + k0sReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0s/releases/tags" + k0sBinaryName = "k0s" ) //mockery:generate: true @@ -89,9 +81,9 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { } if opts.Airgapped { - bundlePath, bundleErr := k.ensureAirgapBundle(version, cacheDir, opts) - if bundleErr != nil { - return "", fmt.Errorf("failed to download k0s airgap bundle: %w", bundleErr) + bundlePath, err := k.ensureAirgapBundle(version, cacheDir, opts) + if err != nil { + return "", fmt.Errorf("failed to download k0s airgap bundle: %w", err) } log.Printf("k0s airgap bundle downloaded to '%s'", bundlePath) @@ -105,14 +97,15 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { // If a binary is already cached and force is false, the cached binary is reused as // long as its version matches; otherwise it is replaced by a fresh download. func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (string, error) { - cachePath := filepath.Join(cacheDir, BinaryName) - if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, BinaryName, opts.Force, opts.Quiet); cached { + cachePath := filepath.Join(cacheDir, k0sBinaryName) + if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sBinaryName, opts.Force, opts.Quiet); cached { return cachedPath, nil } - downloadURL := k.releaseAssetURL(version, fmt.Sprintf("%s-%s-%s", BinaryName, version, k.Goarch)) + assetName := fmt.Sprintf("%s-%s-%s", k0sBinaryName, version, k.Goarch) + downloadURL := releaseAssetURL(k0sReleaseURL, version, assetName) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, BinaryName, downloadURL, opts.Quiet) + path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sBinaryName, downloadURL, opts.Quiet) if err != nil { return "", err } @@ -121,8 +114,3 @@ func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (st return path, nil } - -// releaseAssetURL returns the download URL of a k0s release asset. -func (k *K0s) releaseAssetURL(version, assetName string) string { - return fmt.Sprintf("%s/%s/%s", GitHubReleaseURL, version, assetName) -} diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index f1c0e97b1..d8a5c7379 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -4,6 +4,7 @@ package installer import ( + "cmp" "fmt" "github.com/codesphere-cloud/oms/internal/installer/files" @@ -113,8 +114,8 @@ func GenerateK0sConfig(installConfig *files.RootConfig, airgap AirgapOptions) (* k0sConfig.Spec.Network = &K0sNetwork{ Provider: "calico", - PodCIDR: defaultIfEmpty(installConfig.Kubernetes.PodCIDR, "100.96.0.0/11"), - ServiceCIDR: defaultIfEmpty(installConfig.Kubernetes.ServiceCIDR, "100.64.0.0/13"), + PodCIDR: cmp.Or(installConfig.Kubernetes.PodCIDR, "100.96.0.0/11"), + ServiceCIDR: cmp.Or(installConfig.Kubernetes.ServiceCIDR, "100.64.0.0/13"), ClusterDomain: "cluster.local", } @@ -145,14 +146,6 @@ func pullPolicyFor(options AirgapOptions) string { return "IfNotPresent" } -func defaultIfEmpty(value, defaultValue string) string { - if value != "" { - return value - } - - return defaultValue -} - func (c *K0sConfig) Marshal() ([]byte, error) { return yaml.Marshal(c) } diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index b1a3a098c..6b2f6a756 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -17,8 +17,11 @@ import ( "github.com/codesphere-cloud/oms/internal/util" ) -// k0sctlBinaryName is the name of the cached k0sctl binary. -const k0sctlBinaryName = "k0sctl" +const ( + k0sctlBinaryName = "k0sctl" + k0sctlReleaseURL = "https://github.com/k0sproject/k0sctl/releases/download" + k0sctlReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" +) // DefaultK0sctlVersion is the currently verified k0sctl version. It mirrors // DefaultK0sVersion in k0s.go: the pair is the version combination we test @@ -55,9 +58,7 @@ func NewK0sctl(hw portal.Http, env env.Env, fw util.FileIO) *K0sctl { } func (k *K0sctl) GetLatestVersion() (string, error) { - releaseURL := "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" - - responseBody, err := k.Http.Get(releaseURL) + responseBody, err := k.Http.Get(k0sctlReleaseAPIURL) if err != nil { return "", fmt.Errorf("failed to fetch latest k0sctl release: %w", err) } @@ -81,13 +82,12 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error } if version == "" { - var err error - - version, err = k.GetLatestVersion() + latestVersion, err := k.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest version: %w", err) } + version = latestVersion io.Verbosef(!quiet, "Using latest k0sctl version: %s", version) } @@ -100,8 +100,8 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error return cachedPath, nil } - binaryName := fmt.Sprintf("%s-%s-%s", k0sctlBinaryName, k.Goos, k.Goarch) - downloadURL := fmt.Sprintf("https://github.com/k0sproject/k0sctl/releases/download/%s/%s", version, binaryName) + assetName := fmt.Sprintf("%s-%s-%s", k0sctlBinaryName, k.Goos, k.Goarch) + downloadURL := releaseAssetURL(k0sctlReleaseURL, version, assetName) io.Verbosef(!quiet, "Downloading k0sctl %s from %s", version, downloadURL) @@ -115,8 +115,7 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error return path, nil } -// requireBinaryAndConfig checks that both the k0sctl binary and config exist, -// returning an error if either is missing. +// requireBinaryAndConfig checks that both the k0sctl binary and config exist. func (k *K0sctl) requireBinaryAndConfig(configPath, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return fmt.Errorf("k0sctl binary does not exist at '%s', please download first", k0sctlPath) @@ -140,7 +139,6 @@ func (k *K0sctl) Apply(configPath string, k0sctlPath string, force bool) error { args = append(args, "--force") } - // Add debug flag for more verbose output args = append(args, "--debug") log.Printf("Running k0sctl apply with config: %s", configPath) diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 9e76b5a7d..52b6d957a 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -92,9 +92,9 @@ type K0sctlApplyHooks struct { } // addUniqueK0sctlHost appends a host to the cluster config unless its address is -// already present. isWorker marks hosts that run the k0s worker role, either +// already present. runsWorker marks hosts that run the k0s worker role, either // dedicated workers or control planes installed with --enable-worker. -func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, isWorker bool, options K0sctlOptions) { +func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, installFlags []string, runsWorker bool, options K0sctlOptions) { for _, host := range k.Hosts { if host.PrivateAddress == node.IPAddress { return @@ -121,7 +121,7 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal host.K0sBinaryPath = options.K0sBinaryPath } - if isWorker && options.Airgap.Enabled { + if runsWorker && options.Airgap.Enabled { host.Files = append(host.Files, K0sctlFile{ Src: options.Airgap.BundlePath, DstDir: AirgapImagesDir, @@ -146,7 +146,6 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, options K0sctlOptions return nil, fmt.Errorf("airgapped installations require an airgap bundle path") } - // Generate k0s config that will be embedded in k0sctl config k0sConfig, err := GenerateK0sConfig(installConfig, options.Airgap) if err != nil { return nil, fmt.Errorf("failed to generate k0s config: %w", err) @@ -171,15 +170,14 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, options K0sctlOptions for _, cp := range installConfig.Kubernetes.ControlPlanes { var installFlags []string // A node may intentionally be listed as both a control plane and a worker. - isWorker := slices.Contains(installConfig.Kubernetes.Workers, cp) - if isWorker { + runsWorker := slices.Contains(installConfig.Kubernetes.Workers, cp) + if runsWorker { installFlags = []string{"--enable-worker", "--no-taints=true"} } - k0sctlConfig.Spec.addUniqueK0sctlHost(cp, "controller", installFlags, isWorker, options) + k0sctlConfig.Spec.addUniqueK0sctlHost(cp, "controller", installFlags, runsWorker, options) } - // Add dedicated worker nodes if present for _, worker := range installConfig.Kubernetes.Workers { k0sctlConfig.Spec.addUniqueK0sctlHost(worker, "worker", nil, true, options) } From a598875240fe3880384afad2f1b390ea1696e67d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:09:12 +0200 Subject: [PATCH 11/26] ref: streamline airgap handling and improve download options --- cli/cmd/k0s/download_k0s.go | 24 +++++----- cli/cmd/k0s/download_k0s_test.go | 20 +++++++- cli/cmd/k0s/install_k0s.go | 36 +++++---------- cli/cmd/k0s/install_k0s_test.go | 22 ++++----- internal/installer/airgap.go | 32 ++++++++----- internal/installer/download.go | 21 ++++----- internal/installer/k0s.go | 12 +---- internal/installer/k0s_test.go | 71 ----------------------------- internal/installer/k0sctl.go | 16 ++++--- internal/installer/k0sctl_config.go | 18 ++++++-- internal/installer/k0sctl_test.go | 6 +-- internal/installer/mocks.go | 36 ++++++--------- 12 files changed, 124 insertions(+), 190 deletions(-) diff --git a/cli/cmd/k0s/download_k0s.go b/cli/cmd/k0s/download_k0s.go index 4b0b47860..4edcebd2d 100644 --- a/cli/cmd/k0s/download_k0s.go +++ b/cli/cmd/k0s/download_k0s.go @@ -37,12 +37,7 @@ func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { env := c.Env k0s := installer.NewK0s(hw, env, c.FileWriter) - err := c.DownloadK0s(k0s) - if err != nil { - return fmt.Errorf("failed to download k0s: %w", err) - } - - return nil + return c.DownloadK0s(k0s) } func AddDownloadCmd(download *cobra.Command, opts *util.GlobalOptions) { @@ -78,15 +73,22 @@ func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { return err } - k0sPath, err := k0s.Download(version, installer.DownloadOptions{ - Force: c.Opts.Force, - Quiet: !c.Opts.Verbose, - Airgapped: c.Opts.Airgap, - }) + opts := installer.DownloadOptions{Force: c.Opts.Force, Quiet: !c.Opts.Verbose} + + k0sPath, err := k0s.Download(version, opts) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } + if c.Opts.Airgap { + bundlePath, err := k0s.EnsureAirgapBundle(version, opts) + if err != nil { + return fmt.Errorf("failed to download k0s airgap bundle: %w", err) + } + + log.Printf("k0s airgap bundle downloaded to '%s'", bundlePath) + } + log.Printf("k0s binary downloaded successfully to '%s'", k0sPath) return nil diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index a8fac5b19..5ab20e3e9 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -91,12 +91,30 @@ var _ = Describe("DownloadK0sk0s", func() { c.Opts.Version = "v1.29.1+k0s.0" c.Opts.Airgap = true - mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{Quiet: true, Airgapped: true}).Return("/test/workdir/k0s", nil) + opts := installer.DownloadOptions{Quiet: true} + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", opts).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().EnsureAirgapBundle("v1.29.1+k0s.0", opts).Return("/cache/k0s-airgap-bundle-amd64", nil) err := c.DownloadK0s(mockK0sManager) Expect(err).ToNot(HaveOccurred()) }) + It("reports airgap bundle download failures", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "v1.29.1+k0s.0" + c.Opts.Airgap = true + + opts := installer.DownloadOptions{Quiet: true} + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", opts).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().EnsureAirgapBundle("v1.29.1+k0s.0", opts).Return("", errors.New("network error")) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s airgap bundle")) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + It("succeeds when version is auto-detected and download works", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 858cd39a1..d4873ccaa 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -210,11 +210,11 @@ func (c *InstallK0sCmd) warnAboutNetworkAccess() { } if c.Opts.AirgapBundlePath == "" { - log.Println("Warning: --airgapped without --airgap-bundle downloads the airgap bundle from the internet; pre-download it with 'oms download k0s --airgapped' or pass --airgap-bundle for a truly offline installation") + log.Println("Warning: --airgapped without --airgap-bundle uses the cached airgap bundle or downloads it from the internet; pre-download it with 'oms download k0s --airgapped' or pass --airgap-bundle for a truly offline installation") } - if c.Opts.K0sctlVersion == "" { - log.Println("Warning: k0sctl is resolved and downloaded from the internet; pass --k0sctl-version and pre-cache k0sctl for a truly offline installation") + if c.Opts.K0sctlVersion == "" || c.Opts.K0sctlVersion == installer.DefaultK0sctlVersion { + log.Println("Warning: k0sctl is downloaded from the internet unless it is already cached; pass --k0sctl-version with a pre-cached version for a truly offline installation") } } @@ -276,9 +276,9 @@ func (c *InstallK0sCmd) getAirgapBundlePath(k0s installer.K0sManager, k0sVersion } func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, error) { - log.Println("Downloading k0sctl...") + log.Println("Preparing k0sctl...") - k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, c.Opts.Force, false) + k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, installer.DownloadOptions{Force: c.Opts.Force}) if err != nil { return "", fmt.Errorf("failed to download k0sctl: %w", err) } @@ -332,7 +332,12 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 kubeconfigContent = strings.TrimRight(kubeconfigContent, "\n\r") - vault, err := c.loadOrCreateVault() + store, err := c.vaultStore() + if err != nil { + return err + } + + vault, err := store.LoadOrCreate() if err != nil { return fmt.Errorf("failed to load vault: %w", err) } @@ -349,11 +354,6 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 }, }) - store, err := c.vaultStore() - if err != nil { - return err - } - if err := store.Save(vault); err != nil { return err } @@ -363,20 +363,6 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 return nil } -func (c *InstallK0sCmd) loadOrCreateVault() (*files.InstallVault, error) { - store, err := c.vaultStore() - if err != nil { - return nil, err - } - - data, err := store.LoadOrCreate() - if err != nil { - return nil, fmt.Errorf("failed to load vault: %w", err) - } - - return data, nil -} - func (c *InstallK0sCmd) vaultStore() (vault.Vault, error) { vault, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.VaultPrivKey}) if err != nil { diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index c755af5db..8cfd3d107 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -174,7 +174,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", true, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") - mockK0sctl.EXPECT().Download("", true, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{Force: true}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", true).Return(nil) @@ -190,7 +190,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().Download("v1.29.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -227,7 +227,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundlePath).Return(true) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) // The generated k0sctl config must upload the bundle to the workers and // the embedded k0s config must stop pulling images. mockFileWriter.EXPECT().WriteFile( @@ -253,7 +253,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/k0s-airgap-bundle-amd64", nil) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -300,7 +300,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) // The bundle must be uploaded although no k0s binary is uploaded. mockFileWriter.EXPECT().WriteFile( mock.Anything, @@ -327,7 +327,7 @@ var _ = Describe("InstallK0sCmd", func() { mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -345,7 +345,7 @@ var _ = Describe("InstallK0sCmd", func() { mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/cache/bundle").Return(true) - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) @@ -363,7 +363,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/cache/bundle", nil) - mockK0sctl.EXPECT().Download("", true, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{Force: true}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", true).Return(nil) @@ -380,7 +380,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") - mockK0sctl.EXPECT().Download("", false, false).Return("", os.ErrPermission) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("", os.ErrPermission) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) @@ -396,7 +396,7 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(os.ErrPermission) @@ -409,7 +409,7 @@ var _ = Describe("InstallK0sCmd", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") - mockK0sctl.EXPECT().Download("", false, false).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) } diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go index 7fac53bf4..f92253634 100644 --- a/internal/installer/airgap.go +++ b/internal/installer/airgap.go @@ -37,12 +37,6 @@ func (k *K0s) EnsureAirgapBundle(version string, opts DownloadOptions) (string, return "", err } - return k.ensureAirgapBundle(version, cacheDir, opts) -} - -// ensureAirgapBundle downloads the airgap image bundle into cacheDir unless a bundle -// of that version is already cached there, and returns its path. -func (k *K0s) ensureAirgapBundle(version, cacheDir string, opts DownloadOptions) (string, error) { cachePath, err := k.airgapBundleCachePath(version, cacheDir) if err != nil { return "", err @@ -67,12 +61,8 @@ func (k *K0s) ensureAirgapBundle(version, cacheDir string, opts DownloadOptions) // airgapBundleCachePath prefers an already cached bundle of the given version over // resolving the release metadata, so airgapped installations can stay offline. func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { - if entries, err := k.FileWriter.ReadDir(cacheDir); err == nil { - for _, entry := range entries { - if k.isAirgapBundleFor(entry.Name(), version) { - return filepath.Join(cacheDir, entry.Name()), nil - } - } + if cachedPath, found := k.cachedAirgapBundle(cacheDir, version); found { + return cachedPath, nil } assetName, err := k.resolveAirgapBundleAssetName(version) @@ -83,6 +73,24 @@ func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { return filepath.Join(cacheDir, assetName), nil } +// cachedAirgapBundle returns the path of the cached airgap bundle of the given +// version, if there is one. An unreadable cache is not an error: the release +// metadata then decides which bundle to look for. +func (k *K0s) cachedAirgapBundle(cacheDir, version string) (string, bool) { + entries, err := k.FileWriter.ReadDir(cacheDir) + if err != nil { + return "", false + } + + for _, entry := range entries { + if k.isAirgapBundleFor(entry.Name(), version) { + return filepath.Join(cacheDir, entry.Name()), true + } + } + + return "", false +} + // resolveAirgapBundleAssetName returns the release asset name of the airgap image // bundle of the given version and architecture. // diff --git a/internal/installer/download.go b/internal/installer/download.go index c20467cc9..bbb38051e 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -20,9 +20,6 @@ type DownloadOptions struct { Force bool // Quiet suppresses progress output. Quiet bool - // Airgapped additionally ensures the k0s airgap image bundle. Downloads of - // airgap artifacts themselves ignore it. - Airgapped bool } // githubReleaseAsset is a single downloadable asset of a GitHub release. @@ -51,27 +48,27 @@ func ensureCacheDir(fw util.FileIO, environment env.Env) (string, error) { } // reuseCachedBinary returns the cached binary at cachePath when it exists and reports -// requestedVersion, unless force asks for a fresh download. The returned bool reports +// requestedVersion, unless opts ask for a fresh download. The returned bool reports // whether the cached binary can be reused. -func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, force, quiet bool) (string, bool) { - if !fw.Exists(cachePath) || force { +func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, opts DownloadOptions) (string, bool) { + if !fw.Exists(cachePath) || opts.Force { return "", false } cachedVersion, versionErr := localBinaryVersion(cachePath) if versionErr == nil && cachedVersion == requestedVersion { - io.Verbosef(!quiet, "Using cached %s %s at %s", name, requestedVersion, cachePath) + io.Verbosef(!opts.Quiet, "Using cached %s %s at %s", name, requestedVersion, cachePath) return cachePath, true } - replaceReason := fmt.Sprintf("version %s does not match requested version %s; replacing it", - cachedVersion, requestedVersion) - if versionErr != nil { - replaceReason = fmt.Sprintf("version could not be determined: %v", versionErr) + replaceReason := fmt.Sprintf("version could not be determined: %v", versionErr) + if versionErr == nil { + replaceReason = fmt.Sprintf("version %s does not match requested version %s; replacing it", + cachedVersion, requestedVersion) } - io.Verbosef(!quiet, "Replacing existing %s binary: Cached %s %s", name, name, replaceReason) + io.Verbosef(!opts.Quiet, "Replacing existing %s binary: Cached %s %s", name, name, replaceReason) return "", false } diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index c22345285..4720646fe 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -62,7 +62,6 @@ func (k *K0s) GetLatestVersion() (string, error) { } // Download downloads the k0s binary for the specified version and saves it to the OMS cache dir. -// When the airgap option is set, the airgap image bundle of that version is downloaded as well. func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { if k.Goos != "linux" || k.Goarch != "amd64" { return "", fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) @@ -80,15 +79,6 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { return "", fmt.Errorf("failed to download k0s binary: %w", err) } - if opts.Airgapped { - bundlePath, err := k.ensureAirgapBundle(version, cacheDir, opts) - if err != nil { - return "", fmt.Errorf("failed to download k0s airgap bundle: %w", err) - } - - log.Printf("k0s airgap bundle downloaded to '%s'", bundlePath) - } - return path, nil } @@ -98,7 +88,7 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { // long as its version matches; otherwise it is replaced by a fresh download. func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (string, error) { cachePath := filepath.Join(cacheDir, k0sBinaryName) - if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sBinaryName, opts.Force, opts.Quiet); cached { + if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sBinaryName, opts); cached { return cachedPath, nil } diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index 3e215fd99..a8045d930 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -538,77 +538,6 @@ var _ = Describe("K0s", func() { }) }) - Describe("Download with airgap", func() { - const airgapVersion = "v1.36.3+k0s.2" - - var airgapAsset = "k0s-airgap-bundle-" + airgapVersion + "-linux-amd64.tar" - - // expectBinaryDownload sets up the mocks for a fresh k0s binary download. - expectBinaryDownload := func() { - mockFileWriter.EXPECT().Exists(k0sPath).Return(false) - - err := os.MkdirAll(workDir, 0755) - Expect(err).ToNot(HaveOccurred()) - - realFile, err := os.Create(k0sPath) - Expect(err).ToNot(HaveOccurred()) - - defer util.CloseFileIgnoreError(realFile) - - mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) - mockHttp.EXPECT().Download( - "https://github.com/k0sproject/k0s/releases/download/"+airgapVersion+"/k0s-"+airgapVersion+"-amd64", realFile, false, - ).Return(nil) - mockFileWriter.EXPECT().Chmod(k0sPath, os.FileMode(0755)).Return(nil) - } - - BeforeEach(func() { - k0sImpl.Goos = "linux" - k0sImpl.Goarch = "amd64" - - mockEnv.EXPECT().GetOmsCacheDir().Return(workDir, nil) - mockFileWriter.EXPECT().MkdirAll(workDir, os.FileMode(0755)).Return(nil) - }) - - It("downloads the airgap bundle in addition to the binary", func() { - expectBinaryDownload() - - bundlePath := filepath.Join(workDir, airgapAsset) - - mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) - mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+airgapVersion). - Return(releaseJSON(airgapAsset), nil) - - bundleFile, err := os.Create(bundlePath) - Expect(err).ToNot(HaveOccurred()) - - defer util.CloseFileIgnoreError(bundleFile) - - mockFileWriter.EXPECT().Exists(bundlePath).Return(false) - mockFileWriter.EXPECT().Create(bundlePath).Return(bundleFile, nil) - mockHttp.EXPECT().Download( - "https://github.com/k0sproject/k0s/releases/download/"+airgapVersion+"/"+airgapAsset, bundleFile, false, - ).Return(nil) - - path, err := k0s.Download(airgapVersion, installer.DownloadOptions{Airgapped: true}) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(k0sPath)) - }) - - It("wraps airgap bundle download failures and returns no binary path", func() { - expectBinaryDownload() - - mockFileWriter.EXPECT().ReadDir(workDir).Return(nil, nil) - mockHttp.EXPECT().Get("https://api.github.com/repos/k0sproject/k0s/releases/tags/"+airgapVersion). - Return(nil, errors.New("network error")) - - path, err := k0s.Download(airgapVersion, installer.DownloadOptions{Airgapped: true}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to download k0s airgap bundle")) - Expect(err.Error()).To(ContainSubstring("network error")) - Expect(path).To(BeEmpty()) - }) - }) }) // fakeDirEntry is a minimal os.DirEntry for cache lookups in tests. diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index 6b2f6a756..ccb172c4b 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -33,7 +33,7 @@ const DefaultK0sctlVersion = "v0.33.1" //mockery:generate: true type K0sctlManager interface { GetLatestVersion() (string, error) - Download(version string, force bool, quiet bool) (string, error) + Download(version string, opts DownloadOptions) (string, error) Apply(configPath string, k0sctlPath string, force bool) error Reset(configPath string, k0sctlPath string) error GetKubeconfig(configPath string, k0sctlPath string) (string, error) @@ -75,7 +75,9 @@ func (k *K0sctl) GetLatestVersion() (string, error) { return release.TagName, nil } -func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error) { +// Download stores the k0sctl binary of the requested version in the OMS cache dir and +// returns its path. An empty version resolves to the latest release. +func (k *K0sctl) Download(version string, opts DownloadOptions) (string, error) { cacheDir, err := ensureCacheDir(k.FileWriter, k.Env) if err != nil { return "", err @@ -88,7 +90,7 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error } version = latestVersion - io.Verbosef(!quiet, "Using latest k0sctl version: %s", version) + io.Verbosef(!opts.Quiet, "Using latest k0sctl version: %s", version) } if !strings.HasPrefix(version, "v") { @@ -96,21 +98,21 @@ func (k *K0sctl) Download(version string, force bool, quiet bool) (string, error } cachePath := filepath.Join(cacheDir, k0sctlBinaryName) - if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sctlBinaryName, force, quiet); cached { + if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sctlBinaryName, opts); cached { return cachedPath, nil } assetName := fmt.Sprintf("%s-%s-%s", k0sctlBinaryName, k.Goos, k.Goarch) downloadURL := releaseAssetURL(k0sctlReleaseURL, version, assetName) - io.Verbosef(!quiet, "Downloading k0sctl %s from %s", version, downloadURL) + io.Verbosef(!opts.Quiet, "Downloading k0sctl %s from %s", version, downloadURL) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sctlBinaryName, downloadURL, quiet) + path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sctlBinaryName, downloadURL, opts.Quiet) if err != nil { return "", err } - io.Verbosef(!quiet, "k0sctl downloaded successfully to %s", path) + io.Verbosef(!opts.Quiet, "k0sctl downloaded successfully to %s", path) return path, nil } diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 52b6d957a..6975137cf 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -49,6 +49,18 @@ type K0sctlFile struct { Perm string `yaml:"perm,omitempty"` } +// k0sctlFilePerm is the permission k0sctl creates uploaded files with. +const k0sctlFilePerm = "0644" + +// airgapBundleFile uploads an airgap image bundle to the k0s images dir of a node. +func airgapBundleFile(bundlePath string) K0sctlFile { + return K0sctlFile{ + Src: bundlePath, + DstDir: AirgapImagesDir, + Perm: k0sctlFilePerm, + } +} + // K0sctlOptions configures the k0sctl cluster configuration that oms generates from // an install-config. type K0sctlOptions struct { @@ -122,11 +134,7 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal } if runsWorker && options.Airgap.Enabled { - host.Files = append(host.Files, K0sctlFile{ - Src: options.Airgap.BundlePath, - DstDir: AirgapImagesDir, - Perm: "0644", - }) + host.Files = []K0sctlFile{airgapBundleFile(options.Airgap.BundlePath)} } k.Hosts = append(k.Hosts, host) diff --git a/internal/installer/k0sctl_test.go b/internal/installer/k0sctl_test.go index 8eb02d649..5e70d94a7 100644 --- a/internal/installer/k0sctl_test.go +++ b/internal/installer/k0sctl_test.go @@ -58,7 +58,7 @@ var _ = Describe("K0sctl", func() { writeCachedVersion("v0.32.1") mockFileWriter.EXPECT().Exists(cachedPath).Return(true) - path, err := k0sctl.Download("v0.32.1", false, false) + path, err := k0sctl.Download("v0.32.1", installer.DownloadOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(path).To(Equal(cachedPath)) @@ -69,7 +69,7 @@ var _ = Describe("K0sctl", func() { mockFileWriter.EXPECT().Exists(cachedPath).Return(true) expectDownload("v0.32.1") - path, err := k0sctl.Download("v0.32.1", false, false) + path, err := k0sctl.Download("v0.32.1", installer.DownloadOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(path).To(Equal(cachedPath)) @@ -81,7 +81,7 @@ var _ = Describe("K0sctl", func() { Return([]byte(`{"tag_name":"v0.32.1"}`), nil) mockFileWriter.EXPECT().Exists(cachedPath).Return(true) - path, err := k0sctl.Download("", false, false) + path, err := k0sctl.Download("", installer.DownloadOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(path).To(Equal(cachedPath)) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 5a7fad296..4cc97f91d 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -1483,8 +1483,8 @@ func (_c *MockK0sctlManager_Apply_Call) RunAndReturn(run func(configPath string, } // Download provides a mock function for the type MockK0sctlManager -func (_mock *MockK0sctlManager) Download(version string, force bool, quiet bool) (string, error) { - ret := _mock.Called(version, force, quiet) +func (_mock *MockK0sctlManager) Download(version string, opts DownloadOptions) (string, error) { + ret := _mock.Called(version, opts) if len(ret) == 0 { panic("no return value specified for Download") @@ -1492,16 +1492,16 @@ func (_mock *MockK0sctlManager) Download(version string, force bool, quiet bool) var r0 string var r1 error - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) (string, error)); ok { - return returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) (string, error)); ok { + return returnFunc(version, opts) } - if returnFunc, ok := ret.Get(0).(func(string, bool, bool) string); ok { - r0 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(0).(func(string, DownloadOptions) string); ok { + r0 = returnFunc(version, opts) } else { r0 = ret.Get(0).(string) } - if returnFunc, ok := ret.Get(1).(func(string, bool, bool) error); ok { - r1 = returnFunc(version, force, quiet) + if returnFunc, ok := ret.Get(1).(func(string, DownloadOptions) error); ok { + r1 = returnFunc(version, opts) } else { r1 = ret.Error(1) } @@ -1515,30 +1515,24 @@ type MockK0sctlManager_Download_Call struct { // Download is a helper method to define mock.On call // - version string -// - force bool -// - quiet bool -func (_e *MockK0sctlManager_Expecter) Download(version any, force any, quiet any) *MockK0sctlManager_Download_Call { - return &MockK0sctlManager_Download_Call{Call: _e.mock.On("Download", version, force, quiet)} +// - opts DownloadOptions +func (_e *MockK0sctlManager_Expecter) Download(version any, opts any) *MockK0sctlManager_Download_Call { + return &MockK0sctlManager_Download_Call{Call: _e.mock.On("Download", version, opts)} } -func (_c *MockK0sctlManager_Download_Call) Run(run func(version string, force bool, quiet bool)) *MockK0sctlManager_Download_Call { +func (_c *MockK0sctlManager_Download_Call) Run(run func(version string, opts DownloadOptions)) *MockK0sctlManager_Download_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } - var arg1 bool + var arg1 DownloadOptions if args[1] != nil { - arg1 = args[1].(bool) - } - var arg2 bool - if args[2] != nil { - arg2 = args[2].(bool) + arg1 = args[1].(DownloadOptions) } run( arg0, arg1, - arg2, ) }) return _c @@ -1549,7 +1543,7 @@ func (_c *MockK0sctlManager_Download_Call) Return(s string, err error) *MockK0sc return _c } -func (_c *MockK0sctlManager_Download_Call) RunAndReturn(run func(version string, force bool, quiet bool) (string, error)) *MockK0sctlManager_Download_Call { +func (_c *MockK0sctlManager_Download_Call) RunAndReturn(run func(version string, opts DownloadOptions) (string, error)) *MockK0sctlManager_Download_Call { _c.Call.Return(run) return _c } From 411169491b5ae4fa7ebb6f216f569d7172e03845 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:04:39 +0200 Subject: [PATCH 12/26] fix: comment --- internal/installer/k0s.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 4720646fe..f2fc30b49 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -16,6 +16,7 @@ import ( ) const ( + // DefaultK0sVersion is the k0s version installed unless another one is requested. DefaultK0sVersion = "v1.31.14+k0s.0" k0sReleaseURL = "https://github.com/k0sproject/k0s/releases/download" k0sReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0s/releases/tags" From 2f843e832cfc9984acd43d2c2f30de39117d1f8b Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:24:16 +0200 Subject: [PATCH 13/26] ref: cleanup --- cli/cmd/k0s/download_k0s_test.go | 14 ++------ cli/cmd/k0s/install_k0s.go | 28 ++++++++-------- cli/cmd/k0s/install_k0s_test.go | 50 ++++++++++++----------------- internal/installer/airgap.go | 12 +++---- internal/installer/download.go | 24 +++++++++++--- internal/installer/k0s.go | 6 ++-- internal/installer/k0sctl.go | 16 +++------ internal/installer/k0sctl_config.go | 1 - 8 files changed, 66 insertions(+), 85 deletions(-) diff --git a/cli/cmd/k0s/download_k0s_test.go b/cli/cmd/k0s/download_k0s_test.go index 5ab20e3e9..feec588c1 100644 --- a/cli/cmd/k0s/download_k0s_test.go +++ b/cli/cmd/k0s/download_k0s_test.go @@ -24,11 +24,13 @@ var _ = Describe("DownloadK0sk0s", func() { globalOpts *util.GlobalOptions mockEnv *env.MockEnv mockFileWriter *intutil.MockFileIO + mockK0sManager *installer.MockK0sManager ) BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) mockFileWriter = intutil.NewMockFileIO(GinkgoT()) + mockK0sManager = installer.NewMockK0sManager(GinkgoT()) globalOpts = &util.GlobalOptions{} opts = &k0s.DownloadK0sOpts{ GlobalOptions: globalOpts, @@ -49,8 +51,6 @@ var _ = Describe("DownloadK0sk0s", func() { Context("DownloadK0s method", func() { It("fails when k0s manager fails to get latest version", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "" // Test auto-version detection mockK0sManager.EXPECT().GetLatestVersion().Return("", errors.New("network error")) @@ -62,8 +62,6 @@ var _ = Describe("DownloadK0sk0s", func() { }) It("fails when k0s manager fails to download", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "v1.29.1+k0s.0" mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{Quiet: true}).Return("", errors.New("download failed")) @@ -75,8 +73,6 @@ var _ = Describe("DownloadK0sk0s", func() { }) It("succeeds when version is specified and download works", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "v1.29.1+k0s.0" mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", installer.DownloadOptions{Quiet: true}).Return("/test/workdir/k0s", nil) @@ -86,8 +82,6 @@ var _ = Describe("DownloadK0sk0s", func() { }) It("requests the airgap bundle when --airgapped is set", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "v1.29.1+k0s.0" c.Opts.Airgap = true @@ -100,8 +94,6 @@ var _ = Describe("DownloadK0sk0s", func() { }) It("reports airgap bundle download failures", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "v1.29.1+k0s.0" c.Opts.Airgap = true @@ -116,8 +108,6 @@ var _ = Describe("DownloadK0sk0s", func() { }) It("succeeds when version is auto-detected and download works", func() { - mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - c.Opts.Version = "" // Test auto-version detection c.Opts.Force = true c.Opts.Verbose = true diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index d4873ccaa..2b2bd444c 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -63,7 +63,7 @@ func resolveK0sVersion(k0s installer.K0sManager, version string) (string, error) return latestVersion, nil } -// RunE is the starting point for the k0s install command that executes the bootstrap logic. +// RunE runs the k0s install command. func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { hw := portal.NewHttpWrapper() env := c.Env @@ -130,8 +130,7 @@ const ( vaultSecretNameKubeconfig = "kubeConfig" ) -// InstallK0s generates a k0sctl config based on the input parameters and installs k0s -// Returns an error if installation fails +// InstallK0s generates a k0sctl config from the command options and installs k0s. func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0sManager, k0sctl installer.K0sctlManager) error { if err := c.FileWriter.MkdirAll(c.Env.GetOmsWorkdir(), 0755); err != nil { return fmt.Errorf("failed to create oms workdir: %w", err) @@ -193,7 +192,6 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return nil } -// validateOptions checks that the provided flags can be combined. func (c *InstallK0sCmd) validateOptions() error { if c.Opts.AirgapBundlePath != "" && !c.Opts.Airgap { return fmt.Errorf("--airgap-bundle requires --airgapped") @@ -202,6 +200,10 @@ func (c *InstallK0sCmd) validateOptions() error { return nil } +func (c *InstallK0sCmd) downloadOptions() installer.DownloadOptions { + return installer.DownloadOptions{Force: c.Opts.Force} +} + // warnAboutNetworkAccess logs the steps of an airgapped installation that still // require internet access, so they can be prepared before going offline. func (c *InstallK0sCmd) warnAboutNetworkAccess() { @@ -244,7 +246,7 @@ func (c *InstallK0sCmd) getK0sBinaryPath(pm installer.PackageManager, k0s instal return pm.GetDependencyPath(defaultK0sPath), nil } - k0sBinaryPath, err := k0s.Download(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force}) + k0sBinaryPath, err := k0s.Download(k0sVersion, c.downloadOptions()) if err != nil { return "", fmt.Errorf("failed to download k0s: %w", err) } @@ -267,7 +269,7 @@ func (c *InstallK0sCmd) getAirgapBundlePath(k0s installer.K0sManager, k0sVersion return c.Opts.AirgapBundlePath, nil } - bundlePath, err := k0s.EnsureAirgapBundle(k0sVersion, installer.DownloadOptions{Force: c.Opts.Force}) + bundlePath, err := k0s.EnsureAirgapBundle(k0sVersion, c.downloadOptions()) if err != nil { return "", fmt.Errorf("failed to download k0s airgap bundle: %w", err) } @@ -278,7 +280,7 @@ func (c *InstallK0sCmd) getAirgapBundlePath(k0s installer.K0sManager, k0sVersion func (c *InstallK0sCmd) downloadK0sctl(k0sctl installer.K0sctlManager) (string, error) { log.Println("Preparing k0sctl...") - k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, installer.DownloadOptions{Force: c.Opts.Force}) + k0sctlPath, err := k0sctl.Download(c.Opts.K0sctlVersion, c.downloadOptions()) if err != nil { return "", fmt.Errorf("failed to download k0sctl: %w", err) } @@ -337,16 +339,16 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 return err } - vault, err := store.LoadOrCreate() + vaultData, err := store.LoadOrCreate() if err != nil { return fmt.Errorf("failed to load vault: %w", err) } - if vault.GetSecret(vaultSecretNameKubeconfig) != nil { + if vaultData.GetSecret(vaultSecretNameKubeconfig) != nil { log.Printf("Updating existing %s secret in vault", vaultSecretNameKubeconfig) } - vault.SetSecret(files.SecretEntry{ + vaultData.SetSecret(files.SecretEntry{ Name: vaultSecretNameKubeconfig, File: &files.SecretFile{ Name: vaultSecretNameKubeconfig, @@ -354,7 +356,7 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 }, }) - if err := store.Save(vault); err != nil { + if err := store.Save(vaultData); err != nil { return err } @@ -364,10 +366,10 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 } func (c *InstallK0sCmd) vaultStore() (vault.Vault, error) { - vault, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.VaultPrivKey}) + store, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.VaultPrivKey}) if err != nil { return nil, fmt.Errorf("failed to load vault: %w", err) } - return vault, nil + return store, nil } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 8cfd3d107..138ae0072 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -142,11 +142,15 @@ var _ = Describe("InstallK0sCmd", func() { return configPath } + setupWorkdirMocks := func() { + mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) + mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + } + It("fails when install-config file does not exist", func() { c.Opts.InstallConfig = "/nonexistent/install-config.yaml" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) @@ -156,8 +160,7 @@ var _ = Describe("InstallK0sCmd", func() { It("fails when install-config specifies external Kubernetes", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(false)) - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) @@ -170,8 +173,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Version = "v1.30.0+k0s.0" c.Opts.Force = true - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", true, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") mockK0sctl.EXPECT().Download("", installer.DownloadOptions{Force: true}).Return("/tmp/k0sctl", nil) @@ -187,8 +189,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Package = "" c.Opts.Version = "v1.29.0+k0s.0" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().Download("v1.29.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) @@ -202,8 +203,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("", os.ErrNotExist) @@ -223,8 +223,7 @@ var _ = Describe("InstallK0sCmd", func() { err := os.WriteFile(c.Opts.AirgapBundlePath, []byte("bundle"), 0644) Expect(err).NotTo(HaveOccurred()) - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists(c.Opts.AirgapBundlePath).Return(true) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) @@ -249,8 +248,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Version = "v1.30.0+k0s.0" c.Opts.Airgap = true - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/k0s-airgap-bundle-amd64", nil) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) @@ -267,8 +265,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Airgap = true c.Opts.AirgapBundlePath = "/nonexistent/bundle.tar" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/nonexistent/bundle.tar").Return(false) @@ -281,8 +278,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.AirgapBundlePath = "/cache/k0s-airgap-bundle-amd64" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).To(HaveOccurred()) @@ -297,8 +293,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Airgap = true c.Opts.NoDownload = true - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) // The bundle must be uploaded although no k0s binary is uploaded. @@ -322,8 +317,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Airgap = true c.Opts.Package = "test-package.tar.gz" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) @@ -340,8 +334,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Airgap = true c.Opts.AirgapBundlePath = "/cache/bundle" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().GetLatestVersion().Return("v1.30.0+k0s.0", nil) mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockFileWriter.EXPECT().Exists("/cache/bundle").Return(true) @@ -359,8 +352,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Airgap = true c.Opts.Force = true - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{Force: true}).Return("/cache/bundle", nil) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{Force: true}).Return("/tmp/k0sctl", nil) @@ -376,8 +368,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Package = "test-package.tar.gz" c.Opts.Version = "v1.30.0+k0s.0" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("", os.ErrPermission) @@ -392,8 +383,7 @@ var _ = Describe("InstallK0sCmd", func() { c.Opts.Package = "test-package.tar.gz" c.Opts.Version = "v1.30.0+k0s.0" - mockEnv.EXPECT().GetOmsWorkdir().Return(tempDir) - mockFileWriter.EXPECT().MkdirAll(tempDir, os.FileMode(0755)).Return(nil) + setupWorkdirMocks() mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go index f92253634..25a53090b 100644 --- a/internal/installer/airgap.go +++ b/internal/installer/airgap.go @@ -4,7 +4,6 @@ package installer import ( - "encoding/json" "fmt" "path/filepath" "strings" @@ -99,14 +98,11 @@ func (k *K0s) cachedAirgapBundle(cacheDir, version string) (string, bool) { // "k0s-airgap-bundle---.tar", so the name is resolved from the // release metadata instead of being constructed. func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { - responseBody, err := k.Http.Get(fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version)) - if err != nil { - return "", fmt.Errorf("failed to fetch k0s release %s: %w", version, err) - } + subject := "k0s release " + version - var release githubRelease - if err := json.Unmarshal(responseBody, &release); err != nil { - return "", fmt.Errorf("failed to parse k0s release %s: %w", version, err) + release, err := getGitHubRelease(k.Http, fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version), subject) + if err != nil { + return "", err } for _, asset := range release.Assets { diff --git a/internal/installer/download.go b/internal/installer/download.go index bbb38051e..7ee779912 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -4,6 +4,7 @@ package installer import ( + "encoding/json" "fmt" "strings" @@ -33,7 +34,22 @@ type githubRelease struct { Assets []githubReleaseAsset `json:"assets"` } -// ensureCacheDir returns the OMS cache dir and creates it when it does not exist yet. +// getGitHubRelease fetches the GitHub release at url and decodes it. subject names +// the release in error messages. +func getGitHubRelease(h portal.Http, url, subject string) (*githubRelease, error) { + responseBody, err := h.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", subject, err) + } + + var release githubRelease + if err := json.Unmarshal(responseBody, &release); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", subject, err) + } + + return &release, nil +} + func ensureCacheDir(fw util.FileIO, environment env.Env) (string, error) { cacheDir, err := environment.GetOmsCacheDir() if err != nil { @@ -47,9 +63,8 @@ func ensureCacheDir(fw util.FileIO, environment env.Env) (string, error) { return cacheDir, nil } -// reuseCachedBinary returns the cached binary at cachePath when it exists and reports -// requestedVersion, unless opts ask for a fresh download. The returned bool reports -// whether the cached binary can be reused. +// reuseCachedBinary returns the cached binary at cachePath and true when it exists, +// matches requestedVersion and opts do not force a fresh download. func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, opts DownloadOptions) (string, bool) { if !fw.Exists(cachePath) || opts.Force { return "", false @@ -73,7 +88,6 @@ func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, return "", false } -// releaseAssetURL returns the download URL of an asset of a GitHub release. func releaseAssetURL(releaseURL, version, assetName string) string { return fmt.Sprintf("%s/%s/%s", releaseURL, version, assetName) } diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index f2fc30b49..abfb8e9d4 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -83,10 +83,8 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { return path, nil } -// downloadBinary fetches the k0s binary for the given version from the k0s GitHub -// releases and stores it as "k0s" in cacheDir, returning the path to it. -// If a binary is already cached and force is false, the cached binary is reused as -// long as its version matches; otherwise it is replaced by a fresh download. +// downloadBinary stores the k0s binary of version as k0sBinaryName in cacheDir and +// returns its path, reusing a cached copy unless it is missing or opts force a download. func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (string, error) { cachePath := filepath.Join(cacheDir, k0sBinaryName) if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sBinaryName, opts); cached { diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index ccb172c4b..616ac1891 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -4,7 +4,6 @@ package installer import ( - "encoding/json" "fmt" "log" "path/filepath" @@ -23,9 +22,8 @@ const ( k0sctlReleaseAPIURL = "https://api.github.com/repos/k0sproject/k0sctl/releases/latest" ) -// DefaultK0sctlVersion is the currently verified k0sctl version. It mirrors -// DefaultK0sVersion in k0s.go: the pair is the version combination we test -// against, while users can override k0sctl via --k0sctl-version. +// DefaultK0sctlVersion is the currently verified k0sctl version, mirroring +// DefaultK0sVersion in k0s.go. Users can override it via --k0sctl-version. // // renovate: datasource=github-releases depName=k0sproject/k0sctl const DefaultK0sctlVersion = "v0.33.1" @@ -58,14 +56,9 @@ func NewK0sctl(hw portal.Http, env env.Env, fw util.FileIO) *K0sctl { } func (k *K0sctl) GetLatestVersion() (string, error) { - responseBody, err := k.Http.Get(k0sctlReleaseAPIURL) + release, err := getGitHubRelease(k.Http, k0sctlReleaseAPIURL, "latest k0sctl release") if err != nil { - return "", fmt.Errorf("failed to fetch latest k0sctl release: %w", err) - } - - var release githubRelease - if err := json.Unmarshal(responseBody, &release); err != nil { - return "", fmt.Errorf("failed to parse GitHub API response: %w", err) + return "", err } if release.TagName == "" { @@ -117,7 +110,6 @@ func (k *K0sctl) Download(version string, opts DownloadOptions) (string, error) return path, nil } -// requireBinaryAndConfig checks that both the k0sctl binary and config exist. func (k *K0sctl) requireBinaryAndConfig(configPath, k0sctlPath string) error { if !k.FileWriter.Exists(k0sctlPath) { return fmt.Errorf("k0sctl binary does not exist at '%s', please download first", k0sctlPath) diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 6975137cf..c7a0be282 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -52,7 +52,6 @@ type K0sctlFile struct { // k0sctlFilePerm is the permission k0sctl creates uploaded files with. const k0sctlFilePerm = "0644" -// airgapBundleFile uploads an airgap image bundle to the k0s images dir of a node. func airgapBundleFile(bundlePath string) K0sctlFile { return K0sctlFile{ Src: bundlePath, From b841fd5663f5348dcf981b24501f3e5e47c92f9f Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:16:02 +0200 Subject: [PATCH 14/26] feat: add airgap support for k0s installation and configuration generation --- cli/cmd/bootstrap_gcp.go | 1 + cli/cmd/k0s/install_k0s.go | 110 +++++++++++++----- cli/cmd/k0s/install_k0s_test.go | 74 ++++++++++-- internal/bootstrap/datacenter/datacenter.go | 6 + .../bootstrap/datacenter/datacenter_test.go | 11 ++ internal/bootstrap/gcp/gcp.go | 1 + internal/bootstrap/gcp/gcp_test.go | 42 +++++++ internal/bootstrap/gcp/k0s.go | 62 +++++++++- 8 files changed, 264 insertions(+), 43 deletions(-) diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index d59d940b8..4e15e01f6 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -97,6 +97,7 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSZoneName, "dns-zone-name", "oms-testing", "Cloud DNS Zone Name (optional)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.Preemptible, "preemptible", false, "Use preemptible VMs for Codesphere infrastructure. Mutually exclusive with --spot-vms (default: false)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.SpotVMs, "spot-vms", false, "Use Spot VMs for Codesphere infrastructure. Falls back to standard VMs if spot capacity unavailable. Mutually exclusive with --preemptible (default: false)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.Airgapped, "airgapped", false, "Install k0s from the k0s airgap image bundle, so the cluster nodes pull no k0s images from the internet (default: false)") flags.Int64Var(&bootstrapGcpCmd.CodesphereEnv.RootDiskSize, "root-disk-size", 50, "Instance root disk size in GB (default: 50)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 2b2bd444c..2fd383680 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -41,6 +41,8 @@ type InstallK0sOpts struct { NoDownload bool Airgap bool AirgapBundlePath string + ConfigOnly bool + K0sctlConfig string Vault string VaultPrivKey string VaultType string @@ -97,6 +99,8 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { {Cmd: "--no-download", Desc: "Skip downloading k0s binary (expects it to be on remote nodes)"}, {Cmd: "--airgapped", Desc: "Install k0s without internet access using an airgap image bundle"}, {Cmd: "--airgapped --airgap-bundle ", Desc: "Install k0s airgapped from a local airgap image bundle"}, + {Cmd: "--install-config --config-only --k0sctl-config ", Desc: "Only generate the k0sctl config without installing k0s"}, + {Cmd: "--k0sctl-config ", Desc: "Install k0s from a previously generated k0sctl config"}, }), }, Opts: InstallK0sOpts{GlobalOptions: opts}, @@ -107,19 +111,19 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", installer.DefaultK0sVersion, "Version of k0s to install") k0s.cmd.Flags().StringVar(&k0s.Opts.K0sctlVersion, "k0sctl-version", installer.DefaultK0sctlVersion, "Version of k0sctl to use") k0s.cmd.Flags().StringVarP(&k0s.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from") - k0s.cmd.Flags().StringVar(&k0s.Opts.InstallConfig, "install-config", "", "Path to Codesphere install-config file (required)") + k0s.cmd.Flags().StringVar(&k0s.Opts.InstallConfig, "install-config", "", "Path to Codesphere install-config file (required unless --k0sctl-config is installed)") k0s.cmd.Flags().StringVar(&k0s.Opts.SSHKeyPath, "ssh-key-path", "", "SSH private key path for remote installation") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") k0s.cmd.Flags().BoolVar(&k0s.Opts.NoDownload, "no-download", false, "Skip downloading k0s binary") k0s.cmd.Flags().BoolVar(&k0s.Opts.Airgap, "airgapped", false, "Install k0s without internet access by uploading the airgap image bundle to the workers") k0s.cmd.Flags().StringVar(&k0s.Opts.AirgapBundlePath, "airgap-bundle", "", "Path to the k0s airgap image bundle to install from (requires --airgapped)") + k0s.cmd.Flags().BoolVar(&k0s.Opts.ConfigOnly, "config-only", false, "Only generate the k0sctl config, without installing k0s") + k0s.cmd.Flags().StringVar(&k0s.Opts.K0sctlConfig, "k0sctl-config", "", "With --config-only, where to write the generated k0sctl config; otherwise an existing k0sctl config to install k0s from instead of generating one") k0s.cmd.Flags().StringVar(&k0s.Opts.Vault, "vault", "", "Path to prod.vault.yaml to save the kubeconfig into (optional)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultPrivKey, "vault-priv-key", "", "Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") - _ = k0s.cmd.MarkFlagRequired("install-config") - util.AddCmd(install, k0s.cmd) k0s.cmd.RunE = k0s.RunE @@ -131,6 +135,8 @@ const ( ) // InstallK0s generates a k0sctl config from the command options and installs k0s. +// With ConfigOnly it stops after generating the config, and with an existing +// K0sctlConfig it installs that config instead of generating one. func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0sManager, k0sctl installer.K0sctlManager) error { if err := c.FileWriter.MkdirAll(c.Env.GetOmsWorkdir(), 0755); err != nil { return fmt.Errorf("failed to create oms workdir: %w", err) @@ -142,24 +148,15 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 c.warnAboutNetworkAccess() - config, err := c.loadInstallConfig() + k0sctlConfigPath, err := c.prepareK0sctlConfig(pm, k0s) if err != nil { return err } - k0sVersion, err := resolveK0sVersion(k0s, c.Opts.Version) - if err != nil { - return err - } + if c.Opts.ConfigOnly { + log.Printf("Skipping k0s installation, install the generated config with 'oms install k0s --k0sctl-config %s'", k0sctlConfigPath) - k0sBinaryPath, err := c.getK0sBinaryPath(pm, k0s, k0sVersion) - if err != nil { - return err - } - - airgapBundlePath, err := c.getAirgapBundlePath(k0s, k0sVersion) - if err != nil { - return err + return nil } k0sctlPath, err := c.downloadK0sctl(k0sctl) @@ -167,18 +164,6 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return err } - k0sctlOptions := installer.K0sctlOptions{ - K0sVersion: k0sVersion, - SSHKeyPath: c.Opts.SSHKeyPath, - K0sBinaryPath: k0sBinaryPath, - Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, - } - - k0sctlConfigPath, err := c.generateK0sctlConfig(config, k0sctlOptions) - if err != nil { - return err - } - if err := c.deployK0sCluster(k0sctl, k0sctlPath, k0sctlConfigPath); err != nil { return fmt.Errorf("failed to deploy k0s cluster: %w", err) } @@ -192,14 +177,75 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 return nil } +func (c *InstallK0sCmd) installsExistingConfig() bool { + return c.Opts.K0sctlConfig != "" && !c.Opts.ConfigOnly +} + func (c *InstallK0sCmd) validateOptions() error { if c.Opts.AirgapBundlePath != "" && !c.Opts.Airgap { return fmt.Errorf("--airgap-bundle requires --airgapped") } + if c.Opts.NoDownload && c.Opts.Airgap { + return fmt.Errorf("--no-download cannot be combined with --airgapped, as the nodes would download k0s from the internet") + } + + if !c.installsExistingConfig() { + if c.Opts.InstallConfig == "" { + return fmt.Errorf("--install-config is required unless an existing --k0sctl-config is installed") + } + + return nil + } + + if c.Opts.InstallConfig != "" || c.Opts.Package != "" || c.Opts.NoDownload || c.Opts.Airgap { + return fmt.Errorf("--k0sctl-config without --config-only installs an existing k0sctl config and cannot be combined with --install-config, --package, --no-download or --airgapped") + } + + if !c.FileWriter.Exists(c.Opts.K0sctlConfig) { + return fmt.Errorf("k0sctl config '%s' does not exist", c.Opts.K0sctlConfig) + } + return nil } +func (c *InstallK0sCmd) prepareK0sctlConfig(pm installer.PackageManager, k0s installer.K0sManager) (string, error) { + if c.installsExistingConfig() { + log.Printf("Using existing k0sctl configuration at %s", c.Opts.K0sctlConfig) + + return c.Opts.K0sctlConfig, nil + } + + config, err := c.loadInstallConfig() + if err != nil { + return "", err + } + + k0sVersion, err := resolveK0sVersion(k0s, c.Opts.Version) + if err != nil { + return "", err + } + + k0sBinaryPath, err := c.getK0sBinaryPath(pm, k0s, k0sVersion) + if err != nil { + return "", err + } + + airgapBundlePath, err := c.getAirgapBundlePath(k0s, k0sVersion) + if err != nil { + return "", err + } + + k0sctlOptions := installer.K0sctlOptions{ + K0sVersion: k0sVersion, + SSHKeyPath: c.Opts.SSHKeyPath, + K0sBinaryPath: k0sBinaryPath, + Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, + } + + return c.generateK0sctlConfig(config, k0sctlOptions) +} + func (c *InstallK0sCmd) downloadOptions() installer.DownloadOptions { return installer.DownloadOptions{Force: c.Opts.Force} } @@ -215,7 +261,7 @@ func (c *InstallK0sCmd) warnAboutNetworkAccess() { log.Println("Warning: --airgapped without --airgap-bundle uses the cached airgap bundle or downloads it from the internet; pre-download it with 'oms download k0s --airgapped' or pass --airgap-bundle for a truly offline installation") } - if c.Opts.K0sctlVersion == "" || c.Opts.K0sctlVersion == installer.DefaultK0sctlVersion { + if !c.Opts.ConfigOnly && (c.Opts.K0sctlVersion == "" || c.Opts.K0sctlVersion == installer.DefaultK0sctlVersion) { log.Println("Warning: k0sctl is downloaded from the internet unless it is already cached; pass --k0sctl-version with a pre-cached version for a truly offline installation") } } @@ -301,7 +347,11 @@ func (c *InstallK0sCmd) generateK0sctlConfig(config *files.RootConfig, options i return "", fmt.Errorf("failed to marshal k0sctl config: %w", err) } - k0sctlConfigPath := filepath.Join(c.Env.GetOmsWorkdir(), fmt.Sprintf("k0sctl-config-%s.yaml", config.Datacenter.Name)) + k0sctlConfigPath := c.Opts.K0sctlConfig + if k0sctlConfigPath == "" { + k0sctlConfigPath = filepath.Join(c.Env.GetOmsWorkdir(), fmt.Sprintf("k0sctl-config-%s.yaml", config.Datacenter.Name)) + } + if err := c.FileWriter.WriteFile(k0sctlConfigPath, k0sctlConfigData, 0644); err != nil { return "", fmt.Errorf("failed to write k0sctl config: %w", err) } diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 138ae0072..8b55cdf6d 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -285,32 +285,81 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err.Error()).To(ContainSubstring("--airgap-bundle requires --airgapped")) }) - It("installs k0s airgapped with the k0s binary already on the nodes", func() { - config := createTestConfig(true) - config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "192.168.1.100"}} - c.Opts.InstallConfig = writeTestConfig(config) - c.Opts.Version = "v1.30.0+k0s.0" + It("fails when --no-download is combined with --airgapped", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Airgap = true c.Opts.NoDownload = true setupWorkdirMocks() + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(MatchError(ContainSubstring("--no-download cannot be combined with --airgapped"))) + }) + + It("fails without install-config unless an existing k0sctl config is installed", func() { + c.Opts.ConfigOnly = true + c.Opts.K0sctlConfig = filepath.Join(tempDir, "k0sctl.yaml") + + setupWorkdirMocks() + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(MatchError(ContainSubstring("--install-config is required"))) + }) + + It("only generates the airgap k0sctl config with --config-only", func() { + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + c.Opts.Version = "v1.30.0+k0s.0" + c.Opts.Airgap = true + c.Opts.ConfigOnly = true + c.Opts.K0sctlConfig = "/etc/codesphere/k0sctl-config.yaml" + + setupWorkdirMocks() + mockK0s.EXPECT().Download("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/downloaded/k0s", nil) mockK0s.EXPECT().EnsureAirgapBundle("v1.30.0+k0s.0", installer.DownloadOptions{}).Return("/cache/bundle", nil) - mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) - // The bundle must be uploaded although no k0s binary is uploaded. mockFileWriter.EXPECT().WriteFile( - mock.Anything, + "/etc/codesphere/k0sctl-config.yaml", mock.MatchedBy(func(data []byte) bool { - return strings.Contains(string(data), "src: /cache/bundle") && - !strings.Contains(string(data), "k0sBinaryPath") + return strings.Contains(string(data), "default_pull_policy: Never") }), - mock.Anything, + os.FileMode(0644), ).Return(nil) - mockK0sctl.EXPECT().Apply(mock.Anything, "/tmp/k0sctl", false).Return(nil) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) }) + It("installs an existing k0sctl config without generating one", func() { + c.Opts.K0sctlConfig = "/etc/codesphere/k0sctl-config.yaml" + + setupWorkdirMocks() + mockFileWriter.EXPECT().Exists("/etc/codesphere/k0sctl-config.yaml").Return(true) + mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("/tmp/k0sctl", nil) + mockK0sctl.EXPECT().Apply("/etc/codesphere/k0sctl-config.yaml", "/tmp/k0sctl", false).Return(nil) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails when the existing k0sctl config does not exist", func() { + c.Opts.K0sctlConfig = "/nonexistent/k0sctl-config.yaml" + + setupWorkdirMocks() + mockFileWriter.EXPECT().Exists("/nonexistent/k0sctl-config.yaml").Return(false) + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(MatchError(ContainSubstring("k0sctl config '/nonexistent/k0sctl-config.yaml' does not exist"))) + }) + + It("fails when an existing k0sctl config is combined with generation flags", func() { + c.Opts.K0sctlConfig = "/etc/codesphere/k0sctl-config.yaml" + c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) + + setupWorkdirMocks() + + err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) + Expect(err).To(MatchError(ContainSubstring("cannot be combined with --install-config"))) + }) + It("takes the k0s binary from the package but still ensures the airgap bundle", func() { c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Version = "v1.30.0+k0s.0" @@ -371,6 +420,7 @@ var _ = Describe("InstallK0sCmd", func() { setupWorkdirMocks() mockPM.EXPECT().ExtractDependency("kubernetes/files/k0s", false, false).Return(nil) mockPM.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/path/k0s") + mockFileWriter.EXPECT().WriteFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) mockK0sctl.EXPECT().Download("", installer.DownloadOptions{}).Return("", os.ErrPermission) err := c.InstallK0s(mockPM, mockK0s, mockK0sctl) diff --git a/internal/bootstrap/datacenter/datacenter.go b/internal/bootstrap/datacenter/datacenter.go index 1cb152e77..ab876e550 100644 --- a/internal/bootstrap/datacenter/datacenter.go +++ b/internal/bootstrap/datacenter/datacenter.go @@ -94,6 +94,12 @@ func (dc *DataCenter) K0sConfigScriptPath() string { return fmt.Sprintf("configure-k0s%s.sh", dc.Suffix) } +// RemoteK0sctlConfigPath returns the path on the jumpbox where an airgapped bootstrap generates +// this data center's k0sctl config, next to its install config. +func (dc *DataCenter) RemoteK0sctlConfigPath() string { + return SuffixedPath(filepath.Join(filepath.Dir(dc.RemoteConfigPath), "k0sctl-config.yaml"), dc.Suffix) +} + // StepName qualifies a bootstrap step name with the data center it applies to. Single-DC // bootstraps keep their unqualified step names. func (dc *DataCenter) StepName(name string) string { diff --git a/internal/bootstrap/datacenter/datacenter_test.go b/internal/bootstrap/datacenter/datacenter_test.go index edaeaec17..56a761117 100644 --- a/internal/bootstrap/datacenter/datacenter_test.go +++ b/internal/bootstrap/datacenter/datacenter_test.go @@ -26,3 +26,14 @@ var _ = Describe("SuffixedPath", func() { Entry("absolute path", "/etc/codesphere/config.yaml", "-dc2", "/etc/codesphere/config-dc2.yaml"), ) }) + +var _ = Describe("RemoteK0sctlConfigPath", func() { + DescribeTable("places the k0sctl config next to the install config", + func(remoteConfigPath, suffix, expected string) { + dc := &datacenter.DataCenter{RemoteConfigPath: remoteConfigPath, Suffix: suffix} + Expect(dc.RemoteK0sctlConfigPath()).To(Equal(expected)) + }, + Entry("primary", "/etc/codesphere/config.yaml", "", "/etc/codesphere/k0sctl-config.yaml"), + Entry("secondary", "/etc/codesphere/config-dc2.yaml", "-dc2", "/etc/codesphere/k0sctl-config-dc2.yaml"), + ) +}) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 5e8b12e40..b8bc8740b 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -153,6 +153,7 @@ type CodesphereEnvironment struct { InstallSkipSteps []string `json:"install_skip_steps"` Preemptible bool `json:"preemptible"` SpotVMs bool `json:"spot_vms"` + Airgapped bool `json:"airgapped"` WriteConfig bool `json:"-"` RecoverConfig bool `json:"-"` GatewayIP string `json:"gateway_ip"` diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index b37564107..22104be4d 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -1709,6 +1709,48 @@ var _ = Describe("GCP Bootstrapper", func() { err := bs.InstallK0s() Expect(err).To(MatchError(ContainSubstring("failed to install k0s from jumpbox"))) }) + + It("installs the generated airgap k0sctl config when airgapped", func() { + csEnv.Airgapped = true + + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms install k0s --k0sctl-config /etc/codesphere/k0sctl-config.yaml --vault /etc/codesphere/secrets/prod.vault.yaml --vault-priv-key /etc/codesphere/secrets/age_key.txt").Return(nil) + + err := bs.InstallK0s() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("DownloadK0sAirgapBundle", func() { + It("caches the k0s binary and airgap bundle on the jumpbox", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms download k0s --version v1.31.14+k0s.0 --airgapped").Return(nil) + + Expect(bs.DownloadK0sAirgapBundle()).To(Succeed()) + }) + + It("reports a download failure", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", mock.Anything).Return(fmt.Errorf("offline")) + + err := bs.DownloadK0sAirgapBundle() + Expect(err).To(MatchError(ContainSubstring("failed to download k0s airgap bundle on jumpbox"))) + }) + }) + + Describe("GenerateK0sAirgapConfig", func() { + It("generates the airgap k0sctl config on the jumpbox without installing it", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", + "oms install k0s --version v1.31.14+k0s.0 --install-config /etc/codesphere/config.yaml --airgapped --config-only --k0sctl-config /etc/codesphere/k0sctl-config.yaml").Return(nil) + + Expect(bs.GenerateK0sAirgapConfig()).To(Succeed()) + }) + + It("reports a generation failure", func() { + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpboxMatcher), "root", mock.Anything).Return(fmt.Errorf("no bundle")) + + err := bs.GenerateK0sAirgapConfig() + Expect(err).To(MatchError(ContainSubstring("failed to generate k0s airgap config on jumpbox"))) + }) }) Describe("WaitForK0sNodes", func() { diff --git a/internal/bootstrap/gcp/k0s.go b/internal/bootstrap/gcp/k0s.go index 2f505ef6c..837d763de 100644 --- a/internal/bootstrap/gcp/k0s.go +++ b/internal/bootstrap/gcp/k0s.go @@ -13,6 +13,8 @@ import ( // EnsureK0s executed all steps to ensure a k0s cluster in gcp for every data center. // Only executing the config script needs to be done after installing codesphere, as crucial parts are still in the ts-installer. +// Airgapped bootstraps download the airgap bundle and generate an airgap k0sctl config first, +// which InstallK0s then installs. // Returns an error if k0s could not be ensured. func (b *GCPBootstrapper) EnsureK0s() error { err := b.GenerateK0sConfigScript() @@ -20,6 +22,18 @@ func (b *GCPBootstrapper) EnsureK0s() error { return fmt.Errorf("failed to generate k0s config script: %w", err) } + if b.Env.Airgapped { + err = b.stlog.Step("Download k0s airgap bundle", b.DownloadK0sAirgapBundle) + if err != nil { + return fmt.Errorf("failed to download k0s airgap bundle: %w", err) + } + + err = b.GenerateK0sAirgapConfig() + if err != nil { + return fmt.Errorf("failed to generate k0s airgap config: %w", err) + } + } + err = b.InstallK0s() if err != nil { return fmt.Errorf("failed to install k0s: %w", err) @@ -191,9 +205,50 @@ func (b *GCPBootstrapper) runK0sConfigScript(dc *datacenter.DataCenter) error { return nil } +// DownloadK0sAirgapBundle caches the k0s binary and its airgap image bundle on the jumpbox. All +// data centers install the same k0s version, so they share the cached bundle. +func (b *GCPBootstrapper) DownloadK0sAirgapBundle() error { + downloadCmd := fmt.Sprintf("oms download k0s --version %s --airgapped", installer.DefaultK0sVersion) + if err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd); err != nil { + return fmt.Errorf("failed to download k0s airgap bundle on jumpbox: %w", err) + } + + return nil +} + +// GenerateK0sAirgapConfig generates every data center's airgap k0sctl config on the jumpbox from +// the cached airgap bundle, without installing it yet. +func (b *GCPBootstrapper) GenerateK0sAirgapConfig() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + err := b.stlog.Step(dc.StepName("Generate k0s airgap config"), func() error { + return b.generateK0sAirgapConfig(dc) + }) + if err != nil { + return fmt.Errorf("failed to generate k0s airgap config (data center %d): %w", dc.ID, err) + } + } + + return nil +} + +func (b *GCPBootstrapper) generateK0sAirgapConfig(dc *datacenter.DataCenter) error { + generateCmd := fmt.Sprintf("oms install k0s --version %s --install-config %s --airgapped --config-only --k0sctl-config %s", + installer.DefaultK0sVersion, dc.RemoteConfigPath, dc.RemoteK0sctlConfigPath()) + if err := b.Env.Jumpbox.RunSSHCommand("root", generateCmd); err != nil { + return fmt.Errorf("failed to generate k0s airgap config on jumpbox (data center %d): %w", dc.ID, err) + } + + return nil +} + // InstallK0s deploys k0s into every data center with the native OMS installer. Each data center // gets its own cluster, so every run stores its kubeconfig in that data center's encrypted -// install vault for the remaining installer steps. +// install vault for the remaining installer steps. Airgapped bootstraps install the k0sctl config +// that GenerateK0sAirgapConfig generated. func (b *GCPBootstrapper) InstallK0s() error { if err := b.ensureDataCenters(); err != nil { return err @@ -216,6 +271,11 @@ func (b *GCPBootstrapper) installK0s(dc *datacenter.DataCenter) error { // --force, an unchanged cluster remains untouched on bootstrap retries. installCmd := fmt.Sprintf("oms install k0s --version %s --install-config %s --vault %s --vault-priv-key %s", installer.DefaultK0sVersion, dc.RemoteConfigPath, dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) + if b.Env.Airgapped { + installCmd = fmt.Sprintf("oms install k0s --k0sctl-config %s --vault %s --vault-priv-key %s", + dc.RemoteK0sctlConfigPath(), dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) + } + if err := b.Env.Jumpbox.RunSSHCommand("root", installCmd); err != nil { return fmt.Errorf("failed to install k0s from jumpbox (data center %d): %w", dc.ID, err) } From 830dbf7c70e205f76d09ef6fe6caece0e6e0f2c0 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:59:55 +0200 Subject: [PATCH 15/26] feat: implement SSH agent forwarding and key management for Node --- .../installer/node/agent_forwarding_test.go | 127 +++++++++++++ internal/installer/node/node.go | 169 ++++++++++++++++-- internal/installer/node/node_suite_test.go | 16 ++ 3 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 internal/installer/node/agent_forwarding_test.go create mode 100644 internal/installer/node/node_suite_test.go diff --git a/internal/installer/node/agent_forwarding_test.go b/internal/installer/node/agent_forwarding_test.go new file mode 100644 index 000000000..1dab7c03a --- /dev/null +++ b/internal/installer/node/agent_forwarding_test.go @@ -0,0 +1,127 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package node + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "os" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// writeKeyPair writes a new ed25519 key pair to dir and returns the private key path and the key. +func writeKeyPair(dir string, name string) (string, ed25519.PrivateKey) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + Expect(err).NotTo(HaveOccurred()) + + block, err := ssh.MarshalPrivateKey(priv, "") + Expect(err).NotTo(HaveOccurred()) + signer, err := ssh.NewSignerFromKey(priv) + Expect(err).NotTo(HaveOccurred()) + + keyPath := filepath.Join(dir, name) + Expect(os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600)).To(Succeed()) + Expect(os.WriteFile(keyPath+".pub", ssh.MarshalAuthorizedKey(signer.PublicKey()), 0644)).To(Succeed()) + + return keyPath, priv +} + +func listedKeys(a agent.Agent) []string { + keys, err := a.List() + Expect(err).NotTo(HaveOccurred()) + + blobs := make([]string, 0, len(keys)) + for _, k := range keys { + blobs = append(blobs, string(k.Marshal())) + } + return blobs +} + +func publicBlob(priv ed25519.PrivateKey) string { + signer, err := ssh.NewSignerFromKey(priv) + Expect(err).NotTo(HaveOccurred()) + return string(signer.PublicKey().Marshal()) +} + +var _ = Describe("forwardedAgent", func() { + var ( + dir string + keyPath string + deploy ed25519.PrivateKey + n *Node + ) + + BeforeEach(func() { + dir = GinkgoT().TempDir() + keyPath, deploy = writeKeyPair(dir, "deploy") + n = &Node{FileIO: util.NewFilesystemWriter(), KeyPath: keyPath} + }) + + It("forwards nothing without a local agent or key path", func() { + n.KeyPath = "" + + forwarded, err := n.forwardedAgent(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(forwarded).To(BeNil()) + }) + + It("forwards the local agent as-is when it holds the deploy key", func() { + local := agent.NewKeyring().(agent.ExtendedAgent) + Expect(local.Add(agent.AddedKey{PrivateKey: deploy})).To(Succeed()) + + forwarded, err := n.forwardedAgent(local) + Expect(err).NotTo(HaveOccurred()) + Expect(forwarded).To(BeIdenticalTo(local)) + }) + + It("forwards only the deploy key without a local agent", func() { + forwarded, err := n.forwardedAgent(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(listedKeys(forwarded)).To(ConsistOf(publicBlob(deploy))) + }) + + It("adds the deploy key to a local agent that lacks it and signs with both keys", func() { + _, other := writeKeyPair(dir, "other") + local := agent.NewKeyring().(agent.ExtendedAgent) + Expect(local.Add(agent.AddedKey{PrivateKey: other})).To(Succeed()) + + forwarded, err := n.forwardedAgent(local) + Expect(err).NotTo(HaveOccurred()) + Expect(listedKeys(forwarded)).To(ConsistOf(publicBlob(deploy), publicBlob(other))) + + signers, err := forwarded.Signers() + Expect(err).NotTo(HaveOccurred()) + Expect(signers).To(HaveLen(2)) + + data := []byte("challenge") + for _, s := range signers { + sig, err := forwarded.Sign(s.PublicKey(), data) + Expect(err).NotTo(HaveOccurred()) + Expect(s.PublicKey().Verify(data, sig)).To(Succeed()) + } + }) + + It("falls back to the local agent when the deploy key cannot be loaded", func() { + n.KeyPath = filepath.Join(dir, "missing") + local := agent.NewKeyring().(agent.ExtendedAgent) + + forwarded, err := n.forwardedAgent(local) + Expect(err).NotTo(HaveOccurred()) + Expect(forwarded).To(BeIdenticalTo(local)) + }) + + It("fails without a local agent when the deploy key cannot be loaded", func() { + n.KeyPath = filepath.Join(dir, "missing") + + _, err := n.forwardedAgent(nil) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 8d30e7074..d8180876a 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -31,6 +31,7 @@ type Node struct { Name string `json:"name"` ExternalIP string `json:"external_ip"` InternalIP string `json:"internal_ip"` + cachedKey any `json:"-"` cachedSigner ssh.Signer `json:"-"` sshQuiet bool `json:"-"` @@ -623,8 +624,7 @@ func (n *Node) getAuthMethods() ([]ssh.AuthMethod, error) { // Else load from file with passphrase prompt if needed if shouldLoad { - if signer, err := n.loadPrivateKey(); err == nil { - n.cachedSigner = signer + if _, signer, err := n.loadKey(); err == nil { signers = append(signers, signer) } else { log.Printf("Warning: failed to load private key: %v\n", err) @@ -639,16 +639,39 @@ func (n *Node) getAuthMethods() ([]ssh.AuthMethod, error) { return []ssh.AuthMethod{ssh.PublicKeys(signers...)}, nil } -// loadPrivateKey reads and parses the private key, prompting for passphrase if needed. -func (n *Node) loadPrivateKey() (ssh.Signer, error) { +// loadKey returns the private key at KeyPath and its signer. The key is read only once, so an +// encrypted key prompts for its passphrase at most once. +func (n *Node) loadKey() (any, ssh.Signer, error) { + if n.cachedSigner != nil && n.cachedKey != nil { + return n.cachedKey, n.cachedSigner, nil + } + + key, err := n.loadPrivateKey() + if err != nil { + return nil, nil, err + } + + signer, err := ssh.NewSignerFromKey(key) + if err != nil { + return nil, nil, fmt.Errorf("failed to create signer from private key: %v", err) + } + + n.cachedKey = key + n.cachedSigner = signer + + return key, signer, nil +} + +// loadPrivateKey reads and parses the raw private key, prompting for passphrase if needed. +func (n *Node) loadPrivateKey() (any, error) { key, err := n.FileIO.ReadFile(n.KeyPath) if err != nil { return nil, fmt.Errorf("failed to read private key file %s: %v", n.KeyPath, err) } - signer, err := ssh.ParsePrivateKey(key) + rawKey, err := ssh.ParseRawPrivateKey(key) if err == nil { - return signer, nil + return rawKey, nil } if _, ok := err.(*ssh.PassphraseMissingError); !ok { @@ -663,7 +686,7 @@ func (n *Node) loadPrivateKey() (ssh.Signer, error) { return nil, fmt.Errorf("failed to read passphrase: %v", err) } - signer, err = ssh.ParsePrivateKeyWithPassphrase(key, passphrase) + rawKey, err = ssh.ParseRawPrivateKeyWithPassphrase(key, passphrase) // Clear passphrase from memory for i := range passphrase { passphrase[i] = 0 @@ -672,20 +695,138 @@ func (n *Node) loadPrivateKey() (ssh.Signer, error) { return nil, fmt.Errorf("failed to parse private key with passphrase: %v", err) } - return signer, nil + return rawKey, nil } -// setupAgentForwarding sets up SSH agent forwarding on the client (best effort) +// setupAgentForwarding sets up SSH agent forwarding on the client (best effort). Tools on the +// remote host, such as k0sctl on the jumpbox, authenticate to further hosts through the +// forwarded agent, so it always offers the key at KeyPath, even when the local agent lacks it. func (n *Node) setupAgentForwarding(client *ssh.Client) error { - authSocket := os.Getenv("SSH_AUTH_SOCK") - if authSocket == "" { + var localAgent agent.ExtendedAgent + if authSocket := os.Getenv("SSH_AUTH_SOCK"); authSocket != "" { + conn, err := net.Dial("unix", authSocket) + if err != nil { + log.Printf("Warning: failed to connect to SSH agent: %v", err) + } else { + localAgent = agent.NewClient(conn) + } + } + + forwarded, err := n.forwardedAgent(localAgent) + if err != nil { + return err + } + if forwarded == nil { return nil } - conn, err := net.Dial("unix", authSocket) + return agent.ForwardToAgent(client, forwarded) +} + +// forwardedAgent returns the agent to forward to remote hosts: the local agent when it already +// holds the key at KeyPath, otherwise an agent that adds that key to the local agent's keys. +func (n *Node) forwardedAgent(localAgent agent.ExtendedAgent) (agent.Agent, error) { + if n.KeyPath == "" || (localAgent != nil && agentHoldsKey(localAgent, n.KeyPath, n.FileIO)) { + if localAgent == nil { + return nil, nil + } + return localAgent, nil + } + + key, signer, err := n.loadKey() + if err != nil { + if localAgent == nil { + return nil, fmt.Errorf("no SSH agent to forward and failed to load private key: %w", err) + } + log.Printf("Warning: forwarding SSH agent without the key at %s: %v", n.KeyPath, err) + return localAgent, nil + } + + keyring := agent.NewKeyring() + if err := keyring.Add(agent.AddedKey{PrivateKey: key, Comment: n.KeyPath}); err != nil { + return nil, fmt.Errorf("failed to add private key to forwarded agent: %w", err) + } + + if localAgent == nil { + return keyring, nil + } + + return &keyAddingAgent{ + ExtendedAgent: localAgent, + keyring: keyring.(agent.ExtendedAgent), + keyBlob: signer.PublicKey().Marshal(), + }, nil +} + +// agentHoldsKey reports whether the agent holds the private key whose public key is stored next +// to keyPath. +func agentHoldsKey(a agent.Agent, keyPath string, fileIO util.FileIO) bool { + pubBytes, err := fileIO.ReadFile(keyPath + ".pub") + if err != nil { + return false + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + return false + } + + keys, err := a.List() + if err != nil { + return false + } + target := string(pub.Marshal()) + for _, k := range keys { + if string(k.Marshal()) == target { + return true + } + } + + return false +} + +// keyAddingAgent serves the local agent with one extra key from a keyring. Signing requests for +// that key go to the keyring, everything else to the local agent. +type keyAddingAgent struct { + agent.ExtendedAgent + keyring agent.ExtendedAgent + keyBlob []byte +} + +func (a *keyAddingAgent) List() ([]*agent.Key, error) { + extra, err := a.keyring.List() + if err != nil { + return nil, err + } + keys, err := a.ExtendedAgent.List() + if err != nil { + // The local agent may be locked or broken, the extra key still works. + return extra, nil + } + + return append(extra, keys...), nil +} + +func (a *keyAddingAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.SignWithFlags(key, data, 0) +} + +func (a *keyAddingAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + if bytes.Equal(key.Marshal(), a.keyBlob) { + return a.keyring.SignWithFlags(key, data, flags) + } + + return a.ExtendedAgent.SignWithFlags(key, data, flags) +} + +func (a *keyAddingAgent) Signers() ([]ssh.Signer, error) { + extra, err := a.keyring.Signers() + if err != nil { + return nil, err + } + signers, err := a.ExtendedAgent.Signers() if err != nil { - return fmt.Errorf("failed to connect to SSH agent: %v", err) + return extra, nil } - return agent.ForwardToAgent(client, agent.NewClient(conn)) + return append(extra, signers...), nil } diff --git a/internal/installer/node/node_suite_test.go b/internal/installer/node/node_suite_test.go new file mode 100644 index 000000000..35b32ade3 --- /dev/null +++ b/internal/installer/node/node_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package node + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNode(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Node Suite") +} From b41a88ce729bd2e4a1057aa8b4ed712ce914bcc5 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:29:44 +0000 Subject: [PATCH 16/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 8 ++++---- docs/oms_beta_bootstrap-gcp.md | 1 + docs/oms_install_k0s.md | 10 +++++++++- internal/tmpl/NOTICE | 8 ++++---- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/NOTICE b/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/NOTICE +++ b/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 072855440..9783b4184 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -18,6 +18,7 @@ oms beta bootstrap-gcp [flags] ``` --acme-staging Use the Let's Encrypt staging ACME endpoint (certificates are not browser-trusted) + --airgapped Install k0s from the k0s airgap image bundle, so the cluster nodes pull no k0s images from the internet (default: false) --azure-devops-app-client-id string Azure DevOps App Client ID (optional) --azure-devops-app-client-secret string Azure DevOps App Client Secret (optional) --base-domain string Base domain for Codesphere (required) diff --git a/docs/oms_install_k0s.md b/docs/oms_install_k0s.md index bdf4a4216..d2aadd963 100644 --- a/docs/oms_install_k0s.md +++ b/docs/oms_install_k0s.md @@ -46,6 +46,12 @@ $ oms install k0s --airgapped # Install k0s airgapped from a local airgap image bundle $ oms install k0s --airgapped --airgap-bundle +# Only generate the k0sctl config without installing k0s +$ oms install k0s --install-config --config-only --k0sctl-config + +# Install k0s from a previously generated k0sctl config +$ oms install k0s --k0sctl-config + ``` ### Options @@ -53,9 +59,11 @@ $ oms install k0s --airgapped --airgap-bundle ``` --airgap-bundle string Path to the k0s airgap image bundle to install from (requires --airgapped) --airgapped Install k0s without internet access by uploading the airgap image bundle to the workers + --config-only Only generate the k0sctl config, without installing k0s -f, --force Force new download and installation -h, --help help for k0s - --install-config string Path to Codesphere install-config file (required) + --install-config string Path to Codesphere install-config file (required unless --k0sctl-config is installed) + --k0sctl-config string With --config-only, where to write the generated k0sctl config; otherwise an existing k0sctl config to install k0s from instead of generating one --k0sctl-version string Version of k0sctl to use (default "v0.33.1") --no-download Skip downloading k0s binary -p, --package string Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load k0s from diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api From 895ac8b3e8074d26f979bd20f03f31a179cdc27f Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:31:41 +0000 Subject: [PATCH 17/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 2 +- internal/tmpl/NOTICE | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 8e9dfb6fd..869bfe631 100644 --- a/NOTICE +++ b/NOTICE @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 8e9dfb6fd..869bfe631 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api From aeb4ca4b357d883b06cc7b5738f5168bfdfd6845 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:33:14 +0000 Subject: [PATCH 18/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 2 +- internal/tmpl/NOTICE | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 869bfe631..8e9dfb6fd 100644 --- a/NOTICE +++ b/NOTICE @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 869bfe631..8e9dfb6fd 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api From 9502e019a0318dd3ffb22357d58c61b077d776c9 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:15 +0000 Subject: [PATCH 19/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 2 +- internal/tmpl/NOTICE | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 8e9dfb6fd..24d4a2177 100644 --- a/NOTICE +++ b/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 8e9dfb6fd..24d4a2177 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 From bc6e20d9040cc652d7344e9e7346431cb2fb76c9 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:36:56 +0000 Subject: [PATCH 20/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 6 +++--- internal/tmpl/NOTICE | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/NOTICE b/NOTICE index 24d4a2177..377368eb1 100644 --- a/NOTICE +++ b/NOTICE @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 24d4a2177..377368eb1 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api From af7f3d02c10a82aa59cce0ad7998804319aaaf71 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:38:58 +0000 Subject: [PATCH 21/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 8 ++++---- internal/tmpl/NOTICE | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/NOTICE b/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/NOTICE +++ b/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api From 194e06c6b518ff46d40e670b1867abb4118bae62 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:39:40 +0200 Subject: [PATCH 22/26] ref: cleanup --- internal/bootstrap/gcp/k0s.go | 8 ++++---- internal/installer/airgap.go | 4 +--- internal/installer/k0sctl.go | 3 +-- internal/installer/k0sctl_config.go | 13 +------------ internal/installer/node/node.go | 30 ++++++++++++----------------- 5 files changed, 19 insertions(+), 39 deletions(-) diff --git a/internal/bootstrap/gcp/k0s.go b/internal/bootstrap/gcp/k0s.go index 1ab11a137..b3cd057b2 100644 --- a/internal/bootstrap/gcp/k0s.go +++ b/internal/bootstrap/gcp/k0s.go @@ -274,13 +274,13 @@ func (b *GCPBootstrapper) InstallK0s() error { func (b *GCPBootstrapper) installK0s(dc *datacenter.DataCenter) error { // Reuse matching cached binaries and let k0sctl reconcile normally. Without // --force, an unchanged cluster remains untouched on bootstrap retries. - installCmd := fmt.Sprintf("oms install k0s --version %s --install-config %s --vault %s --vault-priv-key %s", - installer.DefaultK0sVersion, dc.RemoteConfigPath, dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) + configFlags := fmt.Sprintf("--version %s --install-config %s", installer.DefaultK0sVersion, dc.RemoteConfigPath) if b.Env.Airgapped { - installCmd = fmt.Sprintf("oms install k0s --k0sctl-config %s --vault %s --vault-priv-key %s", - dc.RemoteK0sctlConfigPath(), dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) + configFlags = "--k0sctl-config " + dc.RemoteK0sctlConfigPath() } + installCmd := fmt.Sprintf("oms install k0s %s --vault %s --vault-priv-key %s", + configFlags, dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) if err := b.Env.Jumpbox.RunSSHCommand("root", installCmd); err != nil { return fmt.Errorf("failed to install k0s from jumpbox (data center %d): %w", dc.ID, err) } diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go index 25a53090b..f4a1c519f 100644 --- a/internal/installer/airgap.go +++ b/internal/installer/airgap.go @@ -98,9 +98,7 @@ func (k *K0s) cachedAirgapBundle(cacheDir, version string) (string, bool) { // "k0s-airgap-bundle---.tar", so the name is resolved from the // release metadata instead of being constructed. func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { - subject := "k0s release " + version - - release, err := getGitHubRelease(k.Http, fmt.Sprintf("%s/%s", k0sReleaseAPIURL, version), subject) + release, err := getGitHubRelease(k.Http, k0sReleaseAPIURL+"/"+version, "k0s release "+version) if err != nil { return "", err } diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index 616ac1891..6f91dfeb1 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -77,12 +77,11 @@ func (k *K0sctl) Download(version string, opts DownloadOptions) (string, error) } if version == "" { - latestVersion, err := k.GetLatestVersion() + version, err = k.GetLatestVersion() if err != nil { return "", fmt.Errorf("failed to get latest version: %w", err) } - version = latestVersion io.Verbosef(!opts.Quiet, "Using latest k0sctl version: %s", version) } diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index c7a0be282..93ed57755 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -49,17 +49,6 @@ type K0sctlFile struct { Perm string `yaml:"perm,omitempty"` } -// k0sctlFilePerm is the permission k0sctl creates uploaded files with. -const k0sctlFilePerm = "0644" - -func airgapBundleFile(bundlePath string) K0sctlFile { - return K0sctlFile{ - Src: bundlePath, - DstDir: AirgapImagesDir, - Perm: k0sctlFilePerm, - } -} - // K0sctlOptions configures the k0sctl cluster configuration that oms generates from // an install-config. type K0sctlOptions struct { @@ -133,7 +122,7 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal } if runsWorker && options.Airgap.Enabled { - host.Files = []K0sctlFile{airgapBundleFile(options.Airgap.BundlePath)} + host.Files = []K0sctlFile{{Src: options.Airgap.BundlePath, DstDir: AirgapImagesDir, Perm: "0644"}} } k.Hosts = append(k.Hosts, host) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index d8180876a..2ceb43399 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -727,9 +727,6 @@ func (n *Node) setupAgentForwarding(client *ssh.Client) error { // holds the key at KeyPath, otherwise an agent that adds that key to the local agent's keys. func (n *Node) forwardedAgent(localAgent agent.ExtendedAgent) (agent.Agent, error) { if n.KeyPath == "" || (localAgent != nil && agentHoldsKey(localAgent, n.KeyPath, n.FileIO)) { - if localAgent == nil { - return nil, nil - } return localAgent, nil } @@ -793,17 +790,7 @@ type keyAddingAgent struct { } func (a *keyAddingAgent) List() ([]*agent.Key, error) { - extra, err := a.keyring.List() - if err != nil { - return nil, err - } - keys, err := a.ExtendedAgent.List() - if err != nil { - // The local agent may be locked or broken, the extra key still works. - return extra, nil - } - - return append(extra, keys...), nil + return withExtra(a.keyring.List, a.ExtendedAgent.List) } func (a *keyAddingAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { @@ -819,14 +806,21 @@ func (a *keyAddingAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags age } func (a *keyAddingAgent) Signers() ([]ssh.Signer, error) { - extra, err := a.keyring.Signers() + return withExtra(a.keyring.Signers, a.ExtendedAgent.Signers) +} + +// withExtra prepends the keyring's entries to the local agent's. A failing local agent is +// ignored, as it may be locked or broken while the extra key still works. +func withExtra[T any](extra, local func() ([]T, error)) ([]T, error) { + extraItems, err := extra() if err != nil { return nil, err } - signers, err := a.ExtendedAgent.Signers() + + localItems, err := local() if err != nil { - return extra, nil + return extraItems, nil } - return append(extra, signers...), nil + return append(extraItems, localItems...), nil } From 03977c8459148506f1727903620ad485592ac0ea Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:08:41 +0200 Subject: [PATCH 23/26] ref: update airgap handling and configuration options in k0s installation --- cli/cmd/k0s/install_k0s.go | 8 +-- cli/cmd/k0s/install_k0s_integration_test.go | 32 +++++------ internal/installer/airgap.go | 54 ++++++------------ internal/installer/download.go | 18 +++--- internal/installer/k0s.go | 20 ++----- internal/installer/k0s_config.go | 18 +----- internal/installer/k0s_config_test.go | 61 +++++---------------- internal/installer/k0sctl.go | 7 +-- internal/installer/k0sctl_config.go | 18 +++--- internal/installer/k0sctl_config_test.go | 52 ++++++++++-------- 10 files changed, 108 insertions(+), 180 deletions(-) diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 2fd383680..4d189b590 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -237,10 +237,10 @@ func (c *InstallK0sCmd) prepareK0sctlConfig(pm installer.PackageManager, k0s ins } k0sctlOptions := installer.K0sctlOptions{ - K0sVersion: k0sVersion, - SSHKeyPath: c.Opts.SSHKeyPath, - K0sBinaryPath: k0sBinaryPath, - Airgap: installer.AirgapOptions{Enabled: c.Opts.Airgap, BundlePath: airgapBundlePath}, + K0sVersion: k0sVersion, + SSHKeyPath: c.Opts.SSHKeyPath, + K0sBinaryPath: k0sBinaryPath, + AirgapBundlePath: airgapBundlePath, } return c.generateK0sctlConfig(config, k0sctlOptions) diff --git a/cli/cmd/k0s/install_k0s_integration_test.go b/cli/cmd/k0s/install_k0s_integration_test.go index 1ce4c0f79..7e5ec40b3 100644 --- a/cli/cmd/k0s/install_k0s_integration_test.go +++ b/cli/cmd/k0s/install_k0s_integration_test.go @@ -89,7 +89,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(loadedConfig.Kubernetes.ManagedByCodesphere).To(BeTrue()) // Generate k0s config - k0sConfig, err := installer.GenerateK0sConfig(loadedConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(loadedConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig).NotTo(BeNil()) @@ -128,7 +128,7 @@ var _ = Describe("K0s Install-Config Integration", func() { } installConfig.Kubernetes.APIServerHost = "api.cluster.test" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) // Verify primary IP is used @@ -145,7 +145,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network).NotTo(BeNil()) @@ -156,7 +156,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should configure etcd storage correctly", func() { installConfig := createBaseConfig("storage-test", "192.168.1.100") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Storage).NotTo(BeNil()) @@ -168,7 +168,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should generate correct cluster name from datacenter", func() { installConfig := createBaseConfig("prod-us-east", "10.1.2.3") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-prod-us-east")) @@ -178,7 +178,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("empty-cp", "10.0.0.1") installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) // Should either handle gracefully or error if err == nil { Expect(k0sConfig).NotTo(BeNil()) @@ -190,7 +190,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should use default network values when not specified", func() { installConfig := createBaseConfig("defaults-test", "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) // Verify defaults are applied or fields are present @@ -215,7 +215,7 @@ var _ = Describe("K0s Install-Config Integration", func() { for _, tc := range testCases { installConfig := createBaseConfig(tc.name, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal(tc.expected)) } @@ -231,7 +231,7 @@ var _ = Describe("K0s Install-Config Integration", func() { } installConfig.Kubernetes.ControlPlanes = controlPlanes - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.Address).To(Equal("10.0.1.1")) Expect(len(k0sConfig.Spec.API.SANs)).To(BeNumerically(">=", 7)) @@ -242,7 +242,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.APIServerHost = "k8s.example.com" installConfig.Codesphere.Domain = "app.example.com" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.SANs).To(ContainElement("192.168.100.50")) @@ -260,7 +260,7 @@ var _ = Describe("K0s Install-Config Integration", func() { }) It("should fail when generating config from nil", func() { - _, err := installer.GenerateK0sConfig(nil, installer.AirgapOptions{}) + _, err := installer.GenerateK0sConfig(nil) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cannot be nil")) }) @@ -291,7 +291,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("external-k8s", "10.0.0.1") installConfig.Kubernetes.ManagedByCodesphere = false - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig).NotTo(BeNil()) }) @@ -300,7 +300,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig := createBaseConfig("missing-host", "10.0.0.1") installConfig.Kubernetes.APIServerHost = "" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) if err == nil { Expect(k0sConfig).NotTo(BeNil()) Expect(k0sConfig.Spec.API.Address).To(Equal("10.0.0.1")) @@ -312,7 +312,7 @@ var _ = Describe("K0s Install-Config Integration", func() { It("should handle missing datacenter name", func() { installConfig := createBaseConfig("", "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) if err == nil { Expect(k0sConfig).NotTo(BeNil()) } else { @@ -343,7 +343,7 @@ var _ = Describe("K0s Install-Config Integration", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - original, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + original, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) yamlData, err := original.Marshal() @@ -378,7 +378,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(err).NotTo(HaveOccurred()) // Generate and save k0s config - k0sConfig, err := installer.GenerateK0sConfig(originalConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(originalConfig) Expect(err).NotTo(HaveOccurred()) k0sData, err := k0sConfig.Marshal() Expect(err).NotTo(HaveOccurred()) diff --git a/internal/installer/airgap.go b/internal/installer/airgap.go index f4a1c519f..4ab632405 100644 --- a/internal/installer/airgap.go +++ b/internal/installer/airgap.go @@ -5,7 +5,9 @@ package installer import ( "fmt" + "os" "path/filepath" + "slices" "strings" "github.com/codesphere-cloud/cs-go/pkg/io" @@ -20,14 +22,6 @@ const ( AirgapImagesDir = "/var/lib/k0s/images" ) -// AirgapOptions describes an airgapped installation. Enabled marks the installation -// as airgapped and BundlePath is the local airgap image bundle that k0sctl uploads -// to the worker nodes. -type AirgapOptions struct { - Enabled bool - BundlePath string -} - // EnsureAirgapBundle makes sure the airgap image bundle of the given version is // available in the OMS cache dir and returns its path. func (k *K0s) EnsureAirgapBundle(version string, opts DownloadOptions) (string, error) { @@ -36,9 +30,14 @@ func (k *K0s) EnsureAirgapBundle(version string, opts DownloadOptions) (string, return "", err } - cachePath, err := k.airgapBundleCachePath(version, cacheDir) - if err != nil { - return "", err + cachePath, found := k.cachedAirgapBundle(cacheDir, version) + if !found { + assetName, err := k.resolveAirgapBundleAssetName(version) + if err != nil { + return "", err + } + + cachePath = filepath.Join(cacheDir, assetName) } if k.FileWriter.Exists(cachePath) && !opts.Force { @@ -57,21 +56,6 @@ func (k *K0s) EnsureAirgapBundle(version string, opts DownloadOptions) (string, return cachePath, nil } -// airgapBundleCachePath prefers an already cached bundle of the given version over -// resolving the release metadata, so airgapped installations can stay offline. -func (k *K0s) airgapBundleCachePath(version, cacheDir string) (string, error) { - if cachedPath, found := k.cachedAirgapBundle(cacheDir, version); found { - return cachedPath, nil - } - - assetName, err := k.resolveAirgapBundleAssetName(version) - if err != nil { - return "", err - } - - return filepath.Join(cacheDir, assetName), nil -} - // cachedAirgapBundle returns the path of the cached airgap bundle of the given // version, if there is one. An unreadable cache is not an error: the release // metadata then decides which bundle to look for. @@ -81,13 +65,12 @@ func (k *K0s) cachedAirgapBundle(cacheDir, version string) (string, bool) { return "", false } - for _, entry := range entries { - if k.isAirgapBundleFor(entry.Name(), version) { - return filepath.Join(cacheDir, entry.Name()), true - } + i := slices.IndexFunc(entries, func(entry os.DirEntry) bool { return k.isAirgapBundleFor(entry.Name(), version) }) + if i < 0 { + return "", false } - return "", false + return filepath.Join(cacheDir, entries[i].Name()), true } // resolveAirgapBundleAssetName returns the release asset name of the airgap image @@ -103,13 +86,12 @@ func (k *K0s) resolveAirgapBundleAssetName(version string) (string, error) { return "", err } - for _, asset := range release.Assets { - if k.isAirgapBundleFor(asset.Name, version) { - return asset.Name, nil - } + i := slices.IndexFunc(release.Assets, func(asset githubReleaseAsset) bool { return k.isAirgapBundleFor(asset.Name, version) }) + if i < 0 { + return "", fmt.Errorf("no airgap bundle for %s/%s found in k0s release %s", k.Goos, k.Goarch, version) } - return "", fmt.Errorf("no airgap bundle for %s/%s found in k0s release %s", k.Goos, k.Goarch, version) + return release.Assets[i].Name, nil } // isAirgapBundleFor reports whether name is the airgap image bundle asset of the diff --git a/internal/installer/download.go b/internal/installer/download.go index 7ee779912..6bb995d5d 100644 --- a/internal/installer/download.go +++ b/internal/installer/download.go @@ -77,14 +77,12 @@ func reuseCachedBinary(fw util.FileIO, cachePath, requestedVersion, name string, return cachePath, true } - replaceReason := fmt.Sprintf("version could not be determined: %v", versionErr) - if versionErr == nil { - replaceReason = fmt.Sprintf("version %s does not match requested version %s; replacing it", - cachedVersion, requestedVersion) + if versionErr != nil { + io.Verbosef(!opts.Quiet, "Replacing cached %s: version could not be determined: %v", name, versionErr) + } else { + io.Verbosef(!opts.Quiet, "Replacing cached %s %s: requested version %s", name, cachedVersion, requestedVersion) } - io.Verbosef(!opts.Quiet, "Replacing existing %s binary: Cached %s %s", name, name, replaceReason) - return "", false } @@ -108,16 +106,16 @@ func downloadToPath(fw util.FileIO, http portal.Http, path, downloadURL string, return nil } -func downloadBinaryToPath(fw util.FileIO, http portal.Http, binaryPath, binaryName, downloadURL string, quiet bool) (string, error) { +func downloadBinaryToPath(fw util.FileIO, http portal.Http, binaryPath, binaryName, downloadURL string, quiet bool) error { if err := downloadToPath(fw, http, binaryPath, downloadURL, quiet); err != nil { - return "", fmt.Errorf("failed to download: %w", err) + return fmt.Errorf("failed to download: %w", err) } if err := fw.Chmod(binaryPath, 0755); err != nil { - return "", fmt.Errorf("failed to make %s binary executable: %w", binaryName, err) + return fmt.Errorf("failed to make %s binary executable: %w", binaryName, err) } - return binaryPath, nil + return nil } func localBinaryVersion(binaryPath string) (string, error) { diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index abfb8e9d4..f2ef813a1 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -75,17 +75,6 @@ func (k *K0s) Download(version string, opts DownloadOptions) (string, error) { return "", err } - path, err := k.downloadBinary(version, cacheDir, opts) - if err != nil { - return "", fmt.Errorf("failed to download k0s binary: %w", err) - } - - return path, nil -} - -// downloadBinary stores the k0s binary of version as k0sBinaryName in cacheDir and -// returns its path, reusing a cached copy unless it is missing or opts force a download. -func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (string, error) { cachePath := filepath.Join(cacheDir, k0sBinaryName) if cachedPath, cached := reuseCachedBinary(k.FileWriter, cachePath, version, k0sBinaryName, opts); cached { return cachedPath, nil @@ -94,12 +83,11 @@ func (k *K0s) downloadBinary(version, cacheDir string, opts DownloadOptions) (st assetName := fmt.Sprintf("%s-%s-%s", k0sBinaryName, version, k.Goarch) downloadURL := releaseAssetURL(k0sReleaseURL, version, assetName) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sBinaryName, downloadURL, opts.Quiet) - if err != nil { - return "", err + if err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sBinaryName, downloadURL, opts.Quiet); err != nil { + return "", fmt.Errorf("failed to download k0s binary: %w", err) } - log.Printf("k0s binary downloaded and made executable at '%s'", path) + log.Printf("k0s binary downloaded and made executable at '%s'", cachePath) - return path, nil + return cachePath, nil } diff --git a/internal/installer/k0s_config.go b/internal/installer/k0s_config.go index d8a5c7379..a0cc82911 100644 --- a/internal/installer/k0s_config.go +++ b/internal/installer/k0s_config.go @@ -67,10 +67,8 @@ type K0sKonnectivity struct { AgentPort int `yaml:"agentPort,omitempty"` } -// GenerateK0sConfig generates the k0s cluster configuration for a Codesphere -// install-config. Airgapped installations are requested through the airgap options -// and stop k0s from pulling images. -func GenerateK0sConfig(installConfig *files.RootConfig, airgap AirgapOptions) (*K0sConfig, error) { +// GenerateK0sConfig generates a K0sConfig based on the provided RootConfig. +func GenerateK0sConfig(installConfig *files.RootConfig) (*K0sConfig, error) { if installConfig == nil { return nil, fmt.Errorf("installConfig cannot be nil") } @@ -120,7 +118,7 @@ func GenerateK0sConfig(installConfig *files.RootConfig, airgap AirgapOptions) (* } k0sConfig.Spec.Images = &K0sImages{ - DefaultPullPolicy: pullPolicyFor(airgap), + DefaultPullPolicy: "IfNotPresent", } k0sConfig.Spec.Telemetry = &K0sTelemetry{ @@ -136,16 +134,6 @@ func GenerateK0sConfig(installConfig *files.RootConfig, airgap AirgapOptions) (* return k0sConfig, nil } -// pullPolicyFor returns the k0s image pull policy. Airgapped installations get their -// images from a pre-loaded bundle, so they must never pull from the internet. -func pullPolicyFor(options AirgapOptions) string { - if options.Enabled { - return "Never" - } - - return "IfNotPresent" -} - func (c *K0sConfig) Marshal() ([]byte, error) { return yaml.Marshal(c) } diff --git a/internal/installer/k0s_config_test.go b/internal/installer/k0s_config_test.go index 985bf9349..41ee8b462 100644 --- a/internal/installer/k0s_config_test.go +++ b/internal/installer/k0s_config_test.go @@ -21,7 +21,7 @@ var _ = Describe("K0sConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) @@ -59,48 +59,18 @@ var _ = Describe("K0sConfig", func() { It("should handle minimal configuration", func() { installConfig := newTestConfig("minimal", true, "192.168.1.100") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-minimal")) }) - It("should set the pull policy to Never for airgapped installations", func() { - installConfig := newTestConfig("test-dc", true, "10.0.1.10") - - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{Enabled: true}) - Expect(err).ToNot(HaveOccurred()) - - // Airgapped nodes get their images from a pre-loaded bundle and must - // never pull from the internet. - Expect(k0sConfig.Spec.Images.DefaultPullPolicy).To(Equal("Never")) - }) - - It("should keep the default pull policy when airgapped is not enabled", func() { - installConfig := newTestConfig("test-dc", true, "10.0.1.10") - - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(k0sConfig.Spec.Images.DefaultPullPolicy).To(Equal("IfNotPresent")) - }) - - It("should serialize the airgapped pull policy into the k0s config YAML", func() { - installConfig := newTestConfig("test-dc", true, "10.0.1.10") - - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{Enabled: true}) - Expect(err).ToNot(HaveOccurred()) - - yamlData, err := k0sConfig.Marshal() - Expect(err).ToNot(HaveOccurred()) - Expect(string(yamlData)).To(ContainSubstring("default_pull_policy: Never")) - }) - It("should generate valid YAML", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).ToNot(HaveOccurred()) yamlData, err := k0sConfig.Marshal() @@ -109,7 +79,6 @@ var _ = Describe("K0sConfig", func() { // Verify it can be unmarshalled back var parsedConfig installer.K0sConfig - err = yaml.Unmarshal(yamlData, &parsedConfig) Expect(err).ToNot(HaveOccurred()) Expect(parsedConfig.Metadata.Name).To(Equal("codesphere-test-dc")) @@ -118,7 +87,7 @@ var _ = Describe("K0sConfig", func() { Context("with invalid input", func() { It("should return error for nil install-config", func() { - k0sConfig, err := installer.GenerateK0sConfig(nil, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(nil) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("installConfig cannot be nil")) Expect(k0sConfig).To(BeNil()) @@ -131,7 +100,7 @@ var _ = Describe("K0sConfig", func() { installConfig.Kubernetes.PodCIDR = "10.244.0.0/16" installConfig.Kubernetes.ServiceCIDR = "10.96.0.0/12" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).ToNot(HaveOccurred()) Expect(k0sConfig).ToNot(BeNil()) // Should still have basic structure but no specific config @@ -143,7 +112,7 @@ var _ = Describe("K0sConfig", func() { It("should handle empty datacenter name", func() { installConfig := newTestConfig("", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-")) }) @@ -152,7 +121,7 @@ var _ = Describe("K0sConfig", func() { installConfig := newTestConfig("test", true) installConfig.Kubernetes.ControlPlanes = []files.K8sNode{} - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) // Should have basic structure but no API/Storage config Expect(k0sConfig.Spec.API).To(BeNil()) @@ -162,7 +131,7 @@ var _ = Describe("K0sConfig", func() { It("should handle nil control plane addresses", func() { installConfig := newTestConfig("test", true) - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API).To(BeNil()) }) @@ -170,7 +139,7 @@ var _ = Describe("K0sConfig", func() { It("should handle missing APIServerHost", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.ExternalAddress).To(BeEmpty()) Expect(k0sConfig.Spec.API.SANs).To(ConsistOf("10.0.0.1")) @@ -179,7 +148,7 @@ var _ = Describe("K0sConfig", func() { It("should handle missing network CIDRs", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network).NotTo(BeNil()) Expect(k0sConfig.Spec.Network.Provider).To(Equal("calico")) @@ -188,7 +157,7 @@ var _ = Describe("K0sConfig", func() { It("should use default network provider", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Network.Provider).To(Equal("calico")) }) @@ -197,7 +166,7 @@ var _ = Describe("K0sConfig", func() { installConfig := newTestConfig("test", true, "10.0.0.1") installConfig.Kubernetes.APIServerHost = "api.example.com" - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.SANs).To(HaveLen(2)) Expect(k0sConfig.Spec.API.SANs).To(ContainElements("10.0.0.1", "api.example.com")) @@ -206,7 +175,7 @@ var _ = Describe("K0sConfig", func() { It("should handle special characters in datacenter name", func() { installConfig := newTestConfig("test-dc_01.prod", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Metadata.Name).To(Equal("codesphere-test-dc_01.prod")) }) @@ -214,7 +183,7 @@ var _ = Describe("K0sConfig", func() { It("should set correct API port", func() { installConfig := newTestConfig("test", true, "10.0.0.1") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.API.Port).To(Equal(6443)) }) @@ -222,7 +191,7 @@ var _ = Describe("K0sConfig", func() { It("should configure etcd with first control plane IP", func() { installConfig := newTestConfig("test", true, "10.0.0.1", "10.0.0.2") - k0sConfig, err := installer.GenerateK0sConfig(installConfig, installer.AirgapOptions{}) + k0sConfig, err := installer.GenerateK0sConfig(installConfig) Expect(err).NotTo(HaveOccurred()) Expect(k0sConfig.Spec.Storage.Type).To(Equal("etcd")) Expect(k0sConfig.Spec.Storage.Etcd.PeerAddress).To(Equal("10.0.0.1")) diff --git a/internal/installer/k0sctl.go b/internal/installer/k0sctl.go index 6f91dfeb1..a8339ee4e 100644 --- a/internal/installer/k0sctl.go +++ b/internal/installer/k0sctl.go @@ -99,14 +99,13 @@ func (k *K0sctl) Download(version string, opts DownloadOptions) (string, error) io.Verbosef(!opts.Quiet, "Downloading k0sctl %s from %s", version, downloadURL) - path, err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sctlBinaryName, downloadURL, opts.Quiet) - if err != nil { + if err := downloadBinaryToPath(k.FileWriter, k.Http, cachePath, k0sctlBinaryName, downloadURL, opts.Quiet); err != nil { return "", err } - io.Verbosef(!opts.Quiet, "k0sctl downloaded successfully to %s", path) + io.Verbosef(!opts.Quiet, "k0sctl downloaded successfully to %s", cachePath) - return path, nil + return cachePath, nil } func (k *K0sctl) requireBinaryAndConfig(configPath, k0sctlPath string) error { diff --git a/internal/installer/k0sctl_config.go b/internal/installer/k0sctl_config.go index 93ed57755..a6bb3ef4f 100644 --- a/internal/installer/k0sctl_config.go +++ b/internal/installer/k0sctl_config.go @@ -58,8 +58,8 @@ type K0sctlOptions struct { SSHKeyPath string // K0sBinaryPath is the local k0s binary that k0sctl uploads to the nodes. K0sBinaryPath string - // Airgap describes an airgapped installation. - Airgap AirgapOptions + // AirgapBundlePath is the image bundle that k0sctl uploads to the worker nodes. + AirgapBundlePath string } type K0sctlSSH struct { @@ -121,8 +121,8 @@ func (k *K0sctlSpec) addUniqueK0sctlHost(node files.K8sNode, role string, instal host.K0sBinaryPath = options.K0sBinaryPath } - if runsWorker && options.Airgap.Enabled { - host.Files = []K0sctlFile{{Src: options.Airgap.BundlePath, DstDir: AirgapImagesDir, Perm: "0644"}} + if runsWorker && options.AirgapBundlePath != "" { + host.Files = []K0sctlFile{{Src: options.AirgapBundlePath, DstDir: AirgapImagesDir, Perm: "0644"}} } k.Hosts = append(k.Hosts, host) @@ -138,15 +138,15 @@ func GenerateK0sctlConfig(installConfig *files.RootConfig, options K0sctlOptions return nil, fmt.Errorf("k0sctl is only supported for Codesphere-managed Kubernetes") } - if options.Airgap.Enabled && options.Airgap.BundlePath == "" { - return nil, fmt.Errorf("airgapped installations require an airgap bundle path") - } - - k0sConfig, err := GenerateK0sConfig(installConfig, options.Airgap) + k0sConfig, err := GenerateK0sConfig(installConfig) if err != nil { return nil, fmt.Errorf("failed to generate k0s config: %w", err) } + if options.AirgapBundlePath != "" { + k0sConfig.Spec.Images.DefaultPullPolicy = "Never" + } + k0sctlConfig := &K0sctlConfig{ APIVersion: "k0sctl.k0sproject.io/v1beta1", Kind: "Cluster", diff --git a/internal/installer/k0sctl_config_test.go b/internal/installer/k0sctl_config_test.go index 72e88393c..7f720d911 100644 --- a/internal/installer/k0sctl_config_test.go +++ b/internal/installer/k0sctl_config_test.go @@ -146,9 +146,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, } - airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/k0s-airgap-bundle-amd64"} - - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", "/cache/k0s-airgap-bundle-amd64")) Expect(err).ToNot(HaveOccurred()) // Controllers without the worker role do not import image bundles. @@ -169,9 +167,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.1.10"}, // Same node, so it also runs a worker } - airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} - - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", "/cache/bundle")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) @@ -179,6 +175,26 @@ var _ = Describe("K0sctlConfig", func() { Expect(k0sctlConfig.Spec.Hosts[0].Files).To(HaveLen(1)) }) + It("should stop pulling images when airgapped", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", "/cache/bundle")) + Expect(err).ToNot(HaveOccurred()) + Expect(k0sctlConfig.Spec.K0s.Config.Spec.Images.DefaultPullPolicy).To(Equal("Never")) + + yamlData, err := k0sctlConfig.Marshal() + Expect(err).ToNot(HaveOccurred()) + Expect(string(yamlData)).To(ContainSubstring("default_pull_policy: Never")) + }) + + It("should keep the default pull policy for installations with internet access", func() { + installConfig := newTestConfig("test-dc", true, "10.0.1.10") + + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlOptions("v1.30.0+k0s.0", "/path/to/key", "")) + Expect(err).ToNot(HaveOccurred()) + Expect(k0sctlConfig.Spec.K0s.Config.Spec.Images.DefaultPullPolicy).To(Equal("IfNotPresent")) + }) + It("should not upload files for installations with internet access", func() { installConfig := newTestConfig("test-dc", true, "10.0.1.10") installConfig.Kubernetes.Workers = []files.K8sNode{ @@ -198,9 +214,7 @@ var _ = Describe("K0sctlConfig", func() { {IPAddress: "10.0.2.10"}, } - airgap := installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"} - - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", airgap)) + k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, k0sctlAirgapOptions("v1.30.0+k0s.0", "/cache/bundle")) Expect(err).ToNot(HaveOccurred()) yamlData, err := k0sctlConfig.Marshal() @@ -250,16 +264,6 @@ var _ = Describe("K0sctlConfig", func() { Expect(err.Error()).To(ContainSubstring("k0sctl is only supported for Codesphere-managed Kubernetes")) Expect(k0sctlConfig).To(BeNil()) }) - - It("should return error for airgapped installations without a bundle path", func() { - installConfig := newTestConfig("test-dc", true, "10.0.1.10") - - k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, - k0sctlAirgapOptions("v1.30.0+k0s.0", installer.AirgapOptions{Enabled: true})) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("require an airgap bundle path")) - Expect(k0sctlConfig).To(BeNil()) - }) }) Context("edge cases", func() { @@ -302,7 +306,7 @@ var _ = Describe("K0sctlConfig", func() { } k0sctlConfig, err := installer.GenerateK0sctlConfig(installConfig, - k0sctlAirgapOptions("v1.30.0+k0s.0", installer.AirgapOptions{Enabled: true, BundlePath: "/cache/bundle"})) + k0sctlAirgapOptions("v1.30.0+k0s.0", "/cache/bundle")) Expect(err).ToNot(HaveOccurred()) Expect(k0sctlConfig.Spec.Hosts).To(HaveLen(1)) @@ -349,10 +353,10 @@ func k0sctlOptions(k0sVersion, sshKeyPath, k0sBinaryPath string) installer.K0sct } // k0sctlAirgapOptions builds k0sctl options for airgapped installations. -func k0sctlAirgapOptions(k0sVersion string, airgap installer.AirgapOptions) installer.K0sctlOptions { +func k0sctlAirgapOptions(k0sVersion, bundlePath string) installer.K0sctlOptions { return installer.K0sctlOptions{ - K0sVersion: k0sVersion, - SSHKeyPath: "/path/to/key", - Airgap: airgap, + K0sVersion: k0sVersion, + SSHKeyPath: "/path/to/key", + AirgapBundlePath: bundlePath, } } From 72f7d5762968294ebd0c944fc764661a92eb3197 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:10:36 +0000 Subject: [PATCH 24/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 8 ++++---- internal/tmpl/NOTICE | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/NOTICE b/NOTICE index 8e9dfb6fd..377368eb1 100644 --- a/NOTICE +++ b/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 8e9dfb6fd..377368eb1 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api From 300d961fef56566b2c3f6cadfa3690db3849b552 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:36:30 +0200 Subject: [PATCH 25/26] fix: lint errors --- internal/installer/k0s_test.go | 1 - .../installer/node/agent_forwarding_test.go | 2 ++ internal/installer/node/node.go | 23 ++++++++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index a8045d930..6caecf69a 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -537,7 +537,6 @@ var _ = Describe("K0s", func() { Expect(err.Error()).To(ContainSubstring("failed to create workdir")) }) }) - }) // fakeDirEntry is a minimal os.DirEntry for cache lookups in tests. diff --git a/internal/installer/node/agent_forwarding_test.go b/internal/installer/node/agent_forwarding_test.go index 1dab7c03a..d894f7277 100644 --- a/internal/installer/node/agent_forwarding_test.go +++ b/internal/installer/node/agent_forwarding_test.go @@ -42,12 +42,14 @@ func listedKeys(a agent.Agent) []string { for _, k := range keys { blobs = append(blobs, string(k.Marshal())) } + return blobs } func publicBlob(priv ed25519.PrivateKey) string { signer, err := ssh.NewSignerFromKey(priv) Expect(err).NotTo(HaveOccurred()) + return string(signer.PublicKey().Marshal()) } diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 2ceb43399..2339f3c38 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -1,6 +1,7 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 +// Package node manages the remote hosts of an installation over SSH. package node import ( @@ -703,6 +704,7 @@ func (n *Node) loadPrivateKey() (any, error) { // forwarded agent, so it always offers the key at KeyPath, even when the local agent lacks it. func (n *Node) setupAgentForwarding(client *ssh.Client) error { var localAgent agent.ExtendedAgent + if authSocket := os.Getenv("SSH_AUTH_SOCK"); authSocket != "" { conn, err := net.Dial("unix", authSocket) if err != nil { @@ -716,11 +718,16 @@ func (n *Node) setupAgentForwarding(client *ssh.Client) error { if err != nil { return err } + if forwarded == nil { return nil } - return agent.ForwardToAgent(client, forwarded) + if err := agent.ForwardToAgent(client, forwarded); err != nil { + return fmt.Errorf("failed to forward SSH agent: %w", err) + } + + return nil } // forwardedAgent returns the agent to forward to remote hosts: the local agent when it already @@ -735,7 +742,9 @@ func (n *Node) forwardedAgent(localAgent agent.ExtendedAgent) (agent.Agent, erro if localAgent == nil { return nil, fmt.Errorf("no SSH agent to forward and failed to load private key: %w", err) } + log.Printf("Warning: forwarding SSH agent without the key at %s: %v", n.KeyPath, err) + return localAgent, nil } @@ -762,6 +771,7 @@ func agentHoldsKey(a agent.Agent, keyPath string, fileIO util.FileIO) bool { if err != nil { return false } + pub, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes) if err != nil { return false @@ -771,6 +781,7 @@ func agentHoldsKey(a agent.Agent, keyPath string, fileIO util.FileIO) bool { if err != nil { return false } + target := string(pub.Marshal()) for _, k := range keys { if string(k.Marshal()) == target { @@ -798,11 +809,17 @@ func (a *keyAddingAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, e } func (a *keyAddingAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + signer := a.ExtendedAgent if bytes.Equal(key.Marshal(), a.keyBlob) { - return a.keyring.SignWithFlags(key, data, flags) + signer = a.keyring + } + + signature, err := signer.SignWithFlags(key, data, flags) + if err != nil { + return nil, fmt.Errorf("failed to sign with SSH agent: %w", err) } - return a.ExtendedAgent.SignWithFlags(key, data, flags) + return signature, nil } func (a *keyAddingAgent) Signers() ([]ssh.Signer, error) { From 04a836c750a7f6d20f38e90bc7ae0ca2929a0048 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:38:45 +0000 Subject: [PATCH 26/26] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 8 ++++---- internal/tmpl/NOTICE | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/NOTICE b/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/NOTICE +++ b/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 377368eb1..8e9dfb6fd 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -199,7 +199,7 @@ License URL: https://github.com/beorn7/perks/blob/v1.0.1/LICENSE Module: github.com/blang/semver/v4 Version: v4.0.0 License: MIT -License URL: https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE +License URL: https://github.com/blang/semver/blob/v4.0.0/LICENSE ---------- Module: github.com/bmatcuk/doublestar/v4 @@ -397,7 +397,7 @@ License URL: https://github.com/emirpasic/gods/blob/v1.18.1/LICENSE Module: github.com/evanphx/json-patch/v5 Version: v5.9.11 License: BSD-3-Clause -License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/v5/LICENSE +License URL: https://github.com/evanphx/json-patch/blob/v5.9.11/LICENSE ---------- Module: github.com/exponent-io/jsonpath @@ -721,7 +721,7 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. Module: github.com/googleapis/gax-go/v2 Version: v2.26.2 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.26.2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -1507,7 +1507,7 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.16.0:LICENSE Module: gomodules.xyz/jsonpatch/v2 Version: v2.5.0 License: Apache-2.0 -License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE +License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/LICENSE ---------- Module: google.golang.org/api