From c4e0cca6b4af55f6b620fd0f035e6e0c1bebebbe Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Thu, 7 May 2026 11:36:43 +0300 Subject: [PATCH 01/11] Add native airgap bundle support Add native handling for k0s airgap bundles under spec.k0s.airgap so users do not need to model the official bundle upload manually with spec.hosts[*].files. This MVP adds: - schema, defaults, and validation for enabled, source, mode, path, url, and sha256 - support for source: auto, source: local, and source: url in upload mode - official release bundle resolution from spec.k0s.version - local XDG cache storage and per-platform download deduplication - SHA-256 validation for local and custom URL sources - Linux worker-capable host planning for worker, controller+worker, and single roles - upload of bundles to /images before worker install or upgrade work - dry-run output that reports per-host artifact and destination plans - README documentation for native airgap usage and the remaining files: escape hatch Planned follow-up PRs: - add smoke coverage with local fake or cached bundles, likely after improving smoke-test fixtures - support mode: remoteDownload for mirror-backed environments - support sha256File for release and mirror workflows - add destinationDir and upload-only validation controls for advanced deployments - add cleanup policy for bundles previously placed by k0sctl without deleting arbitrary image files - add per-host airgap overrides once a concrete use case exists - support extra addon/workload bundles while keeping the official k0s bundle as the default path Signed-off-by: Kimmo Lehto --- README.md | 37 ++ action/apply.go | 1 + action/apply_test.go | 22 ++ phase/airgap_bundles.go | 228 ++++++++++++ phase/airgap_bundles_test.go | 144 ++++++++ pkg/airgap/airgap.go | 342 ++++++++++++++++++ pkg/airgap/airgap_test.go | 124 +++++++ .../v1beta1/cluster/airgap.go | 88 +++++ .../v1beta1/cluster/k0s.go | 11 +- .../v1beta1/cluster/k0s_test.go | 44 +++ .../v1beta1/cluster/spec.go | 6 + 11 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 action/apply_test.go create mode 100644 phase/airgap_bundles.go create mode 100644 phase/airgap_bundles_test.go create mode 100644 pkg/airgap/airgap.go create mode 100644 pkg/airgap/airgap_test.go create mode 100644 pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go 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..9568c8ca --- /dev/null +++ b/action/apply_test.go @@ -0,0 +1,22 @@ +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() + + require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(initializeK0s)) + require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(installControllers)) + require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(installWorkers)) + require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(upgradeWorkers)) +} diff --git a/phase/airgap_bundles.go b/phase/airgap_bundles.go new file mode 100644 index 00000000..9d731c16 --- /dev/null +++ b/phase/airgap_bundles.go @@ -0,0 +1,228 @@ +package phase + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + + "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 +} + +// 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 + } + } + p.plans = plans + p.indexPlans() + p.warnPullPolicy() + 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 + } + if err := airgap.VerifySHA256(localPath, expected); err != nil { + return fmt.Errorf("verify airgap bundle checksum: %w", err) + } + 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..6a24a362 --- /dev/null +++ b/phase/airgap_bundles_test.go @@ -0,0 +1,144 @@ +package phase + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "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 + cfg.Spec.K0s.Airgap.Path = t.TempDir() + 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 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..3b2c6cce --- /dev/null +++ b/pkg/airgap/airgap.go @@ -0,0 +1,342 @@ +package airgap + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/adrg/xdg" + "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{} + +var downloadHTTPClient = &http.Client{ + Timeout: 10 * time.Minute, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + ResponseHeaderTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + }, +} + +// 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 fmt.Sprintf("k0s-airgap-bundle-%s-%s", k0sVersion.String(), platform), nil +} + +// 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 + } + name, err := BundleName(k0sVersion, platform) + if err != nil { + return Artifact{}, err + } + 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 + } + name, err := BundleName(k0sVersion, platform) + if err != nil { + return Artifact{}, err + } + expanded := ExpandURLTemplate(r.Template, k0sVersion, osKind, platform) + artifactName := artifactNameFromURL(expanded) + if artifactName == "" { + artifactName = name + } + return Artifact{ + Name: artifactName, + URL: expanded, + OS: osKind, + Arch: platform, + SHA256: r.SHA256, + }, nil +} + +func artifactNameFromURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Path == "" { + return "" + } + artifactName := path.Base(parsed.Path) + if artifactName == "." || artifactName == "/" { + return "" + } + return artifactName +} + +// 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) + } + 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 artifactName == "" { + return "", errors.New("artifact name is required") + } + fn := filepath.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 { + return "", fmt.Errorf("verify cached airgap bundle: %w", err) + } + } + 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 := downloadToFile(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 { + return "", fmt.Errorf("verify downloaded airgap bundle: %w", err) + } + } + return dest, 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 airgap 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 + return os.Rename(tmpPath, dest) +} + +// VerifySHA256 checks a file against an expected SHA-256 hex digest. +func VerifySHA256(path, expected string) error { + expected = strings.TrimSpace(strings.ToLower(expected)) + if expected == "" { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + defer func() { + if err := file.Close(); err != nil { + log.Warnf("failed to close %s: %v", path, 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", path, 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..7ac0c48b --- /dev/null +++ b/pkg/airgap/airgap_test.go @@ -0,0 +1,124 @@ +package airgap + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "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: "abc123", + } + + 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, "abc123", artifact.SHA256) +} + +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 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..2cef463d --- /dev/null +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go @@ -0,0 +1,88 @@ +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)), + ); 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 +} + +// 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..709b53a5 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 { @@ -108,6 +108,7 @@ func (k *K0s) Validate() error { 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), ) } 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..3e4fc95f 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,50 @@ func TestVersionDefaulting(t *testing.T) { }) } +func TestAirgapDefaults(t *testing.T) { + k0s := &K0s{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{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{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{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{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{Airgap: &Airgap{Enabled: true, SHA256: "abc123"}} + require.ErrorContains(t, k0s.Validate(), "SHA256: must be 64 hex characters") + }) +} + 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) } From e5be617a88c2ff96246aeba28a0796b387e215fc Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Thu, 7 May 2026 15:22:01 +0300 Subject: [PATCH 02/11] fix: tighten airgap checksum handling Signed-off-by: Kimmo Lehto --- pkg/airgap/airgap.go | 10 +++- pkg/airgap/airgap_test.go | 47 +++++++++++++++++++ .../v1beta1/cluster/airgap.go | 15 +++++- .../v1beta1/cluster/k0s.go | 10 +++- .../v1beta1/cluster/k0s_test.go | 22 ++++++--- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 3b2c6cce..9ae5ef1f 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -229,10 +229,16 @@ func EnsureCached(ctx context.Context, k0sVersion *version.Version, artifact Art if _, err := os.Stat(dest); err == nil { if artifact.SHA256 != "" { if err := VerifySHA256(dest, artifact.SHA256); err != nil { - return "", fmt.Errorf("verify cached airgap bundle: %w", err) + 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 } - return dest, nil } else if !errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("stat airgap cache path %s: %w", dest, err) } diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index 7ac0c48b..5a99a628 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -1,12 +1,16 @@ 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" @@ -90,6 +94,49 @@ func TestVerifySHA256(t *testing.T) { 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 TestLocalPath(t *testing.T) { dir := t.TempDir() bundle := filepath.Join(dir, "bundle") diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go index 2cef463d..c8273ddc 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/airgap.go @@ -52,7 +52,7 @@ func (a *Airgap) Validate() error { 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.Field(&a.SHA256, validation.By(validateSHA256), validation.By(validateSHA256Source(a.Source))), ); err != nil { return err } @@ -79,6 +79,19 @@ func validateSHA256(value any) error { 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 == "" { diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go index 709b53a5..50024579 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go @@ -104,12 +104,18 @@ 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 + } + if k.Airgap != nil && k.Airgap.Enabled && (k.Version == nil || k.Version.IsZero()) { + return fmt.Errorf("version is required when airgap is enabled") + } + 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 3e4fc95f..40ca71c7 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go @@ -45,7 +45,7 @@ func TestVersionDefaulting(t *testing.T) { } func TestAirgapDefaults(t *testing.T) { - k0s := &K0s{Airgap: &Airgap{Enabled: true}} + 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()) @@ -63,29 +63,39 @@ func TestAirgapValidateSetsDefaults(t *testing.T) { func TestAirgapValidation(t *testing.T) { t.Run("local requires path", func(t *testing.T) { - k0s := &K0s{Airgap: &Airgap{Enabled: true, Source: AirgapSourceLocal}} + 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{Airgap: &Airgap{Enabled: true, Source: AirgapSourceURL}} + 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{Airgap: &Airgap{Enabled: true, Source: "other"}} + 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{Airgap: &Airgap{Enabled: true, Mode: AirgapModeRemoteDownload}} + 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{Airgap: &Airgap{Enabled: true, SHA256: "abc123"}} + 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"`) + }) + + t.Run("airgap requires version", func(t *testing.T) { + k0s := &K0s{Airgap: &Airgap{Enabled: true}} + require.ErrorContains(t, k0s.Validate(), "version is required when airgap is enabled") + }) } func TestNodeConfigUsesLowercaseMetadataKey(t *testing.T) { From 2f49f224742b396624f66cf804028c92808b0acf Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 14:22:59 +0300 Subject: [PATCH 03/11] fix: share bundle download handling Signed-off-by: Kimmo Lehto --- internal/download/download.go | 88 +++++++++++++++++++ pkg/airgap/airgap.go | 88 ++++--------------- pkg/airgap/airgap_test.go | 35 ++++++++ .../v1beta1/cluster/k0s.go | 2 +- .../v1beta1/cluster/k0s_test.go | 2 +- pkg/k0s/binprovider/local_upload.go | 81 +---------------- 6 files changed, 143 insertions(+), 153 deletions(-) create mode 100644 internal/download/download.go diff --git a/internal/download/download.go b/internal/download/download.go new file mode 100644 index 00000000..d8c68d49 --- /dev/null +++ b/internal/download/download.go @@ -0,0 +1,88 @@ +package download + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "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 url to dest using a temporary file in the destination directory. +func ToFile(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 := httpClient.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 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 +} diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 9ae5ef1f..4cd50274 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -7,15 +7,14 @@ import ( "errors" "fmt" "io" - "net/http" "net/url" "os" "path" "path/filepath" "strings" - "time" "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" @@ -46,16 +45,6 @@ type Resolver interface { // GitHubReleaseResolver resolves official k0s release airgap bundles. type GitHubReleaseResolver struct{} -var downloadHTTPClient = &http.Client{ - Timeout: 10 * time.Minute, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - ResponseHeaderTimeout: 30 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: time.Second, - }, -} - // BundleName returns the official k0s airgap bundle filename. func BundleName(k0sVersion *version.Version, arch string) (string, error) { if k0sVersion == nil || k0sVersion.IsZero() { @@ -65,7 +54,11 @@ func BundleName(k0sVersion *version.Version, arch string) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("k0s-airgap-bundle-%s-%s", k0sVersion.String(), platform), nil + 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. @@ -87,10 +80,10 @@ func (GitHubReleaseResolver) Resolve(k0sVersion *version.Version, osKind, arch s if err != nil { return Artifact{}, err } - name, err := BundleName(k0sVersion, platform) - 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), @@ -114,10 +107,10 @@ func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) ( if err != nil { return Artifact{}, err } - name, err := BundleName(k0sVersion, platform) - 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 := artifactNameFromURL(expanded) if artifactName == "" { @@ -246,69 +239,20 @@ func EnsureCached(ctx context.Context, k0sVersion *version.Version, artifact Art 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 := downloadToFile(ctx, artifact.URL, dest); err != nil { + 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 } -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 airgap 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 - return os.Rename(tmpPath, dest) -} - // VerifySHA256 checks a file against an expected SHA-256 hex digest. func VerifySHA256(path, expected string) error { expected = strings.TrimSpace(strings.ToLower(expected)) diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index 5a99a628..add873ac 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -137,6 +137,41 @@ func TestEnsureCachedReplacesInvalidCachedBundle(t *testing.T) { 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") diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go index 50024579..45037d9b 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go @@ -113,7 +113,7 @@ func (k *K0s) Validate() error { return err } if k.Airgap != nil && k.Airgap.Enabled && (k.Version == nil || k.Version.IsZero()) { - return fmt.Errorf("version is required when airgap is enabled") + return fmt.Errorf("spec.k0s.version is required when spec.k0s.airgap.enabled is true") } return nil } 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 40ca71c7..074271ce 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go @@ -94,7 +94,7 @@ func TestAirgapValidation(t *testing.T) { t.Run("airgap requires version", func(t *testing.T) { k0s := &K0s{Airgap: &Airgap{Enabled: true}} - require.ErrorContains(t, k0s.Validate(), "version is required when airgap is enabled") + require.ErrorContains(t, k0s.Validate(), "spec.k0s.version is required when spec.k0s.airgap.enabled is true") }) } 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 { From 7c662189a7cf3e8e3969cd3350e20dc3bd879ee0 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 14:25:17 +0300 Subject: [PATCH 04/11] fix: allow defaulted airgap version Signed-off-by: Kimmo Lehto --- pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go | 3 --- pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go | 4 ---- 2 files changed, 7 deletions(-) diff --git a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go index 45037d9b..de0dcbea 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s.go @@ -112,9 +112,6 @@ func (k *K0s) Validate() error { ); err != nil { return err } - if k.Airgap != nil && k.Airgap.Enabled && (k.Version == nil || k.Version.IsZero()) { - return fmt.Errorf("spec.k0s.version is required when spec.k0s.airgap.enabled is true") - } return nil } 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 074271ce..a9776b5a 100644 --- a/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go +++ b/pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/k0s_test.go @@ -92,10 +92,6 @@ func TestAirgapValidation(t *testing.T) { require.ErrorContains(t, k0s.Validate(), `SHA256: must be empty when source is "auto"`) }) - t.Run("airgap requires version", func(t *testing.T) { - k0s := &K0s{Airgap: &Airgap{Enabled: true}} - require.ErrorContains(t, k0s.Validate(), "spec.k0s.version is required when spec.k0s.airgap.enabled is true") - }) } func TestNodeConfigUsesLowercaseMetadataKey(t *testing.T) { From 9ad72f449cd292b01ece127a217dee932f237153 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 14:59:42 +0300 Subject: [PATCH 05/11] test: harden airgap phase ordering check Signed-off-by: Kimmo Lehto --- action/apply_test.go | 19 +++++++++++++++---- pkg/airgap/airgap.go | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/action/apply_test.go b/action/apply_test.go index 9568c8ca..4079883f 100644 --- a/action/apply_test.go +++ b/action/apply_test.go @@ -15,8 +15,19 @@ func TestApplyIncludesAirgapBeforeWorkerPhases(t *testing.T) { installWorkers := (&phase.InstallWorkers{}).Title() upgradeWorkers := (&phase.UpgradeWorkers{}).Title() - require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(initializeK0s)) - require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(installControllers)) - require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(installWorkers)) - require.Less(t, apply.Phases.Index(airgapPhase), apply.Phases.Index(upgradeWorkers)) + 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/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 4cd50274..1adf5c3d 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -206,7 +206,7 @@ func CacheFilePath(k0sVersion *version.Version, osKind, arch, artifactName strin if artifactName == "" { return "", errors.New("artifact name is required") } - fn := filepath.Join("k0sctl", "airgap", strings.TrimPrefix(k0sVersion.String(), "v"), osKind, arch, artifactName) + fn := path.Join("k0sctl", "airgap", strings.TrimPrefix(k0sVersion.String(), "v"), osKind, arch, artifactName) if cached, err := xdg.SearchCacheFile(fn); err == nil { return cached, nil } From b3793984c7ed1a0e1a86d558df8a4463fe8d1680 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 15:54:52 +0300 Subject: [PATCH 06/11] fix: validate airgap artifact paths Signed-off-by: Kimmo Lehto --- phase/airgap_bundles.go | 24 ++++++++++++++++++++++++ phase/airgap_bundles_test.go | 20 +++++++++++++++++++- pkg/airgap/airgap.go | 33 ++++++++++++++++++++++++++------- pkg/airgap/airgap_test.go | 12 ++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/phase/airgap_bundles.go b/phase/airgap_bundles.go index 9d731c16..8b4137fe 100644 --- a/phase/airgap_bundles.go +++ b/phase/airgap_bundles.go @@ -48,6 +48,9 @@ func (p *AirgapBundles) Prepare(config *v1beta1.Cluster) error { for i := range plans { plans[i].Artifact.SHA256 = config.Spec.K0s.Airgap.SHA256 } + if err := p.validateLocalSourcePath(config.Spec.K0s.Airgap.Path, plans); err != nil { + return err + } } p.plans = plans p.indexPlans() @@ -55,6 +58,27 @@ func (p *AirgapBundles) Prepare(config *v1beta1.Cluster) error { return nil } +func (p *AirgapBundles) validateLocalSourcePath(sourcePath 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) + } + if stat.IsDir() { + return nil + } + artifactNames := make(map[string]struct{}, len(plans)) + for _, plan := range plans { + artifactNames[plan.Artifact.Name] = struct{}{} + } + 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 +} + // 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 diff --git a/phase/airgap_bundles_test.go b/phase/airgap_bundles_test.go index 6a24a362..f2ebc580 100644 --- a/phase/airgap_bundles_test.go +++ b/phase/airgap_bundles_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "testing" "github.com/adrg/xdg" @@ -66,7 +67,9 @@ 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 - cfg.Spec.K0s.Airgap.Path = t.TempDir() + 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{} @@ -75,6 +78,21 @@ func TestAirgapBundlesLocalSourceUsesConfiguredSHA256(t *testing.T) { 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 TestAirgapBundlesPopulateCachesDeduplicatesDownloads(t *testing.T) { k0sVersion := version.MustParse("v1.34.1+k0s.0") oldCacheHome, hadCacheHome := os.LookupEnv("XDG_CACHE_HOME") diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 1adf5c3d..70c9861a 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -112,7 +112,10 @@ func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) ( } name := bundleNameForPlatform(k0sVersion, platform) expanded := ExpandURLTemplate(r.Template, k0sVersion, osKind, platform) - artifactName := artifactNameFromURL(expanded) + artifactName, err := artifactNameFromURL(expanded) + if err != nil { + return Artifact{}, err + } if artifactName == "" { artifactName = name } @@ -125,16 +128,29 @@ func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) ( }, nil } -func artifactNameFromURL(rawURL string) string { +func artifactNameFromURL(rawURL string) (string, error) { parsed, err := url.Parse(rawURL) if err != nil || parsed.Path == "" { - return "" + return "", nil } artifactName := path.Base(parsed.Path) if artifactName == "." || artifactName == "/" { - return "" + return "", nil + } + if err := validateArtifactName(artifactName); err != nil { + return "", fmt.Errorf("artifact name from URL %q: %w", rawURL, err) } - return artifactName + return artifactName, nil +} + +func validateArtifactName(name string) error { + if name == "" { + return errors.New("artifact name is required") + } + if name == ".." || strings.Contains(name, "/") || strings.Contains(name, `\`) { + return fmt.Errorf("invalid artifact name %q", name) + } + return nil } // ExpandURLTemplate expands k0s-style URL tokens. @@ -189,6 +205,9 @@ func PlanHosts(hosts cluster.Hosts, k0sVersion *version.Version, resolver Resolv 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, @@ -203,8 +222,8 @@ func CacheFilePath(k0sVersion *version.Version, osKind, arch, artifactName strin if k0sVersion == nil || k0sVersion.IsZero() { return "", errors.New("k0s version is required") } - if artifactName == "" { - return "", errors.New("artifact name 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 { diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index add873ac..399375a3 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -54,6 +54,18 @@ func TestURLResolverResolveExpandsTokens(t *testing.T) { require.Equal(t, "abc123", artifact.SHA256) } +func TestURLResolverRejectsUnsafeArtifactName(t *testing.T) { + k0sVersion := version.MustParse("v1.34.1+k0s.0") + + for _, template := range []string{ + "https://mirror.example.invalid/%o/%p/..", + `https://mirror.example.invalid/%o/%p/bad\name`, + } { + _, err := (URLResolver{Template: template}).Resolve(k0sVersion, "linux", "amd64") + require.ErrorContains(t, err, "invalid artifact name") + } +} + func TestPlanHostsSelectsWorkerCapableLinuxHosts(t *testing.T) { k0sVersion := version.MustParse("v1.34.1+k0s.0") hosts := cluster.Hosts{ From 54908199a4a1a67ea9aa3489d319cfb507b021a7 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 16:04:34 +0300 Subject: [PATCH 07/11] fix: tighten airgap artifact name validation Signed-off-by: Kimmo Lehto --- pkg/airgap/airgap.go | 12 ++++++++++-- pkg/airgap/airgap_test.go | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 70c9861a..4a110f11 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -130,7 +130,10 @@ func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) ( func artifactNameFromURL(rawURL string) (string, error) { parsed, err := url.Parse(rawURL) - if err != nil || parsed.Path == "" { + if err != nil { + return "", fmt.Errorf("parse artifact URL %q: %w", rawURL, err) + } + if parsed.Path == "" { return "", nil } artifactName := path.Base(parsed.Path) @@ -147,9 +150,14 @@ func validateArtifactName(name string) error { if name == "" { return errors.New("artifact name is required") } - if name == ".." || strings.Contains(name, "/") || strings.Contains(name, `\`) { + 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 } diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index 399375a3..b3906a62 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -58,11 +58,14 @@ 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.ErrorContains(t, err, "invalid artifact name") + require.Error(t, err) } } From 1a78153241b033190bac76a692265a576a5527f5 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 16:25:32 +0300 Subject: [PATCH 08/11] perf: memoize airgap checksum verification Signed-off-by: Kimmo Lehto --- phase/airgap_bundles.go | 30 ++++++++++++++++++++++++++++++ phase/airgap_bundles_test.go | 13 +++++++++++++ pkg/airgap/airgap_test.go | 4 ++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/phase/airgap_bundles.go b/phase/airgap_bundles.go index 8b4137fe..3d37d3d2 100644 --- a/phase/airgap_bundles.go +++ b/phase/airgap_bundles.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path" + "sync" "github.com/k0sproject/k0sctl/pkg/airgap" v1beta1 "github.com/k0sproject/k0sctl/pkg/apis/k0sctl.k0sproject.io/v1beta1" @@ -20,6 +21,14 @@ type AirgapBundles struct { 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. @@ -230,9 +239,30 @@ 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 } diff --git a/phase/airgap_bundles_test.go b/phase/airgap_bundles_test.go index f2ebc580..997e0a21 100644 --- a/phase/airgap_bundles_test.go +++ b/phase/airgap_bundles_test.go @@ -3,6 +3,7 @@ package phase import ( "bytes" "context" + "crypto/sha256" "fmt" "net/http" "net/http/httptest" @@ -93,6 +94,18 @@ func TestAirgapBundlesLocalSourceFileRejectsMixedBundles(t *testing.T) { require.ErrorContains(t, phase.Prepare(cfg), "spec.k0s.airgap.path points to a single file but planned hosts require 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") diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index b3906a62..feaae62e 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -44,14 +44,14 @@ 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: "abc123", + 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, "abc123", artifact.SHA256) + require.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", artifact.SHA256) } func TestURLResolverRejectsUnsafeArtifactName(t *testing.T) { From 3329adda0e440d0fd2d0a9e5148bab2ae61bf9c1 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 16:35:03 +0300 Subject: [PATCH 09/11] fix: validate local airgap bundle directories Signed-off-by: Kimmo Lehto --- phase/airgap_bundles.go | 47 ++++++++++++++++++++++++++++++++---- phase/airgap_bundles_test.go | 30 +++++++++++++++++++++++ pkg/airgap/airgap.go | 8 +++--- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/phase/airgap_bundles.go b/phase/airgap_bundles.go index 3d37d3d2..7b548ff2 100644 --- a/phase/airgap_bundles.go +++ b/phase/airgap_bundles.go @@ -6,6 +6,9 @@ import ( "io/fs" "os" "path" + "path/filepath" + "sort" + "strings" "sync" "github.com/k0sproject/k0sctl/pkg/airgap" @@ -57,7 +60,7 @@ func (p *AirgapBundles) Prepare(config *v1beta1.Cluster) error { for i := range plans { plans[i].Artifact.SHA256 = config.Spec.K0s.Airgap.SHA256 } - if err := p.validateLocalSourcePath(config.Spec.K0s.Airgap.Path, plans); err != nil { + if err := p.validateLocalSourcePath(config.Spec.K0s.Airgap.Path, config.Spec.K0s.Airgap.SHA256, plans); err != nil { return err } } @@ -67,7 +70,7 @@ func (p *AirgapBundles) Prepare(config *v1beta1.Cluster) error { return nil } -func (p *AirgapBundles) validateLocalSourcePath(sourcePath string, plans []airgap.Plan) error { +func (p *AirgapBundles) validateLocalSourcePath(sourcePath, sha256 string, plans []airgap.Plan) error { if len(plans) == 0 { return nil } @@ -75,19 +78,53 @@ func (p *AirgapBundles) validateLocalSourcePath(sourcePath string, plans []airga if err != nil { return fmt.Errorf("stat local airgap source %s: %w", sourcePath, err) } - if stat.IsDir() { - return nil - } 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 diff --git a/phase/airgap_bundles_test.go b/phase/airgap_bundles_test.go index 997e0a21..36d8d27a 100644 --- a/phase/airgap_bundles_test.go +++ b/phase/airgap_bundles_test.go @@ -94,6 +94,36 @@ func TestAirgapBundlesLocalSourceFileRejectsMixedBundles(t *testing.T) { 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") diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 4a110f11..ac21cee6 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -281,18 +281,18 @@ func EnsureCached(ctx context.Context, k0sVersion *version.Version, artifact Art } // VerifySHA256 checks a file against an expected SHA-256 hex digest. -func VerifySHA256(path, expected string) error { +func VerifySHA256(filePath, expected string) error { expected = strings.TrimSpace(strings.ToLower(expected)) if expected == "" { return nil } - file, err := os.Open(path) + 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", path, err) + log.Warnf("failed to close %s: %v", filePath, err) } }() hash := sha256.New() @@ -301,7 +301,7 @@ func VerifySHA256(path, expected string) error { } actual := hex.EncodeToString(hash.Sum(nil)) if actual != expected { - return fmt.Errorf("sha256 mismatch for %s: got %s, want %s", path, actual, expected) + return fmt.Errorf("sha256 mismatch for %s: got %s, want %s", filePath, actual, expected) } return nil } From 9c179e3ece307be6bc7b809c226771d30dc0c81c Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 16:48:07 +0300 Subject: [PATCH 10/11] fix: redact airgap download URLs Signed-off-by: Kimmo Lehto --- internal/download/download.go | 22 ++++++-- internal/download/download_test.go | 88 ++++++++++++++++++++++++++++++ pkg/airgap/airgap.go | 12 +++- pkg/airgap/airgap_test.go | 14 +++++ 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 internal/download/download_test.go diff --git a/internal/download/download.go b/internal/download/download.go index d8c68d49..aec628c9 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -6,6 +6,7 @@ import ( "io" "net" "net/http" + "net/url" "os" "path/filepath" "time" @@ -27,8 +28,8 @@ var httpClient = &http.Client{ }, } -// ToFile downloads url to dest using a temporary file in the destination directory. -func ToFile(ctx context.Context, url, dest string) (retErr error) { +// 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 @@ -50,7 +51,7 @@ func ToFile(ctx context.Context, url, dest string) (retErr error) { } } }() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } @@ -64,7 +65,7 @@ func ToFile(ctx context.Context, url, dest string) (retErr error) { } }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected http status %s from %s", resp.Status, url) + return fmt.Errorf("unexpected http status %s from %s", resp.Status, RedactedURL(rawURL)) } if _, err := io.Copy(tmpFile, resp.Body); err != nil { return err @@ -86,3 +87,16 @@ func ToFile(ctx context.Context, url, dest string) (retErr error) { } 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() +} diff --git a/internal/download/download_test.go b/internal/download/download_test.go new file mode 100644 index 00000000..9f2a54c8 --- /dev/null +++ b/internal/download/download_test.go @@ -0,0 +1,88 @@ +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 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/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index ac21cee6..17f7cecc 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -131,7 +131,7 @@ func (r URLResolver) Resolve(k0sVersion *version.Version, osKind, arch string) ( func artifactNameFromURL(rawURL string) (string, error) { parsed, err := url.Parse(rawURL) if err != nil { - return "", fmt.Errorf("parse artifact URL %q: %w", rawURL, err) + return "", fmt.Errorf("parse artifact URL %q: %w", download.RedactedURL(rawURL), urlParseCause(err)) } if parsed.Path == "" { return "", nil @@ -141,11 +141,19 @@ func artifactNameFromURL(rawURL string) (string, error) { return "", nil } if err := validateArtifactName(artifactName); err != nil { - return "", fmt.Errorf("artifact name from URL %q: %w", rawURL, err) + 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") diff --git a/pkg/airgap/airgap_test.go b/pkg/airgap/airgap_test.go index feaae62e..1505875e 100644 --- a/pkg/airgap/airgap_test.go +++ b/pkg/airgap/airgap_test.go @@ -69,6 +69,20 @@ func TestURLResolverRejectsUnsafeArtifactName(t *testing.T) { } } +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{ From e9748d4e5f251ddee2cd012c0179765a5d496e44 Mon Sep 17 00:00:00 2001 From: Kimmo Lehto Date: Fri, 8 May 2026 17:14:02 +0300 Subject: [PATCH 11/11] fix: redact downloader request errors Signed-off-by: Kimmo Lehto --- internal/download/download.go | 14 ++++++++++++-- internal/download/download_test.go | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/download/download.go b/internal/download/download.go index aec628c9..2a8dbcd8 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -2,6 +2,7 @@ package download import ( "context" + "errors" "fmt" "io" "net" @@ -9,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "time" log "github.com/sirupsen/logrus" @@ -53,11 +55,11 @@ func ToFile(ctx context.Context, rawURL, dest string) (retErr error) { }() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return err + return fmt.Errorf("create download request for %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err)) } resp, err := httpClient.Do(req) if err != nil { - return err + return fmt.Errorf("download %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err)) } defer func() { if err := resp.Body.Close(); err != nil && retErr == nil { @@ -100,3 +102,11 @@ func RedactedURL(rawURL string) string { 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 index 9f2a54c8..590e65aa 100644 --- a/internal/download/download_test.go +++ b/internal/download/download_test.go @@ -45,6 +45,28 @@ func TestToFileRedactsURLOnHTTPStatusError(t *testing.T) { 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")