diff --git a/README.md b/README.md index e82a1d8a..80e10d7e 100644 --- a/README.md +++ b/README.md @@ -764,6 +764,43 @@ See also: * [k0s Dynamic Configuration](https://docs.k0sproject.io/stable/dynamic-configuration/) +##### `spec.k0s.airgap` <mapping> (optional) + +Native k0s airgap bundle handling. When enabled, k0sctl resolves the airgap +bundle matching `spec.k0s.version`, downloads or reads it on the machine running +k0sctl, and uploads it to Linux hosts that run worker workloads. + +```yaml +spec: + k0s: + version: v1.34.1+k0s.0 + airgap: + enabled: true + source: auto +``` + +Supported fields: + +* `enabled`: Enables native airgap bundle handling. Default: `false`. +* `source`: Bundle source. Supported values are `auto`, `local`, and `url`. Default: `auto` when enabled. +* `mode`: Transfer mode. `upload` is supported. Default: `upload`. +* `path`: Local bundle file or directory when `source: local`. Directory sources are matched by the official bundle filename for each host architecture. +* `url`: URL template when `source: url`. Supports `%v` for k0s version, `%p` for architecture, `%o` for OS, and `%%` for a literal percent sign. +* `sha256`: Optional SHA-256 checksum for `local` or `url` sources. + +Bundles are uploaded to `/images`, where `` is the host's +k0s data directory. Hosts with role `worker`, `controller+worker`, and `single` +receive bundles. Controller-only hosts do not need them. Windows workers are +skipped for now. + +For fully disconnected environments, set +`spec.k0s.config.spec.images.default_pull_policy: Never` in the embedded k0s +configuration. k0sctl warns when airgap is enabled and that pull policy is not +set, but it does not modify the k0s configuration automatically. + +The lower-level `spec.hosts[*].files` mechanism remains available for custom +bundle placement and other advanced upload workflows. + ##### `spec.k0s.config` <mapping> (optional) (default: auto-generated) Embedded k0s cluster configuration. See [k0s configuration documentation](https://docs.k0sproject.io/stable/configuration/) for details. diff --git a/action/apply.go b/action/apply.go index e5150537..5cdf6fed 100644 --- a/action/apply.go +++ b/action/apply.go @@ -74,6 +74,7 @@ func NewApply(opts ApplyOptions) *Apply { RestoreFrom: opts.RestoreFrom, }, &phase.RunHooks{Stage: "before", Action: "apply"}, + &phase.AirgapBundles{}, &phase.InitializeK0s{}, &phase.InstallControllers{}, &phase.InstallWorkers{}, diff --git a/action/apply_test.go b/action/apply_test.go new file mode 100644 index 00000000..4079883f --- /dev/null +++ b/action/apply_test.go @@ -0,0 +1,33 @@ +package action + +import ( + "testing" + + "github.com/k0sproject/k0sctl/phase" + "github.com/stretchr/testify/require" +) + +func TestApplyIncludesAirgapBeforeWorkerPhases(t *testing.T) { + apply := NewApply(ApplyOptions{}) + airgapPhase := (&phase.AirgapBundles{}).Title() + initializeK0s := (&phase.InitializeK0s{}).Title() + installControllers := (&phase.InstallControllers{}).Title() + installWorkers := (&phase.InstallWorkers{}).Title() + upgradeWorkers := (&phase.UpgradeWorkers{}).Title() + + airgapIndex := apply.Phases.Index(airgapPhase) + initializeIndex := apply.Phases.Index(initializeK0s) + installControllersIndex := apply.Phases.Index(installControllers) + installWorkersIndex := apply.Phases.Index(installWorkers) + upgradeWorkersIndex := apply.Phases.Index(upgradeWorkers) + + require.NotEqual(t, -1, airgapIndex) + require.NotEqual(t, -1, initializeIndex) + require.NotEqual(t, -1, installControllersIndex) + require.NotEqual(t, -1, installWorkersIndex) + require.NotEqual(t, -1, upgradeWorkersIndex) + require.Less(t, airgapIndex, initializeIndex) + require.Less(t, airgapIndex, installControllersIndex) + require.Less(t, airgapIndex, installWorkersIndex) + require.Less(t, airgapIndex, upgradeWorkersIndex) +} diff --git a/internal/download/download.go b/internal/download/download.go new file mode 100644 index 00000000..2a8dbcd8 --- /dev/null +++ b/internal/download/download.go @@ -0,0 +1,112 @@ +package download + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Minute, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ResponseHeaderTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, +} + +// ToFile downloads rawURL to dest using a temporary file in the destination directory. +func ToFile(ctx context.Context, rawURL, dest string) (retErr error) { + dir := filepath.Dir(dest) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmpFile, err := os.CreateTemp(dir, filepath.Base(dest)+".tmp-") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + defer func() { + if tmpFile != nil { + if err := tmpFile.Close(); err != nil && retErr == nil { + retErr = err + } + } + if retErr != nil { + if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove partial download at %s: %v", tmpPath, err) + } + } + }() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("create download request for %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err)) + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err)) + } + defer func() { + if err := resp.Body.Close(); err != nil && retErr == nil { + retErr = err + } + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected http status %s from %s", resp.Status, RedactedURL(rawURL)) + } + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return err + } + if err := tmpFile.Sync(); err != nil { + return err + } + if err := tmpFile.Close(); err != nil { + tmpFile = nil + return err + } + tmpFile = nil + // os.Rename is atomic on Unix (replaces dest if it exists), so concurrent runs are safe. + // On Windows it fails if dest already exists; two simultaneous k0sctl processes targeting + // the same destination could race here. We intentionally propagate that error rather than + // silently accepting whatever file is at dest, which would be a TOCTOU risk. + if err := os.Rename(tmpPath, dest); err != nil { + return err + } + return nil +} + +// RedactedURL returns a URL string suitable for error messages. +func RedactedURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func redactedURLError(rawURL string, err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Err != nil { + return urlErr.Err + } + return errors.New(strings.ReplaceAll(err.Error(), rawURL, RedactedURL(rawURL))) +} diff --git a/internal/download/download_test.go b/internal/download/download_test.go new file mode 100644 index 00000000..590e65aa --- /dev/null +++ b/internal/download/download_test.go @@ -0,0 +1,110 @@ +package download + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToFileDownloadsToDestination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := fmt.Fprint(w, "downloaded") + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + dest := filepath.Join(t.TempDir(), "bundle") + require.NoError(t, ToFile(context.Background(), server.URL+"/bundle", dest)) + + content, err := os.ReadFile(dest) + require.NoError(t, err) + require.Equal(t, "downloaded", string(content)) + require.Empty(t, tempFiles(t, dest)) +} + +func TestToFileRedactsURLOnHTTPStatusError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + t.Cleanup(server.Close) + + dest := filepath.Join(t.TempDir(), "bundle") + err := ToFile(context.Background(), authenticatedURL(server.URL)+"/bundle?token=secret", dest) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected http status 401 Unauthorized") + require.NotContains(t, err.Error(), "token=secret") + require.NotContains(t, err.Error(), "user:pass") + require.NoFileExists(t, dest) + require.Empty(t, tempFiles(t, dest)) +} + +func TestToFileRedactsURLOnRequestError(t *testing.T) { + dest := filepath.Join(t.TempDir(), "bundle") + err := ToFile(context.Background(), "http://user:pass@example.invalid/\n?token=secret", dest) + require.Error(t, err) + require.Contains(t, err.Error(), "create download request for ") + require.NotContains(t, err.Error(), "token=secret") + require.NotContains(t, err.Error(), "user:pass") + require.NoFileExists(t, dest) + require.Empty(t, tempFiles(t, dest)) +} + +func TestToFileRedactsURLOnTransportError(t *testing.T) { + dest := filepath.Join(t.TempDir(), "bundle") + err := ToFile(context.Background(), "http://user:pass@127.0.0.1:1/bundle?token=secret", dest) + require.Error(t, err) + require.Contains(t, err.Error(), "download http://127.0.0.1:1/bundle") + require.NotContains(t, err.Error(), "token=secret") + require.NotContains(t, err.Error(), "user:pass") + require.NoFileExists(t, dest) + require.Empty(t, tempFiles(t, dest)) +} + +func TestToFileRemovesPartialDownloadOnCopyError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "10") + _, err := fmt.Fprint(w, "part") + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + dest := filepath.Join(t.TempDir(), "bundle") + err := ToFile(context.Background(), server.URL+"/bundle", dest) + require.Error(t, err) + require.NoFileExists(t, dest) + require.Empty(t, tempFiles(t, dest)) +} + +func TestToFileRemovesTempFileOnCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + dest := filepath.Join(t.TempDir(), "bundle") + err := ToFile(ctx, "http://127.0.0.1/bundle", dest) + require.Error(t, err) + require.NoFileExists(t, dest) + require.Empty(t, tempFiles(t, dest)) +} + +func TestRedactedURLRemovesCredentialsAndQuery(t *testing.T) { + got := RedactedURL("https://user:pass@example.invalid/path/to/bundle?token=secret#fragment") + require.Equal(t, "https://example.invalid/path/to/bundle", got) +} + +func authenticatedURL(rawURL string) string { + return strings.Replace(rawURL, "http://", "http://user:pass@", 1) +} + +func tempFiles(t *testing.T, dest string) []string { + t.Helper() + matches, err := filepath.Glob(filepath.Join(filepath.Dir(dest), filepath.Base(dest)+".tmp-*")) + require.NoError(t, err) + return matches +} diff --git a/phase/airgap_bundles.go b/phase/airgap_bundles.go new file mode 100644 index 00000000..7b548ff2 --- /dev/null +++ b/phase/airgap_bundles.go @@ -0,0 +1,319 @@ +package phase + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/k0sproject/k0sctl/pkg/airgap" + v1beta1 "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" + "github.com/k0sproject/rig/exec" + log "github.com/sirupsen/logrus" +) + +// AirgapBundles uploads k0s airgap bundles to worker-capable hosts. +type AirgapBundles struct { + GenericPhase + + plans []airgap.Plan + planIndexes map[*cluster.Host]int + + checksumMu sync.Mutex + verifiedChecksums map[string]checksumVerification +} + +type checksumVerification struct { + size int64 + modTimeUnixNano int64 +} + +// Title for the phase. +func (p *AirgapBundles) Title() string { + return "Upload airgap bundles" +} + +// Prepare plans airgap bundle placement for worker-capable Linux hosts. +func (p *AirgapBundles) Prepare(config *v1beta1.Cluster) error { + p.Config = config + if !airgapEnabled(config) { + return nil + } + if config.Spec.K0s.Version == nil || config.Spec.K0s.Version.IsZero() { + return fmt.Errorf("spec.k0s.version is required when airgap is enabled") + } + resolver, err := p.resolver() + if err != nil { + return err + } + plans, err := airgap.PlanHosts(config.Spec.Hosts, config.Spec.K0s.Version, resolver) + if err != nil { + return err + } + if config.Spec.K0s.Airgap.Source == cluster.AirgapSourceLocal { + for i := range plans { + plans[i].Artifact.SHA256 = config.Spec.K0s.Airgap.SHA256 + } + if err := p.validateLocalSourcePath(config.Spec.K0s.Airgap.Path, config.Spec.K0s.Airgap.SHA256, plans); err != nil { + return err + } + } + p.plans = plans + p.indexPlans() + p.warnPullPolicy() + return nil +} + +func (p *AirgapBundles) validateLocalSourcePath(sourcePath, sha256 string, plans []airgap.Plan) error { + if len(plans) == 0 { + return nil + } + stat, err := os.Stat(sourcePath) + if err != nil { + return fmt.Errorf("stat local airgap source %s: %w", sourcePath, err) + } + artifactNames := make(map[string]struct{}, len(plans)) + for _, plan := range plans { + artifactNames[plan.Artifact.Name] = struct{}{} + } + if stat.IsDir() { + if sha256 != "" && len(artifactNames) > 1 { + return fmt.Errorf("spec.k0s.airgap.sha256 cannot be used with a local directory source that requires multiple airgap bundles") + } + if err := validateLocalBundleDirectory(sourcePath, artifactNames); err != nil { + return err + } + return nil + } + if len(artifactNames) > 1 { + return fmt.Errorf("spec.k0s.airgap.path points to a single file but planned hosts require multiple airgap bundles; use a directory containing per-architecture bundles") + } + return nil +} + +func validateLocalBundleDirectory(sourcePath string, artifactNames map[string]struct{}) error { + names := make([]string, 0, len(artifactNames)) + for name := range artifactNames { + names = append(names, name) + } + sort.Strings(names) + + var missing []string + for _, name := range names { + bundlePath := filepath.Join(sourcePath, name) + stat, err := os.Stat(bundlePath) + if os.IsNotExist(err) { + missing = append(missing, name) + continue + } + if err != nil { + return fmt.Errorf("stat local airgap bundle %s: %w", bundlePath, err) + } + if stat.IsDir() { + return fmt.Errorf("local airgap bundle %s is a directory", bundlePath) + } + } + if len(missing) > 0 { + return fmt.Errorf("spec.k0s.airgap.path is missing local airgap bundle(s): %s", strings.Join(missing, ", ")) + } + return nil +} + +// ShouldRun is true when airgap handling is enabled and at least one host needs a bundle. +func (p *AirgapBundles) ShouldRun() bool { + return airgapEnabled(p.Config) && len(p.plans) > 0 +} + +// Run uploads airgap bundles. +func (p *AirgapBundles) Run(ctx context.Context) error { + if err := p.populateCaches(ctx); err != nil { + return err + } + return p.parallelDoUpload(ctx, p.planHosts(), p.uploadForHost) +} + +// DryRun reports planned airgap bundle uploads without downloading large bundles. +func (p *AirgapBundles) DryRun() error { + for _, plan := range p.plans { + p.DryMsgf(plan.Host, "upload airgap bundle %s (%s/%s) => %s", plan.Artifact.Name, plan.Artifact.OS, plan.Artifact.Arch, plan.Destination) + } + return nil +} + +func airgapEnabled(config *v1beta1.Cluster) bool { + return config != nil && + config.Spec != nil && + config.Spec.K0s != nil && + config.Spec.K0s.Airgap != nil && + config.Spec.K0s.Airgap.Enabled +} + +func (p *AirgapBundles) resolver() (airgap.Resolver, error) { + cfg := p.Config.Spec.K0s.Airgap + switch cfg.Source { + case cluster.AirgapSourceAuto: + return airgap.GitHubReleaseResolver{}, nil + case cluster.AirgapSourceURL: + return airgap.URLResolver{Template: cfg.URL, SHA256: cfg.SHA256}, nil + case cluster.AirgapSourceLocal: + return airgap.GitHubReleaseResolver{}, nil + default: + return nil, fmt.Errorf("unsupported airgap source %q", cfg.Source) + } +} + +func (p *AirgapBundles) warnPullPolicy() { + policy := p.Config.Spec.K0s.Config.DigString("spec", "images", "default_pull_policy") + if policy == "Never" { + return + } + log.Warn("airgap is enabled but spec.k0s.config.spec.images.default_pull_policy is not Never") +} + +func (p *AirgapBundles) planHosts() cluster.Hosts { + hosts := make(cluster.Hosts, 0, len(p.plans)) + for _, plan := range p.plans { + hosts = append(hosts, plan.Host) + } + return hosts +} + +func (p *AirgapBundles) indexPlans() { + p.planIndexes = make(map[*cluster.Host]int, len(p.plans)) + for i, plan := range p.plans { + p.planIndexes[plan.Host] = i + } +} + +func (p *AirgapBundles) populateCaches(ctx context.Context) error { + cfg := p.Config.Spec.K0s.Airgap + if cfg.Source == cluster.AirgapSourceLocal { + return nil + } + seen := make(map[string]bool) + for i := range p.plans { + cachePath, err := airgap.CacheFilePath(p.Config.Spec.K0s.Version, p.plans[i].Artifact.OS, p.plans[i].Artifact.Arch, p.plans[i].Artifact.Name) + if err != nil { + return fmt.Errorf("%s: get airgap cache path: %w", p.plans[i].Host, err) + } + if seen[cachePath] { + p.plans[i].LocalPath = cachePath + continue + } + seen[cachePath] = true + localPath, err := airgap.EnsureCached(ctx, p.Config.Spec.K0s.Version, p.plans[i].Artifact) + if err != nil { + return fmt.Errorf("%s: cache airgap bundle: %w", p.plans[i].Host, err) + } + p.plans[i].LocalPath = localPath + } + return nil +} + +func (p *AirgapBundles) uploadForHost(ctx context.Context, h *cluster.Host) error { + planIndex, ok := p.planIndexes[h] + if !ok { + return nil + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("upload airgap bundle canceled: %w", err) + } + return p.uploadBundle(p.plans[planIndex]) +} + +func (p *AirgapBundles) uploadBundle(plan airgap.Plan) error { + localPath, err := p.localPath(plan) + if err != nil { + return fmt.Errorf("resolve local airgap bundle path: %w", err) + } + if err := p.verifyChecksum(localPath, plan.Artifact.SHA256); err != nil { + return err + } + if err := p.ensureImagesDir(plan.Host, path.Dir(plan.Destination)); err != nil { + return err + } + if !plan.Host.FileChanged(localPath, plan.Destination) { + log.Infof("%s: airgap bundle already exists and has not changed, skipping upload", plan.Host) + return nil + } + stat, err := os.Stat(localPath) + if err != nil { + return fmt.Errorf("stat local airgap bundle %s: %w", localPath, err) + } + err = p.Wet(plan.Host, fmt.Sprintf("upload airgap bundle %s => %s", localPath, plan.Destination), func() error { + return plan.Host.Upload(localPath, plan.Destination, stat.Mode(), exec.Sudo(plan.Host), exec.LogError(true)) + }) + if err != nil { + return err + } + return p.Wet(plan.Host, fmt.Sprintf("set permissions for %s to 0644", plan.Destination), func() error { + return chmodWithMode(plan.Host, plan.Destination, fs.FileMode(0o644)) + }) +} + +func (p *AirgapBundles) localPath(plan airgap.Plan) (string, error) { + cfg := p.Config.Spec.K0s.Airgap + if cfg.Source == cluster.AirgapSourceLocal { + localPath, err := airgap.LocalPath(cfg.Path, plan.Artifact.Name) + if err != nil { + return "", err + } + return localPath, nil + } + if plan.LocalPath == "" { + return "", fmt.Errorf("airgap bundle %s was not cached", plan.Artifact.Name) + } + return plan.LocalPath, nil +} + +func (p *AirgapBundles) verifyChecksum(localPath, expected string) error { + if expected == "" { + return nil + } + stat, err := os.Stat(localPath) + if err != nil { + return fmt.Errorf("stat local airgap bundle %s: %w", localPath, err) + } + + key := localPath + "\x00" + expected + current := checksumVerification{ + size: stat.Size(), + modTimeUnixNano: stat.ModTime().UnixNano(), + } + + p.checksumMu.Lock() + defer p.checksumMu.Unlock() + + if p.verifiedChecksums == nil { + p.verifiedChecksums = make(map[string]checksumVerification) + } + if verified, ok := p.verifiedChecksums[key]; ok && verified == current { + return nil + } + if err := airgap.VerifySHA256(localPath, expected); err != nil { + return fmt.Errorf("verify airgap bundle checksum: %w", err) + } + p.verifiedChecksums[key] = current + return nil +} + +func (p *AirgapBundles) ensureImagesDir(h *cluster.Host, dir string) error { + log.Debugf("%s: ensuring airgap image directory %s", h, dir) + if !h.Configurer.FileExist(h, dir) { + err := p.Wet(h, fmt.Sprintf("create airgap image directory %s", dir), func() error { + return h.SudoFsys().MkDirAll(dir, fs.FileMode(0o755)) + }) + if err != nil { + return fmt.Errorf("create airgap image directory %s: %w", dir, err) + } + } + return p.Wet(h, fmt.Sprintf("set permissions for directory %s to 0755", dir), func() error { + return chmodWithMode(h, dir, fs.FileMode(0o755)) + }) +} diff --git a/phase/airgap_bundles_test.go b/phase/airgap_bundles_test.go new file mode 100644 index 00000000..36d8d27a --- /dev/null +++ b/phase/airgap_bundles_test.go @@ -0,0 +1,205 @@ +package phase + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" + linuxcfg "github.com/k0sproject/k0sctl/configurer/linux" + "github.com/k0sproject/k0sctl/pkg/airgap" + v1beta1 "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" + "github.com/k0sproject/version" + "github.com/stretchr/testify/require" +) + +func TestAirgapBundlesPreparePlansWorkerCapableHosts(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + worker := airgapHost("worker", "amd64") + controllerWorker := airgapHost("controller+worker", "arm64") + controllerWorker.DataDir = "/opt/k0s" + single := airgapHost("single", "riscv64") + single.Reset = true + controller := airgapHost("controller", "amd64") + cfg := airgapConfig(k0sVersion, cluster.Hosts{worker, controllerWorker, single, controller}) + + phase := &AirgapBundles{} + require.NoError(t, phase.Prepare(cfg)) + + require.True(t, phase.ShouldRun()) + require.Len(t, phase.plans, 2) + require.Equal(t, worker, phase.plans[0].Host) + require.Equal(t, "/var/lib/k0s/images/k0s-airgap-bundle-v1.34.1+k0s.0-amd64", phase.plans[0].Destination) + require.Equal(t, controllerWorker, phase.plans[1].Host) + require.Equal(t, "/opt/k0s/images/k0s-airgap-bundle-v1.34.1+k0s.0-arm64", phase.plans[1].Destination) + require.Equal(t, 0, phase.planIndexes[worker]) + require.Equal(t, 1, phase.planIndexes[controllerWorker]) +} + +func TestAirgapBundlesDryRunOutput(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + cfg := airgapConfig(k0sVersion, cluster.Hosts{airgapHost("worker", "amd64")}) + var writer bytes.Buffer + manager := Manager{Config: cfg, DryRun: true, Writer: &writer} + manager.AddPhase(&AirgapBundles{}) + + require.NoError(t, manager.Run(context.Background())) + + output := writer.String() + require.Contains(t, output, "dry-run: cluster state altering actions would be performed:") + require.Contains(t, output, "upload airgap bundle k0s-airgap-bundle-v1.34.1+k0s.0-amd64 (linux/amd64) => /var/lib/k0s/images/k0s-airgap-bundle-v1.34.1+k0s.0-amd64") +} + +func TestAirgapBundlesRequiresVersion(t *testing.T) { + cfg := airgapConfig(nil, cluster.Hosts{airgapHost("worker", "amd64")}) + + phase := &AirgapBundles{} + require.ErrorContains(t, phase.Prepare(cfg), "spec.k0s.version is required when airgap is enabled") +} + +func TestAirgapBundlesLocalSourceUsesConfiguredSHA256(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + cfg := airgapConfig(k0sVersion, cluster.Hosts{airgapHost("worker", "amd64")}) + cfg.Spec.K0s.Airgap.Source = cluster.AirgapSourceLocal + bundle := filepath.Join(t.TempDir(), "bundle") + require.NoError(t, os.WriteFile(bundle, []byte("bundle"), 0o644)) + cfg.Spec.K0s.Airgap.Path = bundle + cfg.Spec.K0s.Airgap.SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + phase := &AirgapBundles{} + require.NoError(t, phase.Prepare(cfg)) + require.Len(t, phase.plans, 1) + require.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", phase.plans[0].Artifact.SHA256) +} + +func TestAirgapBundlesLocalSourceFileRejectsMixedBundles(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + bundle := filepath.Join(t.TempDir(), "bundle") + require.NoError(t, os.WriteFile(bundle, []byte("bundle"), 0o644)) + cfg := airgapConfig(k0sVersion, cluster.Hosts{ + airgapHost("worker", "amd64"), + airgapHost("worker", "arm64"), + }) + cfg.Spec.K0s.Airgap.Source = cluster.AirgapSourceLocal + cfg.Spec.K0s.Airgap.Path = bundle + + phase := &AirgapBundles{} + require.ErrorContains(t, phase.Prepare(cfg), "spec.k0s.airgap.path points to a single file but planned hosts require multiple airgap bundles") +} + +func TestAirgapBundlesLocalSourceDirectoryRejectsMissingBundles(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + dir := t.TempDir() + cfg := airgapConfig(k0sVersion, cluster.Hosts{ + airgapHost("worker", "amd64"), + airgapHost("worker", "arm64"), + }) + cfg.Spec.K0s.Airgap.Source = cluster.AirgapSourceLocal + cfg.Spec.K0s.Airgap.Path = dir + require.NoError(t, os.WriteFile(filepath.Join(dir, "k0s-airgap-bundle-v1.34.1+k0s.0-amd64"), []byte("bundle"), 0o644)) + + phase := &AirgapBundles{} + require.ErrorContains(t, phase.Prepare(cfg), "spec.k0s.airgap.path is missing local airgap bundle(s): k0s-airgap-bundle-v1.34.1+k0s.0-arm64") +} + +func TestAirgapBundlesLocalSourceDirectoryRejectsSHA256ForMixedBundles(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + dir := t.TempDir() + cfg := airgapConfig(k0sVersion, cluster.Hosts{ + airgapHost("worker", "amd64"), + airgapHost("worker", "arm64"), + }) + cfg.Spec.K0s.Airgap.Source = cluster.AirgapSourceLocal + cfg.Spec.K0s.Airgap.Path = dir + cfg.Spec.K0s.Airgap.SHA256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + phase := &AirgapBundles{} + require.ErrorContains(t, phase.Prepare(cfg), "spec.k0s.airgap.sha256 cannot be used with a local directory source that requires multiple airgap bundles") +} + +func TestAirgapBundlesVerifyChecksumMemoizesUnchangedBundle(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + content := []byte("bundle") + require.NoError(t, os.WriteFile(bundle, content, 0o644)) + sum := sha256.Sum256(content) + + phase := &AirgapBundles{} + require.NoError(t, phase.verifyChecksum(bundle, fmt.Sprintf("%x", sum))) + require.NoError(t, phase.verifyChecksum(bundle, fmt.Sprintf("%x", sum))) + require.Len(t, phase.verifiedChecksums, 1) +} + +func TestAirgapBundlesPopulateCachesDeduplicatesDownloads(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + oldCacheHome, hadCacheHome := os.LookupEnv("XDG_CACHE_HOME") + require.NoError(t, os.Setenv("XDG_CACHE_HOME", t.TempDir())) + xdg.Reload() + t.Cleanup(func() { + if hadCacheHome { + require.NoError(t, os.Setenv("XDG_CACHE_HOME", oldCacheHome)) + } else { + require.NoError(t, os.Unsetenv("XDG_CACHE_HOME")) + } + xdg.Reload() + }) + + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, err := fmt.Fprint(w, "bundle") + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + artifact := airgap.Artifact{ + Name: "k0s-airgap-bundle-v1.34.1+k0s.0-amd64", + URL: server.URL + "/bundle", + OS: "linux", + Arch: "amd64", + } + phase := &AirgapBundles{ + GenericPhase: GenericPhase{Config: airgapConfig(k0sVersion, nil)}, + plans: []airgap.Plan{ + {Host: airgapHost("worker", "amd64"), Artifact: artifact}, + {Host: airgapHost("worker", "amd64"), Artifact: artifact}, + }, + } + + require.NoError(t, phase.populateCaches(context.Background())) + require.Equal(t, 1, requests) + require.NotEmpty(t, phase.plans[0].LocalPath) + require.Equal(t, phase.plans[0].LocalPath, phase.plans[1].LocalPath) +} + +func airgapConfig(k0sVersion *version.Version, hosts cluster.Hosts) *v1beta1.Cluster { + return &v1beta1.Cluster{ + Spec: &cluster.Spec{ + Hosts: hosts, + K0s: &cluster.K0s{ + Version: k0sVersion, + Airgap: &cluster.Airgap{ + Enabled: true, + Source: cluster.AirgapSourceAuto, + Mode: cluster.AirgapModeUpload, + }, + }, + }, + } +} + +func airgapHost(role, arch string) *cluster.Host { + return &cluster.Host{ + Role: role, + Configurer: &linuxcfg.Ubuntu{}, + Metadata: cluster.HostMetadata{ + Arch: arch, + }, + } +} diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go new file mode 100644 index 00000000..17f7cecc --- /dev/null +++ b/pkg/airgap/airgap.go @@ -0,0 +1,327 @@ +package airgap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/adrg/xdg" + "github.com/k0sproject/k0sctl/internal/download" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" + "github.com/k0sproject/version" + log "github.com/sirupsen/logrus" +) + +// Artifact describes an airgap bundle artifact for a host platform. +type Artifact struct { + Name string + URL string + OS string + Arch string + SHA256 string +} + +// Plan describes one host's airgap bundle placement. +type Plan struct { + Host *cluster.Host + Artifact Artifact + LocalPath string + Destination string +} + +// Resolver resolves airgap bundle artifacts for a platform. +type Resolver interface { + Resolve(k0sVersion *version.Version, osKind, arch string) (Artifact, error) +} + +// GitHubReleaseResolver resolves official k0s release airgap bundles. +type GitHubReleaseResolver struct{} + +// BundleName returns the official k0s airgap bundle filename. +func BundleName(k0sVersion *version.Version, arch string) (string, error) { + if k0sVersion == nil || k0sVersion.IsZero() { + return "", errors.New("k0s version is required") + } + platform, err := BundleArch(arch) + if err != nil { + return "", err + } + return bundleNameForPlatform(k0sVersion, platform), nil +} + +func bundleNameForPlatform(k0sVersion *version.Version, platform string) string { + return fmt.Sprintf("k0s-airgap-bundle-%s-%s", k0sVersion.String(), platform) +} + +// BundleArch maps host architectures to released k0s airgap bundle architectures. +func BundleArch(arch string) (string, error) { + switch arch { + case "amd64", "arm64", "arm", "riscv64": + return arch, nil + default: + return "", fmt.Errorf("unsupported airgap bundle architecture %q", arch) + } +} + +// Resolve resolves an official k0s release artifact. +func (GitHubReleaseResolver) Resolve(k0sVersion *version.Version, osKind, arch string) (Artifact, error) { + if osKind != "linux" { + return Artifact{}, fmt.Errorf("unsupported airgap bundle OS %q", osKind) + } + platform, err := BundleArch(arch) + if err != nil { + return Artifact{}, err + } + if k0sVersion == nil || k0sVersion.IsZero() { + return Artifact{}, errors.New("k0s version is required") + } + name := bundleNameForPlatform(k0sVersion, platform) + return Artifact{ + Name: name, + URL: fmt.Sprintf("https://github.com/k0sproject/k0s/releases/download/%s/%s", url.QueryEscape(k0sVersion.String()), name), + OS: osKind, + Arch: platform, + }, nil +} + +// URLResolver resolves custom URL-template artifacts. +type URLResolver struct { + Template string + SHA256 string +} + +// Resolve resolves a custom URL-template artifact. +func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) (Artifact, error) { + if osKind != "linux" { + return Artifact{}, fmt.Errorf("unsupported airgap bundle OS %q", osKind) + } + platform, err := BundleArch(arch) + if err != nil { + return Artifact{}, err + } + if k0sVersion == nil || k0sVersion.IsZero() { + return Artifact{}, errors.New("k0s version is required") + } + name := bundleNameForPlatform(k0sVersion, platform) + expanded := ExpandURLTemplate(r.Template, k0sVersion, osKind, platform) + artifactName, err := artifactNameFromURL(expanded) + if err != nil { + return Artifact{}, err + } + if artifactName == "" { + artifactName = name + } + return Artifact{ + Name: artifactName, + URL: expanded, + OS: osKind, + Arch: platform, + SHA256: r.SHA256, + }, nil +} + +func artifactNameFromURL(rawURL string) (string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse artifact URL %q: %w", download.RedactedURL(rawURL), urlParseCause(err)) + } + if parsed.Path == "" { + return "", nil + } + artifactName := path.Base(parsed.Path) + if artifactName == "." || artifactName == "/" { + return "", nil + } + if err := validateArtifactName(artifactName); err != nil { + return "", fmt.Errorf("artifact name from URL %q: %w", download.RedactedURL(rawURL), err) + } + return artifactName, nil +} + +func urlParseCause(err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return urlErr.Err + } + return err +} + +func validateArtifactName(name string) error { + if name == "" { + return errors.New("artifact name is required") + } + if name == ".." || strings.ContainsAny(name, `<>:"/\|?*`) { + return fmt.Errorf("invalid artifact name %q", name) + } + for _, r := range name { + if r < 0x20 { + return fmt.Errorf("invalid artifact name %q", name) + } + } + return nil +} + +// ExpandURLTemplate expands k0s-style URL tokens. +func ExpandURLTemplate(template string, k0sVersion *version.Version, osKind, arch string) string { + var versionString string + if k0sVersion != nil { + versionString = url.QueryEscape(k0sVersion.String()) + } + replacer := strings.NewReplacer( + "%%", "\x00", + "%v", versionString, + "%p", arch, + "%o", osKind, + "\x00", "%", + ) + return replacer.Replace(template) +} + +// Destination returns the default bundle destination for a host. +func Destination(h *cluster.Host, artifactName string) string { + return path.Join(h.K0sDataDir(), "images", artifactName) +} + +func isWorkerCapable(h *cluster.Host) bool { + switch h.Role { + case "worker", "controller+worker", "single": + return true + default: + return false + } +} + +// PlanHosts creates airgap placement plans for hosts. +func PlanHosts(hosts cluster.Hosts, k0sVersion *version.Version, resolver Resolver) ([]Plan, error) { + var plans []Plan + for _, h := range hosts { + if h.Reset || !isWorkerCapable(h) { + continue + } + osKind, err := h.OSKind() + if err != nil { + return nil, fmt.Errorf("%s: get OS kind: %w", h, err) + } + if osKind != "linux" { + continue + } + arch, err := h.Arch() + if err != nil { + return nil, fmt.Errorf("%s: get architecture: %w", h, err) + } + artifact, err := resolver.Resolve(k0sVersion, osKind, arch) + if err != nil { + return nil, fmt.Errorf("%s: resolve airgap bundle: %w", h, err) + } + if err := validateArtifactName(artifact.Name); err != nil { + return nil, fmt.Errorf("%s: resolve airgap bundle: %w", h, err) + } + plans = append(plans, Plan{ + Host: h, + Artifact: artifact, + Destination: Destination(h, artifact.Name), + }) + } + return plans, nil +} + +// CacheFilePath returns the XDG cache path for an airgap artifact. +func CacheFilePath(k0sVersion *version.Version, osKind, arch, artifactName string) (string, error) { + if k0sVersion == nil || k0sVersion.IsZero() { + return "", errors.New("k0s version is required") + } + if err := validateArtifactName(artifactName); err != nil { + return "", err + } + fn := path.Join("k0sctl", "airgap", strings.TrimPrefix(k0sVersion.String(), "v"), osKind, arch, artifactName) + if cached, err := xdg.SearchCacheFile(fn); err == nil { + return cached, nil + } + return xdg.CacheFile(fn) +} + +// EnsureCached downloads an artifact to the local XDG cache when needed. +func EnsureCached(ctx context.Context, k0sVersion *version.Version, artifact Artifact) (string, error) { + dest, err := CacheFilePath(k0sVersion, artifact.OS, artifact.Arch, artifact.Name) + if err != nil { + return "", err + } + if _, err := os.Stat(dest); err == nil { + if artifact.SHA256 != "" { + if err := VerifySHA256(dest, artifact.SHA256); err != nil { + log.Warnf("cached airgap bundle %s failed checksum verification, removing it: %v", dest, err) + if removeErr := os.Remove(dest); removeErr != nil { + return "", fmt.Errorf("remove invalid cached airgap bundle %s after checksum failure: %w", dest, removeErr) + } + } else { + return dest, nil + } + } else { + return dest, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("stat airgap cache path %s: %w", dest, err) + } + if artifact.URL == "" { + return "", errors.New("artifact URL is required") + } + log.Infof("downloading k0s airgap bundle %s for %s-%s", artifact.Name, artifact.OS, artifact.Arch) + if err := download.ToFile(ctx, artifact.URL, dest); err != nil { + return "", fmt.Errorf("download airgap bundle: %w", err) + } + if artifact.SHA256 != "" { + if err := VerifySHA256(dest, artifact.SHA256); err != nil { + if removeErr := os.Remove(dest); removeErr != nil && !os.IsNotExist(removeErr) { + return "", fmt.Errorf("remove invalid downloaded airgap bundle %s after checksum failure: %w", dest, removeErr) + } + return "", fmt.Errorf("verify downloaded airgap bundle: %w", err) + } + } + return dest, nil +} + +// VerifySHA256 checks a file against an expected SHA-256 hex digest. +func VerifySHA256(filePath, expected string) error { + expected = strings.TrimSpace(strings.ToLower(expected)) + if expected == "" { + return nil + } + file, err := os.Open(filePath) + if err != nil { + return err + } + defer func() { + if err := file.Close(); err != nil { + log.Warnf("failed to close %s: %v", filePath, err) + } + }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if actual != expected { + return fmt.Errorf("sha256 mismatch for %s: got %s, want %s", filePath, actual, expected) + } + return nil +} + +// LocalPath resolves a local airgap source path for an artifact. +func LocalPath(sourcePath, artifactName string) (string, error) { + stat, err := os.Stat(sourcePath) + if err != nil { + return "", err + } + if stat.IsDir() { + return filepath.Join(sourcePath, artifactName), nil + } + return sourcePath, nil +} diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go new file mode 100644 index 00000000..1505875e --- /dev/null +++ b/pkg/airgap/airgap_test.go @@ -0,0 +1,235 @@ +package airgap + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" + "github.com/k0sproject/k0sctl/configurer" + linuxcfg "github.com/k0sproject/k0sctl/configurer/linux" + "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster" + "github.com/k0sproject/version" + "github.com/stretchr/testify/require" +) + +func TestBundleArch(t *testing.T) { + for _, arch := range []string{"amd64", "arm64", "arm", "riscv64"} { + got, err := BundleArch(arch) + require.NoError(t, err) + require.Equal(t, arch, got) + } + + _, err := BundleArch("ppc64le") + require.ErrorContains(t, err, `unsupported airgap bundle architecture "ppc64le"`) +} + +func TestGitHubReleaseResolverResolve(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + + artifact, err := (GitHubReleaseResolver{}).Resolve(k0sVersion, "linux", "amd64") + require.NoError(t, err) + require.Equal(t, "k0s-airgap-bundle-v1.34.1+k0s.0-amd64", artifact.Name) + require.Equal(t, "https://github.com/k0sproject/k0s/releases/download/v1.34.1%2Bk0s.0/k0s-airgap-bundle-v1.34.1+k0s.0-amd64", artifact.URL) + require.Equal(t, "linux", artifact.OS) + require.Equal(t, "amd64", artifact.Arch) +} + +func TestURLResolverResolveExpandsTokens(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + resolver := URLResolver{ + Template: "https://mirror.example.invalid/%o/%p/k0s-%v.tar?token=redacted", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + + artifact, err := resolver.Resolve(k0sVersion, "linux", "arm64") + require.NoError(t, err) + require.Equal(t, "k0s-v1.34.1+k0s.0.tar", artifact.Name) + require.Equal(t, "https://mirror.example.invalid/linux/arm64/k0s-v1.34.1%2Bk0s.0.tar?token=redacted", artifact.URL) + require.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", artifact.SHA256) +} + +func TestURLResolverRejectsUnsafeArtifactName(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + + for _, template := range []string{ + "https://mirror.example.invalid/%zz", + "https://mirror.example.invalid/%o/%p/..", + "https://mirror.example.invalid/%o/%p/bad:name", + "https://mirror.example.invalid/%o/%p/bad%3Fname", + `https://mirror.example.invalid/%o/%p/bad\name`, + } { + _, err := (URLResolver{Template: template}).Resolve(k0sVersion, "linux", "amd64") + require.Error(t, err) + } +} + +func TestURLResolverRedactsURLInArtifactNameErrors(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + + _, err := (URLResolver{Template: "https://user:pass@mirror.example.invalid/%zz?token=secret"}).Resolve(k0sVersion, "linux", "amd64") + require.Error(t, err) + require.NotContains(t, err.Error(), "token=secret") + require.NotContains(t, err.Error(), "user:pass") + + _, err = (URLResolver{Template: "https://user:pass@mirror.example.invalid/bad:name?token=secret"}).Resolve(k0sVersion, "linux", "amd64") + require.Error(t, err) + require.NotContains(t, err.Error(), "token=secret") + require.NotContains(t, err.Error(), "user:pass") +} + +func TestPlanHostsSelectsWorkerCapableLinuxHosts(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + hosts := cluster.Hosts{ + host("controller", "amd64", &linuxcfg.Ubuntu{}), + host("worker", "amd64", &linuxcfg.Ubuntu{}), + host("controller+worker", "arm64", &linuxcfg.Ubuntu{}), + host("single", "riscv64", &linuxcfg.Ubuntu{}), + host("worker", "amd64", &testConfigurer{osKind: "windows"}), + } + hosts[2].DataDir = "/opt/k0s" + hosts[3].Reset = true + + plans, err := PlanHosts(hosts, k0sVersion, GitHubReleaseResolver{}) + require.NoError(t, err) + require.Len(t, plans, 2) + require.Equal(t, hosts[1], plans[0].Host) + require.Equal(t, "/var/lib/k0s/images/k0s-airgap-bundle-v1.34.1+k0s.0-amd64", plans[0].Destination) + require.Equal(t, hosts[2], plans[1].Host) + require.Equal(t, "/opt/k0s/images/k0s-airgap-bundle-v1.34.1+k0s.0-arm64", plans[1].Destination) +} + +func TestCacheFilePath(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + + got, err := CacheFilePath(k0sVersion, "linux", "amd64", "bundle") + require.NoError(t, err) + require.Contains(t, got, filepath.Join("k0sctl", "airgap", "1.34.1+k0s.0", "linux", "amd64", "bundle")) +} + +func TestVerifySHA256(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "bundle") + content := []byte("airgap bundle") + require.NoError(t, os.WriteFile(file, content, 0o644)) + sum := sha256.Sum256(content) + + require.NoError(t, VerifySHA256(file, fmt.Sprintf("%x", sum))) + require.ErrorContains(t, VerifySHA256(file, "0000"), "sha256 mismatch") +} + +func TestEnsureCachedReplacesInvalidCachedBundle(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + oldCacheHome, hadCacheHome := os.LookupEnv("XDG_CACHE_HOME") + require.NoError(t, os.Setenv("XDG_CACHE_HOME", t.TempDir())) + xdg.Reload() + t.Cleanup(func() { + if hadCacheHome { + require.NoError(t, os.Setenv("XDG_CACHE_HOME", oldCacheHome)) + } else { + require.NoError(t, os.Unsetenv("XDG_CACHE_HOME")) + } + xdg.Reload() + }) + + content := []byte("good bundle") + sum := sha256.Sum256(content) + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, err := w.Write(content) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + artifact := Artifact{ + Name: "bundle", + URL: server.URL + "/bundle", + OS: "linux", + Arch: "amd64", + SHA256: fmt.Sprintf("%x", sum), + } + cachePath, err := CacheFilePath(k0sVersion, artifact.OS, artifact.Arch, artifact.Name) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(cachePath), 0o755)) + require.NoError(t, os.WriteFile(cachePath, []byte("bad bundle"), 0o644)) + + got, err := EnsureCached(context.Background(), k0sVersion, artifact) + require.NoError(t, err) + require.Equal(t, cachePath, got) + require.Equal(t, 1, requests) + require.NoError(t, VerifySHA256(cachePath, artifact.SHA256)) +} + +func TestEnsureCachedRemovesInvalidDownloadedBundle(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + oldCacheHome, hadCacheHome := os.LookupEnv("XDG_CACHE_HOME") + require.NoError(t, os.Setenv("XDG_CACHE_HOME", t.TempDir())) + xdg.Reload() + t.Cleanup(func() { + if hadCacheHome { + require.NoError(t, os.Setenv("XDG_CACHE_HOME", oldCacheHome)) + } else { + require.NoError(t, os.Unsetenv("XDG_CACHE_HOME")) + } + xdg.Reload() + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("bad bundle")) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + artifact := Artifact{ + Name: "bundle", + URL: server.URL + "/bundle", + OS: "linux", + Arch: "amd64", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + cachePath, err := CacheFilePath(k0sVersion, artifact.OS, artifact.Arch, artifact.Name) + require.NoError(t, err) + + _, err = EnsureCached(context.Background(), k0sVersion, artifact) + require.ErrorContains(t, err, "verify downloaded airgap bundle") + require.NoFileExists(t, cachePath) +} + +func TestLocalPath(t *testing.T) { + dir := t.TempDir() + bundle := filepath.Join(dir, "bundle") + require.NoError(t, os.WriteFile(bundle, []byte("data"), 0o644)) + + got, err := LocalPath(dir, "bundle") + require.NoError(t, err) + require.Equal(t, bundle, got) + + got, err = LocalPath(bundle, "ignored") + require.NoError(t, err) + require.Equal(t, bundle, got) +} + +type testConfigurer struct { + linuxcfg.Ubuntu + osKind string +} + +func (c *testConfigurer) OSKind() string { + return c.osKind +} + +func host(role, arch string, cfg configurer.Configurer) *cluster.Host { + return &cluster.Host{ + Role: role, + Configurer: cfg, + Metadata: cluster.HostMetadata{ + Arch: arch, + }, + } +} diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go new file mode 100644 index 00000000..c8273ddc --- /dev/null +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go @@ -0,0 +1,101 @@ +package cluster + +import ( + "encoding/hex" + "fmt" + "path/filepath" + + "github.com/creasty/defaults" + "github.com/jellydator/validation" +) + +const ( + AirgapSourceAuto = "auto" + AirgapSourceLocal = "local" + AirgapSourceURL = "url" + + AirgapModeUpload = "upload" + AirgapModeRemoteDownload = "remoteDownload" +) + +// Airgap configures native k0s airgap bundle handling. +type Airgap struct { + Enabled bool `yaml:"enabled,omitempty" default:"false"` + Source string `yaml:"source,omitempty"` + Mode string `yaml:"mode,omitempty"` + Path string `yaml:"path,omitempty"` + URL string `yaml:"url,omitempty"` + SHA256 string `yaml:"sha256,omitempty"` +} + +// SetDefaults sets airgap defaults when airgap handling is enabled. +func (a *Airgap) SetDefaults() { + if a == nil || !a.Enabled { + return + } + if defaults.CanUpdate(a.Source) { + a.Source = AirgapSourceAuto + } + if defaults.CanUpdate(a.Mode) { + a.Mode = AirgapModeUpload + } +} + +// Validate checks airgap configuration. +func (a *Airgap) Validate() error { + if a == nil || !a.Enabled { + return nil + } + a.SetDefaults() + if err := validation.ValidateStruct(a, + validation.Field(&a.Source, validation.Required, validation.In(AirgapSourceAuto, AirgapSourceLocal, AirgapSourceURL)), + validation.Field(&a.Mode, validation.Required, validation.In(AirgapModeUpload, AirgapModeRemoteDownload)), + validation.Field(&a.Path, validation.Required.When(a.Source == AirgapSourceLocal)), + validation.Field(&a.URL, validation.Required.When(a.Source == AirgapSourceURL)), + validation.Field(&a.SHA256, validation.By(validateSHA256), validation.By(validateSHA256Source(a.Source))), + ); err != nil { + return err + } + if a.Mode == AirgapModeRemoteDownload { + return fmt.Errorf("mode %q is not supported yet", AirgapModeRemoteDownload) + } + return nil +} + +func validateSHA256(value any) error { + checksum, ok := value.(string) + if !ok { + return fmt.Errorf("not a string") + } + if checksum == "" { + return nil + } + if len(checksum) != 64 { + return fmt.Errorf("must be 64 hex characters") + } + if _, err := hex.DecodeString(checksum); err != nil { + return fmt.Errorf("must be 64 hex characters") + } + return nil +} + +func validateSHA256Source(source string) validation.RuleFunc { + return func(value any) error { + checksum, ok := value.(string) + if !ok { + return fmt.Errorf("not a string") + } + if source == AirgapSourceAuto && checksum != "" { + return fmt.Errorf("must be empty when source is %q", AirgapSourceAuto) + } + return nil + } +} + +// Resolve prepares path-based airgap configuration after unmarshalling. +func (a *Airgap) Resolve(baseDir string) { + if a == nil || a.Path == "" || filepath.IsAbs(a.Path) || baseDir == "" { + return + } + a.Path = filepath.Join(baseDir, a.Path) +} diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go index 9128ac9b..de0dcbea 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go @@ -33,6 +33,7 @@ type K0s struct { Version *version.Version `yaml:"version,omitempty"` VersionChannel string `yaml:"versionChannel,omitempty"` DynamicConfig bool `yaml:"dynamicConfig,omitempty" default:"false"` + Airgap *Airgap `yaml:"airgap,omitempty"` Config dig.Mapping `yaml:"config,omitempty"` Metadata K0sMetadata `yaml:"-"` } @@ -77,13 +78,12 @@ func (k *K0s) MarshalYAML() (any, error) { // SetDefaults sets default values func (k *K0s) SetDefaults() { - if k.Version == nil { - return - } - - if k.Version.IsZero() { + if k.Version != nil && k.Version.IsZero() { k.Version = nil } + if k.Airgap != nil { + k.Airgap.SetDefaults() + } } func validateVersion(value any) error { @@ -104,11 +104,15 @@ func validateVersion(value any) error { } func (k *K0s) Validate() error { - return validation.ValidateStruct(k, + if err := validation.ValidateStruct(k, validation.Field(&k.Version, validation.By(validateVersion)), validation.Field(&k.DynamicConfig, validation.By(k.validateMinDynamic())), validation.Field(&k.VersionChannel, validation.In("stable", "latest"), validation.When(k.VersionChannel != "")), - ) + validation.Field(&k.Airgap), + ); err != nil { + return err + } + return nil } func (k *K0s) validateMinDynamic() func(any) error { diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go index 17f42058..a9776b5a 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go @@ -44,6 +44,56 @@ func TestVersionDefaulting(t *testing.T) { }) } +func TestAirgapDefaults(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true}} + + require.NoError(t, defaults.Set(k0s)) + require.NoError(t, k0s.Validate()) + require.Equal(t, AirgapSourceAuto, k0s.Airgap.Source) + require.Equal(t, AirgapModeUpload, k0s.Airgap.Mode) +} + +func TestAirgapValidateSetsDefaults(t *testing.T) { + airgap := &Airgap{Enabled: true} + + require.NoError(t, airgap.Validate()) + require.Equal(t, AirgapSourceAuto, airgap.Source) + require.Equal(t, AirgapModeUpload, airgap.Mode) +} + +func TestAirgapValidation(t *testing.T) { + t.Run("local requires path", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, Source: AirgapSourceLocal}} + require.ErrorContains(t, k0s.Validate(), "Path: cannot be blank") + }) + + t.Run("url requires url", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, Source: AirgapSourceURL}} + require.ErrorContains(t, k0s.Validate(), "URL: cannot be blank") + }) + + t.Run("invalid source", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, Source: "other"}} + require.ErrorContains(t, k0s.Validate(), "Source: must be a valid value") + }) + + t.Run("remote download deferred", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, Mode: AirgapModeRemoteDownload}} + require.ErrorContains(t, k0s.Validate(), `mode "remoteDownload" is not supported yet`) + }) + + t.Run("invalid sha256", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, Source: AirgapSourceURL, URL: "https://example.invalid/bundle", SHA256: "abc123"}} + require.ErrorContains(t, k0s.Validate(), "SHA256: must be 64 hex characters") + }) + + t.Run("auto source rejects sha256", func(t *testing.T) { + k0s := &K0s{Version: version.MustParse("v1.34.1+k0s.0"), Airgap: &Airgap{Enabled: true, SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}} + require.ErrorContains(t, k0s.Validate(), `SHA256: must be empty when source is "auto"`) + }) + +} + func TestNodeConfigUsesLowercaseMetadataKey(t *testing.T) { k0s := &K0s{ Config: dig.Mapping{ diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/spec.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/spec.go index bb296d46..d3a44840 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/spec.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/spec.go @@ -54,6 +54,9 @@ func isEmptyK0s(k *K0s) bool { if k.Version != nil { return false } + if k.Airgap != nil && k.Airgap.Enabled { + return false + } return len(k.Config) == 0 } @@ -108,6 +111,9 @@ func (s *Spec) ResolveUploadFilePaths(baseDir string) error { // Resolve prepares spec-level data after unmarshalling by cascading to hosts. func (s *Spec) Resolve(baseDir string) error { + if s.K0s != nil && s.K0s.Airgap != nil { + s.K0s.Airgap.Resolve(baseDir) + } return s.ResolveUploadFilePaths(baseDir) } diff --git a/pkg/k0s/binprovider/local_upload.go b/pkg/k0s/binprovider/local_upload.go index d446d005..d036829d 100644 --- a/pkg/k0s/binprovider/local_upload.go +++ b/pkg/k0s/binprovider/local_upload.go @@ -4,16 +4,12 @@ import ( "context" "errors" "fmt" - "io" - "net" - "net/http" "os" "path" - "path/filepath" "strings" - "time" "github.com/adrg/xdg" + "github.com/k0sproject/k0sctl/internal/download" "github.com/k0sproject/k0sctl/pkg/k0s" "github.com/k0sproject/version" log "github.com/sirupsen/logrus" @@ -28,20 +24,6 @@ type localUpload struct { var _ k0s.BinaryCacher = (*localUpload)(nil) -var downloadHTTPClient = &http.Client{ - Timeout: 10 * time.Minute, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - ResponseHeaderTimeout: 30 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: time.Second, - }, -} - func cacheFilePath(osKind, arch string, v *version.Version) (string, error) { ext := "" if osKind == "windows" { @@ -93,72 +75,13 @@ func (p *localUpload) EnsureCached(ctx context.Context) error { } url := p.target.DownloadURL(osKind, arch) log.Infof("downloading k0s %s binary for %s-%s", p.target, osKind, arch) - if err := downloadToFile(ctx, url, dest); err != nil { + if err := download.ToFile(ctx, url, dest); err != nil { return fmt.Errorf("download k0s binary: %w", err) } log.Debugf("cached k0s binary to %s", dest) return nil } -func downloadToFile(ctx context.Context, url, dest string) (retErr error) { - dir := filepath.Dir(dest) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmpFile, err := os.CreateTemp(dir, filepath.Base(dest)+".tmp-") - if err != nil { - return err - } - tmpPath := tmpFile.Name() - defer func() { - if tmpFile != nil { - if err := tmpFile.Close(); err != nil && retErr == nil { - retErr = err - } - } - if retErr != nil { - if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { - log.Warnf("failed to remove partial download at %s: %v", tmpPath, err) - } - } - }() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := downloadHTTPClient.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil && retErr == nil { - retErr = err - } - }() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected http status %s from %s", resp.Status, url) - } - if _, err := io.Copy(tmpFile, resp.Body); err != nil { - return err - } - if err := tmpFile.Sync(); err != nil { - return err - } - if err := tmpFile.Close(); err != nil { - tmpFile = nil - return err - } - tmpFile = nil - // os.Rename is atomic on Unix (replaces dest if it exists), so concurrent runs are safe. - // On Windows it fails if dest already exists; two simultaneous k0sctl processes targeting - // the same version/arch could race here. We intentionally propagate that error rather than - // silently accepting whatever file is at dest, which would be a TOCTOU risk. - if err := os.Rename(tmpPath, dest); err != nil { - return err - } - return nil -} - func (p *localUpload) IsUpload() bool { return true } func (p *localUpload) NeedsUpgrade() bool {