Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -257,6 +282,7 @@ jobs:
[
validate-eks-authorization,
validate-publication-contract,
validate-talos-kubernetes-compatibility,
validate-ghcr-fanout-component-gate,
]
runs-on: ubuntu-latest
Expand Down
42 changes: 35 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
devantler marked this conversation as resolved.

- name: ✅ Render + validate patched machine configs
run: |
set -euo pipefail
Expand Down Expand Up @@ -1786,6 +1801,7 @@ jobs:
changes,
validate-eks-authorization,
validate-publication-contract,
validate-talos,
validate-rgd-templates-merge-group,
validate-ghcr-fanout-merge-group,
]
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/validate-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
54 changes: 54 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
5 changes: 4 additions & 1 deletion scripts/validate-dr-signing/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1555,13 +1556,15 @@ func TestMergeQueueContractGateIsEnforced(t *testing.T) {
changes,
validate-eks-authorization,
validate-publication-contract,
validate-talos,
validate-rgd-templates-merge-group,
validate-ghcr-fanout-merge-group,
]`
deployNeedsWithoutGate = ` needs:
[
changes,
validate-eks-authorization,
validate-talos,
validate-rgd-templates-merge-group,
validate-ghcr-fanout-merge-group,
]`
Expand Down
30 changes: 30 additions & 0 deletions scripts/validate-talos-kubernetes-compatibility/README.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions scripts/validate-talos-kubernetes-compatibility/main.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading