diff --git a/Dockerfile b/Dockerfile index 23fa0263e2..f4192828b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,9 @@ FROM --platform=arm64 ghcr.io/siderolabs/sd-boot:${PKGS} AS pkg-sd-boot-arm64 FROM --platform=amd64 ghcr.io/siderolabs/iptables:${PKGS} AS pkg-iptables-amd64 FROM --platform=arm64 ghcr.io/siderolabs/iptables:${PKGS} AS pkg-iptables-arm64 +FROM --platform=amd64 ghcr.io/siderolabs/ipxe:${PKGS} AS pkg-ipxe-amd64 +FROM --platform=arm64 ghcr.io/siderolabs/ipxe:${PKGS} AS pkg-ipxe-arm64 + FROM --platform=amd64 ghcr.io/siderolabs/libinih:${PKGS} AS pkg-libinih-amd64 FROM --platform=arm64 ghcr.io/siderolabs/libinih:${PKGS} AS pkg-libinih-arm64 @@ -275,6 +278,10 @@ COPY --from=embed-abbrev-generate /src/pkg/machinery/gendata/data /pkg/machinery COPY --from=embed-abbrev-generate /src/_out/talos-metadata /_out/talos-metadata COPY --from=embed-abbrev-generate /src/_out/signing_key.x509 /_out/signing_key.x509 +FROM scratch AS ipxe-generate +COPY --from=pkg-ipxe-amd64 /usr/libexec/snp.efi /amd64/snp.efi +COPY --from=pkg-ipxe-arm64 /usr/libexec/snp.efi /arm64/snp.efi + FROM --platform=${BUILDPLATFORM} scratch AS generate COPY --from=proto-format-build /src/api /api/ COPY --from=generate-build /api/common/*.pb.go /pkg/machinery/api/common/ @@ -295,6 +302,7 @@ COPY --from=go-generate /src/pkg/machinery/resources/ /pkg/machinery/resources/ COPY --from=go-generate /src/pkg/machinery/config/types/ /pkg/machinery/config/types/ COPY --from=go-generate /src/pkg/machinery/nethelpers/ /pkg/machinery/nethelpers/ COPY --from=go-generate /src/pkg/machinery/extensions/ /pkg/machinery/extensions/ +COPY --from=ipxe-generate / /pkg/provision/providers/vm/internal/ipxe/data/ipxe/ COPY --from=embed-abbrev / / # The base target provides a container that can be used to build all Talos diff --git a/cmd/talosctl/cmd/mgmt/cluster/create.go b/cmd/talosctl/cmd/mgmt/cluster/create.go index ca134ee74c..97ebc15d11 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create.go @@ -99,6 +99,7 @@ var ( nodeInitramfsPath string nodeISOPath string nodeDiskImagePath string + nodeIPXEBootScript string applyConfigEnabled bool bootloaderEnabled bool uefiEnabled bool @@ -322,11 +323,12 @@ func create(ctx context.Context, flags *pflag.FlagSet) (err error) { Bandwidth: bandwidth, }, - Image: nodeImage, - KernelPath: nodeVmlinuzPath, - InitramfsPath: nodeInitramfsPath, - ISOPath: nodeISOPath, - DiskImagePath: nodeDiskImagePath, + Image: nodeImage, + KernelPath: nodeVmlinuzPath, + InitramfsPath: nodeInitramfsPath, + ISOPath: nodeISOPath, + IPXEBootScript: nodeIPXEBootScript, + DiskImagePath: nodeDiskImagePath, SelfExecutable: os.Args[0], StateDirectory: stateDir, @@ -958,6 +960,7 @@ func init() { createCmd.Flags().StringVar(&nodeISOPath, "iso-path", "", "the ISO path to use for the initial boot (VM only)") createCmd.Flags().StringVar(&nodeInitramfsPath, "initrd-path", helpers.ArtifactPath(constants.InitramfsAssetWithArch), "initramfs image to use") createCmd.Flags().StringVar(&nodeDiskImagePath, "disk-image-path", "", "disk image to use") + createCmd.Flags().StringVar(&nodeIPXEBootScript, "ipxe-boot-script", "", "iPXE boot script (URL) to use") createCmd.Flags().BoolVar(&applyConfigEnabled, "with-apply-config", false, "enable apply config when the VM is starting in maintenance mode") createCmd.Flags().BoolVar(&bootloaderEnabled, bootloaderEnabledFlag, true, "enable bootloader to load kernel and initramfs from disk image after install") createCmd.Flags().BoolVar(&uefiEnabled, "with-uefi", true, "enable UEFI on x86_64 architecture") diff --git a/cmd/talosctl/cmd/mgmt/dhcpd_launch_linux.go b/cmd/talosctl/cmd/mgmt/dhcpd_launch_linux.go index 67ca6e1900..ab31f1fd0f 100644 --- a/cmd/talosctl/cmd/mgmt/dhcpd_launch_linux.go +++ b/cmd/talosctl/cmd/mgmt/dhcpd_launch_linux.go @@ -9,17 +9,19 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" "github.com/siderolabs/talos/pkg/provision/providers/vm" ) var dhcpdLaunchCmdFlags struct { - addr string - ifName string - statePath string + addr string + ifName string + statePath string + ipxeNextHandler string } -// dhcpdLaunchCmd represents the loadbalancer-launch command. +// dhcpdLaunchCmd represents the dhcpd-launch command. var dhcpdLaunchCmd = &cobra.Command{ Use: "dhcpd-launch", Short: "Internal command used by VM provisioners", @@ -33,7 +35,19 @@ var dhcpdLaunchCmd = &cobra.Command{ ips = append(ips, net.ParseIP(ip)) } - return vm.DHCPd(dhcpdLaunchCmdFlags.ifName, ips, dhcpdLaunchCmdFlags.statePath) + var eg errgroup.Group + + eg.Go(func() error { + return vm.DHCPd(dhcpdLaunchCmdFlags.ifName, ips, dhcpdLaunchCmdFlags.statePath) + }) + + if dhcpdLaunchCmdFlags.ipxeNextHandler != "" { + eg.Go(func() error { + return vm.TFTPd(ips, dhcpdLaunchCmdFlags.ipxeNextHandler) + }) + } + + return eg.Wait() }, } @@ -41,5 +55,6 @@ func init() { dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.addr, "addr", "localhost", "IP addresses to listen on") dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.ifName, "interface", "", "interface to listen on") dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.statePath, "state-path", "", "path to state directory") + dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.ipxeNextHandler, "ipxe-next-handler", "", "iPXE script to chain load") addCommand(dhcpdLaunchCmd) } diff --git a/go.mod b/go.mod index 5d5be6128b..0dcae5a36b 100644 --- a/go.mod +++ b/go.mod @@ -90,6 +90,7 @@ require ( github.com/opencontainers/runtime-spec v1.1.0-rc.1 github.com/packethost/packngo v0.30.0 github.com/pelletier/go-toml v1.9.5 + github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible github.com/pin/tftp/v3 v3.1.0 github.com/pmorjan/kmod v1.1.0 github.com/prometheus/procfs v0.12.0 diff --git a/go.sum b/go.sum index 9dceffbc4f..1ce1c3634a 100644 --- a/go.sum +++ b/go.sum @@ -589,6 +589,8 @@ github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+v github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible h1:zQDvVdw4rn2smQfJZwbD5FboCiiTgw/1lpER60easPM= +github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible/go.mod h1:xVpZOMCXTy+A5QMjEVN0Glwa1sUvaJhFXbr/aAxuxGY= github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c= github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= diff --git a/pkg/provision/providers/qemu/node.go b/pkg/provision/providers/qemu/node.go index 7659dcfc08..a40dcf27d9 100644 --- a/pkg/provision/providers/qemu/node.go +++ b/pkg/provision/providers/qemu/node.go @@ -139,6 +139,11 @@ func (p *provisioner) createNode(state *vm.State, clusterReq provision.ClusterRe APIPort: apiPort, } + if clusterReq.IPXEBootScript != "" { + launchConfig.TFTPServer = clusterReq.Network.GatewayAddrs[0].String() + launchConfig.IPXEBootFileName = fmt.Sprintf("ipxe/%s/snp.efi", string(arch)) + } + nodeInfo := provision.NodeInfo{ ID: pidPath, UUID: nodeUUID, @@ -168,7 +173,7 @@ func (p *provisioner) createNode(state *vm.State, clusterReq provision.ClusterRe launchConfig.Hostname = nodeReq.Name } - if !nodeReq.PXEBooted { + if !(nodeReq.PXEBooted || launchConfig.IPXEBootFileName != "") { launchConfig.KernelImagePath = strings.ReplaceAll(clusterReq.KernelPath, constants.ArchVariable, opts.TargetArch) launchConfig.InitrdPath = strings.ReplaceAll(clusterReq.InitramfsPath, constants.ArchVariable, opts.TargetArch) launchConfig.ISOPath = strings.ReplaceAll(clusterReq.ISOPath, constants.ArchVariable, opts.TargetArch) diff --git a/pkg/provision/providers/vm/dhcpd.go b/pkg/provision/providers/vm/dhcpd.go index 96c7ec673f..196d65cf1d 100644 --- a/pkg/provision/providers/vm/dhcpd.go +++ b/pkg/provision/providers/vm/dhcpd.go @@ -91,12 +91,14 @@ func handlerDHCP4(serverIP net.IP, statePath string) server4.Handler { if m.IsOptionRequested(dhcpv4.OptionBootfileName) { log.Printf("received PXE boot request from %s", m.ClientHWAddr) + log.Printf("sending PXE response to %s: %s/%s", m.ClientHWAddr, match.TFTPServer, match.IPXEBootFilename) if match.TFTPServer != "" { - log.Printf("sending PXE response to %s: %s/%s", m.ClientHWAddr, match.TFTPServer, match.IPXEBootFilename) - resp.ServerIPAddr = net.ParseIP(match.TFTPServer) resp.UpdateOption(dhcpv4.OptTFTPServerName(match.TFTPServer)) + } + + if match.IPXEBootFilename != "" { resp.UpdateOption(dhcpv4.OptBootFileName(match.IPXEBootFilename)) } } @@ -293,6 +295,7 @@ func (p *Provisioner) CreateDHCPd(state *State, clusterReq provision.ClusterRequ "--state-path", statePath, "--addr", strings.Join(gatewayAddrs, ","), "--interface", state.BridgeName, + "--ipxe-next-handler", clusterReq.IPXEBootScript, } cmd := exec.Command(clusterReq.SelfExecutable, args...) diff --git a/pkg/provision/providers/vm/internal/ipxe/data/ipxe/amd64/snp.efi b/pkg/provision/providers/vm/internal/ipxe/data/ipxe/amd64/snp.efi new file mode 100644 index 0000000000..04eb976ece Binary files /dev/null and b/pkg/provision/providers/vm/internal/ipxe/data/ipxe/amd64/snp.efi differ diff --git a/pkg/provision/providers/vm/internal/ipxe/data/ipxe/arm64/snp.efi b/pkg/provision/providers/vm/internal/ipxe/data/ipxe/arm64/snp.efi new file mode 100644 index 0000000000..cd0df090de Binary files /dev/null and b/pkg/provision/providers/vm/internal/ipxe/data/ipxe/arm64/snp.efi differ diff --git a/pkg/provision/providers/vm/internal/ipxe/ipxe.go b/pkg/provision/providers/vm/internal/ipxe/ipxe.go new file mode 100644 index 0000000000..bd16a5ff2d --- /dev/null +++ b/pkg/provision/providers/vm/internal/ipxe/ipxe.go @@ -0,0 +1,154 @@ +// 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 ipxe provides utility to deliver iPXE images and build iPXE scripts. +package ipxe + +import ( + "bytes" + "embed" + "fmt" + "io" + "log" + "path/filepath" + "text/template" +) + +//go:embed "data/*" +var ipxeFiles embed.FS + +// TFTPHandler is called when client starts file download from the TFTP server. +// +// TFTP handler also patches the iPXE binary on the fly with the script +// which chainloads next handler. +func TFTPHandler(next string) func(filename string, rf io.ReaderFrom) error { + return func(filename string, rf io.ReaderFrom) error { + log.Printf("tftp request: %s", filename) + + file, err := ipxeFiles.Open(filepath.Join("data", filename)) + if err != nil { + return err + } + + defer file.Close() //nolint:errcheck + + contents, err := io.ReadAll(file) + if err != nil { + return err + } + + var script bytes.Buffer + + if err = scriptTemplate.Execute(&script, struct { + Next string + }{ + Next: next, + }); err != nil { + return err + } + + contents, err = patchScript(contents, script.Bytes()) + if err != nil { + return fmt.Errorf("error patching %q: %w", filename, err) + } + + _, err = rf.ReadFrom(bytes.NewReader(contents)) + + return err + } +} + +// scriptTemplate to run DHCP and chain the boot to the .Next endpoint. +var scriptTemplate = template.Must(template.New("iPXE embedded").Parse(`#!ipxe +prompt --key 0x02 --timeout 2000 Press Ctrl-B for the iPXE command line... && shell || + +{{/* print interfaces */}} +ifstat + +{{/* retry 10 times overall */}} +set attempts:int32 10 +set x:int32 0 + +:retry_loop + + set idx:int32 0 + + :loop + {{/* try DHCP on each interface */}} + isset ${net${idx}/mac} || goto exhausted + + ifclose + iflinkwait --timeout 5000 net${idx} || goto next_iface + dhcp net${idx} || goto next_iface + goto boot + + :next_iface + inc idx && goto loop + + :boot + {{/* attempt boot, if fails try next iface */}} + route + + chain --replace {{ .Next }} || goto next_iface + +:exhausted + echo + echo Failed to iPXE boot successfully via all interfaces + + iseq ${x} ${attempts} && goto fail || + + echo Retrying... + echo + + inc x + goto retry_loop + +:fail + echo + echo Failed to get a valid response after ${attempts} attempts + echo + + echo Rebooting in 5 seconds... + sleep 5 + reboot +`)) + +var ( + placeholderStart = []byte("# *PLACEHOLDER START*") + placeholderEnd = []byte("# *PLACEHOLDER END*") +) + +// patchScript patches the iPXE script into the iPXE binary. +// +// The iPXE binary should be built uncompressed with an embedded +// script stub which contains abovementioned placeholders. +func patchScript(contents, script []byte) ([]byte, error) { + start := bytes.Index(contents, placeholderStart) + if start == -1 { + return nil, fmt.Errorf("placeholder start not found") + } + + end := bytes.Index(contents, placeholderEnd) + if end == -1 { + return nil, fmt.Errorf("placeholder end not found") + } + + if end < start { + return nil, fmt.Errorf("placeholder end before start") + } + + end += len(placeholderEnd) + + length := end - start + + if len(script) > length { + return nil, fmt.Errorf("script size %d is larger than placeholder space %d", len(script), length) + } + + script = append(script, bytes.Repeat([]byte{'\n'}, length-len(script))...) + + copy(contents[start:end], script) + + return contents, nil +} diff --git a/pkg/provision/providers/vm/tftpd.go b/pkg/provision/providers/vm/tftpd.go new file mode 100644 index 0000000000..b5b804f608 --- /dev/null +++ b/pkg/provision/providers/vm/tftpd.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 vm + +import ( + "net" + "time" + + "github.com/pin/tftp" + "golang.org/x/sync/errgroup" + + "github.com/siderolabs/talos/pkg/provision/providers/vm/internal/ipxe" +) + +// TFTPd starts a TFTP server on the given IPs. +func TFTPd(ips []net.IP, nextHandler string) error { + server := tftp.NewServer(ipxe.TFTPHandler(nextHandler), nil) + + server.SetTimeout(5 * time.Second) + + var eg errgroup.Group + + for _, ip := range ips { + ip := ip + + eg.Go(func() error { + return server.ListenAndServe(net.JoinHostPort(ip.String(), "69")) + }) + } + + return eg.Wait() +} diff --git a/pkg/provision/request.go b/pkg/provision/request.go index b59e88811a..a773b1b2a6 100644 --- a/pkg/provision/request.go +++ b/pkg/provision/request.go @@ -23,12 +23,18 @@ type ClusterRequest struct { Network NetworkRequest Nodes NodeRequests - Image string - KernelPath string - InitramfsPath string - ISOPath string - DiskImagePath string - KMSEndpoint string + // Docker specific parameters. + Image string + + // Boot options (QEMU). + KernelPath string + InitramfsPath string + ISOPath string + DiskImagePath string + IPXEBootScript string + + // Encryption + KMSEndpoint string // Path to talosctl executable to re-execute itself as needed. SelfExecutable string diff --git a/website/content/v1.6/learn-more/image-factory.md b/website/content/v1.6/learn-more/image-factory.md index a6cd66f837..c16bd2bd81 100644 --- a/website/content/v1.6/learn-more/image-factory.md +++ b/website/content/v1.6/learn-more/image-factory.md @@ -91,7 +91,7 @@ In this guide we will provide a list of examples: * amd64 ISO (for Talos {{< release >}}, "vanilla" schematic) [https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64.iso](https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64.iso) * arm64 AWS image (for Talos {{< release >}}, "vanilla" schematic) [https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/aws-arm64.raw.xz](https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/aws-arm64.raw.xz) -* amd64 PXE boot script (for Talos {{< release >}}, "vanilla" schematic) [https://factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64](https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64) +* amd64 PXE boot script (for Talos {{< release >}}, "vanilla" schematic) [https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64](https://pxe.factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/metal-amd64) * Talos `installer` image (for Talos {{< release >}}, "vanilla" schematic, architecture is detected automatically): `factory.talos.dev/installer/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba:{{< release >}}` The `installer` image can be used to install Talos Linux on a bare-metal machine, or to upgrade an existing Talos Linux installation. diff --git a/website/content/v1.6/reference/cli.md b/website/content/v1.6/reference/cli.md index 2f0d7c1fcd..b3bf19938b 100644 --- a/website/content/v1.6/reference/cli.md +++ b/website/content/v1.6/reference/cli.md @@ -131,6 +131,7 @@ talosctl cluster create [flags] --install-image string the installer image to use (default "ghcr.io/siderolabs/installer:latest") --ipv4 enable IPv4 network in the cluster (default true) --ipv6 enable IPv6 network in the cluster (QEMU provisioner only) + --ipxe-boot-script string iPXE boot script (URL) to use --iso-path string the ISO path to use for the initial boot (VM only) --kubeprism-port int KubePrism port (set to 0 to disable) (default 7445) --kubernetes-version string desired kubernetes version to run (default "1.29.0") diff --git a/website/content/v1.6/talos-guides/install/bare-metal-platforms/equinix-metal.md b/website/content/v1.6/talos-guides/install/bare-metal-platforms/equinix-metal.md index fa3652cc03..95d2e346da 100644 --- a/website/content/v1.6/talos-guides/install/bare-metal-platforms/equinix-metal.md +++ b/website/content/v1.6/talos-guides/install/bare-metal-platforms/equinix-metal.md @@ -1,7 +1,7 @@ --- title: "Equinix Metal" description: "Creating Talos clusters with Equinix Metal." -aliases: +aliases: - ../../../bare-metal-platforms/equinix-metal --- @@ -103,20 +103,8 @@ Repeat this to create each control plane node desired: there should usually be 3 ### Network Booting via iPXE -You may install Talos over the network using TFTP and iPXE. -You would first need a working TFTP and iPXE server. - -In general this requires a Talos kernel vmlinuz and initramfs. -These assets can be downloaded from a given [release](https://github.com/siderolabs/talos/releases). - -#### PXE Boot Kernel Parameters - -The following is a list of kernel parameters required by Talos: - -* `talos.platform`: set this to `equinixMetal` -* `init_on_alloc=1`: required by KSPP -* `slab_nomerge`: required by KSPP -* `pti=on`: required by KSPP +Talos Linux can be PXE-booted on Equinix Metal using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `equinixMetal` platform: e.g. +`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/equinixMetal-amd64` (this URL references the default schematic and `amd64` architecture). #### Create the Control Plane Nodes diff --git a/website/content/v1.6/talos-guides/install/cloud-platforms/vultr.md b/website/content/v1.6/talos-guides/install/cloud-platforms/vultr.md index 6a370cb74a..6a947c94bd 100644 --- a/website/content/v1.6/talos-guides/install/cloud-platforms/vultr.md +++ b/website/content/v1.6/talos-guides/install/cloud-platforms/vultr.md @@ -12,7 +12,9 @@ This guide will demonstrate how to create a highly-available Kubernetes cluster [Vultr](https://www.vultr.com/) have a very well documented REST API, and an open-source [CLI](https://github.com/vultr/vultr-cli) tool to interact with the API which will be used in this guide. Make sure to follow installation and authentication instructions for the `vultr-cli` tool. -### Upload image +### Boot Options + +#### Upload an ISO Image First step is to make the Talos ISO available to Vultr by uploading the latest release of the ISO to the Vultr ISO server. @@ -22,6 +24,11 @@ vultr-cli iso create --url https://github.com/siderolabs/talos/releases/download Make a note of the `ID` in the output, it will be needed later when creating the instances. +#### PXE Booting via Image Factory + +Talos Linux can be PXE-booted on Vultr using [Image Factory]({{< relref "../../../learn-more/image-factory" >}}), using the `vultr` platform: e.g. +`https://pxe.factory.talos.dev/pxe/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/{{< release >}}/vultr-amd64` (this URL references the default schematic and `amd64` architecture). + ### Create a Load Balancer A load balancer is needed to serve as the Kubernetes endpoint for the cluster.