feat: implement talos.config.early command line arg

Fixes #11449

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Andrey Smirnov 2025-08-01 19:41:07 +04:00
parent a5f3000f2e
commit 326a005382
No known key found for this signature in database
GPG Key ID: FE042E3D4085A811
6 changed files with 140 additions and 44 deletions

View File

@ -1439,7 +1439,7 @@ func (slb *siderolinkBuilder) SetKernelArgs(extraKernelArgs *procfs.Cmdline, tun
return fmt.Errorf("failed to close zstd encoder: %w", err) return fmt.Errorf("failed to close zstd encoder: %w", err)
} }
extraKernelArgs.Append(constants.KernelParamConfigInline, base64.StdEncoding.EncodeToString(buf.Bytes())) extraKernelArgs.Append(constants.KernelParamConfigEarly, base64.StdEncoding.EncodeToString(buf.Bytes()))
return nil return nil
} }

View File

@ -135,6 +135,14 @@ Legacy configuration in `valpha1` machine configuration is still supported.
New per-key option `lockToSTATE` is added to the `VolumeConfig` document, which allows to lock the volume encryption key to the secret salt in the `STATE` volume. New per-key option `lockToSTATE` is added to the `VolumeConfig` document, which allows to lock the volume encryption key to the secret salt in the `STATE` volume.
So, if the `STATE` volume is wiped or replaced, the volume encryption key will not be usable anymore. So, if the `STATE` volume is wiped or replaced, the volume encryption key will not be usable anymore.
"""
[notes.early-config]
title = "Early Inline Configuration"
description = """\
Talos now supports passing early inline configuration via the `talos.config.early` kernel parameter.
This allows to pass the configuration before the platform config source is probed, which is useful for early boot configuration.
The value of this parameter has same format as the `talos.config.inline` parameter, i.e. it should be base64 encoded and zstd-compressed.
""" """
[make_deps] [make_deps]

View File

@ -222,7 +222,7 @@ func (ctrl *AcquireController) Run(ctx context.Context, r controller.Runtime, lo
// //
// Transitions: // Transitions:
// //
// --> platform: no config found on disk, proceed to platform // --> cmdlineEarly: no config found on disk, proceed to cmdlineEarly
// --> maintenanceEnter: config found on disk, but it's incomplete, proceed to maintenance // --> maintenanceEnter: config found on disk, but it's incomplete, proceed to maintenance
// --> done: config found on disk, and it's complete // --> done: config found on disk, and it's complete
// //
@ -239,8 +239,8 @@ func (ctrl *AcquireController) stateDisk(ctx context.Context, r controller.Runti
// wait for the status to be available // wait for the status to be available
return nil, nil, nil return nil, nil, nil
case stateVolumeStatus.TypedSpec().Phase == block.VolumePhaseMissing: case stateVolumeStatus.TypedSpec().Phase == block.VolumePhaseMissing:
// STATE is missing, proceed to platform // STATE is missing, proceed to cmdlineEarly
return ctrl.statePlatform, nil, nil return ctrl.stateCmdlineEarly, nil, nil
case stateVolumeStatus.TypedSpec().Phase == block.VolumePhaseReady: case stateVolumeStatus.TypedSpec().Phase == block.VolumePhaseReady:
// STATE is ready, proceed to to the action // STATE is ready, proceed to to the action
default: default:
@ -264,8 +264,8 @@ func (ctrl *AcquireController) stateDisk(ctx context.Context, r controller.Runti
switch { switch {
case cfg == nil: case cfg == nil:
// no config loaded, proceed to platform // no config loaded, proceed to cmdlineEarly
return ctrl.statePlatform, nil, nil return ctrl.stateCmdlineEarly, nil, nil
case cfg.CompleteForBoot(): case cfg.CompleteForBoot():
// complete config, we are done // complete config, we are done
return ctrl.stateDone, cfg, nil return ctrl.stateDone, cfg, nil
@ -356,11 +356,23 @@ func (ctrl *AcquireController) loadConfigFromDisk(ctx context.Context, r control
return nil return nil
} }
// stateCmdlineEarly acquires machine configuration from the kernel cmdline source (talos.config.early).
//
// It is called before the platform source.
//
// Transitions:
//
// --> platform: config loaded from cmdline, but it's incomplete, or no config: proceed to platform
// --> done: config loaded from cmdline, and it's complete
func (ctrl *AcquireController) stateCmdlineEarly(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) {
return ctrl.stateCmdlineGeneric(constants.KernelParamConfigEarly, "cmdline-early", ctrl.statePlatform)(ctx, r, logger)
}
// statePlatform acquires machine configuration from the platform source. // statePlatform acquires machine configuration from the platform source.
// //
// Transitions: // Transitions:
// //
// --> cmdline: config loaded from platform, but it's incomplete, or no config from platform: proceed to cmdline // --> cmdlineLate: config loaded from platform, but it's incomplete, or no config from platform: proceed to cmdline
// --> done: config loaded from platform, and it's complete // --> done: config loaded from platform, and it's complete
func (ctrl *AcquireController) statePlatform(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) { func (ctrl *AcquireController) statePlatform(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) {
cfg, err := ctrl.loadFromPlatform(ctx, logger) cfg, err := ctrl.loadFromPlatform(ctx, logger)
@ -377,7 +389,7 @@ func (ctrl *AcquireController) statePlatform(ctx context.Context, r controller.R
fallthrough fallthrough
case !cfg.CompleteForBoot(): case !cfg.CompleteForBoot():
// incomplete or missing config, proceed to maintenance // incomplete or missing config, proceed to maintenance
return ctrl.stateCmdline, cfg, nil return ctrl.stateCmdlineLate, cfg, nil
default: default:
// complete config, we are done // complete config, we are done
return ctrl.stateDone, cfg, nil return ctrl.stateDone, cfg, nil
@ -445,52 +457,63 @@ func (ctrl *AcquireController) loadFromPlatform(ctx context.Context, logger *zap
return cfg, nil return cfg, nil
} }
// stateCmdline acquires machine configuration from the kernel cmdline source. // stateCmdlineLate acquires machine configuration from the kernel cmdline source (talos.config.inline).
//
// It is called after the platform source.
// //
// Transitions: // Transitions:
// //
// --> maintenanceEnter: config loaded from cmdline, but it's incomplete, or no config from platform: proceed to maintenance // --> maintenanceEnter: config loaded from cmdline, but it's incomplete, or no config from cmdline: proceed to maintenance
// --> done: config loaded from cmdline, and it's complete // --> done: config loaded from cmdline, and it's complete
func (ctrl *AcquireController) stateCmdline(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) { func (ctrl *AcquireController) stateCmdlineLate(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) {
if ctrl.Mode.InContainer() { return ctrl.stateCmdlineGeneric(constants.KernelParamConfigInline, "cmdline-late", ctrl.stateMaintenanceEnter)(ctx, r, logger)
// no cmdline in containers }
return ctrl.stateMaintenanceEnter, nil, nil
}
cfg, err := ctrl.loadFromCmdline(ctx, logger) // stateCmdlineGeneric is a generic function to load config from cmdline given a parameter name and source name, and the next state in the state machine.
if err != nil { func (ctrl *AcquireController) stateCmdlineGeneric(
return nil, nil, err paramName, sourceName string, next stateMachineFunc,
} ) func(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) {
return func(ctx context.Context, r controller.Runtime, logger *zap.Logger) (stateMachineFunc, config.Provider, error) {
if ctrl.Mode.InContainer() {
// no cmdline in containers
return next, nil, nil
}
if cfg != nil { cfg, err := ctrl.loadFromCmdline(ctx, logger, paramName)
ctrl.configSourcesUsed = append(ctrl.configSourcesUsed, "cmdline") if err != nil {
} return nil, nil, err
}
switch { if cfg != nil {
case cfg == nil: ctrl.configSourcesUsed = append(ctrl.configSourcesUsed, sourceName)
fallthrough }
case !cfg.CompleteForBoot():
// incomplete or missing config, proceed to maintenance switch {
return ctrl.stateMaintenanceEnter, cfg, nil case cfg == nil:
default: fallthrough
// complete config, we are done case !cfg.CompleteForBoot():
return ctrl.stateDone, cfg, nil // incomplete or missing config, proceed to maintenance
return next, cfg, nil
default:
// complete config, we are done
return ctrl.stateDone, cfg, nil
}
} }
} }
// loadFromCmdline is a helper function for stateCmdline. // loadFromCmdline is a helper function for stateCmdline.
// //
//nolint:gocyclo //nolint:gocyclo
func (ctrl *AcquireController) loadFromCmdline(ctx context.Context, logger *zap.Logger) (config.Provider, error) { func (ctrl *AcquireController) loadFromCmdline(ctx context.Context, logger *zap.Logger, paramName string) (config.Provider, error) {
cmdline := ctrl.CmdlineGetter() cmdline := ctrl.CmdlineGetter()
param := cmdline.Get(constants.KernelParamConfigInline) param := cmdline.Get(paramName)
if param == nil { if param == nil {
return nil, nil return nil, nil
} }
logger.Info("getting config from cmdline", zap.String("param", constants.KernelParamConfigInline)) logger.Info("getting config from cmdline", zap.String("param", paramName))
var cfgEncoded strings.Builder var cfgEncoded strings.Builder
@ -505,7 +528,7 @@ func (ctrl *AcquireController) loadFromCmdline(ctx context.Context, logger *zap.
cfgDecoded, err := base64.StdEncoding.DecodeString(cfgEncoded.String()) cfgDecoded, err := base64.StdEncoding.DecodeString(cfgEncoded.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode base64 config from cmdline %s: %w", constants.KernelParamConfigInline, err) return nil, fmt.Errorf("failed to decode base64 config from cmdline %s: %w", paramName, err)
} }
zr, err := zstd.NewReader(bytes.NewReader(cfgDecoded)) zr, err := zstd.NewReader(bytes.NewReader(cfgDecoded))
@ -517,26 +540,26 @@ func (ctrl *AcquireController) loadFromCmdline(ctx context.Context, logger *zap.
cfgBytes, err := io.ReadAll(zr) cfgBytes, err := io.ReadAll(zr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read zstd compressed config from cmdline %s: %w", constants.KernelParamConfigInline, err) return nil, fmt.Errorf("failed to read zstd compressed config from cmdline %s: %w", paramName, err)
} }
cfg, err := configloader.NewFromBytes(cfgBytes) cfg, err := configloader.NewFromBytes(cfgBytes)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load config via cmdline %s: %w", constants.KernelParamConfigInline, err) return nil, fmt.Errorf("failed to load config via cmdline %s: %w", paramName, err)
} }
warnings, err := cfg.Validate(ctrl.ValidationMode) warnings, err := cfg.Validate(ctrl.ValidationMode)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to validate config acquired via cmdline %s: %w", constants.KernelParamConfigInline, err) return nil, fmt.Errorf("failed to validate config acquired via cmdline %s: %w", paramName, err)
} }
warningsRuntime, err := cfg.RuntimeValidate(ctx, ctrl.ResourceState, ctrl.ValidationMode) warningsRuntime, err := cfg.RuntimeValidate(ctx, ctrl.ResourceState, ctrl.ValidationMode)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to validate config acquired via cmdline %s: %w", constants.KernelParamConfigInline, err) return nil, fmt.Errorf("failed to validate config acquired via cmdline %s: %w", paramName, err)
} }
for _, warning := range slices.Concat(warnings, warningsRuntime) { for _, warning := range slices.Concat(warnings, warningsRuntime) {
logger.Warn("config validation warning", zap.String("cmdline", constants.KernelParamConfigInline), zap.String("warning", warning)) logger.Warn("config validation warning", zap.String("cmdline", paramName), zap.String("warning", warning))
} }
return cfg, nil return cfg, nil

View File

@ -526,7 +526,7 @@ func (suite *AcquireSuite) TestFromPlatformToMaintenance() {
) )
} }
func (suite *AcquireSuite) TestFromCmdlineToMaintenance() { func (suite *AcquireSuite) TestFromCmdlineLateToMaintenance() {
var cfgCompressed bytes.Buffer var cfgCompressed bytes.Buffer
zw, err := zstd.NewWriter(&cfgCompressed) zw, err := zstd.NewWriter(&cfgCompressed)
@ -593,6 +593,57 @@ func (suite *AcquireSuite) TestFromCmdlineToMaintenance() {
) )
} }
func (suite *AcquireSuite) TestFromCmdlineEarlyToPlatform() {
var cfgCompressed bytes.Buffer
zw, err := zstd.NewWriter(&cfgCompressed)
suite.Require().NoError(err)
_, err = zw.Write(suite.partialMachineConfig)
suite.Require().NoError(err)
suite.Require().NoError(zw.Close())
cfgEncoded := base64.StdEncoding.EncodeToString(cfgCompressed.Bytes())
suite.cmdline.cmdline = procfs.NewCmdline(fmt.Sprintf("%s=%s", constants.KernelParamConfigEarly, cfgEncoded))
suite.noStateVolume()
suite.platformConfig.configuration = suite.completeMachineConfig
suite.platformConfig.err = nil
suite.triggerAcquire()
var cfg config.Provider
select {
case cfg = <-suite.configSetter.cfgCh:
case <-suite.Ctx().Done():
suite.Require().Fail("timed out waiting for config")
}
select {
case <-suite.configSetter.persistedCfgCh:
case <-suite.Ctx().Done():
suite.Require().Fail("timed out waiting for persisted config")
}
suite.Require().Equal(cfg.SideroLink().APIUrl().Host, "siderolink.api")
cfg = suite.waitForConfig(true)
suite.Require().Equal(cfg.Cluster().Name(), suite.clusterName)
suite.Assert().Empty(suite.eventPublisher.getEvents())
suite.Assert().Equal(
[]platform.Event{
{
Type: platform.EventTypeConfigLoaded,
Message: "Talos machine config loaded successfully.",
},
},
suite.platformEvent.getEvents(),
)
}
func (suite *AcquireSuite) TestFromMaintenance() { func (suite *AcquireSuite) TestFromMaintenance() {
suite.noStateVolume() suite.noStateVolume()
suite.triggerAcquire() suite.triggerAcquire()

View File

@ -25,6 +25,11 @@ const (
// The inline config should be base64 encoded and zstd-compressed. // The inline config should be base64 encoded and zstd-compressed.
KernelParamConfigInline = "talos.config.inline" KernelParamConfigInline = "talos.config.inline"
// KernelParamConfigEarly is the kernel parameter name for specifying the inline config (as the first source).
//
// The inline config should be base64 encoded and zstd-compressed.
KernelParamConfigEarly = "talos.config.early"
// KernelParamConfigOAuthClientID is the kernel parameter name for specifying the OAuth2 client ID. // KernelParamConfigOAuthClientID is the kernel parameter name for specifying the OAuth2 client ID.
KernelParamConfigOAuthClientID = "talos.config.oauth.client_id" KernelParamConfigOAuthClientID = "talos.config.oauth.client_id"

View File

@ -145,10 +145,19 @@ mkisofs -joliet -rock -volid 'metal-iso' -output config.iso iso/
Kernel parameters prefixed with `talos.config.auth.` are used to configure [OAuth2 authentication for the machine configuration]({{< relref "../advanced/machine-config-oauth" >}}). Kernel parameters prefixed with `talos.config.auth.` are used to configure [OAuth2 authentication for the machine configuration]({{< relref "../advanced/machine-config-oauth" >}}).
#### `talos.config.inline` #### `talos.config.early` and `talos.config.inline`
The kernel parameter `talos.config.inline` can be used to provide initial minimal machine configuration directly on the kernel command line, when other means of providing the configuration are not available. The kernel parameters `talos.config.early` and `talos.config.inline` are used to provide the initial machine configuration directly on the kernel command line.
The machine configuration should be `zstd` compressed and base64-encoded to be passed as a kernel parameter.
The difference between the two is the order of configuration loading:
* first, the persisted configuration is loaded from the `STATE` partition, if it exists;
* `talos.config.early` is tried next;
* any platform-specific configuration source is tried next (e.g. `talos.config` for the `metal` platform, our VM user data source, etc.);
* finally, `talos.config.inline` is tried;
* if no complete configuration is found, the system will enter maintenance mode.
For both arguments, the machine configuration should be `zstd` compressed and base64-encoded to be passed as a kernel parameter.
> Note: The kernel command line has a limited size (4096 bytes), so this method is only suitable for small configuration documents. > Note: The kernel command line has a limited size (4096 bytes), so this method is only suitable for small configuration documents.