diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 6a3db26ae..1e72ed1a3 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -60,6 +60,31 @@ jobs: shellcheck scripts/tests/test-rgd-template-static-scan-wiring.sh bash scripts/tests/test-rgd-template-static-scan-wiring.sh + # A manual dispatch can deploy a direct main push that never produced a merge-group revision. + # Run the same production-pin validator here and make it a hard prerequisite of deploy-prod so + # neither production route can reconcile an unsupported Kubernetes/Talos pairing. + validate-talos-kubernetes-compatibility: + name: ๐Ÿงช Validate Kubernetes/Talos Compatibility + runs-on: ubuntu-latest + permissions: + contents: read # checkout repository + steps: + - name: ๐Ÿ“‘ Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: โš™๏ธ Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: โœ… Validate Kubernetes against the pinned Talos release + run: | + go test ./scripts/validate-talos-kubernetes-compatibility + go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml + # A workflow dispatch can deploy a direct push that never had a merge-group revision. Run the # same nested-RGD behavioral contract before authorization; validate-eks-authorization depends # on this job, and deploy-prod already depends on authorization, preserving the signing @@ -257,6 +282,7 @@ jobs: [ validate-eks-authorization, validate-publication-contract, + validate-talos-kubernetes-compatibility, validate-ghcr-fanout-component-gate, ] runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b29911e41..86194e1e1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -454,6 +454,11 @@ jobs: talos: - 'talos/**' - 'talos-local/**' + - 'ksail.prod.yaml' + - '.github/scripts/setup-talosctl.sh' + - 'scripts/validate-talos-kubernetes-compatibility/**' + - 'go.mod' + - 'go.sum' - '.github/workflows/ci.yaml' validate-publication-contract: @@ -1622,13 +1627,13 @@ jobs: name: ๐Ÿงช Validate Talos Machine Config needs: [changes] # Renders the Talos machine config with every talos/ (prod) and talos-local/ - # patch applied and runs `talosctl validate` on the result โ€” on the PR - # event, so a broken patch (or an env expansion that would render empty, - # like #2462's ${WG_SERVER_PRIVATE_KEY}) fails the PR instead of surfacing - # in the merge group's deploy and evicting the queued PR. Placeholder key - # material stands in for the env-expanded secrets, so this needs no real - # secrets and runs on fork PRs too (issue #2477). - if: github.event_name == 'pull_request' && needs.changes.outputs.talos == 'true' + # patch applied and runs `talosctl validate` on the result โ€” on the PR for + # fast feedback and on the merge-group revision that production deploys. + # A broken patch (or an env expansion that would render empty, like #2462's + # ${WG_SERVER_PRIVATE_KEY}) is therefore caught before deploy. Placeholder + # key material stands in for the env-expanded secrets, so this needs no + # real secrets and runs on fork PRs too (issue #2477). + if: (github.event_name == 'pull_request' && needs.changes.outputs.talos == 'true') || github.event_name == 'merge_group' runs-on: ubuntu-latest permissions: contents: read # checkout repository @@ -1642,6 +1647,16 @@ jobs: run: | .github/scripts/setup-talosctl.sh + - name: โš™๏ธ Setup Go for version compatibility validation + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: โœ… Validate Kubernetes against the pinned Talos release + run: | + go test ./scripts/validate-talos-kubernetes-compatibility + go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml + - name: โœ… Render + validate patched machine configs run: | set -euo pipefail @@ -1786,6 +1801,7 @@ jobs: changes, validate-eks-authorization, validate-publication-contract, + validate-talos, validate-rgd-templates-merge-group, validate-ghcr-fanout-merge-group, ] @@ -1884,6 +1900,18 @@ jobs: ref: main persist-credentials: false + - name: โš™๏ธ Setup Go for version compatibility validation + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: โœ… Validate main Kubernetes against the pinned Talos release + # The heal deploys the current main checkout, which may have advanced + # since validate-talos inspected the speculative merge-group revision. + run: | + go test ./scripts/validate-talos-kubernetes-compatibility + go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml + - name: โš™๏ธ Setup Trivy for nested RGD templates uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 with: diff --git a/.github/workflows/validate-main.yaml b/.github/workflows/validate-main.yaml index 4778beb37..e0e1772f1 100644 --- a/.github/workflows/validate-main.yaml +++ b/.github/workflows/validate-main.yaml @@ -137,6 +137,36 @@ jobs: set -o pipefail ./scripts/report-publish-workflow-signing-revisions.sh | tee -a "$GITHUB_STEP_SUMMARY" + + # ci.yaml's validate-talos job runs the Kubernetes/Talos compatibility predicate on + # `pull_request` and `merge_group`, but neither event fires for a direct push to main. + # Such a push could otherwise reach the manually dispatched CD route unchecked. + # `ksail workload validate` does not cover this: it + # accepts the pins offline, the same way `talosctl validate --strict` does. Same gate on + # the push itself, for this workflow's header reason. + # + # Its own job rather than a step in another, matching validate-shared-publish-pin: a + # validator sharing a job with other command steps can be subverted by one of them. + validate-talos-kubernetes-compatibility: + name: ๐Ÿงช Validate Kubernetes/Talos Pin Compatibility + runs-on: ubuntu-latest + permissions: + contents: read # checkout repository + steps: + - name: ๐Ÿ“‘ Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: โš™๏ธ Setup Go for version compatibility validation + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: โœ… Validate Kubernetes against the pinned Talos release + run: | + go test ./scripts/validate-talos-kubernetes-compatibility + go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml # The pull-request gate extracts Kubernetes workloads nested in KRO RGDs because ordinary # manifest scanners see only the outer custom resource. A direct push bypasses that job and can # still reach the manual CD path, so main must exercise the identical behavioral contract. diff --git a/go.mod b/go.mod index 31a2816d1..949955f3a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,20 @@ module github.com/devantler-tech/platform go 1.26.6 -require gopkg.in/yaml.v3 v3.0.1 +require ( + github.com/siderolabs/talos/pkg/machinery v1.13.9 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect + github.com/siderolabs/gen v0.8.7 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect + google.golang.org/grpc v1.81.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect +) diff --git a/go.sum b/go.sum index a62c313c5..5eedda02d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,57 @@ +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 h1:S1hI5JiKP7883xBzZAr1ydcxrKNSVNm7+3+JwjxZEsg= +github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25/go.mod h1:ZQntvDG8TkPgljxtA0R9frDoND4QORU1VXz015N5Ks4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/siderolabs/gen v0.8.7 h1:Nu31kL0ln/facRHBfNX7zcB7w9VZ9tifXKsP4lUtWHw= +github.com/siderolabs/gen v0.8.7/go.mod h1:J9IbusbES2W6QWjtSHpDV9iPGZHc978h1+KJ4oQRspQ= +github.com/siderolabs/talos/pkg/machinery v1.13.9 h1:4Qch4CVfhT3C48PfiIFXYa1LAhSWA6o7NmrEO2bsLlg= +github.com/siderolabs/talos/pkg/machinery v1.13.9/go.mod h1:Ksw0+dndjKS0t1hJlkOfzNdv8KWY0g8En1rJZ0zB9YY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/scripts/validate-dr-signing/main_test.go b/scripts/validate-dr-signing/main_test.go index fa2fc7c36..ecac2ef08 100644 --- a/scripts/validate-dr-signing/main_test.go +++ b/scripts/validate-dr-signing/main_test.go @@ -189,8 +189,9 @@ func TestCDWiringRejectsEachAblation(t *testing.T) { w, " needs:\n [\n validate-eks-authorization,\n"+ " validate-publication-contract,\n"+ + " validate-talos-kubernetes-compatibility,\n"+ " validate-ghcr-fanout-component-gate,\n ]", - " needs: [validate-eks-authorization, validate-ghcr-fanout-component-gate]\n"+ + " needs: [validate-eks-authorization, validate-talos-kubernetes-compatibility, validate-ghcr-fanout-component-gate]\n"+ " env:\n NOTE: \"needs: [validate-publication-contract]\"", 1, ), a @@ -1555,6 +1556,7 @@ func TestMergeQueueContractGateIsEnforced(t *testing.T) { changes, validate-eks-authorization, validate-publication-contract, + validate-talos, validate-rgd-templates-merge-group, validate-ghcr-fanout-merge-group, ]` @@ -1562,6 +1564,7 @@ func TestMergeQueueContractGateIsEnforced(t *testing.T) { [ changes, validate-eks-authorization, + validate-talos, validate-rgd-templates-merge-group, validate-ghcr-fanout-merge-group, ]` diff --git a/scripts/validate-talos-kubernetes-compatibility/README.md b/scripts/validate-talos-kubernetes-compatibility/README.md new file mode 100644 index 000000000..a409d5aeb --- /dev/null +++ b/scripts/validate-talos-kubernetes-compatibility/README.md @@ -0,0 +1,30 @@ +# Kubernetes/Talos compatibility check + +Run from the repository root: + +```sh +go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml +go test ./scripts/validate-talos-kubernetes-compatibility +``` + +The check reads the explicit Kubernetes and Talos pins from one KSail YAML +document. It calls the versioned Talos machinery library's +`KubernetesVersion.SupportedWith` predicate, the same predicate used by Talos +runtime configuration validation. It starts no cluster, reads no credentials, +and does not generate machine secrets. + +`talosctl validate`, including `--strict`, does not invoke runtime validation; +it accepts the incompatible Kubernetes v1.37.0 / Talos v1.13.9 pairing offline. +The regression tests exercise that exact pairing and the supported v1.36.4 +control through the upstream compatibility predicate instead. + +The required Talos validation job runs this check when production pins, its +validator, the Talos installer, or Go dependency inputs change. Unknown Talos +release families fail closed; update the reviewed machinery dependency when +adopting a release it does not yet understand. No compatibility table is copied +into this repository. + +Passing this check proves a declared version pairing only. It does not prove +the fleet has completed an OS rollout or authorize merging a staged upgrade. +A Talos OS upgrade must still land and finish separately before Kubernetes is +raised, as documented beside the production pins. diff --git a/scripts/validate-talos-kubernetes-compatibility/main.go b/scripts/validate-talos-kubernetes-compatibility/main.go new file mode 100644 index 000000000..263eab52c --- /dev/null +++ b/scripts/validate-talos-kubernetes-compatibility/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "regexp" + + "github.com/siderolabs/talos/pkg/machinery/api/machine" + "github.com/siderolabs/talos/pkg/machinery/compatibility" + "gopkg.in/yaml.v3" +) + +var versionPin = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$`) + +// main validates the Kubernetes and Talos version pins in the ksail config +// named by the single optional argument, defaulting to ksail.prod.yaml. It exits +// 2 on usage error, 1 when the pins are rejected, and 0 when they are compatible. +func main() { + path := "ksail.prod.yaml" + if len(os.Args) > 2 { + fmt.Fprintln(os.Stderr, "usage: validate-talos-kubernetes-compatibility [ksail-config]") + os.Exit(2) + } + if len(os.Args) == 2 { + path = os.Args[1] + } + if err := validate(path); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("%s: pinned Kubernetes/Talos versions are compatible\n", path) +} + +// validate reports whether the ksail config at path pins a Kubernetes version that +// Talos verifies as compatible with its pinned Talos version. It fails closed on +// unreadable, malformed, multi-document, or unpinned input rather than treating an +// unvalidated pin as acceptable. +func validate(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read version pins: %w", err) + } + var config struct { + Spec struct { + Cluster struct { + Kubernetes string `yaml:"kubernetesVersion"` + Talos struct { + Version string `yaml:"version"` + } `yaml:"talos"` + } `yaml:"cluster"` + } `yaml:"spec"` + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&config); err != nil { + return fmt.Errorf("parse version pins: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return fmt.Errorf("%s: expected exactly one YAML document", path) + } + talosPin, kubernetesPin := config.Spec.Cluster.Talos.Version, config.Spec.Cluster.Kubernetes + if !versionPin.MatchString(talosPin) || !versionPin.MatchString(kubernetesPin) { + return fmt.Errorf("%s: explicit vMAJOR.MINOR.PATCH Talos and Kubernetes pins are required (got Talos %q, Kubernetes %q)", path, talosPin, kubernetesPin) + } + talos, err := compatibility.ParseTalosVersion(&machine.VersionInfo{Tag: talosPin}) + if err != nil { + return fmt.Errorf("parse Talos pin %q: %w", talosPin, err) + } + kubernetes, err := compatibility.ParseKubernetesVersion(kubernetesPin) + if err != nil { + return fmt.Errorf("parse Kubernetes pin %q: %w", kubernetesPin, err) + } + // Talos calls this same upstream predicate from RuntimeValidate. The offline + // talosctl validate command does not call it, even with --strict. Unknown + // Talos release families fail closed rather than inheriting an old ceiling. + if err := kubernetes.SupportedWith(talos); err != nil { + return fmt.Errorf("%s: Kubernetes %s is not verified compatible with Talos %s: %w. Choose compatible pins; complete any Talos upgrade separately before raising Kubernetes. If the Talos release is unknown, update the reviewed machinery dependency and revalidate", path, kubernetesPin, talosPin, err) + } + return nil +} diff --git a/scripts/validate-talos-kubernetes-compatibility/main_test.go b/scripts/validate-talos-kubernetes-compatibility/main_test.go new file mode 100644 index 000000000..a86d65ef0 --- /dev/null +++ b/scripts/validate-talos-kubernetes-compatibility/main_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func fixture(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "ksail.yaml") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestVersionPairings(t *testing.T) { + for _, tc := range []struct{ name, talos, kubernetes, diagnostic string }{ + {"current production", "v1.13.9", "v1.36.4", ""}, + {"in-range patch bump", "v1.13.9", "v1.36.5", ""}, + {"rejected PR 3534", "v1.13.9", "v1.37.0", "too new"}, + {"too old", "v1.13.9", "v1.30.0", "too old"}, + {"unknown Talos release", "v99.0.0", "v1.36.4", "not supported"}, + {"missing Talos pin", "", "v1.36.4", "explicit"}, + {"missing Kubernetes pin", "v1.13.9", "", "explicit"}, + {"malformed Talos pin", "latest", "v1.36.4", "explicit"}, + {"malformed Kubernetes pin", "v1.13.9", "v1.36", "explicit"}, + } { + t.Run(tc.name, func(t *testing.T) { + body := "spec:\n cluster:\n kubernetesVersion: " + tc.kubernetes + "\n talos:\n version: " + tc.talos + "\n" + err := validate(fixture(t, body)) + if tc.diagnostic == "" { + if err != nil { + t.Fatalf("compatible pairing rejected: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.diagnostic) { + t.Fatalf("expected %q rejection, got %v", tc.diagnostic, err) + } + if tc.name == "rejected PR 3534" { + for _, text := range []string{"v1.13.9", "v1.37.0", "Talos upgrade"} { + if !strings.Contains(err.Error(), text) { + t.Errorf("diagnostic does not name %q: %v", text, err) + } + } + } + }) + } +} + +func TestMalformedConfigurationFailsClosed(t *testing.T) { + for _, body := range []string{ + "", + "spec: [", + "spec: {}\nspec: {}\n", + "spec:\n cluster:\n kubernetesVersion: v1.36.4\n talos:\n version: v1.13.9\n---\nspec: {}\n", + } { + if err := validate(fixture(t, body)); err == nil { + t.Errorf("accepted malformed or incomplete config %q", body) + } + } + if err := validate(filepath.Join(t.TempDir(), "missing.yaml")); err == nil { + t.Fatal("accepted unreadable config") + } +} + +// Removing a version input from the actual path-filter registration must not +// turn a rejected pin into a skipped required job. +func TestProductionPinChangesSelectTalosValidation(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/ci.yaml") + if err != nil { + t.Fatal(err) + } + var workflow struct { + Jobs map[string]struct { + Steps []struct { + ID string `yaml:"id"` + With map[string]string `yaml:"with"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + var filters map[string][]string + for _, step := range workflow.Jobs["changes"].Steps { + if step.ID == "filter" { + if err := yaml.Unmarshal([]byte(step.With["filters"]), &filters); err != nil { + t.Fatal(err) + } + } + } + for _, changed := range []string{"ksail.prod.yaml", "go.mod", "go.sum", ".github/scripts/setup-talosctl.sh", "scripts/validate-talos-kubernetes-compatibility/main.go"} { + matched := false + for _, pattern := range filters["talos"] { + ok, err := filepath.Match(pattern, changed) + if err != nil { + t.Fatal(err) + } + matched = matched || ok + } + if !matched { + t.Errorf("change to %s skips required Talos validation", changed) + } + } +} + +type workflowJob struct { + If string `yaml:"if"` + Needs []string `yaml:"needs"` + Steps []struct { + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + Run string `yaml:"run"` + } `yaml:"steps"` +} + +func repositoryWorkflow(t *testing.T, name string) map[string]workflowJob { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", name)) + if err != nil { + t.Fatal(err) + } + var workflow struct { + Jobs map[string]workflowJob `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + return workflow.Jobs +} + +func jobRunsCompatibilityValidator(job workflowJob) bool { + for _, step := range job.Steps { + if strings.Contains(step.Run, "go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml") { + return true + } + } + return false +} + +func jobNeeds(job workflowJob, dependency string) bool { + for _, need := range job.Needs { + if need == dependency { + return true + } + } + return false +} + +func TestMergeGroupDeployRequiresCompatibilityValidation(t *testing.T) { + jobs := repositoryWorkflow(t, "ci.yaml") + validator, ok := jobs["validate-talos"] + if !ok || !jobRunsCompatibilityValidator(validator) { + t.Fatal("merge-queue workflow does not run the production Kubernetes/Talos compatibility validator") + } + if !strings.Contains(validator.If, "github.event_name == 'merge_group'") { + t.Errorf("compatibility validator does not run on merge_group: if=%q", validator.If) + } + if !jobNeeds(jobs["deploy-prod"], "validate-talos") { + t.Error("merge-group production deploy does not require compatibility validation") + } +} + +func TestManualDeployRequiresCompatibilityValidation(t *testing.T) { + jobs := repositoryWorkflow(t, "cd.yaml") + validator, ok := jobs["validate-talos-kubernetes-compatibility"] + if !ok || !jobRunsCompatibilityValidator(validator) { + t.Fatal("manual deployment workflow does not run the production Kubernetes/Talos compatibility validator") + } + if !jobNeeds(jobs["deploy-prod"], "validate-talos-kubernetes-compatibility") { + t.Error("manual production deploy does not require compatibility validation") + } +} + +func TestHealDeployValidatesCheckedOutMain(t *testing.T) { + steps := repositoryWorkflow(t, "ci.yaml")["heal-prod-on-failure"].Steps + checkoutMain, validator, deploy := -1, -1, -1 + for i, step := range steps { + if strings.HasPrefix(step.Uses, "actions/checkout@") && step.With["ref"] == "main" { + checkoutMain = i + } + if strings.Contains(step.Run, "go run ./scripts/validate-talos-kubernetes-compatibility ksail.prod.yaml") { + validator = i + } + if step.Uses == "./.github/actions/deploy-prod" { + deploy = i + } + } + if checkoutMain < 0 { + t.Fatal("heal route does not check out current main") + } + if validator < 0 { + t.Fatal("heal route does not validate the checked-out main Kubernetes/Talos pairing") + } + if deploy < 0 { + t.Fatal("heal route does not deploy production") + } + if !(checkoutMain < validator && validator < deploy) { + t.Errorf("heal route must validate after checkout and before deploy: checkout=%d validator=%d deploy=%d", checkoutMain, validator, deploy) + } +}