Skip to content

Commit 48fc83b

Browse files
committed
feat: add UnattendedInstall config and controller
Replace the deprecated v1alpha1 `.machine.install` section with a new `UnattendedInstall` multi-document config kind, driven by a dedicated `UnattendedInstallController` (single-flight, no reboot) that exposes an `UnattendedInstallStatus` resource. - Deprecate `.machine.install`; no longer required in validation. - Gate behind version contract (>= 1.14); `talosctl gen config` and `cluster create` emit `UnattendedInstall` by default for new contracts, translating `--install-disk` into a CEL disk selector. - Source `legacyBIOSSupport`/`grubUseUKICmdline` from the new document in the installer, falling back to `.machine.install`. - Skip the legacy install sequence (and its reboot) when the document is present; install is reconciled out-of-band. Signed-off-by: Mateusz Urbanek <mateusz.urbanek@siderolabs.com>
1 parent be7f7a7 commit 48fc83b

36 files changed

Lines changed: 1969 additions & 232 deletions

File tree

api/resource/definitions/runtime/runtime.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ message ServicePIDSpec {
216216
string mount_namespace = 2;
217217
}
218218

219+
// UnattendedInstallStatusSpec describes the unattended install status.
220+
message UnattendedInstallStatusSpec {
221+
string image = 2;
222+
string phase = 4;
223+
string error = 5;
224+
}
225+
219226
// UniqueMachineTokenSpec is the spec for the machine unique token. Token can be empty if machine wasn't assigned any.
220227
message UniqueMachineTokenSpec {
221228
string token = 1;

cmd/installer/cmd/installer/install.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,30 @@ func runInstallCmd(ctx context.Context) (err error) {
7373
}
7474
}
7575

76-
if config.Machine() != nil && config.Machine().Install().LegacyBIOSSupport() {
77-
options.LegacyBIOSSupport = true
78-
}
76+
// defaults from the deprecated .machine.install section (if present).
77+
legacyBIOSSupport := config.Machine() != nil && config.Machine().Install().LegacyBIOSSupport()
7978

8079
// if we don't have v1alpha1 config (we are in maintenance mode),
8180
// or if we have v1alpha1 config, and GrubUseUKICmdline is set to true,
8281
// then we should set the option to true
83-
if config.Machine() == nil || config.Machine().Install().GrubUseUKICmdline() {
82+
grubUseUKICmdline := config.Machine() == nil || config.Machine().Install().GrubUseUKICmdline()
83+
84+
// the UnattendedInstall document takes precedence over the deprecated .machine.install section.
85+
if unattended := config.UnattendedInstallConfig(); unattended != nil {
86+
if v := unattended.InstallLegacyBIOSSupport(); v != nil {
87+
legacyBIOSSupport = *v
88+
}
89+
90+
if v := unattended.InstallGrubUseUKICmdline(); v != nil {
91+
grubUseUKICmdline = *v
92+
}
93+
}
94+
95+
if legacyBIOSSupport {
96+
options.LegacyBIOSSupport = true
97+
}
98+
99+
if grubUseUKICmdline {
84100
options.GrubUseUKICmdline = true
85101
}
86102
}

hack/release.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,20 @@ Volume encryption now supports an `allowDiscards` option (disabled by default) w
270270
through to the underlying device when the encrypted volume is opened.
271271
272272
This only enables passing discards through to the underlying device; Talos does not perform any fstrim/discard operation by itself.
273+
"""
274+
275+
[notes.unattended_install]
276+
title = "Unattended Install Configuration"
277+
description = """\
278+
Talos introduces a new `UnattendedInstall` multi-document config kind which replaces the deprecated `.machine.install`
279+
section of the v1alpha1 config. The document carries the installer `image` and a `provisioning` section with a CEL
280+
`volumeSelector` to match the install disk, plus `wipe`, `legacyBIOSSupport`, and `grubUseUKICmdline` options.
281+
282+
When the `UnattendedInstall` document is present, the install is driven by the new `UnattendedInstallController`
283+
(exposing an `UnattendedInstallStatus` resource) instead of the legacy install sequence.
284+
285+
`talosctl gen config` and `talosctl cluster create` now generate the `UnattendedInstall` document by default.
286+
The `.machine.install` field remains supported for backwards compatibility and is still used for older version contracts.
273287
"""
274288

275289
[make_deps]
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package runtime
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"path/filepath"
11+
"sync"
12+
13+
"github.com/cosi-project/runtime/pkg/controller"
14+
"github.com/cosi-project/runtime/pkg/safe"
15+
"github.com/cosi-project/runtime/pkg/state"
16+
"github.com/siderolabs/gen/optional"
17+
"go.uber.org/zap"
18+
19+
v1alpha1runtime "github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
20+
"github.com/siderolabs/talos/internal/pkg/install"
21+
"github.com/siderolabs/talos/pkg/images"
22+
talosconfig "github.com/siderolabs/talos/pkg/machinery/config/config"
23+
"github.com/siderolabs/talos/pkg/machinery/config/types/block/blockhelpers"
24+
"github.com/siderolabs/talos/pkg/machinery/resources/block"
25+
"github.com/siderolabs/talos/pkg/machinery/resources/config"
26+
crires "github.com/siderolabs/talos/pkg/machinery/resources/cri"
27+
"github.com/siderolabs/talos/pkg/machinery/resources/runtime"
28+
)
29+
30+
// UnattendedInstallController performs an unattended install driven by the UnattendedInstall config document.
31+
//
32+
// It mirrors the legacy `.machine.install` install behavior, but is driven entirely by the multi-document
33+
// config. It does NOT reboot the node after a successful install; reboot is handled separately.
34+
type UnattendedInstallController struct {
35+
V1Alpha1Mode v1alpha1runtime.Mode
36+
37+
// State is the resource state used to match the install disk.
38+
State state.State
39+
40+
// InstalledFunc reports whether the node is already installed to disk.
41+
InstalledFunc func() bool
42+
43+
// InstallFunc performs the actual install of the given image to the given disk.
44+
//
45+
// It is a field to allow the install side-effect to be stubbed in tests.
46+
InstallFunc func(ctx context.Context, disk, image string, wipe bool) error
47+
48+
// installMu provides single-flight semantics for the install: only one install may run at a time.
49+
installMu sync.Mutex
50+
// installDone records, in-memory, that the installer has already run this boot, so it is never run
51+
// twice even if the status resource read lags behind a just-written value.
52+
installDone bool
53+
}
54+
55+
// NewUnattendedInstallController creates an UnattendedInstallController wired to the runtime, with the
56+
// default install behavior (run the installer container, waiting for the image cache around it).
57+
func NewUnattendedInstallController(rt v1alpha1runtime.Runtime) *UnattendedInstallController {
58+
resources := rt.State().V1Alpha2().Resources()
59+
60+
return &UnattendedInstallController{
61+
V1Alpha1Mode: rt.State().Platform().Mode(),
62+
State: resources,
63+
InstalledFunc: rt.State().Machine().Installed,
64+
InstallFunc: func(ctx context.Context, disk, image string, wipe bool) error {
65+
if err := crires.WaitForImageCache(ctx, resources); err != nil {
66+
return fmt.Errorf("failed to wait for the image cache: %w", err)
67+
}
68+
69+
if err := install.RunInstallerContainer(
70+
disk,
71+
rt.State().Platform().Name(),
72+
image,
73+
rt.Config(),
74+
rt.ConfigContainer(),
75+
resources,
76+
crires.RegistryBuilder(resources),
77+
install.WithForce(true),
78+
install.WithZero(wipe),
79+
); err != nil {
80+
return err
81+
}
82+
83+
return crires.WaitForImageCacheCopy(ctx, resources)
84+
},
85+
}
86+
}
87+
88+
// Name implements controller.Controller interface.
89+
func (ctrl *UnattendedInstallController) Name() string {
90+
return "runtime.UnattendedInstallController"
91+
}
92+
93+
// Inputs implements controller.Controller interface.
94+
func (ctrl *UnattendedInstallController) Inputs() []controller.Input {
95+
return []controller.Input{
96+
{
97+
Namespace: config.NamespaceName,
98+
Type: config.MachineConfigType,
99+
ID: optional.Some(config.ActiveID),
100+
Kind: controller.InputWeak,
101+
},
102+
{
103+
Namespace: block.NamespaceName,
104+
Type: block.DiskType,
105+
Kind: controller.InputWeak,
106+
},
107+
}
108+
}
109+
110+
// Outputs implements controller.Controller interface.
111+
func (ctrl *UnattendedInstallController) Outputs() []controller.Output {
112+
return []controller.Output{
113+
{
114+
Type: runtime.UnattendedInstallStatusType,
115+
Kind: controller.OutputExclusive,
116+
},
117+
}
118+
}
119+
120+
// Run implements controller.Controller interface.
121+
func (ctrl *UnattendedInstallController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error {
122+
for {
123+
select {
124+
case <-ctx.Done():
125+
return nil
126+
case <-r.EventCh():
127+
}
128+
129+
// no on-disk install in container mode.
130+
if ctrl.V1Alpha1Mode == v1alpha1runtime.ModeContainer {
131+
continue
132+
}
133+
134+
cfg, err := safe.ReaderGetByID[*config.MachineConfig](ctx, r, config.ActiveID)
135+
if err != nil && !state.IsNotFoundError(err) {
136+
return fmt.Errorf("error getting machine config: %w", err)
137+
}
138+
139+
var doc talosconfig.UnattendedInstallConfig
140+
141+
if cfg != nil {
142+
doc = cfg.Config().UnattendedInstallConfig()
143+
}
144+
145+
r.StartTrackingOutputs()
146+
147+
if doc != nil {
148+
if err = ctrl.reconcile(ctx, r, logger, doc); err != nil {
149+
return err
150+
}
151+
}
152+
153+
if err = safe.CleanupOutputs[*runtime.UnattendedInstallStatus](ctx, r); err != nil {
154+
return err
155+
}
156+
}
157+
}
158+
159+
//nolint:gocyclo
160+
func (ctrl *UnattendedInstallController) reconcile(
161+
ctx context.Context,
162+
r controller.Runtime,
163+
logger *zap.Logger,
164+
doc talosconfig.UnattendedInstallConfig,
165+
) error {
166+
// Once we have recorded a completed install for this boot, the install target is fixed.
167+
// A new disk later matching the selector must not flip the reported disk or trigger a reinstall.
168+
if existing, err := safe.ReaderGetByID[*runtime.UnattendedInstallStatus](ctx, r, runtime.UnattendedInstallStatusID); err != nil {
169+
if !state.IsNotFoundError(err) {
170+
return fmt.Errorf("error getting unattended install status: %w", err)
171+
}
172+
} else if existing.TypedSpec().Phase == runtime.UnattendedInstallPhaseInstalled {
173+
// re-affirm the status (so CleanupOutputs retains it): the install target is fixed and a new
174+
// disk later matching the selector must not trigger a reinstall.
175+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalled, nil)
176+
}
177+
178+
// resolve the target disk from the CEL selector against the discovered disks.
179+
// the selector still matches after the install, so the disk is re-resolved and reported in the
180+
// status after a reboot (the status resource is in-memory and gone after reboot).
181+
matchExpr := doc.VolumeSelector()
182+
183+
matchedDisks, err := blockhelpers.MatchDisks(ctx, ctrl.State, &matchExpr)
184+
if err != nil {
185+
return fmt.Errorf("failed to match install disk: %w", err)
186+
}
187+
188+
var disk string
189+
190+
if len(matchedDisks) > 0 {
191+
if len(matchedDisks) > 1 {
192+
logger.Warn("multiple disks matched the install selector, using the first one",
193+
zap.Int("matched", len(matchedDisks)),
194+
zap.String("disk", matchedDisks[0].TypedSpec().DevPath),
195+
)
196+
}
197+
198+
if disk, err = filepath.EvalSymlinks(matchedDisks[0].TypedSpec().DevPath); err != nil {
199+
return fmt.Errorf("failed to resolve disk symlink: %w", err)
200+
}
201+
}
202+
203+
if ctrl.InstalledFunc() {
204+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalled, nil)
205+
}
206+
207+
if len(matchedDisks) == 0 {
208+
// disks may not have been discovered yet; record and wait for the next event.
209+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhasePending, fmt.Errorf("no disk matched the selector"))
210+
}
211+
212+
// single-flight: only one install may run at a time.
213+
if !ctrl.installMu.TryLock() {
214+
// an install is already in progress; keep the status and wait for it to complete.
215+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalling, nil)
216+
}
217+
defer ctrl.installMu.Unlock()
218+
219+
// the installer already ran this boot: don't run it again, just keep the status as installed.
220+
if ctrl.installDone {
221+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalled, nil)
222+
}
223+
224+
installerImage := doc.InstallerImage()
225+
if installerImage == "" {
226+
installerImage = images.InstallerImage("metal")
227+
}
228+
229+
if err = ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalling, nil); err != nil {
230+
return err
231+
}
232+
233+
logger.Info("installing Talos", zap.String("disk", disk), zap.String("image", installerImage))
234+
235+
if err = ctrl.InstallFunc(ctx, disk, installerImage, doc.VolumeWipe()); err != nil {
236+
// record the failure; returning the error restarts the controller and retries with backoff.
237+
_ = ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseFailed, err) //nolint:errcheck
238+
239+
return fmt.Errorf("failed to run installer: %w", err)
240+
}
241+
242+
ctrl.installDone = true
243+
244+
logger.Info("install successful")
245+
246+
return ctrl.setStatus(ctx, r, doc, runtime.UnattendedInstallPhaseInstalled, nil)
247+
}
248+
249+
func (ctrl *UnattendedInstallController) setStatus(
250+
ctx context.Context,
251+
r controller.Runtime,
252+
doc talosconfig.UnattendedInstallConfig,
253+
phase string,
254+
statusErr error,
255+
) error {
256+
return safe.WriterModify(ctx, r, runtime.NewUnattendedInstallStatus(), func(status *runtime.UnattendedInstallStatus) error {
257+
status.TypedSpec().Image = doc.InstallerImage()
258+
status.TypedSpec().Phase = phase
259+
260+
if statusErr != nil {
261+
status.TypedSpec().Error = statusErr.Error()
262+
} else {
263+
status.TypedSpec().Error = ""
264+
}
265+
266+
return nil
267+
})
268+
}

0 commit comments

Comments
 (0)