Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 15 additions & 0 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 @@ -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
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
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
}
111 changes: 111 additions & 0 deletions scripts/validate-talos-kubernetes-compatibility/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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)
}
}
}
Loading