├── 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 | 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
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 |
--------------------------------------------------------------------------------