mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-11 18:41:13 +09:00
99fd62477d
Run the real operator in a single-container k3s cluster against an in-test Headscale over plain HTTP. k3sic exposes reusable building blocks (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, DeployProxyGroup); the test covers operator registration, an egress connector, ingress connectivity from a tailnet node, and proxy groups. tls-ca-baking.md records the private-CA TLS variant. Updates #1202
604 lines
22 KiB
Go
604 lines
22 KiB
Go
// Package k3sic wraps a single-container k3s cluster (server + agent) as a
|
|
// privileged sibling container on the host docker daemon, like the
|
|
// DERP-in-container wrapper in the dsic package.
|
|
//
|
|
// It exists so that integration tests can install the real Tailscale
|
|
// Kubernetes operator (via its Helm chart) into a real Kubernetes cluster and
|
|
// point it at an in-test Headscale. k3s bundles kubectl and we run helm inside
|
|
// the container through Execute, so the host dev shell needs no kube tooling.
|
|
//
|
|
// The operator is pointed at Headscale over plain HTTP (see
|
|
// hsic.WithoutTLS), so the operator and proxy pods need no CA: there is no
|
|
// image baking and no CoreDNS hostname mapping. To run instead against a TLS
|
|
// Headscale with a private CA, see tls-ca-baking.md.
|
|
//
|
|
// The harness runs as sibling containers on the host docker daemon (the
|
|
// test-suite container has the host docker socket bind-mounted); it is NOT
|
|
// docker-in-docker. We therefore run the purpose-built single-container k3s image
|
|
// as one more privileged sibling joined to the scenario networks, rather than using
|
|
// k3d/kind which shell out to the docker daemon and fight the sibling model.
|
|
// Privileged is the harness-wide norm here, not a k3s-specific escalation: the
|
|
// tsic (client) and dsic (DERP) containers run privileged too (see
|
|
// dockertestutil.DockerAllowNetworkAdministration).
|
|
package k3sic
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/capver"
|
|
"github.com/juanfont/headscale/integration/dockertestutil"
|
|
"github.com/juanfont/headscale/integration/integrationutil"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/ory/dockertest/v3/docker"
|
|
"tailscale.com/util/rands"
|
|
)
|
|
|
|
const (
|
|
k3sicHashLength = 6
|
|
|
|
// K3sImage is the single-container k3s server+agent image, pinned on ghcr (no
|
|
// anonymous Docker Hub rate limit). Pinned rather than resolved from the k3s
|
|
// stable channel because that channel floats the k8s minor unpredictably and
|
|
// would outrun the cgroup-v2 and br_netfilter workarounds below. Bump by hand.
|
|
K3sImage = "ghcr.io/k3s-io/k3s:v1.35.5-k3s1"
|
|
|
|
// operatorImageRepo and proxyImageRepo are the ghcr-hosted operator and
|
|
// proxy images. ghcr is used instead of Docker Hub to avoid anonymous pull
|
|
// rate limits; the pods pull these directly.
|
|
operatorImageRepo = "ghcr.io/tailscale/k8s-operator"
|
|
proxyImageRepo = "ghcr.io/tailscale/tailscale"
|
|
|
|
dockerExecuteTimeout = 300 * time.Second
|
|
|
|
// helmVersionFallback is used when the latest helm release cannot be
|
|
// resolved at runtime (see resolveHelmVersion). The image ships no helm and
|
|
// cannot fetch it itself, so we inject a binary that matches the container
|
|
// arch.
|
|
helmVersionFallback = "v3.19.1"
|
|
|
|
// kubeconfigPath is where k3s writes the kubeconfig (see RunOptions.Env);
|
|
// helm needs it pointed explicitly, kubectl finds it by default.
|
|
kubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
|
|
|
|
// kubectlBin is the in-container kubectl the k3s image ships on PATH.
|
|
kubectlBin = "kubectl"
|
|
|
|
// shellBin is the in-container shell used for compound commands.
|
|
shellBin = "/bin/sh"
|
|
|
|
// tailscaleNamespace is where the operator and its proxies are installed.
|
|
tailscaleNamespace = "tailscale"
|
|
|
|
// kubeSystemNamespace holds CoreDNS and the rest of the k3s system addons.
|
|
kubeSystemNamespace = "kube-system"
|
|
)
|
|
|
|
var (
|
|
errHelmDownload = errors.New("helm download failed")
|
|
errHelmNotInTarball = errors.New("helm binary not found in release tarball")
|
|
|
|
errNoKubeDNSEndpoints = errors.New("kube-dns Service has no ready endpoints yet")
|
|
)
|
|
|
|
// OperatorImageTag is the image tag the operator and proxy images use, derived
|
|
// from the Tailscale minor Headscale tracks via capver (e.g. "v1.98"). The
|
|
// operator shares the Tailscale release train, so this keeps the images and the
|
|
// Helm chart in lockstep with the client versions Headscale is tested against,
|
|
// without a hand-pinned constant. The tag is a rolling tag within the minor.
|
|
func OperatorImageTag() string {
|
|
return capver.TailscaleLatestMajorMinor(1, false)[0]
|
|
}
|
|
|
|
// OperatorImage and ProxyImage are the ghcr operator/proxy image references the
|
|
// test wires into the Helm chart.
|
|
func OperatorImage() string { return operatorImageRepo + ":" + OperatorImageTag() }
|
|
func ProxyImage() string { return proxyImageRepo + ":" + OperatorImageTag() }
|
|
|
|
// OperatorChartVersion is the Helm chart version constraint matching the derived
|
|
// minor; helm resolves the latest patch in that line. The top-level loginServer
|
|
// value the operator needs to target Headscale instead of the Tailscale SaaS
|
|
// first shipped in chart 1.98.4.
|
|
func OperatorChartVersion() string {
|
|
return strings.TrimPrefix(OperatorImageTag(), "v") + ".*"
|
|
}
|
|
|
|
// K3sInContainer represents a k3s cluster running in a single privileged
|
|
// container (K3sInContainer, hence k3sic).
|
|
type K3sInContainer struct {
|
|
hostname string
|
|
|
|
pool *dockertest.Pool
|
|
container *dockertest.Resource
|
|
networks []*dockertest.Network
|
|
}
|
|
|
|
// New starts a new [K3sInContainer] joined to the given networks.
|
|
func New(
|
|
pool *dockertest.Pool,
|
|
networks []*dockertest.Network,
|
|
) (*K3sInContainer, error) {
|
|
hash := rands.HexString(k3sicHashLength)
|
|
|
|
// Include the run ID in the hostname for easier identification of which
|
|
// test run owns this container, matching the dsic/tsic convention.
|
|
runID := dockertestutil.GetIntegrationRunID()
|
|
|
|
var hostname string
|
|
|
|
if runID != "" {
|
|
runIDShort := runID[len(runID)-6:]
|
|
hostname = fmt.Sprintf("k3s-%s-%s", runIDShort, hash)
|
|
} else {
|
|
hostname = "k3s-" + hash
|
|
}
|
|
|
|
k := &K3sInContainer{
|
|
hostname: hostname,
|
|
pool: pool,
|
|
networks: networks,
|
|
}
|
|
|
|
// Pull the k3s image (ghcr, not built) via PullWithAuth, as hsic does for
|
|
// prebuilt/pulled images.
|
|
err := dockertestutil.PullWithAuth(pool, K3sImage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pulling %s: %w", K3sImage, err)
|
|
}
|
|
|
|
repo, tag, ok := strings.Cut(K3sImage, ":")
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid k3s image reference %q", K3sImage) //nolint:err113
|
|
}
|
|
|
|
runOptions := &dockertest.RunOptions{
|
|
Name: hostname,
|
|
Repository: repo,
|
|
Tag: tag,
|
|
Networks: networks,
|
|
// "server" runs both the control plane and a built-in agent in one
|
|
// container. --disable traefik/servicelb/metrics-server keeps the
|
|
// cluster lean: the test only needs the API server and the ability to
|
|
// schedule the operator pods. --tls-san pins the hostname into the
|
|
// apiserver cert (not strictly needed since we exec kubectl in-container,
|
|
// but harmless and future-proof).
|
|
Cmd: []string{
|
|
"server",
|
|
"--disable", "traefik",
|
|
"--disable", "servicelb",
|
|
"--disable", "metrics-server",
|
|
"--disable-network-policy",
|
|
"--snapshotter", "native",
|
|
"--tls-san", hostname,
|
|
},
|
|
Env: []string{
|
|
"K3S_KUBECONFIG_OUTPUT=" + kubeconfigPath,
|
|
"K3S_KUBECONFIG_MODE=0644",
|
|
},
|
|
}
|
|
|
|
// Stamp the run-id label or the reaper leaks the container.
|
|
dockertestutil.DockerAddIntegrationLabels(runOptions, "k3s")
|
|
|
|
// dockertest does not handle pre-existing containers well; make sure a
|
|
// stale one with this name is gone first.
|
|
err = pool.RemoveContainerByName(hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
container, err := pool.RunWithOptions(
|
|
runOptions,
|
|
dockertestutil.DockerRestartPolicy,
|
|
// Privileged + NET_ADMIN: k3s manages iptables/ipvs, mounts cgroups and
|
|
// runs containerd. This is the same knob dsic uses for the DERP server.
|
|
dockertestutil.DockerAllowNetworkAdministration,
|
|
withK3sHostConfig,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s starting k3s container: %w", hostname, err)
|
|
}
|
|
|
|
log.Printf("Created %s container\n", hostname)
|
|
|
|
k.container = container
|
|
|
|
// Make ClusterIP DNAT work before k3s programs kube-proxy rules; without it
|
|
// in-cluster DNS times out on hosts where br_netfilter is not preloaded.
|
|
k.ensureBridgeNetfilter()
|
|
|
|
return k, nil
|
|
}
|
|
|
|
// withK3sHostConfig sets the HostConfig knobs k3s needs beyond
|
|
// privileged/NET_ADMIN: a tmpfs on /run and /var/run, which the k3s image
|
|
// expects.
|
|
//
|
|
// It deliberately does NOT bind-mount the host /sys/fs/cgroup. On a cgroup-v2
|
|
// host, bind-mounting the host cgroup tree into a container that keeps its own
|
|
// (private) cgroup namespace makes the cgroup root visible to the container
|
|
// disagree with the namespace runc places workload pods under. The kubelet can
|
|
// then start (the apiserver and node go Ready), but every workload pod fails to
|
|
// create its sandbox with "failed to apply cgroup configuration: ...
|
|
// cgroup.procs: no such file or directory", so nothing the operator schedules
|
|
// ever runs. Leaving the bind-mount off lets the privileged k3s entrypoint set
|
|
// up cgroup-v2 delegation within its own namespace, which it is
|
|
// designed to do, and workload pods schedule normally.
|
|
func withK3sHostConfig(config *docker.HostConfig) {
|
|
config.Tmpfs = map[string]string{
|
|
"/run": "",
|
|
"/var/run": "",
|
|
}
|
|
|
|
// Bind the host kernel modules read-only so the container can load
|
|
// br_netfilter (see ensureBridgeNetfilter). Without bridge netfilter,
|
|
// kube-proxy's ClusterIP DNAT rules do not apply to bridged pod-to-pod
|
|
// traffic, so kube-dns (and every other Service) is unreachable from pods —
|
|
// the in-cluster DNS timeout seen on the arm64 CI runner. The container
|
|
// shares the host kernel, so the modules match.
|
|
config.Binds = append(config.Binds, "/lib/modules:/lib/modules:ro")
|
|
}
|
|
|
|
// ensureBridgeNetfilter loads br_netfilter and enables the sysctls that make
|
|
// kube-proxy's ClusterIP DNAT apply to bridged pod-to-pod traffic. On a host
|
|
// where the module is already loaded (e.g. the amd64 dev box, where Docker
|
|
// loads it for bridge networks) these are no-ops; on the arm64 CI runner the
|
|
// module is absent and pods cannot reach any Service IP — kube-dns included —
|
|
// so in-cluster DNS times out. Best-effort: k3s also loads the module, and a
|
|
// genuinely missing module surfaces in DumpDiagnostics rather than here.
|
|
func (k *K3sInContainer) ensureBridgeNetfilter() {
|
|
for _, cmd := range []string{
|
|
"modprobe br_netfilter || true",
|
|
"sysctl -w net.bridge.bridge-nf-call-iptables=1 || true",
|
|
"sysctl -w net.bridge.bridge-nf-call-ip6tables=1 || true",
|
|
"sysctl -w net.ipv4.ip_forward=1 || true",
|
|
} {
|
|
out, stderr, err := k.Execute([]string{shellBin, "-c", cmd})
|
|
if err != nil {
|
|
log.Printf("[k3s] %q failed: %v (stdout: %s, stderr: %s)", cmd, err, out, stderr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hostname returns the hostname of the [K3sInContainer].
|
|
func (k *K3sInContainer) Hostname() string {
|
|
return k.hostname
|
|
}
|
|
|
|
// ID returns the docker container ID of the [K3sInContainer].
|
|
func (k *K3sInContainer) ID() string {
|
|
return k.container.Container.ID
|
|
}
|
|
|
|
// ConnectToNetwork connects the cluster container to an additional network.
|
|
func (k *K3sInContainer) ConnectToNetwork(network *dockertest.Network) error {
|
|
return k.container.ConnectToNetwork(network)
|
|
}
|
|
|
|
// Execute runs a command inside the k3s container and returns its stdout.
|
|
// kubectl and the k3s-bundled tools (and helm, once installed via
|
|
// [K3sInContainer.InstallHelm]) are on PATH. KUBECONFIG is exported so helm,
|
|
// which (unlike the image's kubectl) does not default to the k3s config, can
|
|
// reach the cluster.
|
|
func (k *K3sInContainer) Execute(command []string) (string, string, error) {
|
|
return dockertestutil.ExecuteCommand(
|
|
k.container,
|
|
command,
|
|
[]string{"KUBECONFIG=" + kubeconfigPath},
|
|
dockertestutil.ExecuteCommandTimeout(dockerExecuteTimeout),
|
|
)
|
|
}
|
|
|
|
// WriteFile saves a file inside the container.
|
|
func (k *K3sInContainer) WriteFile(path string, data []byte) error {
|
|
return integrationutil.WriteFileToContainer(k.pool, k.container, path, data)
|
|
}
|
|
|
|
// WaitForRunning blocks until the cluster is ready to schedule DNS-dependent
|
|
// workloads: the kube-apiserver is serving, the single node reports Ready, and
|
|
// in-cluster DNS is servable (CoreDNS rolled out with a backed kube-dns
|
|
// Service). Gating on DNS here pins a missing-DNS failure at its source rather
|
|
// than letting the operator crashloop on an opaque lookup timeout.
|
|
func (k *K3sInContainer) WaitForRunning() error {
|
|
log.Printf("waiting for k3s API server in %s to be ready", k.hostname)
|
|
|
|
err := k.pool.Retry(func() error {
|
|
// `kubectl get --raw=/readyz` returns "ok" once the apiserver is up.
|
|
out, _, err := k.Execute([]string{
|
|
kubectlBin, "get", "--raw=/readyz",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("k3s apiserver not ready: %w", err)
|
|
}
|
|
|
|
if !strings.Contains(out, "ok") {
|
|
return fmt.Errorf("k3s apiserver readyz returned %q", strings.TrimSpace(out)) //nolint:err113
|
|
}
|
|
|
|
// Wait for the node object to exist and be Ready before returning so
|
|
// pods can actually be scheduled.
|
|
nodeOut, _, err := k.Execute([]string{
|
|
kubectlBin, "get", "nodes", "--no-headers",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("k3s node not ready: %w", err)
|
|
}
|
|
|
|
if !strings.Contains(nodeOut, " Ready") {
|
|
return fmt.Errorf("k3s node not Ready yet: %q", strings.TrimSpace(nodeOut)) //nolint:err113
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return k.waitForClusterDNS()
|
|
}
|
|
|
|
// InstallHelm installs the helm binary into the container so the operator can be
|
|
// installed. The k3s image ships kubectl but not helm, has no curl, and its
|
|
// busybox wget cannot do HTTPS, so we download helm in the test process (which
|
|
// has network egress) and inject the binary. helm's own HTTPS client then
|
|
// fetches the operator chart from inside the container.
|
|
func (k *K3sInContainer) InstallHelm() error {
|
|
bin, err := fetchHelmBinary(resolveHelmVersion(), runtime.GOARCH)
|
|
if err != nil {
|
|
return fmt.Errorf("fetching helm: %w", err)
|
|
}
|
|
|
|
err = k.WriteFile("/usr/local/bin/helm", bin)
|
|
if err != nil {
|
|
return fmt.Errorf("writing helm binary: %w", err)
|
|
}
|
|
|
|
// WriteFile uploads with mode 0; make it readable+executable.
|
|
_, stderr, err := k.Execute([]string{"chmod", "0755", "/usr/local/bin/helm"})
|
|
if err != nil {
|
|
return fmt.Errorf("chmod helm (stderr: %s): %w", stderr, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitForClusterDNS blocks until CoreDNS is rolled out and the kube-dns Service
|
|
// has at least one ready endpoint — i.e. in-cluster name resolution is actually
|
|
// servable, which every workload (starting with the operator) depends on. k3s
|
|
// deploys CoreDNS via its addon manager shortly after the node reports Ready, so
|
|
// the deployment may not exist yet; retry until it does before checking rollout.
|
|
func (k *K3sInContainer) waitForClusterDNS() error {
|
|
err := k.pool.Retry(func() error {
|
|
_, stderr, err := k.Execute([]string{
|
|
kubectlBin, "-n", kubeSystemNamespace, "get", "deployment", "coredns",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("coredns deployment not present yet (stderr: %s): %w", stderr, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("waiting for coredns deployment to appear: %w", err)
|
|
}
|
|
|
|
_, stderr, err := k.Execute([]string{
|
|
kubectlBin, "-n", kubeSystemNamespace, "rollout", "status",
|
|
"deployment/coredns", "--timeout=150s",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("coredns did not become available (stderr: %s): %w", stderr, err)
|
|
}
|
|
|
|
return k.pool.Retry(func() error {
|
|
// A populated endpoint set means a CoreDNS pod is serving :53 and
|
|
// kube-proxy has a backend to DNAT the kube-dns ClusterIP to; empty means
|
|
// in-cluster lookups will time out no matter how long a client waits.
|
|
out, stderr, err := k.Execute([]string{
|
|
kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns",
|
|
"-o", "jsonpath={.subsets[*].addresses[*].ip}",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("reading kube-dns endpoints (stderr: %s): %w", stderr, err)
|
|
}
|
|
|
|
if strings.TrimSpace(out) == "" {
|
|
return errNoKubeDNSEndpoints
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ConfigureCoreDNSHost makes in-cluster pods resolve hostname to ip via CoreDNS.
|
|
// The operator targets Headscale's control plane by IP, but the embedded DERP map
|
|
// references Headscale by hostname; without this, the proxy pods cannot resolve
|
|
// the DERP server, never connect to it, and — since they only advertise
|
|
// unreachable pod-network endpoints — get no data path to nodes outside the
|
|
// cluster. It installs a coredns-custom ConfigMap (a k3s-native extension point:
|
|
// keys ending in .server become additional server blocks), which CoreDNS's reload
|
|
// plugin picks up without a restart.
|
|
func (k *K3sInContainer) ConfigureCoreDNSHost(hostname, ip string) error {
|
|
manifest := fmt.Sprintf(`apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: coredns-custom
|
|
namespace: kube-system
|
|
data:
|
|
headscale.server: |
|
|
%s {
|
|
hosts {
|
|
%s %s
|
|
fallthrough
|
|
}
|
|
}
|
|
`, hostname, ip, hostname)
|
|
|
|
return k.ApplyManifest("coredns-custom", manifest)
|
|
}
|
|
|
|
// DumpDiagnostics logs cluster state useful for debugging a failed operator
|
|
// install: pod status across namespaces and the operator's own logs and events.
|
|
// Best-effort — every command's failure is logged, not returned.
|
|
func (k *K3sInContainer) DumpDiagnostics() {
|
|
for _, c := range [][]string{
|
|
{kubectlBin, "get", "pods", "-A", "-o", "wide"},
|
|
{kubectlBin, "-n", tailscaleNamespace, "get", "events", "--sort-by=.lastTimestamp"},
|
|
{kubectlBin, "-n", tailscaleNamespace, "describe", "pods"},
|
|
{kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--tail=200"},
|
|
// A crashlooping operator's fatal error is in the previous container.
|
|
{kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--previous", "--tail=200"},
|
|
{kubectlBin, "-n", tailscaleNamespace, "get", "statefulsets,pods", "-o", "wide"},
|
|
// Proxy pods' tailscaled logs: DERP-connection and registration failures
|
|
// (the data-path culprits) surface here, not in the operator log.
|
|
{
|
|
shellBin, "-c",
|
|
"for p in $(kubectl -n " + tailscaleNamespace + " get pods -o name | grep /ts-); do " +
|
|
"echo \"== $p ==\"; kubectl -n " + tailscaleNamespace +
|
|
" logs $p -c tailscale --tail=80 2>&1; done",
|
|
},
|
|
// In-cluster DNS: the operator's first dependency. A "lookup
|
|
// kubernetes.default.svc ... i/o timeout" crash means CoreDNS is not
|
|
// serving, so capture its pod state, logs (Corefile parse errors land
|
|
// here), and the Service endpoints.
|
|
{kubectlBin, "-n", kubeSystemNamespace, "get", "pods", "-l", "k8s-app=kube-dns", "-o", "wide"},
|
|
{kubectlBin, "-n", kubeSystemNamespace, "logs", "-l", "k8s-app=kube-dns", "--tail=100"},
|
|
{kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", "-o", "wide"},
|
|
// Host network state behind a ClusterIP-unreachable DNS timeout: whether
|
|
// br_netfilter is loaded and the call-iptables/forward sysctls are on, and
|
|
// whether kube-proxy actually programmed the kube-dns DNAT rule. If CoreDNS
|
|
// is healthy (above) but these are missing, the fault is the Service DNAT
|
|
// path, not DNS.
|
|
{shellBin, "-c", "lsmod | grep -E 'br_netfilter|nf_conntrack' || echo 'br_netfilter NOT loaded'"},
|
|
{shellBin, "-c", "sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward 2>&1 || true"},
|
|
{shellBin, "-c", "iptables-save -t nat 2>/dev/null | grep -iE 'KUBE-SERVICES|kube-dns|10.43.0.10' | head -40 || echo 'no kube-dns nat rules'"},
|
|
} {
|
|
out, stderr, err := k.Execute(c)
|
|
label := strings.Join(c, " ")
|
|
|
|
if err != nil {
|
|
log.Printf("[k3s diag] %s failed: %v (stderr: %s)", label, err, stderr)
|
|
continue
|
|
}
|
|
|
|
log.Printf("[k3s diag] %s:\n%s", label, out)
|
|
}
|
|
}
|
|
|
|
// resolveHelmVersion returns the latest published helm release tag (e.g.
|
|
// "v3.19.1"), falling back to [helmVersionFallback] if it cannot be resolved.
|
|
// get.helm.sh is not Docker Hub and has no anonymous rate limit, so a "rolling"
|
|
// latest is cheap; the fallback keeps a broken release from breaking CI.
|
|
func resolveHelmVersion() string {
|
|
req, err := http.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "https://get.helm.sh/helm-latest-version", nil)
|
|
if err != nil {
|
|
return helmVersionFallback
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return helmVersionFallback
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return helmVersionFallback
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 32))
|
|
if err != nil {
|
|
return helmVersionFallback
|
|
}
|
|
|
|
version := strings.TrimSpace(string(body))
|
|
if !strings.HasPrefix(version, "v") {
|
|
return helmVersionFallback
|
|
}
|
|
|
|
return version
|
|
}
|
|
|
|
// fetchHelmBinary downloads the helm release tarball for version and goarch and
|
|
// returns the helm binary bytes.
|
|
func fetchHelmBinary(version, goarch string) ([]byte, error) {
|
|
url := fmt.Sprintf("https://get.helm.sh/helm-%s-linux-%s.tar.gz", version, goarch)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%w: %s returned status %d", errHelmDownload, url, resp.StatusCode)
|
|
}
|
|
|
|
gz, err := gzip.NewReader(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gz.Close()
|
|
|
|
want := "linux-" + goarch + "/helm"
|
|
tr := tar.NewReader(gz)
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if hdr.Name == want {
|
|
return io.ReadAll(tr)
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", errHelmNotInTarball, want)
|
|
}
|
|
|
|
// Shutdown saves the container log and then runs k3s-killall in-container so
|
|
// k3s's own child processes/containers (containerd-shims, pods) do not leak,
|
|
// before purging the container itself.
|
|
func (k *K3sInContainer) Shutdown() error {
|
|
err := k.SaveLog("/tmp/control")
|
|
if err != nil {
|
|
log.Printf("saving log from %s: %s", k.hostname, err)
|
|
}
|
|
|
|
// k3s spawns containerd and a tree of child processes inside this
|
|
// container; the bundled k3s-killall.sh tears them down. Best-effort: the
|
|
// Purge below removes the container regardless.
|
|
_, _, err = k.Execute([]string{shellBin, "-c", "k3s-killall.sh || true"})
|
|
if err != nil {
|
|
log.Printf("running k3s-killall in %s: %s", k.hostname, err)
|
|
}
|
|
|
|
return k.pool.Purge(k.container)
|
|
}
|
|
|
|
// SaveLog saves the container stdout/stderr logs to a path on the host.
|
|
func (k *K3sInContainer) SaveLog(path string) error {
|
|
_, _, err := dockertestutil.SaveLog(k.pool, k.container, path)
|
|
|
|
return err
|
|
}
|