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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ cluster:
> For Talos < v1.12, the output is a single YAML document with `machine.network` and `machine.registries` sections (as shown above).
> For Talos >= v1.12, the output uses the multi-document format with separate typed documents instead of the deprecated monolithic fields. `HostnameConfig`, `ResolverConfig` and a network interface document (`LinkConfig`, `BondConfig`, or `VLANConfig` — depending on topology) are always emitted; `Layer2VIPConfig` appears on controlplane nodes when `floatingIP` is set; `RegistryMirrorConfig` is emitted only by the cozystack chart.

> **Version compatibility (`templateOptions.talosVersion` / `--talos-version`).** This setting must match the **Talos version actually running on the target node** — i.e. the maintenance ISO/PXE the node booted from for `apply -i`, or the installed Talos for an authenticated apply. It is **not** the same as `install.image`, which only controls what gets written to disk after a successful apply. When the configured contract is newer than the running binary, machinery injects fields (e.g. `machine.install.grubUseUKICmdline` from v1.12) that the running parser does not know, and the apply fails on the node side with `failed to parse config: unknown keys found during decoding: ...`. `talm apply` runs a best-effort pre-flight check against the running version and prints a `warning: pre-flight: ...` line with a hint when it detects this mismatch; if the warning is missed, the same hint is appended to the apply error. Either reboot the node into a maintenance image that matches the configured contract, or lower `templateOptions.talosVersion` / `--talos-version` to match what is running.

Apply config:
```bash
talm apply -f nodes/node1.yaml -i
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ require (
filippo.io/age v1.3.1
github.com/BurntSushi/toml v1.6.0
github.com/Masterminds/sprig/v3 v3.3.0
github.com/cockroachdb/errors v1.13.0
github.com/gobwas/glob v0.2.3
github.com/pkg/errors v0.9.1
github.com/siderolabs/talos v1.12.6
Expand Down Expand Up @@ -137,6 +138,8 @@ require (
github.com/cilium/ebpf v0.21.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/containerd/containerd/v2 v2.2.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/go-cni v1.1.13 // indirect
Expand All @@ -159,6 +162,7 @@ require (
github.com/fluxcd/pkg/ssa v0.70.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/getsentry/sentry-go v0.46.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
Expand Down Expand Up @@ -198,6 +202,8 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/lmittmann/tint v1.1.3 // indirect
Expand Down Expand Up @@ -228,6 +234,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sasha-s/go-deadlock v0.3.9 // indirect
Expand Down
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA=
github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
github.com/cockroachdb/errors v1.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ=
github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/containerd/containerd/v2 v2.2.2 h1:mjVQdtfryzT7lOqs5EYUFZm8ioPVjOpkSoG1GJPxEMY=
github.com/containerd/containerd/v2 v2.2.2/go.mod h1:5Jhevmv6/2J+Iu/A2xXAdUIdI5Ah/hfyO7okJ4AFIdY=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
Expand All @@ -131,6 +137,7 @@ github.com/cozystack/talos v0.0.0-20260126122716-d18a185e3680/go.mod h1:8ltdWw6w
github.com/cozystack/talos/pkg/machinery v0.0.0-20260126122716-d18a185e3680 h1:p8xt+lGJBmlv7YTeg9HyynlIgjd22o3zcCV4WGaJpwA=
github.com/cozystack/talos/pkg/machinery v0.0.0-20260126122716-d18a185e3680/go.mod h1:dNc4lG9yb2CzCwnJbfSUO9ZmkXE6P3BnVo1UsCITr/U=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
Expand Down Expand Up @@ -179,6 +186,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
Expand Down Expand Up @@ -370,8 +379,11 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
Expand All @@ -393,6 +405,7 @@ github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
Expand Down
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/cockroachdb/errors"
"gopkg.in/yaml.v3"

"github.com/cozystack/talm/pkg/commands"
Expand Down Expand Up @@ -66,6 +67,10 @@ func Execute() error {
if err != nil && !common.SuppressErrors {
fmt.Fprintln(os.Stderr, err.Error())

for _, hint := range errors.GetAllHints(err) {
fmt.Fprintf(os.Stderr, "hint: %s\n", hint)
}

errorString := err.Error()
// TODO: this is a nightmare, but arg-flag related validation returns simple `fmt.Errorf`, no way to distinguish
// these errors
Expand Down
29 changes: 26 additions & 3 deletions pkg/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package commands
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
Expand Down Expand Up @@ -121,14 +122,18 @@ func apply(args []string) error {
fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, nodes, GlobalArgs.Endpoints)

applyClosure := func(ctx context.Context, c *client.Client, data []byte) error {
// applyTemplatesPerNode rotates ctx via client.WithNode per node,
// so ctx here is already single-target and safe for COSI reads.
preflightCheckTalosVersion(ctx, cosiVersionReader(c), applyCmdFlags.talosVersion, os.Stderr)

resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{
Data: data,
Mode: applyCmdFlags.Mode.Mode,
DryRun: applyCmdFlags.dryRun,
TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout),
})
if err != nil {
return fmt.Errorf("error applying new configuration: %w", err)
return fmt.Errorf("error applying new configuration: %w", annotateApplyConfigError(err))
}
helpers.PrintApplyResults(resp)
return nil
Expand Down Expand Up @@ -163,7 +168,25 @@ func apply(args []string) error {
}

if err := withApplyClient(func(ctx context.Context, c *client.Client) error {
fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, GlobalArgs.Nodes, GlobalArgs.Endpoints)
// wrapWithNodeContext fills ctx via client.WithNodes from
// talosconfig when --nodes is omitted, but does not mutate
// GlobalArgs.Nodes. Mirror its resolution here so the log line
// and the per-node preflight loop see the actual targets.
targetNodes := append([]string(nil), GlobalArgs.Nodes...)
if len(targetNodes) == 0 {
if cfg := c.GetConfigContext(); cfg != nil {
targetNodes = append(targetNodes, cfg.Nodes...)
}
}
fmt.Printf("- talm: file=%s, nodes=%s, endpoints=%s\n", configFile, targetNodes, GlobalArgs.Endpoints)

// COSI does not support multi-node proxying
// (see rotate_ca_handler.go:317). Run preflight per node with
// a single-target context.
read := cosiVersionReader(c)
for _, node := range targetNodes {
preflightCheckTalosVersion(client.WithNode(ctx, node), read, applyCmdFlags.talosVersion, os.Stderr)
}

resp, err := c.ApplyConfiguration(ctx, &machineapi.ApplyConfigurationRequest{
Data: result,
Expand All @@ -172,7 +195,7 @@ func apply(args []string) error {
TryModeTimeout: durationpb.New(applyCmdFlags.configTryTimeout),
})
if err != nil {
return fmt.Errorf("error applying new configuration: %w", err)
return fmt.Errorf("error applying new configuration: %w", annotateApplyConfigError(err))
}

helpers.PrintApplyResults(resp)
Expand Down
161 changes: 161 additions & 0 deletions pkg/commands/preflight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright Cozystack Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package commands

import (
"context"
"fmt"
"io"
"strings"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The time package is required to implement a timeout for the pre-flight version check.

Suggested change
"strings"
"strings"
"time"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b6e0618time is now imported and used by the new preflightCOSIReadTimeout constant in cosiVersionReader.

"time"

"github.com/cockroachdb/errors"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/safe"

"github.com/siderolabs/talos/pkg/machinery/client"
machineryconfig "github.com/siderolabs/talos/pkg/machinery/config"
"github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)

// preflightCOSIReadTimeout caps the COSI read latency so a slow or
// unresponsive node cannot turn a best-effort informational check into a
// blocker for `apply`. Two seconds is comfortably above any expected
// roundtrip on a healthy node and short enough to be unnoticeable when the
// read actually fails.
const preflightCOSIReadTimeout = 2 * time.Second

// preflightVersionMismatchHint is the hint attached to the warning when the
// configured talosVersion contract is newer than the version reported by the
// node. It does not name a specific Talos version — the warning line itself
// includes the concrete numbers.
const preflightVersionMismatchHint = "the generated config may include fields the node's machinery doesn't know; " +
"either reboot the node into a maintenance image matching templateOptions.talosVersion / --talos-version, " +
"or lower templateOptions.talosVersion / --talos-version to match the running Talos."

// applyConfigDecodeHint is the hint attached when the node's strict decoder
// rejects the applied config because of an unknown field. It points at the
// machinery contract / running Talos mismatch without naming a specific
// version.
const applyConfigDecodeHint = "the maintenance Talos parser on the node didn't recognize a field talm injected. " +
"this usually means templateOptions.talosVersion / --talos-version is set to a contract " +
"newer than the running Talos. reboot the node into a maintenance image matching the configured " +
"contract, or lower templateOptions.talosVersion / --talos-version to match what's running."

// annotateApplyConfigError attaches applyConfigDecodeHint when err is a
// strict-decoder failure from the node side. Returns err unchanged otherwise.
func annotateApplyConfigError(err error) error {
if err == nil {
return nil
}

if !strings.Contains(err.Error(), "unknown keys found during decoding:") {
return err
}

return errors.WithHint(err, applyConfigDecodeHint)
}

// versionReader fetches the running Talos version from a node. It returns
// ok=false on any error so callers can treat the result as best-effort. The
// signature is what makes preflightCheckTalosVersion testable without a live
// COSI server.
type versionReader func(ctx context.Context) (version string, ok bool)

// cosiVersionReader returns a versionReader that reads the Talos version from
// the node's COSI `Versions.runtime.talos.dev/runtime/version` resource. The
// resource is declared NonSensitive in Talos and is therefore reachable
// through a maintenance (--insecure) connection that only carries the Reader
// role. Any read failure (RPC error, NotFound, PermissionDenied, multi-node
// proxy error) is reported as ok=false.
func cosiVersionReader(c *client.Client) versionReader {
return func(ctx context.Context) (string, bool) {
ctx, cancel := context.WithTimeout(ctx, preflightCOSIReadTimeout)
defer cancel()

res, err := safe.StateGet[*runtime.Version](
ctx,
c.COSI,
resource.NewMetadata(runtime.NamespaceName, runtime.VersionType, "version", resource.VersionUndefined),
)
if err != nil {
return "", false
}
return res.TypedSpec().Version, true
}
}

// preflightCheckTalosVersion compares the configured Talos contract against
// the version reported by `read` and prints a warning + hint to `w` if the
// configured contract is strictly newer than the running version.
//
// Best-effort: any read or parse failure returns silently and never blocks
// apply. An empty configuredVersion is treated as TalosVersionCurrent (the
// nil-pointer contract that machinery uses by default), which is the most
// aggressive contract — this is the documented reproduction case for the
// "unknown keys found during decoding" error.
func preflightCheckTalosVersion(ctx context.Context, read versionReader, configuredVersion string, w io.Writer) {
runningVersion, ok := read(ctx)
if !ok {
return
}

warning := evaluateVersionMismatch(configuredVersion, runningVersion)
if warning == nil {
return
}

_, _ = fmt.Fprintln(w, "warning:", warning.Error())
for _, hint := range errors.GetAllHints(warning) {
_, _ = fmt.Fprintf(w, "hint: %s\n", hint)
}
}

// evaluateVersionMismatch returns a hint-bearing warning error if the
// configured contract is strictly newer than the running version. It returns
// nil when versions agree, when the configured contract isn't newer, or when
// the running version cannot be parsed (best-effort: never block on parse
// failure).
//
// An empty configuredVersion is treated as machinery's TalosVersionCurrent
// (nil pointer), which compares as strictly greater than every concrete
// version. This matches what generate.NewInput does when no
// WithVersionContract option is supplied.
func evaluateVersionMismatch(configuredVersion, runningVersion string) error {
var configuredContract *machineryconfig.VersionContract
if configuredVersion != "" {
var err error
configuredContract, err = machineryconfig.ParseContractFromVersion(configuredVersion)
if err != nil {
return nil
}
}

runningContract, err := machineryconfig.ParseContractFromVersion(runningVersion)
if err != nil {
return nil
}

if !configuredContract.Greater(runningContract) {
return nil
}

warning := fmt.Errorf(
"pre-flight: configured talosVersion=%s is newer than the node's running Talos %s",
configuredContract,
runningVersion,
)
return errors.WithHint(warning, preflightVersionMismatchHint)
}
Loading
Loading