Skip to content
Open
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<data-dir>/images`, where `<data-dir>` 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` &lt;mapping&gt; (optional) (default: auto-generated)

Embedded k0s cluster configuration. See [k0s configuration documentation](https://docs.k0sproject.io/stable/configuration/) for details.
Expand Down
1 change: 1 addition & 0 deletions action/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
33 changes: 33 additions & 0 deletions action/apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package action

import (
"testing"

"github.com/k0sproject/k0sctl/phase"
"github.com/stretchr/testify/require"
)

func TestApplyIncludesAirgapBeforeWorkerPhases(t *testing.T) {
apply := NewApply(ApplyOptions{})
airgapPhase := (&phase.AirgapBundles{}).Title()
initializeK0s := (&phase.InitializeK0s{}).Title()
installControllers := (&phase.InstallControllers{}).Title()
installWorkers := (&phase.InstallWorkers{}).Title()
upgradeWorkers := (&phase.UpgradeWorkers{}).Title()

Comment thread
kke marked this conversation as resolved.
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)
}
112 changes: 112 additions & 0 deletions internal/download/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package download

import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

log "github.com/sirupsen/logrus"
)

var httpClient = &http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
},
}

// ToFile downloads rawURL to dest using a temporary file in the destination directory.
func ToFile(ctx context.Context, rawURL, dest string) (retErr error) {
dir := filepath.Dir(dest)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmpFile, err := os.CreateTemp(dir, filepath.Base(dest)+".tmp-")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer func() {
if tmpFile != nil {
if err := tmpFile.Close(); err != nil && retErr == nil {
retErr = err
}
}
if retErr != nil {
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
log.Warnf("failed to remove partial download at %s: %v", tmpPath, err)
}
}
}()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("create download request for %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err))
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", RedactedURL(rawURL), redactedURLError(rawURL, err))
}
Comment thread
kke marked this conversation as resolved.
defer func() {
if err := resp.Body.Close(); err != nil && retErr == nil {
retErr = err
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected http status %s from %s", resp.Status, RedactedURL(rawURL))
}
Comment thread
kke marked this conversation as resolved.
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
tmpFile = nil
return err
}
tmpFile = nil
// os.Rename is atomic on Unix (replaces dest if it exists), so concurrent runs are safe.
// On Windows it fails if dest already exists; two simultaneous k0sctl processes targeting
// the same destination could race here. We intentionally propagate that error rather than
// silently accepting whatever file is at dest, which would be a TOCTOU risk.
if err := os.Rename(tmpPath, dest); err != nil {
return err
}
return nil
}

// RedactedURL returns a URL string suitable for error messages.
func RedactedURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return "<redacted>"
}
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
return parsed.String()
}

func redactedURLError(rawURL string, err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
return urlErr.Err
}
return errors.New(strings.ReplaceAll(err.Error(), rawURL, RedactedURL(rawURL)))
}
110 changes: 110 additions & 0 deletions internal/download/download_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package download

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestToFileDownloadsToDestination(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := fmt.Fprint(w, "downloaded")
require.NoError(t, err)
}))
t.Cleanup(server.Close)

dest := filepath.Join(t.TempDir(), "bundle")
require.NoError(t, ToFile(context.Background(), server.URL+"/bundle", dest))

content, err := os.ReadFile(dest)
require.NoError(t, err)
require.Equal(t, "downloaded", string(content))
require.Empty(t, tempFiles(t, dest))
}

func TestToFileRedactsURLOnHTTPStatusError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "nope", http.StatusUnauthorized)
}))
t.Cleanup(server.Close)

dest := filepath.Join(t.TempDir(), "bundle")
err := ToFile(context.Background(), authenticatedURL(server.URL)+"/bundle?token=secret", dest)
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected http status 401 Unauthorized")
require.NotContains(t, err.Error(), "token=secret")
require.NotContains(t, err.Error(), "user:pass")
require.NoFileExists(t, dest)
require.Empty(t, tempFiles(t, dest))
}

func TestToFileRedactsURLOnRequestError(t *testing.T) {
dest := filepath.Join(t.TempDir(), "bundle")
err := ToFile(context.Background(), "http://user:pass@example.invalid/\n?token=secret", dest)
require.Error(t, err)
require.Contains(t, err.Error(), "create download request for <redacted>")
require.NotContains(t, err.Error(), "token=secret")
require.NotContains(t, err.Error(), "user:pass")
require.NoFileExists(t, dest)
require.Empty(t, tempFiles(t, dest))
}

func TestToFileRedactsURLOnTransportError(t *testing.T) {
dest := filepath.Join(t.TempDir(), "bundle")
err := ToFile(context.Background(), "http://user:pass@127.0.0.1:1/bundle?token=secret", dest)
require.Error(t, err)
require.Contains(t, err.Error(), "download http://127.0.0.1:1/bundle")
require.NotContains(t, err.Error(), "token=secret")
require.NotContains(t, err.Error(), "user:pass")
require.NoFileExists(t, dest)
require.Empty(t, tempFiles(t, dest))
}

func TestToFileRemovesPartialDownloadOnCopyError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "10")
_, err := fmt.Fprint(w, "part")
require.NoError(t, err)
}))
t.Cleanup(server.Close)

dest := filepath.Join(t.TempDir(), "bundle")
err := ToFile(context.Background(), server.URL+"/bundle", dest)
require.Error(t, err)
require.NoFileExists(t, dest)
require.Empty(t, tempFiles(t, dest))
}

func TestToFileRemovesTempFileOnCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

dest := filepath.Join(t.TempDir(), "bundle")
err := ToFile(ctx, "http://127.0.0.1/bundle", dest)
require.Error(t, err)
require.NoFileExists(t, dest)
require.Empty(t, tempFiles(t, dest))
}

func TestRedactedURLRemovesCredentialsAndQuery(t *testing.T) {
got := RedactedURL("https://user:pass@example.invalid/path/to/bundle?token=secret#fragment")
require.Equal(t, "https://example.invalid/path/to/bundle", got)
}

func authenticatedURL(rawURL string) string {
return strings.Replace(rawURL, "http://", "http://user:pass@", 1)
}

func tempFiles(t *testing.T, dest string) []string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(dest), filepath.Base(dest)+".tmp-*"))
require.NoError(t, err)
return matches
}
Loading
Loading