├── internal ├── version │ ├── data │ │ ├── tag │ │ └── sha │ └── version.go ├── provider │ ├── ipxe │ │ ├── ipxe.go │ │ └── boot.go │ ├── agent │ │ ├── agent.go │ │ ├── options.go │ │ └── client.go │ ├── debug │ │ ├── debug.go │ │ ├── disabled.go │ │ └── enabled.go │ ├── dhcp │ │ └── dhcp.go │ ├── imagefactory │ │ ├── imagefactory.go │ │ └── client.go │ ├── constants │ │ └── constants.go │ ├── resources │ │ ├── resources.go │ │ ├── wipe_status.go │ │ ├── reboot_status.go │ │ ├── tls_config.go │ │ ├── machine_status.go │ │ ├── power_operation.go │ │ └── bmc_configuration.go │ ├── bmc │ │ ├── redfish │ │ │ ├── options.go │ │ │ └── redfish.go │ │ ├── pxe │ │ │ └── pxe.go │ │ ├── ipmi │ │ │ └── ipmi.go │ │ ├── api │ │ │ ├── api.go │ │ │ └── manager.go │ │ └── bmc.go │ ├── meta │ │ └── meta.go │ ├── ip │ │ └── ip.go │ ├── config │ │ └── config.go │ ├── data │ │ └── icon.svg │ ├── machineconfig │ │ ├── machineconfig_test.go │ │ └── machineconfig.go │ ├── options.go │ ├── controllers │ │ ├── power_operation_test.go │ │ ├── controllers.go │ │ ├── wipe_status.go │ │ ├── reboot_status_test.go │ │ ├── bmc_configuration_test.go │ │ ├── controllers_test.go │ │ ├── infra_machine_status.go │ │ ├── power_operation.go │ │ └── bmc_configuration.go │ ├── tftp │ │ └── tftp_server.go │ ├── machine │ │ └── machine.go │ ├── server │ │ └── server.go │ └── tls │ │ └── tls.go ├── constants │ ├── constants.go │ ├── debug_disabled.go │ └── debug_enabled.go ├── qemu │ ├── qemu.go │ └── options.go └── util │ └── util.go ├── hack ├── test │ └── provisionconfig.yaml ├── release.toml ├── certs │ ├── localhost.pem │ ├── localhost-key.pem │ ├── key.public │ └── key.private ├── govulncheck.sh └── release.sh ├── .gitignore ├── .license-header.go.txt ├── .markdownlint.json ├── .dockerignore ├── .codecov.yml ├── .sops.yaml ├── .github ├── workflows │ ├── lock.yml │ ├── stale.yml │ ├── e2e-cron.yaml │ ├── slack-notify-ci-failure.yaml │ └── slack-notify.yaml └── renovate.json ├── .conform.yaml ├── api └── specs │ └── specs.proto ├── .secrets.yaml ├── cmd └── qemu-up │ └── main.go ├── .kres.yaml ├── .golangci.yml └── README.md /internal/version/data/tag: -------------------------------------------------------------------------------- 1 | v0.7.1 -------------------------------------------------------------------------------- /internal/version/data/sha: -------------------------------------------------------------------------------- 1 | undefined -------------------------------------------------------------------------------- /hack/test/provisionconfig.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | count: 8 3 | provider: 4 | id: bare-metal 5 | static: true 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-02-03T21:04:43Z by kres 987bf4d. 4 | 5 | _out 6 | -------------------------------------------------------------------------------- /.license-header.go.txt: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2024-10-14T09:32:55Z by kres 34e72ac. 4 | 5 | { 6 | "MD013": false, 7 | "MD033": false, 8 | "default": true 9 | } 10 | -------------------------------------------------------------------------------- /internal/provider/ipxe/ipxe.go: -------------------------------------------------------------------------------- 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 ipxe provides iPXE functionality. 6 | package ipxe 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-11-17T13:17:47Z by kres e1d6dac. 4 | 5 | * 6 | !api 7 | !cmd 8 | !internal 9 | !go.mod 10 | !go.sum 11 | !.golangci.yml 12 | !CHANGELOG.md 13 | !README.md 14 | !.markdownlint.json 15 | !hack/govulncheck.sh 16 | -------------------------------------------------------------------------------- /internal/provider/agent/agent.go: -------------------------------------------------------------------------------- 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 agent implements the metal agent service. 6 | package agent 7 | -------------------------------------------------------------------------------- /internal/constants/constants.go: -------------------------------------------------------------------------------- 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 constants contains global backend constants. 6 | package constants 7 | -------------------------------------------------------------------------------- /hack/release.toml: -------------------------------------------------------------------------------- 1 | # commit to be tagged for the new release 2 | commit = "HEAD" 3 | 4 | project_name = "omni-infra-provider-bare-metal" 5 | github_repo = "siderolabs/omni-infra-provider-bare-metal" 6 | match_deps = "^github.com/(siderolabs/[a-zA-Z0-9-]+)$" 7 | 8 | previous = "v0.7.0" 9 | pre_release = false 10 | 11 | # [notes] 12 | -------------------------------------------------------------------------------- /internal/provider/debug/debug.go: -------------------------------------------------------------------------------- 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 debug provides a way to check if the build is a debug build. 6 | package debug 7 | -------------------------------------------------------------------------------- /internal/provider/dhcp/dhcp.go: -------------------------------------------------------------------------------- 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 dhcp implements DHCP proxy and other DHCP related functionality. 6 | package dhcp 7 | -------------------------------------------------------------------------------- /internal/qemu/qemu.go: -------------------------------------------------------------------------------- 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 qemu provides functionality to bring up Talos QEMU VMs to develop/test the provider. 6 | package qemu 7 | -------------------------------------------------------------------------------- /internal/provider/imagefactory/imagefactory.go: -------------------------------------------------------------------------------- 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 imagefactory provides an abstraction to the image factory for the bare metal infra provider. 6 | package imagefactory 7 | -------------------------------------------------------------------------------- /internal/provider/debug/disabled.go: -------------------------------------------------------------------------------- 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 | //go:build sidero.debug 6 | 7 | package debug 8 | 9 | // Enabled is set to true when the build is a debug build (WITH_DEBUG=true). 10 | const Enabled = true 11 | -------------------------------------------------------------------------------- /internal/provider/debug/enabled.go: -------------------------------------------------------------------------------- 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 | //go:build !sidero.debug 6 | 7 | package debug 8 | 9 | // Enabled is set to true when the build is a debug build (WITH_DEBUG=true). 10 | const Enabled = false 11 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2024-10-14T09:32:55Z by kres 34e72ac. 4 | 5 | codecov: 6 | require_ci_to_pass: false 7 | 8 | coverage: 9 | status: 10 | project: 11 | default: 12 | target: 0% 13 | threshold: 0.5% 14 | base: auto 15 | if_ci_failed: success 16 | patch: off 17 | 18 | comment: false 19 | -------------------------------------------------------------------------------- /internal/constants/debug_disabled.go: -------------------------------------------------------------------------------- 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 | //go:build sidero.debug 6 | 7 | package constants 8 | 9 | // IsDebugBuild is set to true when the build is a debug build (WITH_DEBUG=true). 10 | const IsDebugBuild = true 11 | -------------------------------------------------------------------------------- /internal/constants/debug_enabled.go: -------------------------------------------------------------------------------- 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 | //go:build !sidero.debug 6 | 7 | package constants 8 | 9 | // IsDebugBuild is set to true when the build is a debug build (WITH_DEBUG=true). 10 | const IsDebugBuild = false 11 | -------------------------------------------------------------------------------- /.sops.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2024-11-28T09:26:49Z by kres 232fe63. 4 | 5 | creation_rules: 6 | - age: age1xrpa9ujxxcj2u2gzfrzv8mxak4rts94a6y60ypurv6rs5cpr4e4sg95f0k 7 | # order: Andrey, Noel, Artem, Utku, Dmitriy 8 | pgp: >- 9 | 15D5721F5F5BAF121495363EFE042E3D4085A811, 10 | CC51116A94490FA6FB3C18EB2401FCAE863A06CA, 11 | 4919F560F0D35F80CF382D76E084A2DF1143C14D, 12 | 11177A43C6E3752E682AC690DBD13117B0A14E93, 13 | AA5213AF261C1977AF38B03A94B473337258BFD5 -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-07-17T21:25:02Z by kres b869533. 4 | 5 | "on": 6 | schedule: 7 | - cron: 0 2 * * * 8 | name: Lock old issues 9 | permissions: 10 | issues: write 11 | jobs: 12 | action: 13 | runs-on: 14 | - ubuntu-latest 15 | steps: 16 | - name: Lock old issues 17 | uses: dessant/lock-threads@v5.0.1 18 | with: 19 | issue-inactive-days: "60" 20 | log-output: "true" 21 | process-only: issues 22 | -------------------------------------------------------------------------------- /internal/provider/constants/constants.go: -------------------------------------------------------------------------------- 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 constants provides constants for the provider package. 6 | package constants 7 | 8 | const ( 9 | // IPXEPath is the path to the iPXE binaries. 10 | IPXEPath = "/var/lib/ipxe" 11 | 12 | // TFTPPath is the path from which the TFTP server serves files. 13 | TFTPPath = "/var/lib/tftp" 14 | 15 | // IPXEURLPath is the path from which the HTTP server serves the iPXE scripts. 16 | IPXEURLPath = "ipxe" 17 | ) 18 | -------------------------------------------------------------------------------- /internal/provider/resources/resources.go: -------------------------------------------------------------------------------- 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 resources contains the resources internal to the provider. 6 | package resources 7 | 8 | import ( 9 | "github.com/siderolabs/omni/client/pkg/infra" 10 | 11 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 12 | ) 13 | 14 | // Namespace is the resource namespace of this provider. 15 | func Namespace() string { 16 | return infra.ResourceNamespace(meta.ProviderID.String()) 17 | } 18 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 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 util provides utility functions. 6 | package util 7 | 8 | import ( 9 | "io" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // LogClose closes the closer and logs any error that occurs. 15 | func LogClose(closer io.Closer, logger *zap.Logger) { 16 | if err := closer.Close(); err != nil { 17 | logger.Error("failed to close", zap.Error(err)) 18 | } 19 | } 20 | 21 | // LogErr calls the function and logs any error that occurs. 22 | func LogErr(f func() error, logger *zap.Logger) { 23 | if err := f(); err != nil { 24 | logger.Error("failed to close", zap.Error(err)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/provider/agent/options.go: -------------------------------------------------------------------------------- 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 agent 6 | 7 | import ( 8 | "time" 9 | ) 10 | 11 | // ClientOptions holds the agent client configuration options. 12 | type ClientOptions struct { 13 | WipeWithZeroes bool 14 | CallTimeout time.Duration 15 | FastWipeTimeout time.Duration 16 | ZeroesWipeTimeout time.Duration 17 | } 18 | 19 | // DefaultClientOptions returns the default client options. 20 | func DefaultClientOptions() ClientOptions { 21 | return ClientOptions{ 22 | WipeWithZeroes: false, 23 | CallTimeout: 30 * time.Second, 24 | FastWipeTimeout: 5 * time.Minute, 25 | ZeroesWipeTimeout: 24 * time.Hour, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/provider/bmc/redfish/options.go: -------------------------------------------------------------------------------- 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 redfish 6 | 7 | // Options is a struct that holds the RedFish configuration options. 8 | type Options struct { 9 | UseAlways bool 10 | UseWhenAvailable bool 11 | UseHTTPS bool 12 | InsecureSkipTLSVerify bool 13 | SetBootSourceOverrideMode bool 14 | Port int 15 | } 16 | 17 | // DefaultOptions is the default RedFish configuration options. 18 | func DefaultOptions() Options { 19 | return Options{ 20 | UseAlways: false, 21 | UseWhenAvailable: true, 22 | UseHTTPS: true, 23 | InsecureSkipTLSVerify: true, 24 | Port: 443, 25 | SetBootSourceOverrideMode: true, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/provider/bmc/pxe/pxe.go: -------------------------------------------------------------------------------- 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 pxe contains types related to PXE booting. 6 | package pxe 7 | 8 | import "fmt" 9 | 10 | // BootMode is the PXE boot mode to be used. 11 | type BootMode string 12 | 13 | const ( 14 | // BootModeBIOS is the mode to boot from disk using BIOS. 15 | BootModeBIOS BootMode = "bios" 16 | 17 | // BootModeUEFI is the mode to boot from disk using UEFI. 18 | BootModeUEFI BootMode = "uefi" 19 | ) 20 | 21 | // ParseBootMode parses a boot mode. 22 | func ParseBootMode(mode string) (BootMode, error) { 23 | switch mode { 24 | case string(BootModeBIOS): 25 | return BootModeBIOS, nil 26 | case string(BootModeUEFI): 27 | return BootModeUEFI, nil 28 | default: 29 | return "", fmt.Errorf("unknown boot mode: %s", mode) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/provider/meta/meta.go: -------------------------------------------------------------------------------- 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 meta contains meta information about the provider. 6 | package meta 7 | 8 | // ProviderID is the ID of the provider. 9 | var ProviderID providerIDFlag = "bare-metal" 10 | 11 | // providerIDFlag is a flag type for the provider ID. 12 | type providerIDFlag string 13 | 14 | // String implements the pflag.Value interface. 15 | // 16 | // It returns the id of the provider. 17 | func (p *providerIDFlag) String() string { 18 | return string(*p) 19 | } 20 | 21 | // Set implements the pflag.Value interface. 22 | func (p *providerIDFlag) Set(val string) error { 23 | *p = providerIDFlag(val) 24 | 25 | return nil 26 | } 27 | 28 | // Type implements the pflag.Value interface. 29 | func (p *providerIDFlag) Type() string { 30 | return "string" 31 | } 32 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "description": "THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.", 4 | "prHeader": "Update Request | Renovate Bot", 5 | "extends": [ 6 | ":dependencyDashboard", 7 | ":gitSignOff", 8 | ":semanticCommitScopeDisabled", 9 | "schedule:earlyMondays" 10 | ], 11 | "packageRules": [ 12 | { 13 | "groupName": "dependencies", 14 | "matchUpdateTypes": [ 15 | "major", 16 | "minor", 17 | "patch", 18 | "pin", 19 | "digest" 20 | ] 21 | }, 22 | { 23 | "enabled": false, 24 | "matchFileNames": [ 25 | "Dockerfile" 26 | ] 27 | }, 28 | { 29 | "enabled": false, 30 | "matchFileNames": [ 31 | ".github/workflows/*.yaml" 32 | ] 33 | } 34 | ], 35 | "separateMajorMinor": false 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-10-15T12:08:56Z by kres d315fc0. 4 | 5 | "on": 6 | schedule: 7 | - cron: 30 1 * * * 8 | name: Close stale issues and PRs 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | jobs: 13 | stale: 14 | runs-on: 15 | - ubuntu-latest 16 | steps: 17 | - name: Close stale issues and PRs 18 | uses: actions/stale@v10.1.0 19 | with: 20 | close-issue-message: This issue was closed because it has been stalled for 7 days with no activity. 21 | days-before-issue-close: "5" 22 | days-before-issue-stale: "180" 23 | days-before-pr-close: "-1" 24 | days-before-pr-stale: "45" 25 | operations-per-run: "2000" 26 | stale-issue-message: This issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days. 27 | stale-pr-message: This PR is stale because it has been open 45 days with no activity. 28 | -------------------------------------------------------------------------------- /internal/qemu/options.go: -------------------------------------------------------------------------------- 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 qemu 6 | 7 | // Options for the set of machines. 8 | type Options struct { 9 | Name string 10 | CIDR string 11 | CNIBundleURL string 12 | TalosctlPath string 13 | CPUs string 14 | DefaultBootOrder string 15 | 16 | Nameservers []string 17 | 18 | NumMachines int 19 | MTU int 20 | 21 | DiskSize uint64 22 | MemSize int64 23 | 24 | UEFIEnabled bool 25 | } 26 | 27 | // DefaultOptions returns the default options for the set of machines. 28 | func DefaultOptions() Options { 29 | return Options{ 30 | Name: "bare-metal", 31 | CIDR: "172.42.0.0/24", 32 | CNIBundleURL: "https://github.com/siderolabs/talos/releases/latest/download/talosctl-cni-bundle-amd64.tar.gz", 33 | NumMachines: 4, 34 | Nameservers: []string{"1.1.1.1", "1.0.0.1"}, 35 | MTU: 1440, 36 | 37 | CPUs: "3", 38 | DiskSize: 6 * 1024 * 1024 * 1024, 39 | MemSize: 3072 * 1024 * 1024, 40 | 41 | DefaultBootOrder: "cn", 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/provider/ipxe/boot.go: -------------------------------------------------------------------------------- 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 ipxe 6 | 7 | import "fmt" 8 | 9 | // BootFromDiskMethod defines a way to boot from disk. 10 | type BootFromDiskMethod string 11 | 12 | const ( 13 | // BootIPXEExit is a method to boot from disk using iPXE script with `exit` command. 14 | BootIPXEExit BootFromDiskMethod = "ipxe-exit" 15 | 16 | // Boot404 is a method to boot from disk using HTTP 404 response to iPXE. 17 | Boot404 BootFromDiskMethod = "http-404" 18 | 19 | // BootSANDisk is a method to boot from disk using iPXE script with `sanboot` command. 20 | BootSANDisk BootFromDiskMethod = "ipxe-sanboot" 21 | ) 22 | 23 | // parseBootFromDiskMethod parses a boot from disk method. 24 | func parseBootFromDiskMethod(method string) (BootFromDiskMethod, error) { 25 | switch method { 26 | case string(BootIPXEExit): 27 | return BootIPXEExit, nil 28 | case string(Boot404): 29 | return Boot404, nil 30 | case string(BootSANDisk): 31 | return BootSANDisk, nil 32 | default: 33 | return "", fmt.Errorf("unknown boot from disk method: %s", method) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 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 | // THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 6 | // 7 | // Generated on 2024-10-14T09:32:55Z by kres 34e72ac. 8 | 9 | // Package version contains variables such as project name, tag and sha. It's a proper alternative to using 10 | // -ldflags '-X ...'. 11 | package version 12 | 13 | import ( 14 | _ "embed" 15 | "runtime/debug" 16 | "strings" 17 | ) 18 | 19 | var ( 20 | // Tag declares project git tag. 21 | //go:embed data/tag 22 | Tag string 23 | // SHA declares project git SHA. 24 | //go:embed data/sha 25 | SHA string 26 | // Name declares project name. 27 | Name = func() string { 28 | info, ok := debug.ReadBuildInfo() 29 | if !ok { 30 | panic("cannot read build info, something is very wrong") 31 | } 32 | 33 | // Check if siderolabs project 34 | if strings.HasPrefix(info.Path, "github.com/siderolabs/") { 35 | return info.Path[strings.LastIndex(info.Path, "/")+1:] 36 | } 37 | 38 | // We could return a proper full path here, but it could be seen as a privacy violation. 39 | return "community-project" 40 | }() 41 | ) 42 | -------------------------------------------------------------------------------- /.conform.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-11-17T13:17:47Z by kres e1d6dac. 4 | 5 | policies: 6 | - type: commit 7 | spec: 8 | dco: true 9 | gpg: 10 | required: true 11 | identity: 12 | gitHubOrganization: siderolabs 13 | spellcheck: 14 | locale: US 15 | maximumOfOneCommit: false 16 | header: 17 | length: 89 18 | imperative: true 19 | case: lower 20 | invalidLastCharacters: . 21 | body: 22 | required: true 23 | conventional: 24 | types: 25 | - chore 26 | - docs 27 | - perf 28 | - refactor 29 | - style 30 | - test 31 | - release 32 | scopes: 33 | - .* 34 | - type: license 35 | spec: 36 | root: . 37 | skipPaths: 38 | - .git/ 39 | - testdata/ 40 | includeSuffixes: 41 | - .go 42 | excludeSuffixes: 43 | - .pb.go 44 | - .pb.gw.go 45 | header: | 46 | // This Source Code Form is subject to the terms of the Mozilla Public 47 | // License, v. 2.0. If a copy of the MPL was not distributed with this 48 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 49 | -------------------------------------------------------------------------------- /internal/provider/ip/ip.go: -------------------------------------------------------------------------------- 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 ip provides IP address related functionality. 6 | package ip 7 | 8 | import ( 9 | "fmt" 10 | "net" 11 | ) 12 | 13 | // RoutableIPs returns a list of routable IP addresses. 14 | func RoutableIPs() ([]string, error) { 15 | addresses, err := net.InterfaceAddrs() 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to get interfaces: %w", err) 18 | } 19 | 20 | routableIPs := make([]string, 0, len(addresses)) 21 | 22 | for _, addr := range addresses { 23 | ipNet, ok := addr.(*net.IPNet) 24 | if !ok { 25 | continue 26 | } 27 | 28 | if isRoutableIP(ipNet.IP) { 29 | routableIPs = append(routableIPs, ipNet.IP.String()) 30 | } 31 | } 32 | 33 | return routableIPs, nil 34 | } 35 | 36 | func isRoutableIP(ip net.IP) bool { 37 | isReservedIPv4 := func(ip net.IP) bool { 38 | return ip[0] >= 240 39 | } 40 | 41 | if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || 42 | ip.IsMulticast() || ip.IsUnspecified() { 43 | return false 44 | } 45 | 46 | if ip.To4() != nil { 47 | return !isReservedIPv4(ip) 48 | } 49 | 50 | return true 51 | } 52 | -------------------------------------------------------------------------------- /internal/provider/config/config.go: -------------------------------------------------------------------------------- 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 config serves machine configuration to the machines that request it via talos.config kernel argument. 6 | package config 7 | 8 | import ( 9 | "net/http" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // Handler handles machine configuration requests. 15 | type Handler struct { 16 | logger *zap.Logger 17 | machineConfig []byte 18 | } 19 | 20 | // NewHandler creates a new Handler. 21 | func NewHandler(machineConfig []byte, logger *zap.Logger) (*Handler, error) { 22 | return &Handler{ 23 | machineConfig: machineConfig, 24 | logger: logger, 25 | }, nil 26 | } 27 | 28 | // ServeHTTP serves the machine configuration. 29 | // 30 | // URL pattern: http://ip-of-this-provider:50042/config?&u=${uuid} 31 | // 32 | // Implements http.Handler interface. 33 | func (s *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 34 | uuid := req.URL.Query().Get("u") 35 | 36 | s.logger.Info("handle config request", zap.String("uuid", uuid)) 37 | 38 | w.WriteHeader(http.StatusOK) 39 | 40 | if _, err := w.Write(s.machineConfig); err != nil { 41 | s.logger.Error("failed to write response", zap.Error(err)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /hack/certs/localhost.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEVzCCAr+gAwIBAgIRAIfOe7RuXJAXhuacBA29W9EwDQYJKoZIhvcNAQELBQAw 3 | WTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMRcwFQYDVQQLDA51dGt1 4 | QHUtaG9tZS1wYzEeMBwGA1UEAwwVbWtjZXJ0IHV0a3VAdS1ob21lLXBjMB4XDTI0 5 | MDgxNjA4MTkwM1oXDTI2MTExNjA5MTkwM1owVjEnMCUGA1UEChMebWtjZXJ0IGRl 6 | dmVsb3BtZW50IGNlcnRpZmljYXRlMSswKQYDVQQLDCJ1dGt1QHUtd29yay1zaWRl 7 | cm8gKFV0a3Ugw5Z6ZGVtaXIpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC 8 | AQEAuIpYRMvIy07EpmFvoU3pxa4fKrzWGGKaPT5EP6bHHeMMj4vdAcSj6ACAFkbE 9 | +RxN3JiYQ/HA7Qpcu/TeCTkh/Ky6FNTklEE7S+FPOJEPfqzaznsUfTvlEQUi8rt9 10 | /CkXZab1LNuc+2p/0h8H+gqjBsfqYKiKfurnMI2F704FsOejzoplX/7tV7OcbMv7 11 | Gh4UFM0MJaVQRhzrQcBVYoEM1KQrHny81BqVWXOSdhVUaEafpO+ophzm2V0KjHQC 12 | Ftg8yESellAeDB55CrDtuRHSx5UXRkva9AX/gl6jyenj2Cylw0QN/yki5Z3p0oR6 13 | dDUdg0DIYhuKA4uEm9U0GBZV6QIDAQABo4GcMIGZMA4GA1UdDwEB/wQEAwIFoDAT 14 | BgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBSUwQKJ6xTCiIbTU/iD3Wgy 15 | OE1oFjBRBgNVHREESjBIgglsb2NhbGhvc3SCCyoubG9jYWxob3N0ghVteS1pbnN0 16 | YW5jZS5sb2NhbGhvc3SCFyoubXktaW5zdGFuY2UubG9jYWxob3N0MA0GCSqGSIb3 17 | DQEBCwUAA4IBgQAcK6EcBER1ExJ+jaLRLvyvkw0Z9JhB5AVVpgKuE9XJiJHrQCuJ 18 | 4nFeCZ2Nse8VkPtLdUCzup9ycbBFGzEp6XzjoeBpvC+uTJ6AhjNG/vPhSzgwX++a 19 | 69BzLGZVvrsh1BOwVy676gif99E5s19slzYHFm4kq/cXhkE8zMww0gFa3Xb9FGJv 20 | hOfPA5yyJAC30bbUU0cKamGMRmS75jnWWlCrIwez+PEzEbLc7jELXfBtfX+zc8Ek 21 | IIpK4S/IgbXNM0dXVNHdhGE56hRB/kBZSjHNy7QSB/mPStInM9YSDz4HKYXCLhQ4 22 | NNXZYst3YFhmgJ8cuVAyP0AZ8gNtbzLhwZXW00UE0q8t+xpgEQ87v4vIqXLCwqOK 23 | iw+G5aGQpsZF6oHmPpPcQ5J7kHLIMvk+VVHVAivn4PRZqRkzbYxVennCcDqPStd4 24 | 5Y9cKB4iWIHjfmdwGOGKJEfLlUHpG7R/8NoXJ8Ga9NU0UvgpTXg4yn7ChV/Cj4H6 25 | hCLKZcW/DPjnWiI= 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /hack/certs/localhost-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQC4ilhEy8jLTsSm 3 | YW+hTenFrh8qvNYYYpo9PkQ/pscd4wyPi90BxKPoAIAWRsT5HE3cmJhD8cDtCly7 4 | 9N4JOSH8rLoU1OSUQTtL4U84kQ9+rNrOexR9O+URBSLyu338KRdlpvUs25z7an/S 5 | Hwf6CqMGx+pgqIp+6ucwjYXvTgWw56POimVf/u1Xs5xsy/saHhQUzQwlpVBGHOtB 6 | wFVigQzUpCsefLzUGpVZc5J2FVRoRp+k76imHObZXQqMdAIW2DzIRJ6WUB4MHnkK 7 | sO25EdLHlRdGS9r0Bf+CXqPJ6ePYLKXDRA3/KSLlnenShHp0NR2DQMhiG4oDi4Sb 8 | 1TQYFlXpAgMBAAECggEBAKPnPD7eQJlSfJbKM7uw19EbtdLfpchCy3tZsoRWPMPu 9 | xVk5gDHx1SJaT2l5sbkPypgDcDnontHqQjMuaYcHl4g0YZHfBKYoyeG7XAGB1aFN 10 | JYn/B1OzvuA/D6tHm747QOyoPVp6NBOZo62cohkTGXkMVr9C8r+HI4+cIzlIswVL 11 | NW4+vCMZ9WQ5z98NpJX3isK6BDemWyfi702oTLG7XIwzn7GQWL0jQX+zOmYkmo+K 12 | InGSnrHl7pILs2InDIA/3AMI4K/w/mqu11h2+VKKmHUyOKi47lBUdtpZp9KLMA6W 13 | kJ3mwQX2RU8mAWpuGE+Bo23WApdcwRV+UYGtU+mE3YECgYEA0Q8OhvtF2/MWyLWp 14 | Q0hNJ4PK7+Htnx/xzxzEOadLwTio19cO51J83zlAqQ6dCE4HUsQmZVlMl7kq6zfL 15 | z7kn1x9H87YcBYnfxUFOQUKco5N/GibhJi85t0tjuReDU3GxZS7MsA7vsOn/uIdC 16 | W91FS01QxgAl1bEnCK2gbNWNYlsCgYEA4fnxDoRuprj83Jz4MxbLSpqOrQE+S1V2 17 | 3XGVHSo2x9i1d7T+CzVaug2r1Jqz02cRn15fE24B2KkoYz7eCNEJ7A0HfOLwwq2s 18 | m9RMyAFY/RoktIZb09SwYneleuX3pvIQJ7hkX0oxg7EeVK/1H93lCrgBz4SVeUXe 19 | nNzmkxY8FAsCgYEAt5MNIqJKudU/0IcUVqyKc4RbE0HEstION9v+wtGQx97FBKMn 20 | xyC73hgcG1dltQEvlRIA1UYQ57oFYf7gzUq9HT2upObovERRZpjt6ohfm5PNLF2v 21 | nyQg/j8JFmL7Qq63Iy5xNrgm6abQkmzTbG9khbcikntWvcqNiCVOlcMAH7kCgYEA 22 | pXg02JGGyNSKbC0Q3bAiOkXEldBkQhuZx3tlWg7QQDRiZP6GS8TM45IhMbP6W6GM 23 | WOtsqTiTZ4guR8YAJeqT3mKICh3PeG5eB1lEw+ugsu0S1ZHQ6eNDKUc9SCne10NH 24 | Kx6teM1GRo1KjW6vCp+cGOY2hTMrlLrh0HE88ZWFdpMCgYEAigbuLOfVlSbIQ8vg 25 | UJHf6JM5Lrc6oG6LW6fRUabnJibQ/TboNeV7PYd594swBrSwzJ/yRch8d6UIRVSB 26 | cftPlut2YvHcoMlOhqS0oOOFNJx1JFNj8s0SMfQBd7MXG8r+OSL4pWPL7EhVmsS2 27 | WMXSd3FqGMORF7NKqrtXGb203mk= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /internal/provider/resources/wipe_status.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // NewWipeStatus creates a new WipeStatus. 19 | func NewWipeStatus(id string) *WipeStatus { 20 | return typed.NewResource[WipeStatusSpec, WipeStatusExtension]( 21 | resource.NewMetadata(Namespace(), WipeStatusType(), id, resource.VersionUndefined), 22 | protobuf.NewResourceSpec(&specs.WipeStatusSpec{}), 23 | ) 24 | } 25 | 26 | // WipeStatusType is the type of WipeStatus resource. 27 | func WipeStatusType() string { 28 | return infra.ResourceType("WipeStatus", providermeta.ProviderID.String()) 29 | } 30 | 31 | // WipeStatus describes the resource configuration. 32 | type WipeStatus = typed.Resource[WipeStatusSpec, WipeStatusExtension] 33 | 34 | // WipeStatusSpec wraps specs.WipeStatusSpec. 35 | type WipeStatusSpec = protobuf.ResourceSpec[specs.WipeStatusSpec, *specs.WipeStatusSpec] 36 | 37 | // WipeStatusExtension providers auxiliary methods for WipeStatus resource. 38 | type WipeStatusExtension struct{} 39 | 40 | // ResourceDefinition implements [typed.Extension] interface. 41 | func (WipeStatusExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 42 | return meta.ResourceDefinitionSpec{ 43 | Type: WipeStatusType(), 44 | Aliases: []resource.Type{}, 45 | DefaultNamespace: Namespace(), 46 | PrintColumns: []meta.PrintColumn{}, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/provider/resources/reboot_status.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // NewRebootStatus creates a new RebootStatus. 19 | func NewRebootStatus(id string) *RebootStatus { 20 | return typed.NewResource[RebootStatusSpec, RebootStatusExtension]( 21 | resource.NewMetadata(Namespace(), RebootStatusType(), id, resource.VersionUndefined), 22 | protobuf.NewResourceSpec(&specs.RebootStatusSpec{}), 23 | ) 24 | } 25 | 26 | // RebootStatusType is the type of RebootStatus resource. 27 | func RebootStatusType() string { 28 | return infra.ResourceType("RebootStatus", providermeta.ProviderID.String()) 29 | } 30 | 31 | // RebootStatus describes the resource configuration. 32 | type RebootStatus = typed.Resource[RebootStatusSpec, RebootStatusExtension] 33 | 34 | // RebootStatusSpec wraps specs.RebootStatusSpec. 35 | type RebootStatusSpec = protobuf.ResourceSpec[specs.RebootStatusSpec, *specs.RebootStatusSpec] 36 | 37 | // RebootStatusExtension providers auxiliary methods for RebootStatus resource. 38 | type RebootStatusExtension struct{} 39 | 40 | // ResourceDefinition implements [typed.Extension] interface. 41 | func (RebootStatusExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 42 | return meta.ResourceDefinitionSpec{ 43 | Type: RebootStatusType(), 44 | Aliases: []resource.Type{}, 45 | DefaultNamespace: Namespace(), 46 | PrintColumns: []meta.PrintColumn{}, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/provider/resources/tls_config.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // TLSConfigID is the ID of the TLSConfig resource. 19 | const TLSConfigID = "tls-config" 20 | 21 | // NewTLSConfig creates a new TLSConfig. 22 | func NewTLSConfig() *TLSConfig { 23 | return typed.NewResource[TLSConfigSpec, TLSConfigExtension]( 24 | resource.NewMetadata(Namespace(), TLSConfigType(), TLSConfigID, resource.VersionUndefined), 25 | protobuf.NewResourceSpec(&specs.TLSConfigSpec{}), 26 | ) 27 | } 28 | 29 | // TLSConfigType is the type of TLSConfig resource. 30 | func TLSConfigType() string { 31 | return infra.ResourceType("TLSConfig", providermeta.ProviderID.String()) 32 | } 33 | 34 | // TLSConfig describes the resource configuration. 35 | type TLSConfig = typed.Resource[TLSConfigSpec, TLSConfigExtension] 36 | 37 | // TLSConfigSpec wraps specs.TLSConfigSpec. 38 | type TLSConfigSpec = protobuf.ResourceSpec[specs.TLSConfigSpec, *specs.TLSConfigSpec] 39 | 40 | // TLSConfigExtension providers auxiliary methods for TLSConfig resource. 41 | type TLSConfigExtension struct{} 42 | 43 | // ResourceDefinition implements [typed.Extension] interface. 44 | func (TLSConfigExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 45 | return meta.ResourceDefinitionSpec{ 46 | Type: TLSConfigType(), 47 | Aliases: []resource.Type{}, 48 | DefaultNamespace: Namespace(), 49 | PrintColumns: []meta.PrintColumn{}, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/provider/resources/machine_status.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // NewMachineStatus creates a new MachineStatus. 19 | func NewMachineStatus(id string) *MachineStatus { 20 | return typed.NewResource[MachineStatusSpec, MachineStatusExtension]( 21 | resource.NewMetadata(Namespace(), MachineStatusType(), id, resource.VersionUndefined), 22 | protobuf.NewResourceSpec(&specs.MachineStatusSpec{}), 23 | ) 24 | } 25 | 26 | // MachineStatusType is the type of MachineStatus resource. 27 | func MachineStatusType() string { 28 | return infra.ResourceType("MachineStatus", providermeta.ProviderID.String()) 29 | } 30 | 31 | // MachineStatus describes the resource configuration. 32 | type MachineStatus = typed.Resource[MachineStatusSpec, MachineStatusExtension] 33 | 34 | // MachineStatusSpec wraps specs.MachineStatusSpec. 35 | type MachineStatusSpec = protobuf.ResourceSpec[specs.MachineStatusSpec, *specs.MachineStatusSpec] 36 | 37 | // MachineStatusExtension providers auxiliary methods for MachineStatus resource. 38 | type MachineStatusExtension struct{} 39 | 40 | // ResourceDefinition implements [typed.Extension] interface. 41 | func (MachineStatusExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 42 | return meta.ResourceDefinitionSpec{ 43 | Type: MachineStatusType(), 44 | Aliases: []resource.Type{}, 45 | DefaultNamespace: Namespace(), 46 | PrintColumns: []meta.PrintColumn{}, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/provider/resources/power_operation.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // NewPowerOperation creates a new PowerOperation. 19 | func NewPowerOperation(id string) *PowerOperation { 20 | return typed.NewResource[PowerOperationSpec, PowerOperationExtension]( 21 | resource.NewMetadata(Namespace(), PowerOperationType(), id, resource.VersionUndefined), 22 | protobuf.NewResourceSpec(&specs.PowerOperationSpec{}), 23 | ) 24 | } 25 | 26 | // PowerOperationType is the type of PowerOperation resource. 27 | func PowerOperationType() string { 28 | return infra.ResourceType("PowerOperation", providermeta.ProviderID.String()) 29 | } 30 | 31 | // PowerOperation describes power status configuration. 32 | type PowerOperation = typed.Resource[PowerOperationSpec, PowerOperationExtension] 33 | 34 | // PowerOperationSpec wraps specs.PowerOperationSpec. 35 | type PowerOperationSpec = protobuf.ResourceSpec[specs.PowerOperationSpec, *specs.PowerOperationSpec] 36 | 37 | // PowerOperationExtension providers auxiliary methods for PowerOperation resource. 38 | type PowerOperationExtension struct{} 39 | 40 | // ResourceDefinition implements [typed.Extension] interface. 41 | func (PowerOperationExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 42 | return meta.ResourceDefinitionSpec{ 43 | Type: PowerOperationType(), 44 | Aliases: []resource.Type{}, 45 | DefaultNamespace: Namespace(), 46 | PrintColumns: []meta.PrintColumn{}, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /hack/govulncheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Source: https://github.com/tianon/gosu/blob/e157efb/govulncheck-with-excludes.sh 3 | # Licensed under the Apache License, Version 2.0 4 | # Copyright Tianon Gravi 5 | set -Eeuo pipefail 6 | 7 | exclude_arg="" 8 | pass_args=() 9 | 10 | while [[ $# -gt 0 ]]; do 11 | case "$1" in 12 | -exclude) 13 | exclude_arg="$2" 14 | shift 2 15 | ;; 16 | *) 17 | pass_args+=("$1") 18 | shift 19 | ;; 20 | esac 21 | done 22 | 23 | if [[ -n "$exclude_arg" ]]; then 24 | excludeVulns="$(jq -nc --arg list "$exclude_arg" '$list | split(",")')" 25 | else 26 | excludeVulns="[]" 27 | fi 28 | 29 | export excludeVulns 30 | 31 | # Debug print 32 | echo "excludeVulns = $excludeVulns" 33 | echo "Passing args: ${pass_args[*]}" 34 | 35 | if ! command -v govulncheck > /dev/null; then 36 | printf "govulncheck not installed" 37 | exit 1 38 | fi 39 | 40 | if out="$(govulncheck "${pass_args[@]}")"; then 41 | printf '%s\n' "$out" 42 | exit 0 43 | fi 44 | 45 | json="$(govulncheck -json "${pass_args[@]}")" 46 | 47 | vulns="$(jq <<<"$json" -cs ' 48 | ( 49 | map( 50 | .osv // empty 51 | | { key: .id, value: . } 52 | ) 53 | | from_entries 54 | ) as $meta 55 | # https://github.com/tianon/gosu/issues/144 56 | | map( 57 | .finding // empty 58 | # https://github.com/golang/vuln/blob/3740f5cb12a3f93b18dbe200c4bcb6256f8586e2/internal/scan/template.go#L97-L104 59 | | select((.trace[0].function // "") != "") 60 | | .osv 61 | ) 62 | | unique 63 | | map($meta[.]) 64 | ')" 65 | if [ "$(jq <<<"$vulns" -r 'length')" -le 0 ]; then 66 | printf '%s\n' "$out" 67 | exit 1 68 | fi 69 | 70 | filtered="$(jq <<<"$vulns" -c ' 71 | (env.excludeVulns | fromjson) as $exclude 72 | | map(select( 73 | .id as $id 74 | | $exclude | index($id) | not 75 | )) 76 | ')" 77 | 78 | text="$(jq <<<"$filtered" -r 'map("- \(.id) (aka \(.aliases | join(", ")))\n\n\t\(.details | gsub("\n"; "\n\t"))") | join("\n\n")')" 79 | 80 | if [ -z "$text" ]; then 81 | printf 'No vulnerabilities found.\n' 82 | exit 0 83 | else 84 | printf '%s\n' "$text" 85 | exit 1 86 | fi 87 | -------------------------------------------------------------------------------- /internal/provider/resources/bmc_configuration.go: -------------------------------------------------------------------------------- 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 resources 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | "github.com/cosi-project/runtime/pkg/resource/typed" 12 | "github.com/siderolabs/omni/client/pkg/infra" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 15 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 16 | ) 17 | 18 | // NewBMCConfiguration creates a new BMCConfiguration. 19 | func NewBMCConfiguration(id string) *BMCConfiguration { 20 | return typed.NewResource[BMCConfigurationSpec, BMCConfigurationExtension]( 21 | resource.NewMetadata(Namespace(), BMCConfigurationType(), id, resource.VersionUndefined), 22 | protobuf.NewResourceSpec(&specs.BMCConfigurationSpec{}), 23 | ) 24 | } 25 | 26 | // BMCConfigurationType is the type of BMCConfiguration resource. 27 | func BMCConfigurationType() string { 28 | return infra.ResourceType("BMCConfiguration", providermeta.ProviderID.String()) 29 | } 30 | 31 | // BMCConfiguration describes the resource configuration. 32 | type BMCConfiguration = typed.Resource[BMCConfigurationSpec, BMCConfigurationExtension] 33 | 34 | // BMCConfigurationSpec wraps specs.BMCConfigurationSpec. 35 | type BMCConfigurationSpec = protobuf.ResourceSpec[specs.BMCConfigurationSpec, *specs.BMCConfigurationSpec] 36 | 37 | // BMCConfigurationExtension providers auxiliary methods for BMCConfiguration resource. 38 | type BMCConfigurationExtension struct{} 39 | 40 | // ResourceDefinition implements [typed.Extension] interface. 41 | func (BMCConfigurationExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 42 | return meta.ResourceDefinitionSpec{ 43 | Type: BMCConfigurationType(), 44 | Aliases: []resource.Type{}, 45 | DefaultNamespace: Namespace(), 46 | PrintColumns: []meta.PrintColumn{}, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/provider/data/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/specs/specs.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package baremetalproviderspecs; 4 | 5 | option go_package = "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs"; 6 | 7 | import "google/protobuf/timestamp.proto"; 8 | 9 | enum PowerState { 10 | POWER_STATE_UNKNOWN = 0; 11 | POWER_STATE_OFF = 1; 12 | POWER_STATE_ON = 2; 13 | } 14 | 15 | message PowerOperationSpec { 16 | PowerState last_power_operation = 1; 17 | google.protobuf.Timestamp last_power_on_timestamp = 2; 18 | } 19 | 20 | message BMCConfigurationSpec { 21 | message IPMI { 22 | string address = 1; 23 | uint32 port = 2; 24 | string username = 3; 25 | string password = 4; 26 | } 27 | 28 | message API { 29 | string address = 1; 30 | } 31 | 32 | IPMI ipmi = 1; 33 | API api = 2; 34 | bool manually_configured = 3; 35 | } 36 | 37 | message MachineStatusSpec { 38 | bool agent_accessible = 1; 39 | PowerState power_state = 2; 40 | reserved 3; 41 | bool initialized = 4; 42 | } 43 | 44 | message WipeStatusSpec { 45 | // LastWipeId is the ID of the last wipe operation that was performed on the machine. 46 | // 47 | // It is used to track if the machine needs to be wiped for an allocation. 48 | string last_wipe_id = 1; 49 | 50 | // LastWipeInstallEventId is set to the same value of InfraMachine.InstallEventId field each time machine gets wiped. 51 | // 52 | // Using this, the provider is able to track the installation state of Talos on the machine. It does it by comparing this stored value 53 | // with the value of InfraMachine.InstallEventId field. 54 | // 55 | // If the value of InfraMachine.InstallEventId field is greater than the value of this field, 56 | // it means that Omni observed, after the wipe, at least one event indicating Talos is installed on that machine. 57 | uint64 last_wipe_install_event_id = 2; 58 | 59 | bool initial_wipe_done = 3; 60 | 61 | // WipedNodeUniqueToken contains the value of the node unique token used during the last node wipe. 62 | string wiped_node_unique_token = 4; 63 | } 64 | 65 | message RebootStatusSpec { 66 | string last_reboot_id = 1; 67 | 68 | // LastRebootTimestamp is the timestamp of the last reboot (or power on) of the machine. 69 | // 70 | // It is used to track the last reboot time of the machine, and to enforce the MinRebootInterval. 71 | google.protobuf.Timestamp last_reboot_timestamp = 2; 72 | } 73 | 74 | message TLSConfigSpec { 75 | string ca_cert = 1; 76 | string ca_key = 2; 77 | } 78 | -------------------------------------------------------------------------------- /internal/provider/machineconfig/machineconfig_test.go: -------------------------------------------------------------------------------- 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 machineconfig_test 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | "time" 13 | 14 | "github.com/cosi-project/runtime/pkg/resource/meta" 15 | "github.com/cosi-project/runtime/pkg/state" 16 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 17 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 18 | "github.com/siderolabs/omni/client/api/omni/specs" 19 | "github.com/siderolabs/omni/client/pkg/omni/resources/siderolink" 20 | "github.com/siderolabs/talos/pkg/machinery/config/configloader" 21 | "github.com/stretchr/testify/require" 22 | 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/machineconfig" 24 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 25 | ) 26 | 27 | func TestV2ExtraDocs(t *testing.T) { 28 | t.Parallel() 29 | 30 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) 31 | t.Cleanup(cancel) 32 | 33 | st := state.WrapCore(namespaced.NewState(inmem.Build)) 34 | 35 | providerJoinConfig := siderolink.NewProviderJoinConfig(providermeta.ProviderID.String()) 36 | providerJoinConfig.TypedSpec().Value.Config = &specs.JoinConfig{ 37 | Config: `apiVersion: v1alpha1 38 | kind: SideroLinkConfig 39 | apiUrl: grpc://127.0.0.1:8090?jointoken=test 40 | --- 41 | apiVersion: v1alpha1 42 | kind: EventSinkConfig 43 | endpoint: '[fdae:41e4:649b:9303::1]:8090' 44 | --- 45 | apiVersion: v1alpha1 46 | kind: KmsgLogConfig 47 | name: omni-kmsg 48 | url: tcp://[fdae:41e4:649b:9303::1]:8092`, 49 | } 50 | 51 | require.NoError(t, st.Create(ctx, providerJoinConfig)) 52 | 53 | resDef, err := meta.NewResourceDefinition(providerJoinConfig.ResourceDefinition()) 54 | require.NoError(t, err) 55 | 56 | require.NoError(t, st.Create(ctx, resDef)) 57 | 58 | configPath := filepath.Join(t.TempDir(), "extra-config.yaml") 59 | 60 | err = os.WriteFile(configPath, []byte(`apiVersion: v1alpha1 61 | kind: KmsgLogConfig 62 | name: extra-doc 63 | url: tcp://[fdae:41e4:649b:9303::1]:8092`), 0o644) 64 | require.NoError(t, err) 65 | 66 | config, err := machineconfig.Build(ctx, st, nil, configPath) 67 | require.NoError(t, err) 68 | 69 | returnedConfig, err := configloader.NewFromBytes(config) 70 | require.NoError(t, err) 71 | 72 | require.Len(t, returnedConfig.Documents(), 4) 73 | require.Contains(t, string(config), "name: extra-doc") 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/e2e-cron.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-09-26T10:43:03Z by kres fdbc9fc. 4 | 5 | concurrency: 6 | group: ${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | "on": 9 | schedule: 10 | - cron: 30 1 * * * 11 | name: e2e-cron 12 | jobs: 13 | default: 14 | runs-on: 15 | group: large 16 | steps: 17 | - name: gather-system-info 18 | id: system-info 19 | uses: kenchan0130/actions-system-info@v1.4.0 20 | continue-on-error: true 21 | - name: print-system-info 22 | run: | 23 | MEMORY_GB=$((${{ steps.system-info.outputs.totalmem }}/1024/1024/1024)) 24 | 25 | OUTPUTS=( 26 | "CPU Core: ${{ steps.system-info.outputs.cpu-core }}" 27 | "CPU Model: ${{ steps.system-info.outputs.cpu-model }}" 28 | "Hostname: ${{ steps.system-info.outputs.hostname }}" 29 | "NodeName: ${NODE_NAME}" 30 | "Kernel release: ${{ steps.system-info.outputs.kernel-release }}" 31 | "Kernel version: ${{ steps.system-info.outputs.kernel-version }}" 32 | "Name: ${{ steps.system-info.outputs.name }}" 33 | "Platform: ${{ steps.system-info.outputs.platform }}" 34 | "Release: ${{ steps.system-info.outputs.release }}" 35 | "Total memory: ${MEMORY_GB} GB" 36 | ) 37 | 38 | for OUTPUT in "${OUTPUTS[@]}";do 39 | echo "${OUTPUT}" 40 | done 41 | continue-on-error: true 42 | - name: checkout 43 | uses: actions/checkout@v5 44 | - name: Unshallow 45 | run: | 46 | git fetch --prune --unshallow 47 | - name: Set up Docker Buildx 48 | id: setup-buildx 49 | uses: docker/setup-buildx-action@v3 50 | with: 51 | driver: remote 52 | endpoint: tcp://buildkit-amd64.ci.svc.cluster.local:1234 53 | timeout-minutes: 10 54 | - name: Mask secrets 55 | run: | 56 | echo "$(sops -d .secrets.yaml | yq -e '.secrets | to_entries[] | "::add-mask::" + .value')" 57 | - name: Set secrets for job 58 | run: | 59 | sops -d .secrets.yaml | yq -e '.secrets | to_entries[] | .key + "=" + .value' >> "$GITHUB_ENV" 60 | - name: run-integration-test 61 | env: 62 | TEMP_REGISTRY: registry.dev.siderolabs.io 63 | run: | 64 | sudo -E make run-integration-test 65 | - name: save-integration-test-artifacts 66 | if: always() 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: integration-test 70 | path: /tmp/integration-test 71 | retention-days: "5" 72 | -------------------------------------------------------------------------------- /internal/provider/options.go: -------------------------------------------------------------------------------- 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 provider 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/agent" 11 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 12 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/redfish" 13 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/ipxe" 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/tls" 15 | ) 16 | 17 | // Options contains the provider options. 18 | type Options struct { 19 | IPMIPXEBootMode string 20 | DHCPProxyIfaceOrIP string 21 | OmniAPIEndpoint string 22 | ImageFactoryBaseURL string 23 | ImageFactoryPXEBaseURL string 24 | AgentModeTalosVersion string // todo: get this from Omni. Warning: needs to be Talos 1.9 with agent code inside 25 | APIListenAddress string 26 | APIAdvertiseAddress string 27 | APIPowerMgmtStateDir string 28 | Name string 29 | Description string 30 | BootFromDiskMethod string 31 | ExtraMachineConfigPath string 32 | 33 | MachineLabels []string 34 | 35 | TLS tls.Options 36 | AgentClient agent.ClientOptions 37 | Redfish redfish.Options 38 | MinRebootInterval time.Duration 39 | DHCPProxyPort int 40 | APIPort int 41 | 42 | UseLocalBootAssets bool 43 | AgentTestMode bool 44 | InsecureSkipTLSVerify bool 45 | EnableResourceCache bool 46 | ClearState bool 47 | DisableDHCPProxy bool 48 | SecureBootEnabled bool 49 | } 50 | 51 | // DefaultOptions returns the default provider options. 52 | func DefaultOptions() Options { 53 | return Options{ 54 | Name: "Bare Metal", 55 | Description: "Bare metal infrastructure provider", 56 | ImageFactoryBaseURL: "https://factory.talos.dev", 57 | ImageFactoryPXEBaseURL: "https://pxe.factory.talos.dev", 58 | AgentModeTalosVersion: "v1.11.3", 59 | DHCPProxyPort: 67, 60 | BootFromDiskMethod: string(ipxe.BootIPXEExit), 61 | IPMIPXEBootMode: string(pxe.BootModeUEFI), 62 | APIPort: 50042, 63 | MinRebootInterval: 15 * time.Minute, 64 | Redfish: redfish.DefaultOptions(), 65 | TLS: tls.Options{ 66 | Enabled: false, 67 | APIPort: 50043, 68 | AgentSkipVerify: false, 69 | CATTL: 30 * 365 * 24 * time.Hour, // 30 years 70 | CertTTL: 24 * time.Hour, 71 | }, 72 | AgentClient: agent.DefaultClientOptions(), 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/provider/controllers/power_operation_test.go: -------------------------------------------------------------------------------- 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 controllers_test 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "time" 11 | 12 | "github.com/cosi-project/runtime/pkg/controller/runtime" 13 | "github.com/cosi-project/runtime/pkg/resource/rtestutils" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 16 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | "go.uber.org/zap" 20 | 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 22 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/controllers" 24 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 25 | ) 26 | 27 | func TestPowerOn(t *testing.T) { 28 | t.Parallel() 29 | 30 | powerOnCh := make(chan struct{}) 31 | setPXEBootOnceCh := make(chan pxe.BootMode) 32 | 33 | bmcClientFactory := &bmcClientFactoryMock{ 34 | bmcClient: &bmcClientMock{ 35 | poweredOn: false, 36 | powerOnCh: powerOnCh, 37 | setPXEBootOnceCh: setPXEBootOnceCh, 38 | }, 39 | } 40 | 41 | pxeBootMode := pxe.BootModeUEFI 42 | 43 | now := time.Now() 44 | nowFunc := func() time.Time { return now } 45 | 46 | withRuntime(t, 47 | func(_ context.Context, _ state.State, rt *runtime.Runtime, _ *zap.Logger) { 48 | controller := controllers.NewPowerOperationController(nowFunc, bmcClientFactory, 0, pxeBootMode) 49 | 50 | require.NoError(t, rt.RegisterQController(controller)) 51 | }, 52 | func(ctx context.Context, st state.State, _ *runtime.Runtime, _ *zap.Logger) { 53 | bmcConfiguration := resources.NewBMCConfiguration("test-machine") 54 | 55 | require.NoError(t, st.Create(ctx, bmcConfiguration)) 56 | 57 | infraMachine := infra.NewMachine("test-machine") 58 | 59 | infraMachine.TypedSpec().Value.AcceptanceStatus = omnispecs.InfraMachineConfigSpec_ACCEPTED 60 | 61 | require.NoError(t, st.Create(ctx, infraMachine)) 62 | 63 | // expect a SetPXEBootOnce call 64 | mode := requireChReceive(ctx, t, setPXEBootOnceCh) 65 | require.Equal(t, pxeBootMode, mode) 66 | 67 | // expect a PowerOn call 68 | requireChReceive(ctx, t, powerOnCh) 69 | 70 | rtestutils.AssertResource(ctx, t, st, infraMachine.Metadata().ID(), func(res *resources.PowerOperation, assertion *assert.Assertions) { 71 | assertion.Equal(specs.PowerState_POWER_STATE_ON, res.TypedSpec().Value.LastPowerOperation) 72 | assertion.Equal(now.Unix(), res.TypedSpec().Value.LastPowerOnTimestamp.AsTime().Unix()) 73 | }) 74 | }, 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /internal/provider/bmc/ipmi/ipmi.go: -------------------------------------------------------------------------------- 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 ipmi provides BMC functionality using IPMI. 6 | package ipmi 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "time" 12 | 13 | "github.com/bougou/go-ipmi" 14 | 15 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 16 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 17 | ) 18 | 19 | const ipmiUsername = "talos-agent" 20 | 21 | // Client is a wrapper around the goipmi client. 22 | type Client struct { 23 | ipmiClient *ipmi.Client 24 | } 25 | 26 | // Close implements the power.Client interface. 27 | func (c *Client) Close() error { 28 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(ipmi.DefaultExchangeTimeoutSec)*time.Second) 29 | defer cancel() 30 | 31 | return c.ipmiClient.Close(ctx) 32 | } 33 | 34 | // Reboot implements the power.Client interface. 35 | func (c *Client) Reboot(ctx context.Context) error { 36 | _, err := c.ipmiClient.ChassisControl(ctx, ipmi.ChassisControlPowerCycle) 37 | 38 | return err 39 | } 40 | 41 | // PowerOn implements the power.Client interface. 42 | func (c *Client) PowerOn(ctx context.Context) error { 43 | _, err := c.ipmiClient.ChassisControl(ctx, ipmi.ChassisControlPowerUp) 44 | 45 | return err 46 | } 47 | 48 | // PowerOff implements the power.Client interface. 49 | func (c *Client) PowerOff(ctx context.Context) error { 50 | _, err := c.ipmiClient.ChassisControl(ctx, ipmi.ChassisControlPowerDown) 51 | 52 | return err 53 | } 54 | 55 | // SetPXEBootOnce implements the power.Client interface. 56 | func (c *Client) SetPXEBootOnce(ctx context.Context, mode pxe.BootMode) error { 57 | var bootType ipmi.BIOSBootType 58 | 59 | switch mode { 60 | case pxe.BootModeBIOS: 61 | bootType = ipmi.BIOSBootTypeLegacy 62 | case pxe.BootModeUEFI: 63 | bootType = ipmi.BIOSBootTypeEFI 64 | default: 65 | return fmt.Errorf("unsupported mode %q", mode) 66 | } 67 | 68 | return c.ipmiClient.SetBootDevice(ctx, ipmi.BootDeviceSelectorForcePXE, bootType, false) 69 | } 70 | 71 | // IsPoweredOn implements the power.Client interface. 72 | func (c *Client) IsPoweredOn(ctx context.Context) (bool, error) { 73 | resp, err := c.ipmiClient.GetChassisStatus(ctx) 74 | if err != nil { 75 | return false, err 76 | } 77 | 78 | return resp.PowerIsOn, nil 79 | } 80 | 81 | // NewClient creates a new IPMI client and connects to the BMC using the provided configuration. 82 | // 83 | // It needs to be closed after use to release resources. 84 | func NewClient(ctx context.Context, info *specs.BMCConfigurationSpec_IPMI) (*Client, error) { 85 | client, err := ipmi.NewClient(info.Address, int(info.Port), ipmiUsername, info.Password) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to create IPMI client: %w", err) 88 | } 89 | 90 | if err = client.Connect(ctx); err != nil { 91 | return nil, fmt.Errorf("failed to connect IPMI client: %w", err) 92 | } 93 | 94 | return &Client{ 95 | ipmiClient: client, 96 | }, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/provider/controllers/controllers.go: -------------------------------------------------------------------------------- 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 controllers implements COSI controllers for the bare metal provider. 6 | package controllers 7 | 8 | import ( 9 | "context" 10 | "time" 11 | 12 | "github.com/cosi-project/runtime/pkg/controller/generic/qtransform" 13 | "github.com/siderolabs/gen/xerrors" 14 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 15 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 16 | agentpb "github.com/siderolabs/talos-metal-agent/api/agent" 17 | "go.uber.org/zap" 18 | 19 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc" 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 21 | ) 22 | 23 | const ( 24 | // IPMIUsername is the username used for IPMI. 25 | IPMIUsername = "talos-agent" 26 | 27 | // IPMIPasswordLength is the length of the IPMI password. 28 | IPMIPasswordLength = 16 29 | 30 | // IPMIDefaultPort is the default port for IPMI. 31 | IPMIDefaultPort = 623 32 | ) 33 | 34 | // AgentClient is the interface for interacting with the Talos agent over the reverse GRPC tunnel. 35 | type AgentClient interface { 36 | GetPowerManagement(ctx context.Context, id string) (*agentpb.GetPowerManagementResponse, error) 37 | SetPowerManagement(ctx context.Context, id string, req *agentpb.SetPowerManagementRequest) error 38 | WipeDisks(ctx context.Context, id string) error 39 | AllConnectedMachines() map[string]struct{} 40 | IsAccessible(ctx context.Context, machineID string) (bool, error) 41 | } 42 | 43 | // BMCClientFactory is the interface for creating BMC clients. 44 | type BMCClientFactory interface { 45 | GetClient(ctx context.Context, bmcConfiguration *resources.BMCConfiguration, logger *zap.Logger) (bmc.Client, error) 46 | } 47 | 48 | func validateInfraMachine(infraMachine *infra.Machine, logger *zap.Logger) error { 49 | if infraMachine.TypedSpec().Value.AcceptanceStatus != omnispecs.InfraMachineConfigSpec_ACCEPTED { 50 | logger.Debug("machine not accepted, skip") 51 | 52 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine not accepted") 53 | } 54 | 55 | if infraMachine.TypedSpec().Value.Cordoned { 56 | logger.Debug("machine cordoned, skip") 57 | 58 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine is cordoned") 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func getTimeSinceLastPowerOn(powerOperation *resources.PowerOperation, rebootStatus *resources.RebootStatus) time.Duration { 65 | var lastPowerOnTime time.Time 66 | 67 | if powerOperation != nil && powerOperation.TypedSpec().Value.LastPowerOnTimestamp != nil { 68 | lastPowerOnTime = powerOperation.TypedSpec().Value.LastPowerOnTimestamp.AsTime() 69 | } 70 | 71 | if rebootStatus != nil && rebootStatus.TypedSpec().Value.LastRebootTimestamp != nil { 72 | lastRebootTime := rebootStatus.TypedSpec().Value.LastRebootTimestamp.AsTime() 73 | if lastRebootTime.After(lastPowerOnTime) { 74 | lastPowerOnTime = lastRebootTime 75 | } 76 | } 77 | 78 | return time.Since(lastPowerOnTime) 79 | } 80 | -------------------------------------------------------------------------------- /hack/certs/key.public: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | 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 50 | 2lEebIgAF9oRsC9BU9vSsg== 51 | =f37X 52 | -----END PGP PUBLIC KEY BLOCK----- 53 | -------------------------------------------------------------------------------- /internal/provider/bmc/api/api.go: -------------------------------------------------------------------------------- 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 api provides BMC functionality using an HTTP API, e.g., the HTTP API run by 'talosctl cluster create'. 6 | package api 7 | 8 | import ( 9 | "context" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "time" 15 | 16 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 17 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 18 | ) 19 | 20 | // Client is an API BMC client: it communicates with an HTTP API to send BMC commands. 21 | type Client struct { 22 | address string 23 | } 24 | 25 | // Close implements the power.Client interface. 26 | func (c *Client) Close() error { 27 | return nil 28 | } 29 | 30 | // Reboot implements the power.Client interface. 31 | func (c *Client) Reboot(ctx context.Context) error { 32 | return c.doPost(ctx, "/reboot") 33 | } 34 | 35 | // PowerOn implements the power.Client interface. 36 | func (c *Client) PowerOn(ctx context.Context) error { 37 | return c.doPost(ctx, "/poweron") 38 | } 39 | 40 | // PowerOff implements the power.Client interface. 41 | func (c *Client) PowerOff(ctx context.Context) error { 42 | return c.doPost(ctx, "/poweroff") 43 | } 44 | 45 | // SetPXEBootOnce implements the power.Client interface. 46 | func (c *Client) SetPXEBootOnce(ctx context.Context, _ pxe.BootMode) error { 47 | return c.doPost(ctx, "/pxeboot") 48 | } 49 | 50 | func (c *Client) doPost(ctx context.Context, path string) error { 51 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 52 | defer cancel() 53 | 54 | endpoint := "http://" + c.address + path 55 | 56 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) 57 | if err != nil { 58 | return fmt.Errorf("failed to create request %q: %w", path, err) 59 | } 60 | 61 | resp, err := http.DefaultClient.Do(req) 62 | if err != nil { 63 | return fmt.Errorf("failed to make request %q: %w", path, err) 64 | } 65 | 66 | defer closeBody(resp) 67 | 68 | if resp.StatusCode != http.StatusOK { 69 | return fmt.Errorf("unexpected status code while resetting machine: %d", resp.StatusCode) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // IsPoweredOn implements the power.Client interface. 76 | func (c *Client) IsPoweredOn(ctx context.Context) (bool, error) { 77 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 78 | defer cancel() 79 | 80 | endpoint := "http://" + c.address + "/status" 81 | 82 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 83 | if err != nil { 84 | return false, err 85 | } 86 | 87 | resp, err := http.DefaultClient.Do(req) 88 | if err != nil { 89 | return false, err 90 | } 91 | 92 | defer closeBody(resp) 93 | 94 | var status struct { 95 | PoweredOn bool 96 | } 97 | 98 | if err = json.NewDecoder(resp.Body).Decode(&status); err != nil { //nolint:musttag 99 | return false, err 100 | } 101 | 102 | return status.PoweredOn, nil 103 | } 104 | 105 | //nolint:errcheck 106 | func closeBody(resp *http.Response) { 107 | io.Copy(io.Discard, resp.Body) 108 | resp.Body.Close() 109 | } 110 | 111 | // NewClient creates a new API BMC client. 112 | func NewClient(info *specs.BMCConfigurationSpec_API) (*Client, error) { 113 | return &Client{address: info.Address}, nil 114 | } 115 | -------------------------------------------------------------------------------- /internal/provider/imagefactory/client.go: -------------------------------------------------------------------------------- 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 imagefactory 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/siderolabs/image-factory/pkg/client" 13 | "github.com/siderolabs/image-factory/pkg/schematic" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | var agentModeExtensions = []string{ 18 | // include all firmware extensions 19 | "siderolabs/amd-ucode", 20 | "siderolabs/amdgpu-firmware", 21 | "siderolabs/bnx2-bnx2x", 22 | "siderolabs/chelsio-firmware", 23 | "siderolabs/i915-ucode", 24 | "siderolabs/intel-ice-firmware", 25 | "siderolabs/intel-ucode", 26 | "siderolabs/qlogic-firmware", 27 | "siderolabs/realtek-firmware", 28 | // include the agent extension itself 29 | "siderolabs/metal-agent", 30 | } 31 | 32 | // Client is an image factory client. 33 | type Client struct { 34 | factoryClient *client.Client 35 | logger *zap.Logger 36 | pxeBaseURL string 37 | agentModeTalosVersion string 38 | secureBootEnabled bool 39 | } 40 | 41 | // NewClient creates a new image factory client. 42 | func NewClient(baseURL, pxeBaseURL, agentModeTalosVersion string, secureBootEnabled bool, logger *zap.Logger) (*Client, error) { 43 | factoryClient, err := client.New(baseURL) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return &Client{ 49 | pxeBaseURL: pxeBaseURL, 50 | agentModeTalosVersion: agentModeTalosVersion, 51 | factoryClient: factoryClient, 52 | secureBootEnabled: secureBootEnabled, 53 | logger: logger, 54 | }, nil 55 | } 56 | 57 | // SchematicIPXEURL ensures a schematic exists on the image factory and returns the iPXE URL to it. 58 | // 59 | // If agentMode is true, the schematic will be created with the firmware extensions and the metal-agent extension. 60 | func (c *Client) SchematicIPXEURL(ctx context.Context, agentMode bool, talosVersion, arch string, extensions, extraKernelArgs []string) (string, error) { 61 | logger := c.logger.With(zap.String("talos_version", talosVersion), zap.String("arch", arch), 62 | zap.Strings("extensions", extensions), zap.Strings("extra_kernel_args", extraKernelArgs)) 63 | 64 | logger.Debug("generate schematic iPXE URL") 65 | 66 | var metaValues []schematic.MetaValue 67 | 68 | if !agentMode && talosVersion == "" { 69 | return "", fmt.Errorf("talosVersion is required when not booting into agent mode") 70 | } 71 | 72 | if agentMode { 73 | talosVersion = c.agentModeTalosVersion 74 | 75 | extensions = agentModeExtensions 76 | } 77 | 78 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 79 | defer cancel() 80 | 81 | sch := schematic.Schematic{ 82 | Customization: schematic.Customization{ 83 | ExtraKernelArgs: extraKernelArgs, 84 | Meta: metaValues, 85 | SystemExtensions: schematic.SystemExtensions{ 86 | OfficialExtensions: extensions, 87 | }, 88 | }, 89 | } 90 | 91 | marshaled, err := sch.Marshal() 92 | if err != nil { 93 | return "", fmt.Errorf("failed to marshal schematic: %w", err) 94 | } 95 | 96 | logger.Debug("generated schematic", zap.String("schematic", string(marshaled))) 97 | 98 | schematicID, err := c.factoryClient.SchematicCreate(ctx, sch) 99 | if err != nil { 100 | return "", fmt.Errorf("failed to create schematic: %w", err) 101 | } 102 | 103 | ipxeURL := fmt.Sprintf("%s/pxe/%s/%s/metal-%s", c.pxeBaseURL, schematicID, talosVersion, arch) 104 | if c.secureBootEnabled { 105 | ipxeURL += "-secureboot" 106 | } 107 | 108 | logger.Debug("generated schematic iPXE URL", zap.String("ipxe_url", ipxeURL)) 109 | 110 | return ipxeURL, nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/provider/bmc/api/manager.go: -------------------------------------------------------------------------------- 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 api 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "net" 11 | "net/netip" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | 17 | "go.uber.org/zap" 18 | ) 19 | 20 | // AddressReader reads the BMC address from the state directory for a given machine ID. 21 | type AddressReader struct { 22 | stateDir string 23 | } 24 | 25 | // NewAddressReader creates a new API AddressReader. 26 | func NewAddressReader(stateDir string) *AddressReader { 27 | return &AddressReader{stateDir: stateDir} 28 | } 29 | 30 | // ReadManagementAddress reads the BMC address from the state directory for the given machine ID. 31 | func (manager *AddressReader) ReadManagementAddress(machineID string, logger *zap.Logger) (string, error) { 32 | files, err := os.ReadDir(manager.stateDir) 33 | if err != nil { 34 | return "", fmt.Errorf("failed to read directory %s: %w", manager.stateDir, err) 35 | } 36 | 37 | numConfigFiles := 0 38 | 39 | for _, file := range files { 40 | if !strings.HasSuffix(file.Name(), ".config") { 41 | continue 42 | } 43 | 44 | numConfigFiles++ 45 | 46 | configPath := filepath.Join(manager.stateDir, file.Name()) 47 | 48 | addr, addrErr := processConfigFile(configPath, machineID, logger) 49 | if addrErr != nil { 50 | logger.Warn("error processing config file", 51 | zap.String("file", file.Name()), 52 | zap.Error(addrErr)) 53 | 54 | continue 55 | } 56 | 57 | if addr == "" { 58 | logger.Warn("address is empty in config file", zap.String("file", file.Name()), zap.String("machine_id", machineID)) 59 | 60 | continue 61 | } 62 | 63 | return addr, nil 64 | } 65 | 66 | return "", fmt.Errorf("no management address found in %d config files: machine ID: %q, total files: %d, state dir: %q", numConfigFiles, machineID, len(files), manager.stateDir) 67 | } 68 | 69 | func processConfigFile(configPath, machineID string, logger *zap.Logger) (addr string, err error) { 70 | configData, err := os.ReadFile(configPath) 71 | if err != nil { 72 | return "", fmt.Errorf("failed to read config file: %w", err) 73 | } 74 | 75 | var conf launchConfig 76 | if err = json.Unmarshal(configData, &conf); err != nil { 77 | return "", fmt.Errorf("failed to unmarshal config file: %w", err) 78 | } 79 | 80 | // Skip if NodeUUID doesn't match machineID 81 | if conf.NodeUUID != machineID { 82 | return "", nil 83 | } 84 | 85 | gatewayAddrs := conf.GatewayAddrs 86 | if len(conf.Network.GatewayAddrs) > 0 { // if the config is in the new format in the Talos machinery, use that 87 | gatewayAddrs = conf.Network.GatewayAddrs 88 | } 89 | 90 | if len(gatewayAddrs) == 0 { 91 | return "", fmt.Errorf("no gateway address found in matching machine launch config: %s", configPath) 92 | } 93 | 94 | gatewayAddr := gatewayAddrs[0].String() 95 | 96 | if len(gatewayAddrs) > 1 { 97 | logger.Warn("multiple gateway addresses found in machine launch config, using the first one", 98 | zap.String("gateway_addr", gatewayAddr), 99 | zap.String("file", configPath)) 100 | } 101 | 102 | apiPort := conf.APIPort 103 | if conf.APIBindAddress != nil { // if the config is in the new format in the Talos machinery, use that 104 | apiPort = conf.APIBindAddress.Port 105 | } 106 | 107 | if apiPort == 0 { 108 | return "", fmt.Errorf("api port is not found in the machine launch config: %s", configPath) 109 | } 110 | 111 | addr = net.JoinHostPort(gatewayAddr, strconv.Itoa(apiPort)) 112 | 113 | return addr, nil 114 | } 115 | 116 | // launchConfig is the JSON structure of the machine launch config, containing only the fields needed by this provisioner. 117 | type launchConfig struct { 118 | APIBindAddress *net.TCPAddr 119 | NodeUUID string 120 | GatewayAddrs []netip.Addr 121 | Network struct{ GatewayAddrs []netip.Addr } 122 | APIPort int 123 | } 124 | -------------------------------------------------------------------------------- /internal/provider/controllers/wipe_status.go: -------------------------------------------------------------------------------- 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 controllers 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/cosi-project/runtime/pkg/controller" 12 | "github.com/cosi-project/runtime/pkg/controller/generic/qtransform" 13 | "github.com/cosi-project/runtime/pkg/safe" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | "github.com/siderolabs/gen/xerrors" 16 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 17 | "go.uber.org/zap" 18 | 19 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/machine" 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 22 | ) 23 | 24 | // WipeStatusController manages machine power management. 25 | type WipeStatusController = qtransform.QController[*infra.Machine, *resources.WipeStatus] 26 | 27 | // NewWipeStatusController creates a new WipeStatusController. 28 | func NewWipeStatusController(agentClient AgentClient) *WipeStatusController { 29 | helper := &wipeStatusControllerHelper{ 30 | agentClient: agentClient, 31 | } 32 | 33 | return qtransform.NewQController( 34 | qtransform.Settings[*infra.Machine, *resources.WipeStatus]{ 35 | Name: meta.ProviderID.String() + ".WipeStatusController", 36 | MapMetadataFunc: func(infraMachine *infra.Machine) *resources.WipeStatus { 37 | return resources.NewWipeStatus(infraMachine.Metadata().ID()) 38 | }, 39 | UnmapMetadataFunc: func(wipeStatus *resources.WipeStatus) *infra.Machine { 40 | return infra.NewMachine(wipeStatus.Metadata().ID()) 41 | }, 42 | TransformFunc: helper.transform, 43 | }, 44 | qtransform.WithExtraMappedInput[*resources.MachineStatus]( 45 | qtransform.MapperSameID[*infra.Machine](), 46 | ), 47 | qtransform.WithConcurrency(4), 48 | ) 49 | } 50 | 51 | type wipeStatusControllerHelper struct { 52 | agentClient AgentClient 53 | } 54 | 55 | func (helper *wipeStatusControllerHelper) transform(ctx context.Context, r controller.Reader, logger *zap.Logger, infraMachine *infra.Machine, wipeStatus *resources.WipeStatus) error { 56 | machineStatus, err := safe.ReaderGetByID[*resources.MachineStatus](ctx, r, infraMachine.Metadata().ID()) 57 | if err != nil && !state.IsNotFoundError(err) { 58 | return err 59 | } 60 | 61 | if err = validateInfraMachine(infraMachine, logger); err != nil { 62 | return err 63 | } 64 | 65 | logger = logger.With( 66 | zap.String("wipe_id", infraMachine.TypedSpec().Value.WipeId), 67 | zap.String("last_wipe_id", wipeStatus.TypedSpec().Value.LastWipeId), 68 | ) 69 | 70 | if machineStatus == nil { 71 | logger.Debug("machine status not found, skip") 72 | 73 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine status not found") 74 | } 75 | 76 | if !machine.RequiresWipe(infraMachine, wipeStatus) { 77 | logger.Debug("machine does not require wipe, skip") 78 | 79 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine does not require wipe") 80 | } 81 | 82 | if !machineStatus.TypedSpec().Value.AgentAccessible { 83 | logger.Info("agent is not accessible, skip") 84 | 85 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("agent is not accessible") 86 | } 87 | 88 | if err = helper.agentClient.WipeDisks(ctx, infraMachine.Metadata().ID()); err != nil { 89 | return fmt.Errorf("failed to wipe disks: %w", err) 90 | } 91 | 92 | wasInitialWipe := !wipeStatus.TypedSpec().Value.InitialWipeDone 93 | wipeStatus.TypedSpec().Value.InitialWipeDone = true 94 | wipeStatus.TypedSpec().Value.LastWipeId = infraMachine.TypedSpec().Value.WipeId 95 | wipeStatus.TypedSpec().Value.LastWipeInstallEventId = infraMachine.TypedSpec().Value.InstallEventId 96 | wipeStatus.TypedSpec().Value.WipedNodeUniqueToken = infraMachine.TypedSpec().Value.NodeUniqueToken 97 | 98 | logger.Info("wiped disks on the machine", zap.String("wipe_id", wipeStatus.TypedSpec().Value.LastWipeId), 99 | zap.Bool("was_initial_wipe", wasInitialWipe), zap.Uint64("install_event_id", infraMachine.TypedSpec().Value.InstallEventId)) 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /.secrets.yaml: -------------------------------------------------------------------------------- 1 | secrets: 2 | AUTH0_TEST_USERNAME: ENC[AES256_GCM,data:lPddHbDVfWxaEW7ujLDnWdhIBMFj2hcp,iv:oG3Ebn8ym7g/Z7L3A3BTHRHIk+zzblZKvzMKYMPSfWI=,tag:wV7xJWbnLrj/UWj0fGGQCw==,type:str] 3 | AUTH0_TEST_PASSWORD: ENC[AES256_GCM,data:3tgQjqv5ktdnnGUQw5Lpuw==,iv:F8zYxqk5P0tV1Pvt6QBlho8H0wuX+K91pgwLzF+4kC8=,tag:HJ4s14d/u2KyP780wFDk/w==,type:str] 4 | AUTH0_CLIENT_ID: ENC[AES256_GCM,data:HevA8uFKCOPF8W/FRjSo/pyUFN66eXwvAxaqT5LdnT0=,iv:qpWNjsRSZ28lWQJGfMoGQvLY8KRKWv1dhR07vCgIvIU=,tag:x5BS26iacdBMv2ZkdCdr3A==,type:str] 5 | AUTH0_DOMAIN: ENC[AES256_GCM,data:2vv9ay+hC1kN46MG8E0v1Z3G7Dm0hMmLx1/AWg==,iv:9thZflFQ1yhf0jH3u6Om7RV7Y/qYzrTf82hoYrDvyG0=,tag:BUNuHJobt/NoR5FFQBIbIQ==,type:str] 6 | sops: 7 | kms: [] 8 | gcp_kms: [] 9 | azure_kv: [] 10 | hc_vault: [] 11 | age: 12 | - recipient: age1xrpa9ujxxcj2u2gzfrzv8mxak4rts94a6y60ypurv6rs5cpr4e4sg95f0k 13 | enc: | 14 | -----BEGIN AGE ENCRYPTED FILE----- 15 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRRC9PWFJkK2FJa3N0UHlv 16 | WkJGQVZCVUl4NEhqRDdiUm03TC9NQTVmWm5vCmNFZGdCUE1OMzZiNnpoSnR0UVYx 17 | UVF2Rzg4VEdSdGNZdUNXd2V5UGdrOTAKLS0tIEQvNjVjUlRPZFBuK2M4dS9KYTFR 18 | a3QwZCtyZ0NBQXo4cmFRazVwWlZEWFkKIHUHaImxN+SgMVSd6pgMQyiAy+mTQAaQ 19 | mGIKj1vrDXs1FLAl5lkV7IxFkf51qqTk6rOxjv1zCzFYLATAr3t4eg== 20 | -----END AGE ENCRYPTED FILE----- 21 | lastmodified: "2024-05-06T09:52:57Z" 22 | mac: ENC[AES256_GCM,data:4qmhG/liKJdnEBxvvnxnpb9xJpS8GGjCAHGUVM4dGtYY5+TkfgnSQyvVdg88Ag16nMDTBEeRJO6VfOYD/Wx/PfIYnajhxRm3ZYuPPSJ5t0LGqRryUtR9vJTtHuTew5gjX8FCTvjiGJzqcfTiq11HhN3Xyu7VNwwan50QUvz5oKY=,iv:Rc0/1kH74ahBkNygwFrOZymWMnPj3VCQZ7wBi1d7Rzc=,tag:Cgdjhlc24S2gklSKYe5mPw==,type:str] 23 | pgp: 24 | - created_at: "2024-11-27T13:27:48Z" 25 | enc: |- 26 | -----BEGIN PGP MESSAGE----- 27 | 28 | hF4D/dYBJRlWfQISAQdAWb3gbi9Rm9Ery5O4vjcms/Inx26KJ8SODWRZ1t/k7nkw 29 | I+u059duWr0e7O2ykSzDnGQA6Aj+HdhPoPcdnNlBWpy3raPwKzJ+X8kVIYp3qiba 30 | 0lwB4HMtkNBpz91ErblPNWiVVVe3+G8LLJpDTpaVuPNsj1d3OgnKnWnVFK0b8FAL 31 | kQSmurQgKKbWZ7R6uhfWLINBR4ICZg51FViURfpWoUtlGvrL+nnbvmy9hQVGMQ== 32 | =7teE 33 | -----END PGP MESSAGE----- 34 | fp: 15D5721F5F5BAF121495363EFE042E3D4085A811 35 | - created_at: "2024-11-27T13:27:48Z" 36 | enc: |- 37 | -----BEGIN PGP MESSAGE----- 38 | 39 | hF4D+EORkHurkvgSAQdAkbVUYD2I5MOoPjRjTRI6EfMlA8oEGsMu+ovxmEyMLwkw 40 | egHUl/oDTgovO+12mC/iRAhaKzV5fvCeZUTDbPB7BT+a2HsGyQEE2O4JYrSY/EMJ 41 | 0lwBe8TdlHF9HUqwzHbRTo8UDbap0gpJ/KngXJ51m90Az6u4+lmpPgcgWSyJAnBF 42 | u/rjpjqobTmwR8Ea7NZpF3ZcoltlJLyd6w0O7JD5hd8nVWEhbN2KDHwKAnbneQ== 43 | =aBI7 44 | -----END PGP MESSAGE----- 45 | fp: CC51116A94490FA6FB3C18EB2401FCAE863A06CA 46 | - created_at: "2024-11-27T13:27:48Z" 47 | enc: |- 48 | -----BEGIN PGP MESSAGE----- 49 | 50 | hF4DCsA/BhMt3V4SAQdAasIYIdDD+bb/JHfxT0yCMbxOu0JFB95rVSMTzLLw+BAw 51 | K8XVL7U+2EbmjO2OkTEbI80SEu//L/c/ZjmvTU8dt18WWMWb/FDWYGrw40KBMbts 52 | 0lwBUk4UzZ38uDmnNPka1k6vBPWxvfIOHylbZYFoC4oLXEkghj70cPji6ZBUxpe1 53 | d6Avs0cXx5PLbM2lL/Rh/+9dAhSkl+Uzc5kEXfYPW0IBNlHaH1By+wsoVTgsGQ== 54 | =+zAf 55 | -----END PGP MESSAGE----- 56 | fp: 4919F560F0D35F80CF382D76E084A2DF1143C14D 57 | - created_at: "2024-11-27T13:27:48Z" 58 | enc: |- 59 | -----BEGIN PGP MESSAGE----- 60 | 61 | hF4DRbry8yWl6IgSAQdAJoHdZndKL5N3KuO+gNofIpZYKhihf0L5MP+tG5LPKD0w 62 | Hhm5QOMjYNT7LjOTKtRb59ymKSP0oLiQMNvphw4q0IYsFz+l4UkanT6IO6K5tdvQ 63 | 1GYBCQIQrIdQeujzcbTI2z9Iwh8RnBxdEhhBYSZMyrzBS7B2V5p0qpjaFojbI3oa 64 | A4uaNEpxnk59mfCuZeKHNURVHn0VT/jZdzalC7aQMk1h/7w6cjZLjJ/Skn5t1Obp 65 | mQKG7tBZzLo= 66 | =PH0s 67 | -----END PGP MESSAGE----- 68 | fp: 11177A43C6E3752E682AC690DBD13117B0A14E93 69 | - created_at: "2024-11-27T13:27:48Z" 70 | enc: |- 71 | -----BEGIN PGP MESSAGE----- 72 | 73 | hF4DzfZC0UNQ1VgSAQdAZ0yc24RcLD0R7Bs4UPQTAfbp5Y/DtanIv7kOSjVtlAEw 74 | +F1qLNuwR0r1K9HLndgu0G5vi3L3ra8drb6YMKR7kXyN9RLXjDBt8gD3s386VGMa 75 | 1GYBCQIQx6GailOK4BBn2H8HQqDTEgrsLj0ZhG6jOCwKpdPD3VoC4OWDJ0yzt72Z 76 | fwP3VgEH1IB+QF+XBJgdJsh54d79UDutBLxIYJAInQ3foxD3DOQ96D0onysA/qqh 77 | ZV7CB+9hCYc= 78 | =36ga 79 | -----END PGP MESSAGE----- 80 | fp: AA5213AF261C1977AF38B03A94B473337258BFD5 81 | unencrypted_suffix: _unencrypted 82 | version: 3.8.1 83 | -------------------------------------------------------------------------------- /internal/provider/tftp/tftp_server.go: -------------------------------------------------------------------------------- 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 tftp implements a TFTP server. 6 | package tftp 7 | 8 | import ( 9 | "context" 10 | "io" 11 | "net" 12 | "os" 13 | "path/filepath" 14 | "time" 15 | 16 | "github.com/pin/tftp/v3" 17 | "go.uber.org/zap" 18 | "golang.org/x/sync/errgroup" 19 | 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/constants" 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/util" 22 | ) 23 | 24 | // Server represents the TFTP server serving iPXE binaries. 25 | type Server struct { 26 | logger *zap.Logger 27 | 28 | listenAddress string 29 | } 30 | 31 | // NewServer creates a new TFTP server. 32 | func NewServer(listenAddress string, logger *zap.Logger) *Server { 33 | return &Server{ 34 | listenAddress: listenAddress, 35 | logger: logger, 36 | } 37 | } 38 | 39 | // Run runs the TFTP server. 40 | func (s *Server) Run(ctx context.Context) error { 41 | if err := os.MkdirAll(constants.TFTPPath, 0o777); err != nil { 42 | return err 43 | } 44 | 45 | readHandler := func(filename string, rf io.ReaderFrom) error { 46 | return handleRead(filename, rf, s.logger) 47 | } 48 | 49 | srv := tftp.NewServer(readHandler, nil) 50 | 51 | // A standard TFTP server implementation receives requests on port 69 and 52 | // allocates a new high port (over 1024) dedicated to that request. In single 53 | // port mode, the same port is used for transmit and receive. If the server 54 | // is started on port 69, all communication will be done on port 69. 55 | // This option is required since the Kubernetes service definition defines a 56 | // single port. 57 | srv.EnableSinglePort() 58 | srv.SetTimeout(5 * time.Second) 59 | 60 | eg, ctx := errgroup.WithContext(ctx) 61 | 62 | eg.Go(func() error { 63 | return srv.ListenAndServe(net.JoinHostPort(s.listenAddress, "69")) 64 | }) 65 | 66 | eg.Go(func() error { 67 | <-ctx.Done() 68 | 69 | srv.Shutdown() 70 | 71 | return nil 72 | }) 73 | 74 | return eg.Wait() 75 | } 76 | 77 | // cleanPath makes a path safe for use with filepath.Join. This is done by not 78 | // only cleaning the path, but also (if the path is relative) adding a leading 79 | // '/' and cleaning it (then removing the leading '/'). This ensures that a 80 | // path resulting from prepending another path will always resolve to lexically 81 | // be a subdirectory of the prefixed path. This is all done lexically, so paths 82 | // that include symlinks won't be safe as a result of using CleanPath. 83 | func cleanPath(path string) string { 84 | // Deal with empty strings nicely. 85 | if path == "" { 86 | return "" 87 | } 88 | 89 | // Ensure that all paths are cleaned (especially problematic ones like 90 | // "/../../../../../" which can cause lots of issues). 91 | path = filepath.Clean(path) 92 | 93 | // If the path isn't absolute, we need to do more processing to fix paths 94 | // such as "../../../..//some/path". We also shouldn't convert absolute 95 | // paths to relative ones. 96 | if !filepath.IsAbs(path) { 97 | path = filepath.Clean(string(os.PathSeparator) + path) 98 | // This can't fail, as (by definition) all paths are relative to root. 99 | path, _ = filepath.Rel(string(os.PathSeparator), path) //nolint:errcheck 100 | } 101 | 102 | // Clean the path again for good measure. 103 | return filepath.Clean(path) 104 | } 105 | 106 | // handleRead is called when a client starts file download from server. 107 | func handleRead(filename string, rf io.ReaderFrom, logger *zap.Logger) error { 108 | logger.Info("file requested", zap.String("filename", filename)) 109 | 110 | filename = filepath.Join(constants.TFTPPath, cleanPath(filename)) 111 | 112 | file, err := os.Open(filename) 113 | if err != nil { 114 | logger.Error("failed to open file", zap.String("filename", filename), zap.Error(err)) 115 | 116 | return err 117 | } 118 | 119 | defer util.LogClose(file, logger) 120 | 121 | n, err := rf.ReadFrom(file) 122 | if err != nil { 123 | logger.Error("failed to read from file", zap.String("filename", filename), zap.Error(err)) 124 | 125 | return err 126 | } 127 | 128 | logger.Info("file sent", zap.String("filename", filename), zap.Int64("bytes", n)) 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/provider/controllers/reboot_status_test.go: -------------------------------------------------------------------------------- 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 controllers_test 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "time" 11 | 12 | "github.com/cosi-project/runtime/pkg/controller/runtime" 13 | "github.com/cosi-project/runtime/pkg/state" 14 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 15 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 16 | "github.com/stretchr/testify/require" 17 | "go.uber.org/zap" 18 | 19 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/controllers" 22 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/machine" 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 24 | ) 25 | 26 | func TestNoRebootWhenPoweredOnNotRequired(t *testing.T) { 27 | t.Parallel() 28 | 29 | rebootCh := make(chan struct{}, 8) 30 | setPXEBootOnceCh := make(chan pxe.BootMode, 8) 31 | 32 | bmcClientFactory := &bmcClientFactoryMock{ 33 | bmcClient: &bmcClientMock{ 34 | poweredOn: true, 35 | rebootCh: rebootCh, 36 | 37 | setPXEBootOnceCh: setPXEBootOnceCh, 38 | }, 39 | } 40 | 41 | asserter := reconcileAsserter{t: t} 42 | 43 | withRuntime(t, 44 | func(_ context.Context, _ state.State, rt *runtime.Runtime, _ *zap.Logger) { 45 | controller := controllers.NewRebootStatusController(bmcClientFactory, 5*time.Minute, pxe.BootModeUEFI, controllers.RebootStatusControllerOptions{ 46 | PostTransformFunc: asserter.incrementReconcile, 47 | }) 48 | 49 | require.NoError(t, rt.RegisterQController(controller)) 50 | }, 51 | func(ctx context.Context, st state.State, _ *runtime.Runtime, _ *zap.Logger) { 52 | bmcConfiguration := resources.NewBMCConfiguration("test-machine") 53 | 54 | require.NoError(t, st.Create(ctx, bmcConfiguration)) 55 | 56 | infraMachine := infra.NewMachine("test-machine") 57 | infraMachine.TypedSpec().Value.AcceptanceStatus = omnispecs.InfraMachineConfigSpec_ACCEPTED 58 | infraMachine.TypedSpec().Value.WipeId = "test-wipe-id" 59 | 60 | require.NoError(t, st.Create(ctx, infraMachine)) 61 | 62 | machineStatus := resources.NewMachineStatus(infraMachine.Metadata().ID()) 63 | machineStatus.TypedSpec().Value.PowerState = specs.PowerState_POWER_STATE_ON 64 | machineStatus.TypedSpec().Value.Initialized = true 65 | machineStatus.TypedSpec().Value.AgentAccessible = false 66 | 67 | require.NoError(t, st.Create(ctx, machineStatus)) 68 | 69 | // The machine meets the following conditions: 70 | // - It is powered on, accepted, initialized, but the agent is not accessible -> mode mismatch. 71 | // - Its initial wipe is not done, so it requires a wipe. 72 | // Because it requires a wipe, it needs to stay powered on. 73 | // -> The controller is expected to attempt to reboot into the agent mode. 74 | 75 | require.True(t, machine.RequiresPowerOn(infraMachine, nil), "machine should require power on") 76 | 77 | requireChReceive(ctx, t, setPXEBootOnceCh) 78 | requireChReceive(ctx, t, rebootCh) 79 | 80 | // Mark the machine as "does not need wiping": its initial wipe is done, and its last wipe ID matches the currently expected wipe ID. 81 | 82 | wipeStatus := resources.NewWipeStatus(infraMachine.Metadata().ID()) 83 | wipeStatus.TypedSpec().Value.InitialWipeDone = true 84 | wipeStatus.TypedSpec().Value.LastWipeId = infraMachine.TypedSpec().Value.WipeId 85 | 86 | // ensure that we had at least one reconciliation 87 | numReconcilesBefore := asserter.requireNotZero(ctx) 88 | 89 | require.NoError(t, st.Create(ctx, wipeStatus)) 90 | 91 | // At this point, the machine is not needed to be powered on, 92 | // as it is neither allocated, nor installed, nor requires a wipe. 93 | require.False(t, machine.RequiresPowerOn(infraMachine, wipeStatus), "machine should not require power on") 94 | 95 | // wait for a new reconciliation to happen 96 | asserter.requireReconcile(ctx, numReconcilesBefore) 97 | 98 | require.Empty(t, rebootCh, "reboot channel should be empty, no reboot should be issued") 99 | require.Empty(t, setPXEBootOnceCh, "setPXEBootOnce channel should be empty, no PXE boot mode should be set") 100 | }, 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/slack-notify-ci-failure.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-09-26T08:52:23Z by kres fdbc9fc. 4 | 5 | "on": 6 | workflow_run: 7 | workflows: 8 | - default 9 | - e2e-cron 10 | types: 11 | - completed 12 | branches: 13 | - main 14 | name: slack-notify-failure 15 | jobs: 16 | slack-notify: 17 | runs-on: 18 | group: generic 19 | if: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' 20 | steps: 21 | - name: Slack Notify 22 | uses: slackapi/slack-github-action@v2 23 | with: 24 | method: chat.postMessage 25 | payload: | 26 | { 27 | "channel": "ci-failure", 28 | "text": "${{ github.event.workflow_run.conclusion }} - ${{ github.repository }}", 29 | "icon_emoji": "${{ github.event.workflow_run.conclusion == 'success' && ':white_check_mark:' || github.event.workflow_run.conclusion == 'failure' && ':x:' || ':warning:' }}", 30 | "username": "GitHub Actions", 31 | "attachments": [ 32 | { 33 | "blocks": [ 34 | { 35 | "fields": [ 36 | { 37 | "text": "${{ github.event.workflow_run.event == 'pull_request' && format('*Pull Request:* {0} (`{1}`)\n<{2}/pull/{3}|{4}>', github.repository, github.ref_name, github.event.repository.html_url, steps.get-pr-number.outputs.pull_request_number, github.event.workflow_run.display_title) || format('*Build:* {0} (`{1}`)\n<{2}/commit/{3}|{4}>', github.repository, github.ref_name, github.event.repository.html_url, github.sha, github.event.workflow_run.display_title) }}", 38 | "type": "mrkdwn" 39 | }, 40 | { 41 | "text": "*Status:*\n`${{ github.event.workflow_run.conclusion }}`", 42 | "type": "mrkdwn" 43 | } 44 | ], 45 | "type": "section" 46 | }, 47 | { 48 | "fields": [ 49 | { 50 | "text": "*Author:*\n`${{ github.actor }}`", 51 | "type": "mrkdwn" 52 | }, 53 | { 54 | "text": "*Event:*\n`${{ github.event.workflow_run.event }}`", 55 | "type": "mrkdwn" 56 | } 57 | ], 58 | "type": "section" 59 | }, 60 | { 61 | "type": "divider" 62 | }, 63 | { 64 | "elements": [ 65 | { 66 | "text": { 67 | "text": "Logs", 68 | "type": "plain_text" 69 | }, 70 | "type": "button", 71 | "url": "${{ github.event.workflow_run.html_url }}" 72 | }, 73 | { 74 | "text": { 75 | "text": "Commit", 76 | "type": "plain_text" 77 | }, 78 | "type": "button", 79 | "url": "${{ github.event.repository.html_url }}/commit/${{ github.sha }}" 80 | } 81 | ], 82 | "type": "actions" 83 | } 84 | ], 85 | "color": "${{ github.event.workflow_run.conclusion == 'success' && '#2EB886' || github.event.workflow_run.conclusion == 'failure' && '#A30002' || '#FFCC00' }}" 86 | } 87 | ] 88 | } 89 | token: ${{ secrets.SLACK_BOT_TOKEN_V2 }} 90 | -------------------------------------------------------------------------------- /internal/provider/machine/machine.go: -------------------------------------------------------------------------------- 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 machine provides utilities for determining the required state of a machine. 6 | package machine 7 | 8 | import ( 9 | "github.com/cosi-project/runtime/pkg/resource" 10 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 11 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 12 | "go.uber.org/zap" 13 | 14 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 15 | ) 16 | 17 | // BootMode represents the boot mode of a machine. 18 | type BootMode string 19 | 20 | const ( 21 | // BootModeAgentPXE is the boot mode for agent PXE boot. 22 | BootModeAgentPXE BootMode = "agent-pxe" 23 | // BootModeTalosPXE is the boot mode for Talos PXE boot. 24 | BootModeTalosPXE BootMode = "talos-pxe" 25 | // BootModeTalosDisk is the boot mode for Talos disk boot. 26 | BootModeTalosDisk BootMode = "talos-disk" 27 | ) 28 | 29 | // IsInstalled returns true if the machine is installed. 30 | func IsInstalled(infraMachine *infra.Machine, wipeStatus *resources.WipeStatus) bool { 31 | if infraMachine == nil { 32 | return false 33 | } 34 | 35 | installEventID := infraMachine.TypedSpec().Value.InstallEventId 36 | lastWipeInstallEventID := uint64(0) 37 | 38 | if wipeStatus != nil { 39 | lastWipeInstallEventID = wipeStatus.TypedSpec().Value.LastWipeInstallEventId 40 | } 41 | 42 | return installEventID > lastWipeInstallEventID 43 | } 44 | 45 | // RequiresWipe returns true if the machine needs to be wiped. 46 | func RequiresWipe(infraMachine *infra.Machine, wipeStatus *resources.WipeStatus) bool { 47 | // maybe check acceptance here (or here as well) 48 | if infraMachine == nil || wipeStatus == nil || !wipeStatus.TypedSpec().Value.InitialWipeDone { 49 | return true 50 | } 51 | 52 | return infraMachine.TypedSpec().Value.WipeId != wipeStatus.TypedSpec().Value.LastWipeId 53 | } 54 | 55 | // RequiredBootMode returns the required boot mode for the machine. 56 | func RequiredBootMode(infraMachine *infra.Machine, bmcConfiguration *resources.BMCConfiguration, wipeStatus *resources.WipeStatus, logger *zap.Logger) BootMode { 57 | installed := IsInstalled(infraMachine, wipeStatus) 58 | requiresWipe := RequiresWipe(infraMachine, wipeStatus) 59 | acceptanceStatus := omnispecs.InfraMachineConfigSpec_PENDING 60 | infraMachineTearingDown := false 61 | allocated := false 62 | 63 | if infraMachine != nil { 64 | acceptanceStatus = infraMachine.TypedSpec().Value.AcceptanceStatus 65 | infraMachineTearingDown = infraMachine.Metadata().Phase() == resource.PhaseTearingDown 66 | allocated = infraMachine.TypedSpec().Value.ClusterTalosVersion != "" 67 | } 68 | 69 | acceptancePending := acceptanceStatus == omnispecs.InfraMachineConfigSpec_PENDING 70 | rejected := acceptanceStatus == omnispecs.InfraMachineConfigSpec_REJECTED 71 | requiresPowerMgmtConfig := bmcConfiguration == nil 72 | 73 | bootIntoAgentMode := infraMachineTearingDown || acceptancePending || !allocated || requiresPowerMgmtConfig || requiresWipe 74 | 75 | var requiredBootMode BootMode 76 | 77 | switch { 78 | case rejected: 79 | requiredBootMode = BootModeTalosDisk 80 | case bootIntoAgentMode: 81 | requiredBootMode = BootModeAgentPXE 82 | case installed: 83 | requiredBootMode = BootModeTalosDisk 84 | default: 85 | requiredBootMode = BootModeTalosPXE 86 | } 87 | 88 | logger.With( 89 | zap.Bool("infra_machine_tearing_down", infraMachineTearingDown), 90 | zap.Bool("requires_power_mgmt_config", requiresPowerMgmtConfig), 91 | zap.Bool("installed", installed), 92 | zap.Stringer("acceptance_status", acceptanceStatus), 93 | zap.String("required_boot_mode", string(requiredBootMode)), 94 | ).Debug("determined boot mode") 95 | 96 | return requiredBootMode 97 | } 98 | 99 | // RequiresPXEBoot returns true if the machine requires to be PXE booted. 100 | func RequiresPXEBoot(requiredBootMode BootMode) bool { 101 | return requiredBootMode == BootModeAgentPXE || requiredBootMode == BootModeTalosPXE 102 | } 103 | 104 | // RequiresPowerOn returns true if the machine requires to be powered on. 105 | func RequiresPowerOn(infraMachine *infra.Machine, wipeStatus *resources.WipeStatus) bool { 106 | allocated := infraMachine.TypedSpec().Value.ClusterTalosVersion != "" 107 | installed := IsInstalled(infraMachine, wipeStatus) 108 | requiresWipe := RequiresWipe(infraMachine, wipeStatus) 109 | 110 | return allocated || installed || requiresWipe 111 | } 112 | -------------------------------------------------------------------------------- /hack/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 4 | # 5 | # Generated on 2024-10-14T09:32:55Z by kres 34e72ac. 6 | 7 | set -e 8 | 9 | RELEASE_TOOL_IMAGE="ghcr.io/siderolabs/release-tool:latest" 10 | 11 | function release-tool { 12 | docker pull "${RELEASE_TOOL_IMAGE}" >/dev/null 13 | docker run --rm -w /src -v "${PWD}":/src:ro "${RELEASE_TOOL_IMAGE}" -l -d -n -t "${1}" ./hack/release.toml 14 | } 15 | 16 | function changelog { 17 | if [ "$#" -eq 1 ]; then 18 | (release-tool ${1}; echo; cat CHANGELOG.md) > CHANGELOG.md- && mv CHANGELOG.md- CHANGELOG.md 19 | else 20 | echo 1>&2 "Usage: $0 changelog [tag]" 21 | exit 1 22 | fi 23 | } 24 | 25 | function release-notes { 26 | release-tool "${2}" > "${1}" 27 | } 28 | 29 | function cherry-pick { 30 | if [ $# -ne 2 ]; then 31 | echo 1>&2 "Usage: $0 cherry-pick " 32 | exit 1 33 | fi 34 | 35 | git checkout $2 36 | git fetch 37 | git rebase upstream/$2 38 | git cherry-pick -x $1 39 | } 40 | 41 | function commit { 42 | if [ $# -ne 1 ]; then 43 | echo 1>&2 "Usage: $0 commit " 44 | exit 1 45 | fi 46 | 47 | if is_on_main_branch; then 48 | update_license_files 49 | fi 50 | 51 | git commit -s -m "release($1): prepare release" -m "This is the official $1 release." 52 | } 53 | 54 | function is_on_main_branch { 55 | main_remotes=("upstream" "origin") 56 | branch_names=("main" "master") 57 | current_branch=$(git rev-parse --abbrev-ref HEAD) 58 | 59 | echo "Check current branch: $current_branch" 60 | 61 | for remote in "${main_remotes[@]}"; do 62 | echo "Fetch remote $remote..." 63 | 64 | if ! git fetch --quiet "$remote" &>/dev/null; then 65 | echo "Failed to fetch $remote, skip..." 66 | 67 | continue 68 | fi 69 | 70 | for branch_name in "${branch_names[@]}"; do 71 | if ! git rev-parse --verify "$branch_name" &>/dev/null; then 72 | echo "Branch $branch_name does not exist, skip..." 73 | 74 | continue 75 | fi 76 | 77 | echo "Branch $remote/$branch_name exists, comparing..." 78 | 79 | merge_base=$(git merge-base "$current_branch" "$remote/$branch_name") 80 | latest_main=$(git rev-parse "$remote/$branch_name") 81 | 82 | if [ "$merge_base" = "$latest_main" ]; then 83 | echo "Current branch is up-to-date with $remote/$branch_name" 84 | 85 | return 0 86 | else 87 | echo "Current branch is not on $remote/$branch_name" 88 | 89 | return 1 90 | fi 91 | done 92 | done 93 | 94 | echo "No main or master branch found on any remote" 95 | 96 | return 1 97 | } 98 | 99 | function update_license_files { 100 | script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 101 | parent_dir="$(dirname "$script_dir")" 102 | current_year=$(date +"%Y") 103 | change_date=$(date -v+4y +"%Y-%m-%d" 2>/dev/null || date -d "+4 years" +"%Y-%m-%d" 2>/dev/null || date --date="+4 years" +"%Y-%m-%d") 104 | 105 | # Find LICENSE and .kres.yaml files recursively in the parent directory (project root) 106 | find "$parent_dir" \( -name "LICENSE" -o -name ".kres.yaml" \) -type f | while read -r file; do 107 | temp_file="${file}.tmp" 108 | 109 | if [[ $file == *"LICENSE" ]]; then 110 | if grep -q "^Business Source License" "$file"; then 111 | sed -e "s/The Licensed Work is (c) [0-9]\{4\}/The Licensed Work is (c) $current_year/" \ 112 | -e "s/Change Date: [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}/Change Date: $change_date/" \ 113 | "$file" >"$temp_file" 114 | else 115 | continue # Not a Business Source License file 116 | fi 117 | elif [[ $file == *".kres.yaml" ]]; then 118 | sed -E 's/^([[:space:]]*)ChangeDate:.*$/\1ChangeDate: "'"$change_date"'"/' "$file" >"$temp_file" 119 | fi 120 | 121 | # Check if the file has changed 122 | if ! cmp -s "$file" "$temp_file"; then 123 | mv "$temp_file" "$file" 124 | echo "Updated: $file" 125 | git add "$file" 126 | else 127 | echo "No changes: $file" 128 | rm "$temp_file" 129 | fi 130 | done 131 | } 132 | 133 | if declare -f "$1" > /dev/null 134 | then 135 | cmd="$1" 136 | shift 137 | $cmd "$@" 138 | else 139 | cat <- 101 | TEMP_REGISTRY=$(TEMP_REGISTRY) 102 | OMNI_IMAGE=$(OMNI_IMAGE) 103 | OMNI_INTEGRATION_TEST_IMAGE=$(OMNI_INTEGRATION_TEST_IMAGE) 104 | SKIP_CLEANUP=$(SKIP_CLEANUP) 105 | hack/test/integration.sh 106 | variables: 107 | - name: TEMP_REGISTRY 108 | defaultValue: 127.0.0.1:5005 # local development registry 109 | - name: OMNI_IMAGE 110 | defaultValue: ghcr.io/siderolabs/omni:latest 111 | - name: OMNI_INTEGRATION_TEST_IMAGE 112 | defaultValue: ghcr.io/siderolabs/omni-integration-test:latest 113 | - name: SKIP_CLEANUP 114 | defaultValue: "false" 115 | ghaction: 116 | enabled: true 117 | sops: true 118 | parallelJob: 119 | name: integration-test 120 | runnerGroup: large 121 | needsOverride: [default, lint, unit-tests] 122 | environment: 123 | TEMP_REGISTRY: registry.dev.siderolabs.io 124 | artifacts: 125 | enabled: true 126 | extraPaths: 127 | - "!_out/omni/" 128 | additional: 129 | - name: integration-test 130 | always: true 131 | continueOnError: true 132 | paths: 133 | - "/tmp/integration-test" 134 | jobs: 135 | - name: e2e 136 | crons: 137 | - "30 1 * * *" 138 | runnerGroup: large # we need large runners for QEMU/KVM to work 139 | triggerLabels: 140 | - integration/e2e 141 | --- 142 | kind: common.SOPS 143 | spec: 144 | enabled: true 145 | config: |- 146 | creation_rules: 147 | - age: age1xrpa9ujxxcj2u2gzfrzv8mxak4rts94a6y60ypurv6rs5cpr4e4sg95f0k 148 | # order: Andrey, Noel, Artem, Utku, Dmitriy 149 | pgp: >- 150 | 15D5721F5F5BAF121495363EFE042E3D4085A811, 151 | CC51116A94490FA6FB3C18EB2401FCAE863A06CA, 152 | 4919F560F0D35F80CF382D76E084A2DF1143C14D, 153 | 11177A43C6E3752E682AC690DBD13117B0A14E93, 154 | AA5213AF261C1977AF38B03A94B473337258BFD5 155 | -------------------------------------------------------------------------------- /internal/provider/controllers/bmc_configuration_test.go: -------------------------------------------------------------------------------- 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 controllers_test 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | 11 | "github.com/cosi-project/runtime/pkg/controller/runtime" 12 | "github.com/cosi-project/runtime/pkg/resource/rtestutils" 13 | "github.com/cosi-project/runtime/pkg/state" 14 | "github.com/siderolabs/gen/containers" 15 | "github.com/siderolabs/gen/pair" 16 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 17 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 18 | agentpb "github.com/siderolabs/talos-metal-agent/api/agent" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | "go.uber.org/zap" 22 | 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/controllers" 24 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 25 | ) 26 | 27 | func TestBMCConfiguration(t *testing.T) { 28 | t.Parallel() 29 | 30 | var getPowerMgmtResponseMap containers.ConcurrentMap[string, *agentpb.GetPowerManagementResponse] 31 | 32 | getPowerMgmtResponseMap.Set("test-machine", &agentpb.GetPowerManagementResponse{ 33 | Ipmi: &agentpb.GetPowerManagementResponse_IPMI{ 34 | Address: "5.6.7.8", 35 | Port: 5678, 36 | }, 37 | }) 38 | 39 | setPowerMgmtRequestCh := make(chan pair.Pair[string, *agentpb.SetPowerManagementRequest]) 40 | 41 | agentClient := &agentClientMock{ 42 | getPowerMgmtResponseMap: &getPowerMgmtResponseMap, 43 | setPowerMgmtRequestCh: setPowerMgmtRequestCh, 44 | } 45 | 46 | withRuntime(t, 47 | func(_ context.Context, _ state.State, rt *runtime.Runtime, _ *zap.Logger) { 48 | controller := controllers.NewBMCConfigurationController(agentClient, nil) 49 | require.NoError(t, rt.RegisterQController(controller)) 50 | }, 51 | 52 | func(ctx context.Context, st state.State, _ *runtime.Runtime, _ *zap.Logger) { 53 | machineStatus := resources.NewMachineStatus("test-machine") 54 | 55 | machineStatus.TypedSpec().Value.AgentAccessible = true 56 | 57 | require.NoError(t, st.Create(ctx, machineStatus)) 58 | 59 | // create a user-provided BMC config and assert that it is stored 60 | 61 | bmcConfig := infra.NewBMCConfig("test-machine") 62 | 63 | bmcConfig.TypedSpec().Value.Config = &omnispecs.InfraMachineBMCConfigSpec{ 64 | Ipmi: &omnispecs.InfraMachineBMCConfigSpec_IPMI{ 65 | Address: "1.2.3.4", 66 | Port: 1234, 67 | Username: "test-user", 68 | Password: "test-password", 69 | }, 70 | } 71 | 72 | require.NoError(t, st.Create(ctx, bmcConfig)) 73 | 74 | // create the infra machine to trigger the controller 75 | 76 | infraMachine := infra.NewMachine("test-machine") 77 | 78 | infraMachine.TypedSpec().Value.AcceptanceStatus = omnispecs.InfraMachineConfigSpec_ACCEPTED 79 | 80 | require.NoError(t, st.Create(ctx, infraMachine)) 81 | 82 | rtestutils.AssertResource(ctx, t, st, infraMachine.Metadata().ID(), func(res *resources.BMCConfiguration, assertion *assert.Assertions) { 83 | assertion.True(res.TypedSpec().Value.ManuallyConfigured) 84 | 85 | assertion.Equal("1.2.3.4", res.TypedSpec().Value.Ipmi.Address) 86 | assertion.Equal(uint32(1234), res.TypedSpec().Value.Ipmi.Port) 87 | assertion.Equal("test-user", res.TypedSpec().Value.Ipmi.Username) 88 | assertion.Equal("test-password", res.TypedSpec().Value.Ipmi.Password) 89 | }) 90 | 91 | // remove user-provided config, so we will go back to the config over the agent 92 | 93 | rtestutils.Destroy[*infra.BMCConfig](ctx, t, st, []string{bmcConfig.Metadata().ID()}) 94 | 95 | setPowerMgmtRequest := requireChReceive(ctx, t, setPowerMgmtRequestCh) 96 | 97 | assert.Equal(t, "test-machine", setPowerMgmtRequest.F1) 98 | assert.Equal(t, controllers.IPMIUsername, setPowerMgmtRequest.F2.Ipmi.Username) 99 | assert.NotEqual(t, "test-password", setPowerMgmtRequest.F2.Ipmi.Password) 100 | assert.Len(t, setPowerMgmtRequest.F2.Ipmi.Password, controllers.IPMIPasswordLength) 101 | 102 | rtestutils.AssertResource(ctx, t, st, infraMachine.Metadata().ID(), func(res *resources.BMCConfiguration, assertion *assert.Assertions) { 103 | assertion.False(res.TypedSpec().Value.ManuallyConfigured) 104 | 105 | assertion.Equal("5.6.7.8", res.TypedSpec().Value.Ipmi.Address) 106 | assertion.Equal(uint32(5678), res.TypedSpec().Value.Ipmi.Port) 107 | assertion.Equal(controllers.IPMIUsername, res.TypedSpec().Value.Ipmi.Username) 108 | assertion.Equal(setPowerMgmtRequest.F2.Ipmi.Password, res.TypedSpec().Value.Ipmi.Password) 109 | }) 110 | }, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-07-17T21:25:02Z by kres b869533. 4 | 5 | version: "2" 6 | 7 | # options for analysis running 8 | run: 9 | modules-download-mode: readonly 10 | issues-exit-code: 1 11 | tests: true 12 | 13 | # output configuration options 14 | output: 15 | formats: 16 | text: 17 | path: stdout 18 | print-issued-lines: true 19 | print-linter-name: true 20 | path-prefix: "" 21 | 22 | 23 | linters: 24 | default: all 25 | disable: 26 | - exhaustruct 27 | - err113 28 | - forbidigo 29 | - funcorder 30 | - funlen 31 | - gochecknoglobals 32 | - gochecknoinits 33 | - godox 34 | - gomoddirectives 35 | - gosec 36 | - inamedparam 37 | - ireturn 38 | - mnd 39 | - nestif 40 | - nonamedreturns 41 | - paralleltest 42 | - tagalign 43 | - tagliatelle 44 | - thelper 45 | - varnamelen 46 | - wrapcheck 47 | - testifylint # complains about our assert recorder and has a number of false positives for assert.Greater(t, thing, 1) 48 | - protogetter # complains about us using Value field on typed spec, instead of GetValue which has a different signature 49 | - perfsprint # complains about us using fmt.Sprintf in non-performance critical code, updating just kres took too long 50 | - musttag # seems to be broken - goes into imported libraries and reports issues there 51 | - nolintlint # gives false positives - disable until https://github.com/golangci/golangci-lint/issues/3228 is resolved 52 | - wsl # replaced by wsl_v5 53 | - noinlineerr 54 | - embeddedstructfieldcheck # fighting in many places with fieldalignment 55 | # all available settings of specific linters 56 | settings: 57 | cyclop: 58 | # the maximal code complexity to report 59 | max-complexity: 20 60 | dogsled: 61 | max-blank-identifiers: 2 62 | dupl: 63 | threshold: 150 64 | errcheck: 65 | check-type-assertions: true 66 | check-blank: true 67 | exhaustive: 68 | default-signifies-exhaustive: false 69 | gocognit: 70 | min-complexity: 30 71 | nestif: 72 | min-complexity: 5 73 | goconst: 74 | min-len: 3 75 | min-occurrences: 3 76 | gocritic: 77 | disabled-checks: [ ] 78 | gocyclo: 79 | min-complexity: 20 80 | godot: 81 | scope: declarations 82 | gomodguard: { } 83 | govet: 84 | enable-all: true 85 | lll: 86 | line-length: 200 87 | tab-width: 4 88 | misspell: 89 | locale: US 90 | nakedret: 91 | max-func-lines: 30 92 | prealloc: 93 | simple: true 94 | range-loops: true # Report preallocation suggestions on range loops, true by default 95 | for-loops: false # Report preallocation suggestions on for loops, false by default 96 | revive: 97 | rules: 98 | - name: var-naming # Complains about package names like "common" 99 | disabled: true 100 | rowserrcheck: { } 101 | testpackage: { } 102 | unparam: 103 | check-exported: false 104 | unused: 105 | local-variables-are-used: false 106 | whitespace: 107 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 108 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 109 | wsl: 110 | strict-append: true 111 | allow-assign-and-call: true 112 | allow-multiline-assign: true 113 | allow-trailing-comment: false 114 | force-case-trailing-whitespace: 0 115 | allow-separated-leading-comment: false 116 | allow-cuddle-declarations: false 117 | force-err-cuddling: false 118 | depguard: 119 | rules: 120 | prevent_unmaintained_packages: 121 | list-mode: lax # allow unless explicitly denied 122 | files: 123 | - $all 124 | deny: 125 | - pkg: io/ioutil 126 | desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" 127 | 128 | exclusions: 129 | generated: lax 130 | paths: 131 | - third_party$ 132 | - builtin$ 133 | - examples$ 134 | issues: 135 | max-issues-per-linter: 10 136 | max-same-issues: 3 137 | uniq-by-line: true 138 | new: false 139 | 140 | severity: 141 | default: error 142 | formatters: 143 | enable: 144 | - gci 145 | - gofmt 146 | - gofumpt 147 | settings: 148 | gci: 149 | sections: 150 | - standard 151 | - default 152 | - localmodule 153 | gofmt: 154 | simplify: true 155 | gofumpt: 156 | extra-rules: false 157 | exclusions: 158 | generated: lax 159 | paths: 160 | - third_party$ 161 | - builtin$ 162 | - examples$ 163 | -------------------------------------------------------------------------------- /.github/workflows/slack-notify.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2025-09-26T08:52:23Z by kres fdbc9fc. 4 | 5 | "on": 6 | workflow_run: 7 | workflows: 8 | - default 9 | - e2e-cron 10 | types: 11 | - completed 12 | name: slack-notify 13 | jobs: 14 | slack-notify: 15 | runs-on: 16 | group: generic 17 | if: github.event.workflow_run.conclusion != 'skipped' 18 | steps: 19 | - name: Get PR number 20 | id: get-pr-number 21 | if: github.event.workflow_run.event == 'pull_request' 22 | env: 23 | GH_TOKEN: ${{ github.token }} 24 | run: | 25 | echo pull_request_number=$(gh pr view -R ${{ github.repository }} ${{ github.event.workflow_run.head_repository.owner.login }}:${{ github.event.workflow_run.head_branch }} --json number --jq .number) >> $GITHUB_OUTPUT 26 | - name: Slack Notify 27 | uses: slackapi/slack-github-action@v2 28 | with: 29 | method: chat.postMessage 30 | payload: | 31 | { 32 | "channel": "ci-all", 33 | "text": "${{ github.event.workflow_run.conclusion }} - ${{ github.repository }}", 34 | "icon_emoji": "${{ github.event.workflow_run.conclusion == 'success' && ':white_check_mark:' || github.event.workflow_run.conclusion == 'failure' && ':x:' || ':warning:' }}", 35 | "username": "GitHub Actions", 36 | "attachments": [ 37 | { 38 | "blocks": [ 39 | { 40 | "fields": [ 41 | { 42 | "text": "${{ github.event.workflow_run.event == 'pull_request' && format('*Pull Request:* {0} (`{1}`)\n<{2}/pull/{3}|{4}>', github.repository, github.ref_name, github.event.repository.html_url, steps.get-pr-number.outputs.pull_request_number, github.event.workflow_run.display_title) || format('*Build:* {0} (`{1}`)\n<{2}/commit/{3}|{4}>', github.repository, github.ref_name, github.event.repository.html_url, github.sha, github.event.workflow_run.display_title) }}", 43 | "type": "mrkdwn" 44 | }, 45 | { 46 | "text": "*Status:*\n`${{ github.event.workflow_run.conclusion }}`", 47 | "type": "mrkdwn" 48 | } 49 | ], 50 | "type": "section" 51 | }, 52 | { 53 | "fields": [ 54 | { 55 | "text": "*Author:*\n`${{ github.actor }}`", 56 | "type": "mrkdwn" 57 | }, 58 | { 59 | "text": "*Event:*\n`${{ github.event.workflow_run.event }}`", 60 | "type": "mrkdwn" 61 | } 62 | ], 63 | "type": "section" 64 | }, 65 | { 66 | "type": "divider" 67 | }, 68 | { 69 | "elements": [ 70 | { 71 | "text": { 72 | "text": "Logs", 73 | "type": "plain_text" 74 | }, 75 | "type": "button", 76 | "url": "${{ github.event.workflow_run.html_url }}" 77 | }, 78 | { 79 | "text": { 80 | "text": "Commit", 81 | "type": "plain_text" 82 | }, 83 | "type": "button", 84 | "url": "${{ github.event.repository.html_url }}/commit/${{ github.sha }}" 85 | } 86 | ], 87 | "type": "actions" 88 | } 89 | ], 90 | "color": "${{ github.event.workflow_run.conclusion == 'success' && '#2EB886' || github.event.workflow_run.conclusion == 'failure' && '#A30002' || '#FFCC00' }}" 91 | } 92 | ] 93 | } 94 | token: ${{ secrets.SLACK_BOT_TOKEN_V2 }} 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # omni-infra-provider-bare-metal 2 | 3 | This repo contains the code for the Omni Bare Metal Infra Provider. 4 | If you would like to deploy the provider in your environment please [see the official documentation](https://omni.siderolabs.com/tutorials/setting-up-the-bare-metal-infrastructure-provider). 5 | 6 | ## Requirements 7 | 8 | To run the provider, you need: 9 | 10 | - A running Omni instance 11 | - An infra provider created in Omni, matching the ID you'll use with this provider (`bare-metal` by default). 12 | To create it, run: 13 | 14 | ```bash 15 | omnictl infraprovider create bare-metal 16 | ``` 17 | 18 | Replace `bare-metal` with your desired provider ID. 19 | - A DHCP server: This provider runs a DHCP proxy to provide DHCP responses for iPXE boot, so a DHCP server must be running in the same network as the provider. 20 | - Access to an [Image Factory](https://www.talos.dev/v1.8/learn-more/image-factory/). 21 | 22 | ## Development 23 | 24 | For local development using Talos running on QEMU, follow these steps: 25 | 26 | 1. Set up a `buildx` builder instance with host network access, if you don't have one already: 27 | 28 | ```bash 29 | docker buildx create --driver docker-container --driver-opt network=host --name local1 --buildkitd-flags '--allow-insecure-entitlement security.insecure' --use 30 | ``` 31 | 32 | 2. Start a local image registry if you don't have one running: 33 | 34 | ```bash 35 | docker run -d -p 5005:5000 --restart always --name local registry:2 36 | ``` 37 | 38 | 3. Build `qemu-up` command line tool, and use it to start some QEMU machines: 39 | 40 | ```bash 41 | make qemu-up 42 | sudo -E _out/qemu-up-linux-amd64 43 | ``` 44 | 45 | 4. (Optional) If you have made local changes to the [Talos Metal agent](https://github.com/siderolabs/talos-metal-agent), follow these steps to use your local version: 46 | 1. Build and push Talos Metal Agent boot assets image following [these instructions](https://github.com/siderolabs/talos-metal-agent/blob/main/README.md). 47 | 2. Replace the `ghcr.io/siderolabs/talos-metal-agent-boot-assets` image reference in [.kres.yaml](.kres.yaml) with your built image, 48 | e.g., `127.0.0.1:5005/siderolabs/talos-metal-agent-boot-assets:v1.9.0-agent-v0.1.0-beta.1-1-gbf1282b-dirty`. 49 | 3. Re-kres the project to propagate this change into `Dockerfile`: 50 | 51 | ```bash 52 | make rekres 53 | ``` 54 | 55 | 5. Build a local provider image: 56 | 57 | ```bash 58 | make image-provider PLATFORM=linux/amd64 REGISTRY=127.0.0.1:5005 PUSH=true TAG=local-dev 59 | docker pull 127.0.0.1:5005/siderolabs/omni-infra-provider-bare-metal:local-dev 60 | ``` 61 | 62 | 6. Start the provider with your Omni API address and the infra provider service account credentials: 63 | 64 | ```bash 65 | export OMNI_ENDPOINT= 66 | export OMNI_SERVICE_ACCOUNT_KEY= 67 | 68 | docker run --name=omni-bare-metal-provider --network host --rm -it \ 69 | -v "$HOME/.talos/clusters/bare-metal:/api-power-mgmt-state:ro" \ 70 | -e OMNI_ENDPOINT -e OMNI_SERVICE_ACCOUNT_KEY \ 71 | 127.0.0.1:5005/siderolabs/omni-infra-provider-bare-metal:local-dev \ 72 | --insecure-skip-tls-verify \ 73 | --api-advertise-address= \ 74 | --use-local-boot-assets \ 75 | --agent-test-mode \ 76 | --api-power-mgmt-state-dir=/api-power-mgmt-state \ 77 | --dhcp-proxy-iface-or-ip=172.42.0.1 \ 78 | --debug 79 | ``` 80 | 81 | Important flags: 82 | - `--use-local-boot-assets`: Makes the provider serve the boot assets image embedded in the provider image. 83 | This is useful for testing local Talos Metal Agent boot assets. 84 | Omit this flag to use the upstream agent version, which will forward agent mode PXE boot requests to the image factory. 85 | - `--agent-test-mode`: Boots the agent in test mode when booting a Talos node in agent mode, enabling API-based power management instead of IPMI/RedFish. 86 | This is necessary for QEMU development, 87 | as it uses the power management API run by the `talosctl cluster create` command. 88 | - The volume mount `-v "$HOME/.talos/clusters/talos-default:/api-power-mgmt-state:ro"` 89 | mounts the directory containing API-based power management state information generated by `talosctl cluster create`. 90 | - `--api-power-mgmt-state-dir`: Specifies where to read the API power management address of the nodes. 91 | - `--dhcp-proxy-iface-or-ip`: Specifies the IP address or interface name for running the DHCP proxy 92 | (e.g., the IP address of the QEMU bridge interface). 93 | The tool `qemu-up` uses the subnet `172.42.0.0/24` by default, and the bridge IP address on the host is `172.42.0.1`. 94 | 95 | 7. When you are done with the development/testing, destroy all QEMU machines and their network bridge: 96 | 97 | ```bash 98 | sudo -E _out/qemu-up-linux-amd64 --destroy 99 | ``` 100 | -------------------------------------------------------------------------------- /internal/provider/bmc/bmc.go: -------------------------------------------------------------------------------- 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 bmc provides BMC functionality for machines. 6 | package bmc 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "io" 12 | "sync" 13 | 14 | "go.uber.org/zap" 15 | 16 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 17 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/api" 18 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/ipmi" 19 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/redfish" 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 22 | ) 23 | 24 | // Client is the interface to interact with a single machine to send BMC commands to it. 25 | type Client interface { 26 | io.Closer 27 | Reboot(ctx context.Context) error 28 | IsPoweredOn(ctx context.Context) (bool, error) 29 | PowerOn(ctx context.Context) error 30 | PowerOff(ctx context.Context) error 31 | SetPXEBootOnce(ctx context.Context, mode pxe.BootMode) error 32 | } 33 | 34 | // ClientFactory is a factory to create BMC clients. 35 | type ClientFactory struct { 36 | addressToRedfishAvailability map[string]bool 37 | options ClientFactoryOptions 38 | addressToRedfishAvailabilityMu sync.Mutex 39 | } 40 | 41 | // ClientFactoryOptions contains options for the client factory. 42 | type ClientFactoryOptions struct { 43 | RedfishOptions redfish.Options 44 | } 45 | 46 | // NewClientFactory creates a new BMC client factory. 47 | func NewClientFactory(options ClientFactoryOptions) *ClientFactory { 48 | return &ClientFactory{ 49 | options: options, 50 | addressToRedfishAvailability: map[string]bool{}, 51 | } 52 | } 53 | 54 | // GetClient returns a BMC client for the given bare metal machine. 55 | func (factory *ClientFactory) GetClient(ctx context.Context, config *resources.BMCConfiguration, logger *zap.Logger) (Client, error) { 56 | if config == nil { 57 | return nil, fmt.Errorf("cannot get BMC client: config is nil") 58 | } 59 | 60 | spec := config.TypedSpec().Value 61 | 62 | if spec.Ipmi == nil && spec.Api == nil { 63 | return nil, fmt.Errorf("invalid BMC config: both IPMI and API fields are nil") 64 | } 65 | 66 | if spec.Api != nil { 67 | apiClient, err := api.NewClient(spec.Api) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return &loggingClient{client: apiClient, logger: logger.With(zap.String("bmc_client", "api"))}, nil 73 | } 74 | 75 | useRedfish := factory.options.RedfishOptions.UseAlways || (factory.options.RedfishOptions.UseWhenAvailable && factory.redfishAvailable(ctx, spec.Ipmi, logger)) 76 | 77 | if useRedfish { 78 | logger = logger.With(zap.String("bmc_client", "redfish")) 79 | redfishClient := redfish.NewClient(factory.options.RedfishOptions, spec.Ipmi.Address, spec.Ipmi.Username, spec.Ipmi.Password, logger) 80 | 81 | return &loggingClient{client: redfishClient, logger: logger}, nil 82 | } 83 | 84 | ipmiClient, err := ipmi.NewClient(ctx, spec.Ipmi) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return &loggingClient{client: ipmiClient, logger: logger.With(zap.String("bmc_client", "ipmi"))}, nil 90 | } 91 | 92 | func (factory *ClientFactory) redfishAvailable(ctx context.Context, ipmiInfo *specs.BMCConfigurationSpec_IPMI, logger *zap.Logger) bool { 93 | factory.addressToRedfishAvailabilityMu.Lock() 94 | defer factory.addressToRedfishAvailabilityMu.Unlock() 95 | 96 | address := ipmiInfo.Address 97 | 98 | available, ok := factory.addressToRedfishAvailability[address] 99 | if ok { 100 | return available 101 | } 102 | 103 | logger.Debug("probe redfish availability", zap.String("address", address)) 104 | 105 | redfishClient := redfish.NewClient(factory.options.RedfishOptions, address, ipmiInfo.Username, ipmiInfo.Password, logger) 106 | 107 | if _, err := redfishClient.IsPoweredOn(ctx); err != nil { 108 | logger.Debug("redfish is not available on address", zap.String("address", address), zap.Error(err)) 109 | 110 | factory.addressToRedfishAvailability[address] = false 111 | 112 | return false 113 | } 114 | 115 | logger.Debug("redfish is available on address", zap.String("address", address)) 116 | 117 | factory.addressToRedfishAvailability[address] = true 118 | 119 | return true 120 | } 121 | 122 | type loggingClient struct { 123 | client Client 124 | logger *zap.Logger 125 | } 126 | 127 | func (client *loggingClient) Close() error { 128 | client.logger.Debug("close client") 129 | 130 | return client.client.Close() 131 | } 132 | 133 | func (client *loggingClient) Reboot(ctx context.Context) error { 134 | client.logger.Debug("reboot") 135 | 136 | return client.client.Reboot(ctx) 137 | } 138 | 139 | func (client *loggingClient) IsPoweredOn(ctx context.Context) (bool, error) { 140 | client.logger.Debug("is powered on") 141 | 142 | return client.client.IsPoweredOn(ctx) 143 | } 144 | 145 | func (client *loggingClient) PowerOn(ctx context.Context) error { 146 | client.logger.Debug("power on") 147 | 148 | return client.client.PowerOn(ctx) 149 | } 150 | 151 | func (client *loggingClient) PowerOff(ctx context.Context) error { 152 | client.logger.Debug("power off") 153 | 154 | return client.client.PowerOff(ctx) 155 | } 156 | 157 | func (client *loggingClient) SetPXEBootOnce(ctx context.Context, mode pxe.BootMode) error { 158 | client.logger.Debug("set PXE boot once", zap.String("mode", string(mode))) 159 | 160 | return client.client.SetPXEBootOnce(ctx, mode) 161 | } 162 | -------------------------------------------------------------------------------- /internal/provider/controllers/controllers_test.go: -------------------------------------------------------------------------------- 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 controllers_test 6 | 7 | import ( 8 | "context" 9 | "sync/atomic" 10 | "testing" 11 | "time" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller/runtime" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 16 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 17 | "github.com/siderolabs/gen/containers" 18 | "github.com/siderolabs/gen/pair" 19 | agentpb "github.com/siderolabs/talos-metal-agent/api/agent" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/stretchr/testify/require" 22 | "go.uber.org/zap" 23 | "go.uber.org/zap/zaptest" 24 | "golang.org/x/sync/errgroup" 25 | 26 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider" 27 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc" 28 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 29 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 30 | ) 31 | 32 | func init() { 33 | if err := provider.RegisterResources(); err != nil { 34 | panic("failed to register resources: " + err.Error()) 35 | } 36 | } 37 | 38 | type testFunc func(ctx context.Context, st state.State, rt *runtime.Runtime, logger *zap.Logger) 39 | 40 | func withRuntime(t *testing.T, beforeStart, afterStart testFunc) { 41 | ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) 42 | t.Cleanup(cancel) 43 | 44 | logger := zaptest.NewLogger(t) 45 | st := state.WrapCore(namespaced.NewState(inmem.Build)) 46 | 47 | cosiRuntime, err := provider.BuildCOSIRuntime(st, false, logger) 48 | require.NoError(t, err) 49 | 50 | beforeStart(ctx, st, cosiRuntime, logger) 51 | 52 | eg, ctx := errgroup.WithContext(ctx) 53 | 54 | eg.Go(func() error { 55 | return cosiRuntime.Run(ctx) 56 | }) 57 | 58 | afterStart(ctx, st, cosiRuntime, logger) 59 | 60 | cancel() 61 | 62 | require.NoError(t, eg.Wait()) 63 | } 64 | 65 | type bmcClientFactoryMock struct { 66 | bmcClient bmc.Client 67 | } 68 | 69 | func (b *bmcClientFactoryMock) GetClient(context.Context, *resources.BMCConfiguration, *zap.Logger) (bmc.Client, error) { 70 | return b.bmcClient, nil 71 | } 72 | 73 | type bmcClientMock struct { 74 | powerOnCh chan<- struct{} 75 | rebootCh chan<- struct{} 76 | setPXEBootOnceCh chan<- pxe.BootMode 77 | poweredOn bool 78 | } 79 | 80 | func (b *bmcClientMock) Close() error { 81 | return nil 82 | } 83 | 84 | func (b *bmcClientMock) Reboot(ctx context.Context) error { 85 | select { 86 | case b.rebootCh <- struct{}{}: 87 | case <-ctx.Done(): 88 | return ctx.Err() 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (b *bmcClientMock) IsPoweredOn(context.Context) (bool, error) { 95 | return b.poweredOn, nil 96 | } 97 | 98 | func (b *bmcClientMock) PowerOn(ctx context.Context) error { 99 | select { 100 | case b.powerOnCh <- struct{}{}: 101 | case <-ctx.Done(): 102 | return ctx.Err() 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (b *bmcClientMock) PowerOff(context.Context) error { 109 | return nil 110 | } 111 | 112 | func (b *bmcClientMock) SetPXEBootOnce(ctx context.Context, mode pxe.BootMode) error { 113 | select { 114 | case b.setPXEBootOnceCh <- mode: 115 | case <-ctx.Done(): 116 | return ctx.Err() 117 | } 118 | 119 | return nil 120 | } 121 | 122 | type agentClientMock struct { 123 | getPowerMgmtResponseMap *containers.ConcurrentMap[string, *agentpb.GetPowerManagementResponse] 124 | setPowerMgmtRequestCh chan<- pair.Pair[string, *agentpb.SetPowerManagementRequest] 125 | } 126 | 127 | func (a *agentClientMock) GetPowerManagement(_ context.Context, id string) (*agentpb.GetPowerManagementResponse, error) { 128 | val, _ := a.getPowerMgmtResponseMap.Get(id) 129 | 130 | return val, nil 131 | } 132 | 133 | func (a *agentClientMock) SetPowerManagement(ctx context.Context, id string, req *agentpb.SetPowerManagementRequest) error { 134 | select { 135 | case a.setPowerMgmtRequestCh <- pair.MakePair(id, req): 136 | case <-ctx.Done(): 137 | return ctx.Err() 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func (a *agentClientMock) WipeDisks(context.Context, string) error { 144 | return nil 145 | } 146 | 147 | func (a *agentClientMock) AllConnectedMachines() map[string]struct{} { 148 | return nil 149 | } 150 | 151 | func (a *agentClientMock) IsAccessible(context.Context, string) (bool, error) { 152 | return false, nil 153 | } 154 | 155 | func requireChReceive[T any](ctx context.Context, t *testing.T, ch chan T) T { 156 | select { 157 | case val := <-ch: 158 | return val 159 | case <-ctx.Done(): 160 | require.Fail(t, "timeout waiting for channel receive") 161 | 162 | return *new(T) // unreachable 163 | } 164 | } 165 | 166 | type reconcileAsserter struct { 167 | t *testing.T 168 | numReconciles atomic.Uint32 169 | } 170 | 171 | func (r *reconcileAsserter) incrementReconcile() { 172 | r.numReconciles.Add(1) 173 | } 174 | 175 | func (r *reconcileAsserter) requireNotZero(ctx context.Context) uint32 { 176 | var numReconciles uint32 177 | 178 | require.EventuallyWithT(r.t, func(c *assert.CollectT) { 179 | require.NoError(c, ctx.Err()) 180 | 181 | numReconciles = r.numReconciles.Load() 182 | 183 | assert.NotZero(c, numReconciles, "expected at least one reconcile to be called") 184 | }, 5*time.Second, 100*time.Millisecond, "expected at least one reconcile to be called") 185 | 186 | r.t.Logf("numReconciles: %d", numReconciles) 187 | 188 | return numReconciles 189 | } 190 | 191 | func (r *reconcileAsserter) requireReconcile(ctx context.Context, before uint32) { 192 | require.EventuallyWithT(r.t, func(c *assert.CollectT) { 193 | require.NoError(c, ctx.Err()) 194 | 195 | numReconciles := r.numReconciles.Load() 196 | if assert.Greater(c, numReconciles, before, "expected at least one reconcile to be called") { 197 | r.t.Logf("numReconciles = %d", numReconciles) 198 | } 199 | }, 5*time.Second, 100*time.Millisecond, "expected at least one reconcile to be called") 200 | } 201 | -------------------------------------------------------------------------------- /internal/provider/controllers/infra_machine_status.go: -------------------------------------------------------------------------------- 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 controllers 6 | 7 | import ( 8 | "context" 9 | "strings" 10 | 11 | "github.com/cosi-project/runtime/pkg/controller" 12 | "github.com/cosi-project/runtime/pkg/controller/generic/qtransform" 13 | "github.com/cosi-project/runtime/pkg/safe" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 16 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 17 | "github.com/siderolabs/omni/client/pkg/omni/resources/omni" 18 | "go.uber.org/zap" 19 | 20 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/machine" 22 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 24 | ) 25 | 26 | // InfraMachineStatusController manages InfraMachine resource lifecycle. 27 | type InfraMachineStatusController = qtransform.QController[*infra.Machine, *infra.MachineStatus] 28 | 29 | // NewInfraMachineStatusController initializes InfraMachineStatusController. 30 | func NewInfraMachineStatusController(machineLabels map[string]string) *InfraMachineStatusController { 31 | helper := &infraMachineStatusControllerHelper{ 32 | machineLabels: machineLabels, 33 | } 34 | 35 | return qtransform.NewQController( 36 | qtransform.Settings[*infra.Machine, *infra.MachineStatus]{ 37 | Name: meta.ProviderID.String() + ".InfraMachineStatusController", 38 | MapMetadataFunc: func(infraMachine *infra.Machine) *infra.MachineStatus { 39 | return infra.NewMachineStatus(infraMachine.Metadata().ID()) 40 | }, 41 | UnmapMetadataFunc: func(infraMachineStatus *infra.MachineStatus) *infra.Machine { 42 | return infra.NewMachine(infraMachineStatus.Metadata().ID()) 43 | }, 44 | TransformFunc: helper.transform, 45 | }, 46 | qtransform.WithConcurrency(4), 47 | qtransform.WithExtraMappedInput[*resources.MachineStatus](qtransform.MapperSameID[*infra.Machine]()), 48 | qtransform.WithExtraMappedInput[*resources.RebootStatus](qtransform.MapperSameID[*infra.Machine]()), 49 | qtransform.WithExtraMappedInput[*resources.WipeStatus](qtransform.MapperSameID[*infra.Machine]()), 50 | qtransform.WithExtraMappedInput[*resources.BMCConfiguration](qtransform.MapperSameID[*infra.Machine]()), 51 | ) 52 | } 53 | 54 | type infraMachineStatusControllerHelper struct { 55 | machineLabels map[string]string 56 | } 57 | 58 | //nolint:gocyclo,cyclop 59 | func (helper *infraMachineStatusControllerHelper) transform(ctx context.Context, r controller.Reader, logger *zap.Logger, 60 | infraMachine *infra.Machine, infraMachineStatus *infra.MachineStatus, 61 | ) error { 62 | machineStatus, err := safe.ReaderGetByID[*resources.MachineStatus](ctx, r, infraMachine.Metadata().ID()) 63 | if err != nil && !state.IsNotFoundError(err) { 64 | return err 65 | } 66 | 67 | rebootStatus, err := safe.ReaderGetByID[*resources.RebootStatus](ctx, r, infraMachine.Metadata().ID()) 68 | if err != nil && !state.IsNotFoundError(err) { 69 | return err 70 | } 71 | 72 | wipeStatus, err := safe.ReaderGetByID[*resources.WipeStatus](ctx, r, infraMachine.Metadata().ID()) 73 | if err != nil && !state.IsNotFoundError(err) { 74 | return err 75 | } 76 | 77 | bmcConfiguration, err := safe.ReaderGetByID[*resources.BMCConfiguration](ctx, r, infraMachine.Metadata().ID()) 78 | if err != nil && !state.IsNotFoundError(err) { 79 | return err 80 | } 81 | 82 | if err = validateInfraMachine(infraMachine, logger); err != nil { 83 | return err 84 | } 85 | 86 | // we do not need to call validateInfraMachine here, as this controller does not 87 | // do any operations/modifications on the machine itself 88 | 89 | // clear existing labels 90 | for k := range infraMachineStatus.Metadata().Labels().Raw() { 91 | if strings.HasPrefix(k, omni.SystemLabelPrefix) { 92 | continue 93 | } 94 | 95 | infraMachineStatus.Metadata().Labels().Delete(k) 96 | } 97 | 98 | // set the new labels 99 | for k, v := range helper.machineLabels { 100 | infraMachineStatus.Metadata().Labels().Set(k, v) 101 | } 102 | 103 | for _, k := range []string{omni.LabelCluster, omni.LabelMachineSet, omni.LabelControlPlaneRole, omni.LabelWorkerRole} { 104 | if val, ok := infraMachine.Metadata().Labels().Get(k); ok { 105 | infraMachineStatus.Metadata().Labels().Set(k, val) 106 | } else { 107 | infraMachineStatus.Metadata().Labels().Delete(k) 108 | } 109 | } 110 | 111 | if machineStatus != nil { 112 | switch machineStatus.TypedSpec().Value.PowerState { 113 | case specs.PowerState_POWER_STATE_UNKNOWN: 114 | infraMachineStatus.TypedSpec().Value.PowerState = omnispecs.InfraMachineStatusSpec_POWER_STATE_UNKNOWN 115 | case specs.PowerState_POWER_STATE_OFF: 116 | infraMachineStatus.TypedSpec().Value.PowerState = omnispecs.InfraMachineStatusSpec_POWER_STATE_OFF 117 | case specs.PowerState_POWER_STATE_ON: 118 | infraMachineStatus.TypedSpec().Value.PowerState = omnispecs.InfraMachineStatusSpec_POWER_STATE_ON 119 | } 120 | } 121 | 122 | if rebootStatus != nil { 123 | infraMachineStatus.TypedSpec().Value.LastRebootId = rebootStatus.TypedSpec().Value.LastRebootId 124 | infraMachineStatus.TypedSpec().Value.LastRebootTimestamp = rebootStatus.TypedSpec().Value.LastRebootTimestamp 125 | } 126 | 127 | installed := machine.IsInstalled(infraMachine, wipeStatus) 128 | requiresWipe := machine.RequiresWipe(infraMachine, wipeStatus) 129 | bmcConfigurationConfigured := bmcConfiguration != nil 130 | 131 | infraMachineStatus.TypedSpec().Value.Installed = installed 132 | infraMachineStatus.TypedSpec().Value.ReadyToUse = bmcConfigurationConfigured && !requiresWipe 133 | 134 | if wipeStatus != nil { 135 | infraMachineStatus.TypedSpec().Value.WipedNodeUniqueToken = wipeStatus.TypedSpec().Value.WipedNodeUniqueToken 136 | } 137 | 138 | logger.Debug("machine status", 139 | zap.Bool("installed", infraMachineStatus.TypedSpec().Value.Installed), 140 | zap.Bool("ready_to_use", infraMachineStatus.TypedSpec().Value.ReadyToUse)) 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/provider/server/server.go: -------------------------------------------------------------------------------- 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 server implements the HTTP and GRPC servers. 6 | package server 7 | 8 | import ( 9 | "context" 10 | "crypto/tls" 11 | "errors" 12 | "fmt" 13 | "net" 14 | "net/http" 15 | "strconv" 16 | "strings" 17 | "time" 18 | 19 | "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" 20 | "github.com/jhump/grpctunnel/tunnelpb" 21 | "go.uber.org/zap" 22 | "golang.org/x/net/http2" 23 | "golang.org/x/net/http2/h2c" 24 | "golang.org/x/sync/errgroup" 25 | "google.golang.org/grpc" 26 | "google.golang.org/grpc/codes" 27 | "google.golang.org/grpc/credentials/insecure" 28 | "google.golang.org/grpc/status" 29 | 30 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/constants" 31 | providertls "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/tls" 32 | ) 33 | 34 | // Server represents the HTTP and GRPC servers. 35 | type Server struct { 36 | grpcServer *grpc.Server 37 | httpServer *http.Server 38 | tlsServer *http.Server 39 | logger *zap.Logger 40 | } 41 | 42 | // RegisterService registers a service with the GRPC server. 43 | // 44 | // Implements grpc.ServiceRegistrar interface. 45 | func (s *Server) RegisterService(desc *grpc.ServiceDesc, impl any) { 46 | s.grpcServer.RegisterService(desc, impl) 47 | } 48 | 49 | // New creates a new server. 50 | func New(ctx context.Context, listenAddress string, port, tlsPort int, serveAssetsDir bool, certs *providertls.Certs, 51 | configHandler, ipxeHandler http.Handler, tunnelServiceServer tunnelpb.TunnelServiceServer, logger *zap.Logger, 52 | ) *Server { 53 | recoveryOption := recovery.WithRecoveryHandler(recoveryHandler(logger)) 54 | 55 | grpcServer := grpc.NewServer( 56 | grpc.ChainUnaryInterceptor(recovery.UnaryServerInterceptor(recoveryOption)), 57 | grpc.ChainStreamInterceptor(recovery.StreamServerInterceptor(recoveryOption)), 58 | grpc.Creds(insecure.NewCredentials()), 59 | ) 60 | 61 | tunnelpb.RegisterTunnelServiceServer(grpcServer, tunnelServiceServer) 62 | 63 | var tlsServer *http.Server 64 | 65 | if certs != nil { // TLS mode, initialize the TLS server with the GRPC handler 66 | tlsServer = &http.Server{ 67 | Addr: net.JoinHostPort(listenAddress, strconv.Itoa(tlsPort)), 68 | Handler: grpcServer, 69 | BaseContext: func(net.Listener) context.Context { 70 | return ctx 71 | }, 72 | TLSConfig: &tls.Config{ 73 | GetCertificate: certs.GetCertificate, 74 | ClientAuth: tls.NoClientCert, 75 | MinVersion: tls.VersionTLS13, 76 | }, 77 | } 78 | } 79 | 80 | httpServer := &http.Server{ 81 | Addr: net.JoinHostPort(listenAddress, strconv.Itoa(port)), 82 | Handler: newMultiHandler(configHandler, ipxeHandler, grpcServer, serveAssetsDir, logger), 83 | BaseContext: func(net.Listener) context.Context { 84 | return ctx 85 | }, 86 | } 87 | 88 | return &Server{ 89 | grpcServer: grpcServer, 90 | httpServer: httpServer, 91 | tlsServer: tlsServer, 92 | logger: logger, 93 | } 94 | } 95 | 96 | // Run runs the server. 97 | func (s *Server) Run(ctx context.Context) error { 98 | eg, ctx := errgroup.WithContext(ctx) 99 | 100 | eg.Go(func() error { 101 | return s.shutdownOnCancel(ctx, s.httpServer) 102 | }) 103 | 104 | eg.Go(func() error { 105 | s.logger.Info("start HTTP server", zap.String("address", s.httpServer.Addr)) 106 | 107 | if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 108 | return fmt.Errorf("failed to run server: %w", err) 109 | } 110 | 111 | return nil 112 | }) 113 | 114 | if s.tlsServer != nil { 115 | eg.Go(func() error { 116 | return s.shutdownOnCancel(ctx, s.tlsServer) 117 | }) 118 | 119 | eg.Go(func() error { 120 | s.logger.Info("start TLS HTTP server", zap.String("address", s.tlsServer.Addr)) 121 | 122 | if err := s.tlsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { 123 | return fmt.Errorf("failed to run TLS server: %w", err) 124 | } 125 | 126 | return nil 127 | }) 128 | } 129 | 130 | return eg.Wait() 131 | } 132 | 133 | func (s *Server) shutdownOnCancel(ctx context.Context, server *http.Server) error { 134 | <-ctx.Done() 135 | 136 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 137 | defer cancel() 138 | 139 | if err := server.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck 140 | return fmt.Errorf("failed to shutdown iPXE server: %w", err) 141 | } 142 | 143 | return nil 144 | } 145 | 146 | func newMultiHandler(configHandler, ipxeHandler, grpcHandler http.Handler, serveAssetsDir bool, logger *zap.Logger) http.Handler { 147 | mux := http.NewServeMux() 148 | 149 | mux.Handle("/config", configHandler) 150 | mux.Handle(fmt.Sprintf("/%s/{script}", constants.IPXEURLPath), ipxeHandler) 151 | mux.Handle("/tftp/", http.StripPrefix("/tftp/", http.FileServer(http.Dir(constants.IPXEPath+"/")))) 152 | 153 | if serveAssetsDir { 154 | mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("/assets/")))) 155 | } 156 | 157 | loggingMiddleware := func(next http.Handler) http.Handler { 158 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 159 | start := time.Now() 160 | 161 | next.ServeHTTP(w, req) 162 | 163 | logger.Info("request", 164 | zap.String("method", req.Method), 165 | zap.String("path", req.URL.Path), 166 | zap.Duration("duration", time.Since(start)), 167 | ) 168 | }) 169 | } 170 | 171 | multi := &multiHandler{ 172 | httpHandler: loggingMiddleware(mux), 173 | grpcHandler: grpcHandler, 174 | } 175 | 176 | return h2c.NewHandler(multi, &http2.Server{}) 177 | } 178 | 179 | type multiHandler struct { 180 | httpHandler http.Handler 181 | grpcHandler http.Handler 182 | } 183 | 184 | func (m *multiHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 185 | if req.ProtoMajor == 2 && strings.HasPrefix( 186 | req.Header.Get("Content-Type"), "application/grpc") { 187 | m.grpcHandler.ServeHTTP(w, req) 188 | 189 | return 190 | } 191 | 192 | m.httpHandler.ServeHTTP(w, req) 193 | } 194 | 195 | func recoveryHandler(logger *zap.Logger) recovery.RecoveryHandlerFunc { 196 | return func(p any) error { 197 | if logger != nil { 198 | logger.Error("grpc panic", zap.Any("panic", p), zap.Stack("stack")) 199 | } 200 | 201 | return status.Errorf(codes.Internal, "%v", p) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /internal/provider/agent/client.go: -------------------------------------------------------------------------------- 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 agent 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jhump/grpctunnel" 11 | "github.com/jhump/grpctunnel/tunnelpb" 12 | agentpb "github.com/siderolabs/talos-metal-agent/api/agent" 13 | agentconstants "github.com/siderolabs/talos-metal-agent/pkg/constants" 14 | "go.uber.org/zap" 15 | "google.golang.org/grpc/codes" 16 | "google.golang.org/grpc/metadata" 17 | "google.golang.org/grpc/status" 18 | 19 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/controllers" 20 | ) 21 | 22 | // Client controls servers by establishing a reverse GRPC tunnel with them and by sending them commands. 23 | type Client struct { 24 | logger *zap.Logger 25 | tunnelHandler *grpctunnel.TunnelServiceHandler 26 | 27 | options ClientOptions 28 | } 29 | 30 | // NewClient creates a new agent service. 31 | func NewClient(agentConnectionEventCh chan<- controllers.AgentConnectionEvent, options ClientOptions, logger *zap.Logger) *Client { 32 | tunnelHandler := grpctunnel.NewTunnelServiceHandler( 33 | grpctunnel.TunnelServiceHandlerOptions{ 34 | OnReverseTunnelOpen: func(channel grpctunnel.TunnelChannel) { 35 | handleTunnelEvent(channel, agentConnectionEventCh, true, logger) 36 | }, 37 | OnReverseTunnelClose: func(channel grpctunnel.TunnelChannel) { 38 | handleTunnelEvent(channel, agentConnectionEventCh, false, logger) 39 | }, 40 | AffinityKey: func(channel grpctunnel.TunnelChannel) any { 41 | id, ok := machineIDAffinityKey(channel.Context(), logger) 42 | if !ok { 43 | return "invalid" 44 | } 45 | 46 | return id 47 | }, 48 | }, 49 | ) 50 | 51 | return &Client{ 52 | logger: logger, 53 | tunnelHandler: tunnelHandler, 54 | options: options, 55 | } 56 | } 57 | 58 | // TunnelServiceServer returns the GRPC tunnel service server. 59 | func (c *Client) TunnelServiceServer() tunnelpb.TunnelServiceServer { 60 | return c.tunnelHandler.Service() 61 | } 62 | 63 | // IsAccessible checks if the agent with the given ID is accessible. 64 | func (c *Client) IsAccessible(ctx context.Context, id string) (bool, error) { 65 | ctx, cancel := context.WithTimeout(ctx, c.options.CallTimeout) 66 | defer cancel() 67 | 68 | channel := c.tunnelHandler.KeyAsChannel(id) 69 | cli := agentpb.NewAgentServiceClient(channel) 70 | 71 | _, err := cli.Hello(ctx, &agentpb.HelloRequest{}) 72 | if err != nil { 73 | if status.Code(err) == codes.Unavailable { 74 | return false, nil 75 | } 76 | 77 | return false, err 78 | } 79 | 80 | return true, nil 81 | } 82 | 83 | // GetPowerManagement retrieves the IPMI information from the server with the given ID. 84 | func (c *Client) GetPowerManagement(ctx context.Context, id string) (*agentpb.GetPowerManagementResponse, error) { 85 | ctx, cancel := context.WithTimeout(ctx, c.options.CallTimeout) 86 | defer cancel() 87 | 88 | channel := c.tunnelHandler.KeyAsChannel(id) 89 | cli := agentpb.NewAgentServiceClient(channel) 90 | 91 | return cli.GetPowerManagement(ctx, &agentpb.GetPowerManagementRequest{}) 92 | } 93 | 94 | // SetPowerManagement sets the IPMI information on the server with the given ID. 95 | func (c *Client) SetPowerManagement(ctx context.Context, id string, req *agentpb.SetPowerManagementRequest) error { 96 | ctx, cancel := context.WithTimeout(ctx, c.options.CallTimeout) 97 | defer cancel() 98 | 99 | channel := c.tunnelHandler.KeyAsChannel(id) 100 | cli := agentpb.NewAgentServiceClient(channel) 101 | 102 | _, err := cli.SetPowerManagement(ctx, req) 103 | 104 | return err 105 | } 106 | 107 | // WipeDisks wipes the disks on the server with the given ID. 108 | func (c *Client) WipeDisks(ctx context.Context, id string) error { 109 | channel := c.tunnelHandler.KeyAsChannel(id) 110 | cli := agentpb.NewAgentServiceClient(channel) 111 | 112 | wipeTimeout := c.options.FastWipeTimeout 113 | if c.options.WipeWithZeroes { 114 | wipeTimeout = c.options.ZeroesWipeTimeout 115 | } 116 | 117 | ctx, cancel := context.WithTimeout(ctx, wipeTimeout) 118 | defer cancel() 119 | 120 | _, err := cli.WipeDisks(ctx, &agentpb.WipeDisksRequest{ 121 | Zeroes: c.options.WipeWithZeroes, 122 | }) 123 | 124 | return err 125 | } 126 | 127 | // AllConnectedMachines returns a set of all connected machines. 128 | func (c *Client) AllConnectedMachines() map[string]struct{} { 129 | allTunnels := c.tunnelHandler.AllReverseTunnels() 130 | 131 | machines := make(map[string]struct{}, len(allTunnels)) 132 | 133 | for _, tunnel := range allTunnels { 134 | affinityKey, ok := machineIDAffinityKey(tunnel.Context(), c.logger) 135 | if !ok { 136 | c.logger.Warn("invalid affinity key", zap.String("reason", "no machine ID in metadata")) 137 | 138 | continue 139 | } 140 | 141 | machines[affinityKey] = struct{}{} 142 | } 143 | 144 | return machines 145 | } 146 | 147 | func machineIDAffinityKey(ctx context.Context, logger *zap.Logger) (string, bool) { 148 | md, ok := metadata.FromIncomingContext(ctx) 149 | if !ok { 150 | logger.Warn("invalid affinity key", zap.String("reason", "no metadata")) 151 | 152 | return "", false 153 | } 154 | 155 | machineID := md.Get(agentconstants.MachineIDMetadataKey) 156 | if len(machineID) == 0 { 157 | logger.Warn("invalid affinity key", zap.String("reason", "no machine ID in metadata")) 158 | 159 | return "", false 160 | } 161 | 162 | if len(machineID) > 1 { 163 | logger.Warn("multiple machine IDs in metadata", zap.Strings("machine_ids", machineID)) 164 | } 165 | 166 | return machineID[0], true 167 | } 168 | 169 | func handleTunnelEvent(channel grpctunnel.TunnelChannel, agentConnectionEventCh chan<- controllers.AgentConnectionEvent, connected bool, logger *zap.Logger) { 170 | affinityKey, ok := machineIDAffinityKey(channel.Context(), logger) 171 | if !ok { 172 | logger.Warn("invalid affinity key", zap.String("reason", "no machine ID in metadata")) 173 | 174 | return 175 | } 176 | 177 | logger = logger.With(zap.String("machine_id", affinityKey), zap.Bool("connected", connected)) 178 | 179 | logger.Debug("machine tunnel event") 180 | 181 | if channel.Context().Err() != nil { // context is closed, probably the app is shutting down, nothing to do 182 | return 183 | } 184 | 185 | select { 186 | case <-channel.Context().Done(): 187 | return 188 | case agentConnectionEventCh <- controllers.AgentConnectionEvent{ 189 | MachineID: affinityKey, 190 | Connected: connected, 191 | }: 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /internal/provider/machineconfig/machineconfig.go: -------------------------------------------------------------------------------- 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 machineconfig builds the machine configuration for the bare-metal provider. 6 | package machineconfig 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "strings" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller" 14 | "github.com/cosi-project/runtime/pkg/resource/meta" 15 | "github.com/cosi-project/runtime/pkg/safe" 16 | "github.com/cosi-project/runtime/pkg/state" 17 | "github.com/hashicorp/go-multierror" 18 | "github.com/siderolabs/omni/client/pkg/jointoken" 19 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 20 | siderolinkres "github.com/siderolabs/omni/client/pkg/omni/resources/siderolink" 21 | "github.com/siderolabs/omni/client/pkg/siderolink" 22 | "github.com/siderolabs/talos/pkg/machinery/config/config" 23 | "github.com/siderolabs/talos/pkg/machinery/config/configloader" 24 | "github.com/siderolabs/talos/pkg/machinery/config/container" 25 | "github.com/siderolabs/talos/pkg/machinery/config/encoder" 26 | "github.com/siderolabs/talos/pkg/machinery/config/types/runtime" 27 | "github.com/siderolabs/talos/pkg/machinery/config/types/security" 28 | siderolinktalos "github.com/siderolabs/talos/pkg/machinery/config/types/siderolink" 29 | "github.com/siderolabs/talos/pkg/machinery/config/types/v1alpha1" 30 | 31 | providermeta "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 32 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/tls" 33 | ) 34 | 35 | const ( 36 | kmsgLogName = "siderolink-agent-kmsg-log" 37 | infraProviderCAName = "infra-provider-ca" 38 | ) 39 | 40 | // Build builds the machine configuration for the bare-metal provider. 41 | func Build(ctx context.Context, r controller.Reader, certs *tls.Certs, extraMachineConfigPath string) ([]byte, error) { 42 | var extraDocs []config.Document 43 | 44 | if certs != nil && certs.CACertPEM != "" { 45 | trustedRootsConfig := security.NewTrustedRootsConfigV1Alpha1() 46 | trustedRootsConfig.MetaName = infraProviderCAName 47 | trustedRootsConfig.Certificates = certs.CACertPEM 48 | 49 | extraDocs = append(extraDocs, trustedRootsConfig) 50 | } 51 | 52 | if extraMachineConfigPath != "" { 53 | additionalDocs, err := parseAdditionalDocuments(extraMachineConfigPath) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to load extra machine config: %w", err) 56 | } 57 | 58 | extraDocs = append(extraDocs, additionalDocs...) 59 | } 60 | 61 | providerJoinConfigRD, err := safe.ReaderGetByID[*meta.ResourceDefinition](ctx, r, strings.ToLower(siderolinkres.ProviderJoinConfigType)) 62 | if err != nil && !state.IsNotFoundError(err) { 63 | return nil, err 64 | } 65 | 66 | // V2 flow 67 | if providerJoinConfigRD != nil { 68 | return buildV2MachineConfig(ctx, r, extraDocs) 69 | } 70 | 71 | // keeping this code for compatibility with the older Omni versions 72 | connectionParams, err := safe.ReaderGetByID[*siderolinkres.ConnectionParams](ctx, r, siderolinkres.ConfigID) //nolint:staticcheck 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to get connection params: %w", err) 75 | } 76 | 77 | opts, err := siderolink.NewJoinOptions( 78 | siderolink.WithJoinToken(connectionParams.TypedSpec().Value.JoinToken), 79 | siderolink.WithMachineAPIURL(connectionParams.TypedSpec().Value.ApiEndpoint), 80 | siderolink.WithEventSinkPort(int(connectionParams.TypedSpec().Value.EventsPort)), 81 | siderolink.WithLogServerPort(int(connectionParams.TypedSpec().Value.LogsPort)), 82 | siderolink.WithProvider(infra.NewProvider(providermeta.ProviderID.String())), 83 | siderolink.WithJoinTokenVersion(jointoken.Version1), 84 | ) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return opts.RenderJoinConfig(extraDocs...) 90 | } 91 | 92 | func buildV2MachineConfig(ctx context.Context, r controller.Reader, extraDocs []config.Document) ([]byte, error) { 93 | providerJoinConfig, err := safe.ReaderGetByID[*siderolinkres.ProviderJoinConfig](ctx, r, providermeta.ProviderID.String()) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | parsedConfig, err := configloader.NewFromReader(strings.NewReader(providerJoinConfig.TypedSpec().Value.Config.Config)) 99 | if err != nil { 100 | return nil, fmt.Errorf("failed to load provider join config: %w", err) 101 | } 102 | 103 | docs := parsedConfig.Documents() 104 | docs = append(docs, extraDocs...) 105 | 106 | configContainer, err := container.New(docs...) 107 | if err != nil { 108 | return nil, fmt.Errorf("failed to create config container: %w", err) 109 | } 110 | 111 | return configContainer.EncodeBytes(encoder.WithComments(encoder.CommentsDisabled)) 112 | } 113 | 114 | func parseAdditionalDocuments(extraMachineConfigPath string) ([]config.Document, error) { 115 | loadedConfig, err := configloader.NewFromFile(extraMachineConfigPath) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to load extra machine config: %w", err) 118 | } 119 | 120 | var errs error 121 | 122 | for _, document := range loadedConfig.Documents() { 123 | switch document.Kind() { 124 | case v1alpha1.Version, siderolinktalos.Kind, runtime.EventSinkKind: 125 | errs = multierror.Append(errs, fmt.Errorf("extra machine config must not contain %s documents", document.Kind())) 126 | case runtime.KmsgLogKind: 127 | kmsgLogConfig, ok := document.(*runtime.KmsgLogV1Alpha1) 128 | if !ok { 129 | errs = multierror.Append(errs, fmt.Errorf("expected %s document, got %T", runtime.KmsgLogKind, document)) 130 | 131 | continue 132 | } 133 | 134 | if kmsgLogConfig.MetaName == kmsgLogName { 135 | errs = multierror.Append(errs, fmt.Errorf("extra machine config must not contain %s document with name %q", runtime.KmsgLogKind, kmsgLogName)) 136 | } 137 | case security.TrustedRootsConfig: 138 | trustedRootsConfig, ok := document.(*security.TrustedRootsConfigV1Alpha1) 139 | if !ok { 140 | errs = multierror.Append(errs, fmt.Errorf("expected %s document, got %T", security.TrustedRootsConfig, document)) 141 | 142 | continue 143 | } 144 | 145 | if trustedRootsConfig.MetaName == infraProviderCAName { 146 | errs = multierror.Append(errs, fmt.Errorf("extra machine config must not contain %s document with name %q", security.TrustedRootsConfig, infraProviderCAName)) 147 | } 148 | } 149 | } 150 | 151 | if errs != nil { 152 | return nil, fmt.Errorf("invalid extra machine config: %w", errs) 153 | } 154 | 155 | return loadedConfig.Documents(), nil 156 | } 157 | -------------------------------------------------------------------------------- /hack/certs/key.private: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PRIVATE KEY BLOCK----- 2 | 3 | lQcYBGMzVUsBEAC5Ioyi/Rs06GL3zeKVgmi9F8xF6yDjec+E83FFyUdO1xiZ4fDf 4 | WQhUKh5c4gJi1hoGOWPkFWsEx1sTPmZsAdYIHmOSt6qPGbdt22JnfGseYdTRpMmi 5 | blkcbvUm3uidghDd5HiacmhaZqh+5AjN0T61QVZfryo5gb9y5wMxCVp2d8t5PG1/ 6 | ULosQ6WfhQhCkyzF1eiuLyxfCToyYUhRRhveRsitrNnpAaNBjOvtG9ujdRz3EPsn 7 | q0kcwDscqqUdZIPNNe0LyUk7PDy61eTEYz6wSZVVw9l7Gng0cIs5ws89xa2Q1AFC 8 | 9V8XE5/755R/j4V4+Iu4oqKhOF9U5Ic85LJ55wbu8mowgPsC2hK+09lqNaHzI7qf 9 | 2pmS8Vd/6ABLCOILOFuXgvZtx7tV124GPhS72jOabkjegmFA22Gfnnrv56KrWnSv 10 | +nk1R8FTaggC54JxsdWYckyb3bcE8J9R5T1fU+54sspEbiGQpzMALgk1AnKnlVsV 11 | kcXO7NvsN5xZinskRXfi9YdtwLuzZegDuq/8/dByatwKHLzn5QEQLw9HQlhQcnJB 12 | 5GqCUl/ljncl6CmXpLZbDJSaBTeTeeDMe/a508IoMiIe0s7VBFGFZOaW5pOMWZo4 13 | 0nVE91v2oIEBvF3IKVNlEWDCekiNnc0G4K1VMLq3Kq+OgTnjFnEZCM1M6wARAQAB 14 | AA/7B4h1sIFxWfXFZnqPfbCQvSD6xDLiY7R70oJqrtbseojGDNI6EDHPOkgnHssT 15 | eHITHQMOIs8RoQrQ6eI1c1pKcIohWGxLbwcyL4YoZY2VI5ICLDBIWWj5Ye7Mit2x 16 | xAxGzhXofQsACVhOrYXEgJttsce20ViPSfJoQoSj3Jrvfg03JPe7J4hhYEELEYft 17 | jh1ESnMpxJdRduHT5w44+Gr3N1QqAOcc9sjaRmXgM7BZKjgvCt6Qrvcz7QzlWtqW 18 | srDPAXVFmp/WGv2YewG5DUSnMwUgznHpp4NW4MteNDt5CJ/ChYzFgF6mP993OF4e 19 | l2d/nCyA2EJwhkmE7NoKUVqnkxHPuUXYgLjc1ca+rtaSScbmlwIOJKRDVD5hsZEw 20 | TKImUsaJ/PVOmCN8MwW5yXUx3ZvB6JlFSOjOT267B/Jk7LM3zupKuQJt8M4o6QZy 21 | vkMAUxZau74jsTcXB6fB2NCSmaFOlPi9rMmOgqiNpdzzaSOVuJfI7lHgS7UDS2yn 22 | oaWSoPUxVLEmNRTFbP1ArZ2aEUaCy/ThvQ7aFJlDbBbQbmrxz7hMp0oiWJVf2X+2 23 | VUEuTZAlTNYoXCSMVR3UoJfKJ99trNP7kuNiiOt5puDrhFDwNKxdjm4qRPBw6tsZ 24 | cBUFmnbx9S6tGNGM9BMhGZW87YMHrMmPRwGHZrBXiaV99gEIAMpFtVIXP2RTL+T6 25 | DgjmTz15pf1WuGBZcIg2DUs9lNRe///E0hv+IxNY1rY5g7xmJRpZfMNDCFvtEWen 26 | jrCJ5vJBO1Vxky4NsM3t5wbqJXYam2lib39HAXKuMTIGjXh29ShM57++FBTITXiO 27 | pgdO3tg2FwIi/Hc5wqYwRYpJ+gVn6rDy5mR1jD99z5ccIKAPEPWw4qQ2+ICHhICu 28 | Oe7aU3OTtFaS/RS7Vo+bXxENUb26bTyQMrztxHgIPnU01DamZHbhwq7uEustZ9+c 29 | DBMuo98XOOru15XJAaQnPd5Qi2Nz8Cqttfu7QoVBxwcWxvNU6QuYxASeGOrtYfAf 30 | 5nfvsx0IAOpPhWEtcoRQZxf/EQ5s7YsQ8ztmVVOY6vNQC2x0EqRbJaJfI62KdQBB 31 | nao3Je6syKRAGQLHKcoc3dDuKT2aFPoRXFyptSLK83E2ktlIa71RPI3HcEoduqm7 32 | H6MPfxKXWvKF09SV5e1Bj9Dqpe8NuE81w4CkDlaVfvdoBm0HRa0c1oZ5zDNZ/Jxk 33 | ogPBoLWJ/WJbOo8O3ddtYILJG2mAOeIztslGu3Slsaz3Uk7WaG+5hrc783/K9yDZ 34 | 7J5cP8hgv/Z0uVJEy34mY5s++dce3uO5OilV3QosehrV8PLt2lXOwcxNesy5QqWf 35 | 1fDW0jb1znP+o6uQ5J0blmwekxtmOacIAIen0hx4LbhHEvWvK7AAKvct/OmVgJxB 36 | n1IxsaJV6c/qKEqVbf5g7cg/0I9hlsCO1B/1eqrXBD7QDjB3/3lSr3WlIOHKf4Bf 37 | re3v7kmXEGN1RZzyO2YdI4mLpYF3GYrQtMwo5pD6QPZudW+uRbZk5cAYIO7pb5uZ 38 | pbJGK5pU35UE+wkL6/oWTjn7dnBpKtamGlZ08ssl7HDPZ/A51MGnyNjYCEMC/3IE 39 | nmhfaeQklKI8uF6VBy0ixueTXSRbb3EO7Pj9+tF3bVRIESBfvujaH++TAJ9h89gi 40 | UFIwBtNJvXGj5DA2nF46y/PurMQKMr7djUA7BLBu7Usj0x9jWAv0Z5iF7LQ2RG1p 41 | dHJ5IE1hdHJlbmljaGV2IDxkbWl0cnkubWF0cmVuaWNoZXZAc2lkZXJvbGFicy5j 42 | b20+iQJUBBMBCgA+FiEEozmFFCsEH0WF59oCIBBLTR3n8ckFAmMzVUsCGwMFCQlm 43 | AYAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQIBBLTR3n8cnGkxAAkdDRmP++ 44 | K2q3s1gIGMd+ENzRplyDPxlUrQTKuopgR9xf1xRf6vdZoBT4D3sDtY7s7LBakxk9 45 | u15+Og7V9w8L+3i620W0KRYS1cOTjLN1BP7inaiqGCHF/KUFHlHIF08Iz6xv3X4P 46 | /+3oDrB/6TGqdU+DUThjA/ugUt8eVvY+VNg/8d+A4M21YOU/rjTf/PpN9FbXDbdZ 47 | 3i3gfue9lNHJxK5CLv8sDltUFH2KF8pMgobkQ7WObwmR2hHcBaFtQAgxKV2pb1La 48 | jnZQAHWBEkNpJGL5R4AoApfLupcv/AASmdwLkBiwP/PzvvxhSKUMbb/LQKxK+M+3 49 | HLJ+lTHlNs92lU4umvpZ5W1AJQ8nLR1GKn3ufm8cD+hDzekroO1y8MTnzxKX77rA 50 | RjEQt//6GTuRkRrQcd6zjcAOnl3rMpEtuneWFnUWghBSuz82IM7WNGFTSVKRNthp 51 | NzKlu45rgBmERzn6IXA/l/r5Q4g5uM48FvJUy05znWftEjumuzG5m+1eB2Zu7xwd 52 | 7icW1/mgL14XDvGnlnlsm6BQRR6eN5Vrx0TADFuJQPpilsouhBYuz1p02hcxMkGo 53 | Svjcwphc3w+yc8610eosBUbVV5KbX68wC+aSFIa4L5CS9fl68JGkREeEKA5YCCOj 54 | fgB8JX/mfSkeoSSZg0vGxBDajsN0G50O6BGdBxgEYzNVSwEQAKIkCwr/fw9VWLbm 55 | bCYckMEkF6wcXssO6ymoHwk003F1ZUsR8RQXsey7TWj6sLmS79tW8/M5RGxHdsZm 56 | SBxymFbdOo5c2nup0jxngV5v0IkkqWidryKOCLctK6l65qjUBhDzqLy4jRGcnf+2 57 | Z9XFjy+BbzY7FScT2rq4Zj3af4Zu+xfIIQ/JO9Sk9E/k9sKLNd/7BXWxDR5cPQs2 58 | K7WjK0ax6vND1xYLU8wm6ct+Z0dpMNA9xbxRM7uTLR7P3oonO4OVjNBHYWgpKYVE 59 | YF9zcevLByfgMAagGIsG3QSqdgv9LNKwv68LR7jP6JNFcdaQ1FW1e14QLECjs+xl 60 | RjgY/hoaCsCg7XXpbj5aJr5Me9hIbF0M1toMjujws0VGjpv1CNYuQv16efzZytQK 61 | GmIm30Tj5nHY7L3TwDiER9wCdvnjjX8+gIqM82mu7jElpLWFE+xg6IxJgRGcmOd1 62 | PPQY+5bCGVKCUHLNCJRPtYC/cXmjJUTDmLJUzdc4ysWLrdclSnaOHvv4T/VnVzq9 63 | EuibZ3xu+/8a/amxnjq3Ck+pzKbtdpCycqYJSfnXhaHcY8I8M1mKYg721cKy5eAV 64 | D+zkRm7oJz0CblB9Ds8+s3O0KLRosGSHRrBcpNfEUs/qs/NeuCx7DxSmnA/9Ohrl 65 | P3SrEpBodv8dNKgKry18VjjMBUMBABEBAAEAD/0TPebHl9ma1ryP/BlyjmpJWYCr 66 | rrQ7MdqLl4WTYJ8FNHLgbVEoWsWFPBcsMa/+Xec0JwYNY8rwdKyuT94X7iuRB3EX 67 | CwLssRMfkwMB05AyblTicvAhUCzNnEE1vD2aZIsRwPDR8K7hG66OdbWt42OiNiCe 68 | FXXlrNAE37RWe9Mtf4cx49C0oGOG0UqjHp+AJ+gtXAtiU7AkXbrq1TNru2D740po 69 | MzFXzuFTdXzCZw5Xpa6iz+ni9toGVSmCIhYdXBmOfJV49Delllj0lVBAk6E949rG 70 | Cy934dD30skw8A/RTWrf2BTvb43D63yE2bVwSsDAKSjqWU3/H85O7BfguWqSOw/b 71 | d9c/1PzY6DnmBjP6a6UqQ207mMmQwm+kUzOrIoPeGrwrksAperHNvNwM0uFymVkx 72 | 87tgMI2amGXhvo2FjeYhpAmXIygMmXGmNvFKW0DqJiUzbEYM7RC+A2+1fw2eSuqN 73 | YOEhjA8jvRkKjIUhB5/X10GYp/vlV8Kaomhcxfk12VnwtEblwOHoeIsAREnN7wzs 74 | mG50sV1NdaJy4B6JwE5gJU6i9h+KcYrs49vqOIJSGJ7nDRwweMzxhEvl5PuaZJy6 75 | FLuslHvgRT6GWNE/eIAWavgYmj5a25Tx80i2Z+ztbtKT984eZ6VxKXa+JlOkAcXC 76 | 0REKPz0h7pm5NvEafwgAyXMhkjNKNpB6C6HfQ1/lwo4d4+LN/C9+3D/BeTSI56aa 77 | vVzXK/EeF0cvwsbaTn+M9HoF/MhGKftk+TSsWXyp/Yp9se4LntyOPBHspRI/mXj7 78 | 8kvcZCBBfLoCe8L2ytM3a7c0NZsJ8qFD3qNZgaYELyH59c7bCWhczLLbg0QSXZHl 79 | H2l3LPdBDcrgY1CthWOtd2kKPPNaW7B6/8Mk2eymHFVmFfVJ/6jW0Y6HJjgsF5L+ 80 | a2zx/t2sAfB4WIBlAXIInZXWtcWHb2ArJDzHiSjhtzIRuHG5uG+48MbuYAO8zK5j 81 | 5Y3UscHbZ6MQ0rDMKbFJawOhtuOW/dCQy2Zsj6NQAwgAzgvxjblw3NxKAcQ94M3Z 82 | 8o1cJ80oH6xNqEceqHhlGr9QLKPIK9FLBgO3zv6NqDWrbyA2TxKMK/09EfaYKzcr 83 | VP8e53Xvl27lzQH3jy+4ii7ZF9GlVLtn/hw/CrNQYBOO9whEEoe95oKkBc6B1tRP 84 | uUVrpnTfY5qgPKn64pwflTZ1iNnSmW1GkUkHeJn3Ckts2dK5tOC3lbgRzyUMzq0r 85 | Ze+pxi8tLIUOxw05yngUU7Xpf6nDlhZJLxO1vQIBJolwTpRnrudQftBnZz7guQKL 86 | p1IWeNb8QkCjMq23kSaeyvAlpOzoIBm1ePmcavA7fLJJqLepV2C1JCDZTByNbX+b 87 | qwgArquLk/d1sShSfA6LqESxaJGYXAkYf2kXcJSIR3A0z7m91swL+Qrs7G/g5BfI 88 | 8T5xtErmzK3ex4/ZYYKYCIUU/xDO/ZL+Ks7u2aPJSQGQZSPRNLk0LJK1T132FB+z 89 | j9WPqqu2mfr7T5YO5vMJDcd6gDEFSqZzS/8aogWwZwoIQD6DBCBCJvOodDyncgzV 90 | cMQytTQC+Qjea5Ji++1NHA7i9Pyj70HgqmyL6T90Lrm8BurA+/IBWgOYFf2aFrVi 91 | 6tgxryIYjEHNrDvEBU9SVUHco3s1+7dAm86DrL5K3rLwbDbRlz5SjlJdL0+NLG1+ 92 | wDbzwdI0AHuhxc/PZHxjRhm1DY5IiQI8BBgBCgAmFiEEozmFFCsEH0WF59oCIBBL 93 | TR3n8ckFAmMzVUsCGwwFCQlmAYAACgkQIBBLTR3n8cn5ghAAkXuf1KDd8I/hPxH/ 94 | C2b6u4wjrTWaaSvzLacAjCwOf4ctHyApb7SbiQQ0cFD/0c3Vedf13O0xJJ8BdSO7 95 | cdF/RpVvR8hE8ebGoylnkWhKKGmB68aw7LmFYLE2pwuhx8GFuAsVz2eozrbzRqyk 96 | R7o/HnMSoJ41zlPLI4Go0NjETycZyzSKfNQw+5KWKbOnU7kGvCZReEuNGpyjDZ9I 97 | HbnB+qlRyY5QCcX+nEX6sWJyIoNyOJbu2D3XG7ylQGZL71nogTRhLMBL7VDUaR3r 98 | /s5l3E16jn5cn798p8o4bnFXukY9JOi3V8yL+X2Kg/ylwJYmnhhyG9c5Bu4I9g0F 99 | Q+Z+rbN4YoG1bt112rAA1KlekhuJwdDAn9QhKx0Nod13wcHFrjKp/Z1YH8TYxcUc 100 | pjeg0+BMs/K4PyO9xvnGEuITcJBoSnQRx/E+gShu3DL5ndmFsM8qyx7fJRXsTtDb 101 | 21sArBHBNuUa5blAp1fVCm4RQ9+F1GOwnOFRJfWVrhp8ninHtnM78TnDd1wczLVv 102 | SNVAXjsHoZTVuuCWWXPVX0OG22fPAy90nJpxGtPNVFI5se39Srk/1Yg92vhO/G3T 103 | UtoMuMxSBCD7BD6ZFox/ga3fARvfreZuiNQn9t6J6d7DChqCQxo2Rtk9WWdy72W9 104 | hp/KSDr6GKR6H5ggj1v1tBWE95A= 105 | =pdI2 106 | -----END PGP PRIVATE KEY BLOCK----- 107 | -------------------------------------------------------------------------------- /internal/provider/controllers/power_operation.go: -------------------------------------------------------------------------------- 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 controllers 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/cosi-project/runtime/pkg/controller" 12 | "github.com/cosi-project/runtime/pkg/controller/generic/qtransform" 13 | "github.com/cosi-project/runtime/pkg/safe" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | "github.com/siderolabs/gen/xerrors" 16 | omnispecs "github.com/siderolabs/omni/client/api/omni/specs" 17 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 18 | "go.uber.org/zap" 19 | "google.golang.org/protobuf/types/known/timestamppb" 20 | 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 22 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/machine" 24 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 25 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 26 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/util" 27 | ) 28 | 29 | // NowFunc is a function that returns the current time. 30 | type NowFunc = func() time.Time 31 | 32 | // PowerOperationController manages InfraMachine resource lifecycle. 33 | type PowerOperationController = qtransform.QController[*infra.Machine, *resources.PowerOperation] 34 | 35 | // NewPowerOperationController creates a new PowerOperationController. 36 | // 37 | //nolint:dupl 38 | func NewPowerOperationController(nowFunc NowFunc, bmcClientFactory BMCClientFactory, minRebootInterval time.Duration, pxeBootMode pxe.BootMode) *PowerOperationController { 39 | helper := &powerOperationControllerHelper{ 40 | nowFunc: nowFunc, 41 | bmcClientFactory: bmcClientFactory, 42 | minRebootInterval: minRebootInterval, 43 | pxeBootMode: pxeBootMode, 44 | } 45 | 46 | return qtransform.NewQController( 47 | qtransform.Settings[*infra.Machine, *resources.PowerOperation]{ 48 | Name: meta.ProviderID.String() + ".PowerOperationController", 49 | MapMetadataFunc: func(infraMachine *infra.Machine) *resources.PowerOperation { 50 | return resources.NewPowerOperation(infraMachine.Metadata().ID()) 51 | }, 52 | UnmapMetadataFunc: func(powerOperation *resources.PowerOperation) *infra.Machine { 53 | return infra.NewMachine(powerOperation.Metadata().ID()) 54 | }, 55 | TransformFunc: helper.transform, 56 | }, 57 | qtransform.WithExtraMappedInput[*resources.BMCConfiguration](qtransform.MapperSameID[*infra.Machine]()), 58 | qtransform.WithExtraMappedInput[*resources.WipeStatus](qtransform.MapperSameID[*infra.Machine]()), 59 | qtransform.WithExtraMappedInput[*resources.RebootStatus](qtransform.MapperSameID[*infra.Machine]()), 60 | qtransform.WithExtraMappedInput[*resources.MachineStatus](qtransform.MapperSameID[*infra.Machine]()), 61 | qtransform.WithConcurrency(4), 62 | ) 63 | } 64 | 65 | type powerOperationControllerHelper struct { 66 | bmcClientFactory BMCClientFactory 67 | nowFunc NowFunc 68 | pxeBootMode pxe.BootMode 69 | minRebootInterval time.Duration 70 | } 71 | 72 | //nolint:gocyclo,cyclop 73 | func (helper *powerOperationControllerHelper) transform(ctx context.Context, r controller.Reader, logger *zap.Logger, 74 | infraMachine *infra.Machine, powerOperation *resources.PowerOperation, 75 | ) error { 76 | bmcConfiguration, err := safe.ReaderGetByID[*resources.BMCConfiguration](ctx, r, infraMachine.Metadata().ID()) 77 | if err != nil && !state.IsNotFoundError(err) { 78 | return err 79 | } 80 | 81 | wipeStatus, err := safe.ReaderGetByID[*resources.WipeStatus](ctx, r, infraMachine.Metadata().ID()) 82 | if err != nil && !state.IsNotFoundError(err) { 83 | return err 84 | } 85 | 86 | // this controller needs to wake up after a reboot to bring the machine again to the desired power state 87 | rebootStatus, err := safe.ReaderGetByID[*resources.RebootStatus](ctx, r, infraMachine.Metadata().ID()) 88 | if err != nil && !state.IsNotFoundError(err) { 89 | return err 90 | } 91 | 92 | if err = validateInfraMachine(infraMachine, logger); err != nil { 93 | return err 94 | } 95 | 96 | if bmcConfiguration == nil { 97 | logger.Debug("machine has no power management configuration") 98 | 99 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine has no power management configuration") 100 | } 101 | 102 | requiresPowerOn := machine.RequiresPowerOn(infraMachine, wipeStatus) 103 | 104 | logger.Info("power operation", 105 | zap.Bool("installed", machine.IsInstalled(infraMachine, wipeStatus)), 106 | zap.Bool("allocated", infraMachine.TypedSpec().Value.ClusterTalosVersion != ""), 107 | zap.Bool("requires_wipe", machine.RequiresWipe(infraMachine, wipeStatus)), 108 | zap.Bool("requires_power_on", requiresPowerOn), 109 | ) 110 | 111 | bmcClient, err := helper.bmcClientFactory.GetClient(ctx, bmcConfiguration, logger) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | defer util.LogClose(bmcClient, logger) 117 | 118 | isPoweredOn, err := bmcClient.IsPoweredOn(ctx) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | preferredPowerState := infraMachine.TypedSpec().Value.PreferredPowerState 124 | 125 | logger = logger.With(zap.Bool("is_powered_on", isPoweredOn), zap.Stringer("preferred_power_state", preferredPowerState)) 126 | 127 | switch { 128 | case !isPoweredOn && (requiresPowerOn || preferredPowerState == omnispecs.InfraMachineSpec_POWER_STATE_ON): 129 | logger.Debug("power on machine") 130 | 131 | requiredBootMode := machine.RequiredBootMode(infraMachine, bmcConfiguration, wipeStatus, logger) 132 | if machine.RequiresPXEBoot(requiredBootMode) { 133 | if err = bmcClient.SetPXEBootOnce(ctx, helper.pxeBootMode); err != nil { 134 | return err 135 | } 136 | } 137 | 138 | if err = bmcClient.PowerOn(ctx); err != nil { 139 | return err 140 | } 141 | 142 | powerOperation.TypedSpec().Value.LastPowerOperation = specs.PowerState_POWER_STATE_ON 143 | powerOperation.TypedSpec().Value.LastPowerOnTimestamp = timestamppb.New(helper.nowFunc()) 144 | case isPoweredOn && (!requiresPowerOn && preferredPowerState == omnispecs.InfraMachineSpec_POWER_STATE_OFF): 145 | timeSinceLastPowerOn := getTimeSinceLastPowerOn(powerOperation, rebootStatus) 146 | if timeSinceLastPowerOn < helper.minRebootInterval { 147 | logger.Debug("we are in power off cooldown period, requeue", zap.Duration("elapsed", timeSinceLastPowerOn), zap.Duration("min_reboot_interval", helper.minRebootInterval)) 148 | 149 | return controller.NewRequeueInterval(helper.minRebootInterval - timeSinceLastPowerOn + time.Second) 150 | } 151 | 152 | logger.Debug("power off machine") 153 | 154 | if err = bmcClient.PowerOff(ctx); err != nil { 155 | return err 156 | } 157 | 158 | powerOperation.TypedSpec().Value.LastPowerOperation = specs.PowerState_POWER_STATE_OFF 159 | default: 160 | logger.Debug("machine power state is already as desired") 161 | } 162 | 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /internal/provider/bmc/redfish/redfish.go: -------------------------------------------------------------------------------- 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 redfish provides BMC functionality using Redfish. 6 | package redfish 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "net" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/siderolabs/gen/xslices" 17 | "github.com/stmcginnis/gofish" 18 | "github.com/stmcginnis/gofish/redfish" 19 | "go.uber.org/zap" 20 | 21 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/bmc/pxe" 22 | ) 23 | 24 | // Client is a wrapper around the gofish client. 25 | type Client struct { 26 | logger *zap.Logger 27 | config gofish.ClientConfig 28 | setBootSourceOverrideMode bool 29 | } 30 | 31 | // Close implements the power.Client interface. 32 | func (c *Client) Close() error { 33 | return nil 34 | } 35 | 36 | // Reboot implements the power.Client interface. 37 | func (c *Client) Reboot(ctx context.Context) error { 38 | return c.withClient(ctx, func(client *gofish.APIClient) error { 39 | return c.doComputerSystemReset(client, redfish.ForceRestartResetType) // todo: consider making reset type configurable 40 | }) 41 | } 42 | 43 | // IsPoweredOn implements the power.Client interface. 44 | func (c *Client) IsPoweredOn(ctx context.Context) (bool, error) { 45 | poweredOn := false 46 | 47 | if err := c.withClient(ctx, func(client *gofish.APIClient) error { 48 | system, err := c.getSystem(client) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | poweredOn = system.PowerState == redfish.OnPowerState 54 | 55 | return nil 56 | }); err != nil { 57 | return false, err 58 | } 59 | 60 | return poweredOn, nil 61 | } 62 | 63 | // PowerOn implements the power.Client interface. 64 | func (c *Client) PowerOn(ctx context.Context) error { 65 | return c.withClient(ctx, func(client *gofish.APIClient) error { 66 | return c.doComputerSystemReset(client, redfish.OnResetType) 67 | }) 68 | } 69 | 70 | // PowerOff implements the power.Client interface. 71 | func (c *Client) PowerOff(ctx context.Context) error { 72 | return c.withClient(ctx, func(client *gofish.APIClient) error { 73 | return c.doComputerSystemReset(client, redfish.ForceOffResetType) 74 | }) 75 | } 76 | 77 | func (c *Client) doComputerSystemReset(client *gofish.APIClient, resetType redfish.ResetType) error { 78 | system, err := c.getSystem(client) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return system.Reset(resetType) 84 | } 85 | 86 | // SetPXEBootOnce implements the power.Client interface. 87 | func (c *Client) SetPXEBootOnce(ctx context.Context, mode pxe.BootMode) error { 88 | return c.withClient(ctx, func(client *gofish.APIClient) error { 89 | system, err := c.getSystem(client) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | boot := redfish.Boot{ 95 | BootSourceOverrideEnabled: redfish.OnceBootSourceOverrideEnabled, 96 | BootSourceOverrideTarget: redfish.PxeBootSourceOverrideTarget, 97 | } 98 | 99 | if c.setBootSourceOverrideMode { 100 | switch mode { 101 | case pxe.BootModeBIOS: 102 | boot.BootSourceOverrideMode = redfish.LegacyBootSourceOverrideMode 103 | case pxe.BootModeUEFI: 104 | boot.BootSourceOverrideMode = redfish.UEFIBootSourceOverrideMode 105 | default: 106 | return fmt.Errorf("unknown boot mode: %s", mode) 107 | } 108 | } 109 | 110 | if err = system.SetBoot(boot); err != nil { 111 | if c.isAMIFutureStateError(err) { 112 | c.logger.Debug("attempting AMI FutureState workaround for boot settings") 113 | 114 | return c.setBootAMIFutureState(client, system, boot) 115 | } 116 | 117 | return err 118 | } 119 | 120 | return nil 121 | }) 122 | } 123 | 124 | func (c *Client) withClient(ctx context.Context, f func(client *gofish.APIClient) error) error { 125 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 126 | defer cancel() 127 | 128 | client, err := gofish.ConnectContext(ctx, c.config) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | defer client.Logout() 134 | 135 | return f(client) 136 | } 137 | 138 | func (c *Client) getSystem(client *gofish.APIClient) (*redfish.ComputerSystem, error) { 139 | systems, err := client.Service.Systems() 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | if len(systems) == 0 { 145 | return nil, fmt.Errorf("no systems found") 146 | } 147 | 148 | if len(systems) > 1 { 149 | ids := xslices.Map(systems, func(system *redfish.ComputerSystem) string { 150 | return system.ID 151 | }) 152 | 153 | c.logger.Warn("multiple systems found, using first one", zap.Strings("system_ids", ids)) 154 | } 155 | 156 | return systems[0], nil 157 | } 158 | 159 | // isAMIFutureStateError checks if the error is the specific AMI error requiring FutureState URI. 160 | func (c *Client) isAMIFutureStateError(err error) bool { 161 | return strings.Contains(err.Error(), "Ami.1.0.OperationSupportedInFutureStateURI") 162 | } 163 | 164 | // setBootAMIFutureState handles boot setting for AMI BMCs using the FutureState URI. 165 | func (c *Client) setBootAMIFutureState(client *gofish.APIClient, system *redfish.ComputerSystem, boot redfish.Boot) error { 166 | // For AMI BMCs, we need to: 167 | // 1. GET the current FutureState to obtain ETag 168 | // 2. PATCH boot settings to /redfish/v1/Systems/{id}/SD (FutureState URI) with If-Match header 169 | 170 | // Construct the FutureState URI 171 | futureStateURI := system.ODataID + "/SD" 172 | 173 | c.logger.Debug("using AMI FutureState URI for boot settings", zap.String("uri", futureStateURI)) 174 | 175 | // First, GET the current FutureState to obtain ETag 176 | resp, err := client.Get(futureStateURI) 177 | if err != nil { 178 | return fmt.Errorf("failed to get current FutureState: %w", err) 179 | } 180 | 181 | etag := resp.Header.Get("ETag") 182 | if etag == "" { 183 | return fmt.Errorf("no ETag found in FutureState response") 184 | } 185 | 186 | c.logger.Debug("obtained ETag from FutureState", zap.String("etag", etag)) 187 | 188 | // PATCH to the FutureState URI with If-Match header 189 | headers := map[string]string{ 190 | "If-Match": etag, 191 | } 192 | 193 | // Boot should be a field in the SD object, so we need to wrap it in a Boot object 194 | // See https://pubs.lenovo.com/tsm/patch_systems_instance_sd for more details 195 | payload := struct { 196 | Boot redfish.Boot `json:"Boot"` 197 | }{Boot: boot} 198 | 199 | _, err = client.PatchWithHeaders(futureStateURI, payload, headers) 200 | if err != nil { 201 | return fmt.Errorf("failed to set boot via AMI FutureState URI: %w", err) 202 | } 203 | 204 | c.logger.Debug("successfully set boot settings via AMI FutureState URI") 205 | 206 | return nil 207 | } 208 | 209 | // NewClient returns a new Redfish BMC client. 210 | func NewClient(options Options, address, username, password string, logger *zap.Logger) *Client { 211 | host, _, err := net.SplitHostPort(address) 212 | if err != nil { 213 | host = address 214 | } 215 | 216 | protocol := "http" 217 | if options.UseHTTPS { 218 | protocol = "https" 219 | } 220 | 221 | endpoint := fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(host, strconv.Itoa(options.Port))) 222 | 223 | return &Client{ 224 | config: gofish.ClientConfig{ 225 | Endpoint: endpoint, 226 | Username: username, 227 | Password: password, 228 | Insecure: options.InsecureSkipTLSVerify, 229 | BasicAuth: true, 230 | }, 231 | 232 | setBootSourceOverrideMode: options.SetBootSourceOverrideMode, 233 | logger: logger, 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /internal/provider/controllers/bmc_configuration.go: -------------------------------------------------------------------------------- 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 controllers 6 | 7 | import ( 8 | "context" 9 | "crypto/rand" 10 | "fmt" 11 | "math/big" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller" 14 | "github.com/cosi-project/runtime/pkg/controller/generic/qtransform" 15 | "github.com/cosi-project/runtime/pkg/resource" 16 | "github.com/cosi-project/runtime/pkg/safe" 17 | "github.com/cosi-project/runtime/pkg/state" 18 | "github.com/siderolabs/gen/xerrors" 19 | "github.com/siderolabs/omni/client/pkg/omni/resources/infra" 20 | agentpb "github.com/siderolabs/talos-metal-agent/api/agent" 21 | "go.uber.org/zap" 22 | 23 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 24 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/meta" 25 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 26 | ) 27 | 28 | // BMCAPIAddressReader is the interface for reading power management information from the API state directory. 29 | type BMCAPIAddressReader interface { 30 | ReadManagementAddress(id resource.ID, logger *zap.Logger) (string, error) 31 | } 32 | 33 | // BMCConfigurationController manages machine power management. 34 | type BMCConfigurationController = qtransform.QController[*infra.Machine, *resources.BMCConfiguration] 35 | 36 | // NewBMCConfigurationController creates a new BMCConfigurationController. 37 | func NewBMCConfigurationController(agentClient AgentClient, bmcAPIAddressReader BMCAPIAddressReader) *BMCConfigurationController { 38 | helper := &bmcConfigurationControllerHelper{ 39 | agentClient: agentClient, 40 | bmcAPIAddressReader: bmcAPIAddressReader, 41 | } 42 | 43 | return qtransform.NewQController( 44 | qtransform.Settings[*infra.Machine, *resources.BMCConfiguration]{ 45 | Name: meta.ProviderID.String() + ".BMCConfigurationController", 46 | MapMetadataFunc: func(infraMachine *infra.Machine) *resources.BMCConfiguration { 47 | return resources.NewBMCConfiguration(infraMachine.Metadata().ID()) 48 | }, 49 | UnmapMetadataFunc: func(bmcConfiguration *resources.BMCConfiguration) *infra.Machine { 50 | return infra.NewMachine(bmcConfiguration.Metadata().ID()) 51 | }, 52 | TransformFunc: helper.transform, 53 | }, 54 | qtransform.WithConcurrency(4), 55 | qtransform.WithExtraMappedInput[*resources.MachineStatus](qtransform.MapperSameID[*infra.Machine]()), 56 | qtransform.WithExtraMappedInput[*infra.BMCConfig](qtransform.MapperSameID[*infra.Machine]()), 57 | qtransform.WithIgnoreTeardownUntil(), // keep this resource around until all other controllers are done with it 58 | ) 59 | } 60 | 61 | type bmcConfigurationControllerHelper struct { 62 | agentClient AgentClient 63 | bmcAPIAddressReader BMCAPIAddressReader 64 | } 65 | 66 | func (helper *bmcConfigurationControllerHelper) transform(ctx context.Context, r controller.Reader, logger *zap.Logger, 67 | infraMachine *infra.Machine, bmcConfiguration *resources.BMCConfiguration, 68 | ) error { 69 | machineStatus, err := safe.ReaderGetByID[*resources.MachineStatus](ctx, r, infraMachine.Metadata().ID()) 70 | if err != nil && !state.IsNotFoundError(err) { 71 | return err 72 | } 73 | 74 | bmcConfig, err := safe.ReaderGetByID[*infra.BMCConfig](ctx, r, infraMachine.Metadata().ID()) 75 | if err != nil && !state.IsNotFoundError(err) { 76 | return err 77 | } 78 | 79 | if err = validateInfraMachine(infraMachine, logger); err != nil { 80 | return err 81 | } 82 | 83 | if machineStatus == nil { 84 | logger.Debug("machine status not found, skip") 85 | 86 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("machine status not found") 87 | } 88 | 89 | if !machineStatus.TypedSpec().Value.AgentAccessible { 90 | logger.Info("agent is not accessible, skip") 91 | 92 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("agent is not accessible") 93 | } 94 | 95 | id := infraMachine.Metadata().ID() 96 | 97 | if bmcConfig != nil { 98 | return helper.storeUserProvidedBMCConfig(bmcConfig, bmcConfiguration, logger) 99 | } 100 | 101 | alreadyInitialized := !bmcConfiguration.TypedSpec().Value.ManuallyConfigured && 102 | (bmcConfiguration.TypedSpec().Value.Api != nil || bmcConfiguration.TypedSpec().Value.Ipmi != nil) 103 | 104 | if alreadyInitialized { 105 | logger.Debug("bmc config already initialized, skip") 106 | 107 | return xerrors.NewTaggedf[qtransform.SkipReconcileTag]("bmc config already initialized") 108 | } 109 | 110 | powerManagementOnAgent, err := helper.agentClient.GetPowerManagement(ctx, id) 111 | if err != nil { 112 | return fmt.Errorf("failed to get power management information: %w", err) 113 | } 114 | 115 | ipmiPassword, err := helper.ensurePowerManagementOnAgent(ctx, id, powerManagementOnAgent) 116 | if err != nil { 117 | return fmt.Errorf("failed to ensure power management on agent: %w", err) 118 | } 119 | 120 | bmcConfiguration.TypedSpec().Value.ManuallyConfigured = false 121 | 122 | if powerManagementOnAgent.Api != nil { 123 | address, addressErr := helper.bmcAPIAddressReader.ReadManagementAddress(id, logger) 124 | if addressErr != nil { 125 | return addressErr 126 | } 127 | 128 | bmcConfiguration.TypedSpec().Value.Api = &specs.BMCConfigurationSpec_API{ 129 | Address: address, 130 | } 131 | 132 | logger.Debug("api bmc config initialized", zap.String("api_address", address)) 133 | } 134 | 135 | if powerManagementOnAgent.Ipmi != nil { 136 | bmcConfiguration.TypedSpec().Value.Ipmi = &specs.BMCConfigurationSpec_IPMI{ 137 | Address: powerManagementOnAgent.Ipmi.Address, 138 | Port: powerManagementOnAgent.Ipmi.Port, 139 | Username: IPMIUsername, 140 | Password: ipmiPassword, 141 | } 142 | 143 | logger.Debug("ipmi bmc config initialized", zap.String("ipmi_address", powerManagementOnAgent.Ipmi.Address), zap.String("ipmi_username", IPMIUsername)) 144 | } 145 | 146 | return nil 147 | } 148 | 149 | func (helper *bmcConfigurationControllerHelper) storeUserProvidedBMCConfig(userConfig *infra.BMCConfig, bmcConfiguration *resources.BMCConfiguration, logger *zap.Logger) error { 150 | config := userConfig.TypedSpec().Value.Config 151 | if config == nil { 152 | return fmt.Errorf("user provided BMC config is nil") 153 | } 154 | 155 | logger.Info("initialize BMC config from user-provided config") 156 | 157 | bmcConfiguration.TypedSpec().Value.ManuallyConfigured = true 158 | 159 | if config.Ipmi != nil { 160 | port := config.Ipmi.Port 161 | if port == 0 { 162 | port = IPMIDefaultPort 163 | } 164 | 165 | bmcConfiguration.TypedSpec().Value.Ipmi = &specs.BMCConfigurationSpec_IPMI{ 166 | Address: config.Ipmi.Address, 167 | Port: port, 168 | Username: config.Ipmi.Username, 169 | Password: config.Ipmi.Password, 170 | } 171 | 172 | logger.Info("user-provided ipmi config initialized", 173 | zap.String("ipmi_address", config.Ipmi.Address), 174 | zap.String("ipmi_username", config.Ipmi.Username), 175 | zap.Uint32("ipmi_port", port), 176 | ) 177 | } 178 | 179 | if config.Api != nil { 180 | bmcConfiguration.TypedSpec().Value.Api = &specs.BMCConfigurationSpec_API{ 181 | Address: config.Api.Address, 182 | } 183 | 184 | logger.Info("user-provided api config initialized", zap.String("api_address", config.Api.Address)) 185 | } 186 | 187 | return nil 188 | } 189 | 190 | // ensurePowerManagementOnAgent ensures that the power management (e.g., IPMI) is configured and credentials are set on the Talos machine running agent. 191 | func (helper *bmcConfigurationControllerHelper) ensurePowerManagementOnAgent(ctx context.Context, id resource.ID, 192 | powerManagement *agentpb.GetPowerManagementResponse, 193 | ) (ipmiPassword string, err error) { 194 | if powerManagement.Api == nil && powerManagement.Ipmi == nil { 195 | return "", fmt.Errorf("machine did not provide any power management information") 196 | } 197 | 198 | var ( 199 | api *agentpb.SetPowerManagementRequest_API 200 | ipmi *agentpb.SetPowerManagementRequest_IPMI 201 | ) 202 | 203 | if powerManagement.Api != nil { 204 | api = &agentpb.SetPowerManagementRequest_API{} 205 | } 206 | 207 | if powerManagement.Ipmi != nil { 208 | ipmiPassword, err = generateIPMIPassword() 209 | if err != nil { 210 | return "", err 211 | } 212 | 213 | ipmi = &agentpb.SetPowerManagementRequest_IPMI{ 214 | Username: IPMIUsername, 215 | Password: ipmiPassword, 216 | } 217 | } 218 | 219 | if err = helper.agentClient.SetPowerManagement(ctx, id, &agentpb.SetPowerManagementRequest{ 220 | Api: api, 221 | Ipmi: ipmi, 222 | }); err != nil { 223 | return "", err 224 | } 225 | 226 | return ipmiPassword, nil 227 | } 228 | 229 | var runes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 230 | 231 | // generateIPMIPassword returns a random password of length 16 for IPMI. 232 | func generateIPMIPassword() (string, error) { 233 | b := make([]rune, IPMIPasswordLength) 234 | for i := range b { 235 | rando, err := rand.Int(rand.Reader, big.NewInt(int64(len(runes)))) 236 | if err != nil { 237 | return "", err 238 | } 239 | 240 | b[i] = runes[rando.Int64()] 241 | } 242 | 243 | return string(b), nil 244 | } 245 | -------------------------------------------------------------------------------- /internal/provider/tls/tls.go: -------------------------------------------------------------------------------- 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 tls provides the TLS configuration for the provider. 6 | package tls 7 | 8 | import ( 9 | "context" 10 | "crypto/tls" 11 | "crypto/x509" 12 | "encoding/pem" 13 | "fmt" 14 | "net" 15 | "os" 16 | "slices" 17 | "sync" 18 | "time" 19 | 20 | "github.com/cosi-project/runtime/pkg/safe" 21 | "github.com/cosi-project/runtime/pkg/state" 22 | siderox509 "github.com/siderolabs/crypto/x509" 23 | "go.uber.org/zap" 24 | 25 | "github.com/siderolabs/omni-infra-provider-bare-metal/api/specs" 26 | "github.com/siderolabs/omni-infra-provider-bare-metal/internal/provider/resources" 27 | ) 28 | 29 | // Options contains the TLS options. 30 | type Options struct { 31 | CACertFile string 32 | CertFile string 33 | KeyFile string 34 | CustomIPXECACertFile string 35 | APIPort int 36 | CATTL time.Duration 37 | CertTTL time.Duration 38 | Enabled bool 39 | AgentSkipVerify bool 40 | } 41 | 42 | // Certs contains the CA certificate and the function to get a new valid certificate signed by the CA. 43 | type Certs struct { 44 | GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) 45 | CACertPEM string 46 | } 47 | 48 | // Initialize initializes the TLS configuration. 49 | func Initialize(ctx context.Context, st state.State, host string, options Options, logger *zap.Logger) (*Certs, error) { 50 | if options.CACertFile != "" || options.CertFile != "" || options.KeyFile != "" { 51 | logger.Info("loading TLS certificates from files", zap.String("cert_file", options.CertFile), 52 | zap.String("key_file", options.KeyFile), zap.String("ca_cert_file", options.CACertFile)) 53 | 54 | certs, err := loadCerts(host, options) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to load TLS certificates: %w", err) 57 | } 58 | 59 | return certs, nil 60 | } 61 | 62 | ca, err := initCA(ctx, st, options.CATTL, logger) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to initialize CA: %w", err) 65 | } 66 | 67 | provider, err := newRenewingCertificateProvider(ca, host, options.CertTTL, logger) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to create certificate provider: %w", err) 70 | } 71 | 72 | return &Certs{ 73 | GetCertificate: provider.GetCertificate, 74 | CACertPEM: string(ca.CrtPEM), 75 | }, nil 76 | } 77 | 78 | func loadCerts(host string, options Options) (*Certs, error) { 79 | certPEMBytes, err := os.ReadFile(options.CertFile) 80 | if err != nil { 81 | return nil, fmt.Errorf("failed to read certificate file %q: %w", options.CertFile, err) 82 | } 83 | 84 | keyPEMBytes, err := os.ReadFile(options.KeyFile) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to read key file %q: %w", options.KeyFile, err) 87 | } 88 | 89 | var caCertPEMBytes []byte 90 | 91 | if options.CACertFile != "" { 92 | if caCertPEMBytes, err = os.ReadFile(options.CACertFile); err != nil { 93 | return nil, fmt.Errorf("failed to read CA certificate file %q: %w", options.CACertFile, err) 94 | } 95 | } 96 | 97 | cert, err := tls.X509KeyPair(certPEMBytes, keyPEMBytes) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to load X509 key pair: %w", err) 100 | } 101 | 102 | block, _ := pem.Decode(certPEMBytes) 103 | if block == nil || block.Type != "CERTIFICATE" { 104 | return nil, fmt.Errorf("no certificate PEM found") 105 | } 106 | 107 | x509Cert, err := x509.ParseCertificate(block.Bytes) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to parse certificate: %w", err) 110 | } 111 | 112 | if err = x509Cert.VerifyHostname(host); err != nil { 113 | return nil, fmt.Errorf("loaded certificate is not valid for host %q: %w", host, err) 114 | } 115 | 116 | return &Certs{ 117 | GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { 118 | return &cert, nil 119 | }, 120 | CACertPEM: string(caCertPEMBytes), 121 | }, nil 122 | } 123 | 124 | func initCA(ctx context.Context, st state.State, caTTL time.Duration, logger *zap.Logger) (*siderox509.CertificateAuthority, error) { 125 | tlsConfig, err := safe.ReaderGetByID[*resources.TLSConfig](ctx, st, resources.TLSConfigID) 126 | if err != nil && !state.IsNotFoundError(err) { 127 | return nil, fmt.Errorf("failed to get TLS config: %w", err) 128 | } 129 | 130 | if tlsConfig == nil { 131 | logger.Info("no existing CA found, generating a new one") 132 | 133 | return generateCA(ctx, st, caTTL, false) 134 | } 135 | 136 | logger.Info("found existing CA, decoding it") 137 | 138 | var ca *siderox509.CertificateAuthority 139 | 140 | if ca, err = decodeCA(tlsConfig); err != nil { 141 | return nil, fmt.Errorf("failed to decode CA: %w", err) 142 | } 143 | 144 | if ca.Crt.NotAfter.After(time.Now()) { 145 | return ca, nil 146 | } 147 | 148 | logger.Info("existing CA is expired, generating a new one") 149 | 150 | return generateCA(ctx, st, caTTL, true) 151 | } 152 | 153 | func decodeCA(tlsConfig *resources.TLSConfig) (*siderox509.CertificateAuthority, error) { 154 | certAndKey := siderox509.PEMEncodedCertificateAndKey{ 155 | Crt: []byte(tlsConfig.TypedSpec().Value.CaCert), 156 | Key: []byte(tlsConfig.TypedSpec().Value.CaKey), 157 | } 158 | 159 | ca, err := siderox509.NewCertificateAuthorityFromCertificateAndKey(&certAndKey) 160 | if err != nil { 161 | return nil, fmt.Errorf("failed to decode CA: %w", err) 162 | } 163 | 164 | return ca, nil 165 | } 166 | 167 | func generateCA(ctx context.Context, st state.State, caTTL time.Duration, update bool) (*siderox509.CertificateAuthority, error) { 168 | now := time.Now() 169 | expiration := now.Add(caTTL) 170 | 171 | ca, err := siderox509.NewSelfSignedCertificateAuthority( 172 | siderox509.Organization("siderolabs"), 173 | siderox509.NotBefore(now), 174 | siderox509.NotAfter(expiration)) 175 | if err != nil { 176 | return nil, fmt.Errorf("failed to create self-signed CA: %w", err) 177 | } 178 | 179 | spec := &specs.TLSConfigSpec{ 180 | CaCert: string(ca.CrtPEM), 181 | CaKey: string(ca.KeyPEM), 182 | } 183 | 184 | if update { 185 | if _, err = safe.StateUpdateWithConflicts(ctx, st, resources.NewTLSConfig().Metadata(), func(res *resources.TLSConfig) error { 186 | res.TypedSpec().Value = spec 187 | 188 | return nil 189 | }); err != nil { 190 | return nil, fmt.Errorf("failed to update TLS config: %w", err) 191 | } 192 | 193 | return ca, nil 194 | } 195 | 196 | // Create a new TLS config 197 | tlsConfig := resources.NewTLSConfig() 198 | tlsConfig.TypedSpec().Value = spec 199 | 200 | if err = st.Create(ctx, tlsConfig); err != nil { 201 | return nil, fmt.Errorf("failed to create TLS config: %w", err) 202 | } 203 | 204 | return ca, nil 205 | } 206 | 207 | type renewingCertificateProvider struct { 208 | ca *siderox509.CertificateAuthority 209 | logger *zap.Logger 210 | cert *tls.Certificate 211 | host string 212 | opts []siderox509.Option 213 | certTTL time.Duration 214 | mu sync.Mutex 215 | } 216 | 217 | func newRenewingCertificateProvider(ca *siderox509.CertificateAuthority, host string, certTTL time.Duration, logger *zap.Logger) (*renewingCertificateProvider, error) { 218 | switch { 219 | case ca == nil: 220 | return nil, fmt.Errorf("CA is not set") 221 | case host == "": 222 | return nil, fmt.Errorf("host is not set") 223 | case certTTL < 5*time.Minute: 224 | return nil, fmt.Errorf("certTTL is too short: %v", certTTL) 225 | } 226 | 227 | opts := []siderox509.Option{ 228 | siderox509.CommonName("omni-infra-provider-bare-metal"), 229 | siderox509.Organization("siderolabs"), 230 | siderox509.KeyUsage(x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature), 231 | } 232 | 233 | parsedIP := net.ParseIP(host) 234 | if parsedIP != nil { 235 | opts = append(opts, siderox509.IPAddresses([]net.IP{parsedIP})) 236 | } else { 237 | opts = append(opts, siderox509.DNSNames([]string{host})) 238 | } 239 | 240 | return &renewingCertificateProvider{ 241 | ca: ca, 242 | logger: logger, 243 | host: host, 244 | opts: opts, 245 | certTTL: certTTL, 246 | }, nil 247 | } 248 | 249 | func (provider *renewingCertificateProvider) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { 250 | provider.mu.Lock() 251 | defer provider.mu.Unlock() 252 | 253 | now := time.Now() 254 | 255 | if !provider.shouldRenew(now) { 256 | return provider.cert, nil 257 | } 258 | 259 | opts := append(slices.Clip(provider.opts), siderox509.NotBefore(now), siderox509.NotAfter(now.Add(provider.certTTL))) 260 | 261 | keyPair, err := siderox509.NewKeyPair(provider.ca, opts...) 262 | if err != nil { 263 | return nil, fmt.Errorf("failed to create key pair: %w", err) 264 | } 265 | 266 | provider.cert = keyPair.Certificate 267 | 268 | return keyPair.Certificate, nil 269 | } 270 | 271 | func (provider *renewingCertificateProvider) shouldRenew(now time.Time) bool { 272 | if provider.cert == nil { 273 | provider.logger.Info("initialize certificate") 274 | 275 | return true 276 | } 277 | 278 | remainingTTL := provider.cert.Leaf.NotAfter.Sub(now) 279 | 280 | if remainingTTL < provider.certTTL/10 { 281 | provider.logger.Info("renew certificate", zap.Duration("remaining_ttl", remainingTTL)) 282 | 283 | return true 284 | } 285 | 286 | return false 287 | } 288 | --------------------------------------------------------------------------------