Skip to content

Commit 2fa8855

Browse files
committed
feat: validate Talos extensions against the catalog
Extension names on installation media configs, machine request sets, and extensions configurations are now validated against the Talos extensions catalog for the relevant Talos version. Unknown names, duplicates, and oversized lists are rejected. When no Talos version is set, the default version's catalog is used so the names still get checked. Names without a namespace are looked up under siderolabs/ so older clients that send the documented short form keep working. The omnictl installation media create command also resolves short or partial extension names to canonical form before sending, replacing the client-side catalog check it used to do. Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
1 parent 807fe47 commit 2fa8855

12 files changed

Lines changed: 462 additions & 27 deletions

File tree

client/pkg/omnictl/installationmedia/create.go

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -121,27 +121,28 @@ func createPreset(ctx context.Context, cmd *cobra.Command, client *client.Client
121121
// Strip a leading "v" so the value matches the canonical Talos version form.
122122
talosVersion := strings.TrimPrefix(createCmdFlags.talosVersion, "v")
123123

124-
// An empty user value means "use the server's default at download time"; for create-time
125-
// validation against platform min-versions and extension catalogs, fall back to the CLI's
126-
// default Talos version so the checks still run.
127-
validationTalosVersion := talosVersion
128-
if validationTalosVersion == "" {
129-
validationTalosVersion = constants.DefaultTalosVersion
124+
bootloader, err := download.ParseBootloader(createCmdFlags.bootloader)
125+
if err != nil {
126+
return err
130127
}
131128

132-
if err = download.ValidateTalosVersion(ctx, client.Omni().State(), validationTalosVersion); err != nil {
133-
return err
129+
// Resolve short or partial extension names to full catalog names. Falls back to the default
130+
// Talos version when the user left it empty, since a concrete catalog is needed to look the
131+
// names up. The server still validates the result.
132+
resolveTalosVersion := talosVersion
133+
if resolveTalosVersion == "" {
134+
resolveTalosVersion = constants.DefaultTalosVersion
134135
}
135136

136-
bootloader, err := download.ParseBootloader(createCmdFlags.bootloader)
137+
resolvedExtensions, err := download.ResolveExtensions(ctx, client.Omni().State(), resolveTalosVersion, createCmdFlags.extensions)
137138
if err != nil {
138139
return err
139140
}
140141

141142
spec := &specs.InstallationMediaConfigSpec{
142143
TalosVersion: talosVersion,
143144
Architecture: arch,
144-
InstallExtensions: createCmdFlags.extensions,
145+
InstallExtensions: resolvedExtensions,
145146
KernelArgs: strings.Join(createCmdFlags.extraKernelArgs, " "),
146147
JoinToken: tokenID,
147148
SecureBoot: createCmdFlags.secureBoot,
@@ -158,7 +159,7 @@ func createPreset(ctx context.Context, cmd *cobra.Command, client *client.Client
158159
}
159160

160161
if createCmdFlags.platform != "" {
161-
if err = download.ValidateCloudPlatform(ctx, client.Omni().State(), createCmdFlags.platform, arch, createCmdFlags.secureBoot, validationTalosVersion); err != nil {
162+
if err = download.ValidateCloudPlatform(ctx, client.Omni().State(), createCmdFlags.platform, arch, createCmdFlags.secureBoot, resolveTalosVersion); err != nil {
162163
return err
163164
}
164165

@@ -168,7 +169,7 @@ func createPreset(ctx context.Context, cmd *cobra.Command, client *client.Client
168169
}
169170

170171
if createCmdFlags.overlay != "" {
171-
if err = download.ValidateSBC(ctx, client.Omni().State(), createCmdFlags.overlay, validationTalosVersion); err != nil {
172+
if err = download.ValidateSBC(ctx, client.Omni().State(), createCmdFlags.overlay, resolveTalosVersion); err != nil {
172173
return err
173174
}
174175

@@ -178,10 +179,6 @@ func createPreset(ctx context.Context, cmd *cobra.Command, client *client.Client
178179
}
179180
}
180181

181-
if err = download.ValidateExtensions(ctx, client.Omni().State(), validationTalosVersion, createCmdFlags.extensions); err != nil {
182-
return err
183-
}
184-
185182
if createCmdFlags.labels != nil {
186183
spec.MachineLabels, err = download.ParseLabelPairs(createCmdFlags.labels)
187184
if err != nil {

client/pkg/omnictl/internal/download/download.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,16 @@ func ValidateExtensions(ctx context.Context, st state.State, talosVersion string
328328
return err
329329
}
330330

331+
// ResolveExtensions expands short or partial extension names into full catalog names for the
332+
// given Talos version. Returns an error if any name has no match.
333+
func ResolveExtensions(ctx context.Context, st state.State, talosVersion string, extensions []string) ([]string, error) {
334+
if len(extensions) == 0 {
335+
return nil, nil
336+
}
337+
338+
return lookupExtensions(ctx, st, talosVersion, extensions, false)
339+
}
340+
331341
func checkMinTalosVersion(actual, minVersion, source string) error {
332342
if minVersion == "" {
333343
return nil

internal/backend/runtime/omni/validations/export_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,7 @@ func MetadataValidationOptions() []validated.StateOption {
120120
func KubernetesHealthCheckValidationOptions() []validated.StateOption {
121121
return kubernetesHealthcheckValidationOptions()
122122
}
123+
124+
func ExtensionsConfigurationValidationOptions(st state.State) []validated.StateOption {
125+
return extensionsConfigurationValidationOptions(st)
126+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
6+
package validations
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"strings"
12+
13+
"github.com/cosi-project/runtime/pkg/safe"
14+
"github.com/cosi-project/runtime/pkg/state"
15+
16+
"github.com/siderolabs/omni/client/pkg/constants"
17+
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
18+
"github.com/siderolabs/omni/internal/backend/extensions"
19+
)
20+
21+
// MaxExtensionsCount caps the number of entries in a repeated extensions list. The value is
22+
// arbitrary, picked well above what real callers would send. Bump if needed.
23+
const MaxExtensionsCount = 256
24+
25+
// validateExtensions checks each requested extension name against the TalosExtensions catalog. An
26+
// empty list is accepted. An empty Talos version is the "automatic" sentinel used by resources
27+
// like InstallationMediaConfig, in which case the default Talos version's catalog is used.
28+
// Names without a slash are looked up under the official extensions namespace so older clients
29+
// that send the documented short form keep working. The list is rejected if it exceeds MaxExtensionsCount or
30+
// contains duplicate entries.
31+
func validateExtensions(ctx context.Context, st state.State, talosVersion string, names []string) error {
32+
if len(names) == 0 {
33+
return nil
34+
}
35+
36+
if len(names) > MaxExtensionsCount {
37+
return fmt.Errorf("extensions list has %d entries, exceeds maximum of %d", len(names), MaxExtensionsCount)
38+
}
39+
40+
if talosVersion == "" {
41+
talosVersion = constants.DefaultTalosVersion
42+
}
43+
44+
catalog, err := safe.StateGet[*omni.TalosExtensions](ctx, st, omni.NewTalosExtensions(talosVersion).Metadata())
45+
if err != nil {
46+
if state.IsNotFoundError(err) {
47+
return fmt.Errorf("no Talos extensions catalog for version %q", talosVersion)
48+
}
49+
50+
return fmt.Errorf("failed to look up Talos extensions catalog for version %q: %w", talosVersion, err)
51+
}
52+
53+
available := make(map[string]struct{}, len(catalog.TypedSpec().Value.GetItems()))
54+
for _, item := range catalog.TypedSpec().Value.GetItems() {
55+
available[item.GetName()] = struct{}{}
56+
}
57+
58+
seen := make(map[string]struct{}, len(names))
59+
60+
for i, name := range names {
61+
lookup := name
62+
if !strings.Contains(lookup, "/") {
63+
lookup = extensions.OfficialPrefix + lookup
64+
}
65+
66+
if _, ok := available[lookup]; !ok {
67+
return fmt.Errorf("extension %q (entry %d) is not available for Talos version %q", name, i, talosVersion)
68+
}
69+
70+
if _, ok := seen[lookup]; ok {
71+
return fmt.Errorf("extension %q (entry %d) is listed more than once", name, i)
72+
}
73+
74+
seen[lookup] = struct{}{}
75+
}
76+
77+
return nil
78+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
6+
package validations
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
13+
"github.com/cosi-project/runtime/pkg/safe"
14+
"github.com/cosi-project/runtime/pkg/state"
15+
16+
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
17+
"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
18+
)
19+
20+
func extensionsConfigurationValidationOptions(st state.State) []validated.StateOption {
21+
validate := func(ctx context.Context, res *omni.ExtensionsConfiguration) error {
22+
extensions := res.TypedSpec().Value.GetExtensions()
23+
if len(extensions) == 0 {
24+
return nil
25+
}
26+
27+
clusterID, ok := res.Metadata().Labels().Get(omni.LabelCluster)
28+
if !ok || clusterID == "" {
29+
return errors.New("extensions configuration with a non-empty extensions list must target a cluster via the cluster label")
30+
}
31+
32+
cluster, err := safe.StateGet[*omni.Cluster](ctx, st, omni.NewCluster(clusterID).Metadata())
33+
if err != nil {
34+
if state.IsNotFoundError(err) {
35+
return fmt.Errorf("cluster %q does not exist", clusterID)
36+
}
37+
38+
return fmt.Errorf("failed to look up cluster %q: %w", clusterID, err)
39+
}
40+
41+
return validateExtensions(ctx, st, cluster.TypedSpec().Value.GetTalosVersion(), extensions)
42+
}
43+
44+
return []validated.StateOption{
45+
validated.WithCreateValidations(validated.NewCreateValidationForType(func(ctx context.Context, res *omni.ExtensionsConfiguration, _ ...state.CreateOption) error {
46+
return validate(ctx, res)
47+
})),
48+
validated.WithUpdateValidations(validated.NewUpdateValidationForType(func(ctx context.Context, _, newRes *omni.ExtensionsConfiguration, _ ...state.UpdateOption) error {
49+
return validate(ctx, newRes)
50+
})),
51+
}
52+
}

internal/backend/runtime/omni/validations/infra_machine_config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import (
1919
"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
2020
)
2121

22-
// MaxRequestIDLength caps the byte length of the request ID fields on InfraMachineConfig.
22+
// MaxRequestIDLength caps the byte length of the request ID fields on InfraMachineConfig. The
23+
// value is arbitrary, picked well above what real callers would send. Bump if needed.
2324
const MaxRequestIDLength = 128
2425

2526
func infraMachineConfigValidationOptions(st state.State) []validated.StateOption {

internal/backend/runtime/omni/validations/installation_media_config.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,22 @@ func installationMediaConfigValidationOptions(st state.State) []validated.StateO
6363
return err
6464
}
6565

66-
if version := spec.GetTalosVersion(); version != "" {
67-
// Strip a leading "v" before the lookup so older omnictl clients that did not yet
68-
// normalize the user input still validate against the canonical "1.2.3" form stored
69-
// on Talos version resources.
70-
lookup := strings.TrimPrefix(version, "v")
66+
// Strip a leading "v" so older omnictl clients that did not yet normalize the user input
67+
// still validate against the canonical "1.2.3" form stored on Talos version resources and
68+
// the extensions catalog.
69+
talosVersion := strings.TrimPrefix(spec.GetTalosVersion(), "v")
7170

72-
if _, err := safe.StateGet[*omni.TalosVersion](ctx, st, omni.NewTalosVersion(lookup).Metadata()); err != nil {
71+
if talosVersion != "" {
72+
if _, err := safe.StateGet[*omni.TalosVersion](ctx, st, omni.NewTalosVersion(talosVersion).Metadata()); err != nil {
7373
if state.IsNotFoundError(err) {
74-
return fmt.Errorf("unknown Talos version %q", version)
74+
return fmt.Errorf("unknown Talos version %q", spec.GetTalosVersion())
7575
}
7676

77-
return fmt.Errorf("failed to look up Talos version %q: %w", version, err)
77+
return fmt.Errorf("failed to look up Talos version %q: %w", spec.GetTalosVersion(), err)
7878
}
7979
}
8080

81-
return nil
81+
return validateExtensions(ctx, st, talosVersion, spec.GetInstallExtensions())
8282
}
8383

8484
return []validated.StateOption{

internal/backend/runtime/omni/validations/kernel_args.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
1515
)
1616

17+
// The caps below are arbitrary, picked well above what real callers would send.
18+
// They bound user input. Bump if needed.
1719
const (
1820
// MaxKernelArgLength caps the byte length of a single entry in a repeated kernel args list.
1921
MaxKernelArgLength = 256

internal/backend/runtime/omni/validations/machine_request_set.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,9 @@ func validateMachineRequestSet(ctx context.Context, st state.State, oldRes, res
4141
return err
4242
}
4343

44-
return validateTalosVersion(ctx, st, "", res.TypedSpec().Value.TalosVersion)
44+
if err := validateTalosVersion(ctx, st, "", res.TypedSpec().Value.TalosVersion); err != nil {
45+
return err
46+
}
47+
48+
return validateExtensions(ctx, st, res.TypedSpec().Value.TalosVersion, res.TypedSpec().Value.GetExtensions())
4549
}

internal/backend/runtime/omni/validations/metadata.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/siderolabs/omni/internal/backend/runtime/omni/validated"
1919
)
2020

21+
// The caps below are arbitrary, picked well above what real callers would send.
22+
// They bound user input. Bump if needed.
2123
const (
2224
// MaxResourceIDLength caps the byte length of a resource ID.
2325
MaxResourceIDLength = 1024

0 commit comments

Comments
 (0)