diff --git a/cmd/talosctl/cmd/constants/constants.go b/cmd/talosctl/cmd/constants/constants.go new file mode 100644 index 00000000000..192a926d132 --- /dev/null +++ b/cmd/talosctl/cmd/constants/constants.go @@ -0,0 +1,13 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package constants + +const ( + // ImageFactoryEmptySchematicID is the ID of an empty image factory schematic. + ImageFactoryEmptySchematicID = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + + // ImageFactoryURL is the url of the Sidero hosted image factory. + ImageFactoryURL = "https://factory.talos.dev/" +) diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common.go index f309bfe3fcd..23c9ee0d955 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common.go @@ -11,6 +11,7 @@ import ( "net/netip" "os" "slices" + "strconv" "strings" "github.com/google/uuid" @@ -23,8 +24,10 @@ import ( "github.com/siderolabs/talos/pkg/machinery/config" "github.com/siderolabs/talos/pkg/machinery/config/bundle" "github.com/siderolabs/talos/pkg/machinery/config/configpatcher" + "github.com/siderolabs/talos/pkg/machinery/config/container" "github.com/siderolabs/talos/pkg/machinery/config/generate" "github.com/siderolabs/talos/pkg/machinery/config/machine" + "github.com/siderolabs/talos/pkg/machinery/config/types/siderolink" "github.com/siderolabs/talos/pkg/machinery/config/types/v1alpha1" "github.com/siderolabs/talos/pkg/machinery/constants" "github.com/siderolabs/talos/pkg/provision" @@ -67,6 +70,7 @@ type Maker[ExtraOps any] struct { Cidrs []netip.Prefix InClusterEndpoint string Endpoints []string + WithOmni bool ProvisionOps []provision.Option GenOps []generate.Option @@ -101,15 +105,18 @@ func (m *Maker[T]) InitExtra() error { return err } - if err := m.extraOptionsProvider.AddExtraGenOps(); err != nil { - return err - } + // skip generating machine config if nodes are to be used with omni + if !m.WithOmni { + if err := m.extraOptionsProvider.AddExtraGenOps(); err != nil { + return err + } - if err := m.extraOptionsProvider.AddExtraProvisionOpts(); err != nil { - return err + if err := m.extraOptionsProvider.AddExtraConfigBundleOpts(); err != nil { + return err + } } - if err := m.extraOptionsProvider.AddExtraConfigBundleOpts(); err != nil { + if err := m.extraOptionsProvider.AddExtraProvisionOpts(); err != nil { return err } @@ -125,7 +132,13 @@ func (m *Maker[T]) InitExtra() error { } // InitCommon initializes the common fields. +// +//nolint:gocyclo func (m *Maker[T]) InitCommon() error { + if m.Ops.OmniAPIEndpoint != "" { + m.WithOmni = true + } + if err := m.initVersionContract(); err != nil { return err } @@ -152,15 +165,18 @@ func (m *Maker[T]) InitCommon() error { return err } - if err := m.initGenOps(); err != nil { - return err - } + // skip generating machine config if nodes are to be used with omni + if !m.WithOmni { + if err := m.initGenOps(); err != nil { + return err + } - if err := m.initProvisionOps(); err != nil { - return err + if err := m.initConfigBundleOps(); err != nil { + return err + } } - if err := m.initConfigBundleOps(); err != nil { + if err := m.initProvisionOps(); err != nil { return err } @@ -210,6 +226,53 @@ func (m *Maker[T]) initVersionContract() error { // GetClusterConfigs prepares and returns the cluster create request data. This method is ment to be called after the implemeting maker // logic has been run. func (m *Maker[T]) GetClusterConfigs() (clusterops.ClusterConfigs, error) { + var configBundle *bundle.Bundle + + if !m.WithOmni { + cfgBundle, err := m.finalizeMachineConfigs() + if err != nil { + return clusterops.ClusterConfigs{}, err + } + + configBundle = cfgBundle + } else { + err := m.applyOmniConfigs() + if err != nil { + return clusterops.ClusterConfigs{}, err + } + } + + return clusterops.ClusterConfigs{ + ClusterRequest: m.ClusterRequest, + ProvisionOptions: m.ProvisionOps, + ConfigBundle: configBundle, + }, nil +} + +func (m *Maker[T]) applyOmniConfigs() error { + cfg := siderolink.NewConfigV1Alpha1() + + parsedURL, err := ParseOmniAPIUrl(m.Ops.OmniAPIEndpoint) + if err != nil { + return fmt.Errorf("error parsing omni api url: %w", err) + } + + cfg.APIUrlConfig.URL = parsedURL + + ctr, err := container.New(cfg) + if err != nil { + return err + } + + m.ForEachNode(func(i int, node *provision.NodeRequest) { + node.Config = ctr + node.Name = m.Ops.RootOps.ClusterName + "-machine-" + strconv.Itoa(i+1) + }) + + return nil +} + +func (m *Maker[T]) finalizeMachineConfigs() (*bundle.Bundle, error) { // These options needs to be generated after the implementing maker has made changes to the cluster request. m.GenOps = slices.Concat(m.GenOps, m.Provisioner.GenOptions(m.ClusterRequest.Network, m.VersionContract)) m.GenOps = slices.Concat(m.GenOps, []generate.Option{generate.WithEndpointList(m.Endpoints)}) @@ -226,7 +289,7 @@ func (m *Maker[T]) GetClusterConfigs() (clusterops.ClusterConfigs, error) { configBundle, err := bundle.NewBundle(m.ConfigBundleOps...) if err != nil { - return clusterops.ClusterConfigs{}, err + return nil, err } if m.ClusterRequest.Nodes[0].Type == machine.TypeInit { @@ -243,7 +306,7 @@ func (m *Maker[T]) GetClusterConfigs() (clusterops.ClusterConfigs, error) { if m.Ops.WireguardCIDR != "" { wireguardConfigBundle, err := helpers.NewWireguardConfigBundle(m.IPs[0], m.Ops.WireguardCIDR, 51111, m.Ops.Controlplanes) if err != nil { - return clusterops.ClusterConfigs{}, err + return nil, err } for i := range m.ClusterRequest.Nodes { @@ -251,7 +314,7 @@ func (m *Maker[T]) GetClusterConfigs() (clusterops.ClusterConfigs, error) { patchedCfg, err := wireguardConfigBundle.PatchConfig(node.IPs[0], node.Config) if err != nil { - return clusterops.ClusterConfigs{}, err + return nil, err } node.Config = patchedCfg @@ -260,11 +323,7 @@ func (m *Maker[T]) GetClusterConfigs() (clusterops.ClusterConfigs, error) { m.ProvisionOps = append(m.ProvisionOps, provision.WithTalosConfig(configBundle.TalosConfig())) - return clusterops.ClusterConfigs{ - ClusterRequest: m.ClusterRequest, - ProvisionOptions: m.ProvisionOps, - ConfigBundle: configBundle, - }, nil + return configBundle, nil } // ForEachNode iterates over all nodes allowing modification of each node. diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common_test.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common_test.go index 1c2bd0501c7..787837dc32c 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common_test.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/common_test.go @@ -119,6 +119,24 @@ func TestCommonMaker(t *testing.T) { _, err = m.GetClusterConfigs() assert.NoError(t, err) + + m.Ops.OmniAPIEndpoint = "grpc://10.5.0.1:8090?jointoken=my-token" + err = m.Init() + assert.NoError(t, err) + + clusterCfgs, err := m.GetClusterConfigs() + assert.NoError(t, err) + + req := clusterCfgs.ClusterRequest + assert.Equal(t, "test-cluster-machine-1", req.Nodes[0].Name) + assert.Equal(t, "test-cluster-machine-2", req.Nodes[1].Name) + + cfgBytes, err := req.Nodes[0].Config.Bytes() + assert.NoError(t, err) + + assert.Contains(t, string(cfgBytes), "apiVersion: v1alpha1") + assert.Contains(t, string(cfgBytes), "kind: SideroLinkConfig") + assert.Contains(t, string(cfgBytes), "apiUrl: grpc://10.5.0.1:8090?jointoken=my-token") } func TestCommonMaker_MachineConfig(t *testing.T) { diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers.go new file mode 100644 index 00000000000..c408b83a8cb --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers.go @@ -0,0 +1,33 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package makers + +import ( + "fmt" + "net/url" + "strings" +) + +// ParseOmniAPIUrl validates and parses the omni api url. +func ParseOmniAPIUrl(urlIn string) (*url.URL, error) { + if !strings.HasPrefix(urlIn, "grpc://") && !strings.HasPrefix(urlIn, "https://") { + return nil, fmt.Errorf("invalid url scheme: must be either 'grpc://' or 'https://'") + } + + if !strings.Contains(urlIn, "?jointoken=") { + return nil, fmt.Errorf("invalid url: must contain a jointoken query parameter") + } + + url, err := url.Parse(urlIn) + if err != nil { + return nil, err + } + + if url.Port() == "" { + return nil, fmt.Errorf("invalid url: must contain a port") + } + + return url, nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers_test.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers_test.go new file mode 100644 index 00000000000..cee115d22d6 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/helpers_test.go @@ -0,0 +1,55 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package makers_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers" +) + +func TestParseOmniAPIUrl(t *testing.T) { + t.Run("valid grpc", func(t *testing.T) { + u, err := makers.ParseOmniAPIUrl("grpc://10.5.0.1:8090?jointoken=abc") + assert.NoError(t, err) + + if assert.NotNil(t, u) { + assert.Equal(t, "grpc", u.Scheme) + assert.Equal(t, "10.5.0.1:8090", u.Host) + assert.Equal(t, "abc", u.Query().Get("jointoken")) + } + }) + + t.Run("valid https", func(t *testing.T) { + u, err := makers.ParseOmniAPIUrl("https://example.com:443?jointoken=token123") + assert.NoError(t, err) + + if assert.NotNil(t, u) { + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "example.com:443", u.Host) + assert.Equal(t, "token123", u.Query().Get("jointoken")) + } + }) + + t.Run("invalid scheme", func(t *testing.T) { + u, err := makers.ParseOmniAPIUrl("http://10.5.0.1:8090?jointoken=abc") + assert.Error(t, err) + assert.Nil(t, u) + }) + + t.Run("missing jointoken", func(t *testing.T) { + u, err := makers.ParseOmniAPIUrl("grpc://10.5.0.1:8090") + assert.Error(t, err) + assert.Nil(t, u) + }) + + t.Run("missing port", func(t *testing.T) { + u, err := makers.ParseOmniAPIUrl("grpc://10.5.0.1?jointoken=abc") + assert.Error(t, err) + assert.Nil(t, u) + }) +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/qemu.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/qemu.go index ed06051ddb0..36db93198a7 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/qemu.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/qemu.go @@ -99,6 +99,8 @@ func (m *Qemu) InitExtra() error { m.SideroLinkBuilder = slb } + m.initEndpoints() + if m.Ops.WithJSONLogs { m.initJSONLogs() } @@ -106,6 +108,21 @@ func (m *Qemu) InitExtra() error { return nil } +func (m *Qemu) initEndpoints() { + switch { + case m.Ops.ForceEndpoint != "": + // using non-default endpoints, provision additional cert SANs and fix endpoint list + m.Endpoints = []string{m.Ops.ForceEndpoint} + case m.Ops.ForceInitNodeAsEndpoint: + m.Endpoints = []string{m.IPs[0][0].String()} + case m.Endpoints == nil: + // use control plane nodes as endpoints, client-side load-balancing + for i := range m.Ops.Controlplanes { + m.Endpoints = slices.Concat(m.Endpoints, []string{m.IPs[0][i].String()}) + } + } +} + // AddExtraGenOps implements ExtraOptionsProvider. func (m *Qemu) AddExtraGenOps() error { m.GenOps = slices.Concat(m.GenOps, []generate.Option{generate.WithInstallImage(m.EOps.NodeInstallImage)}) @@ -134,18 +151,8 @@ func (m *Qemu) AddExtraGenOps() error { }) } - switch { - case m.Ops.ForceEndpoint != "": - // using non-default endpoints, provision additional cert SANs and fix endpoint list - m.Endpoints = []string{m.Ops.ForceEndpoint} + if m.Ops.ForceEndpoint != "" { m.GenOps = slices.Concat(m.GenOps, []generate.Option{generate.WithAdditionalSubjectAltNames(m.Endpoints)}) - case m.Ops.ForceInitNodeAsEndpoint: - m.Endpoints = []string{m.IPs[0][0].String()} - case m.Endpoints == nil: - // use control plane nodes as endpoints, client-side load-balancing - for i := range m.Ops.Controlplanes { - m.Endpoints = slices.Concat(m.Endpoints, []string{m.IPs[0][i].String()}) - } } return nil diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/disk_image_preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/disk_image_preset.go new file mode 100644 index 00000000000..e662115f025 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/disk_image_preset.go @@ -0,0 +1,37 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import ( + "fmt" + "net/url" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + "github.com/siderolabs/talos/pkg/machinery/platforms" +) + +// DiskImage configures Talos to boot from a disk image from the Image Factory. +type DiskImage struct{} + +// Name implements the Preset interface. +func (DiskImage) Name() string { return "disk-image" } + +// Description implements the Preset interface. +func (DiskImage) Description() string { + return "Configure Talos to boot from a disk image from the Image Factory." +} + +// ModifyOptions implements the Preset interface. +func (DiskImage) ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + diskImageURL, err := url.JoinPath(presetOps.ImageFactoryURL.String(), "image", presetOps.SchematicID, cOps.TalosVersion, + platforms.MetalPlatform().DiskImageDefaultPath(qOps.TargetArch)) + if err != nil { + return fmt.Errorf("failed to build an Image Factory disk-image url: %w", err) + } + + qOps.NodeDiskImagePath = diskImageURL + + return nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_preset.go new file mode 100644 index 00000000000..0a1db40052a --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_preset.go @@ -0,0 +1,44 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import ( + "net/url" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + "github.com/siderolabs/talos/pkg/machinery/platforms" +) + +// ISO configures Talos to boot from an iso from the Image Factory. +type ISO struct{} + +// Name implements the Preset interface. +func (ISO) Name() string { return "iso" } + +// Description implements the Preset interface. +func (ISO) Description() string { + return "Configure Talos to boot from an ISO from the Image Factory." +} + +// ModifyOptions implements the Preset interface. +func (ISO) ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + isoURL, err := getISOURL(presetOps, cOps, qOps) + if err != nil { + return err + } + + qOps.NodeISOPath = isoURL + + return nil +} + +func getISOURL(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) (string, error) { + isoPath := platforms.MetalPlatform().ISOPath(qOps.TargetArch) + if presetOps.secureBoot { + isoPath = platforms.MetalPlatform().SecureBootISOPath(qOps.TargetArch) + } + + return url.JoinPath(presetOps.ImageFactoryURL.String(), "image", presetOps.SchematicID, cOps.TalosVersion, isoPath) +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_secureboot_preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_secureboot_preset.go new file mode 100644 index 00000000000..f9eaf1c6ae1 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/iso_secureboot_preset.go @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + +// ISOSecureBoot configures Talos to boot from a disk image from the Image Factory. +type ISOSecureBoot struct{} + +// Name implements the Preset interface. +func (ISOSecureBoot) Name() string { return "iso-secureboot" } + +// Description implements the Preset interface. +func (ISOSecureBoot) Description() string { + return "Configure Talos for Secureboot via ISO. Only available on Linux hosts." +} + +// ModifyOptions implements the Preset interface. +func (ISOSecureBoot) ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + isoURL, err := getISOURL(presetOps, cOps, qOps) + if err != nil { + return err + } + + qOps.NodeISOPath = isoURL + qOps.Tpm2Enabled = true + qOps.DiskEncryptionKeyTypes = []string{"tpm"} + qOps.EncryptEphemeralPartition = true + qOps.EncryptStatePartition = true + + return nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/maintenance_preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/maintenance_preset.go new file mode 100644 index 00000000000..624a01b3be5 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/maintenance_preset.go @@ -0,0 +1,26 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + +// Maintenance configures Talos to boot from a disk image from the Image Factory. +type Maintenance struct{} + +// Name implements the Preset interface. +func (Maintenance) Name() string { return "maintenance" } + +// Description implements the Preset interface. +func (Maintenance) Description() string { + return "Skip applying machine configuration and leave the machines in maintenance mode. The machine configuration files are written to the working directory." +} + +// ModifyOptions implements the Preset interface. +func (Maintenance) ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + cOps.SkipInjectingConfig = true + cOps.ApplyConfigEnabled = false + + return nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset.go new file mode 100644 index 00000000000..733767129b6 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset.go @@ -0,0 +1,135 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import ( + "errors" + "flag" + "fmt" + "net/url" + "runtime" + + "gopkg.in/typ.v4/slices" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" +) + +const secureBootSuffix = "-secureboot" + +// Preset modifies cluster create options to achieve certain behavior. +type Preset interface { + Name() string + Description() string + + // ModifyOptions modifies configs to achieve the desired behavior + ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error +} + +// Options are the options required for presets to function. +type Options struct { + SchematicID string + ImageFactoryURL *url.URL + + // secureBoot preset also affects other presets so this option needs to be shared. + secureBoot bool +} + +// Presets is a list of all available presets. +var Presets = [...]Preset{ + ISO{}, + ISOSecureBoot{}, + PXE{}, + DiskImage{}, + Maintenance{}, +} + +// Apply validates and applies a set of multiple presets. +func Apply(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu, presetNames []string) error { + presets, err := slices.MapErr(presetNames, func(name string) (Preset, error) { + if name == (ISOSecureBoot{}).Name() { + presetOps.secureBoot = true + } + + for _, p := range Presets { + if p.Name() == name { + return p, nil + } + } + + return nil, fmt.Errorf("error: unknown preset: %q", name) + }) + if err != nil { + return err + } + + err = Validate(presetNames, presetOps) + if err != nil { + return err + } + + if err := applyDefaultSettings(presetOps, cOps, qOps); err != nil { + return err + } + + for _, p := range presets { + err = p.ModifyOptions(presetOps, cOps, qOps) + if err != nil { + return fmt.Errorf("failed to apply %q preset: %w", p.Name(), err) + } + } + + return nil +} + +// Validate checks if the provided presets are valid and compatible. +// +//nolint:gocyclo +func Validate(presetNames []string, presetOps Options) error { + bootMethodPresets := []string{ISO{}.Name(), PXE{}.Name(), DiskImage{}.Name(), ISOSecureBoot{}.Name()} + + // check if at least one boot method preset is selected, but no more than one + bootMethodPresetCount := 0 + + for _, name := range presetNames { + for _, bm := range bootMethodPresets { + if name == bm { + bootMethodPresetCount++ + } + } + } + + if bootMethodPresetCount == 0 { + return fmt.Errorf("error: at least one boot method preset must be specified (one of %v)", bootMethodPresets) + } + + if bootMethodPresetCount > 1 { + return fmt.Errorf("error: multiple boot method presets specified, please select only one (one of %v)", bootMethodPresets) + } + + if presetOps.secureBoot && runtime.GOOS == "darwin" { + // skip the check if it's a unit test environment + if flag.Lookup("test.v") == nil { + return errors.New("error: 'secureboot' preset is currently not supported on darwin") + } + } + + return nil +} + +func applyDefaultSettings(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + installerName := "metal-installer" + if presetOps.secureBoot { + installerName += secureBootSuffix + } + + installerURL, err := url.JoinPath(presetOps.ImageFactoryURL.Host, installerName, presetOps.SchematicID+":"+cOps.TalosVersion) + if err != nil { + return fmt.Errorf("failed to build installer image URL: %w", err) + } + + qOps.NodeInstallImage = installerURL + + return nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset_test.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset_test.go new file mode 100644 index 00000000000..4afbe770b01 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/preset_test.go @@ -0,0 +1,130 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/constants" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset" +) + +func TestValidatePresets(t *testing.T) { + imageFactoryURL, err := url.Parse(constants.ImageFactoryURL) + require.NoError(t, err) + + tests := []struct { + name string + presets []string + shouldFail bool + }{ + { + name: "no presets", + presets: []string{}, + shouldFail: true, + }, + { + name: "multiple boot method presets", + presets: []string{preset.ISO{}.Name(), preset.PXE{}.Name()}, + shouldFail: true, + }, + { + name: "valid single boot method preset - iso", + presets: []string{preset.ISO{}.Name()}, + shouldFail: false, + }, + { + name: "valid single boot method preset - pxe", + presets: []string{preset.PXE{}.Name()}, + shouldFail: false, + }, + { + name: "valid single boot method preset - disk-image", + presets: []string{preset.DiskImage{}.Name()}, + shouldFail: false, + }, + { + name: "valid boot method preset with maintenance", + presets: []string{preset.ISO{}.Name(), preset.Maintenance{}.Name()}, + shouldFail: false, + }, + { + name: "iso-secureboot", + presets: []string{preset.ISOSecureBoot{}.Name()}, + shouldFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := preset.Validate(tt.presets, preset.Options{ + SchematicID: constants.ImageFactoryEmptySchematicID, + ImageFactoryURL: imageFactoryURL, + }) + + if tt.shouldFail { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func applyPreset(t *testing.T, presets ...string) (clusterops.Common, clusterops.Qemu) { + imageFactoryURL, err := url.Parse(constants.ImageFactoryURL) + require.NoError(t, err) + + cOps := clusterops.GetCommon() + qOps := clusterops.GetQemu() + qOps.TargetArch = "arm64" + cOps.TalosVersion = "v9.9.9" + + err = preset.Apply(preset.Options{ + SchematicID: "123schematic123", + ImageFactoryURL: imageFactoryURL, + }, &cOps, &qOps, presets) + require.NoError(t, err) + + return cOps, qOps +} + +func TestPXE(t *testing.T) { + _, qOps := applyPreset(t, preset.PXE{}.Name()) + + require.Equal(t, "factory.talos.dev/metal-installer/123schematic123:v9.9.9", qOps.NodeInstallImage) + require.Equal(t, "https://factory.talos.dev/pxe/123schematic123/v9.9.9/metal-arm64", qOps.NodeIPXEBootScript) + require.False(t, qOps.Tpm2Enabled) + require.Empty(t, qOps.NodeISOPath) +} + +func TestSecureboot(t *testing.T) { + _, qOps := applyPreset(t, preset.ISOSecureBoot{}.Name()) + + require.Equal(t, "https://factory.talos.dev/image/123schematic123/v9.9.9/metal-arm64-secureboot.iso", qOps.NodeISOPath) + require.True(t, qOps.Tpm2Enabled) + require.Contains(t, qOps.DiskEncryptionKeyTypes, "tpm") + require.True(t, qOps.EncryptEphemeralPartition) + require.True(t, qOps.EncryptStatePartition) + + require.Equal(t, "factory.talos.dev/metal-installer-secureboot/123schematic123:v9.9.9", qOps.NodeInstallImage) +} + +func TestDiskImage(t *testing.T) { + _, qOps := applyPreset(t, preset.DiskImage{}.Name()) + + require.Equal(t, "https://factory.talos.dev/image/123schematic123/v9.9.9/metal-arm64.raw.zst", qOps.NodeDiskImagePath) +} + +func TestMaintenance(t *testing.T) { + cOps, _ := applyPreset(t, preset.Maintenance{}.Name(), preset.ISO{}.Name()) + + require.True(t, cOps.SkipInjectingConfig) + require.False(t, cOps.ApplyConfigEnabled) +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/pxe_preset.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/pxe_preset.go new file mode 100644 index 00000000000..52f9725ebb6 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset/pxe_preset.go @@ -0,0 +1,37 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package preset + +import ( + "fmt" + "net/url" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + "github.com/siderolabs/talos/pkg/machinery/platforms" +) + +// PXE configures Talos to boot from via pxe from the Image Factory. +type PXE struct{} + +// Name implements the Preset interface. +func (PXE) Name() string { return "pxe" } + +// Description implements the Preset interface. +func (PXE) Description() string { + return "Configure Talos to boot via PXE from the Image Factory." +} + +// ModifyOptions implements the Preset interface. +func (PXE) ModifyOptions(presetOps Options, cOps *clusterops.Common, qOps *clusterops.Qemu) error { + pxeURL, err := url.JoinPath(presetOps.ImageFactoryURL.String(), "pxe", presetOps.SchematicID, cOps.TalosVersion, + platforms.MetalPlatform().PXEScriptPath(qOps.TargetArch)) + if err != nil { + return fmt.Errorf("failed to build an Image Factory pxe url: %w", err) + } + + qOps.NodeIPXEBootScript = pxeURL + + return nil +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/options.go b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/options.go index 41909ecc3a1..b39a704ffe9 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/options.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/options.go @@ -82,6 +82,7 @@ type Common struct { WireguardCIDR string WithUUIDHostnames bool NetworkIPv6 bool + OmniAPIEndpoint string } // Docker are options specific to docker provisioner. diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/cmd.go b/cmd/talosctl/cmd/mgmt/cluster/create/cmd.go index e8f3b1566fa..d87595c9016 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/cmd.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/cmd.go @@ -9,6 +9,7 @@ import ( "fmt" "path/filepath" + "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" @@ -38,7 +39,8 @@ var ( talosconfigDestinationFlagName = "talosconfig-destination" // Qemu flags. - disksFlagName = "disks" + disksFlagName = "disks" + omniAPIEndpointFlagName = "omni-api-endpoint" ) func getCommonUserFacingFlags(pointer *clusterops.Common) *pflag.FlagSet { @@ -135,3 +137,11 @@ func addDisksFlag(flagset *pflag.FlagSet, bind *flags.Disks) { flagset.Var(bind, disksFlagName, `list of disks to create in format ":" (disks after the first one are added only to worker machines)`) } + +func addOmniJoinTokenFlag(cmd *cobra.Command, bindAPIEndpoint *string, cfgPatchAllFlagName, cfgPatchWorkersFlagName, cfgPatchCPsFlagName string) { + cmd.Flags().StringVar(bindAPIEndpoint, omniAPIEndpointFlagName, *bindAPIEndpoint, "the Omni API endpoint (must include a scheme, a port and a join token)") + + cmd.MarkFlagsMutuallyExclusive(omniAPIEndpointFlagName, cfgPatchAllFlagName) + cmd.MarkFlagsMutuallyExclusive(omniAPIEndpointFlagName, cfgPatchWorkersFlagName) + cmd.MarkFlagsMutuallyExclusive(omniAPIEndpointFlagName, cfgPatchCPsFlagName) +} diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_dev.go b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_dev.go index ab48f6a4ce4..91ece73bc75 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_dev.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_dev.go @@ -278,11 +278,10 @@ func getCreateCmd() *cobra.Command { return err } - return create(ctx, cOps, qOps) + return createDevCluster(ctx, cOps, qOps) }) }, } - createCmd.Flags().IntVar(&legacyOps.clusterDiskSize, clusterDiskSizeFlag, 6*1024, "default limit on disk size in MB (each VM)") createCmd.Flags().IntVar(&legacyOps.extraDisks, extraDisksFlag, 0, "number of extra disks to create for each worker VM") createCmd.Flags().StringSliceVar(&legacyOps.extraDisksDrivers, "extra-disks-drivers", nil, "driver for each extra disk (virtio, ide, ahci, scsi, nvme, megaraid)") @@ -293,6 +292,7 @@ func getCreateCmd() *cobra.Command { createCmd.Flags().AddFlagSet(getCommonFlags()) createCmd.Flags().AddFlagSet(getQemuFlags()) + addOmniJoinTokenFlag(createCmd, &cOps.OmniAPIEndpoint, configPatchFlag, configPatchWorkerFlag, configPatchControlPlaneFlag) createCmd.MarkFlagsMutuallyExclusive(tpmEnabledFlag, tpm2EnabledFlag) diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_docker.go b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_docker.go index 5435bc32600..ca8567c4c03 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_docker.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_docker.go @@ -56,17 +56,17 @@ func init() { return err } - data, err := getDockerClusterRequest(cOps, dOps, provisioner) + clusterConfigs, err := getDockerClusterRequest(cOps, dOps, provisioner) if err != nil { return err } - cluster, err := provisioner.Create(ctx, data.ClusterRequest, data.ProvisionOptions...) + cluster, err := provisioner.Create(ctx, clusterConfigs.ClusterRequest, clusterConfigs.ProvisionOptions...) if err != nil { return err } - err = postCreate(ctx, cOps, data.ConfigBundle.TalosCfg, cluster, data.ProvisionOptions, data.ClusterRequest) + err = postCreate(ctx, cOps, cluster, clusterConfigs) if err != nil { return err } diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_qemu.go b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_qemu.go index f152558280f..8d247ebe043 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/cmd_qemu.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/cmd_qemu.go @@ -10,21 +10,21 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - clustercmd "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster" + "github.com/siderolabs/talos/cmd/talosctl/cmd/constants" "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset" "github.com/siderolabs/talos/pkg/cli" "github.com/siderolabs/talos/pkg/provision/providers" ) -const emptySchemanticID = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" - -type createQemuOps struct { +type presetOptions struct { schematicID string imageFactoryURL string + presets []string } func init() { - cqOps := createQemuOps{} + presetOptions := presetOptions{} qOps := clusterops.GetQemu() cOps := clusterops.GetCommon() cOps.SkipInjectingConfig = true @@ -39,15 +39,28 @@ func init() { qemu := pflag.NewFlagSet("qemu", pflag.PanicOnError) addDisksFlag(qemu, &qOps.Disks) - qemu.StringVar(&cqOps.schematicID, "schematic-id", "", "image factory schematic id (defaults to an empty schematic)") - qemu.StringVar(&cqOps.imageFactoryURL, "image-factory-url", "https://factory.talos.dev/", "image factory url") + qemu.StringVar(&presetOptions.schematicID, "schematic-id", "", "image factory schematic id (defaults to an empty schematic)") + qemu.StringVar(&presetOptions.imageFactoryURL, "image-factory-url", constants.ImageFactoryURL, "image factory url") + qemu.StringSliceVar(&presetOptions.presets, "presets", []string{preset.ISO{}.Name()}, "list of presets to apply") return qemu } + descriptionShort := "Create a local QEMU based Talos cluster" + descriptionLong := descriptionShort + "\n" + + descriptionLong += "Available presets:\n" + for _, p := range preset.Presets { + descriptionLong += " - " + p.Name() + ": " + p.Description() + "\n" + } + + descriptionLong += "\n" + descriptionLong += "Note: exactly one of 'iso', 'iso-secureboot', 'pxe' or 'disk-image' presets must be specified.\n" + createQemuCmd := &cobra.Command{ Use: providers.QemuProviderName, - Short: "Create a local QEMU based Talos cluster", + Short: descriptionShort, + Long: descriptionLong, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return cli.WithContext(context.Background(), func(ctx context.Context) error { @@ -56,28 +69,14 @@ func init() { return err } - data, err := getQemuClusterRequest(ctx, qOps, cOps, cqOps, provisioner) - if err != nil { - return err - } - - cluster, err := provisioner.Create(ctx, data.ClusterRequest, data.ProvisionOptions...) - if err != nil { - return err - } - - err = postCreate(ctx, cOps, data.ConfigBundle.TalosCfg, cluster, data.ProvisionOptions, data.ClusterRequest) - if err != nil { - return err - } - - return clustercmd.ShowCluster(cluster) + return createQemuCluster(ctx, qOps, cOps, presetOptions, provisioner) }) }, } createQemuCmd.Flags().AddFlagSet(commonFlags) createQemuCmd.Flags().AddFlagSet(getQemuFlags()) + addOmniJoinTokenFlag(createQemuCmd, &cOps.OmniAPIEndpoint, configPatchFlagName, configPatchWorkerFlagName, configPatchControlPlaneFlagName) createCmd.AddCommand(createQemuCmd) } diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/create.go b/cmd/talosctl/cmd/mgmt/cluster/create/create.go index ad2c75849b2..32745d770ae 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/create.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/create.go @@ -129,33 +129,37 @@ func downloadBootAssets(ctx context.Context, qOps *clusterops.Qemu) error { func postCreate( ctx context.Context, cOps clusterops.Common, - bundleTalosconfig *clientconfig.Config, cluster provision.Cluster, - provisionOptions []provision.Option, - request provision.ClusterRequest, + clusterConfigs clusterops.ClusterConfigs, ) error { - if err := saveConfig(bundleTalosconfig, cOps.TalosconfigDestination); err != nil { - return err + if clusterConfigs.ConfigBundle != nil { + bundleTalosconfig := clusterConfigs.ConfigBundle.TalosConfig() + + if err := saveConfig(bundleTalosconfig, cOps.TalosconfigDestination); err != nil { + return err + } } - clusterAccess := access.NewAdapter(cluster, provisionOptions...) + clusterAccess := access.NewAdapter(cluster, clusterConfigs.ProvisionOptions...) defer clusterAccess.Close() //nolint:errcheck if cOps.ApplyConfigEnabled { - err := clusterAccess.ApplyConfig(ctx, request.Nodes, request.SiderolinkRequest, os.Stdout) + fmt.Println("applying configuration to the cluster nodes") + + err := clusterAccess.ApplyConfig(ctx, clusterConfigs.ClusterRequest.Nodes, clusterConfigs.ClusterRequest.SiderolinkRequest, os.Stdout) if err != nil { return err } } + if cOps.OmniAPIEndpoint != "" || (cOps.SkipInjectingConfig && !cOps.ApplyConfigEnabled) { + return nil + } + return bootstrapCluster(ctx, clusterAccess, cOps) } func bootstrapCluster(ctx context.Context, clusterAccess *access.Adapter, cOps clusterops.Common) error { - if cOps.SkipInjectingConfig && !cOps.ApplyConfigEnabled { - return nil - } - if !cOps.WithInitNode { if err := clusterAccess.Bootstrap(ctx, os.Stdout); err != nil { return fmt.Errorf("bootstrap error: %w", err) diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/create_dev.go b/cmd/talosctl/cmd/mgmt/cluster/create/create_dev.go index 4ecf859f0c7..742caa47647 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/create_dev.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/create_dev.go @@ -11,7 +11,6 @@ import ( "io/fs" "os" "path/filepath" - "slices" "strings" "github.com/siderolabs/go-kubeconfig" @@ -21,14 +20,12 @@ import ( "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker" clientconfig "github.com/siderolabs/talos/pkg/machinery/client/config" - "github.com/siderolabs/talos/pkg/machinery/config/encoder" - "github.com/siderolabs/talos/pkg/machinery/config/machine" "github.com/siderolabs/talos/pkg/provision/access" "github.com/siderolabs/talos/pkg/provision/providers" ) //nolint:gocyclo,cyclop -func create(ctx context.Context, cOps clusterops.Common, qOps clusterops.Qemu) error { +func createDevCluster(ctx context.Context, cOps clusterops.Common, qOps clusterops.Qemu) error { if err := downloadBootAssets(ctx, &qOps); err != nil { return err } @@ -52,16 +49,9 @@ func create(ctx context.Context, cOps clusterops.Common, qOps clusterops.Qemu) e return err } - if cOps.SkipInjectingConfig { - types := []machine.Type{machine.TypeControlPlane, machine.TypeWorker} - - if cOps.WithInitNode { - types = slices.Insert(types, 0, machine.TypeInit) - } - - if err = clusterConfigs.ConfigBundle.Write(".", encoder.CommentsAll, types...); err != nil { - return err - } + err = preCreate(cOps, clusterConfigs) + if err != nil { + return err } cluster, err := provisioner.Create(ctx, clusterConfigs.ClusterRequest, clusterConfigs.ProvisionOptions...) @@ -85,7 +75,7 @@ func create(ctx context.Context, cOps clusterops.Common, qOps clusterops.Qemu) e } // Create and save the talosctl configuration file. - err = postCreate(ctx, cOps, clusterConfigs.ConfigBundle.TalosConfig(), cluster, clusterConfigs.ProvisionOptions, clusterConfigs.ClusterRequest) + err = postCreate(ctx, cOps, cluster, clusterConfigs) if err != nil { return err } diff --git a/cmd/talosctl/cmd/mgmt/cluster/create/create_qemu.go b/cmd/talosctl/cmd/mgmt/cluster/create/create_qemu.go index 02a499e8e02..92d7e5d876c 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create/create_qemu.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create/create_qemu.go @@ -7,57 +7,156 @@ package create import ( "context" "fmt" + "net/netip" "net/url" + "os" + "path/filepath" + "slices" + "github.com/siderolabs/talos/cmd/talosctl/cmd/constants" + clustercmd "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster" "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops" "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker" - "github.com/siderolabs/talos/pkg/cli" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/preset" "github.com/siderolabs/talos/pkg/machinery/config" + "github.com/siderolabs/talos/pkg/machinery/config/encoder" + "github.com/siderolabs/talos/pkg/machinery/config/machine" "github.com/siderolabs/talos/pkg/provision" ) //nolint:gocyclo,cyclop -func getQemuClusterRequest( +func createQemuCluster( ctx context.Context, qOps clusterops.Qemu, cOps clusterops.Common, - cqOps createQemuOps, + presetOptions presetOptions, provisioner provision.Provisioner, -) (clusterops.ClusterConfigs, error) { +) error { if cOps.TalosVersion == "" || cOps.TalosVersion[0] != 'v' { - return clusterops.ClusterConfigs{}, fmt.Errorf("failed to parse talos version: version string must start with a 'v'") + return fmt.Errorf("failed to parse talos version: version string must start with a 'v'") } _, err := config.ParseContractFromVersion(cOps.TalosVersion) if err != nil { - return clusterops.ClusterConfigs{}, fmt.Errorf("failed to parse talos version: %s", err) + return fmt.Errorf("failed to parse talos version: %s", err) } - if cqOps.schematicID == "" { - cqOps.schematicID = emptySchemanticID + if presetOptions.schematicID == "" { + presetOptions.schematicID = constants.ImageFactoryEmptySchematicID } - factoryURL, err := url.Parse(cqOps.imageFactoryURL) + factoryURL, err := url.Parse(presetOptions.imageFactoryURL) if err != nil { - return clusterops.ClusterConfigs{}, fmt.Errorf("malformed Image Factory URL: %q: %w", cqOps.imageFactoryURL, err) + return fmt.Errorf("malformed Image Factory URL: %q: %w", presetOptions.imageFactoryURL, err) } if factoryURL.Scheme == "" || factoryURL.Host == "" { - return clusterops.ClusterConfigs{}, fmt.Errorf("image Factory URL must include scheme and host: %q", cqOps.imageFactoryURL) + return fmt.Errorf("image Factory URL must include scheme and host: %q", presetOptions.imageFactoryURL) } - qOps.NodeISOPath, err = url.JoinPath(factoryURL.String(), "image", cqOps.schematicID, cOps.TalosVersion, "metal-"+qOps.TargetArch+".iso") - cli.Should(err) - qOps.NodeInstallImage, err = url.JoinPath(factoryURL.Host, "metal-installer", cqOps.schematicID+":"+cOps.TalosVersion) - cli.Should(err) + if slices.Contains(presetOptions.presets, preset.Maintenance{}.Name()) && cOps.OmniAPIEndpoint != "" { + fmt.Println("omni-api-endpoint specified along with the 'maintenance' preset") + fmt.Println("machine configuration containing 'SideroLinkConfig' will be written to the working path but will not be applied to the nodes") + } + + err = preset.Apply( + preset.Options{ + SchematicID: presetOptions.schematicID, + ImageFactoryURL: factoryURL, + }, &cOps, &qOps, presetOptions.presets) + if err != nil { + return err + } if err := downloadBootAssets(ctx, &qOps); err != nil { - return clusterops.ClusterConfigs{}, err + return err } - return configmaker.GetQemuConfigs(configmaker.QemuOptions{ + clusterConfigs, err := configmaker.GetQemuConfigs(configmaker.QemuOptions{ ExtraOps: qOps, CommonOps: cOps, Provisioner: provisioner, }) + if err != nil { + return err + } + + err = preCreate(cOps, clusterConfigs) + if err != nil { + return err + } + + cluster, err := provisioner.Create(ctx, clusterConfigs.ClusterRequest, clusterConfigs.ProvisionOptions...) + if err != nil { + return err + } + + err = postCreate(ctx, cOps, cluster, clusterConfigs) + if err != nil { + return err + } + + return clustercmd.ShowCluster(cluster) +} + +func preCreate(cOps clusterops.Common, clusterConfigs clusterops.ClusterConfigs) error { + if cOps.OmniAPIEndpoint != "" { + err := checkLoopbackOmniURL(cOps, clusterConfigs) + if err != nil { + return err + } + } + + // write machine config + if cOps.SkipInjectingConfig { + if clusterConfigs.ConfigBundle != nil { + types := []machine.Type{machine.TypeControlPlane, machine.TypeWorker} + + if cOps.WithInitNode { + types = slices.Insert(types, 0, machine.TypeInit) + } + + if err := clusterConfigs.ConfigBundle.Write(".", encoder.CommentsAll, types...); err != nil { + return err + } + } + + // no configbundle, just write the machine config as-is + cfgBytes, err := clusterConfigs.ClusterRequest.Nodes[0].Config.Bytes() + if err != nil { + return err + } + + fullFilePath := filepath.Join(".", "machineconfig.yaml") + if err = os.WriteFile(fullFilePath, cfgBytes, 0o644); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "created %s\n", fullFilePath) + } + + return nil +} + +func checkLoopbackOmniURL(cOps clusterops.Common, clusterConfigs clusterops.ClusterConfigs) error { + parsedURL, err := url.Parse(cOps.OmniAPIEndpoint) + if err != nil { + return err + } + + host := parsedURL.Hostname() + + ip, err := netip.ParseAddr(host) + if err != nil { + return err + } + + gwIP := clusterConfigs.ClusterRequest.Network.GatewayAddrs[0] + + if ip.IsLoopback() && ip != gwIP { + fmt.Fprintf(os.Stderr, "WARNING: the Omni API url is pointing to a local address %q which is different from the cluster gateway address %q\n", ip.String(), gwIP.String()) + fmt.Fprintln(os.Stderr, "the nodes will not be able to reach the Omni API server unless Omni is running in the same virtual network") + } + + return nil } diff --git a/go.mod b/go.mod index 6f303f6c5e5..3a87bac1b93 100644 --- a/go.mod +++ b/go.mod @@ -191,6 +191,7 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.75.1 google.golang.org/protobuf v1.36.10 + gopkg.in/typ.v4 v4.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d diff --git a/go.sum b/go.sum index bf32c4713a4..ab9a767e83f 100644 --- a/go.sum +++ b/go.sum @@ -1018,6 +1018,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/typ.v4 v4.4.0 h1:O9vTueEmZd0iA9DF+g2wXeNCeloN2TOpxu6FXKl3AqM= +gopkg.in/typ.v4 v4.4.0/go.mod h1:wolXe8DlewxRCjA7SOiT3zjrZ0eQJZcr8cmV6bQWJUM= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/hack/test/e2e-image-factory.sh b/hack/test/e2e-image-factory.sh index fe2e7bb17e1..b5a0c7c89e8 100755 --- a/hack/test/e2e-image-factory.sh +++ b/hack/test/e2e-image-factory.sh @@ -10,22 +10,24 @@ CLUSTER_NAME="e2e-${PROVISIONER}" LOG_ARCHIVE_SUFFIX="${GITHUB_STEP_NAME:-e2e-${PROVISIONER}}" FACTORY_HOSTNAME=${FACTORY_HOSTNAME:-factory.talos.dev} -PXE_FACTORY_HOSTNAME=${PXE_FACTORY_HOSTNAME:-pxe.factory.talos.dev} +if [ "${FACTORY_BOOT_METHOD}" = "ipxe" ]; then + FACTORY_HOSTNAME=${PXE_FACTORY_HOSTNAME:-pxe.${FACTORY_HOSTNAME}} +fi FACTORY_SCHEME=${FACTORY_SCHEME:-https} INSTALLER_IMAGE_NAME=${INSTALLER_IMAGE_NAME:-installer} case "${FACTORY_BOOT_METHOD:-iso}" in iso) - QEMU_FLAGS+=("--iso-path=${FACTORY_SCHEME}://${FACTORY_HOSTNAME}/image/${FACTORY_SCHEMATIC}/${FACTORY_VERSION}/metal-amd64.iso") + QEMU_FLAGS+=("--presets=iso") ;; disk-image) - QEMU_FLAGS+=("--disk-image-path=${FACTORY_SCHEME}://${FACTORY_HOSTNAME}/image/${FACTORY_SCHEMATIC}/${FACTORY_VERSION}/metal-amd64.raw.xz") + QEMU_FLAGS+=("--presets=disk-image") ;; ipxe) - QEMU_FLAGS+=("--ipxe-boot-script=${FACTORY_SCHEME}://${PXE_FACTORY_HOSTNAME}/pxe/${FACTORY_SCHEMATIC}/${FACTORY_VERSION}/metal-amd64") + QEMU_FLAGS+=("--presets=pxe") ;; secureboot-iso) - QEMU_FLAGS+=("--iso-path=${FACTORY_SCHEME}://${FACTORY_HOSTNAME}/image/${FACTORY_SCHEMATIC}/${FACTORY_VERSION}/metal-amd64-secureboot.iso" "--with-tpm2" "--encrypt-ephemeral" "--encrypt-state" "--disk-encryption-key-types=tpm") + QEMU_FLAGS+=("--presets=iso-secureboot") INSTALLER_IMAGE_NAME=installer-secureboot ;; *) @@ -46,24 +48,19 @@ function assert_secureboot { function create_cluster { build_registry_mirrors - "${TALOSCTL}" cluster create \ - --provisioner="${PROVISIONER}" \ + "${TALOSCTL}" cluster create qemu \ --name="${CLUSTER_NAME}" \ --kubernetes-version="${KUBERNETES_VERSION}" \ --controlplanes=3 \ --workers="${QEMU_WORKERS:-1}" \ - --disk=15360 \ --mtu=1430 \ - --memory=2048 \ + --memory-controlplanes=2048 \ --memory-workers="${QEMU_MEMORY_WORKERS:-2048}" \ - --cpus="${QEMU_CPUS:-2}" \ + --cpus-controlplanes="${QEMU_CPUS:-2}" \ --cpus-workers="${QEMU_CPUS_WORKERS:-2}" \ --cidr=172.20.1.0/24 \ - --cni-bundle-url="${ARTIFACTS}/talosctl-cni-bundle-\${ARCH}.tar.gz" \ - --skip-injecting-config \ - --with-apply-config \ --talos-version="${FACTORY_VERSION}" \ - --install-image="${FACTORY_HOSTNAME}/${INSTALLER_IMAGE_NAME}/${FACTORY_SCHEMATIC}:${FACTORY_VERSION}" \ + --schematic-id="${FACTORY_SCHEMATIC}" \ "${REGISTRY_MIRROR_FLAGS[@]}" \ "${QEMU_FLAGS[@]}" diff --git a/website/content/v1.12/reference/cli.md b/website/content/v1.12/reference/cli.md index 930973f186e..59188574197 100644 --- a/website/content/v1.12/reference/cli.md +++ b/website/content/v1.12/reference/cli.md @@ -162,6 +162,19 @@ talosctl cluster create docker [flags] Create a local QEMU based Talos cluster +### Synopsis + +Create a local QEMU based Talos cluster +Available presets: + - iso: Configure Talos to boot from an ISO from the Image Factory. + - iso-secureboot: Configure Talos for Secureboot via ISO. Only available on Linux hosts. + - pxe: Configure Talos to boot via PXE from the Image Factory. + - disk-image: Configure Talos to boot from a disk image from the Image Factory. + - maintenance: Skip applying machine configuration and leave the machines in maintenance mode. The machine configuration files are written to the working directory. + +Note: exactly one of 'iso', 'iso-secureboot', 'pxe' or 'disk-image' presets must be specified. + + ``` talosctl cluster create qemu [flags] ``` @@ -182,6 +195,8 @@ talosctl cluster create qemu [flags] --kubernetes-version string desired kubernetes version to run (default "1.34.1") --memory-controlplanes string(mb,gb) the limit on memory usage for each control plane/VM (default 2.0GiB) --memory-workers string(mb,gb) the limit on memory usage for each worker/VM (default 2.0GiB) + --omni-api-endpoint string the Omni API endpoint (must include a scheme, a port and a join token) + --presets strings list of presets to apply (default [iso]) --schematic-id string image factory schematic id (defaults to an empty schematic) --talos-version string the desired talos version (default "latest") --talosconfig-destination string The location to save the generated Talos configuration file to. Defaults to 'TALOSCONFIG' env variable if set, otherwise '$HOME/.talos/config' and '/var/run/secrets/talos.dev/config' in order. @@ -257,6 +272,7 @@ talosctl cluster create [flags] --mtu int MTU of the cluster network (default 1500) --nameservers strings list of nameservers to use (default [8.8.8.8,1.1.1.1,2001:4860:4860::8888,2606:4700:4700::1111]) --no-masquerade-cidrs strings list of CIDRs to exclude from NAT + --omni-api-endpoint string the Omni API endpoint (must include a scheme, a port and a join token) --registry-insecure-skip-verify strings list of registry hostnames to skip TLS verification for --registry-mirror strings list of registry mirrors to use in format: = --skip-injecting-config skip injecting config from embedded metadata server, write config files to current directory