+1
.gitignore
+1
.gitignore
···
1
+
bin/
+52
Makefile
+52
Makefile
···
1
+
.PHONY: build install clean tidy test
2
+
3
+
# Build variables
4
+
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
5
+
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
6
+
BINARY := talos-upgrade
7
+
8
+
# Default target
9
+
all: build
10
+
11
+
# Build the binary
12
+
build:
13
+
go build $(LDFLAGS) -o bin/$(BINARY) ./cmd/talos-upgrade
14
+
15
+
# Build for multiple platforms
16
+
build-all:
17
+
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-amd64 ./cmd/talos-upgrade
18
+
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-arm64 ./cmd/talos-upgrade
19
+
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY)-darwin-amd64 ./cmd/talos-upgrade
20
+
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY)-darwin-arm64 ./cmd/talos-upgrade
21
+
22
+
# Install to GOPATH/bin
23
+
install:
24
+
go install $(LDFLAGS) ./cmd/talos-upgrade
25
+
26
+
# Clean build artifacts
27
+
clean:
28
+
rm -rf bin/
29
+
30
+
# Tidy dependencies
31
+
tidy:
32
+
go mod tidy
33
+
34
+
# Run tests
35
+
test:
36
+
go test -v ./...
37
+
38
+
# Run with dry-run
39
+
dry-run: build
40
+
./bin/$(BINARY) --dry-run status
41
+
42
+
# Show help
43
+
help:
44
+
@echo "Available targets:"
45
+
@echo " build - Build the binary to bin/"
46
+
@echo " build-all - Build for multiple platforms"
47
+
@echo " install - Install to GOPATH/bin"
48
+
@echo " clean - Remove build artifacts"
49
+
@echo " tidy - Tidy go.mod dependencies"
50
+
@echo " test - Run tests"
51
+
@echo " dry-run - Build and run status in dry-run mode"
52
+
@echo " help - Show this help"
+25
cmd/talos-upgrade/main.go
+25
cmd/talos-upgrade/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"os"
7
+
"os/signal"
8
+
"syscall"
9
+
10
+
"github.com/evanjarrett/homelab/internal/cmd"
11
+
)
12
+
13
+
// Version is set at build time
14
+
var version = "dev"
15
+
16
+
func main() {
17
+
// Handle interrupt signal
18
+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
19
+
defer cancel()
20
+
21
+
if err := cmd.Execute(ctx); err != nil {
22
+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
23
+
os.Exit(1)
24
+
}
25
+
}
+74
configs/talos-profiles.yaml
+74
configs/talos-profiles.yaml
···
1
+
# Talos Upgrade Configuration
2
+
# Node profiles and cluster topology for talos-upgrade tool
3
+
4
+
settings:
5
+
factory_base_url: "https://factory.talos.dev"
6
+
default_timeout_seconds: 600
7
+
default_preserve: true
8
+
github_releases_url: "https://api.github.com/repos/siderolabs/talos/releases/latest"
9
+
10
+
# Node profiles define the hardware configurations
11
+
profiles:
12
+
amd64:
13
+
description: "Intel GPU nodes with secureboot"
14
+
arch: amd64
15
+
platform: metal
16
+
secureboot: true
17
+
kernel_args:
18
+
- pci=realloc
19
+
- amd_iommu=off
20
+
extensions:
21
+
- siderolabs/amd-ucode
22
+
- siderolabs/amdgpu
23
+
- siderolabs/gasket-driver
24
+
- siderolabs/i915
25
+
- siderolabs/intel-ucode
26
+
- siderolabs/iscsi-tools
27
+
- siderolabs/nut-client
28
+
- siderolabs/realtek-firmware
29
+
- siderolabs/thunderbolt
30
+
- siderolabs/util-linux-tools
31
+
- siderolabs/xe
32
+
33
+
arm64-rpi:
34
+
description: "Raspberry Pi workers"
35
+
arch: arm64
36
+
platform: metal
37
+
secureboot: false
38
+
overlay:
39
+
name: rpi_generic
40
+
image: siderolabs/sbc-raspberrypi
41
+
extensions:
42
+
- siderolabs/iscsi-tools
43
+
- siderolabs/util-linux-tools
44
+
45
+
arm64-turing:
46
+
description: "Turing RK1 workers"
47
+
arch: arm64
48
+
platform: metal
49
+
secureboot: false
50
+
overlay:
51
+
name: turingrk1
52
+
image: siderolabs/sbc-rockchip
53
+
extensions:
54
+
- siderolabs/iscsi-tools
55
+
- siderolabs/util-linux-tools
56
+
57
+
# Detection rules for automatic profile matching
58
+
# Nodes are discovered via Talos cluster members API
59
+
# Profiles are detected based on hardware info
60
+
detection:
61
+
rules:
62
+
- profile: arm64-rpi
63
+
match:
64
+
system_manufacturer: raspberrypi
65
+
arch: arm64
66
+
67
+
- profile: arm64-turing
68
+
match:
69
+
system_manufacturer: turing
70
+
arch: arm64
71
+
72
+
- profile: amd64
73
+
match:
74
+
arch: amd64
+113
go.mod
+113
go.mod
···
1
+
module github.com/evanjarrett/homelab
2
+
3
+
go 1.25.5
4
+
5
+
require (
6
+
github.com/cosi-project/runtime v1.13.0
7
+
github.com/fatih/color v1.18.0
8
+
github.com/siderolabs/talos/pkg/machinery v1.12.0
9
+
github.com/spf13/cobra v1.10.2
10
+
github.com/stretchr/testify v1.11.1
11
+
gopkg.in/yaml.v3 v3.0.1
12
+
k8s.io/api v0.35.0
13
+
k8s.io/apimachinery v0.35.0
14
+
k8s.io/client-go v0.35.0
15
+
)
16
+
17
+
require (
18
+
cel.dev/expr v0.24.0 // indirect
19
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
20
+
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
21
+
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
22
+
github.com/adrg/xdg v0.5.3 // indirect
23
+
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
24
+
github.com/blang/semver/v4 v4.0.0 // indirect
25
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
26
+
github.com/cloudflare/circl v1.6.2 // indirect
27
+
github.com/containerd/go-cni v1.1.13 // indirect
28
+
github.com/containernetworking/cni v1.3.0 // indirect
29
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
30
+
github.com/dustin/go-humanize v1.0.1 // indirect
31
+
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
32
+
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
33
+
github.com/gertd/go-pluralize v0.2.1 // indirect
34
+
github.com/go-logr/logr v1.4.3 // indirect
35
+
github.com/go-openapi/jsonpointer v0.22.4 // indirect
36
+
github.com/go-openapi/jsonreference v0.21.4 // indirect
37
+
github.com/go-openapi/swag v0.25.4 // indirect
38
+
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
39
+
github.com/go-openapi/swag/conv v0.25.4 // indirect
40
+
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
41
+
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
42
+
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
43
+
github.com/go-openapi/swag/loading v0.25.4 // indirect
44
+
github.com/go-openapi/swag/mangling v0.25.4 // indirect
45
+
github.com/go-openapi/swag/netutils v0.25.4 // indirect
46
+
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
47
+
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
48
+
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
49
+
github.com/google/cel-go v0.26.1 // indirect
50
+
github.com/google/gnostic-models v0.7.1 // indirect
51
+
github.com/google/go-cmp v0.7.0 // indirect
52
+
github.com/google/uuid v1.6.0 // indirect
53
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
54
+
github.com/hashicorp/errwrap v1.1.0 // indirect
55
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
56
+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
57
+
github.com/josharian/native v1.1.0 // indirect
58
+
github.com/jsimonetti/rtnetlink/v2 v2.1.0 // indirect
59
+
github.com/json-iterator/go v1.1.12 // indirect
60
+
github.com/mattn/go-colorable v0.1.14 // indirect
61
+
github.com/mattn/go-isatty v0.0.20 // indirect
62
+
github.com/mdlayher/ethtool v0.4.0 // indirect
63
+
github.com/mdlayher/genetlink v1.3.2 // indirect
64
+
github.com/mdlayher/netlink v1.8.0 // indirect
65
+
github.com/mdlayher/socket v0.5.1 // indirect
66
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
67
+
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
68
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
69
+
github.com/opencontainers/runtime-spec v1.3.0 // indirect
70
+
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
71
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
72
+
github.com/pkg/errors v0.9.1 // indirect
73
+
github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2 // indirect
74
+
github.com/pmezard/go-difflib v1.0.0 // indirect
75
+
github.com/ryanuber/go-glob v1.0.0 // indirect
76
+
github.com/sasha-s/go-deadlock v0.3.6 // indirect
77
+
github.com/siderolabs/crypto v0.6.4 // indirect
78
+
github.com/siderolabs/gen v0.8.6 // indirect
79
+
github.com/siderolabs/go-api-signature v0.3.12 // indirect
80
+
github.com/siderolabs/go-pointer v1.0.1 // indirect
81
+
github.com/siderolabs/net v0.4.0 // indirect
82
+
github.com/siderolabs/protoenc v0.2.4 // indirect
83
+
github.com/spf13/pflag v1.0.10 // indirect
84
+
github.com/stoewer/go-strcase v1.3.0 // indirect
85
+
github.com/x448/float16 v0.8.4 // indirect
86
+
go.uber.org/multierr v1.11.0 // indirect
87
+
go.uber.org/zap v1.27.1 // indirect
88
+
go.yaml.in/yaml/v2 v2.4.3 // indirect
89
+
go.yaml.in/yaml/v3 v3.0.4 // indirect
90
+
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
91
+
golang.org/x/crypto v0.46.0 // indirect
92
+
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
93
+
golang.org/x/net v0.48.0 // indirect
94
+
golang.org/x/oauth2 v0.34.0 // indirect
95
+
golang.org/x/sync v0.19.0 // indirect
96
+
golang.org/x/sys v0.39.0 // indirect
97
+
golang.org/x/term v0.38.0 // indirect
98
+
golang.org/x/text v0.32.0 // indirect
99
+
golang.org/x/time v0.14.0 // indirect
100
+
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
101
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
102
+
google.golang.org/grpc v1.78.0 // indirect
103
+
google.golang.org/protobuf v1.36.11 // indirect
104
+
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
105
+
gopkg.in/inf.v0 v0.9.1 // indirect
106
+
k8s.io/klog/v2 v2.130.1 // indirect
107
+
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
108
+
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 // indirect
109
+
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
110
+
sigs.k8s.io/randfill v1.0.0 // indirect
111
+
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
112
+
sigs.k8s.io/yaml v1.6.0 // indirect
113
+
)
+326
go.sum
+326
go.sum
···
1
+
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
2
+
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
3
+
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
4
+
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
5
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
6
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
7
+
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
8
+
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
9
+
github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE=
10
+
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
11
+
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
12
+
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
13
+
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
14
+
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
15
+
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
16
+
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
17
+
github.com/brianvoe/gofakeit/v7 v7.7.3 h1:RWOATEGpJ5EVg2nN8nlaEyaV/aB4d6c3GqYrbqQekss=
18
+
github.com/brianvoe/gofakeit/v7 v7.7.3/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
19
+
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
20
+
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
21
+
github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao=
22
+
github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY=
23
+
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
24
+
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
25
+
github.com/containerd/go-cni v1.1.13 h1:eFSGOKlhoYNxpJ51KRIMHZNlg5UgocXEIEBGkY7Hnis=
26
+
github.com/containerd/go-cni v1.1.13/go.mod h1:nTieub0XDRmvCZ9VI/SBG6PyqT95N4FIhxsauF1vSBI=
27
+
github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo=
28
+
github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4=
29
+
github.com/cosi-project/runtime v1.13.0 h1:EKy/GwhVTgq131w0g3pbB0bTEf6FiZFjbK6go/I0pmE=
30
+
github.com/cosi-project/runtime v1.13.0/go.mod h1:/9fspODJfZrO5dQatMRgN440K8DjWP1jFSgiLX+FmQc=
31
+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
32
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
34
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
35
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
36
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
37
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
38
+
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
39
+
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
40
+
github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
41
+
github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
42
+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
43
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
44
+
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
45
+
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
46
+
github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
47
+
github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
48
+
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
49
+
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
50
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
51
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
52
+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
53
+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
54
+
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
55
+
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
56
+
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
57
+
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
58
+
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
59
+
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
60
+
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
61
+
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
62
+
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
63
+
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
64
+
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
65
+
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
66
+
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
67
+
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
68
+
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
69
+
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
70
+
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
71
+
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
72
+
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
73
+
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
74
+
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
75
+
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
76
+
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
77
+
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
78
+
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
79
+
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
80
+
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
81
+
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
82
+
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
83
+
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
84
+
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
85
+
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
86
+
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
87
+
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
88
+
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
89
+
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
90
+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
91
+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
92
+
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
93
+
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
94
+
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
95
+
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
96
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
97
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
98
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
99
+
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
100
+
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
101
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
102
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
103
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
104
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
105
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
106
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
107
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
108
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
109
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
110
+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
111
+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
112
+
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
113
+
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
114
+
github.com/jsimonetti/rtnetlink/v2 v2.1.0 h1:3sSPD0k+Qvia3wbv6kZXCN0Dlz6Swv7RHjvvonuOcKE=
115
+
github.com/jsimonetti/rtnetlink/v2 v2.1.0/go.mod h1:hPPUTE+ekH3HD+zCEGAGLxzFY9HrJCyD1aN7JJ3SHIY=
116
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
117
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
118
+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
119
+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
120
+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
121
+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
122
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
123
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
124
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
125
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
126
+
github.com/mdlayher/ethtool v0.4.0 h1:jjMGNSQfqauwFCtSzcqpa57R0AJdxKdQgbQ9mAOtM4Q=
127
+
github.com/mdlayher/ethtool v0.4.0/go.mod h1:GrljOneAFOTPGazYlf8qpxvYLdu4mo3pdJqXWLZ2Re8=
128
+
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
129
+
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
130
+
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
131
+
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
132
+
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
133
+
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
134
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
135
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
136
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
137
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
138
+
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
139
+
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
140
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
141
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
142
+
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
143
+
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
144
+
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
145
+
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
146
+
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
147
+
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
148
+
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
149
+
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
150
+
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
151
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
152
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
153
+
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
154
+
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
155
+
github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2 h1:1sLMdKq4gNANTj0dUibycTLzpIEKVnLnbaEkxws78nw=
156
+
github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
157
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
158
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
159
+
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
160
+
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
161
+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
162
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
163
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
164
+
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
165
+
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
166
+
github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw=
167
+
github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo=
168
+
github.com/siderolabs/crypto v0.6.4 h1:uMoe/X/mABOv6yOgvKcjmjIMdv6U8JegBXlPKtyjn3g=
169
+
github.com/siderolabs/crypto v0.6.4/go.mod h1:39B7Mdrd8qTfEYOjsWPQOk7gLTWrEI30isAW+YYj9nk=
170
+
github.com/siderolabs/gen v0.8.6 h1:pE6shuqov3L+5rEcAUJ/kY6iJofimljQw5G95P8a5c4=
171
+
github.com/siderolabs/gen v0.8.6/go.mod h1:J9IbusbES2W6QWjtSHpDV9iPGZHc978h1+KJ4oQRspQ=
172
+
github.com/siderolabs/go-api-signature v0.3.12 h1:i1X+kPh9fzo+lEjtEplZSbtq1p21vKv4FCWJcB/ozvk=
173
+
github.com/siderolabs/go-api-signature v0.3.12/go.mod h1:dPLiXohup4qHX7KUgF/wwOE3lRU5uAr3ssEomNxiyxY=
174
+
github.com/siderolabs/go-pointer v1.0.1 h1:f7Yi4IK1jptS8yrT9GEbwhmGcVxvPQgBUG/weH3V3DM=
175
+
github.com/siderolabs/go-pointer v1.0.1/go.mod h1:C8Q/3pNHT4RE9e4rYR9PHeS6KPMlStRBgYrJQJNy/vA=
176
+
github.com/siderolabs/go-retry v0.3.3 h1:zKV+S1vumtO72E6sYsLlmIdV/G/GcYSBLiEx/c9oCEg=
177
+
github.com/siderolabs/go-retry v0.3.3/go.mod h1:Ff/VGc7v7un4uQg3DybgrmOWHEmJ8BzZds/XNn/BqMI=
178
+
github.com/siderolabs/net v0.4.0 h1:1bOgVay/ijPkJz4qct98nHsiB/ysLQU0KLoBC4qLm7I=
179
+
github.com/siderolabs/net v0.4.0/go.mod h1:/ibG+Hm9HU27agp5r9Q3eZicEfjquzNzQNux5uEk0kM=
180
+
github.com/siderolabs/protoenc v0.2.4 h1:D3Fpn2nQSQOhl8ZlAxijZAf7K6F8CM1uZq0afIGsr8Q=
181
+
github.com/siderolabs/protoenc v0.2.4/go.mod h1:i5XLHjfv5vyi7LhQrSEo19HCA+lYtDd7CWxsoWp9XE8=
182
+
github.com/siderolabs/talos/pkg/machinery v1.12.0 h1:SDXXCTEuPhaXIA84WuBeG8cxR71DkF8FhXXgb2S1048=
183
+
github.com/siderolabs/talos/pkg/machinery v1.12.0/go.mod h1:dNc4lG9yb2CzCwnJbfSUO9ZmkXE6P3BnVo1UsCITr/U=
184
+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
185
+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
186
+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
187
+
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
188
+
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
189
+
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
190
+
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
191
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
192
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
193
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
194
+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
195
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
196
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
197
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
198
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
199
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
200
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
201
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
202
+
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
203
+
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
204
+
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
205
+
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
206
+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
207
+
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
208
+
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
209
+
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
210
+
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
211
+
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
212
+
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
213
+
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
214
+
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
215
+
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
216
+
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
217
+
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
218
+
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
219
+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
220
+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
221
+
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
222
+
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
223
+
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
224
+
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
225
+
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
226
+
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
227
+
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
228
+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
229
+
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
230
+
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
231
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
232
+
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
233
+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
234
+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
235
+
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
236
+
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
237
+
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
238
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
239
+
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
240
+
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
241
+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
242
+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
243
+
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
244
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
245
+
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
246
+
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
247
+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
248
+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
249
+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
250
+
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
251
+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
252
+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
253
+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
254
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
255
+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
256
+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
257
+
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
258
+
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
259
+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
260
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
261
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
262
+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
263
+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
264
+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
265
+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
266
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
267
+
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
268
+
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
269
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
270
+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
271
+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
272
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
273
+
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
274
+
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
275
+
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
276
+
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
277
+
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
278
+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
279
+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
280
+
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
281
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
282
+
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
283
+
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
284
+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
285
+
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
286
+
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
287
+
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
288
+
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
289
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
290
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
291
+
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
292
+
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
293
+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
294
+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
295
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
296
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
297
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
298
+
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
299
+
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
300
+
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
301
+
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
302
+
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
303
+
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
304
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
305
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
306
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
307
+
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
308
+
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
309
+
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
310
+
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
311
+
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
312
+
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
313
+
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
314
+
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
315
+
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
316
+
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
317
+
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE=
318
+
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
319
+
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
320
+
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
321
+
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
322
+
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
323
+
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
324
+
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
325
+
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
326
+
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+90
internal/cmd/images.go
+90
internal/cmd/images.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"fmt"
5
+
"sort"
6
+
7
+
"github.com/evanjarrett/homelab/internal/factory"
8
+
"github.com/evanjarrett/homelab/internal/output"
9
+
"github.com/spf13/cobra"
10
+
)
11
+
12
+
func imagesCmd() *cobra.Command {
13
+
return &cobra.Command{
14
+
Use: "images [version]",
15
+
Short: "Generate installer image URLs for each profile",
16
+
Long: `Generate installer image URLs by posting schematics to the Talos Factory API.
17
+
These URLs can be used with 'talosctl upgrade --image <URL>'.`,
18
+
Args: cobra.MaximumNArgs(1),
19
+
PreRunE: func(cmd *cobra.Command, args []string) error {
20
+
return loadConfig()
21
+
},
22
+
RunE: func(cmd *cobra.Command, args []string) error {
23
+
// Get version from args or flags
24
+
version := ""
25
+
if len(args) > 0 {
26
+
version = args[0]
27
+
}
28
+
if version == "" {
29
+
var err error
30
+
version, err = getVersion()
31
+
if err != nil {
32
+
return err
33
+
}
34
+
}
35
+
36
+
return runImages(version)
37
+
},
38
+
}
39
+
}
40
+
41
+
func runImages(version string) error {
42
+
// Create factory client
43
+
factoryClient := factory.NewClient(cfg.Settings.FactoryBaseURL)
44
+
return runImagesWithClient(factoryClient, version)
45
+
}
46
+
47
+
// runImagesWithClient is the testable core of runImages
48
+
func runImagesWithClient(factoryClient factory.FactoryClientInterface, version string) error {
49
+
output.Header("Installer Images for Talos v%s", version)
50
+
fmt.Println()
51
+
fmt.Println("These are the installer image URLs for 'talosctl upgrade --image <URL>'")
52
+
fmt.Println()
53
+
54
+
// Sort profile names for consistent output
55
+
var profileNames []string
56
+
for name := range cfg.Profiles {
57
+
profileNames = append(profileNames, name)
58
+
}
59
+
sort.Strings(profileNames)
60
+
61
+
for _, name := range profileNames {
62
+
profile := cfg.Profiles[name]
63
+
64
+
output.SubHeader("Profile: %s", name)
65
+
66
+
// Get installer image from factory API
67
+
output.LogInfo("Fetching schematic ID from factory...")
68
+
image, err := factoryClient.GetInstallerImage(profile, version)
69
+
if err != nil {
70
+
output.LogError("Failed to get image for %s: %v", name, err)
71
+
fmt.Println()
72
+
continue
73
+
}
74
+
75
+
fmt.Printf(" %s\n", image)
76
+
fmt.Println()
77
+
78
+
// Print nodes using this profile
79
+
nodes := cfg.GetNodesByProfile(name)
80
+
if len(nodes) > 0 {
81
+
fmt.Println(" Nodes:")
82
+
for _, node := range nodes {
83
+
fmt.Printf(" - %s (%s)\n", node.IP, node.Role)
84
+
}
85
+
fmt.Println()
86
+
}
87
+
}
88
+
89
+
return nil
90
+
}
+124
internal/cmd/images_test.go
+124
internal/cmd/images_test.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"fmt"
5
+
"testing"
6
+
7
+
"github.com/evanjarrett/homelab/internal/config"
8
+
"github.com/evanjarrett/homelab/internal/factory"
9
+
"github.com/stretchr/testify/require"
10
+
)
11
+
12
+
// ============================================================================
13
+
// runImagesWithClient() Tests
14
+
// ============================================================================
15
+
16
+
func TestRunImagesWithClient_SingleProfile(t *testing.T) {
17
+
cfg = &config.Config{
18
+
Settings: config.Settings{
19
+
FactoryBaseURL: "https://factory.talos.dev",
20
+
},
21
+
Profiles: map[string]config.Profile{
22
+
"test-profile": {
23
+
Arch: "amd64",
24
+
Platform: "metal",
25
+
Extensions: []string{"siderolabs/i915"},
26
+
},
27
+
},
28
+
Nodes: []config.Node{
29
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
30
+
},
31
+
}
32
+
33
+
factoryMock := &factory.MockFactoryClient{
34
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
35
+
return fmt.Sprintf("factory.talos.dev/installer/abc123:v%s", version), nil
36
+
},
37
+
}
38
+
39
+
err := runImagesWithClient(factoryMock, "1.9.0")
40
+
require.NoError(t, err)
41
+
}
42
+
43
+
func TestRunImagesWithClient_MultipleProfiles(t *testing.T) {
44
+
cfg = &config.Config{
45
+
Settings: config.Settings{
46
+
FactoryBaseURL: "https://factory.talos.dev",
47
+
},
48
+
Profiles: map[string]config.Profile{
49
+
"zebra-profile": {Arch: "amd64", Platform: "metal"},
50
+
"alpha-profile": {Arch: "arm64", Platform: "metal"},
51
+
},
52
+
Nodes: []config.Node{},
53
+
}
54
+
55
+
callCount := 0
56
+
factoryMock := &factory.MockFactoryClient{
57
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
58
+
callCount++
59
+
return fmt.Sprintf("factory.talos.dev/installer/schematic%d:v%s", callCount, version), nil
60
+
},
61
+
}
62
+
63
+
err := runImagesWithClient(factoryMock, "1.9.0")
64
+
require.NoError(t, err)
65
+
require.Equal(t, 2, callCount, "should call factory for each profile")
66
+
}
67
+
68
+
func TestRunImagesWithClient_FactoryError(t *testing.T) {
69
+
cfg = &config.Config{
70
+
Settings: config.Settings{
71
+
FactoryBaseURL: "https://factory.talos.dev",
72
+
},
73
+
Profiles: map[string]config.Profile{
74
+
"test-profile": {Arch: "amd64", Platform: "metal"},
75
+
},
76
+
Nodes: []config.Node{},
77
+
}
78
+
79
+
factoryMock := &factory.MockFactoryClient{
80
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
81
+
return "", fmt.Errorf("factory API error")
82
+
},
83
+
}
84
+
85
+
// runImagesWithClient continues on error (logs but doesn't fail)
86
+
err := runImagesWithClient(factoryMock, "1.9.0")
87
+
require.NoError(t, err)
88
+
}
89
+
90
+
func TestRunImagesWithClient_EmptyProfiles(t *testing.T) {
91
+
cfg = &config.Config{
92
+
Settings: config.Settings{
93
+
FactoryBaseURL: "https://factory.talos.dev",
94
+
},
95
+
Profiles: map[string]config.Profile{},
96
+
Nodes: []config.Node{},
97
+
}
98
+
99
+
factoryMock := &factory.MockFactoryClient{}
100
+
101
+
err := runImagesWithClient(factoryMock, "1.9.0")
102
+
require.NoError(t, err)
103
+
}
104
+
105
+
func TestRunImagesWithClient_WithNodes(t *testing.T) {
106
+
cfg = &config.Config{
107
+
Settings: config.Settings{
108
+
FactoryBaseURL: "https://factory.talos.dev",
109
+
},
110
+
Profiles: map[string]config.Profile{
111
+
"test-profile": {Arch: "amd64", Platform: "metal"},
112
+
},
113
+
Nodes: []config.Node{
114
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleControlPlane},
115
+
{IP: "192.168.1.2", Profile: "test-profile", Role: config.RoleWorker},
116
+
{IP: "192.168.1.3", Profile: "test-profile", Role: config.RoleWorker},
117
+
},
118
+
}
119
+
120
+
factoryMock := &factory.MockFactoryClient{}
121
+
122
+
err := runImagesWithClient(factoryMock, "1.9.0")
123
+
require.NoError(t, err)
124
+
}
+118
internal/cmd/root.go
+118
internal/cmd/root.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"os"
6
+
7
+
"github.com/evanjarrett/homelab/internal/config"
8
+
"github.com/evanjarrett/homelab/internal/output"
9
+
"github.com/spf13/cobra"
10
+
)
11
+
12
+
var (
13
+
// Global flags
14
+
cfgFile string
15
+
dryRun bool
16
+
talosVersion string
17
+
preserve bool
18
+
19
+
// Loaded config (populated in PreRunE)
20
+
cfg *config.Config
21
+
22
+
// Injectable for testing
23
+
configLoader = config.Load
24
+
versionResolver = config.GetLatestTalosVersion
25
+
)
26
+
27
+
// rootCmd represents the base command
28
+
var rootCmd = &cobra.Command{
29
+
Use: "talos-upgrade",
30
+
Short: "Talos cluster upgrade tool",
31
+
Long: `A CLI tool for managing Talos Linux cluster upgrades with profile-based configurations.
32
+
33
+
This tool supports:
34
+
- Multiple node profiles with different architectures and extensions
35
+
- Generating factory URLs for browser access
36
+
- Generating installer image URLs
37
+
- Upgrading nodes individually or in groups
38
+
- Dry-run mode for testing`,
39
+
SilenceUsage: true,
40
+
SilenceErrors: true,
41
+
}
42
+
43
+
// Execute runs the root command
44
+
func Execute(ctx context.Context) error {
45
+
return rootCmd.ExecuteContext(ctx)
46
+
}
47
+
48
+
func init() {
49
+
// Config file flag
50
+
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "",
51
+
"config file (default: configs/talos-profiles.yaml)")
52
+
53
+
// Dry run flag
54
+
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false,
55
+
"perform a dry run without making changes")
56
+
57
+
// Version flag (not the built-in --version, this is the Talos version)
58
+
rootCmd.PersistentFlags().StringVarP(&talosVersion, "talos-version", "V", "",
59
+
"Talos version to upgrade to (default: fetch from GitHub)")
60
+
61
+
// Preserve flag
62
+
rootCmd.PersistentFlags().BoolVar(&preserve, "preserve", true,
63
+
"preserve ephemeral data during upgrade")
64
+
65
+
// Check environment variables
66
+
if os.Getenv("DRY_RUN") == "true" {
67
+
dryRun = true
68
+
}
69
+
if v := os.Getenv("TALOS_VERSION"); v != "" && talosVersion == "" {
70
+
talosVersion = v
71
+
}
72
+
if os.Getenv("PRESERVE") == "false" {
73
+
preserve = false
74
+
}
75
+
76
+
// Add subcommands
77
+
rootCmd.AddCommand(statusCmd())
78
+
rootCmd.AddCommand(urlsCmd())
79
+
rootCmd.AddCommand(imagesCmd())
80
+
rootCmd.AddCommand(upgradeCmd())
81
+
rootCmd.AddCommand(upgradeNodeCmd())
82
+
}
83
+
84
+
// loadConfig loads the configuration file
85
+
func loadConfig() error {
86
+
return loadConfigWithLoader(configLoader)
87
+
}
88
+
89
+
// loadConfigWithLoader is the testable core of loadConfig
90
+
func loadConfigWithLoader(loader func(string) (*config.Config, error)) error {
91
+
var err error
92
+
cfg, err = loader(cfgFile)
93
+
if err != nil {
94
+
return err
95
+
}
96
+
cfg.SetDefaults()
97
+
return nil
98
+
}
99
+
100
+
// getVersion returns the Talos version to use
101
+
func getVersion() (string, error) {
102
+
return getVersionWithResolver(versionResolver)
103
+
}
104
+
105
+
// getVersionWithResolver is the testable core of getVersion
106
+
func getVersionWithResolver(resolver func(string) (string, error)) (string, error) {
107
+
if talosVersion != "" {
108
+
return talosVersion, nil
109
+
}
110
+
111
+
// Fetch from GitHub
112
+
version, err := resolver(cfg.Settings.GithubReleasesURL)
113
+
if err != nil {
114
+
output.LogWarn("Failed to fetch latest version: %v, using fallback", err)
115
+
return "1.9.5", nil // Fallback version
116
+
}
117
+
return version, nil
118
+
}
+198
internal/cmd/root_test.go
+198
internal/cmd/root_test.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"fmt"
5
+
"testing"
6
+
7
+
"github.com/evanjarrett/homelab/internal/config"
8
+
"github.com/stretchr/testify/assert"
9
+
"github.com/stretchr/testify/require"
10
+
)
11
+
12
+
// ============================================================================
13
+
// getVersionWithResolver() Tests
14
+
// ============================================================================
15
+
16
+
func TestGetVersionWithResolver_FlagSet(t *testing.T) {
17
+
// Save and restore global state
18
+
originalVersion := talosVersion
19
+
defer func() { talosVersion = originalVersion }()
20
+
21
+
talosVersion = "1.8.0"
22
+
23
+
// Resolver should not be called when flag is set
24
+
resolverCalled := false
25
+
mockResolver := func(url string) (string, error) {
26
+
resolverCalled = true
27
+
return "1.9.0", nil
28
+
}
29
+
30
+
version, err := getVersionWithResolver(mockResolver)
31
+
require.NoError(t, err)
32
+
assert.Equal(t, "1.8.0", version)
33
+
assert.False(t, resolverCalled, "resolver should not be called when flag is set")
34
+
}
35
+
36
+
func TestGetVersionWithResolver_APISuccess(t *testing.T) {
37
+
// Save and restore global state
38
+
originalVersion := talosVersion
39
+
originalCfg := cfg
40
+
defer func() {
41
+
talosVersion = originalVersion
42
+
cfg = originalCfg
43
+
}()
44
+
45
+
talosVersion = "" // No flag set
46
+
cfg = &config.Config{
47
+
Settings: config.Settings{
48
+
GithubReleasesURL: "https://api.github.com/repos/siderolabs/talos/releases/latest",
49
+
},
50
+
}
51
+
52
+
mockResolver := func(url string) (string, error) {
53
+
assert.Equal(t, "https://api.github.com/repos/siderolabs/talos/releases/latest", url)
54
+
return "1.9.1", nil
55
+
}
56
+
57
+
version, err := getVersionWithResolver(mockResolver)
58
+
require.NoError(t, err)
59
+
assert.Equal(t, "1.9.1", version)
60
+
}
61
+
62
+
func TestGetVersionWithResolver_APIError_ReturnsFallback(t *testing.T) {
63
+
// Save and restore global state
64
+
originalVersion := talosVersion
65
+
originalCfg := cfg
66
+
defer func() {
67
+
talosVersion = originalVersion
68
+
cfg = originalCfg
69
+
}()
70
+
71
+
talosVersion = "" // No flag set
72
+
cfg = &config.Config{
73
+
Settings: config.Settings{
74
+
GithubReleasesURL: "https://api.github.com/repos/siderolabs/talos/releases/latest",
75
+
},
76
+
}
77
+
78
+
mockResolver := func(url string) (string, error) {
79
+
return "", fmt.Errorf("network error: connection refused")
80
+
}
81
+
82
+
version, err := getVersionWithResolver(mockResolver)
83
+
require.NoError(t, err) // Should not return error, uses fallback
84
+
assert.Equal(t, "1.9.5", version)
85
+
}
86
+
87
+
func TestGetVersionWithResolver_EmptyURLConfig(t *testing.T) {
88
+
// Save and restore global state
89
+
originalVersion := talosVersion
90
+
originalCfg := cfg
91
+
defer func() {
92
+
talosVersion = originalVersion
93
+
cfg = originalCfg
94
+
}()
95
+
96
+
talosVersion = "" // No flag set
97
+
cfg = &config.Config{
98
+
Settings: config.Settings{
99
+
GithubReleasesURL: "", // Empty URL
100
+
},
101
+
}
102
+
103
+
mockResolver := func(url string) (string, error) {
104
+
assert.Equal(t, "", url) // Should pass empty URL
105
+
return "1.9.2", nil
106
+
}
107
+
108
+
version, err := getVersionWithResolver(mockResolver)
109
+
require.NoError(t, err)
110
+
assert.Equal(t, "1.9.2", version)
111
+
}
112
+
113
+
// ============================================================================
114
+
// loadConfigWithLoader() Tests
115
+
// ============================================================================
116
+
117
+
func TestLoadConfigWithLoader_Success(t *testing.T) {
118
+
// Save and restore global state
119
+
originalCfgFile := cfgFile
120
+
originalCfg := cfg
121
+
defer func() {
122
+
cfgFile = originalCfgFile
123
+
cfg = originalCfg
124
+
}()
125
+
126
+
cfgFile = "test-config.yaml"
127
+
cfg = nil
128
+
129
+
expectedConfig := &config.Config{
130
+
Settings: config.Settings{
131
+
FactoryBaseURL: "https://factory.talos.dev",
132
+
},
133
+
Profiles: map[string]config.Profile{
134
+
"test": {Arch: "amd64", Platform: "metal"},
135
+
},
136
+
}
137
+
138
+
mockLoader := func(path string) (*config.Config, error) {
139
+
assert.Equal(t, "test-config.yaml", path)
140
+
return expectedConfig, nil
141
+
}
142
+
143
+
err := loadConfigWithLoader(mockLoader)
144
+
require.NoError(t, err)
145
+
require.NotNil(t, cfg)
146
+
assert.Equal(t, "https://factory.talos.dev", cfg.Settings.FactoryBaseURL)
147
+
}
148
+
149
+
func TestLoadConfigWithLoader_Error(t *testing.T) {
150
+
// Save and restore global state
151
+
originalCfgFile := cfgFile
152
+
originalCfg := cfg
153
+
defer func() {
154
+
cfgFile = originalCfgFile
155
+
cfg = originalCfg
156
+
}()
157
+
158
+
cfgFile = "nonexistent.yaml"
159
+
cfg = nil
160
+
161
+
mockLoader := func(path string) (*config.Config, error) {
162
+
return nil, fmt.Errorf("config file not found: %s", path)
163
+
}
164
+
165
+
err := loadConfigWithLoader(mockLoader)
166
+
require.Error(t, err)
167
+
assert.Contains(t, err.Error(), "config file not found")
168
+
assert.Nil(t, cfg)
169
+
}
170
+
171
+
func TestLoadConfigWithLoader_DefaultsApplied(t *testing.T) {
172
+
// Save and restore global state
173
+
originalCfgFile := cfgFile
174
+
originalCfg := cfg
175
+
defer func() {
176
+
cfgFile = originalCfgFile
177
+
cfg = originalCfg
178
+
}()
179
+
180
+
cfgFile = "test-config.yaml"
181
+
cfg = nil
182
+
183
+
// Return a config with empty settings to verify SetDefaults is called
184
+
mockLoader := func(path string) (*config.Config, error) {
185
+
return &config.Config{
186
+
Settings: config.Settings{
187
+
// Empty - SetDefaults should fill these
188
+
},
189
+
Profiles: map[string]config.Profile{},
190
+
}, nil
191
+
}
192
+
193
+
err := loadConfigWithLoader(mockLoader)
194
+
require.NoError(t, err)
195
+
require.NotNil(t, cfg)
196
+
// SetDefaults should have been called
197
+
assert.NotEmpty(t, cfg.Settings.FactoryBaseURL, "SetDefaults should set FactoryBaseURL")
198
+
}
+191
internal/cmd/status.go
+191
internal/cmd/status.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"sort"
7
+
"sync"
8
+
9
+
"github.com/evanjarrett/homelab/internal/config"
10
+
"github.com/evanjarrett/homelab/internal/output"
11
+
"github.com/evanjarrett/homelab/internal/talos"
12
+
"github.com/spf13/cobra"
13
+
)
14
+
15
+
func statusCmd() *cobra.Command {
16
+
return &cobra.Command{
17
+
Use: "status",
18
+
Short: "Show current cluster status and node versions",
19
+
Long: `Display the current status of all nodes in the cluster, including versions and reachability.`,
20
+
PreRunE: func(cmd *cobra.Command, args []string) error {
21
+
return loadConfig()
22
+
},
23
+
RunE: func(cmd *cobra.Command, args []string) error {
24
+
return runStatus(cmd.Context())
25
+
},
26
+
}
27
+
}
28
+
29
+
func runStatus(ctx context.Context) error {
30
+
// Create Talos client
31
+
talosClient, err := talos.NewClient(ctx)
32
+
if err != nil {
33
+
return fmt.Errorf("failed to create Talos client: %w", err)
34
+
}
35
+
defer talosClient.Close()
36
+
37
+
return runStatusWithClient(ctx, talosClient)
38
+
}
39
+
40
+
// runStatusWithClient is the testable core of runStatus
41
+
func runStatusWithClient(ctx context.Context, client talos.TalosClientInterface) error {
42
+
output.Header("Talos Cluster Status")
43
+
fmt.Println()
44
+
45
+
// Get nodes - either from discovery or legacy config
46
+
nodes, err := getStatusNodes(ctx, client)
47
+
if err != nil {
48
+
return err
49
+
}
50
+
51
+
// Collect statuses in parallel
52
+
var (
53
+
statuses []talos.NodeStatus
54
+
mu sync.Mutex
55
+
wg sync.WaitGroup
56
+
)
57
+
58
+
for _, node := range nodes {
59
+
wg.Add(1)
60
+
go func(node statusNode) {
61
+
defer wg.Done()
62
+
63
+
status := client.GetNodeStatus(ctx, node.IP, node.Profile, node.Role, node.Secureboot)
64
+
65
+
mu.Lock()
66
+
statuses = append(statuses, status)
67
+
mu.Unlock()
68
+
}(node)
69
+
}
70
+
71
+
wg.Wait()
72
+
73
+
// Sort by IP (last octet)
74
+
sort.Slice(statuses, func(i, j int) bool {
75
+
return statuses[i].IP < statuses[j].IP
76
+
})
77
+
78
+
// Print table
79
+
tw := output.NewTabWriter()
80
+
fmt.Fprintf(tw, "NODE\tTYPE\tPROFILE\tVERSION\tSECBOOT\tSTATUS\n")
81
+
fmt.Fprintf(tw, "----\t----\t-------\t-------\t-------\t------\n")
82
+
83
+
for _, s := range statuses {
84
+
statusStr := "OK"
85
+
if !s.Reachable {
86
+
statusStr = "UNREACHABLE"
87
+
}
88
+
89
+
secbootStr := "no"
90
+
if s.Secureboot {
91
+
secbootStr = "yes"
92
+
}
93
+
94
+
statusColor := output.StatusColor(statusStr)
95
+
roleColor := output.RoleColor(s.Role)
96
+
97
+
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
98
+
s.IP,
99
+
roleColor(s.Role),
100
+
s.Profile,
101
+
s.Version,
102
+
secbootStr,
103
+
statusColor(statusStr),
104
+
)
105
+
}
106
+
107
+
tw.Flush()
108
+
return nil
109
+
}
110
+
111
+
// statusNode represents a node for status display
112
+
type statusNode struct {
113
+
IP string
114
+
Profile string
115
+
Role string
116
+
Secureboot bool
117
+
}
118
+
119
+
// getStatusNodes returns nodes to check, using discovery if detection is configured
120
+
func getStatusNodes(ctx context.Context, client talos.TalosClientInterface) ([]statusNode, error) {
121
+
// If detection is configured, use discovery
122
+
if cfg.HasDetection() {
123
+
return discoverNodes(ctx, client)
124
+
}
125
+
126
+
// Fall back to legacy config nodes
127
+
var nodes []statusNode
128
+
for _, node := range cfg.Nodes {
129
+
profile := cfg.Profiles[node.Profile]
130
+
nodes = append(nodes, statusNode{
131
+
IP: node.IP,
132
+
Profile: node.Profile,
133
+
Role: node.Role,
134
+
Secureboot: profile.Secureboot,
135
+
})
136
+
}
137
+
return nodes, nil
138
+
}
139
+
140
+
// discoverNodes uses Talos API to discover nodes and detect their profiles
141
+
func discoverNodes(ctx context.Context, client talos.TalosClientInterface) ([]statusNode, error) {
142
+
// Get cluster members
143
+
members, err := client.GetClusterMembers(ctx)
144
+
if err != nil {
145
+
return nil, fmt.Errorf("failed to discover cluster members: %w", err)
146
+
}
147
+
148
+
var nodes []statusNode
149
+
for _, member := range members {
150
+
// Get hardware info for profile detection
151
+
hwInfo, err := client.GetHardwareInfo(ctx, member.IP)
152
+
if err != nil {
153
+
// If we can't get hardware info, use unknown profile
154
+
nodes = append(nodes, statusNode{
155
+
IP: member.IP,
156
+
Profile: "unknown",
157
+
Role: member.Role,
158
+
Secureboot: false,
159
+
})
160
+
continue
161
+
}
162
+
163
+
// Convert to config.HardwareInfo for detection
164
+
cfgHwInfo := &config.HardwareInfo{
165
+
SystemManufacturer: hwInfo.SystemManufacturer,
166
+
SystemProductName: hwInfo.SystemProductName,
167
+
ProcessorManufacturer: hwInfo.ProcessorManufacturer,
168
+
ProcessorProductName: hwInfo.ProcessorProductName,
169
+
}
170
+
171
+
// Detect profile
172
+
profileName, profile := cfg.DetectProfile(cfgHwInfo)
173
+
if profile == nil {
174
+
profileName = "unknown"
175
+
}
176
+
177
+
secureboot := false
178
+
if profile != nil {
179
+
secureboot = profile.Secureboot
180
+
}
181
+
182
+
nodes = append(nodes, statusNode{
183
+
IP: member.IP,
184
+
Profile: profileName,
185
+
Role: member.Role,
186
+
Secureboot: secureboot,
187
+
})
188
+
}
189
+
190
+
return nodes, nil
191
+
}
+189
internal/cmd/status_test.go
+189
internal/cmd/status_test.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"sync"
6
+
"testing"
7
+
8
+
"github.com/evanjarrett/homelab/internal/config"
9
+
"github.com/evanjarrett/homelab/internal/talos"
10
+
"github.com/stretchr/testify/assert"
11
+
"github.com/stretchr/testify/require"
12
+
)
13
+
14
+
// ============================================================================
15
+
// runStatusWithClient() Tests
16
+
// ============================================================================
17
+
18
+
func TestRunStatusWithClient_AllNodesReachable(t *testing.T) {
19
+
cfg = &config.Config{
20
+
Settings: config.Settings{
21
+
FactoryBaseURL: "https://factory.talos.dev",
22
+
DefaultTimeoutSeconds: 600,
23
+
},
24
+
Profiles: map[string]config.Profile{
25
+
"profile-a": {Arch: "amd64", Platform: "metal", Secureboot: false},
26
+
},
27
+
Nodes: []config.Node{
28
+
{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleControlPlane},
29
+
{IP: "192.168.1.2", Profile: "profile-a", Role: config.RoleWorker},
30
+
},
31
+
}
32
+
33
+
mock := &talos.MockClient{
34
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
35
+
return talos.NodeStatus{
36
+
IP: nodeIP,
37
+
Profile: profile,
38
+
Role: role,
39
+
Version: "1.9.0",
40
+
Reachable: true,
41
+
Secureboot: secureboot,
42
+
}
43
+
},
44
+
}
45
+
46
+
err := runStatusWithClient(context.Background(), mock)
47
+
require.NoError(t, err)
48
+
}
49
+
50
+
func TestRunStatusWithClient_SomeNodesUnreachable(t *testing.T) {
51
+
cfg = &config.Config{
52
+
Settings: config.Settings{
53
+
FactoryBaseURL: "https://factory.talos.dev",
54
+
DefaultTimeoutSeconds: 600,
55
+
},
56
+
Profiles: map[string]config.Profile{
57
+
"profile-a": {Arch: "amd64", Platform: "metal", Secureboot: false},
58
+
},
59
+
Nodes: []config.Node{
60
+
{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleControlPlane},
61
+
{IP: "192.168.1.2", Profile: "profile-a", Role: config.RoleWorker},
62
+
},
63
+
}
64
+
65
+
mock := &talos.MockClient{
66
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
67
+
reachable := nodeIP == "192.168.1.1" // Only first node is reachable
68
+
version := "1.9.0"
69
+
if !reachable {
70
+
version = "N/A"
71
+
}
72
+
return talos.NodeStatus{
73
+
IP: nodeIP,
74
+
Profile: profile,
75
+
Role: role,
76
+
Version: version,
77
+
Reachable: reachable,
78
+
Secureboot: secureboot,
79
+
}
80
+
},
81
+
}
82
+
83
+
err := runStatusWithClient(context.Background(), mock)
84
+
require.NoError(t, err)
85
+
}
86
+
87
+
func TestRunStatusWithClient_MixedRoles(t *testing.T) {
88
+
cfg = &config.Config{
89
+
Settings: config.Settings{
90
+
FactoryBaseURL: "https://factory.talos.dev",
91
+
DefaultTimeoutSeconds: 600,
92
+
},
93
+
Profiles: map[string]config.Profile{
94
+
"profile-a": {Arch: "amd64", Platform: "metal", Secureboot: false},
95
+
"profile-b": {Arch: "arm64", Platform: "metal", Secureboot: true},
96
+
},
97
+
Nodes: []config.Node{
98
+
{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleControlPlane},
99
+
{IP: "192.168.1.2", Profile: "profile-a", Role: config.RoleWorker},
100
+
{IP: "192.168.1.3", Profile: "profile-b", Role: config.RoleControlPlane},
101
+
{IP: "192.168.1.4", Profile: "profile-b", Role: config.RoleWorker},
102
+
},
103
+
}
104
+
105
+
var mu sync.Mutex
106
+
calledWith := make(map[string]bool)
107
+
mock := &talos.MockClient{
108
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
109
+
mu.Lock()
110
+
calledWith[nodeIP] = true
111
+
mu.Unlock()
112
+
return talos.NodeStatus{
113
+
IP: nodeIP,
114
+
Profile: profile,
115
+
Role: role,
116
+
Version: "1.9.0",
117
+
Reachable: true,
118
+
Secureboot: secureboot,
119
+
}
120
+
},
121
+
}
122
+
123
+
err := runStatusWithClient(context.Background(), mock)
124
+
require.NoError(t, err)
125
+
126
+
// All nodes should have been queried
127
+
assert.True(t, calledWith["192.168.1.1"])
128
+
assert.True(t, calledWith["192.168.1.2"])
129
+
assert.True(t, calledWith["192.168.1.3"])
130
+
assert.True(t, calledWith["192.168.1.4"])
131
+
}
132
+
133
+
func TestRunStatusWithClient_SecurebootIndicators(t *testing.T) {
134
+
cfg = &config.Config{
135
+
Settings: config.Settings{
136
+
FactoryBaseURL: "https://factory.talos.dev",
137
+
DefaultTimeoutSeconds: 600,
138
+
},
139
+
Profiles: map[string]config.Profile{
140
+
"secureboot": {Arch: "amd64", Platform: "metal", Secureboot: true},
141
+
"no-secureboot": {Arch: "amd64", Platform: "metal", Secureboot: false},
142
+
},
143
+
Nodes: []config.Node{
144
+
{IP: "192.168.1.1", Profile: "secureboot", Role: config.RoleControlPlane},
145
+
{IP: "192.168.1.2", Profile: "no-secureboot", Role: config.RoleWorker},
146
+
},
147
+
}
148
+
149
+
var mu sync.Mutex
150
+
securebootNodes := make(map[string]bool)
151
+
mock := &talos.MockClient{
152
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
153
+
mu.Lock()
154
+
securebootNodes[nodeIP] = secureboot
155
+
mu.Unlock()
156
+
return talos.NodeStatus{
157
+
IP: nodeIP,
158
+
Profile: profile,
159
+
Role: role,
160
+
Version: "1.9.0",
161
+
Reachable: true,
162
+
Secureboot: secureboot,
163
+
}
164
+
},
165
+
}
166
+
167
+
err := runStatusWithClient(context.Background(), mock)
168
+
require.NoError(t, err)
169
+
170
+
// Verify correct secureboot values were passed
171
+
assert.True(t, securebootNodes["192.168.1.1"], "secureboot profile should have secureboot=true")
172
+
assert.False(t, securebootNodes["192.168.1.2"], "no-secureboot profile should have secureboot=false")
173
+
}
174
+
175
+
func TestRunStatusWithClient_EmptyConfig(t *testing.T) {
176
+
cfg = &config.Config{
177
+
Settings: config.Settings{
178
+
FactoryBaseURL: "https://factory.talos.dev",
179
+
DefaultTimeoutSeconds: 600,
180
+
},
181
+
Profiles: map[string]config.Profile{},
182
+
Nodes: []config.Node{},
183
+
}
184
+
185
+
mock := &talos.MockClient{}
186
+
187
+
err := runStatusWithClient(context.Background(), mock)
188
+
require.NoError(t, err)
189
+
}
+599
internal/cmd/upgrade.go
+599
internal/cmd/upgrade.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"bufio"
5
+
"context"
6
+
"fmt"
7
+
"io"
8
+
"os"
9
+
"sort"
10
+
"strings"
11
+
"time"
12
+
13
+
"github.com/evanjarrett/homelab/internal/config"
14
+
"github.com/evanjarrett/homelab/internal/factory"
15
+
"github.com/evanjarrett/homelab/internal/output"
16
+
"github.com/evanjarrett/homelab/internal/talos"
17
+
"github.com/spf13/cobra"
18
+
)
19
+
20
+
// internalExtensions are system-level extensions that should be ignored during comparison
21
+
var internalExtensions = map[string]bool{
22
+
"schematic": true, // Virtual extension from Image Factory
23
+
"modules.dep": true, // Combined modules.dep for all extensions
24
+
}
25
+
26
+
// UpgradeRequest contains all information needed to upgrade a node
27
+
type UpgradeRequest struct {
28
+
Node config.Node
29
+
Image string // Full installer image URL
30
+
Version string // Target Talos version
31
+
ExpectedExtensions []string // Extensions from profile
32
+
ExpectedKernelArgs []string // Kernel args from profile
33
+
}
34
+
35
+
// extensionsDiffer compares running extensions with expected extensions from the profile.
36
+
// Returns true if there's a difference (upgrade needed), false otherwise.
37
+
// Also returns a description of the difference.
38
+
func extensionsDiffer(running []talos.ExtensionInfo, expected []string) (bool, string) {
39
+
// Build a set of running extension names (excluding internal extensions)
40
+
runningNames := make(map[string]bool)
41
+
for _, ext := range running {
42
+
if !internalExtensions[ext.Name] {
43
+
runningNames[ext.Name] = true
44
+
}
45
+
}
46
+
47
+
// Build a set of expected extension names (strip siderolabs/ prefix)
48
+
expectedNames := make(map[string]bool)
49
+
for _, ext := range expected {
50
+
name := ext
51
+
// Strip vendor prefix (e.g., "siderolabs/gasket-driver" -> "gasket-driver")
52
+
if idx := strings.LastIndex(ext, "/"); idx >= 0 {
53
+
name = ext[idx+1:]
54
+
}
55
+
expectedNames[name] = true
56
+
}
57
+
58
+
// Find missing (expected but not running)
59
+
var missing []string
60
+
for name := range expectedNames {
61
+
if !runningNames[name] {
62
+
missing = append(missing, name)
63
+
}
64
+
}
65
+
66
+
// Find extra (running but not expected)
67
+
var extra []string
68
+
for name := range runningNames {
69
+
if !expectedNames[name] {
70
+
extra = append(extra, name)
71
+
}
72
+
}
73
+
74
+
if len(missing) == 0 && len(extra) == 0 {
75
+
return false, ""
76
+
}
77
+
78
+
var parts []string
79
+
if len(missing) > 0 {
80
+
sort.Strings(missing)
81
+
parts = append(parts, fmt.Sprintf("missing: %s", strings.Join(missing, ", ")))
82
+
}
83
+
if len(extra) > 0 {
84
+
sort.Strings(extra)
85
+
parts = append(parts, fmt.Sprintf("extra: %s", strings.Join(extra, ", ")))
86
+
}
87
+
88
+
return true, strings.Join(parts, "; ")
89
+
}
90
+
91
+
// kernelArgsDiffer compares running kernel cmdline with expected kernel args from the profile.
92
+
// Returns true if there's a difference (upgrade needed), false otherwise.
93
+
// Also returns a description of the difference.
94
+
func kernelArgsDiffer(cmdline string, expected []string) (bool, string) {
95
+
if len(expected) == 0 {
96
+
return false, ""
97
+
}
98
+
99
+
// Split cmdline into individual args
100
+
cmdlineArgs := strings.Fields(cmdline)
101
+
cmdlineSet := make(map[string]bool)
102
+
for _, arg := range cmdlineArgs {
103
+
cmdlineSet[arg] = true
104
+
}
105
+
106
+
// Find missing kernel args
107
+
var missing []string
108
+
for _, arg := range expected {
109
+
if !cmdlineSet[arg] {
110
+
missing = append(missing, arg)
111
+
}
112
+
}
113
+
114
+
if len(missing) == 0 {
115
+
return false, ""
116
+
}
117
+
118
+
sort.Strings(missing)
119
+
return true, fmt.Sprintf("missing kernel args: %s", strings.Join(missing, ", "))
120
+
}
121
+
122
+
func upgradeCmd() *cobra.Command {
123
+
return &cobra.Command{
124
+
Use: "upgrade [target] [version]",
125
+
Short: "Upgrade nodes to specified version",
126
+
Long: `Upgrade nodes to specified version.
127
+
128
+
Target can be:
129
+
all - All nodes (workers first, then control planes)
130
+
workers - Worker nodes only
131
+
controlplanes - Control plane nodes only
132
+
<profile> - Nodes matching a specific profile
133
+
<ip> - A single node by IP address
134
+
135
+
Arguments can be in either order:
136
+
upgrade 1.9.5 workers
137
+
upgrade workers 1.9.5`,
138
+
Args: cobra.MaximumNArgs(2),
139
+
PreRunE: func(cmd *cobra.Command, args []string) error {
140
+
return loadConfig()
141
+
},
142
+
RunE: func(cmd *cobra.Command, args []string) error {
143
+
target, version := parseUpgradeArgs(args)
144
+
return runUpgrade(cmd.Context(), target, version)
145
+
},
146
+
}
147
+
}
148
+
149
+
// parseUpgradeArgs handles flexible argument ordering
150
+
func parseUpgradeArgs(args []string) (target, version string) {
151
+
target = "all"
152
+
version = ""
153
+
154
+
if len(args) == 0 {
155
+
return
156
+
}
157
+
158
+
// Check if first arg looks like a version (starts with digit)
159
+
if len(args) >= 1 {
160
+
if isVersion(args[0]) {
161
+
version = args[0]
162
+
if len(args) >= 2 {
163
+
target = args[1]
164
+
}
165
+
} else {
166
+
target = args[0]
167
+
if len(args) >= 2 {
168
+
version = args[1]
169
+
}
170
+
}
171
+
}
172
+
173
+
return
174
+
}
175
+
176
+
// isVersion checks if a string looks like a version number (e.g., 1.12.0)
177
+
func isVersion(s string) bool {
178
+
if len(s) == 0 {
179
+
return false
180
+
}
181
+
// Must start with a digit
182
+
if s[0] < '0' || s[0] > '9' {
183
+
return false
184
+
}
185
+
// Count dots - versions have 2 dots (X.Y.Z), IPs have 3 dots (A.B.C.D)
186
+
dots := 0
187
+
for _, c := range s {
188
+
if c == '.' {
189
+
dots++
190
+
}
191
+
}
192
+
// Version: 1.12.0 (2 dots), IP: 192.168.1.1 (3 dots)
193
+
return dots <= 2
194
+
}
195
+
196
+
func runUpgrade(ctx context.Context, target, version string) error {
197
+
// Get version if not specified
198
+
if version == "" {
199
+
var err error
200
+
version, err = getVersion()
201
+
if err != nil {
202
+
return err
203
+
}
204
+
}
205
+
206
+
// Create clients
207
+
talosClient, err := talos.NewClient(ctx)
208
+
if err != nil {
209
+
return fmt.Errorf("failed to create Talos client: %w", err)
210
+
}
211
+
defer talosClient.Close()
212
+
213
+
factoryClient := factory.NewClient(cfg.Settings.FactoryBaseURL)
214
+
215
+
return runUpgradeWithClients(ctx, talosClient, factoryClient, target, version)
216
+
}
217
+
218
+
// runUpgradeWithClients is the testable core of runUpgrade
219
+
func runUpgradeWithClients(ctx context.Context, talosClient talos.TalosClientInterface,
220
+
factoryClient factory.FactoryClientInterface, target, version string) error {
221
+
222
+
output.Header("Talos Cluster Upgrade to v%s", version)
223
+
fmt.Println()
224
+
225
+
if dryRun {
226
+
output.LogWarn("DRY RUN MODE - No changes will be made")
227
+
fmt.Println()
228
+
}
229
+
230
+
// Build list of nodes to upgrade (uses discovery if detection is configured)
231
+
nodes, err := getUpgradeNodes(ctx, talosClient, target)
232
+
if err != nil {
233
+
return err
234
+
}
235
+
236
+
if len(nodes) == 0 {
237
+
output.LogError("No nodes found to upgrade for target: %s", target)
238
+
return fmt.Errorf("no nodes found")
239
+
}
240
+
241
+
// Sort nodes by IP
242
+
sort.Slice(nodes, func(i, j int) bool {
243
+
return nodes[i].IP < nodes[j].IP
244
+
})
245
+
246
+
// Print nodes to upgrade
247
+
fmt.Println("Nodes to upgrade (in order):")
248
+
for _, node := range nodes {
249
+
fmt.Printf(" - %s (%s, profile: %s)\n", node.IP, node.Role, node.Profile)
250
+
}
251
+
fmt.Println()
252
+
253
+
// Confirm unless dry run
254
+
if !dryRun {
255
+
if !confirm("Proceed with upgrade?") {
256
+
fmt.Println("Aborted.")
257
+
return nil
258
+
}
259
+
}
260
+
261
+
// Get installer images for each profile (cache them)
262
+
profileImages := make(map[string]string)
263
+
profilesNeeded := make(map[string]bool)
264
+
for _, node := range nodes {
265
+
profilesNeeded[node.Profile] = true
266
+
}
267
+
268
+
for profileName := range profilesNeeded {
269
+
profile := cfg.Profiles[profileName]
270
+
output.LogInfo("Getting installer image for profile %s...", profileName)
271
+
272
+
image, err := factoryClient.GetInstallerImage(profile, version)
273
+
if err != nil {
274
+
return fmt.Errorf("failed to get image for profile %s: %w", profileName, err)
275
+
}
276
+
277
+
profileImages[profileName] = image
278
+
output.LogSuccess(" %s", image)
279
+
}
280
+
fmt.Println()
281
+
282
+
// Upgrade nodes one by one
283
+
var failedNodes []string
284
+
var skippedNodes []string
285
+
for _, node := range nodes {
286
+
image := profileImages[node.Profile]
287
+
profile := cfg.Profiles[node.Profile]
288
+
289
+
fmt.Println()
290
+
output.Separator()
291
+
292
+
req := UpgradeRequest{
293
+
Node: node,
294
+
Image: image,
295
+
Version: version,
296
+
ExpectedExtensions: profile.Extensions,
297
+
ExpectedKernelArgs: profile.KernelArgs,
298
+
}
299
+
skipped, err := upgradeNode(ctx, talosClient, req)
300
+
if err != nil {
301
+
failedNodes = append(failedNodes, node.IP)
302
+
output.LogError("Failed to upgrade %s: %v", node.IP, err)
303
+
304
+
// For control plane failures, ask before continuing
305
+
if node.Role == config.RoleControlPlane && !dryRun {
306
+
if !confirm("Control plane upgrade failed. Continue?") {
307
+
break
308
+
}
309
+
}
310
+
} else if skipped {
311
+
skippedNodes = append(skippedNodes, node.IP)
312
+
}
313
+
}
314
+
315
+
// Summary
316
+
fmt.Println()
317
+
output.Separator()
318
+
output.Header("Upgrade Summary")
319
+
fmt.Println()
320
+
321
+
if len(skippedNodes) > 0 {
322
+
output.LogInfo("Skipped (already at target): %s", strings.Join(skippedNodes, ", "))
323
+
}
324
+
if len(failedNodes) > 0 {
325
+
output.LogError("Failed nodes: %s", strings.Join(failedNodes, ", "))
326
+
}
327
+
if len(failedNodes) == 0 {
328
+
output.LogSuccess("All nodes upgraded successfully!")
329
+
}
330
+
331
+
// Show final status
332
+
fmt.Println()
333
+
return runStatusWithClient(ctx, talosClient)
334
+
}
335
+
336
+
// getNodesForTarget returns the list of nodes matching the target (legacy config)
337
+
func getNodesForTarget(target string) ([]config.Node, error) {
338
+
switch target {
339
+
case "all":
340
+
return cfg.GetAllNodesOrdered(), nil
341
+
case "workers":
342
+
return cfg.GetWorkerNodes(), nil
343
+
case "controlplanes":
344
+
return cfg.GetControlPlaneNodes(), nil
345
+
default:
346
+
// Check if it's a node IP
347
+
if node := cfg.GetNodeByIP(target); node != nil {
348
+
return []config.Node{*node}, nil
349
+
}
350
+
351
+
// Check if it's a profile name
352
+
nodes := cfg.GetNodesByProfile(target)
353
+
if len(nodes) > 0 {
354
+
return nodes, nil
355
+
}
356
+
357
+
return nil, fmt.Errorf("unknown target: %s", target)
358
+
}
359
+
}
360
+
361
+
// getUpgradeNodes returns nodes for upgrade, using discovery if detection is configured
362
+
func getUpgradeNodes(ctx context.Context, client talos.TalosClientInterface, target string) ([]config.Node, error) {
363
+
// If detection is configured, use discovery
364
+
if cfg.HasDetection() {
365
+
return discoverUpgradeNodes(ctx, client, target)
366
+
}
367
+
368
+
// Fall back to legacy config
369
+
return getNodesForTarget(target)
370
+
}
371
+
372
+
// discoverUpgradeNodes discovers nodes and builds config.Node structs with detected profiles
373
+
func discoverUpgradeNodes(ctx context.Context, client talos.TalosClientInterface, target string) ([]config.Node, error) {
374
+
// Get cluster members
375
+
members, err := client.GetClusterMembers(ctx)
376
+
if err != nil {
377
+
return nil, fmt.Errorf("failed to discover cluster members: %w", err)
378
+
}
379
+
380
+
var nodes []config.Node
381
+
for _, member := range members {
382
+
// Get hardware info for profile detection
383
+
hwInfo, err := client.GetHardwareInfo(ctx, member.IP)
384
+
if err != nil {
385
+
return nil, fmt.Errorf("failed to get hardware info for %s: %w", member.IP, err)
386
+
}
387
+
388
+
// Convert to config.HardwareInfo for detection
389
+
cfgHwInfo := &config.HardwareInfo{
390
+
SystemManufacturer: hwInfo.SystemManufacturer,
391
+
SystemProductName: hwInfo.SystemProductName,
392
+
ProcessorManufacturer: hwInfo.ProcessorManufacturer,
393
+
ProcessorProductName: hwInfo.ProcessorProductName,
394
+
}
395
+
396
+
// Detect profile
397
+
profileName, profile := cfg.DetectProfile(cfgHwInfo)
398
+
if profile == nil {
399
+
return nil, fmt.Errorf("no profile detected for node %s (hw: %+v)", member.IP, cfgHwInfo)
400
+
}
401
+
402
+
nodes = append(nodes, config.Node{
403
+
IP: member.IP,
404
+
Profile: profileName,
405
+
Role: member.Role,
406
+
})
407
+
}
408
+
409
+
// Filter by target
410
+
return filterNodesByTarget(nodes, target)
411
+
}
412
+
413
+
// filterNodesByTarget filters nodes based on target (all, workers, controlplanes, profile, IP)
414
+
func filterNodesByTarget(nodes []config.Node, target string) ([]config.Node, error) {
415
+
switch target {
416
+
case "all":
417
+
// Order: workers first, then control planes
418
+
var workers, controlplanes []config.Node
419
+
for _, node := range nodes {
420
+
if node.Role == config.RoleWorker {
421
+
workers = append(workers, node)
422
+
} else {
423
+
controlplanes = append(controlplanes, node)
424
+
}
425
+
}
426
+
return append(workers, controlplanes...), nil
427
+
428
+
case "workers":
429
+
var filtered []config.Node
430
+
for _, node := range nodes {
431
+
if node.Role == config.RoleWorker {
432
+
filtered = append(filtered, node)
433
+
}
434
+
}
435
+
return filtered, nil
436
+
437
+
case "controlplanes":
438
+
var filtered []config.Node
439
+
for _, node := range nodes {
440
+
if node.Role == config.RoleControlPlane {
441
+
filtered = append(filtered, node)
442
+
}
443
+
}
444
+
return filtered, nil
445
+
446
+
default:
447
+
// Check if it's a node IP
448
+
for _, node := range nodes {
449
+
if node.IP == target {
450
+
return []config.Node{node}, nil
451
+
}
452
+
}
453
+
454
+
// Check if it's a profile name
455
+
var filtered []config.Node
456
+
for _, node := range nodes {
457
+
if node.Profile == target {
458
+
filtered = append(filtered, node)
459
+
}
460
+
}
461
+
if len(filtered) > 0 {
462
+
return filtered, nil
463
+
}
464
+
465
+
return nil, fmt.Errorf("unknown target: %s", target)
466
+
}
467
+
}
468
+
469
+
// upgradeNode upgrades a single node
470
+
// Returns (skipped, error) - skipped is true if node was already at target version/extensions/kernel args
471
+
func upgradeNode(ctx context.Context, client talos.TalosClientInterface, req UpgradeRequest) (bool, error) {
472
+
// Get current version
473
+
currentVersion, err := client.GetVersion(ctx, req.Node.IP)
474
+
if err != nil {
475
+
currentVersion = "unknown"
476
+
}
477
+
478
+
// Get current extensions
479
+
currentExtensions, err := client.GetExtensions(ctx, req.Node.IP)
480
+
if err != nil {
481
+
// Non-fatal: we'll just assume extensions differ
482
+
currentExtensions = nil
483
+
}
484
+
485
+
// Get current kernel cmdline
486
+
currentCmdline, err := client.GetKernelCmdline(ctx, req.Node.IP)
487
+
if err != nil {
488
+
// Non-fatal: we'll just assume kernel args differ
489
+
currentCmdline = ""
490
+
}
491
+
492
+
// Check if extensions differ
493
+
extDiffer, extDiff := extensionsDiffer(currentExtensions, req.ExpectedExtensions)
494
+
495
+
// Check if kernel args differ
496
+
kaDiffer, kaDiff := kernelArgsDiffer(currentCmdline, req.ExpectedKernelArgs)
497
+
498
+
// Skip if already at target version AND extensions match AND kernel args match
499
+
if currentVersion == req.Version && !extDiffer && !kaDiffer {
500
+
output.LogSuccess("Node %s already at v%s with matching config, skipping", req.Node.IP, req.Version)
501
+
return true, nil
502
+
}
503
+
504
+
output.LogInfo("Upgrading node %s (%s)", req.Node.IP, req.Node.Role)
505
+
output.LogInfo(" Current version: %s", currentVersion)
506
+
if extDiffer {
507
+
output.LogInfo(" Extensions differ: %s", extDiff)
508
+
}
509
+
if kaDiffer {
510
+
output.LogInfo(" Kernel args differ: %s", kaDiff)
511
+
}
512
+
output.LogInfo(" Target image: %s", req.Image)
513
+
514
+
if dryRun {
515
+
output.LogWarn("DRY RUN: Would run: talosctl upgrade -n %s --image %s --preserve=%v",
516
+
req.Node.IP, req.Image, preserve)
517
+
return false, nil
518
+
}
519
+
520
+
// Run upgrade
521
+
if err := client.Upgrade(ctx, req.Node.IP, req.Image, preserve); err != nil {
522
+
return false, fmt.Errorf("upgrade command failed: %w", err)
523
+
}
524
+
525
+
// Watch upgrade progress with streaming events
526
+
output.LogInfo("Watching upgrade progress...")
527
+
timeout := time.Duration(cfg.Settings.DefaultTimeoutSeconds) * time.Second
528
+
529
+
var lastPhase, lastTask string
530
+
err = client.WatchUpgrade(ctx, req.Node.IP, timeout, func(p talos.UpgradeProgress) {
531
+
// Show stage changes
532
+
if p.Stage != "" {
533
+
output.LogInfo(" [%s]", p.Stage)
534
+
}
535
+
// Show phase changes (avoid duplicates)
536
+
if p.Phase != "" && p.Phase != lastPhase {
537
+
lastPhase = p.Phase
538
+
output.LogInfo(" phase: %s (%s)", p.Phase, p.Action)
539
+
}
540
+
// Show task changes (avoid duplicates)
541
+
if p.Task != "" && p.Task != lastTask {
542
+
lastTask = p.Task
543
+
output.LogInfo(" task: %s (%s)", p.Task, p.Action)
544
+
}
545
+
})
546
+
if err != nil {
547
+
return false, fmt.Errorf("upgrade failed: %w", err)
548
+
}
549
+
550
+
// Wait for critical services to be healthy
551
+
var services []string
552
+
if req.Node.Role == config.RoleControlPlane {
553
+
services = talos.GetControlPlaneServices()
554
+
} else {
555
+
services = talos.GetWorkerServices()
556
+
}
557
+
output.LogInfo("Waiting for Talos services: %v", services)
558
+
if err := client.WaitForServices(ctx, req.Node.IP, services, 60*time.Second); err != nil {
559
+
output.LogWarn("Services health check timed out: %v", err)
560
+
} else {
561
+
output.LogSuccess("Talos services healthy")
562
+
}
563
+
564
+
// For control plane nodes, also wait for K8s static pods
565
+
if req.Node.Role == config.RoleControlPlane {
566
+
output.LogInfo("Waiting for K8s control plane pods: apiserver, controller-manager, scheduler")
567
+
if err := client.WaitForStaticPods(ctx, req.Node.IP, 90*time.Second); err != nil {
568
+
output.LogWarn("Static pods health check timed out: %v", err)
569
+
} else {
570
+
output.LogSuccess("K8s control plane pods healthy")
571
+
}
572
+
}
573
+
574
+
// Verify new version
575
+
newVersion, err := client.GetVersion(ctx, req.Node.IP)
576
+
if err != nil {
577
+
newVersion = "unknown"
578
+
}
579
+
580
+
output.LogSuccess("Node %s upgraded: %s -> %s", req.Node.IP, currentVersion, newVersion)
581
+
return false, nil
582
+
}
583
+
584
+
// confirmReader is the reader used for confirmation prompts (can be replaced in tests)
585
+
var confirmReader io.Reader = os.Stdin
586
+
587
+
// confirm asks the user for confirmation
588
+
func confirm(prompt string) bool {
589
+
reader := bufio.NewReader(confirmReader)
590
+
fmt.Printf("%s [y/N] ", prompt)
591
+
592
+
response, err := reader.ReadString('\n')
593
+
if err != nil {
594
+
return false
595
+
}
596
+
597
+
response = strings.TrimSpace(strings.ToLower(response))
598
+
return response == "y" || response == "yes"
599
+
}
+149
internal/cmd/upgrade_node.go
+149
internal/cmd/upgrade_node.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/evanjarrett/homelab/internal/config"
8
+
"github.com/evanjarrett/homelab/internal/factory"
9
+
"github.com/evanjarrett/homelab/internal/output"
10
+
"github.com/evanjarrett/homelab/internal/talos"
11
+
"github.com/spf13/cobra"
12
+
)
13
+
14
+
func upgradeNodeCmd() *cobra.Command {
15
+
return &cobra.Command{
16
+
Use: "upgrade-node <ip> [version]",
17
+
Short: "Upgrade a single node",
18
+
Long: `Upgrade a single node by IP address to the specified version.`,
19
+
Args: cobra.RangeArgs(1, 2),
20
+
PreRunE: func(cmd *cobra.Command, args []string) error {
21
+
return loadConfig()
22
+
},
23
+
RunE: func(cmd *cobra.Command, args []string) error {
24
+
nodeIP := args[0]
25
+
version := ""
26
+
if len(args) > 1 {
27
+
version = args[1]
28
+
}
29
+
return runUpgradeNode(cmd.Context(), nodeIP, version)
30
+
},
31
+
}
32
+
}
33
+
34
+
func runUpgradeNode(ctx context.Context, nodeIP, version string) error {
35
+
// Get version if not specified
36
+
if version == "" {
37
+
var err error
38
+
version, err = getVersion()
39
+
if err != nil {
40
+
return err
41
+
}
42
+
}
43
+
44
+
// Create clients
45
+
talosClient, err := talos.NewClient(ctx)
46
+
if err != nil {
47
+
return fmt.Errorf("failed to create Talos client: %w", err)
48
+
}
49
+
defer talosClient.Close()
50
+
51
+
factoryClient := factory.NewClient(cfg.Settings.FactoryBaseURL)
52
+
53
+
return runUpgradeNodeWithClients(ctx, talosClient, factoryClient, nodeIP, version)
54
+
}
55
+
56
+
// runUpgradeNodeWithClients is the testable core of runUpgradeNode
57
+
func runUpgradeNodeWithClients(ctx context.Context, talosClient talos.TalosClientInterface,
58
+
factoryClient factory.FactoryClientInterface, nodeIP, version string) error {
59
+
60
+
// Get or detect the node and profile
61
+
node, profile, profileName, err := getNodeAndProfile(ctx, talosClient, nodeIP)
62
+
if err != nil {
63
+
return err
64
+
}
65
+
66
+
output.Header("Upgrading node %s to v%s", nodeIP, version)
67
+
fmt.Println()
68
+
69
+
if dryRun {
70
+
output.LogWarn("DRY RUN MODE - No changes will be made")
71
+
fmt.Println()
72
+
}
73
+
74
+
// Get installer image
75
+
output.LogInfo("Getting installer image for profile %s...", profileName)
76
+
image, err := factoryClient.GetInstallerImage(*profile, version)
77
+
if err != nil {
78
+
return fmt.Errorf("failed to get image: %w", err)
79
+
}
80
+
output.LogSuccess(" %s", image)
81
+
fmt.Println()
82
+
83
+
// Run upgrade
84
+
req := UpgradeRequest{
85
+
Node: *node,
86
+
Image: image,
87
+
Version: version,
88
+
ExpectedExtensions: profile.Extensions,
89
+
ExpectedKernelArgs: profile.KernelArgs,
90
+
}
91
+
_, err = upgradeNode(ctx, talosClient, req)
92
+
return err
93
+
}
94
+
95
+
// getNodeAndProfile returns the node and profile for an IP, using detection if available
96
+
func getNodeAndProfile(ctx context.Context, client talos.TalosClientInterface, nodeIP string) (*config.Node, *config.Profile, string, error) {
97
+
// Try legacy config first
98
+
if node := cfg.GetNodeByIP(nodeIP); node != nil {
99
+
profile, ok := cfg.Profiles[node.Profile]
100
+
if !ok {
101
+
return nil, nil, "", fmt.Errorf("unknown profile: %s", node.Profile)
102
+
}
103
+
return node, &profile, node.Profile, nil
104
+
}
105
+
106
+
// If detection is configured, try to detect
107
+
if cfg.HasDetection() {
108
+
hwInfo, err := client.GetHardwareInfo(ctx, nodeIP)
109
+
if err != nil {
110
+
return nil, nil, "", fmt.Errorf("failed to get hardware info for %s: %w", nodeIP, err)
111
+
}
112
+
113
+
// Convert to config.HardwareInfo for detection
114
+
cfgHwInfo := &config.HardwareInfo{
115
+
SystemManufacturer: hwInfo.SystemManufacturer,
116
+
SystemProductName: hwInfo.SystemProductName,
117
+
ProcessorManufacturer: hwInfo.ProcessorManufacturer,
118
+
ProcessorProductName: hwInfo.ProcessorProductName,
119
+
}
120
+
121
+
profileName, profile := cfg.DetectProfile(cfgHwInfo)
122
+
if profile == nil {
123
+
return nil, nil, "", fmt.Errorf("no profile detected for node %s", nodeIP)
124
+
}
125
+
126
+
// Get role from cluster members
127
+
members, err := client.GetClusterMembers(ctx)
128
+
if err != nil {
129
+
return nil, nil, "", fmt.Errorf("failed to get cluster members: %w", err)
130
+
}
131
+
132
+
role := config.RoleWorker
133
+
for _, member := range members {
134
+
if member.IP == nodeIP {
135
+
role = member.Role
136
+
break
137
+
}
138
+
}
139
+
140
+
node := &config.Node{
141
+
IP: nodeIP,
142
+
Profile: profileName,
143
+
Role: role,
144
+
}
145
+
return node, profile, profileName, nil
146
+
}
147
+
148
+
return nil, nil, "", fmt.Errorf("unknown node: %s", nodeIP)
149
+
}
+164
internal/cmd/upgrade_node_test.go
+164
internal/cmd/upgrade_node_test.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"testing"
7
+
"time"
8
+
9
+
"github.com/evanjarrett/homelab/internal/config"
10
+
"github.com/evanjarrett/homelab/internal/factory"
11
+
"github.com/evanjarrett/homelab/internal/talos"
12
+
"github.com/stretchr/testify/assert"
13
+
"github.com/stretchr/testify/require"
14
+
)
15
+
16
+
// ============================================================================
17
+
// runUpgradeNodeWithClients() Tests
18
+
// ============================================================================
19
+
20
+
func TestRunUpgradeNodeWithClients_Success(t *testing.T) {
21
+
cfg = &config.Config{
22
+
Settings: config.Settings{
23
+
FactoryBaseURL: "https://factory.talos.dev",
24
+
DefaultTimeoutSeconds: 60,
25
+
},
26
+
Profiles: map[string]config.Profile{
27
+
"test-profile": {Arch: "amd64", Platform: "metal"},
28
+
},
29
+
Nodes: []config.Node{
30
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
31
+
},
32
+
}
33
+
dryRun = false
34
+
preserve = true
35
+
36
+
talosMock := &talos.MockClient{
37
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
38
+
return "1.8.0", nil
39
+
},
40
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
41
+
return nil
42
+
},
43
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
44
+
return nil
45
+
},
46
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
47
+
return nil
48
+
},
49
+
}
50
+
51
+
factoryMock := &factory.MockFactoryClient{
52
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
53
+
return fmt.Sprintf("factory.talos.dev/installer/test:v%s", version), nil
54
+
},
55
+
}
56
+
57
+
err := runUpgradeNodeWithClients(context.Background(), talosMock, factoryMock, "192.168.1.1", "1.9.0")
58
+
require.NoError(t, err)
59
+
}
60
+
61
+
func TestRunUpgradeNodeWithClients_UnknownNode(t *testing.T) {
62
+
cfg = &config.Config{
63
+
Settings: config.Settings{
64
+
FactoryBaseURL: "https://factory.talos.dev",
65
+
DefaultTimeoutSeconds: 60,
66
+
},
67
+
Profiles: map[string]config.Profile{
68
+
"test-profile": {Arch: "amd64", Platform: "metal"},
69
+
},
70
+
Nodes: []config.Node{
71
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
72
+
},
73
+
}
74
+
75
+
talosMock := &talos.MockClient{}
76
+
factoryMock := &factory.MockFactoryClient{}
77
+
78
+
err := runUpgradeNodeWithClients(context.Background(), talosMock, factoryMock, "192.168.1.99", "1.9.0")
79
+
require.Error(t, err)
80
+
assert.Contains(t, err.Error(), "unknown node")
81
+
}
82
+
83
+
func TestRunUpgradeNodeWithClients_UnknownProfile(t *testing.T) {
84
+
cfg = &config.Config{
85
+
Settings: config.Settings{
86
+
FactoryBaseURL: "https://factory.talos.dev",
87
+
DefaultTimeoutSeconds: 60,
88
+
},
89
+
Profiles: map[string]config.Profile{
90
+
"other-profile": {Arch: "amd64", Platform: "metal"},
91
+
},
92
+
Nodes: []config.Node{
93
+
{IP: "192.168.1.1", Profile: "missing-profile", Role: config.RoleWorker},
94
+
},
95
+
}
96
+
97
+
talosMock := &talos.MockClient{}
98
+
factoryMock := &factory.MockFactoryClient{}
99
+
100
+
err := runUpgradeNodeWithClients(context.Background(), talosMock, factoryMock, "192.168.1.1", "1.9.0")
101
+
require.Error(t, err)
102
+
assert.Contains(t, err.Error(), "unknown profile")
103
+
}
104
+
105
+
func TestRunUpgradeNodeWithClients_FactoryError(t *testing.T) {
106
+
cfg = &config.Config{
107
+
Settings: config.Settings{
108
+
FactoryBaseURL: "https://factory.talos.dev",
109
+
DefaultTimeoutSeconds: 60,
110
+
},
111
+
Profiles: map[string]config.Profile{
112
+
"test-profile": {Arch: "amd64", Platform: "metal"},
113
+
},
114
+
Nodes: []config.Node{
115
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
116
+
},
117
+
}
118
+
119
+
talosMock := &talos.MockClient{}
120
+
factoryMock := &factory.MockFactoryClient{
121
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
122
+
return "", fmt.Errorf("factory API error: connection refused")
123
+
},
124
+
}
125
+
126
+
err := runUpgradeNodeWithClients(context.Background(), talosMock, factoryMock, "192.168.1.1", "1.9.0")
127
+
require.Error(t, err)
128
+
assert.Contains(t, err.Error(), "failed to get image")
129
+
}
130
+
131
+
func TestRunUpgradeNodeWithClients_DryRun(t *testing.T) {
132
+
cfg = &config.Config{
133
+
Settings: config.Settings{
134
+
FactoryBaseURL: "https://factory.talos.dev",
135
+
DefaultTimeoutSeconds: 60,
136
+
},
137
+
Profiles: map[string]config.Profile{
138
+
"test-profile": {Arch: "amd64", Platform: "metal"},
139
+
},
140
+
Nodes: []config.Node{
141
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
142
+
},
143
+
}
144
+
dryRun = true
145
+
preserve = true
146
+
defer func() { dryRun = false }()
147
+
148
+
upgradeWasCalled := false
149
+
talosMock := &talos.MockClient{
150
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
151
+
return "1.8.0", nil
152
+
},
153
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
154
+
upgradeWasCalled = true
155
+
return nil
156
+
},
157
+
}
158
+
159
+
factoryMock := &factory.MockFactoryClient{}
160
+
161
+
err := runUpgradeNodeWithClients(context.Background(), talosMock, factoryMock, "192.168.1.1", "1.9.0")
162
+
require.NoError(t, err)
163
+
assert.False(t, upgradeWasCalled, "upgrade should not be called in dry run mode")
164
+
}
+1189
internal/cmd/upgrade_test.go
+1189
internal/cmd/upgrade_test.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"strings"
7
+
"testing"
8
+
"time"
9
+
10
+
"github.com/evanjarrett/homelab/internal/config"
11
+
"github.com/evanjarrett/homelab/internal/factory"
12
+
"github.com/evanjarrett/homelab/internal/talos"
13
+
"github.com/stretchr/testify/assert"
14
+
"github.com/stretchr/testify/require"
15
+
)
16
+
17
+
// ============================================================================
18
+
// isVersion() Tests
19
+
// ============================================================================
20
+
21
+
func TestIsVersion_ValidVersions(t *testing.T) {
22
+
tests := []struct {
23
+
input string
24
+
expected bool
25
+
}{
26
+
{"1.7.0", true},
27
+
{"1.12.0", true},
28
+
{"2.0.0", true},
29
+
{"1.7", true}, // Two-part version
30
+
{"1", true}, // Single number
31
+
{"10.20.30", true},
32
+
}
33
+
34
+
for _, tt := range tests {
35
+
t.Run(tt.input, func(t *testing.T) {
36
+
result := isVersion(tt.input)
37
+
assert.Equal(t, tt.expected, result, "isVersion(%q)", tt.input)
38
+
})
39
+
}
40
+
}
41
+
42
+
func TestIsVersion_IPAddresses(t *testing.T) {
43
+
tests := []struct {
44
+
input string
45
+
expected bool
46
+
}{
47
+
{"192.168.1.1", false}, // 3 dots = IP
48
+
{"192.168.1.123", false},
49
+
{"10.0.0.1", false},
50
+
{"172.16.0.100", false},
51
+
}
52
+
53
+
for _, tt := range tests {
54
+
t.Run(tt.input, func(t *testing.T) {
55
+
result := isVersion(tt.input)
56
+
assert.Equal(t, tt.expected, result, "isVersion(%q) should detect IP", tt.input)
57
+
})
58
+
}
59
+
}
60
+
61
+
func TestIsVersion_InvalidInputs(t *testing.T) {
62
+
tests := []struct {
63
+
input string
64
+
expected bool
65
+
}{
66
+
{"", false}, // Empty string
67
+
{"all", false}, // Target keyword
68
+
{"workers", false}, // Target keyword
69
+
{"controlplanes", false},
70
+
{"v1.7.0", false}, // Starts with letter
71
+
{"profile-name", false},
72
+
}
73
+
74
+
for _, tt := range tests {
75
+
t.Run(tt.input, func(t *testing.T) {
76
+
result := isVersion(tt.input)
77
+
assert.Equal(t, tt.expected, result, "isVersion(%q)", tt.input)
78
+
})
79
+
}
80
+
}
81
+
82
+
// ============================================================================
83
+
// parseUpgradeArgs() Tests
84
+
// ============================================================================
85
+
86
+
func TestParseUpgradeArgs_NoArgs(t *testing.T) {
87
+
target, version := parseUpgradeArgs([]string{})
88
+
assert.Equal(t, "all", target)
89
+
assert.Empty(t, version)
90
+
}
91
+
92
+
func TestParseUpgradeArgs_VersionOnly(t *testing.T) {
93
+
target, version := parseUpgradeArgs([]string{"1.7.0"})
94
+
assert.Equal(t, "all", target)
95
+
assert.Equal(t, "1.7.0", version)
96
+
}
97
+
98
+
func TestParseUpgradeArgs_TargetOnly(t *testing.T) {
99
+
target, version := parseUpgradeArgs([]string{"workers"})
100
+
assert.Equal(t, "workers", target)
101
+
assert.Empty(t, version)
102
+
}
103
+
104
+
func TestParseUpgradeArgs_VersionThenTarget(t *testing.T) {
105
+
// Version comes first
106
+
target, version := parseUpgradeArgs([]string{"1.7.0", "workers"})
107
+
assert.Equal(t, "workers", target)
108
+
assert.Equal(t, "1.7.0", version)
109
+
}
110
+
111
+
func TestParseUpgradeArgs_TargetThenVersion(t *testing.T) {
112
+
// Target comes first
113
+
target, version := parseUpgradeArgs([]string{"workers", "1.7.0"})
114
+
assert.Equal(t, "workers", target)
115
+
assert.Equal(t, "1.7.0", version)
116
+
}
117
+
118
+
func TestParseUpgradeArgs_IPAsTarget(t *testing.T) {
119
+
// IP address should be recognized as target, not version
120
+
target, version := parseUpgradeArgs([]string{"192.168.1.123"})
121
+
assert.Equal(t, "192.168.1.123", target)
122
+
assert.Empty(t, version)
123
+
}
124
+
125
+
func TestParseUpgradeArgs_VersionAndIP(t *testing.T) {
126
+
// Version first, then IP
127
+
target, version := parseUpgradeArgs([]string{"1.7.0", "192.168.1.123"})
128
+
assert.Equal(t, "192.168.1.123", target)
129
+
assert.Equal(t, "1.7.0", version)
130
+
}
131
+
132
+
func TestParseUpgradeArgs_IPAndVersion(t *testing.T) {
133
+
// IP first, then version
134
+
target, version := parseUpgradeArgs([]string{"192.168.1.123", "1.7.0"})
135
+
assert.Equal(t, "192.168.1.123", target)
136
+
assert.Equal(t, "1.7.0", version)
137
+
}
138
+
139
+
func TestParseUpgradeArgs_ProfileTarget(t *testing.T) {
140
+
target, version := parseUpgradeArgs([]string{"arm64-rpi", "1.8.0"})
141
+
assert.Equal(t, "arm64-rpi", target)
142
+
assert.Equal(t, "1.8.0", version)
143
+
}
144
+
145
+
// ============================================================================
146
+
// getNodesForTarget() Tests
147
+
// ============================================================================
148
+
149
+
func setupTestConfig() {
150
+
cfg = &config.Config{
151
+
Settings: config.Settings{
152
+
FactoryBaseURL: "https://factory.talos.dev",
153
+
DefaultTimeoutSeconds: 600,
154
+
},
155
+
Profiles: map[string]config.Profile{
156
+
"profile-a": {Arch: "amd64", Platform: "metal"},
157
+
"profile-b": {Arch: "arm64", Platform: "metal"},
158
+
},
159
+
Nodes: []config.Node{
160
+
{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleControlPlane},
161
+
{IP: "192.168.1.2", Profile: "profile-a", Role: config.RoleWorker},
162
+
{IP: "192.168.1.3", Profile: "profile-b", Role: config.RoleWorker},
163
+
{IP: "192.168.1.4", Profile: "profile-b", Role: config.RoleControlPlane},
164
+
},
165
+
}
166
+
}
167
+
168
+
func TestGetNodesForTarget_All(t *testing.T) {
169
+
setupTestConfig()
170
+
171
+
nodes, err := getNodesForTarget("all")
172
+
require.NoError(t, err)
173
+
assert.Len(t, nodes, 4)
174
+
175
+
// Workers should come first (GetAllNodesOrdered)
176
+
assert.Equal(t, config.RoleWorker, nodes[0].Role)
177
+
assert.Equal(t, config.RoleWorker, nodes[1].Role)
178
+
assert.Equal(t, config.RoleControlPlane, nodes[2].Role)
179
+
assert.Equal(t, config.RoleControlPlane, nodes[3].Role)
180
+
}
181
+
182
+
func TestGetNodesForTarget_Workers(t *testing.T) {
183
+
setupTestConfig()
184
+
185
+
nodes, err := getNodesForTarget("workers")
186
+
require.NoError(t, err)
187
+
assert.Len(t, nodes, 2)
188
+
189
+
for _, node := range nodes {
190
+
assert.Equal(t, config.RoleWorker, node.Role)
191
+
}
192
+
}
193
+
194
+
func TestGetNodesForTarget_ControlPlanes(t *testing.T) {
195
+
setupTestConfig()
196
+
197
+
nodes, err := getNodesForTarget("controlplanes")
198
+
require.NoError(t, err)
199
+
assert.Len(t, nodes, 2)
200
+
201
+
for _, node := range nodes {
202
+
assert.Equal(t, config.RoleControlPlane, node.Role)
203
+
}
204
+
}
205
+
206
+
func TestGetNodesForTarget_ByIP(t *testing.T) {
207
+
setupTestConfig()
208
+
209
+
nodes, err := getNodesForTarget("192.168.1.2")
210
+
require.NoError(t, err)
211
+
require.Len(t, nodes, 1)
212
+
assert.Equal(t, "192.168.1.2", nodes[0].IP)
213
+
assert.Equal(t, "profile-a", nodes[0].Profile)
214
+
}
215
+
216
+
func TestGetNodesForTarget_ByProfile(t *testing.T) {
217
+
setupTestConfig()
218
+
219
+
nodes, err := getNodesForTarget("profile-b")
220
+
require.NoError(t, err)
221
+
assert.Len(t, nodes, 2)
222
+
223
+
for _, node := range nodes {
224
+
assert.Equal(t, "profile-b", node.Profile)
225
+
}
226
+
}
227
+
228
+
func TestGetNodesForTarget_UnknownTarget(t *testing.T) {
229
+
setupTestConfig()
230
+
231
+
nodes, err := getNodesForTarget("unknown-target")
232
+
assert.Error(t, err)
233
+
assert.Nil(t, nodes)
234
+
assert.Contains(t, err.Error(), "unknown target")
235
+
}
236
+
237
+
func TestGetNodesForTarget_NonexistentIP(t *testing.T) {
238
+
setupTestConfig()
239
+
240
+
// IP with 3 dots but not in config - treated as profile name search
241
+
nodes, err := getNodesForTarget("192.168.1.99")
242
+
assert.Error(t, err)
243
+
assert.Nil(t, nodes)
244
+
}
245
+
246
+
// ============================================================================
247
+
// extensionsDiffer() Tests
248
+
// ============================================================================
249
+
250
+
func TestExtensionsDiffer_MatchingExtensions(t *testing.T) {
251
+
running := []talos.ExtensionInfo{
252
+
{Name: "i915", Version: "1.0.0"},
253
+
{Name: "iscsi-tools", Version: "1.0.0"},
254
+
}
255
+
expected := []string{"siderolabs/i915", "siderolabs/iscsi-tools"}
256
+
257
+
differs, diff := extensionsDiffer(running, expected)
258
+
assert.False(t, differs, "should not differ when extensions match")
259
+
assert.Empty(t, diff)
260
+
}
261
+
262
+
func TestExtensionsDiffer_MissingExtension(t *testing.T) {
263
+
running := []talos.ExtensionInfo{
264
+
{Name: "i915", Version: "1.0.0"},
265
+
}
266
+
expected := []string{"siderolabs/i915", "siderolabs/iscsi-tools"}
267
+
268
+
differs, diff := extensionsDiffer(running, expected)
269
+
assert.True(t, differs, "should differ when extension is missing")
270
+
assert.Contains(t, diff, "missing")
271
+
assert.Contains(t, diff, "iscsi-tools")
272
+
}
273
+
274
+
func TestExtensionsDiffer_ExtraExtension(t *testing.T) {
275
+
running := []talos.ExtensionInfo{
276
+
{Name: "i915", Version: "1.0.0"},
277
+
{Name: "iscsi-tools", Version: "1.0.0"},
278
+
{Name: "gasket-driver", Version: "1.0.0"},
279
+
}
280
+
expected := []string{"siderolabs/i915", "siderolabs/iscsi-tools"}
281
+
282
+
differs, diff := extensionsDiffer(running, expected)
283
+
assert.True(t, differs, "should differ when extra extension is present")
284
+
assert.Contains(t, diff, "extra")
285
+
assert.Contains(t, diff, "gasket-driver")
286
+
}
287
+
288
+
func TestExtensionsDiffer_BothMissingAndExtra(t *testing.T) {
289
+
running := []talos.ExtensionInfo{
290
+
{Name: "i915", Version: "1.0.0"},
291
+
{Name: "gasket-driver", Version: "1.0.0"},
292
+
}
293
+
expected := []string{"siderolabs/i915", "siderolabs/iscsi-tools"}
294
+
295
+
differs, diff := extensionsDiffer(running, expected)
296
+
assert.True(t, differs)
297
+
assert.Contains(t, diff, "missing")
298
+
assert.Contains(t, diff, "iscsi-tools")
299
+
assert.Contains(t, diff, "extra")
300
+
assert.Contains(t, diff, "gasket-driver")
301
+
}
302
+
303
+
func TestExtensionsDiffer_EmptyLists(t *testing.T) {
304
+
running := []talos.ExtensionInfo{}
305
+
expected := []string{}
306
+
307
+
differs, _ := extensionsDiffer(running, expected)
308
+
assert.False(t, differs, "empty lists should not differ")
309
+
}
310
+
311
+
func TestExtensionsDiffer_NoVendorPrefix(t *testing.T) {
312
+
running := []talos.ExtensionInfo{
313
+
{Name: "i915", Version: "1.0.0"},
314
+
}
315
+
// Extension without vendor prefix should still match
316
+
expected := []string{"i915"}
317
+
318
+
differs, _ := extensionsDiffer(running, expected)
319
+
assert.False(t, differs, "should handle extensions without vendor prefix")
320
+
}
321
+
322
+
func TestExtensionsDiffer_IgnoresInternalExtensions(t *testing.T) {
323
+
running := []talos.ExtensionInfo{
324
+
{Name: "i915", Version: "1.0.0"},
325
+
{Name: "schematic", Version: "abc123"}, // Internal - should be ignored
326
+
{Name: "modules.dep", Version: "6.18.1"}, // Internal - should be ignored
327
+
}
328
+
expected := []string{"siderolabs/i915"}
329
+
330
+
differs, diff := extensionsDiffer(running, expected)
331
+
assert.False(t, differs, "internal extensions should be ignored")
332
+
assert.Empty(t, diff)
333
+
}
334
+
335
+
// ============================================================================
336
+
// upgradeNode() Tests
337
+
// ============================================================================
338
+
339
+
func TestUpgradeNode_AlreadyAtVersion(t *testing.T) {
340
+
setupTestConfig()
341
+
cfg.Settings.DefaultTimeoutSeconds = 60
342
+
dryRun = false
343
+
preserve = true
344
+
345
+
mock := &talos.MockClient{
346
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
347
+
return "1.9.0", nil // Same as target
348
+
},
349
+
GetExtensionsFunc: func(ctx context.Context, nodeIP string) ([]talos.ExtensionInfo, error) {
350
+
return []talos.ExtensionInfo{
351
+
{Name: "i915", Version: "1.0.0"},
352
+
}, nil
353
+
},
354
+
}
355
+
356
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
357
+
req := UpgradeRequest{
358
+
Node: node,
359
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
360
+
Version: "1.9.0",
361
+
ExpectedExtensions: []string{"siderolabs/i915"},
362
+
}
363
+
skipped, err := upgradeNode(context.Background(), mock, req)
364
+
365
+
require.NoError(t, err)
366
+
assert.True(t, skipped, "should skip node already at target version with matching extensions")
367
+
}
368
+
369
+
func TestUpgradeNode_SameVersionDifferentExtensions(t *testing.T) {
370
+
setupTestConfig()
371
+
cfg.Settings.DefaultTimeoutSeconds = 60
372
+
dryRun = true // Use dry run to simplify test
373
+
defer func() { dryRun = false }()
374
+
preserve = true
375
+
376
+
mock := &talos.MockClient{
377
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
378
+
return "1.9.0", nil // Same as target version
379
+
},
380
+
GetExtensionsFunc: func(ctx context.Context, nodeIP string) ([]talos.ExtensionInfo, error) {
381
+
return []talos.ExtensionInfo{
382
+
{Name: "i915", Version: "1.0.0"},
383
+
}, nil
384
+
},
385
+
}
386
+
387
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
388
+
// Expected extensions include a new one not currently running
389
+
req := UpgradeRequest{
390
+
Node: node,
391
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
392
+
Version: "1.9.0",
393
+
ExpectedExtensions: []string{"siderolabs/i915", "siderolabs/gasket-driver"},
394
+
}
395
+
skipped, err := upgradeNode(context.Background(), mock, req)
396
+
397
+
require.NoError(t, err)
398
+
assert.False(t, skipped, "should not skip when extensions differ even if version matches")
399
+
}
400
+
401
+
func TestUpgradeNode_DryRun(t *testing.T) {
402
+
setupTestConfig()
403
+
cfg.Settings.DefaultTimeoutSeconds = 60
404
+
dryRun = true
405
+
preserve = true
406
+
407
+
upgradeWasCalled := false
408
+
mock := &talos.MockClient{
409
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
410
+
return "1.8.0", nil // Old version
411
+
},
412
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, preserve bool) error {
413
+
upgradeWasCalled = true
414
+
return nil
415
+
},
416
+
}
417
+
418
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
419
+
req := UpgradeRequest{
420
+
Node: node,
421
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
422
+
Version: "1.9.0",
423
+
}
424
+
skipped, err := upgradeNode(context.Background(), mock, req)
425
+
426
+
require.NoError(t, err)
427
+
assert.False(t, skipped)
428
+
assert.False(t, upgradeWasCalled, "upgrade should not be called in dry run mode")
429
+
430
+
// Reset for other tests
431
+
dryRun = false
432
+
}
433
+
434
+
func TestUpgradeNode_Success_Worker(t *testing.T) {
435
+
setupTestConfig()
436
+
cfg.Settings.DefaultTimeoutSeconds = 60
437
+
dryRun = false
438
+
preserve = true
439
+
440
+
upgradeCalledWith := ""
441
+
mock := &talos.MockClient{
442
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
443
+
// First call returns old version, second call returns new version
444
+
if upgradeCalledWith != "" {
445
+
return "1.9.0", nil
446
+
}
447
+
return "1.8.0", nil
448
+
},
449
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
450
+
upgradeCalledWith = image
451
+
assert.Equal(t, "192.168.1.2", nodeIP)
452
+
assert.True(t, pres)
453
+
return nil
454
+
},
455
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
456
+
// Simulate progress
457
+
if cb != nil {
458
+
cb(talos.UpgradeProgress{Stage: "upgrading"})
459
+
cb(talos.UpgradeProgress{Done: true})
460
+
}
461
+
return nil
462
+
},
463
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
464
+
// Worker services
465
+
assert.Contains(t, services, "kubelet")
466
+
return nil
467
+
},
468
+
}
469
+
470
+
node := config.Node{IP: "192.168.1.2", Profile: "profile-a", Role: config.RoleWorker}
471
+
req := UpgradeRequest{
472
+
Node: node,
473
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
474
+
Version: "1.9.0",
475
+
}
476
+
skipped, err := upgradeNode(context.Background(), mock, req)
477
+
478
+
require.NoError(t, err)
479
+
assert.False(t, skipped)
480
+
assert.Equal(t, "factory.talos.dev/installer/abc:v1.9.0", upgradeCalledWith)
481
+
}
482
+
483
+
func TestUpgradeNode_Success_ControlPlane(t *testing.T) {
484
+
setupTestConfig()
485
+
cfg.Settings.DefaultTimeoutSeconds = 60
486
+
dryRun = false
487
+
preserve = true
488
+
489
+
waitForStaticPodsCalled := false
490
+
mock := &talos.MockClient{
491
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
492
+
return "1.8.0", nil
493
+
},
494
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
495
+
return nil
496
+
},
497
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
498
+
return nil
499
+
},
500
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
501
+
// Control plane services should include etcd
502
+
assert.Contains(t, services, "etcd")
503
+
return nil
504
+
},
505
+
WaitForStaticPodsFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
506
+
waitForStaticPodsCalled = true
507
+
return nil
508
+
},
509
+
}
510
+
511
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleControlPlane}
512
+
req := UpgradeRequest{
513
+
Node: node,
514
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
515
+
Version: "1.9.0",
516
+
}
517
+
skipped, err := upgradeNode(context.Background(), mock, req)
518
+
519
+
require.NoError(t, err)
520
+
assert.False(t, skipped)
521
+
assert.True(t, waitForStaticPodsCalled, "should wait for static pods on control plane")
522
+
}
523
+
524
+
func TestUpgradeNode_GetVersionError(t *testing.T) {
525
+
setupTestConfig()
526
+
cfg.Settings.DefaultTimeoutSeconds = 60
527
+
dryRun = false
528
+
preserve = true
529
+
530
+
upgradeWasCalled := false
531
+
mock := &talos.MockClient{
532
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
533
+
return "", fmt.Errorf("connection refused")
534
+
},
535
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
536
+
upgradeWasCalled = true
537
+
return nil
538
+
},
539
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
540
+
return nil
541
+
},
542
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
543
+
return nil
544
+
},
545
+
}
546
+
547
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
548
+
req := UpgradeRequest{
549
+
Node: node,
550
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
551
+
Version: "1.9.0",
552
+
}
553
+
skipped, err := upgradeNode(context.Background(), mock, req)
554
+
555
+
require.NoError(t, err)
556
+
assert.False(t, skipped)
557
+
assert.True(t, upgradeWasCalled, "should proceed with upgrade even if version check fails")
558
+
}
559
+
560
+
func TestUpgradeNode_UpgradeCommandFailure(t *testing.T) {
561
+
setupTestConfig()
562
+
cfg.Settings.DefaultTimeoutSeconds = 60
563
+
dryRun = false
564
+
preserve = true
565
+
566
+
mock := &talos.MockClient{
567
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
568
+
return "1.8.0", nil
569
+
},
570
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
571
+
return fmt.Errorf("upgrade rejected: disk full")
572
+
},
573
+
}
574
+
575
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
576
+
req := UpgradeRequest{
577
+
Node: node,
578
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
579
+
Version: "1.9.0",
580
+
}
581
+
skipped, err := upgradeNode(context.Background(), mock, req)
582
+
583
+
require.Error(t, err)
584
+
assert.False(t, skipped)
585
+
assert.Contains(t, err.Error(), "upgrade command failed")
586
+
}
587
+
588
+
func TestUpgradeNode_WatchUpgradeFailure(t *testing.T) {
589
+
setupTestConfig()
590
+
cfg.Settings.DefaultTimeoutSeconds = 60
591
+
dryRun = false
592
+
preserve = true
593
+
594
+
mock := &talos.MockClient{
595
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
596
+
return "1.8.0", nil
597
+
},
598
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
599
+
return nil
600
+
},
601
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
602
+
return fmt.Errorf("upgrade failed: kernel panic")
603
+
},
604
+
}
605
+
606
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
607
+
req := UpgradeRequest{
608
+
Node: node,
609
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
610
+
Version: "1.9.0",
611
+
}
612
+
skipped, err := upgradeNode(context.Background(), mock, req)
613
+
614
+
require.Error(t, err)
615
+
assert.False(t, skipped)
616
+
assert.Contains(t, err.Error(), "upgrade failed")
617
+
}
618
+
619
+
func TestUpgradeNode_ServiceWaitTimeout(t *testing.T) {
620
+
setupTestConfig()
621
+
cfg.Settings.DefaultTimeoutSeconds = 60
622
+
dryRun = false
623
+
preserve = true
624
+
625
+
mock := &talos.MockClient{
626
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
627
+
return "1.8.0", nil
628
+
},
629
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
630
+
return nil
631
+
},
632
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
633
+
return nil
634
+
},
635
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
636
+
return fmt.Errorf("timeout waiting for services")
637
+
},
638
+
}
639
+
640
+
node := config.Node{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker}
641
+
req := UpgradeRequest{
642
+
Node: node,
643
+
Image: "factory.talos.dev/installer/abc:v1.9.0",
644
+
Version: "1.9.0",
645
+
}
646
+
skipped, err := upgradeNode(context.Background(), mock, req)
647
+
648
+
// Service wait timeout should log warning but not fail the upgrade
649
+
require.NoError(t, err)
650
+
assert.False(t, skipped)
651
+
}
652
+
653
+
// ============================================================================
654
+
// confirm() Tests
655
+
// ============================================================================
656
+
657
+
func TestConfirm_Yes(t *testing.T) {
658
+
// Save and restore original reader
659
+
origReader := confirmReader
660
+
defer func() { confirmReader = origReader }()
661
+
662
+
confirmReader = strings.NewReader("y\n")
663
+
assert.True(t, confirm("Proceed?"))
664
+
}
665
+
666
+
func TestConfirm_YesFullWord(t *testing.T) {
667
+
origReader := confirmReader
668
+
defer func() { confirmReader = origReader }()
669
+
670
+
confirmReader = strings.NewReader("yes\n")
671
+
assert.True(t, confirm("Proceed?"))
672
+
}
673
+
674
+
func TestConfirm_YesUppercase(t *testing.T) {
675
+
origReader := confirmReader
676
+
defer func() { confirmReader = origReader }()
677
+
678
+
confirmReader = strings.NewReader("Y\n")
679
+
assert.True(t, confirm("Proceed?"))
680
+
}
681
+
682
+
func TestConfirm_No(t *testing.T) {
683
+
origReader := confirmReader
684
+
defer func() { confirmReader = origReader }()
685
+
686
+
confirmReader = strings.NewReader("n\n")
687
+
assert.False(t, confirm("Proceed?"))
688
+
}
689
+
690
+
func TestConfirm_Empty(t *testing.T) {
691
+
origReader := confirmReader
692
+
defer func() { confirmReader = origReader }()
693
+
694
+
confirmReader = strings.NewReader("\n")
695
+
assert.False(t, confirm("Proceed?"))
696
+
}
697
+
698
+
func TestConfirm_Invalid(t *testing.T) {
699
+
origReader := confirmReader
700
+
defer func() { confirmReader = origReader }()
701
+
702
+
confirmReader = strings.NewReader("maybe\n")
703
+
assert.False(t, confirm("Proceed?"))
704
+
}
705
+
706
+
// ============================================================================
707
+
// runURLs() Tests
708
+
// ============================================================================
709
+
710
+
func TestRunURLs_SingleProfile(t *testing.T) {
711
+
cfg = &config.Config{
712
+
Settings: config.Settings{
713
+
FactoryBaseURL: "https://factory.talos.dev",
714
+
},
715
+
Profiles: map[string]config.Profile{
716
+
"test-profile": {
717
+
Arch: "amd64",
718
+
Platform: "metal",
719
+
Extensions: []string{"siderolabs/i915"},
720
+
},
721
+
},
722
+
Nodes: []config.Node{
723
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
724
+
},
725
+
}
726
+
727
+
err := runURLs("1.9.0")
728
+
require.NoError(t, err)
729
+
}
730
+
731
+
func TestRunURLs_MultipleProfiles(t *testing.T) {
732
+
cfg = &config.Config{
733
+
Settings: config.Settings{
734
+
FactoryBaseURL: "https://factory.talos.dev",
735
+
},
736
+
Profiles: map[string]config.Profile{
737
+
"zebra-profile": {Arch: "amd64", Platform: "metal"},
738
+
"alpha-profile": {Arch: "arm64", Platform: "metal"},
739
+
"beta-profile": {Arch: "amd64", Platform: "metal", Secureboot: true},
740
+
},
741
+
Nodes: []config.Node{},
742
+
}
743
+
744
+
err := runURLs("1.9.0")
745
+
require.NoError(t, err)
746
+
}
747
+
748
+
func TestRunURLs_ProfileWithOverlay(t *testing.T) {
749
+
cfg = &config.Config{
750
+
Settings: config.Settings{
751
+
FactoryBaseURL: "https://factory.talos.dev",
752
+
},
753
+
Profiles: map[string]config.Profile{
754
+
"rpi-profile": {
755
+
Arch: "arm64",
756
+
Platform: "metal",
757
+
Overlay: &config.Overlay{
758
+
Name: "rpi_generic",
759
+
Image: "siderolabs/sbc-raspberrypi",
760
+
},
761
+
Extensions: []string{"siderolabs/iscsi-tools"},
762
+
},
763
+
},
764
+
Nodes: []config.Node{
765
+
{IP: "192.168.1.10", Profile: "rpi-profile", Role: config.RoleWorker},
766
+
},
767
+
}
768
+
769
+
err := runURLs("1.9.0")
770
+
require.NoError(t, err)
771
+
}
772
+
773
+
func TestRunURLs_ProfileWithKernelArgs(t *testing.T) {
774
+
cfg = &config.Config{
775
+
Settings: config.Settings{
776
+
FactoryBaseURL: "https://factory.talos.dev",
777
+
},
778
+
Profiles: map[string]config.Profile{
779
+
"custom-profile": {
780
+
Arch: "amd64",
781
+
Platform: "metal",
782
+
KernelArgs: []string{"amd_iommu=off", "nomodeset"},
783
+
Extensions: []string{"siderolabs/i915"},
784
+
},
785
+
},
786
+
Nodes: []config.Node{},
787
+
}
788
+
789
+
err := runURLs("1.9.0")
790
+
require.NoError(t, err)
791
+
}
792
+
793
+
func TestRunURLs_EmptyProfiles(t *testing.T) {
794
+
cfg = &config.Config{
795
+
Settings: config.Settings{
796
+
FactoryBaseURL: "https://factory.talos.dev",
797
+
},
798
+
Profiles: map[string]config.Profile{},
799
+
Nodes: []config.Node{},
800
+
}
801
+
802
+
err := runURLs("1.9.0")
803
+
require.NoError(t, err)
804
+
}
805
+
806
+
// ============================================================================
807
+
// runUpgradeWithClients() Tests
808
+
// ============================================================================
809
+
810
+
func TestRunUpgradeWithClients_Success(t *testing.T) {
811
+
cfg = &config.Config{
812
+
Settings: config.Settings{
813
+
FactoryBaseURL: "https://factory.talos.dev",
814
+
DefaultTimeoutSeconds: 60,
815
+
},
816
+
Profiles: map[string]config.Profile{
817
+
"test-profile": {Arch: "amd64", Platform: "metal"},
818
+
},
819
+
Nodes: []config.Node{
820
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
821
+
},
822
+
}
823
+
dryRun = true // Use dry run to simplify test
824
+
defer func() { dryRun = false }()
825
+
826
+
talosMock := &talos.MockClient{
827
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
828
+
return "1.8.0", nil
829
+
},
830
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
831
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
832
+
},
833
+
}
834
+
835
+
factoryMock := &factory.MockFactoryClient{
836
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
837
+
return fmt.Sprintf("factory.talos.dev/installer/abc:v%s", version), nil
838
+
},
839
+
}
840
+
841
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
842
+
require.NoError(t, err)
843
+
}
844
+
845
+
func TestRunUpgradeWithClients_NoNodesFound(t *testing.T) {
846
+
cfg = &config.Config{
847
+
Settings: config.Settings{
848
+
FactoryBaseURL: "https://factory.talos.dev",
849
+
DefaultTimeoutSeconds: 60,
850
+
},
851
+
Profiles: map[string]config.Profile{
852
+
"test-profile": {Arch: "amd64", Platform: "metal"},
853
+
},
854
+
Nodes: []config.Node{}, // No nodes
855
+
}
856
+
dryRun = false
857
+
858
+
talosMock := &talos.MockClient{}
859
+
factoryMock := &factory.MockFactoryClient{}
860
+
861
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
862
+
require.Error(t, err)
863
+
assert.Contains(t, err.Error(), "no nodes found")
864
+
}
865
+
866
+
func TestRunUpgradeWithClients_UnknownTarget(t *testing.T) {
867
+
cfg = &config.Config{
868
+
Settings: config.Settings{
869
+
FactoryBaseURL: "https://factory.talos.dev",
870
+
DefaultTimeoutSeconds: 60,
871
+
},
872
+
Profiles: map[string]config.Profile{
873
+
"test-profile": {Arch: "amd64", Platform: "metal"},
874
+
},
875
+
Nodes: []config.Node{
876
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
877
+
},
878
+
}
879
+
dryRun = false
880
+
881
+
talosMock := &talos.MockClient{}
882
+
factoryMock := &factory.MockFactoryClient{}
883
+
884
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "unknown-target", "1.9.0")
885
+
require.Error(t, err)
886
+
assert.Contains(t, err.Error(), "unknown target")
887
+
}
888
+
889
+
func TestRunUpgradeWithClients_DryRun(t *testing.T) {
890
+
cfg = &config.Config{
891
+
Settings: config.Settings{
892
+
FactoryBaseURL: "https://factory.talos.dev",
893
+
DefaultTimeoutSeconds: 60,
894
+
},
895
+
Profiles: map[string]config.Profile{
896
+
"test-profile": {Arch: "amd64", Platform: "metal"},
897
+
},
898
+
Nodes: []config.Node{
899
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
900
+
},
901
+
}
902
+
dryRun = true
903
+
defer func() { dryRun = false }()
904
+
905
+
upgradeWasCalled := false
906
+
talosMock := &talos.MockClient{
907
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
908
+
return "1.8.0", nil
909
+
},
910
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
911
+
upgradeWasCalled = true
912
+
return nil
913
+
},
914
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
915
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
916
+
},
917
+
}
918
+
919
+
factoryMock := &factory.MockFactoryClient{
920
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
921
+
return "factory.talos.dev/installer/abc:v1.9.0", nil
922
+
},
923
+
}
924
+
925
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
926
+
require.NoError(t, err)
927
+
assert.False(t, upgradeWasCalled, "upgrade should not be called in dry run mode")
928
+
}
929
+
930
+
func TestRunUpgradeWithClients_UserCancels(t *testing.T) {
931
+
cfg = &config.Config{
932
+
Settings: config.Settings{
933
+
FactoryBaseURL: "https://factory.talos.dev",
934
+
DefaultTimeoutSeconds: 60,
935
+
},
936
+
Profiles: map[string]config.Profile{
937
+
"test-profile": {Arch: "amd64", Platform: "metal"},
938
+
},
939
+
Nodes: []config.Node{
940
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
941
+
},
942
+
}
943
+
dryRun = false
944
+
945
+
// Mock user saying "no"
946
+
origReader := confirmReader
947
+
confirmReader = strings.NewReader("n\n")
948
+
defer func() { confirmReader = origReader }()
949
+
950
+
factoryCalled := false
951
+
talosMock := &talos.MockClient{}
952
+
factoryMock := &factory.MockFactoryClient{
953
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
954
+
factoryCalled = true
955
+
return "factory.talos.dev/installer/abc:v1.9.0", nil
956
+
},
957
+
}
958
+
959
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
960
+
require.NoError(t, err) // Cancellation is not an error
961
+
assert.False(t, factoryCalled, "factory should not be called after user cancels")
962
+
}
963
+
964
+
func TestRunUpgradeWithClients_FactoryError(t *testing.T) {
965
+
cfg = &config.Config{
966
+
Settings: config.Settings{
967
+
FactoryBaseURL: "https://factory.talos.dev",
968
+
DefaultTimeoutSeconds: 60,
969
+
},
970
+
Profiles: map[string]config.Profile{
971
+
"test-profile": {Arch: "amd64", Platform: "metal"},
972
+
},
973
+
Nodes: []config.Node{
974
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
975
+
},
976
+
}
977
+
dryRun = false
978
+
979
+
// Mock user confirming
980
+
origReader := confirmReader
981
+
confirmReader = strings.NewReader("y\n")
982
+
defer func() { confirmReader = origReader }()
983
+
984
+
talosMock := &talos.MockClient{}
985
+
factoryMock := &factory.MockFactoryClient{
986
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
987
+
return "", fmt.Errorf("API error: rate limited")
988
+
},
989
+
}
990
+
991
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
992
+
require.Error(t, err)
993
+
assert.Contains(t, err.Error(), "failed to get image")
994
+
assert.Contains(t, err.Error(), "test-profile")
995
+
}
996
+
997
+
func TestRunUpgradeWithClients_NodeFailure(t *testing.T) {
998
+
cfg = &config.Config{
999
+
Settings: config.Settings{
1000
+
FactoryBaseURL: "https://factory.talos.dev",
1001
+
DefaultTimeoutSeconds: 60,
1002
+
},
1003
+
Profiles: map[string]config.Profile{
1004
+
"test-profile": {Arch: "amd64", Platform: "metal"},
1005
+
},
1006
+
Nodes: []config.Node{
1007
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
1008
+
{IP: "192.168.1.2", Profile: "test-profile", Role: config.RoleWorker},
1009
+
},
1010
+
}
1011
+
dryRun = false
1012
+
1013
+
// Mock user confirming
1014
+
origReader := confirmReader
1015
+
confirmReader = strings.NewReader("y\n")
1016
+
defer func() { confirmReader = origReader }()
1017
+
1018
+
nodesUpgraded := make(map[string]bool)
1019
+
talosMock := &talos.MockClient{
1020
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
1021
+
return "1.8.0", nil
1022
+
},
1023
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
1024
+
if nodeIP == "192.168.1.1" {
1025
+
return fmt.Errorf("disk full")
1026
+
}
1027
+
nodesUpgraded[nodeIP] = true
1028
+
return nil
1029
+
},
1030
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, cb talos.ProgressCallback) error {
1031
+
return nil
1032
+
},
1033
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
1034
+
return nil
1035
+
},
1036
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
1037
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
1038
+
},
1039
+
}
1040
+
1041
+
factoryMock := &factory.MockFactoryClient{
1042
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
1043
+
return "factory.talos.dev/installer/abc:v1.9.0", nil
1044
+
},
1045
+
}
1046
+
1047
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
1048
+
require.NoError(t, err) // Overall should succeed even with one node failure
1049
+
assert.True(t, nodesUpgraded["192.168.1.2"], "second node should still be upgraded")
1050
+
}
1051
+
1052
+
func TestRunUpgradeWithClients_CPFailureAbort(t *testing.T) {
1053
+
cfg = &config.Config{
1054
+
Settings: config.Settings{
1055
+
FactoryBaseURL: "https://factory.talos.dev",
1056
+
DefaultTimeoutSeconds: 60,
1057
+
},
1058
+
Profiles: map[string]config.Profile{
1059
+
"test-profile": {Arch: "amd64", Platform: "metal"},
1060
+
},
1061
+
Nodes: []config.Node{
1062
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleControlPlane},
1063
+
{IP: "192.168.1.2", Profile: "test-profile", Role: config.RoleWorker},
1064
+
},
1065
+
}
1066
+
dryRun = false
1067
+
1068
+
// Mock user confirming first prompt, then aborting on CP failure
1069
+
origReader := confirmReader
1070
+
confirmReader = strings.NewReader("y\nn\n")
1071
+
defer func() { confirmReader = origReader }()
1072
+
1073
+
secondNodeAttempted := false
1074
+
talosMock := &talos.MockClient{
1075
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
1076
+
return "1.8.0", nil
1077
+
},
1078
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
1079
+
if nodeIP == "192.168.1.1" {
1080
+
return fmt.Errorf("etcd cluster unhealthy")
1081
+
}
1082
+
secondNodeAttempted = true
1083
+
return nil
1084
+
},
1085
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
1086
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
1087
+
},
1088
+
}
1089
+
1090
+
factoryMock := &factory.MockFactoryClient{
1091
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
1092
+
return "factory.talos.dev/installer/abc:v1.9.0", nil
1093
+
},
1094
+
}
1095
+
1096
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "controlplanes", "1.9.0")
1097
+
require.NoError(t, err)
1098
+
assert.False(t, secondNodeAttempted, "should not attempt second node after user aborts")
1099
+
}
1100
+
1101
+
func TestRunUpgradeWithClients_AlreadyAtVersion(t *testing.T) {
1102
+
cfg = &config.Config{
1103
+
Settings: config.Settings{
1104
+
FactoryBaseURL: "https://factory.talos.dev",
1105
+
DefaultTimeoutSeconds: 60,
1106
+
},
1107
+
Profiles: map[string]config.Profile{
1108
+
"test-profile": {Arch: "amd64", Platform: "metal"},
1109
+
},
1110
+
Nodes: []config.Node{
1111
+
{IP: "192.168.1.1", Profile: "test-profile", Role: config.RoleWorker},
1112
+
},
1113
+
}
1114
+
dryRun = false
1115
+
1116
+
// Mock user confirming
1117
+
origReader := confirmReader
1118
+
confirmReader = strings.NewReader("y\n")
1119
+
defer func() { confirmReader = origReader }()
1120
+
1121
+
upgradeWasCalled := false
1122
+
talosMock := &talos.MockClient{
1123
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
1124
+
return "1.9.0", nil // Already at target version
1125
+
},
1126
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, pres bool) error {
1127
+
upgradeWasCalled = true
1128
+
return nil
1129
+
},
1130
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
1131
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
1132
+
},
1133
+
}
1134
+
1135
+
factoryMock := &factory.MockFactoryClient{
1136
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
1137
+
return "factory.talos.dev/installer/abc:v1.9.0", nil
1138
+
},
1139
+
}
1140
+
1141
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
1142
+
require.NoError(t, err)
1143
+
assert.False(t, upgradeWasCalled, "should skip upgrade for node already at version")
1144
+
}
1145
+
1146
+
func TestRunUpgradeWithClients_MultipleProfiles(t *testing.T) {
1147
+
cfg = &config.Config{
1148
+
Settings: config.Settings{
1149
+
FactoryBaseURL: "https://factory.talos.dev",
1150
+
DefaultTimeoutSeconds: 60,
1151
+
},
1152
+
Profiles: map[string]config.Profile{
1153
+
"profile-a": {Arch: "amd64", Platform: "metal"},
1154
+
"profile-b": {Arch: "arm64", Platform: "metal"},
1155
+
},
1156
+
Nodes: []config.Node{
1157
+
{IP: "192.168.1.1", Profile: "profile-a", Role: config.RoleWorker},
1158
+
{IP: "192.168.1.2", Profile: "profile-b", Role: config.RoleWorker},
1159
+
},
1160
+
}
1161
+
dryRun = true
1162
+
defer func() { dryRun = false }()
1163
+
1164
+
profilesRequested := make(map[string]bool)
1165
+
talosMock := &talos.MockClient{
1166
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
1167
+
return "1.8.0", nil
1168
+
},
1169
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) talos.NodeStatus {
1170
+
return talos.NodeStatus{IP: nodeIP, Version: "1.9.0", Profile: profile, Role: role}
1171
+
},
1172
+
}
1173
+
1174
+
factoryMock := &factory.MockFactoryClient{
1175
+
GetInstallerImageFunc: func(profile config.Profile, version string) (string, error) {
1176
+
if profile.Arch == "amd64" {
1177
+
profilesRequested["profile-a"] = true
1178
+
} else if profile.Arch == "arm64" {
1179
+
profilesRequested["profile-b"] = true
1180
+
}
1181
+
return fmt.Sprintf("factory.talos.dev/installer/%s:v%s", profile.Arch, version), nil
1182
+
},
1183
+
}
1184
+
1185
+
err := runUpgradeWithClients(context.Background(), talosMock, factoryMock, "all", "1.9.0")
1186
+
require.NoError(t, err)
1187
+
assert.True(t, profilesRequested["profile-a"], "should request image for profile-a")
1188
+
assert.True(t, profilesRequested["profile-b"], "should request image for profile-b")
1189
+
}
+87
internal/cmd/urls.go
+87
internal/cmd/urls.go
···
1
+
package cmd
2
+
3
+
import (
4
+
"fmt"
5
+
"sort"
6
+
7
+
"github.com/evanjarrett/homelab/internal/factory"
8
+
"github.com/evanjarrett/homelab/internal/output"
9
+
"github.com/spf13/cobra"
10
+
)
11
+
12
+
func urlsCmd() *cobra.Command {
13
+
return &cobra.Command{
14
+
Use: "urls [version]",
15
+
Short: "Generate factory URLs for each profile (for browser)",
16
+
Long: `Generate factory.talos.dev URLs that can be opened in a browser
17
+
to download images or get installer commands for each profile.`,
18
+
Args: cobra.MaximumNArgs(1),
19
+
PreRunE: func(cmd *cobra.Command, args []string) error {
20
+
return loadConfig()
21
+
},
22
+
RunE: func(cmd *cobra.Command, args []string) error {
23
+
// Get version from args or flags
24
+
version := ""
25
+
if len(args) > 0 {
26
+
version = args[0]
27
+
}
28
+
if version == "" {
29
+
var err error
30
+
version, err = getVersion()
31
+
if err != nil {
32
+
return err
33
+
}
34
+
}
35
+
36
+
return runURLs(version)
37
+
},
38
+
}
39
+
}
40
+
41
+
func runURLs(version string) error {
42
+
output.Header("Factory URLs for Talos v%s", version)
43
+
fmt.Println()
44
+
fmt.Println("Open these URLs in a browser to download images or get installer commands.")
45
+
fmt.Println()
46
+
47
+
// Sort profile names for consistent output
48
+
var profileNames []string
49
+
for name := range cfg.Profiles {
50
+
profileNames = append(profileNames, name)
51
+
}
52
+
sort.Strings(profileNames)
53
+
54
+
for _, name := range profileNames {
55
+
profile := cfg.Profiles[name]
56
+
57
+
output.SubHeader("Profile: %s", name)
58
+
59
+
// Print profile info
60
+
fmt.Printf(" Arch: %s, Secureboot: %v\n", profile.Arch, profile.Secureboot)
61
+
if profile.Overlay != nil {
62
+
fmt.Printf(" Overlay: %s\n", profile.Overlay.Name)
63
+
}
64
+
if len(profile.KernelArgs) > 0 {
65
+
fmt.Printf(" Kernel Args: %v\n", profile.KernelArgs)
66
+
}
67
+
fmt.Printf(" Extensions: %v\n", profile.Extensions)
68
+
fmt.Println()
69
+
70
+
// Generate and print URL
71
+
url := factory.GenerateFactoryURL(profile, version, cfg.Settings.FactoryBaseURL)
72
+
fmt.Printf(" URL:\n%s\n", url)
73
+
fmt.Println()
74
+
75
+
// Print nodes using this profile
76
+
nodes := cfg.GetNodesByProfile(name)
77
+
if len(nodes) > 0 {
78
+
fmt.Println(" Nodes:")
79
+
for _, node := range nodes {
80
+
fmt.Printf(" - %s (%s)\n", node.IP, node.Role)
81
+
}
82
+
fmt.Println()
83
+
}
84
+
}
85
+
86
+
return nil
87
+
}
+184
internal/config/config.go
+184
internal/config/config.go
···
1
+
package config
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"os"
8
+
"path/filepath"
9
+
"time"
10
+
11
+
"gopkg.in/yaml.v3"
12
+
)
13
+
14
+
// DefaultConfigPaths defines where to search for config files
15
+
var DefaultConfigPaths = []string{
16
+
"configs/talos-profiles.yaml",
17
+
"talos-profiles.yaml",
18
+
}
19
+
20
+
// Load reads and parses the configuration file
21
+
func Load(path string) (*Config, error) {
22
+
if path == "" {
23
+
path = findDefaultConfig()
24
+
if path == "" {
25
+
return nil, fmt.Errorf("no config file found, tried: %v", DefaultConfigPaths)
26
+
}
27
+
}
28
+
29
+
data, err := os.ReadFile(path)
30
+
if err != nil {
31
+
return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
32
+
}
33
+
34
+
var cfg Config
35
+
if err := yaml.Unmarshal(data, &cfg); err != nil {
36
+
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
37
+
}
38
+
39
+
if err := cfg.Validate(); err != nil {
40
+
return nil, fmt.Errorf("invalid config: %w", err)
41
+
}
42
+
43
+
return &cfg, nil
44
+
}
45
+
46
+
// findDefaultConfig searches for config in default locations
47
+
func findDefaultConfig() string {
48
+
// First check relative to current directory
49
+
for _, p := range DefaultConfigPaths {
50
+
if _, err := os.Stat(p); err == nil {
51
+
return p
52
+
}
53
+
}
54
+
55
+
// Check relative to executable location
56
+
exe, err := os.Executable()
57
+
if err == nil {
58
+
exeDir := filepath.Dir(exe)
59
+
for _, p := range DefaultConfigPaths {
60
+
fullPath := filepath.Join(exeDir, p)
61
+
if _, err := os.Stat(fullPath); err == nil {
62
+
return fullPath
63
+
}
64
+
}
65
+
}
66
+
67
+
return ""
68
+
}
69
+
70
+
// Validate checks that the configuration is valid
71
+
func (c *Config) Validate() error {
72
+
if len(c.Profiles) == 0 {
73
+
return fmt.Errorf("no profiles defined")
74
+
}
75
+
76
+
// Validate each profile
77
+
for name, profile := range c.Profiles {
78
+
if profile.Arch == "" {
79
+
return fmt.Errorf("profile %s: arch is required", name)
80
+
}
81
+
if profile.Platform == "" {
82
+
return fmt.Errorf("profile %s: platform is required", name)
83
+
}
84
+
}
85
+
86
+
// Either nodes or detection must be configured
87
+
hasNodes := len(c.Nodes) > 0
88
+
hasDetection := c.Detection != nil && len(c.Detection.Rules) > 0
89
+
90
+
if !hasNodes && !hasDetection {
91
+
return fmt.Errorf("either nodes or detection rules must be defined")
92
+
}
93
+
94
+
// Validate node references (if using legacy nodes config)
95
+
for _, node := range c.Nodes {
96
+
if node.IP == "" {
97
+
return fmt.Errorf("node with empty IP found")
98
+
}
99
+
if node.Profile == "" {
100
+
return fmt.Errorf("node %s: profile is required", node.IP)
101
+
}
102
+
if _, ok := c.Profiles[node.Profile]; !ok {
103
+
return fmt.Errorf("node %s: references unknown profile %s", node.IP, node.Profile)
104
+
}
105
+
if node.Role != RoleControlPlane && node.Role != RoleWorker {
106
+
return fmt.Errorf("node %s: role must be 'controlplane' or 'worker', got %s", node.IP, node.Role)
107
+
}
108
+
}
109
+
110
+
// Validate detection rules reference valid profiles
111
+
if c.Detection != nil {
112
+
for i, rule := range c.Detection.Rules {
113
+
if rule.Profile == "" {
114
+
return fmt.Errorf("detection rule %d: profile is required", i)
115
+
}
116
+
if _, ok := c.Profiles[rule.Profile]; !ok {
117
+
return fmt.Errorf("detection rule %d: references unknown profile %s", i, rule.Profile)
118
+
}
119
+
if rule.Match.SystemManufacturer == "" && rule.Match.ProcessorManufacturer == "" {
120
+
return fmt.Errorf("detection rule %d: at least one match criterion required", i)
121
+
}
122
+
}
123
+
}
124
+
125
+
return nil
126
+
}
127
+
128
+
// HasDetection returns true if detection rules are configured
129
+
func (c *Config) HasDetection() bool {
130
+
return c.Detection != nil && len(c.Detection.Rules) > 0
131
+
}
132
+
133
+
// SetDefaults fills in default values for settings
134
+
func (c *Config) SetDefaults() {
135
+
if c.Settings.FactoryBaseURL == "" {
136
+
c.Settings.FactoryBaseURL = "https://factory.talos.dev"
137
+
}
138
+
if c.Settings.DefaultTimeoutSeconds == 0 {
139
+
c.Settings.DefaultTimeoutSeconds = 600
140
+
}
141
+
if c.Settings.GithubReleasesURL == "" {
142
+
c.Settings.GithubReleasesURL = "https://api.github.com/repos/siderolabs/talos/releases/latest"
143
+
}
144
+
}
145
+
146
+
// githubRelease represents the GitHub API response for releases
147
+
type githubRelease struct {
148
+
TagName string `json:"tag_name"`
149
+
}
150
+
151
+
// GetLatestTalosVersion fetches the latest Talos version from GitHub
152
+
func GetLatestTalosVersion(githubURL string) (string, error) {
153
+
if githubURL == "" {
154
+
githubURL = "https://api.github.com/repos/siderolabs/talos/releases/latest"
155
+
}
156
+
157
+
client := &http.Client{Timeout: 10 * time.Second}
158
+
resp, err := client.Get(githubURL)
159
+
if err != nil {
160
+
return "", fmt.Errorf("failed to fetch latest version: %w", err)
161
+
}
162
+
defer resp.Body.Close()
163
+
164
+
if resp.StatusCode != http.StatusOK {
165
+
return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
166
+
}
167
+
168
+
var release githubRelease
169
+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
170
+
return "", fmt.Errorf("failed to decode GitHub response: %w", err)
171
+
}
172
+
173
+
// Strip leading 'v' if present
174
+
version := release.TagName
175
+
if len(version) > 0 && version[0] == 'v' {
176
+
version = version[1:]
177
+
}
178
+
179
+
if version == "" {
180
+
return "", fmt.Errorf("empty version returned from GitHub")
181
+
}
182
+
183
+
return version, nil
184
+
}
+944
internal/config/config_test.go
+944
internal/config/config_test.go
···
1
+
package config
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/http/httptest"
7
+
"os"
8
+
"path/filepath"
9
+
"testing"
10
+
11
+
"github.com/stretchr/testify/assert"
12
+
"github.com/stretchr/testify/require"
13
+
)
14
+
15
+
// testdataDir returns the path to the testdata directory
16
+
func testdataDir(t *testing.T) string {
17
+
t.Helper()
18
+
dir, err := os.Getwd()
19
+
require.NoError(t, err)
20
+
return filepath.Join(dir, "testdata")
21
+
}
22
+
23
+
func testdataPath(t *testing.T, filename string) string {
24
+
t.Helper()
25
+
return filepath.Join(testdataDir(t), filename)
26
+
}
27
+
28
+
// ============================================================================
29
+
// Load() Tests
30
+
// ============================================================================
31
+
32
+
func TestLoad_ValidConfig(t *testing.T) {
33
+
cfg, err := Load(testdataPath(t, "valid-config.yaml"))
34
+
require.NoError(t, err)
35
+
require.NotNil(t, cfg)
36
+
37
+
assert.Len(t, cfg.Profiles, 2)
38
+
assert.Len(t, cfg.Nodes, 3)
39
+
40
+
// Verify profile content
41
+
intelProfile, ok := cfg.Profiles["amd64-intel"]
42
+
require.True(t, ok)
43
+
assert.Equal(t, "amd64", intelProfile.Arch)
44
+
assert.Equal(t, "metal", intelProfile.Platform)
45
+
assert.True(t, intelProfile.Secureboot)
46
+
assert.Contains(t, intelProfile.Extensions, "siderolabs/i915")
47
+
48
+
// Verify node content
49
+
assert.Equal(t, "192.168.1.101", cfg.Nodes[0].IP)
50
+
assert.Equal(t, "arm64-rpi", cfg.Nodes[0].Profile)
51
+
assert.Equal(t, "worker", cfg.Nodes[0].Role)
52
+
53
+
// Verify settings
54
+
assert.Equal(t, "https://factory.talos.dev", cfg.Settings.FactoryBaseURL)
55
+
assert.Equal(t, 600, cfg.Settings.DefaultTimeoutSeconds)
56
+
}
57
+
58
+
func TestLoad_MinimalConfig(t *testing.T) {
59
+
cfg, err := Load(testdataPath(t, "minimal-config.yaml"))
60
+
require.NoError(t, err)
61
+
require.NotNil(t, cfg)
62
+
63
+
assert.Len(t, cfg.Profiles, 1)
64
+
assert.Len(t, cfg.Nodes, 1)
65
+
}
66
+
67
+
func TestLoad_EmptyPathNoDefaultConfig(t *testing.T) {
68
+
// Change to temp dir with no config files to test findDefaultConfig() failure
69
+
origDir, err := os.Getwd()
70
+
require.NoError(t, err)
71
+
72
+
tmpDir := t.TempDir()
73
+
err = os.Chdir(tmpDir)
74
+
require.NoError(t, err)
75
+
defer os.Chdir(origDir)
76
+
77
+
cfg, err := Load("")
78
+
assert.Error(t, err)
79
+
assert.Nil(t, cfg)
80
+
assert.Contains(t, err.Error(), "no config file found")
81
+
}
82
+
83
+
func TestLoad_NonexistentFile(t *testing.T) {
84
+
cfg, err := Load(testdataPath(t, "nonexistent.yaml"))
85
+
assert.Error(t, err)
86
+
assert.Nil(t, cfg)
87
+
assert.Contains(t, err.Error(), "failed to read config file")
88
+
}
89
+
90
+
func TestLoad_MalformedYAML(t *testing.T) {
91
+
cfg, err := Load(testdataPath(t, "malformed.yaml"))
92
+
assert.Error(t, err)
93
+
assert.Nil(t, cfg)
94
+
assert.Contains(t, err.Error(), "failed to parse config file")
95
+
}
96
+
97
+
func TestLoad_InvalidNoNodes(t *testing.T) {
98
+
cfg, err := Load(testdataPath(t, "invalid-no-nodes.yaml"))
99
+
assert.Error(t, err)
100
+
assert.Nil(t, cfg)
101
+
assert.Contains(t, err.Error(), "either nodes or detection rules must be defined")
102
+
}
103
+
104
+
func TestLoad_InvalidNoProfiles(t *testing.T) {
105
+
cfg, err := Load(testdataPath(t, "invalid-no-profiles.yaml"))
106
+
assert.Error(t, err)
107
+
assert.Nil(t, cfg)
108
+
assert.Contains(t, err.Error(), "no profiles defined")
109
+
}
110
+
111
+
func TestLoad_InvalidUnknownProfile(t *testing.T) {
112
+
cfg, err := Load(testdataPath(t, "invalid-unknown-profile.yaml"))
113
+
assert.Error(t, err)
114
+
assert.Nil(t, cfg)
115
+
assert.Contains(t, err.Error(), "references unknown profile")
116
+
}
117
+
118
+
func TestLoad_InvalidMissingArch(t *testing.T) {
119
+
cfg, err := Load(testdataPath(t, "invalid-missing-arch.yaml"))
120
+
assert.Error(t, err)
121
+
assert.Nil(t, cfg)
122
+
assert.Contains(t, err.Error(), "arch is required")
123
+
}
124
+
125
+
func TestLoad_InvalidBadRole(t *testing.T) {
126
+
cfg, err := Load(testdataPath(t, "invalid-bad-role.yaml"))
127
+
assert.Error(t, err)
128
+
assert.Nil(t, cfg)
129
+
assert.Contains(t, err.Error(), "role must be 'controlplane' or 'worker'")
130
+
}
131
+
132
+
// ============================================================================
133
+
// Validate() Tests
134
+
// ============================================================================
135
+
136
+
func TestValidate_Success(t *testing.T) {
137
+
cfg := &Config{
138
+
Profiles: map[string]Profile{
139
+
"test": {Arch: "amd64", Platform: "metal"},
140
+
},
141
+
Nodes: []Node{
142
+
{IP: "192.168.1.1", Profile: "test", Role: RoleControlPlane},
143
+
},
144
+
}
145
+
assert.NoError(t, cfg.Validate())
146
+
}
147
+
148
+
func TestValidate_EmptyProfiles(t *testing.T) {
149
+
cfg := &Config{
150
+
Profiles: map[string]Profile{},
151
+
Nodes: []Node{
152
+
{IP: "192.168.1.1", Profile: "test", Role: RoleControlPlane},
153
+
},
154
+
}
155
+
err := cfg.Validate()
156
+
assert.Error(t, err)
157
+
assert.Contains(t, err.Error(), "no profiles defined")
158
+
}
159
+
160
+
func TestValidate_EmptyNodes(t *testing.T) {
161
+
cfg := &Config{
162
+
Profiles: map[string]Profile{
163
+
"test": {Arch: "amd64", Platform: "metal"},
164
+
},
165
+
Nodes: []Node{},
166
+
}
167
+
err := cfg.Validate()
168
+
assert.Error(t, err)
169
+
assert.Contains(t, err.Error(), "either nodes or detection rules must be defined")
170
+
}
171
+
172
+
func TestValidate_ProfileMissingArch(t *testing.T) {
173
+
cfg := &Config{
174
+
Profiles: map[string]Profile{
175
+
"test": {Platform: "metal"},
176
+
},
177
+
Nodes: []Node{
178
+
{IP: "192.168.1.1", Profile: "test", Role: RoleControlPlane},
179
+
},
180
+
}
181
+
err := cfg.Validate()
182
+
assert.Error(t, err)
183
+
assert.Contains(t, err.Error(), "arch is required")
184
+
}
185
+
186
+
func TestValidate_ProfileMissingPlatform(t *testing.T) {
187
+
cfg := &Config{
188
+
Profiles: map[string]Profile{
189
+
"test": {Arch: "amd64"},
190
+
},
191
+
Nodes: []Node{
192
+
{IP: "192.168.1.1", Profile: "test", Role: RoleControlPlane},
193
+
},
194
+
}
195
+
err := cfg.Validate()
196
+
assert.Error(t, err)
197
+
assert.Contains(t, err.Error(), "platform is required")
198
+
}
199
+
200
+
func TestValidate_NodeEmptyIP(t *testing.T) {
201
+
cfg := &Config{
202
+
Profiles: map[string]Profile{
203
+
"test": {Arch: "amd64", Platform: "metal"},
204
+
},
205
+
Nodes: []Node{
206
+
{IP: "", Profile: "test", Role: RoleControlPlane},
207
+
},
208
+
}
209
+
err := cfg.Validate()
210
+
assert.Error(t, err)
211
+
assert.Contains(t, err.Error(), "node with empty IP")
212
+
}
213
+
214
+
func TestValidate_NodeEmptyProfile(t *testing.T) {
215
+
cfg := &Config{
216
+
Profiles: map[string]Profile{
217
+
"test": {Arch: "amd64", Platform: "metal"},
218
+
},
219
+
Nodes: []Node{
220
+
{IP: "192.168.1.1", Profile: "", Role: RoleControlPlane},
221
+
},
222
+
}
223
+
err := cfg.Validate()
224
+
assert.Error(t, err)
225
+
assert.Contains(t, err.Error(), "profile is required")
226
+
}
227
+
228
+
func TestValidate_NodeUnknownProfile(t *testing.T) {
229
+
cfg := &Config{
230
+
Profiles: map[string]Profile{
231
+
"test": {Arch: "amd64", Platform: "metal"},
232
+
},
233
+
Nodes: []Node{
234
+
{IP: "192.168.1.1", Profile: "unknown", Role: RoleControlPlane},
235
+
},
236
+
}
237
+
err := cfg.Validate()
238
+
assert.Error(t, err)
239
+
assert.Contains(t, err.Error(), "references unknown profile")
240
+
}
241
+
242
+
func TestValidate_NodeInvalidRole(t *testing.T) {
243
+
tests := []struct {
244
+
name string
245
+
role string
246
+
}{
247
+
{"empty role", ""},
248
+
{"master role", "master"},
249
+
{"invalid role", "invalid"},
250
+
}
251
+
for _, tt := range tests {
252
+
t.Run(tt.name, func(t *testing.T) {
253
+
cfg := &Config{
254
+
Profiles: map[string]Profile{
255
+
"test": {Arch: "amd64", Platform: "metal"},
256
+
},
257
+
Nodes: []Node{
258
+
{IP: "192.168.1.1", Profile: "test", Role: tt.role},
259
+
},
260
+
}
261
+
err := cfg.Validate()
262
+
assert.Error(t, err)
263
+
assert.Contains(t, err.Error(), "role must be 'controlplane' or 'worker'")
264
+
})
265
+
}
266
+
}
267
+
268
+
// ============================================================================
269
+
// SetDefaults() Tests
270
+
// ============================================================================
271
+
272
+
func TestSetDefaults_Empty(t *testing.T) {
273
+
cfg := &Config{}
274
+
cfg.SetDefaults()
275
+
276
+
assert.Equal(t, "https://factory.talos.dev", cfg.Settings.FactoryBaseURL)
277
+
assert.Equal(t, 600, cfg.Settings.DefaultTimeoutSeconds)
278
+
assert.Equal(t, "https://api.github.com/repos/siderolabs/talos/releases/latest", cfg.Settings.GithubReleasesURL)
279
+
}
280
+
281
+
func TestSetDefaults_PreservesExisting(t *testing.T) {
282
+
cfg := &Config{
283
+
Settings: Settings{
284
+
FactoryBaseURL: "https://custom.factory.dev",
285
+
DefaultTimeoutSeconds: 1200,
286
+
GithubReleasesURL: "https://custom.github.api/releases",
287
+
},
288
+
}
289
+
cfg.SetDefaults()
290
+
291
+
assert.Equal(t, "https://custom.factory.dev", cfg.Settings.FactoryBaseURL)
292
+
assert.Equal(t, 1200, cfg.Settings.DefaultTimeoutSeconds)
293
+
assert.Equal(t, "https://custom.github.api/releases", cfg.Settings.GithubReleasesURL)
294
+
}
295
+
296
+
func TestSetDefaults_PartialSettings(t *testing.T) {
297
+
cfg := &Config{
298
+
Settings: Settings{
299
+
FactoryBaseURL: "https://custom.factory.dev",
300
+
// DefaultTimeoutSeconds and GithubReleasesURL are zero/empty
301
+
},
302
+
}
303
+
cfg.SetDefaults()
304
+
305
+
assert.Equal(t, "https://custom.factory.dev", cfg.Settings.FactoryBaseURL)
306
+
assert.Equal(t, 600, cfg.Settings.DefaultTimeoutSeconds)
307
+
assert.Equal(t, "https://api.github.com/repos/siderolabs/talos/releases/latest", cfg.Settings.GithubReleasesURL)
308
+
}
309
+
310
+
// ============================================================================
311
+
// GetNodesByRole() Tests
312
+
// ============================================================================
313
+
314
+
func TestGetNodesByRole(t *testing.T) {
315
+
cfg := &Config{
316
+
Nodes: []Node{
317
+
{IP: "192.168.1.1", Role: RoleControlPlane},
318
+
{IP: "192.168.1.2", Role: RoleWorker},
319
+
{IP: "192.168.1.3", Role: RoleWorker},
320
+
{IP: "192.168.1.4", Role: RoleControlPlane},
321
+
},
322
+
}
323
+
324
+
t.Run("control planes", func(t *testing.T) {
325
+
nodes := cfg.GetNodesByRole(RoleControlPlane)
326
+
assert.Len(t, nodes, 2)
327
+
assert.Equal(t, "192.168.1.1", nodes[0].IP)
328
+
assert.Equal(t, "192.168.1.4", nodes[1].IP)
329
+
})
330
+
331
+
t.Run("workers", func(t *testing.T) {
332
+
nodes := cfg.GetNodesByRole(RoleWorker)
333
+
assert.Len(t, nodes, 2)
334
+
assert.Equal(t, "192.168.1.2", nodes[0].IP)
335
+
assert.Equal(t, "192.168.1.3", nodes[1].IP)
336
+
})
337
+
338
+
t.Run("no matching role", func(t *testing.T) {
339
+
nodes := cfg.GetNodesByRole("unknown")
340
+
assert.Empty(t, nodes)
341
+
})
342
+
}
343
+
344
+
// ============================================================================
345
+
// GetNodesByProfile() Tests
346
+
// ============================================================================
347
+
348
+
func TestGetNodesByProfile(t *testing.T) {
349
+
cfg := &Config{
350
+
Nodes: []Node{
351
+
{IP: "192.168.1.1", Profile: "profile-a"},
352
+
{IP: "192.168.1.2", Profile: "profile-b"},
353
+
{IP: "192.168.1.3", Profile: "profile-a"},
354
+
{IP: "192.168.1.4", Profile: "profile-c"},
355
+
},
356
+
}
357
+
358
+
t.Run("profile-a", func(t *testing.T) {
359
+
nodes := cfg.GetNodesByProfile("profile-a")
360
+
assert.Len(t, nodes, 2)
361
+
assert.Equal(t, "192.168.1.1", nodes[0].IP)
362
+
assert.Equal(t, "192.168.1.3", nodes[1].IP)
363
+
})
364
+
365
+
t.Run("profile-b", func(t *testing.T) {
366
+
nodes := cfg.GetNodesByProfile("profile-b")
367
+
assert.Len(t, nodes, 1)
368
+
assert.Equal(t, "192.168.1.2", nodes[0].IP)
369
+
})
370
+
371
+
t.Run("no matching profile", func(t *testing.T) {
372
+
nodes := cfg.GetNodesByProfile("unknown")
373
+
assert.Empty(t, nodes)
374
+
})
375
+
}
376
+
377
+
// ============================================================================
378
+
// GetNodeByIP() Tests
379
+
// ============================================================================
380
+
381
+
func TestGetNodeByIP(t *testing.T) {
382
+
cfg := &Config{
383
+
Nodes: []Node{
384
+
{IP: "192.168.1.1", Profile: "test", Role: RoleControlPlane},
385
+
{IP: "192.168.1.2", Profile: "test", Role: RoleWorker},
386
+
},
387
+
}
388
+
389
+
t.Run("existing node", func(t *testing.T) {
390
+
node := cfg.GetNodeByIP("192.168.1.1")
391
+
require.NotNil(t, node)
392
+
assert.Equal(t, "192.168.1.1", node.IP)
393
+
assert.Equal(t, RoleControlPlane, node.Role)
394
+
})
395
+
396
+
t.Run("non-existing node", func(t *testing.T) {
397
+
node := cfg.GetNodeByIP("192.168.1.99")
398
+
assert.Nil(t, node)
399
+
})
400
+
401
+
t.Run("empty IP", func(t *testing.T) {
402
+
node := cfg.GetNodeByIP("")
403
+
assert.Nil(t, node)
404
+
})
405
+
}
406
+
407
+
// ============================================================================
408
+
// GetProfileForNode() Tests
409
+
// ============================================================================
410
+
411
+
func TestGetProfileForNode(t *testing.T) {
412
+
cfg := &Config{
413
+
Profiles: map[string]Profile{
414
+
"profile-a": {Arch: "amd64", Platform: "metal"},
415
+
"profile-b": {Arch: "arm64", Platform: "metal"},
416
+
},
417
+
Nodes: []Node{
418
+
{IP: "192.168.1.1", Profile: "profile-a"},
419
+
{IP: "192.168.1.2", Profile: "profile-b"},
420
+
},
421
+
}
422
+
423
+
t.Run("existing node with profile", func(t *testing.T) {
424
+
profile := cfg.GetProfileForNode("192.168.1.1")
425
+
require.NotNil(t, profile)
426
+
assert.Equal(t, "amd64", profile.Arch)
427
+
})
428
+
429
+
t.Run("non-existing node", func(t *testing.T) {
430
+
profile := cfg.GetProfileForNode("192.168.1.99")
431
+
assert.Nil(t, profile)
432
+
})
433
+
434
+
t.Run("node with missing profile definition", func(t *testing.T) {
435
+
cfg := &Config{
436
+
Profiles: map[string]Profile{
437
+
"profile-a": {Arch: "amd64", Platform: "metal"},
438
+
},
439
+
Nodes: []Node{
440
+
{IP: "192.168.1.1", Profile: "missing-profile"},
441
+
},
442
+
}
443
+
profile := cfg.GetProfileForNode("192.168.1.1")
444
+
assert.Nil(t, profile)
445
+
})
446
+
}
447
+
448
+
// ============================================================================
449
+
// GetControlPlaneNodes() and GetWorkerNodes() Tests
450
+
// ============================================================================
451
+
452
+
func TestGetControlPlaneNodes(t *testing.T) {
453
+
cfg := &Config{
454
+
Nodes: []Node{
455
+
{IP: "192.168.1.1", Role: RoleControlPlane},
456
+
{IP: "192.168.1.2", Role: RoleWorker},
457
+
{IP: "192.168.1.3", Role: RoleControlPlane},
458
+
},
459
+
}
460
+
461
+
nodes := cfg.GetControlPlaneNodes()
462
+
assert.Len(t, nodes, 2)
463
+
assert.Equal(t, "192.168.1.1", nodes[0].IP)
464
+
assert.Equal(t, "192.168.1.3", nodes[1].IP)
465
+
}
466
+
467
+
func TestGetWorkerNodes(t *testing.T) {
468
+
cfg := &Config{
469
+
Nodes: []Node{
470
+
{IP: "192.168.1.1", Role: RoleControlPlane},
471
+
{IP: "192.168.1.2", Role: RoleWorker},
472
+
{IP: "192.168.1.3", Role: RoleWorker},
473
+
},
474
+
}
475
+
476
+
nodes := cfg.GetWorkerNodes()
477
+
assert.Len(t, nodes, 2)
478
+
assert.Equal(t, "192.168.1.2", nodes[0].IP)
479
+
assert.Equal(t, "192.168.1.3", nodes[1].IP)
480
+
}
481
+
482
+
// ============================================================================
483
+
// GetAllNodesOrdered() Tests
484
+
// ============================================================================
485
+
486
+
func TestGetAllNodesOrdered(t *testing.T) {
487
+
cfg := &Config{
488
+
Nodes: []Node{
489
+
{IP: "192.168.1.1", Role: RoleControlPlane},
490
+
{IP: "192.168.1.2", Role: RoleWorker},
491
+
{IP: "192.168.1.3", Role: RoleControlPlane},
492
+
{IP: "192.168.1.4", Role: RoleWorker},
493
+
},
494
+
}
495
+
496
+
nodes := cfg.GetAllNodesOrdered()
497
+
require.Len(t, nodes, 4)
498
+
499
+
// Workers should come first
500
+
assert.Equal(t, "192.168.1.2", nodes[0].IP)
501
+
assert.Equal(t, RoleWorker, nodes[0].Role)
502
+
assert.Equal(t, "192.168.1.4", nodes[1].IP)
503
+
assert.Equal(t, RoleWorker, nodes[1].Role)
504
+
505
+
// Then control planes
506
+
assert.Equal(t, "192.168.1.1", nodes[2].IP)
507
+
assert.Equal(t, RoleControlPlane, nodes[2].Role)
508
+
assert.Equal(t, "192.168.1.3", nodes[3].IP)
509
+
assert.Equal(t, RoleControlPlane, nodes[3].Role)
510
+
}
511
+
512
+
func TestGetAllNodesOrdered_OnlyWorkers(t *testing.T) {
513
+
cfg := &Config{
514
+
Nodes: []Node{
515
+
{IP: "192.168.1.1", Role: RoleWorker},
516
+
{IP: "192.168.1.2", Role: RoleWorker},
517
+
},
518
+
}
519
+
520
+
nodes := cfg.GetAllNodesOrdered()
521
+
assert.Len(t, nodes, 2)
522
+
}
523
+
524
+
func TestGetAllNodesOrdered_OnlyControlPlanes(t *testing.T) {
525
+
cfg := &Config{
526
+
Nodes: []Node{
527
+
{IP: "192.168.1.1", Role: RoleControlPlane},
528
+
{IP: "192.168.1.2", Role: RoleControlPlane},
529
+
},
530
+
}
531
+
532
+
nodes := cfg.GetAllNodesOrdered()
533
+
assert.Len(t, nodes, 2)
534
+
}
535
+
536
+
// ============================================================================
537
+
// GetLatestTalosVersion() Tests
538
+
// ============================================================================
539
+
540
+
func TestGetLatestTalosVersion_Success(t *testing.T) {
541
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
542
+
response := map[string]string{"tag_name": "v1.7.0"}
543
+
json.NewEncoder(w).Encode(response)
544
+
}))
545
+
defer server.Close()
546
+
547
+
version, err := GetLatestTalosVersion(server.URL)
548
+
require.NoError(t, err)
549
+
assert.Equal(t, "1.7.0", version) // 'v' should be stripped
550
+
}
551
+
552
+
func TestGetLatestTalosVersion_WithoutVPrefix(t *testing.T) {
553
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
554
+
response := map[string]string{"tag_name": "1.7.0"}
555
+
json.NewEncoder(w).Encode(response)
556
+
}))
557
+
defer server.Close()
558
+
559
+
version, err := GetLatestTalosVersion(server.URL)
560
+
require.NoError(t, err)
561
+
assert.Equal(t, "1.7.0", version)
562
+
}
563
+
564
+
func TestGetLatestTalosVersion_ServerError(t *testing.T) {
565
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
566
+
w.WriteHeader(http.StatusInternalServerError)
567
+
}))
568
+
defer server.Close()
569
+
570
+
version, err := GetLatestTalosVersion(server.URL)
571
+
assert.Error(t, err)
572
+
assert.Empty(t, version)
573
+
assert.Contains(t, err.Error(), "returned status 500")
574
+
}
575
+
576
+
func TestGetLatestTalosVersion_NotFound(t *testing.T) {
577
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
578
+
w.WriteHeader(http.StatusNotFound)
579
+
}))
580
+
defer server.Close()
581
+
582
+
version, err := GetLatestTalosVersion(server.URL)
583
+
assert.Error(t, err)
584
+
assert.Empty(t, version)
585
+
assert.Contains(t, err.Error(), "returned status 404")
586
+
}
587
+
588
+
func TestGetLatestTalosVersion_InvalidJSON(t *testing.T) {
589
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
590
+
w.Write([]byte("not json"))
591
+
}))
592
+
defer server.Close()
593
+
594
+
version, err := GetLatestTalosVersion(server.URL)
595
+
assert.Error(t, err)
596
+
assert.Empty(t, version)
597
+
assert.Contains(t, err.Error(), "failed to decode")
598
+
}
599
+
600
+
func TestGetLatestTalosVersion_EmptyVersion(t *testing.T) {
601
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
602
+
response := map[string]string{"tag_name": ""}
603
+
json.NewEncoder(w).Encode(response)
604
+
}))
605
+
defer server.Close()
606
+
607
+
version, err := GetLatestTalosVersion(server.URL)
608
+
assert.Error(t, err)
609
+
assert.Empty(t, version)
610
+
assert.Contains(t, err.Error(), "empty version")
611
+
}
612
+
613
+
func TestGetLatestTalosVersion_ConnectionError(t *testing.T) {
614
+
// Use an invalid URL that will fail to connect
615
+
version, err := GetLatestTalosVersion("http://localhost:59999")
616
+
assert.Error(t, err)
617
+
assert.Empty(t, version)
618
+
assert.Contains(t, err.Error(), "failed to fetch")
619
+
}
620
+
621
+
func TestGetLatestTalosVersion_DefaultURL(t *testing.T) {
622
+
// This just ensures we don't panic when empty URL is passed
623
+
// and the default is used. We expect an error since we're not mocking GitHub.
624
+
version, err := GetLatestTalosVersion("")
625
+
// We expect either an error (network issues) or success (if GitHub is reachable)
626
+
// The important thing is it doesn't panic
627
+
_ = version
628
+
_ = err
629
+
}
630
+
631
+
// ============================================================================
632
+
// Detection Rules Tests
633
+
// ============================================================================
634
+
635
+
func TestLoad_ValidDetectionConfig(t *testing.T) {
636
+
cfg, err := Load(testdataPath(t, "valid-detection-config.yaml"))
637
+
require.NoError(t, err)
638
+
require.NotNil(t, cfg)
639
+
640
+
// Should have profiles but no nodes
641
+
assert.Len(t, cfg.Profiles, 3)
642
+
assert.Empty(t, cfg.Nodes)
643
+
644
+
// Should have detection rules
645
+
require.NotNil(t, cfg.Detection)
646
+
assert.Len(t, cfg.Detection.Rules, 3)
647
+
648
+
// Verify detection rule content
649
+
assert.Equal(t, "amd64-intel", cfg.Detection.Rules[0].Profile)
650
+
assert.Equal(t, "Intel", cfg.Detection.Rules[0].Match.ProcessorManufacturer)
651
+
652
+
assert.Equal(t, "arm64-rpi", cfg.Detection.Rules[2].Profile)
653
+
assert.Equal(t, "Raspberry Pi", cfg.Detection.Rules[2].Match.SystemManufacturer)
654
+
}
655
+
656
+
func TestLoad_InvalidDetectionNoMatch(t *testing.T) {
657
+
cfg, err := Load(testdataPath(t, "invalid-detection-no-match.yaml"))
658
+
assert.Error(t, err)
659
+
assert.Nil(t, cfg)
660
+
assert.Contains(t, err.Error(), "at least one match criterion required")
661
+
}
662
+
663
+
func TestLoad_InvalidDetectionUnknownProfile(t *testing.T) {
664
+
cfg, err := Load(testdataPath(t, "invalid-detection-unknown-profile.yaml"))
665
+
assert.Error(t, err)
666
+
assert.Nil(t, cfg)
667
+
assert.Contains(t, err.Error(), "references unknown profile")
668
+
}
669
+
670
+
func TestLoad_InvalidDetectionEmptyProfile(t *testing.T) {
671
+
cfg, err := Load(testdataPath(t, "invalid-detection-empty-profile.yaml"))
672
+
assert.Error(t, err)
673
+
assert.Nil(t, cfg)
674
+
assert.Contains(t, err.Error(), "profile is required")
675
+
}
676
+
677
+
func TestValidate_WithDetectionRules(t *testing.T) {
678
+
cfg := &Config{
679
+
Profiles: map[string]Profile{
680
+
"test": {Arch: "amd64", Platform: "metal"},
681
+
},
682
+
Detection: &Detection{
683
+
Rules: []DetectionRule{
684
+
{
685
+
Profile: "test",
686
+
Match: DetectionMatch{SystemManufacturer: "Dell"},
687
+
},
688
+
},
689
+
},
690
+
}
691
+
assert.NoError(t, cfg.Validate())
692
+
}
693
+
694
+
func TestValidate_DetectionRuleEmptyProfile(t *testing.T) {
695
+
cfg := &Config{
696
+
Profiles: map[string]Profile{
697
+
"test": {Arch: "amd64", Platform: "metal"},
698
+
},
699
+
Detection: &Detection{
700
+
Rules: []DetectionRule{
701
+
{
702
+
Profile: "",
703
+
Match: DetectionMatch{SystemManufacturer: "Dell"},
704
+
},
705
+
},
706
+
},
707
+
}
708
+
err := cfg.Validate()
709
+
assert.Error(t, err)
710
+
assert.Contains(t, err.Error(), "profile is required")
711
+
}
712
+
713
+
func TestValidate_DetectionRuleUnknownProfile(t *testing.T) {
714
+
cfg := &Config{
715
+
Profiles: map[string]Profile{
716
+
"test": {Arch: "amd64", Platform: "metal"},
717
+
},
718
+
Detection: &Detection{
719
+
Rules: []DetectionRule{
720
+
{
721
+
Profile: "nonexistent",
722
+
Match: DetectionMatch{SystemManufacturer: "Dell"},
723
+
},
724
+
},
725
+
},
726
+
}
727
+
err := cfg.Validate()
728
+
assert.Error(t, err)
729
+
assert.Contains(t, err.Error(), "references unknown profile")
730
+
}
731
+
732
+
func TestValidate_DetectionRuleNoMatchCriteria(t *testing.T) {
733
+
cfg := &Config{
734
+
Profiles: map[string]Profile{
735
+
"test": {Arch: "amd64", Platform: "metal"},
736
+
},
737
+
Detection: &Detection{
738
+
Rules: []DetectionRule{
739
+
{
740
+
Profile: "test",
741
+
Match: DetectionMatch{},
742
+
},
743
+
},
744
+
},
745
+
}
746
+
err := cfg.Validate()
747
+
assert.Error(t, err)
748
+
assert.Contains(t, err.Error(), "at least one match criterion required")
749
+
}
750
+
751
+
// ============================================================================
752
+
// HasDetection() Tests
753
+
// ============================================================================
754
+
755
+
func TestHasDetection(t *testing.T) {
756
+
t.Run("with detection rules", func(t *testing.T) {
757
+
cfg := &Config{
758
+
Detection: &Detection{
759
+
Rules: []DetectionRule{
760
+
{Profile: "test", Match: DetectionMatch{SystemManufacturer: "Dell"}},
761
+
},
762
+
},
763
+
}
764
+
assert.True(t, cfg.HasDetection())
765
+
})
766
+
767
+
t.Run("nil detection", func(t *testing.T) {
768
+
cfg := &Config{}
769
+
assert.False(t, cfg.HasDetection())
770
+
})
771
+
772
+
t.Run("empty rules", func(t *testing.T) {
773
+
cfg := &Config{
774
+
Detection: &Detection{
775
+
Rules: []DetectionRule{},
776
+
},
777
+
}
778
+
assert.False(t, cfg.HasDetection())
779
+
})
780
+
}
781
+
782
+
// ============================================================================
783
+
// DetectProfile() Tests
784
+
// ============================================================================
785
+
786
+
func TestDetectProfile(t *testing.T) {
787
+
cfg := &Config{
788
+
Profiles: map[string]Profile{
789
+
"intel-profile": {Arch: "amd64", Platform: "metal"},
790
+
"amd-profile": {Arch: "amd64", Platform: "metal"},
791
+
"dell-profile": {Arch: "amd64", Platform: "metal"},
792
+
},
793
+
Detection: &Detection{
794
+
Rules: []DetectionRule{
795
+
{
796
+
Profile: "intel-profile",
797
+
Match: DetectionMatch{ProcessorManufacturer: "Intel"},
798
+
},
799
+
{
800
+
Profile: "amd-profile",
801
+
Match: DetectionMatch{ProcessorManufacturer: "AMD"},
802
+
},
803
+
{
804
+
Profile: "dell-profile",
805
+
Match: DetectionMatch{SystemManufacturer: "Dell"},
806
+
},
807
+
},
808
+
},
809
+
}
810
+
811
+
t.Run("match processor manufacturer", func(t *testing.T) {
812
+
hw := &HardwareInfo{ProcessorManufacturer: "Intel Corporation"}
813
+
name, profile := cfg.DetectProfile(hw)
814
+
assert.Equal(t, "intel-profile", name)
815
+
require.NotNil(t, profile)
816
+
assert.Equal(t, "amd64", profile.Arch)
817
+
})
818
+
819
+
t.Run("match system manufacturer", func(t *testing.T) {
820
+
hw := &HardwareInfo{SystemManufacturer: "Dell Inc."}
821
+
name, profile := cfg.DetectProfile(hw)
822
+
assert.Equal(t, "dell-profile", name)
823
+
require.NotNil(t, profile)
824
+
})
825
+
826
+
t.Run("case insensitive match", func(t *testing.T) {
827
+
hw := &HardwareInfo{ProcessorManufacturer: "INTEL"}
828
+
name, profile := cfg.DetectProfile(hw)
829
+
assert.Equal(t, "intel-profile", name)
830
+
require.NotNil(t, profile)
831
+
})
832
+
833
+
t.Run("no match", func(t *testing.T) {
834
+
hw := &HardwareInfo{
835
+
SystemManufacturer: "HP",
836
+
ProcessorManufacturer: "ARM",
837
+
}
838
+
name, profile := cfg.DetectProfile(hw)
839
+
assert.Empty(t, name)
840
+
assert.Nil(t, profile)
841
+
})
842
+
843
+
t.Run("nil hardware info", func(t *testing.T) {
844
+
name, profile := cfg.DetectProfile(nil)
845
+
assert.Empty(t, name)
846
+
assert.Nil(t, profile)
847
+
})
848
+
849
+
t.Run("nil detection config", func(t *testing.T) {
850
+
cfgNoDetection := &Config{
851
+
Profiles: map[string]Profile{
852
+
"test": {Arch: "amd64", Platform: "metal"},
853
+
},
854
+
}
855
+
hw := &HardwareInfo{ProcessorManufacturer: "Intel"}
856
+
name, profile := cfgNoDetection.DetectProfile(hw)
857
+
assert.Empty(t, name)
858
+
assert.Nil(t, profile)
859
+
})
860
+
861
+
t.Run("first matching rule wins", func(t *testing.T) {
862
+
cfgMultiple := &Config{
863
+
Profiles: map[string]Profile{
864
+
"first": {Arch: "amd64", Platform: "metal"},
865
+
"second": {Arch: "arm64", Platform: "metal"},
866
+
},
867
+
Detection: &Detection{
868
+
Rules: []DetectionRule{
869
+
{Profile: "first", Match: DetectionMatch{SystemManufacturer: "Dell"}},
870
+
{Profile: "second", Match: DetectionMatch{SystemManufacturer: "Dell"}},
871
+
},
872
+
},
873
+
}
874
+
hw := &HardwareInfo{SystemManufacturer: "Dell Inc."}
875
+
name, _ := cfgMultiple.DetectProfile(hw)
876
+
assert.Equal(t, "first", name)
877
+
})
878
+
879
+
t.Run("rule references missing profile", func(t *testing.T) {
880
+
cfgBadRule := &Config{
881
+
Profiles: map[string]Profile{
882
+
"exists": {Arch: "amd64", Platform: "metal"},
883
+
},
884
+
Detection: &Detection{
885
+
Rules: []DetectionRule{
886
+
{Profile: "missing", Match: DetectionMatch{SystemManufacturer: "Dell"}},
887
+
},
888
+
},
889
+
}
890
+
hw := &HardwareInfo{SystemManufacturer: "Dell Inc."}
891
+
name, profile := cfgBadRule.DetectProfile(hw)
892
+
assert.Empty(t, name)
893
+
assert.Nil(t, profile)
894
+
})
895
+
}
896
+
897
+
func TestDetectProfile_MultipleMatchCriteria(t *testing.T) {
898
+
cfg := &Config{
899
+
Profiles: map[string]Profile{
900
+
"dell-intel": {Arch: "amd64", Platform: "metal"},
901
+
},
902
+
Detection: &Detection{
903
+
Rules: []DetectionRule{
904
+
{
905
+
Profile: "dell-intel",
906
+
Match: DetectionMatch{
907
+
SystemManufacturer: "Dell",
908
+
ProcessorManufacturer: "Intel",
909
+
},
910
+
},
911
+
},
912
+
},
913
+
}
914
+
915
+
t.Run("both criteria match", func(t *testing.T) {
916
+
hw := &HardwareInfo{
917
+
SystemManufacturer: "Dell Inc.",
918
+
ProcessorManufacturer: "Intel Corporation",
919
+
}
920
+
name, profile := cfg.DetectProfile(hw)
921
+
assert.Equal(t, "dell-intel", name)
922
+
require.NotNil(t, profile)
923
+
})
924
+
925
+
t.Run("only system matches", func(t *testing.T) {
926
+
hw := &HardwareInfo{
927
+
SystemManufacturer: "Dell Inc.",
928
+
ProcessorManufacturer: "AMD",
929
+
}
930
+
name, profile := cfg.DetectProfile(hw)
931
+
assert.Empty(t, name)
932
+
assert.Nil(t, profile)
933
+
})
934
+
935
+
t.Run("only processor matches", func(t *testing.T) {
936
+
hw := &HardwareInfo{
937
+
SystemManufacturer: "HP",
938
+
ProcessorManufacturer: "Intel Corporation",
939
+
}
940
+
name, profile := cfg.DetectProfile(hw)
941
+
assert.Empty(t, name)
942
+
assert.Nil(t, profile)
943
+
})
944
+
}
+11
internal/config/testdata/invalid-bad-role.yaml
+11
internal/config/testdata/invalid-bad-role.yaml
+10
internal/config/testdata/invalid-detection-empty-profile.yaml
+10
internal/config/testdata/invalid-detection-empty-profile.yaml
+9
internal/config/testdata/invalid-detection-no-match.yaml
+9
internal/config/testdata/invalid-detection-no-match.yaml
+10
internal/config/testdata/invalid-detection-unknown-profile.yaml
+10
internal/config/testdata/invalid-detection-unknown-profile.yaml
+10
internal/config/testdata/invalid-missing-arch.yaml
+10
internal/config/testdata/invalid-missing-arch.yaml
+8
internal/config/testdata/invalid-no-nodes.yaml
+8
internal/config/testdata/invalid-no-nodes.yaml
+6
internal/config/testdata/invalid-no-profiles.yaml
+6
internal/config/testdata/invalid-no-profiles.yaml
+11
internal/config/testdata/invalid-unknown-profile.yaml
+11
internal/config/testdata/invalid-unknown-profile.yaml
+4
internal/config/testdata/malformed.yaml
+4
internal/config/testdata/malformed.yaml
+11
internal/config/testdata/minimal-config.yaml
+11
internal/config/testdata/minimal-config.yaml
+37
internal/config/testdata/valid-config.yaml
+37
internal/config/testdata/valid-config.yaml
···
1
+
settings:
2
+
factory_base_url: "https://factory.talos.dev"
3
+
default_timeout_seconds: 600
4
+
default_preserve: true
5
+
github_releases_url: "https://api.github.com/repos/siderolabs/talos/releases/latest"
6
+
7
+
profiles:
8
+
amd64-intel:
9
+
description: "Intel GPU nodes"
10
+
arch: amd64
11
+
platform: metal
12
+
secureboot: true
13
+
extensions:
14
+
- siderolabs/i915
15
+
- siderolabs/iscsi-tools
16
+
17
+
arm64-rpi:
18
+
description: "Raspberry Pi workers"
19
+
arch: arm64
20
+
platform: metal
21
+
secureboot: false
22
+
overlay:
23
+
name: rpi_generic
24
+
image: siderolabs/sbc-raspberrypi
25
+
extensions:
26
+
- siderolabs/iscsi-tools
27
+
28
+
nodes:
29
+
- ip: 192.168.1.101
30
+
profile: arm64-rpi
31
+
role: worker
32
+
- ip: 192.168.1.102
33
+
profile: arm64-rpi
34
+
role: worker
35
+
- ip: 192.168.1.201
36
+
profile: amd64-intel
37
+
role: controlplane
+41
internal/config/testdata/valid-detection-config.yaml
+41
internal/config/testdata/valid-detection-config.yaml
···
1
+
settings:
2
+
factory_base_url: "https://factory.talos.dev"
3
+
default_timeout_seconds: 600
4
+
5
+
profiles:
6
+
amd64-intel:
7
+
description: "Intel GPU nodes"
8
+
arch: amd64
9
+
platform: metal
10
+
secureboot: true
11
+
extensions:
12
+
- siderolabs/i915
13
+
14
+
amd64-amd:
15
+
description: "AMD nodes"
16
+
arch: amd64
17
+
platform: metal
18
+
secureboot: true
19
+
extensions:
20
+
- siderolabs/amdgpu
21
+
22
+
arm64-rpi:
23
+
description: "Raspberry Pi workers"
24
+
arch: arm64
25
+
platform: metal
26
+
secureboot: false
27
+
overlay:
28
+
name: rpi_generic
29
+
image: siderolabs/sbc-raspberrypi
30
+
31
+
detection:
32
+
rules:
33
+
- profile: amd64-intel
34
+
match:
35
+
processor_manufacturer: Intel
36
+
- profile: amd64-amd
37
+
match:
38
+
processor_manufacturer: AMD
39
+
- profile: arm64-rpi
40
+
match:
41
+
system_manufacturer: Raspberry Pi
+176
internal/config/types.go
+176
internal/config/types.go
···
1
+
package config
2
+
3
+
import "strings"
4
+
5
+
// Config represents the complete configuration file
6
+
type Config struct {
7
+
Settings Settings `yaml:"settings"`
8
+
Profiles map[string]Profile `yaml:"profiles"`
9
+
Nodes []Node `yaml:"nodes,omitempty"` // Deprecated: use detection rules instead
10
+
Detection *Detection `yaml:"detection,omitempty"` // Auto-detection configuration
11
+
}
12
+
13
+
// Settings contains global configuration
14
+
type Settings struct {
15
+
FactoryBaseURL string `yaml:"factory_base_url"`
16
+
DefaultTimeoutSeconds int `yaml:"default_timeout_seconds"`
17
+
DefaultPreserve bool `yaml:"default_preserve"`
18
+
GithubReleasesURL string `yaml:"github_releases_url"`
19
+
}
20
+
21
+
// Profile defines a hardware profile for nodes
22
+
type Profile struct {
23
+
Description string `yaml:"description,omitempty"`
24
+
Arch string `yaml:"arch"`
25
+
Platform string `yaml:"platform"`
26
+
Secureboot bool `yaml:"secureboot"`
27
+
KernelArgs []string `yaml:"kernel_args,omitempty"`
28
+
Extensions []string `yaml:"extensions"`
29
+
Overlay *Overlay `yaml:"overlay,omitempty"`
30
+
}
31
+
32
+
// Overlay defines SBC overlay configuration
33
+
type Overlay struct {
34
+
Name string `yaml:"name"`
35
+
Image string `yaml:"image"`
36
+
}
37
+
38
+
// Node represents a single cluster node
39
+
type Node struct {
40
+
IP string `yaml:"ip"`
41
+
Profile string `yaml:"profile"`
42
+
Role string `yaml:"role"` // controlplane, worker
43
+
}
44
+
45
+
// Detection configures automatic profile detection
46
+
type Detection struct {
47
+
Rules []DetectionRule `yaml:"rules"`
48
+
}
49
+
50
+
// DetectionRule maps hardware characteristics to a profile
51
+
type DetectionRule struct {
52
+
Profile string `yaml:"profile"`
53
+
Match DetectionMatch `yaml:"match"`
54
+
}
55
+
56
+
// DetectionMatch defines the hardware characteristics to match
57
+
type DetectionMatch struct {
58
+
SystemManufacturer string `yaml:"system_manufacturer,omitempty"`
59
+
ProcessorManufacturer string `yaml:"processor_manufacturer,omitempty"`
60
+
}
61
+
62
+
// HardwareInfo represents detected hardware information
63
+
type HardwareInfo struct {
64
+
SystemManufacturer string
65
+
SystemProductName string
66
+
ProcessorManufacturer string
67
+
ProcessorProductName string
68
+
}
69
+
70
+
// DetectProfile finds the matching profile for given hardware info
71
+
func (c *Config) DetectProfile(hw *HardwareInfo) (string, *Profile) {
72
+
if c.Detection == nil || hw == nil {
73
+
return "", nil
74
+
}
75
+
76
+
for _, rule := range c.Detection.Rules {
77
+
if matchesRule(hw, &rule.Match) {
78
+
if profile, ok := c.Profiles[rule.Profile]; ok {
79
+
return rule.Profile, &profile
80
+
}
81
+
}
82
+
}
83
+
return "", nil
84
+
}
85
+
86
+
// matchesRule checks if hardware info matches a detection rule
87
+
func matchesRule(hw *HardwareInfo, match *DetectionMatch) bool {
88
+
// Check system manufacturer (case-insensitive contains match)
89
+
if match.SystemManufacturer != "" {
90
+
if !strings.Contains(strings.ToLower(hw.SystemManufacturer), strings.ToLower(match.SystemManufacturer)) {
91
+
return false
92
+
}
93
+
}
94
+
95
+
// Check processor manufacturer (case-insensitive contains match)
96
+
if match.ProcessorManufacturer != "" {
97
+
if !strings.Contains(strings.ToLower(hw.ProcessorManufacturer), strings.ToLower(match.ProcessorManufacturer)) {
98
+
return false
99
+
}
100
+
}
101
+
102
+
// At least one match criterion must be specified
103
+
if match.SystemManufacturer == "" && match.ProcessorManufacturer == "" {
104
+
return false
105
+
}
106
+
107
+
return true
108
+
}
109
+
110
+
// NodeRole constants
111
+
const (
112
+
RoleControlPlane = "controlplane"
113
+
RoleWorker = "worker"
114
+
)
115
+
116
+
// GetNodesByRole returns nodes filtered by role
117
+
func (c *Config) GetNodesByRole(role string) []Node {
118
+
var nodes []Node
119
+
for _, n := range c.Nodes {
120
+
if n.Role == role {
121
+
nodes = append(nodes, n)
122
+
}
123
+
}
124
+
return nodes
125
+
}
126
+
127
+
// GetNodesByProfile returns nodes filtered by profile name
128
+
func (c *Config) GetNodesByProfile(profile string) []Node {
129
+
var nodes []Node
130
+
for _, n := range c.Nodes {
131
+
if n.Profile == profile {
132
+
nodes = append(nodes, n)
133
+
}
134
+
}
135
+
return nodes
136
+
}
137
+
138
+
// GetNodeByIP returns a node by IP address
139
+
func (c *Config) GetNodeByIP(ip string) *Node {
140
+
for _, n := range c.Nodes {
141
+
if n.IP == ip {
142
+
return &n
143
+
}
144
+
}
145
+
return nil
146
+
}
147
+
148
+
// GetProfileForNode returns the profile for a given node IP
149
+
func (c *Config) GetProfileForNode(ip string) *Profile {
150
+
node := c.GetNodeByIP(ip)
151
+
if node == nil {
152
+
return nil
153
+
}
154
+
profile, ok := c.Profiles[node.Profile]
155
+
if !ok {
156
+
return nil
157
+
}
158
+
return &profile
159
+
}
160
+
161
+
// GetControlPlaneNodes returns all control plane nodes
162
+
func (c *Config) GetControlPlaneNodes() []Node {
163
+
return c.GetNodesByRole(RoleControlPlane)
164
+
}
165
+
166
+
// GetWorkerNodes returns all worker nodes
167
+
func (c *Config) GetWorkerNodes() []Node {
168
+
return c.GetNodesByRole(RoleWorker)
169
+
}
170
+
171
+
// GetAllNodesOrdered returns nodes in upgrade order (workers first, then control planes)
172
+
func (c *Config) GetAllNodesOrdered() []Node {
173
+
nodes := c.GetWorkerNodes()
174
+
nodes = append(nodes, c.GetControlPlaneNodes()...)
175
+
return nodes
176
+
}
+210
internal/factory/factory.go
+210
internal/factory/factory.go
···
1
+
package factory
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/json"
6
+
"fmt"
7
+
"net/http"
8
+
"net/url"
9
+
"time"
10
+
11
+
"github.com/evanjarrett/homelab/internal/config"
12
+
"gopkg.in/yaml.v3"
13
+
)
14
+
15
+
// Schematic represents the YAML schema sent to factory.talos.dev
16
+
type Schematic struct {
17
+
Overlay *SchematicOverlay `yaml:"overlay,omitempty"`
18
+
Customization *SchematicCustomization `yaml:"customization,omitempty"`
19
+
}
20
+
21
+
// SchematicOverlay for SBC boards
22
+
type SchematicOverlay struct {
23
+
Name string `yaml:"name"`
24
+
Image string `yaml:"image"`
25
+
}
26
+
27
+
// SchematicCustomization for extensions and kernel args
28
+
type SchematicCustomization struct {
29
+
ExtraKernelArgs []string `yaml:"extraKernelArgs,omitempty"`
30
+
SystemExtensions *SchematicSystemExtensions `yaml:"systemExtensions,omitempty"`
31
+
}
32
+
33
+
// SchematicSystemExtensions lists official extensions
34
+
type SchematicSystemExtensions struct {
35
+
OfficialExtensions []string `yaml:"officialExtensions"`
36
+
}
37
+
38
+
// SchematicResponse from factory.talos.dev/schematics POST
39
+
type SchematicResponse struct {
40
+
ID string `json:"id"`
41
+
}
42
+
43
+
// FactoryClientInterface defines the interface for factory API operations.
44
+
// This enables mocking the factory client for testing.
45
+
type FactoryClientInterface interface {
46
+
GetInstallerImage(profile config.Profile, version string) (string, error)
47
+
GetSchematicID(schematic *Schematic) (string, error)
48
+
}
49
+
50
+
// Client for interacting with the Talos Factory API
51
+
type Client struct {
52
+
baseURL string
53
+
httpClient *http.Client
54
+
}
55
+
56
+
// Ensure Client implements FactoryClientInterface
57
+
var _ FactoryClientInterface = (*Client)(nil)
58
+
59
+
// NewClient creates a new Factory API client
60
+
func NewClient(baseURL string) *Client {
61
+
if baseURL == "" {
62
+
baseURL = "https://factory.talos.dev"
63
+
}
64
+
return &Client{
65
+
baseURL: baseURL,
66
+
httpClient: &http.Client{Timeout: 30 * time.Second},
67
+
}
68
+
}
69
+
70
+
// BuildSchematic creates a Schematic from a Profile
71
+
func BuildSchematic(profile config.Profile) *Schematic {
72
+
schematic := &Schematic{
73
+
Customization: &SchematicCustomization{},
74
+
}
75
+
76
+
// Add extensions
77
+
if len(profile.Extensions) > 0 {
78
+
schematic.Customization.SystemExtensions = &SchematicSystemExtensions{
79
+
OfficialExtensions: profile.Extensions,
80
+
}
81
+
}
82
+
83
+
// Add kernel args if present
84
+
if len(profile.KernelArgs) > 0 {
85
+
schematic.Customization.ExtraKernelArgs = profile.KernelArgs
86
+
}
87
+
88
+
// Add overlay if present (SBC boards)
89
+
if profile.Overlay != nil {
90
+
schematic.Overlay = &SchematicOverlay{
91
+
Name: profile.Overlay.Name,
92
+
Image: profile.Overlay.Image,
93
+
}
94
+
}
95
+
96
+
return schematic
97
+
}
98
+
99
+
// GetSchematicID posts a schematic to the factory and returns the ID
100
+
func (c *Client) GetSchematicID(schematic *Schematic) (string, error) {
101
+
yamlBytes, err := yaml.Marshal(schematic)
102
+
if err != nil {
103
+
return "", fmt.Errorf("failed to marshal schematic: %w", err)
104
+
}
105
+
106
+
resp, err := c.httpClient.Post(
107
+
c.baseURL+"/schematics",
108
+
"application/yaml",
109
+
bytes.NewReader(yamlBytes),
110
+
)
111
+
if err != nil {
112
+
return "", fmt.Errorf("failed to post schematic: %w", err)
113
+
}
114
+
defer resp.Body.Close()
115
+
116
+
// Accept both 200 OK and 201 Created
117
+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
118
+
return "", fmt.Errorf("factory API returned status %d", resp.StatusCode)
119
+
}
120
+
121
+
var result SchematicResponse
122
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
123
+
return "", fmt.Errorf("failed to decode response: %w", err)
124
+
}
125
+
126
+
if result.ID == "" {
127
+
return "", fmt.Errorf("empty schematic ID returned")
128
+
}
129
+
130
+
return result.ID, nil
131
+
}
132
+
133
+
// GetInstallerImage builds the full installer image URL for a profile
134
+
func (c *Client) GetInstallerImage(profile config.Profile, version string) (string, error) {
135
+
schematic := BuildSchematic(profile)
136
+
137
+
schematicID, err := c.GetSchematicID(schematic)
138
+
if err != nil {
139
+
return "", err
140
+
}
141
+
142
+
imageBase := "factory.talos.dev/installer"
143
+
if profile.Secureboot {
144
+
imageBase = "factory.talos.dev/installer-secureboot"
145
+
}
146
+
147
+
return fmt.Sprintf("%s/%s:v%s", imageBase, schematicID, version), nil
148
+
}
149
+
150
+
// GenerateFactoryURL builds a browser-friendly URL for the Talos Factory
151
+
func GenerateFactoryURL(profile config.Profile, version, baseURL string) string {
152
+
if baseURL == "" {
153
+
baseURL = "https://factory.talos.dev"
154
+
}
155
+
156
+
params := url.Values{}
157
+
params.Set("arch", profile.Arch)
158
+
159
+
if profile.Overlay != nil {
160
+
// SBC board handling
161
+
params.Set("board", profile.Overlay.Name)
162
+
params.Set("target", "sbc")
163
+
} else {
164
+
params.Set("target", "metal")
165
+
if profile.Secureboot {
166
+
params.Set("secureboot", "true")
167
+
}
168
+
}
169
+
170
+
params.Set("platform", profile.Platform)
171
+
params.Set("bootloader", "auto")
172
+
params.Set("cmdline-set", "true")
173
+
params.Set("version", version)
174
+
175
+
// Add kernel args
176
+
for _, arg := range profile.KernelArgs {
177
+
params.Add("cmdline", arg)
178
+
}
179
+
180
+
// Add extensions (reset first for SBC, then add each)
181
+
if profile.Overlay != nil {
182
+
params.Set("extensions", "-") // Reset default extensions for SBC
183
+
}
184
+
for _, ext := range profile.Extensions {
185
+
params.Add("extensions", ext)
186
+
}
187
+
188
+
return fmt.Sprintf("%s/?%s", baseURL, params.Encode())
189
+
}
190
+
191
+
// MockFactoryClient is a mock implementation of FactoryClientInterface for testing
192
+
type MockFactoryClient struct {
193
+
GetInstallerImageFunc func(profile config.Profile, version string) (string, error)
194
+
GetSchematicIDFunc func(schematic *Schematic) (string, error)
195
+
}
196
+
197
+
func (m *MockFactoryClient) GetInstallerImage(profile config.Profile, version string) (string, error) {
198
+
if m.GetInstallerImageFunc != nil {
199
+
return m.GetInstallerImageFunc(profile, version)
200
+
}
201
+
// Default: return a valid-looking image URL
202
+
return fmt.Sprintf("factory.talos.dev/installer/mock-schematic:v%s", version), nil
203
+
}
204
+
205
+
func (m *MockFactoryClient) GetSchematicID(schematic *Schematic) (string, error) {
206
+
if m.GetSchematicIDFunc != nil {
207
+
return m.GetSchematicIDFunc(schematic)
208
+
}
209
+
return "mock-schematic-id", nil
210
+
}
+487
internal/factory/factory_test.go
+487
internal/factory/factory_test.go
···
1
+
package factory
2
+
3
+
import (
4
+
"encoding/json"
5
+
"io"
6
+
"net/http"
7
+
"net/http/httptest"
8
+
"net/url"
9
+
"testing"
10
+
11
+
"github.com/evanjarrett/homelab/internal/config"
12
+
"github.com/stretchr/testify/assert"
13
+
"github.com/stretchr/testify/require"
14
+
"gopkg.in/yaml.v3"
15
+
)
16
+
17
+
// ============================================================================
18
+
// NewClient() Tests
19
+
// ============================================================================
20
+
21
+
func TestNewClient_DefaultURL(t *testing.T) {
22
+
client := NewClient("")
23
+
assert.Equal(t, "https://factory.talos.dev", client.baseURL)
24
+
assert.NotNil(t, client.httpClient)
25
+
}
26
+
27
+
func TestNewClient_CustomURL(t *testing.T) {
28
+
client := NewClient("https://custom.factory.dev")
29
+
assert.Equal(t, "https://custom.factory.dev", client.baseURL)
30
+
}
31
+
32
+
// ============================================================================
33
+
// BuildSchematic() Tests
34
+
// ============================================================================
35
+
36
+
func TestBuildSchematic_MinimalProfile(t *testing.T) {
37
+
profile := config.Profile{
38
+
Arch: "amd64",
39
+
Platform: "metal",
40
+
Extensions: []string{},
41
+
}
42
+
43
+
schematic := BuildSchematic(profile)
44
+
require.NotNil(t, schematic)
45
+
assert.NotNil(t, schematic.Customization)
46
+
assert.Nil(t, schematic.Customization.SystemExtensions)
47
+
assert.Empty(t, schematic.Customization.ExtraKernelArgs)
48
+
assert.Nil(t, schematic.Overlay)
49
+
}
50
+
51
+
func TestBuildSchematic_WithExtensions(t *testing.T) {
52
+
profile := config.Profile{
53
+
Arch: "amd64",
54
+
Platform: "metal",
55
+
Extensions: []string{
56
+
"siderolabs/i915",
57
+
"siderolabs/iscsi-tools",
58
+
},
59
+
}
60
+
61
+
schematic := BuildSchematic(profile)
62
+
require.NotNil(t, schematic)
63
+
require.NotNil(t, schematic.Customization)
64
+
require.NotNil(t, schematic.Customization.SystemExtensions)
65
+
assert.Len(t, schematic.Customization.SystemExtensions.OfficialExtensions, 2)
66
+
assert.Contains(t, schematic.Customization.SystemExtensions.OfficialExtensions, "siderolabs/i915")
67
+
assert.Contains(t, schematic.Customization.SystemExtensions.OfficialExtensions, "siderolabs/iscsi-tools")
68
+
}
69
+
70
+
func TestBuildSchematic_WithKernelArgs(t *testing.T) {
71
+
profile := config.Profile{
72
+
Arch: "amd64",
73
+
Platform: "metal",
74
+
KernelArgs: []string{
75
+
"amd_iommu=off",
76
+
"nomodeset",
77
+
},
78
+
}
79
+
80
+
schematic := BuildSchematic(profile)
81
+
require.NotNil(t, schematic)
82
+
assert.Len(t, schematic.Customization.ExtraKernelArgs, 2)
83
+
assert.Contains(t, schematic.Customization.ExtraKernelArgs, "amd_iommu=off")
84
+
assert.Contains(t, schematic.Customization.ExtraKernelArgs, "nomodeset")
85
+
}
86
+
87
+
func TestBuildSchematic_WithOverlay(t *testing.T) {
88
+
profile := config.Profile{
89
+
Arch: "arm64",
90
+
Platform: "metal",
91
+
Overlay: &config.Overlay{
92
+
Name: "rpi_generic",
93
+
Image: "siderolabs/sbc-raspberrypi",
94
+
},
95
+
}
96
+
97
+
schematic := BuildSchematic(profile)
98
+
require.NotNil(t, schematic)
99
+
require.NotNil(t, schematic.Overlay)
100
+
assert.Equal(t, "rpi_generic", schematic.Overlay.Name)
101
+
assert.Equal(t, "siderolabs/sbc-raspberrypi", schematic.Overlay.Image)
102
+
}
103
+
104
+
func TestBuildSchematic_CompleteProfile(t *testing.T) {
105
+
profile := config.Profile{
106
+
Arch: "arm64",
107
+
Platform: "metal",
108
+
Secureboot: false,
109
+
KernelArgs: []string{"console=ttyS0"},
110
+
Extensions: []string{"siderolabs/iscsi-tools"},
111
+
Overlay: &config.Overlay{
112
+
Name: "turingrk1",
113
+
Image: "siderolabs/sbc-rockchip",
114
+
},
115
+
}
116
+
117
+
schematic := BuildSchematic(profile)
118
+
require.NotNil(t, schematic)
119
+
require.NotNil(t, schematic.Customization)
120
+
require.NotNil(t, schematic.Customization.SystemExtensions)
121
+
require.NotNil(t, schematic.Overlay)
122
+
123
+
assert.Len(t, schematic.Customization.ExtraKernelArgs, 1)
124
+
assert.Len(t, schematic.Customization.SystemExtensions.OfficialExtensions, 1)
125
+
assert.Equal(t, "turingrk1", schematic.Overlay.Name)
126
+
}
127
+
128
+
func TestBuildSchematic_YAMLOutput(t *testing.T) {
129
+
profile := config.Profile{
130
+
Arch: "amd64",
131
+
Platform: "metal",
132
+
Extensions: []string{
133
+
"siderolabs/i915",
134
+
},
135
+
}
136
+
137
+
schematic := BuildSchematic(profile)
138
+
yamlBytes, err := yaml.Marshal(schematic)
139
+
require.NoError(t, err)
140
+
141
+
// Verify YAML can be unmarshalled back
142
+
var parsed Schematic
143
+
err = yaml.Unmarshal(yamlBytes, &parsed)
144
+
require.NoError(t, err)
145
+
require.NotNil(t, parsed.Customization.SystemExtensions)
146
+
assert.Contains(t, parsed.Customization.SystemExtensions.OfficialExtensions, "siderolabs/i915")
147
+
}
148
+
149
+
// ============================================================================
150
+
// GetSchematicID() Tests
151
+
// ============================================================================
152
+
153
+
func TestGetSchematicID_Success(t *testing.T) {
154
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155
+
// Verify request
156
+
assert.Equal(t, "POST", r.Method)
157
+
assert.Equal(t, "/schematics", r.URL.Path)
158
+
assert.Equal(t, "application/yaml", r.Header.Get("Content-Type"))
159
+
160
+
// Read and verify body is valid YAML
161
+
body, err := io.ReadAll(r.Body)
162
+
require.NoError(t, err)
163
+
var schematic Schematic
164
+
err = yaml.Unmarshal(body, &schematic)
165
+
assert.NoError(t, err)
166
+
167
+
// Return success
168
+
w.WriteHeader(http.StatusOK)
169
+
json.NewEncoder(w).Encode(SchematicResponse{ID: "abc123def456"})
170
+
}))
171
+
defer server.Close()
172
+
173
+
client := NewClient(server.URL)
174
+
schematic := &Schematic{
175
+
Customization: &SchematicCustomization{
176
+
SystemExtensions: &SchematicSystemExtensions{
177
+
OfficialExtensions: []string{"siderolabs/i915"},
178
+
},
179
+
},
180
+
}
181
+
182
+
id, err := client.GetSchematicID(schematic)
183
+
require.NoError(t, err)
184
+
assert.Equal(t, "abc123def456", id)
185
+
}
186
+
187
+
func TestGetSchematicID_Created201(t *testing.T) {
188
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189
+
w.WriteHeader(http.StatusCreated)
190
+
json.NewEncoder(w).Encode(SchematicResponse{ID: "newschematic123"})
191
+
}))
192
+
defer server.Close()
193
+
194
+
client := NewClient(server.URL)
195
+
id, err := client.GetSchematicID(&Schematic{})
196
+
require.NoError(t, err)
197
+
assert.Equal(t, "newschematic123", id)
198
+
}
199
+
200
+
func TestGetSchematicID_ServerError(t *testing.T) {
201
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202
+
w.WriteHeader(http.StatusInternalServerError)
203
+
}))
204
+
defer server.Close()
205
+
206
+
client := NewClient(server.URL)
207
+
id, err := client.GetSchematicID(&Schematic{})
208
+
assert.Error(t, err)
209
+
assert.Empty(t, id)
210
+
assert.Contains(t, err.Error(), "returned status 500")
211
+
}
212
+
213
+
func TestGetSchematicID_BadRequest(t *testing.T) {
214
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215
+
w.WriteHeader(http.StatusBadRequest)
216
+
}))
217
+
defer server.Close()
218
+
219
+
client := NewClient(server.URL)
220
+
id, err := client.GetSchematicID(&Schematic{})
221
+
assert.Error(t, err)
222
+
assert.Empty(t, id)
223
+
assert.Contains(t, err.Error(), "returned status 400")
224
+
}
225
+
226
+
func TestGetSchematicID_InvalidJSON(t *testing.T) {
227
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
228
+
w.WriteHeader(http.StatusOK)
229
+
w.Write([]byte("not json"))
230
+
}))
231
+
defer server.Close()
232
+
233
+
client := NewClient(server.URL)
234
+
id, err := client.GetSchematicID(&Schematic{})
235
+
assert.Error(t, err)
236
+
assert.Empty(t, id)
237
+
assert.Contains(t, err.Error(), "failed to decode")
238
+
}
239
+
240
+
func TestGetSchematicID_EmptyID(t *testing.T) {
241
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242
+
w.WriteHeader(http.StatusOK)
243
+
json.NewEncoder(w).Encode(SchematicResponse{ID: ""})
244
+
}))
245
+
defer server.Close()
246
+
247
+
client := NewClient(server.URL)
248
+
id, err := client.GetSchematicID(&Schematic{})
249
+
assert.Error(t, err)
250
+
assert.Empty(t, id)
251
+
assert.Contains(t, err.Error(), "empty schematic ID")
252
+
}
253
+
254
+
func TestGetSchematicID_ConnectionError(t *testing.T) {
255
+
client := NewClient("http://localhost:59999")
256
+
id, err := client.GetSchematicID(&Schematic{})
257
+
assert.Error(t, err)
258
+
assert.Empty(t, id)
259
+
assert.Contains(t, err.Error(), "failed to post schematic")
260
+
}
261
+
262
+
// ============================================================================
263
+
// GetInstallerImage() Tests
264
+
// ============================================================================
265
+
266
+
func TestGetInstallerImage_Standard(t *testing.T) {
267
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268
+
w.WriteHeader(http.StatusOK)
269
+
json.NewEncoder(w).Encode(SchematicResponse{ID: "schematic123"})
270
+
}))
271
+
defer server.Close()
272
+
273
+
client := NewClient(server.URL)
274
+
profile := config.Profile{
275
+
Arch: "amd64",
276
+
Platform: "metal",
277
+
Secureboot: false,
278
+
Extensions: []string{"siderolabs/i915"},
279
+
}
280
+
281
+
image, err := client.GetInstallerImage(profile, "1.7.0")
282
+
require.NoError(t, err)
283
+
assert.Equal(t, "factory.talos.dev/installer/schematic123:v1.7.0", image)
284
+
}
285
+
286
+
func TestGetInstallerImage_Secureboot(t *testing.T) {
287
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
288
+
w.WriteHeader(http.StatusOK)
289
+
json.NewEncoder(w).Encode(SchematicResponse{ID: "secureboot456"})
290
+
}))
291
+
defer server.Close()
292
+
293
+
client := NewClient(server.URL)
294
+
profile := config.Profile{
295
+
Arch: "amd64",
296
+
Platform: "metal",
297
+
Secureboot: true,
298
+
Extensions: []string{"siderolabs/i915"},
299
+
}
300
+
301
+
image, err := client.GetInstallerImage(profile, "1.7.0")
302
+
require.NoError(t, err)
303
+
assert.Equal(t, "factory.talos.dev/installer-secureboot/secureboot456:v1.7.0", image)
304
+
}
305
+
306
+
func TestGetInstallerImage_APIError(t *testing.T) {
307
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308
+
w.WriteHeader(http.StatusInternalServerError)
309
+
}))
310
+
defer server.Close()
311
+
312
+
client := NewClient(server.URL)
313
+
profile := config.Profile{
314
+
Arch: "amd64",
315
+
Platform: "metal",
316
+
}
317
+
318
+
image, err := client.GetInstallerImage(profile, "1.7.0")
319
+
assert.Error(t, err)
320
+
assert.Empty(t, image)
321
+
}
322
+
323
+
// ============================================================================
324
+
// GenerateFactoryURL() Tests
325
+
// ============================================================================
326
+
327
+
func TestGenerateFactoryURL_BasicProfile(t *testing.T) {
328
+
profile := config.Profile{
329
+
Arch: "amd64",
330
+
Platform: "metal",
331
+
Secureboot: false,
332
+
}
333
+
334
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "")
335
+
336
+
parsed, err := url.Parse(urlStr)
337
+
require.NoError(t, err)
338
+
339
+
assert.Equal(t, "https", parsed.Scheme)
340
+
assert.Equal(t, "factory.talos.dev", parsed.Host)
341
+
assert.Equal(t, "/", parsed.Path)
342
+
343
+
params := parsed.Query()
344
+
assert.Equal(t, "amd64", params.Get("arch"))
345
+
assert.Equal(t, "metal", params.Get("platform"))
346
+
assert.Equal(t, "metal", params.Get("target"))
347
+
assert.Equal(t, "1.7.0", params.Get("version"))
348
+
assert.Equal(t, "auto", params.Get("bootloader"))
349
+
assert.Equal(t, "true", params.Get("cmdline-set"))
350
+
assert.Empty(t, params.Get("secureboot"))
351
+
}
352
+
353
+
func TestGenerateFactoryURL_Secureboot(t *testing.T) {
354
+
profile := config.Profile{
355
+
Arch: "amd64",
356
+
Platform: "metal",
357
+
Secureboot: true,
358
+
}
359
+
360
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "")
361
+
362
+
parsed, err := url.Parse(urlStr)
363
+
require.NoError(t, err)
364
+
365
+
params := parsed.Query()
366
+
assert.Equal(t, "true", params.Get("secureboot"))
367
+
assert.Equal(t, "metal", params.Get("target"))
368
+
}
369
+
370
+
func TestGenerateFactoryURL_WithExtensions(t *testing.T) {
371
+
profile := config.Profile{
372
+
Arch: "amd64",
373
+
Platform: "metal",
374
+
Extensions: []string{
375
+
"siderolabs/i915",
376
+
"siderolabs/iscsi-tools",
377
+
},
378
+
}
379
+
380
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "")
381
+
382
+
parsed, err := url.Parse(urlStr)
383
+
require.NoError(t, err)
384
+
385
+
params := parsed.Query()
386
+
extensions := params["extensions"]
387
+
assert.Len(t, extensions, 2)
388
+
assert.Contains(t, extensions, "siderolabs/i915")
389
+
assert.Contains(t, extensions, "siderolabs/iscsi-tools")
390
+
}
391
+
392
+
func TestGenerateFactoryURL_WithKernelArgs(t *testing.T) {
393
+
profile := config.Profile{
394
+
Arch: "amd64",
395
+
Platform: "metal",
396
+
KernelArgs: []string{
397
+
"amd_iommu=off",
398
+
"nomodeset",
399
+
},
400
+
}
401
+
402
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "")
403
+
404
+
parsed, err := url.Parse(urlStr)
405
+
require.NoError(t, err)
406
+
407
+
params := parsed.Query()
408
+
cmdline := params["cmdline"]
409
+
assert.Len(t, cmdline, 2)
410
+
assert.Contains(t, cmdline, "amd_iommu=off")
411
+
assert.Contains(t, cmdline, "nomodeset")
412
+
}
413
+
414
+
func TestGenerateFactoryURL_SBCWithOverlay(t *testing.T) {
415
+
profile := config.Profile{
416
+
Arch: "arm64",
417
+
Platform: "metal",
418
+
Overlay: &config.Overlay{
419
+
Name: "rpi_generic",
420
+
Image: "siderolabs/sbc-raspberrypi",
421
+
},
422
+
Extensions: []string{"siderolabs/iscsi-tools"},
423
+
}
424
+
425
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "")
426
+
427
+
parsed, err := url.Parse(urlStr)
428
+
require.NoError(t, err)
429
+
430
+
params := parsed.Query()
431
+
assert.Equal(t, "arm64", params.Get("arch"))
432
+
assert.Equal(t, "sbc", params.Get("target"))
433
+
assert.Equal(t, "rpi_generic", params.Get("board"))
434
+
assert.Empty(t, params.Get("secureboot")) // SBCs don't use secureboot param
435
+
436
+
// SBCs should have "-" to reset defaults, then extensions
437
+
extensions := params["extensions"]
438
+
assert.Contains(t, extensions, "-")
439
+
assert.Contains(t, extensions, "siderolabs/iscsi-tools")
440
+
}
441
+
442
+
func TestGenerateFactoryURL_CustomBaseURL(t *testing.T) {
443
+
profile := config.Profile{
444
+
Arch: "amd64",
445
+
Platform: "metal",
446
+
}
447
+
448
+
urlStr := GenerateFactoryURL(profile, "1.7.0", "https://custom.factory.dev")
449
+
450
+
parsed, err := url.Parse(urlStr)
451
+
require.NoError(t, err)
452
+
453
+
assert.Equal(t, "https", parsed.Scheme)
454
+
assert.Equal(t, "custom.factory.dev", parsed.Host)
455
+
}
456
+
457
+
func TestGenerateFactoryURL_CompleteProfile(t *testing.T) {
458
+
profile := config.Profile{
459
+
Arch: "amd64",
460
+
Platform: "metal",
461
+
Secureboot: true,
462
+
KernelArgs: []string{"console=ttyS0"},
463
+
Extensions: []string{
464
+
"siderolabs/i915",
465
+
"siderolabs/nut-client",
466
+
},
467
+
}
468
+
469
+
urlStr := GenerateFactoryURL(profile, "1.8.0", "")
470
+
471
+
parsed, err := url.Parse(urlStr)
472
+
require.NoError(t, err)
473
+
474
+
params := parsed.Query()
475
+
assert.Equal(t, "amd64", params.Get("arch"))
476
+
assert.Equal(t, "metal", params.Get("platform"))
477
+
assert.Equal(t, "metal", params.Get("target"))
478
+
assert.Equal(t, "true", params.Get("secureboot"))
479
+
assert.Equal(t, "1.8.0", params.Get("version"))
480
+
481
+
cmdline := params["cmdline"]
482
+
assert.Contains(t, cmdline, "console=ttyS0")
483
+
484
+
extensions := params["extensions"]
485
+
assert.Contains(t, extensions, "siderolabs/i915")
486
+
assert.Contains(t, extensions, "siderolabs/nut-client")
487
+
}
+106
internal/output/output.go
+106
internal/output/output.go
···
1
+
package output
2
+
3
+
import (
4
+
"fmt"
5
+
"io"
6
+
"os"
7
+
"text/tabwriter"
8
+
9
+
"github.com/fatih/color"
10
+
)
11
+
12
+
var (
13
+
// Color functions
14
+
blue = color.New(color.FgBlue).SprintFunc()
15
+
green = color.New(color.FgGreen).SprintFunc()
16
+
yellow = color.New(color.FgYellow).SprintFunc()
17
+
red = color.New(color.FgRed).SprintFunc()
18
+
19
+
// Output destination (can be changed for testing)
20
+
Out io.Writer = os.Stdout
21
+
Err io.Writer = os.Stderr
22
+
)
23
+
24
+
// LogInfo prints an info message with blue [INFO] prefix
25
+
func LogInfo(format string, args ...interface{}) {
26
+
fmt.Fprintf(Out, "%s %s\n", blue("[INFO]"), fmt.Sprintf(format, args...))
27
+
}
28
+
29
+
// LogSuccess prints a success message with green [OK] prefix
30
+
func LogSuccess(format string, args ...interface{}) {
31
+
fmt.Fprintf(Out, "%s %s\n", green("[OK]"), fmt.Sprintf(format, args...))
32
+
}
33
+
34
+
// LogWarn prints a warning message with yellow [WARN] prefix
35
+
func LogWarn(format string, args ...interface{}) {
36
+
fmt.Fprintf(Out, "%s %s\n", yellow("[WARN]"), fmt.Sprintf(format, args...))
37
+
}
38
+
39
+
// LogError prints an error message with red [ERROR] prefix
40
+
func LogError(format string, args ...interface{}) {
41
+
fmt.Fprintf(Err, "%s %s\n", red("[ERROR]"), fmt.Sprintf(format, args...))
42
+
}
43
+
44
+
// Header prints a header line
45
+
func Header(format string, args ...interface{}) {
46
+
fmt.Fprintf(Out, "=== %s ===\n", fmt.Sprintf(format, args...))
47
+
}
48
+
49
+
// SubHeader prints a sub-header line
50
+
func SubHeader(format string, args ...interface{}) {
51
+
fmt.Fprintf(Out, "--- %s ---\n", fmt.Sprintf(format, args...))
52
+
}
53
+
54
+
// Separator prints a separator line
55
+
func Separator() {
56
+
fmt.Fprintln(Out, "============================================")
57
+
}
58
+
59
+
// Print prints a plain message
60
+
func Print(format string, args ...interface{}) {
61
+
fmt.Fprintf(Out, format, args...)
62
+
}
63
+
64
+
// Println prints a plain message with newline
65
+
func Println(format string, args ...interface{}) {
66
+
fmt.Fprintf(Out, format+"\n", args...)
67
+
}
68
+
69
+
// NewTabWriter creates a new tabwriter for formatted table output
70
+
func NewTabWriter() *tabwriter.Writer {
71
+
return tabwriter.NewWriter(Out, 0, 0, 2, ' ', 0)
72
+
}
73
+
74
+
// StatusColor returns the appropriate color function for a status
75
+
func StatusColor(status string) func(a ...interface{}) string {
76
+
switch status {
77
+
case "OK", "Ready", "true":
78
+
return green
79
+
case "UNREACHABLE", "NotReady", "false":
80
+
return red
81
+
default:
82
+
return yellow
83
+
}
84
+
}
85
+
86
+
// RoleColor returns a color function for node roles
87
+
func RoleColor(role string) func(a ...interface{}) string {
88
+
switch role {
89
+
case "controlplane":
90
+
return color.New(color.FgMagenta).SprintFunc()
91
+
case "worker":
92
+
return color.New(color.FgCyan).SprintFunc()
93
+
default:
94
+
return color.New(color.FgWhite).SprintFunc()
95
+
}
96
+
}
97
+
98
+
// ProgressDot prints a progress dot (for waiting loops)
99
+
func ProgressDot() {
100
+
fmt.Fprint(Out, ".")
101
+
}
102
+
103
+
// ProgressNewline ends a progress line
104
+
func ProgressNewline() {
105
+
fmt.Fprintln(Out)
106
+
}
+377
internal/output/output_test.go
+377
internal/output/output_test.go
···
1
+
package output
2
+
3
+
import (
4
+
"bytes"
5
+
"strings"
6
+
"testing"
7
+
8
+
"github.com/stretchr/testify/assert"
9
+
"github.com/stretchr/testify/require"
10
+
)
11
+
12
+
// captureOutput captures stdout and stderr during a test
13
+
func captureOutput(t *testing.T, fn func()) (stdout, stderr string) {
14
+
t.Helper()
15
+
16
+
oldOut := Out
17
+
oldErr := Err
18
+
defer func() {
19
+
Out = oldOut
20
+
Err = oldErr
21
+
}()
22
+
23
+
var outBuf, errBuf bytes.Buffer
24
+
Out = &outBuf
25
+
Err = &errBuf
26
+
27
+
fn()
28
+
29
+
return outBuf.String(), errBuf.String()
30
+
}
31
+
32
+
// ============================================================================
33
+
// LogInfo() Tests
34
+
// ============================================================================
35
+
36
+
func TestLogInfo_SimpleMessage(t *testing.T) {
37
+
stdout, _ := captureOutput(t, func() {
38
+
LogInfo("test message")
39
+
})
40
+
41
+
assert.Contains(t, stdout, "[INFO]")
42
+
assert.Contains(t, stdout, "test message")
43
+
assert.True(t, strings.HasSuffix(stdout, "\n"))
44
+
}
45
+
46
+
func TestLogInfo_Formatted(t *testing.T) {
47
+
stdout, _ := captureOutput(t, func() {
48
+
LogInfo("node %s version %s", "192.168.1.1", "1.7.0")
49
+
})
50
+
51
+
assert.Contains(t, stdout, "[INFO]")
52
+
assert.Contains(t, stdout, "node 192.168.1.1 version 1.7.0")
53
+
}
54
+
55
+
// ============================================================================
56
+
// LogSuccess() Tests
57
+
// ============================================================================
58
+
59
+
func TestLogSuccess_SimpleMessage(t *testing.T) {
60
+
stdout, _ := captureOutput(t, func() {
61
+
LogSuccess("operation complete")
62
+
})
63
+
64
+
assert.Contains(t, stdout, "[OK]")
65
+
assert.Contains(t, stdout, "operation complete")
66
+
}
67
+
68
+
func TestLogSuccess_Formatted(t *testing.T) {
69
+
stdout, _ := captureOutput(t, func() {
70
+
LogSuccess("upgraded %d nodes", 5)
71
+
})
72
+
73
+
assert.Contains(t, stdout, "upgraded 5 nodes")
74
+
}
75
+
76
+
// ============================================================================
77
+
// LogWarn() Tests
78
+
// ============================================================================
79
+
80
+
func TestLogWarn_SimpleMessage(t *testing.T) {
81
+
stdout, _ := captureOutput(t, func() {
82
+
LogWarn("this is a warning")
83
+
})
84
+
85
+
assert.Contains(t, stdout, "[WARN]")
86
+
assert.Contains(t, stdout, "this is a warning")
87
+
}
88
+
89
+
func TestLogWarn_Formatted(t *testing.T) {
90
+
stdout, _ := captureOutput(t, func() {
91
+
LogWarn("node %s is unreachable", "192.168.1.99")
92
+
})
93
+
94
+
assert.Contains(t, stdout, "node 192.168.1.99 is unreachable")
95
+
}
96
+
97
+
// ============================================================================
98
+
// LogError() Tests
99
+
// ============================================================================
100
+
101
+
func TestLogError_SimpleMessage(t *testing.T) {
102
+
_, stderr := captureOutput(t, func() {
103
+
LogError("something failed")
104
+
})
105
+
106
+
assert.Contains(t, stderr, "[ERROR]")
107
+
assert.Contains(t, stderr, "something failed")
108
+
}
109
+
110
+
func TestLogError_Formatted(t *testing.T) {
111
+
_, stderr := captureOutput(t, func() {
112
+
LogError("failed to connect to %s: %v", "192.168.1.1", "connection refused")
113
+
})
114
+
115
+
assert.Contains(t, stderr, "failed to connect to 192.168.1.1: connection refused")
116
+
}
117
+
118
+
// ============================================================================
119
+
// Header() Tests
120
+
// ============================================================================
121
+
122
+
func TestHeader_SimpleMessage(t *testing.T) {
123
+
stdout, _ := captureOutput(t, func() {
124
+
Header("Talos Upgrade")
125
+
})
126
+
127
+
assert.Equal(t, "=== Talos Upgrade ===\n", stdout)
128
+
}
129
+
130
+
func TestHeader_Formatted(t *testing.T) {
131
+
stdout, _ := captureOutput(t, func() {
132
+
Header("Upgrade to v%s", "1.7.0")
133
+
})
134
+
135
+
assert.Equal(t, "=== Upgrade to v1.7.0 ===\n", stdout)
136
+
}
137
+
138
+
// ============================================================================
139
+
// SubHeader() Tests
140
+
// ============================================================================
141
+
142
+
func TestSubHeader_SimpleMessage(t *testing.T) {
143
+
stdout, _ := captureOutput(t, func() {
144
+
SubHeader("Node Status")
145
+
})
146
+
147
+
assert.Equal(t, "--- Node Status ---\n", stdout)
148
+
}
149
+
150
+
func TestSubHeader_Formatted(t *testing.T) {
151
+
stdout, _ := captureOutput(t, func() {
152
+
SubHeader("Processing %d nodes", 10)
153
+
})
154
+
155
+
assert.Equal(t, "--- Processing 10 nodes ---\n", stdout)
156
+
}
157
+
158
+
// ============================================================================
159
+
// Separator() Tests
160
+
// ============================================================================
161
+
162
+
func TestSeparator(t *testing.T) {
163
+
stdout, _ := captureOutput(t, func() {
164
+
Separator()
165
+
})
166
+
167
+
assert.Equal(t, "============================================\n", stdout)
168
+
}
169
+
170
+
// ============================================================================
171
+
// Print() and Println() Tests
172
+
// ============================================================================
173
+
174
+
func TestPrint_NoNewline(t *testing.T) {
175
+
stdout, _ := captureOutput(t, func() {
176
+
Print("no newline")
177
+
})
178
+
179
+
assert.Equal(t, "no newline", stdout)
180
+
assert.False(t, strings.HasSuffix(stdout, "\n"))
181
+
}
182
+
183
+
func TestPrint_Formatted(t *testing.T) {
184
+
stdout, _ := captureOutput(t, func() {
185
+
Print("value: %d", 42)
186
+
})
187
+
188
+
assert.Equal(t, "value: 42", stdout)
189
+
}
190
+
191
+
func TestPrintln_WithNewline(t *testing.T) {
192
+
stdout, _ := captureOutput(t, func() {
193
+
Println("with newline")
194
+
})
195
+
196
+
assert.Equal(t, "with newline\n", stdout)
197
+
}
198
+
199
+
func TestPrintln_Formatted(t *testing.T) {
200
+
stdout, _ := captureOutput(t, func() {
201
+
Println("count: %d", 100)
202
+
})
203
+
204
+
assert.Equal(t, "count: 100\n", stdout)
205
+
}
206
+
207
+
// ============================================================================
208
+
// NewTabWriter() Tests
209
+
// ============================================================================
210
+
211
+
func TestNewTabWriter(t *testing.T) {
212
+
stdout, _ := captureOutput(t, func() {
213
+
tw := NewTabWriter()
214
+
require.NotNil(t, tw)
215
+
216
+
tw.Write([]byte("column1\tcolumn2\tcolumn3\n"))
217
+
tw.Write([]byte("a\tb\tc\n"))
218
+
tw.Flush()
219
+
})
220
+
221
+
// Verify tabwriter produced aligned output
222
+
lines := strings.Split(strings.TrimSpace(stdout), "\n")
223
+
require.Len(t, lines, 2)
224
+
225
+
// Both lines should have consistent spacing (tabwriter adds padding)
226
+
assert.Contains(t, stdout, "column1")
227
+
assert.Contains(t, stdout, "column2")
228
+
assert.Contains(t, stdout, "column3")
229
+
}
230
+
231
+
// ============================================================================
232
+
// StatusColor() Tests
233
+
// ============================================================================
234
+
235
+
func TestStatusColor_OK(t *testing.T) {
236
+
colorFn := StatusColor("OK")
237
+
assert.NotNil(t, colorFn)
238
+
239
+
// Verify it produces some output (color codes may vary)
240
+
result := colorFn("test")
241
+
assert.NotEmpty(t, result)
242
+
}
243
+
244
+
func TestStatusColor_Ready(t *testing.T) {
245
+
colorFn := StatusColor("Ready")
246
+
assert.NotNil(t, colorFn)
247
+
}
248
+
249
+
func TestStatusColor_True(t *testing.T) {
250
+
colorFn := StatusColor("true")
251
+
assert.NotNil(t, colorFn)
252
+
}
253
+
254
+
func TestStatusColor_Unreachable(t *testing.T) {
255
+
colorFn := StatusColor("UNREACHABLE")
256
+
assert.NotNil(t, colorFn)
257
+
}
258
+
259
+
func TestStatusColor_NotReady(t *testing.T) {
260
+
colorFn := StatusColor("NotReady")
261
+
assert.NotNil(t, colorFn)
262
+
}
263
+
264
+
func TestStatusColor_False(t *testing.T) {
265
+
colorFn := StatusColor("false")
266
+
assert.NotNil(t, colorFn)
267
+
}
268
+
269
+
func TestStatusColor_Unknown(t *testing.T) {
270
+
colorFn := StatusColor("unknown")
271
+
assert.NotNil(t, colorFn)
272
+
}
273
+
274
+
// ============================================================================
275
+
// RoleColor() Tests
276
+
// ============================================================================
277
+
278
+
func TestRoleColor_ControlPlane(t *testing.T) {
279
+
colorFn := RoleColor("controlplane")
280
+
assert.NotNil(t, colorFn)
281
+
282
+
result := colorFn("controlplane")
283
+
assert.NotEmpty(t, result)
284
+
}
285
+
286
+
func TestRoleColor_Worker(t *testing.T) {
287
+
colorFn := RoleColor("worker")
288
+
assert.NotNil(t, colorFn)
289
+
290
+
result := colorFn("worker")
291
+
assert.NotEmpty(t, result)
292
+
}
293
+
294
+
func TestRoleColor_Unknown(t *testing.T) {
295
+
colorFn := RoleColor("unknown")
296
+
assert.NotNil(t, colorFn)
297
+
298
+
result := colorFn("unknown")
299
+
assert.NotEmpty(t, result)
300
+
}
301
+
302
+
// ============================================================================
303
+
// ProgressDot() and ProgressNewline() Tests
304
+
// ============================================================================
305
+
306
+
func TestProgressDot(t *testing.T) {
307
+
stdout, _ := captureOutput(t, func() {
308
+
ProgressDot()
309
+
ProgressDot()
310
+
ProgressDot()
311
+
})
312
+
313
+
assert.Equal(t, "...", stdout)
314
+
}
315
+
316
+
func TestProgressNewline(t *testing.T) {
317
+
stdout, _ := captureOutput(t, func() {
318
+
ProgressDot()
319
+
ProgressDot()
320
+
ProgressNewline()
321
+
})
322
+
323
+
assert.Equal(t, "..\n", stdout)
324
+
}
325
+
326
+
// ============================================================================
327
+
// Output Isolation Tests
328
+
// ============================================================================
329
+
330
+
func TestOutput_ErrorGoesToStderr(t *testing.T) {
331
+
stdout, stderr := captureOutput(t, func() {
332
+
LogInfo("info message")
333
+
LogSuccess("success message")
334
+
LogWarn("warning message")
335
+
LogError("error message")
336
+
})
337
+
338
+
// Info, Success, and Warn should go to stdout
339
+
assert.Contains(t, stdout, "[INFO]")
340
+
assert.Contains(t, stdout, "[OK]")
341
+
assert.Contains(t, stdout, "[WARN]")
342
+
assert.NotContains(t, stdout, "[ERROR]")
343
+
344
+
// Only Error should go to stderr
345
+
assert.Contains(t, stderr, "[ERROR]")
346
+
assert.NotContains(t, stderr, "[INFO]")
347
+
}
348
+
349
+
// ============================================================================
350
+
// Edge Cases
351
+
// ============================================================================
352
+
353
+
func TestLogInfo_EmptyMessage(t *testing.T) {
354
+
stdout, _ := captureOutput(t, func() {
355
+
LogInfo("")
356
+
})
357
+
358
+
assert.Contains(t, stdout, "[INFO]")
359
+
// Message is empty but prefix should still be there
360
+
}
361
+
362
+
func TestLogInfo_SpecialCharacters(t *testing.T) {
363
+
stdout, _ := captureOutput(t, func() {
364
+
LogInfo("path: /var/log/file.txt, user: test@example.com")
365
+
})
366
+
367
+
assert.Contains(t, stdout, "/var/log/file.txt")
368
+
assert.Contains(t, stdout, "test@example.com")
369
+
}
370
+
371
+
func TestLogInfo_Unicode(t *testing.T) {
372
+
stdout, _ := captureOutput(t, func() {
373
+
LogInfo("status: %s", "✓ complete")
374
+
})
375
+
376
+
assert.Contains(t, stdout, "✓ complete")
377
+
}
+780
internal/talos/client.go
+780
internal/talos/client.go
···
1
+
package talos
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"strings"
7
+
"time"
8
+
9
+
"github.com/cosi-project/runtime/pkg/resource"
10
+
"github.com/siderolabs/talos/pkg/machinery/api/machine"
11
+
talosclient "github.com/siderolabs/talos/pkg/machinery/client"
12
+
"github.com/siderolabs/talos/pkg/machinery/resources/cluster"
13
+
"github.com/siderolabs/talos/pkg/machinery/resources/hardware"
14
+
"github.com/siderolabs/talos/pkg/machinery/resources/k8s"
15
+
"github.com/siderolabs/talos/pkg/machinery/resources/runtime"
16
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17
+
"k8s.io/client-go/kubernetes"
18
+
"k8s.io/client-go/tools/clientcmd"
19
+
)
20
+
21
+
// NodeStatus represents the status of a single node
22
+
type NodeStatus struct {
23
+
IP string
24
+
Profile string
25
+
Role string
26
+
Version string
27
+
MachineType string
28
+
Secureboot bool
29
+
Reachable bool
30
+
}
31
+
32
+
// Client wraps the Talos SDK client
33
+
type Client struct {
34
+
talos TalosMachineClient
35
+
k8s kubernetes.Interface
36
+
clock Clock
37
+
}
38
+
39
+
// NewClient creates a new Talos client using default config
40
+
func NewClient(ctx context.Context) (*Client, error) {
41
+
talosClient, err := talosclient.New(ctx, talosclient.WithDefaultConfig())
42
+
if err != nil {
43
+
return nil, fmt.Errorf("failed to create Talos client: %w", err)
44
+
}
45
+
46
+
// Wrap the SDK client
47
+
wrapper := newTalosClientWrapper(talosClient)
48
+
49
+
// Load kubeconfig for node ready checks
50
+
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
51
+
configOverrides := &clientcmd.ConfigOverrides{}
52
+
kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
53
+
54
+
config, err := kubeConfig.ClientConfig()
55
+
if err != nil {
56
+
// K8s client is optional - we can still work without it
57
+
return &Client{talos: wrapper, k8s: nil, clock: newRealClock()}, nil
58
+
}
59
+
60
+
k8sClient, err := kubernetes.NewForConfig(config)
61
+
if err != nil {
62
+
// K8s client is optional
63
+
return &Client{talos: wrapper, k8s: nil, clock: newRealClock()}, nil
64
+
}
65
+
66
+
return &Client{talos: wrapper, k8s: k8sClient, clock: newRealClock()}, nil
67
+
}
68
+
69
+
// Close closes the client connection
70
+
func (c *Client) Close() error {
71
+
if c.talos != nil {
72
+
return c.talos.Close()
73
+
}
74
+
return nil
75
+
}
76
+
77
+
// GetVersion retrieves the Talos version for a node
78
+
func (c *Client) GetVersion(ctx context.Context, nodeIP string) (string, error) {
79
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
80
+
resp, err := c.talos.Version(nodeCtx)
81
+
if err != nil {
82
+
return "", fmt.Errorf("failed to get version: %w", err)
83
+
}
84
+
85
+
// Extract version from response
86
+
for _, msg := range resp.Messages {
87
+
if msg.Version != nil {
88
+
tag := msg.Version.Tag
89
+
// Strip leading 'v' if present
90
+
if len(tag) > 0 && tag[0] == 'v' {
91
+
tag = tag[1:]
92
+
}
93
+
return tag, nil
94
+
}
95
+
}
96
+
return "", fmt.Errorf("no version in response")
97
+
}
98
+
99
+
// GetMachineType retrieves the machine type (controlplane/worker) for a node
100
+
// This is inferred from the Version response metadata since the COSI API
101
+
// requires specific resource definitions that are internal to Talos
102
+
func (c *Client) GetMachineType(ctx context.Context, nodeIP string) (string, error) {
103
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
104
+
105
+
// The Version response includes platform info that can help identify node type
106
+
// For now, we rely on the config to provide the role
107
+
resp, err := c.talos.Version(nodeCtx)
108
+
if err != nil {
109
+
return "", fmt.Errorf("failed to get version: %w", err)
110
+
}
111
+
112
+
// Check if any message indicates this is a control plane
113
+
for _, msg := range resp.Messages {
114
+
if msg.Platform != nil {
115
+
// Platform info is available but doesn't directly indicate role
116
+
// The role is best obtained from config
117
+
break
118
+
}
119
+
}
120
+
121
+
// Return unknown - the caller should use the role from config
122
+
return "unknown", nil
123
+
}
124
+
125
+
// GetExtensions retrieves the list of installed extensions for a node
126
+
func (c *Client) GetExtensions(ctx context.Context, nodeIP string) ([]ExtensionInfo, error) {
127
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
128
+
129
+
// Query ExtensionStatus resources via COSI
130
+
list, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(runtime.NamespaceName, runtime.ExtensionStatusType, "", resource.VersionUndefined))
131
+
if err != nil {
132
+
return nil, fmt.Errorf("failed to list extensions: %w", err)
133
+
}
134
+
135
+
var extensions []ExtensionInfo
136
+
for _, res := range list.Items {
137
+
status, ok := res.(*runtime.ExtensionStatus)
138
+
if !ok {
139
+
continue
140
+
}
141
+
142
+
spec := status.TypedSpec()
143
+
extensions = append(extensions, ExtensionInfo{
144
+
Name: spec.Metadata.Name,
145
+
Version: spec.Metadata.Version,
146
+
Image: spec.Image,
147
+
})
148
+
}
149
+
150
+
return extensions, nil
151
+
}
152
+
153
+
// IsReachable checks if a node is reachable via the Talos API
154
+
func (c *Client) IsReachable(ctx context.Context, nodeIP string) bool {
155
+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
156
+
defer cancel()
157
+
158
+
_, err := c.GetVersion(ctx, nodeIP)
159
+
return err == nil
160
+
}
161
+
162
+
// Upgrade performs an upgrade on a node
163
+
func (c *Client) Upgrade(ctx context.Context, nodeIP, image string, preserve bool) error {
164
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
165
+
166
+
_, err := c.talos.UpgradeWithOptions(
167
+
nodeCtx,
168
+
talosclient.WithUpgradeImage(image),
169
+
talosclient.WithUpgradePreserve(preserve),
170
+
)
171
+
if err != nil {
172
+
return fmt.Errorf("upgrade failed: %w", err)
173
+
}
174
+
175
+
return nil
176
+
}
177
+
178
+
// WaitForNode waits for a node to be ready after upgrade
179
+
func (c *Client) WaitForNode(ctx context.Context, nodeIP string, timeout time.Duration) error {
180
+
clock := c.clock
181
+
if clock == nil {
182
+
clock = newRealClock()
183
+
}
184
+
deadline := clock.Now().Add(timeout)
185
+
186
+
for clock.Now().Before(deadline) {
187
+
select {
188
+
case <-ctx.Done():
189
+
return ctx.Err()
190
+
default:
191
+
}
192
+
193
+
// Check Talos API reachability
194
+
if c.IsReachable(ctx, nodeIP) {
195
+
// Check Kubernetes node ready status if k8s client is available
196
+
if c.k8s == nil || c.isK8sNodeReady(ctx, nodeIP) {
197
+
return nil
198
+
}
199
+
}
200
+
201
+
clock.Sleep(5 * time.Second)
202
+
}
203
+
204
+
return fmt.Errorf("timeout waiting for node %s", nodeIP)
205
+
}
206
+
207
+
// isK8sNodeReady checks if the Kubernetes node is in Ready state
208
+
func (c *Client) isK8sNodeReady(ctx context.Context, nodeIP string) bool {
209
+
if c.k8s == nil {
210
+
return true // Skip check if no k8s client
211
+
}
212
+
213
+
nodes, err := c.k8s.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
214
+
if err != nil {
215
+
return false
216
+
}
217
+
218
+
for _, node := range nodes.Items {
219
+
// Check if this node matches the IP
220
+
for _, addr := range node.Status.Addresses {
221
+
if addr.Address == nodeIP {
222
+
// Check Ready condition
223
+
for _, cond := range node.Status.Conditions {
224
+
if cond.Type == "Ready" && cond.Status == "True" {
225
+
return true
226
+
}
227
+
}
228
+
}
229
+
}
230
+
}
231
+
232
+
return false
233
+
}
234
+
235
+
// GetK8sNodeName returns the Kubernetes node name for an IP
236
+
func (c *Client) GetK8sNodeName(ctx context.Context, nodeIP string) string {
237
+
if c.k8s == nil {
238
+
return ""
239
+
}
240
+
241
+
nodes, err := c.k8s.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
242
+
if err != nil {
243
+
return ""
244
+
}
245
+
246
+
for _, node := range nodes.Items {
247
+
for _, addr := range node.Status.Addresses {
248
+
if addr.Address == nodeIP {
249
+
return node.Name
250
+
}
251
+
}
252
+
}
253
+
254
+
return ""
255
+
}
256
+
257
+
// GetNodeStatus retrieves comprehensive status for a node
258
+
func (c *Client) GetNodeStatus(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus {
259
+
status := NodeStatus{
260
+
IP: nodeIP,
261
+
Profile: profile,
262
+
Role: role,
263
+
Secureboot: secureboot,
264
+
Reachable: false,
265
+
Version: "N/A",
266
+
MachineType: "unknown",
267
+
}
268
+
269
+
// Check reachability first
270
+
if !c.IsReachable(ctx, nodeIP) {
271
+
return status
272
+
}
273
+
status.Reachable = true
274
+
275
+
// Get version
276
+
if version, err := c.GetVersion(ctx, nodeIP); err == nil {
277
+
status.Version = version
278
+
}
279
+
280
+
// Get machine type
281
+
if machineType, err := c.GetMachineType(ctx, nodeIP); err == nil {
282
+
status.MachineType = strings.ToLower(machineType)
283
+
}
284
+
285
+
return status
286
+
}
287
+
288
+
// UpgradeProgress represents the current state of an upgrade
289
+
type UpgradeProgress struct {
290
+
Stage string // Machine stage: upgrading, rebooting, booting, running
291
+
Phase string // Current phase name
292
+
Task string // Current task name
293
+
Action string // START or STOP
294
+
Error string // Error message if any
295
+
Done bool // True when upgrade is complete (node is running)
296
+
}
297
+
298
+
// ProgressCallback is called for each upgrade progress event
299
+
type ProgressCallback func(UpgradeProgress)
300
+
301
+
// WatchUpgrade streams upgrade events and calls the callback for each event.
302
+
// It handles the node reboot by reconnecting and waiting for the node to reach RUNNING state.
303
+
// Since the upgrade has already been initiated before this is called, any disconnect
304
+
// means the node is rebooting and we should wait for it to come back.
305
+
//
306
+
// IMPORTANT: The upgrade sequence emits RUNNING twice:
307
+
// 1. Before reboot (system still running while staging upgrade)
308
+
// 2. After reboot (new system fully booted)
309
+
// We must wait for the second RUNNING, which comes AFTER seeing UPGRADING/REBOOTING or connection loss.
310
+
func (c *Client) WatchUpgrade(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
311
+
clock := c.clock
312
+
if clock == nil {
313
+
clock = newRealClock()
314
+
}
315
+
deadline := clock.Now().Add(timeout)
316
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
317
+
318
+
// Start watching events
319
+
eventCh := make(chan talosclient.EventResult, 100)
320
+
321
+
// Start event watcher in goroutine
322
+
watchCtx, watchCancel := context.WithCancel(nodeCtx)
323
+
defer watchCancel()
324
+
325
+
go func() {
326
+
// Watch from now (tail -1 means all new events)
327
+
// Note: Do NOT close eventCh - the SDK's internal goroutine may still send to it
328
+
// after this function returns. Let it be garbage collected.
329
+
c.talos.EventsWatchV2(watchCtx, eventCh, talosclient.WithTailEvents(-1))
330
+
}()
331
+
332
+
// Track if we've seen upgrade-related stages (upgrading, rebooting, or connection loss)
333
+
// We only consider RUNNING as "done" after seeing one of these
334
+
sawUpgradeStage := false
335
+
336
+
// Wait for first event with a startup timeout to detect silent connection failures
337
+
const startupTimeout = 15 * time.Second
338
+
select {
339
+
case <-ctx.Done():
340
+
return ctx.Err()
341
+
case <-clock.After(startupTimeout):
342
+
// No events received - event stream may have failed silently
343
+
// Fall back to polling for node to come back
344
+
if onProgress != nil {
345
+
onProgress(UpgradeProgress{Stage: "rebooting", Action: "no events received, polling"})
346
+
}
347
+
return c.waitForRunning(ctx, nodeIP, deadline.Sub(clock.Now()), onProgress)
348
+
case result := <-eventCh:
349
+
if result.Error != nil {
350
+
// Stream error on first event - node is likely rebooting
351
+
if onProgress != nil {
352
+
onProgress(UpgradeProgress{Stage: "rebooting", Action: "connection lost"})
353
+
}
354
+
return c.waitForRunning(ctx, nodeIP, deadline.Sub(clock.Now()), onProgress)
355
+
}
356
+
// Process first event
357
+
progress := c.parseEvent(result.Event)
358
+
if progress != nil {
359
+
if onProgress != nil {
360
+
onProgress(*progress)
361
+
}
362
+
// Track upgrade stages
363
+
if progress.Stage == "upgrading" || progress.Stage == "rebooting" {
364
+
sawUpgradeStage = true
365
+
}
366
+
if progress.Error != "" {
367
+
return fmt.Errorf("upgrade failed: %s", progress.Error)
368
+
}
369
+
}
370
+
}
371
+
372
+
// Process remaining events until done or timeout
373
+
for {
374
+
select {
375
+
case <-ctx.Done():
376
+
return ctx.Err()
377
+
378
+
case <-clock.After(deadline.Sub(clock.Now())):
379
+
return fmt.Errorf("timeout waiting for upgrade to complete on %s", nodeIP)
380
+
381
+
case result := <-eventCh:
382
+
if result.Error != nil {
383
+
// Stream error - node is rebooting
384
+
sawUpgradeStage = true
385
+
if onProgress != nil {
386
+
onProgress(UpgradeProgress{Stage: "rebooting", Action: "connection lost"})
387
+
}
388
+
return c.waitForRunning(ctx, nodeIP, deadline.Sub(clock.Now()), onProgress)
389
+
}
390
+
391
+
// Process the event
392
+
progress := c.parseEvent(result.Event)
393
+
if progress != nil {
394
+
// Call the callback
395
+
if onProgress != nil {
396
+
onProgress(*progress)
397
+
}
398
+
399
+
// Track upgrade stages
400
+
if progress.Stage == "upgrading" || progress.Stage == "rebooting" {
401
+
sawUpgradeStage = true
402
+
}
403
+
404
+
// Only consider RUNNING as done if we've seen an upgrade stage
405
+
// This prevents exiting on the pre-reboot RUNNING event
406
+
if progress.Stage == "running" && sawUpgradeStage {
407
+
return nil
408
+
}
409
+
410
+
// Check for error in the event
411
+
if progress.Error != "" {
412
+
return fmt.Errorf("upgrade failed: %s", progress.Error)
413
+
}
414
+
}
415
+
}
416
+
}
417
+
}
418
+
419
+
// parseEvent converts a Talos event to UpgradeProgress
420
+
func (c *Client) parseEvent(event talosclient.Event) *UpgradeProgress {
421
+
progress := &UpgradeProgress{}
422
+
423
+
switch e := event.Payload.(type) {
424
+
case *machine.SequenceEvent:
425
+
progress.Phase = e.GetSequence()
426
+
progress.Action = e.GetAction().String()
427
+
if e.GetError() != nil {
428
+
progress.Error = e.GetError().GetMessage()
429
+
}
430
+
return progress
431
+
432
+
case *machine.PhaseEvent:
433
+
progress.Phase = e.GetPhase()
434
+
progress.Action = e.GetAction().String()
435
+
return progress
436
+
437
+
case *machine.TaskEvent:
438
+
progress.Task = e.GetTask()
439
+
progress.Action = e.GetAction().String()
440
+
return progress
441
+
442
+
case *machine.MachineStatusEvent:
443
+
stage := e.GetStage()
444
+
progress.Stage = strings.ToLower(stage.String())
445
+
// Note: Don't set Done here - let caller decide based on context
446
+
// (e.g., WatchUpgrade tracks sawUpgradeStage before accepting RUNNING as done)
447
+
return progress
448
+
449
+
default:
450
+
// Unknown event type, ignore
451
+
return nil
452
+
}
453
+
}
454
+
455
+
// WaitForServices waits for critical Talos services to be healthy
456
+
func (c *Client) WaitForServices(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
457
+
clock := c.clock
458
+
if clock == nil {
459
+
clock = newRealClock()
460
+
}
461
+
deadline := clock.Now().Add(timeout)
462
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
463
+
464
+
for clock.Now().Before(deadline) {
465
+
select {
466
+
case <-ctx.Done():
467
+
return ctx.Err()
468
+
default:
469
+
}
470
+
471
+
allHealthy := true
472
+
for _, svc := range services {
473
+
resp, err := c.talos.ServiceInfo(nodeCtx, svc)
474
+
if err != nil {
475
+
allHealthy = false
476
+
break
477
+
}
478
+
479
+
// Check if service is running and healthy
480
+
// resp is []ServiceInfo with Service field containing the actual info
481
+
for _, svcInfo := range resp {
482
+
if svcInfo.Service != nil && svcInfo.Service.Id == svc {
483
+
if svcInfo.Service.State != "Running" || (svcInfo.Service.Health != nil && !svcInfo.Service.Health.Healthy) {
484
+
allHealthy = false
485
+
}
486
+
}
487
+
}
488
+
}
489
+
490
+
if allHealthy {
491
+
return nil
492
+
}
493
+
494
+
clock.Sleep(2 * time.Second)
495
+
}
496
+
497
+
return fmt.Errorf("timeout waiting for services to be healthy")
498
+
}
499
+
500
+
// GetControlPlaneServices returns the list of services to wait for on control plane nodes
501
+
func GetControlPlaneServices() []string {
502
+
return []string{"etcd", "kubelet", "apid", "trustd"}
503
+
}
504
+
505
+
// GetWorkerServices returns the list of services to wait for on worker nodes
506
+
func GetWorkerServices() []string {
507
+
return []string{"kubelet", "apid", "trustd"}
508
+
}
509
+
510
+
// WaitForStaticPods waits for K8s control plane static pods to be healthy
511
+
func (c *Client) WaitForStaticPods(ctx context.Context, nodeIP string, timeout time.Duration) error {
512
+
clock := c.clock
513
+
if clock == nil {
514
+
clock = newRealClock()
515
+
}
516
+
deadline := clock.Now().Add(timeout)
517
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
518
+
519
+
requiredPods := []string{"kube-apiserver", "kube-controller-manager", "kube-scheduler"}
520
+
521
+
for clock.Now().Before(deadline) {
522
+
select {
523
+
case <-ctx.Done():
524
+
return ctx.Err()
525
+
default:
526
+
}
527
+
528
+
// List static pod statuses via COSI
529
+
list, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(k8s.NamespaceName, k8s.StaticPodStatusType, "", resource.VersionUndefined))
530
+
if err != nil {
531
+
clock.Sleep(2 * time.Second)
532
+
continue
533
+
}
534
+
535
+
// Check if all required pods are ready
536
+
readyPods := make(map[string]bool)
537
+
for _, res := range list.Items {
538
+
status, ok := res.(*k8s.StaticPodStatus)
539
+
if !ok {
540
+
continue
541
+
}
542
+
543
+
// Extract pod name from ID (e.g., "kube-system/kube-apiserver-node")
544
+
id := res.Metadata().ID()
545
+
for _, required := range requiredPods {
546
+
if strings.Contains(id, required) {
547
+
// Check if pod is ready
548
+
podStatus := status.TypedSpec().PodStatus
549
+
if phase, ok := podStatus["phase"].(string); ok && phase == "Running" {
550
+
// Check conditions for Ready
551
+
if conditions, ok := podStatus["conditions"].([]any); ok {
552
+
for _, cond := range conditions {
553
+
if condMap, ok := cond.(map[string]any); ok {
554
+
if condMap["type"] == "Ready" && condMap["status"] == "True" {
555
+
readyPods[required] = true
556
+
}
557
+
}
558
+
}
559
+
}
560
+
}
561
+
}
562
+
}
563
+
}
564
+
565
+
// Check if all required pods are ready
566
+
allReady := true
567
+
for _, pod := range requiredPods {
568
+
if !readyPods[pod] {
569
+
allReady = false
570
+
break
571
+
}
572
+
}
573
+
574
+
if allReady {
575
+
return nil
576
+
}
577
+
578
+
clock.Sleep(2 * time.Second)
579
+
}
580
+
581
+
return fmt.Errorf("timeout waiting for static pods to be healthy")
582
+
}
583
+
584
+
// waitForRunning polls for the node to come back up after reboot
585
+
func (c *Client) waitForRunning(ctx context.Context, nodeIP string, remaining time.Duration, onProgress ProgressCallback) error {
586
+
clock := c.clock
587
+
if clock == nil {
588
+
clock = newRealClock()
589
+
}
590
+
if onProgress != nil {
591
+
onProgress(UpgradeProgress{Stage: "rebooting", Action: "waiting for node to come back"})
592
+
}
593
+
594
+
deadline := clock.Now().Add(remaining)
595
+
pollInterval := 2 * time.Second
596
+
597
+
for clock.Now().Before(deadline) {
598
+
select {
599
+
case <-ctx.Done():
600
+
return ctx.Err()
601
+
case <-clock.After(pollInterval):
602
+
}
603
+
604
+
// Try to connect and check status
605
+
if c.IsReachable(ctx, nodeIP) {
606
+
// Node is back - try to watch for RUNNING state
607
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
608
+
eventCh := make(chan talosclient.EventResult, 10)
609
+
610
+
watchCtx, watchCancel := context.WithTimeout(nodeCtx, 10*time.Second)
611
+
612
+
go func() {
613
+
// Note: Do NOT close eventCh - SDK may still send after this returns
614
+
c.talos.EventsWatchV2(watchCtx, eventCh, talosclient.WithTailEvents(10))
615
+
}()
616
+
617
+
// Check recent events for machine status (with timeout)
618
+
eventLoop:
619
+
for {
620
+
select {
621
+
case <-watchCtx.Done():
622
+
break eventLoop
623
+
case result := <-eventCh:
624
+
if result.Error != nil {
625
+
break eventLoop
626
+
}
627
+
628
+
if progress := c.parseEvent(result.Event); progress != nil {
629
+
if onProgress != nil {
630
+
onProgress(*progress)
631
+
}
632
+
// In waitForRunning, we're already past the reboot so RUNNING means done
633
+
if progress.Stage == "running" {
634
+
watchCancel()
635
+
// Also verify k8s node is ready
636
+
if c.k8s == nil || c.isK8sNodeReady(ctx, nodeIP) {
637
+
return nil
638
+
}
639
+
}
640
+
}
641
+
}
642
+
}
643
+
watchCancel()
644
+
645
+
// If no RUNNING event but node is reachable and k8s ready, we're done
646
+
if c.k8s == nil || c.isK8sNodeReady(ctx, nodeIP) {
647
+
if onProgress != nil {
648
+
onProgress(UpgradeProgress{Stage: "running", Done: true})
649
+
}
650
+
return nil
651
+
}
652
+
}
653
+
}
654
+
655
+
return fmt.Errorf("timeout waiting for node %s to come back after reboot", nodeIP)
656
+
}
657
+
658
+
// GetClusterMembers discovers all nodes in the cluster by querying the Members resource.
659
+
// This uses the default talosconfig endpoints to connect to the cluster.
660
+
func (c *Client) GetClusterMembers(ctx context.Context) ([]ClusterMember, error) {
661
+
// Query Members resource - we need to query from any reachable node
662
+
// The SDK will use endpoints from talosconfig
663
+
list, err := c.talos.COSIList(ctx, resource.NewMetadata(cluster.NamespaceName, cluster.MemberType, "", resource.VersionUndefined))
664
+
if err != nil {
665
+
return nil, fmt.Errorf("failed to list cluster members: %w", err)
666
+
}
667
+
668
+
var members []ClusterMember
669
+
seen := make(map[string]bool)
670
+
671
+
for _, res := range list.Items {
672
+
member, ok := res.(*cluster.Member)
673
+
if !ok {
674
+
continue
675
+
}
676
+
677
+
spec := member.TypedSpec()
678
+
if len(spec.Addresses) == 0 {
679
+
continue
680
+
}
681
+
682
+
// Use the first IPv4 address as the primary IP
683
+
var primaryIP string
684
+
for _, addr := range spec.Addresses {
685
+
if addr.Is4() {
686
+
primaryIP = addr.String()
687
+
break
688
+
}
689
+
}
690
+
if primaryIP == "" {
691
+
primaryIP = spec.Addresses[0].String()
692
+
}
693
+
694
+
// Skip duplicates (members are replicated across all nodes)
695
+
if seen[primaryIP] {
696
+
continue
697
+
}
698
+
seen[primaryIP] = true
699
+
700
+
// Convert machine type to role string
701
+
role := "worker"
702
+
if spec.MachineType.String() == "controlplane" {
703
+
role = "controlplane"
704
+
}
705
+
706
+
members = append(members, ClusterMember{
707
+
IP: primaryIP,
708
+
Hostname: spec.Hostname,
709
+
Role: role,
710
+
MachineType: spec.MachineType.String(),
711
+
})
712
+
}
713
+
714
+
return members, nil
715
+
}
716
+
717
+
// GetKernelCmdline retrieves the kernel command line from a node.
718
+
func (c *Client) GetKernelCmdline(ctx context.Context, nodeIP string) (string, error) {
719
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
720
+
721
+
list, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(runtime.NamespaceName, runtime.KernelCmdlineType, "", resource.VersionUndefined))
722
+
if err != nil {
723
+
return "", fmt.Errorf("failed to get kernel cmdline: %w", err)
724
+
}
725
+
726
+
for _, res := range list.Items {
727
+
cmdline, ok := res.(*runtime.KernelCmdline)
728
+
if !ok {
729
+
continue
730
+
}
731
+
return cmdline.TypedSpec().Cmdline, nil
732
+
}
733
+
734
+
return "", fmt.Errorf("no kernel cmdline found")
735
+
}
736
+
737
+
// GetHardwareInfo retrieves hardware information from a node for profile detection.
738
+
func (c *Client) GetHardwareInfo(ctx context.Context, nodeIP string) (*HardwareInfo, error) {
739
+
nodeCtx := talosclient.WithNode(ctx, nodeIP)
740
+
info := &HardwareInfo{}
741
+
742
+
// Query SystemInformation resource
743
+
sysInfoList, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(hardware.NamespaceName, hardware.SystemInformationType, "", resource.VersionUndefined))
744
+
if err != nil {
745
+
return nil, fmt.Errorf("failed to get system information: %w", err)
746
+
}
747
+
748
+
for _, res := range sysInfoList.Items {
749
+
sysInfo, ok := res.(*hardware.SystemInformation)
750
+
if !ok {
751
+
continue
752
+
}
753
+
spec := sysInfo.TypedSpec()
754
+
info.SystemManufacturer = spec.Manufacturer
755
+
info.SystemProductName = spec.ProductName
756
+
break // Only one SystemInformation resource
757
+
}
758
+
759
+
// Query Processor resources
760
+
procList, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(hardware.NamespaceName, hardware.ProcessorType, "", resource.VersionUndefined))
761
+
if err != nil {
762
+
return nil, fmt.Errorf("failed to get processor information: %w", err)
763
+
}
764
+
765
+
for _, res := range procList.Items {
766
+
proc, ok := res.(*hardware.Processor)
767
+
if !ok {
768
+
continue
769
+
}
770
+
spec := proc.TypedSpec()
771
+
// Take the first processor with manufacturer info
772
+
if spec.Manufacturer != "" {
773
+
info.ProcessorManufacturer = spec.Manufacturer
774
+
info.ProcessorProductName = spec.ProductName
775
+
break
776
+
}
777
+
}
778
+
779
+
return info, nil
780
+
}
+1371
internal/talos/client_test.go
+1371
internal/talos/client_test.go
···
1
+
package talos
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"testing"
7
+
"time"
8
+
9
+
"github.com/cosi-project/runtime/pkg/resource"
10
+
"github.com/siderolabs/talos/pkg/machinery/api/common"
11
+
"github.com/siderolabs/talos/pkg/machinery/api/machine"
12
+
talosclient "github.com/siderolabs/talos/pkg/machinery/client"
13
+
"github.com/stretchr/testify/assert"
14
+
"github.com/stretchr/testify/require"
15
+
corev1 "k8s.io/api/core/v1"
16
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17
+
"k8s.io/client-go/kubernetes/fake"
18
+
)
19
+
20
+
// ============================================================================
21
+
// MockClient Tests
22
+
// ============================================================================
23
+
24
+
func TestMockClient_DefaultBehavior(t *testing.T) {
25
+
mock := &MockClient{}
26
+
27
+
t.Run("Close returns nil", func(t *testing.T) {
28
+
err := mock.Close()
29
+
assert.NoError(t, err)
30
+
})
31
+
32
+
t.Run("GetVersion returns default", func(t *testing.T) {
33
+
version, err := mock.GetVersion(context.Background(), "192.168.1.1")
34
+
require.NoError(t, err)
35
+
assert.Equal(t, "1.7.0", version)
36
+
})
37
+
38
+
t.Run("GetMachineType returns unknown", func(t *testing.T) {
39
+
machineType, err := mock.GetMachineType(context.Background(), "192.168.1.1")
40
+
require.NoError(t, err)
41
+
assert.Equal(t, "unknown", machineType)
42
+
})
43
+
44
+
t.Run("IsReachable returns true", func(t *testing.T) {
45
+
reachable := mock.IsReachable(context.Background(), "192.168.1.1")
46
+
assert.True(t, reachable)
47
+
})
48
+
49
+
t.Run("Upgrade returns nil", func(t *testing.T) {
50
+
err := mock.Upgrade(context.Background(), "192.168.1.1", "image:v1.7.0", true)
51
+
assert.NoError(t, err)
52
+
})
53
+
54
+
t.Run("WaitForNode returns nil", func(t *testing.T) {
55
+
err := mock.WaitForNode(context.Background(), "192.168.1.1", time.Minute)
56
+
assert.NoError(t, err)
57
+
})
58
+
59
+
t.Run("GetNodeStatus returns populated status", func(t *testing.T) {
60
+
status := mock.GetNodeStatus(context.Background(), "192.168.1.1", "profile-a", "worker", true)
61
+
assert.Equal(t, "192.168.1.1", status.IP)
62
+
assert.Equal(t, "profile-a", status.Profile)
63
+
assert.Equal(t, "worker", status.Role)
64
+
assert.Equal(t, "1.7.0", status.Version)
65
+
assert.True(t, status.Secureboot)
66
+
assert.True(t, status.Reachable)
67
+
})
68
+
69
+
t.Run("WatchUpgrade returns nil", func(t *testing.T) {
70
+
err := mock.WatchUpgrade(context.Background(), "192.168.1.1", time.Minute, nil)
71
+
assert.NoError(t, err)
72
+
})
73
+
74
+
t.Run("WaitForServices returns nil", func(t *testing.T) {
75
+
err := mock.WaitForServices(context.Background(), "192.168.1.1", []string{"etcd", "kubelet"}, time.Minute)
76
+
assert.NoError(t, err)
77
+
})
78
+
79
+
t.Run("WaitForStaticPods returns nil", func(t *testing.T) {
80
+
err := mock.WaitForStaticPods(context.Background(), "192.168.1.1", time.Minute)
81
+
assert.NoError(t, err)
82
+
})
83
+
}
84
+
85
+
func TestMockClient_CustomFunctions(t *testing.T) {
86
+
t.Run("custom GetVersion", func(t *testing.T) {
87
+
mock := &MockClient{
88
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
89
+
if nodeIP == "192.168.1.1" {
90
+
return "1.8.0", nil
91
+
}
92
+
return "", errors.New("node not found")
93
+
},
94
+
}
95
+
96
+
version, err := mock.GetVersion(context.Background(), "192.168.1.1")
97
+
require.NoError(t, err)
98
+
assert.Equal(t, "1.8.0", version)
99
+
100
+
_, err = mock.GetVersion(context.Background(), "192.168.1.99")
101
+
assert.Error(t, err)
102
+
})
103
+
104
+
t.Run("custom IsReachable", func(t *testing.T) {
105
+
unreachableNodes := map[string]bool{"192.168.1.2": true}
106
+
mock := &MockClient{
107
+
IsReachableFunc: func(ctx context.Context, nodeIP string) bool {
108
+
return !unreachableNodes[nodeIP]
109
+
},
110
+
}
111
+
112
+
assert.True(t, mock.IsReachable(context.Background(), "192.168.1.1"))
113
+
assert.False(t, mock.IsReachable(context.Background(), "192.168.1.2"))
114
+
})
115
+
116
+
t.Run("custom Upgrade with error", func(t *testing.T) {
117
+
mock := &MockClient{
118
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, preserve bool) error {
119
+
return errors.New("upgrade failed")
120
+
},
121
+
}
122
+
123
+
err := mock.Upgrade(context.Background(), "192.168.1.1", "image:v1.7.0", true)
124
+
assert.Error(t, err)
125
+
assert.Contains(t, err.Error(), "upgrade failed")
126
+
})
127
+
128
+
t.Run("custom WaitForNode timeout", func(t *testing.T) {
129
+
mock := &MockClient{
130
+
WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
131
+
return errors.New("timeout waiting for node")
132
+
},
133
+
}
134
+
135
+
err := mock.WaitForNode(context.Background(), "192.168.1.1", time.Minute)
136
+
assert.Error(t, err)
137
+
})
138
+
139
+
t.Run("custom GetNodeStatus unreachable", func(t *testing.T) {
140
+
mock := &MockClient{
141
+
GetNodeStatusFunc: func(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus {
142
+
return NodeStatus{
143
+
IP: nodeIP,
144
+
Profile: profile,
145
+
Role: role,
146
+
Version: "N/A",
147
+
Reachable: false,
148
+
}
149
+
},
150
+
}
151
+
152
+
status := mock.GetNodeStatus(context.Background(), "192.168.1.1", "test", "worker", false)
153
+
assert.False(t, status.Reachable)
154
+
assert.Equal(t, "N/A", status.Version)
155
+
})
156
+
157
+
t.Run("custom WatchUpgrade with callback", func(t *testing.T) {
158
+
var callbackCalls []UpgradeProgress
159
+
mock := &MockClient{
160
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
161
+
// Simulate progress events
162
+
if onProgress != nil {
163
+
onProgress(UpgradeProgress{Stage: "upgrading", Phase: "prepare"})
164
+
onProgress(UpgradeProgress{Stage: "rebooting"})
165
+
onProgress(UpgradeProgress{Stage: "running", Done: true})
166
+
}
167
+
return nil
168
+
},
169
+
}
170
+
171
+
err := mock.WatchUpgrade(context.Background(), "192.168.1.1", time.Minute, func(p UpgradeProgress) {
172
+
callbackCalls = append(callbackCalls, p)
173
+
})
174
+
require.NoError(t, err)
175
+
assert.Len(t, callbackCalls, 3)
176
+
assert.Equal(t, "upgrading", callbackCalls[0].Stage)
177
+
assert.Equal(t, "running", callbackCalls[2].Stage)
178
+
assert.True(t, callbackCalls[2].Done)
179
+
})
180
+
181
+
t.Run("custom WaitForServices with error", func(t *testing.T) {
182
+
mock := &MockClient{
183
+
WaitForServicesFunc: func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
184
+
return errors.New("service not healthy")
185
+
},
186
+
}
187
+
err := mock.WaitForServices(context.Background(), "192.168.1.1", []string{"etcd"}, time.Minute)
188
+
assert.Error(t, err)
189
+
assert.Contains(t, err.Error(), "service not healthy")
190
+
})
191
+
192
+
t.Run("custom WaitForStaticPods with error", func(t *testing.T) {
193
+
mock := &MockClient{
194
+
WaitForStaticPodsFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
195
+
return errors.New("pods not ready")
196
+
},
197
+
}
198
+
err := mock.WaitForStaticPods(context.Background(), "192.168.1.1", time.Minute)
199
+
assert.Error(t, err)
200
+
assert.Contains(t, err.Error(), "pods not ready")
201
+
})
202
+
}
203
+
204
+
// ============================================================================
205
+
// NodeStatus Tests
206
+
// ============================================================================
207
+
208
+
func TestNodeStatus_Struct(t *testing.T) {
209
+
status := NodeStatus{
210
+
IP: "192.168.1.1",
211
+
Profile: "amd64-intel",
212
+
Role: "controlplane",
213
+
Version: "1.7.0",
214
+
MachineType: "controlplane",
215
+
Secureboot: true,
216
+
Reachable: true,
217
+
}
218
+
219
+
assert.Equal(t, "192.168.1.1", status.IP)
220
+
assert.Equal(t, "amd64-intel", status.Profile)
221
+
assert.Equal(t, "controlplane", status.Role)
222
+
assert.Equal(t, "1.7.0", status.Version)
223
+
assert.Equal(t, "controlplane", status.MachineType)
224
+
assert.True(t, status.Secureboot)
225
+
assert.True(t, status.Reachable)
226
+
}
227
+
228
+
func TestNodeStatus_Unreachable(t *testing.T) {
229
+
status := NodeStatus{
230
+
IP: "192.168.1.99",
231
+
Profile: "unknown",
232
+
Role: "worker",
233
+
Version: "N/A",
234
+
MachineType: "unknown",
235
+
Secureboot: false,
236
+
Reachable: false,
237
+
}
238
+
239
+
assert.Equal(t, "192.168.1.99", status.IP)
240
+
assert.Equal(t, "N/A", status.Version)
241
+
assert.False(t, status.Reachable)
242
+
}
243
+
244
+
// ============================================================================
245
+
// UpgradeProgress Tests
246
+
// ============================================================================
247
+
248
+
func TestUpgradeProgress_Struct(t *testing.T) {
249
+
progress := UpgradeProgress{
250
+
Stage: "upgrading",
251
+
Phase: "install",
252
+
Task: "downloading image",
253
+
Action: "START",
254
+
Done: false,
255
+
}
256
+
257
+
assert.Equal(t, "upgrading", progress.Stage)
258
+
assert.Equal(t, "install", progress.Phase)
259
+
assert.Equal(t, "downloading image", progress.Task)
260
+
assert.Equal(t, "START", progress.Action)
261
+
assert.False(t, progress.Done)
262
+
}
263
+
264
+
func TestUpgradeProgress_Done(t *testing.T) {
265
+
progress := UpgradeProgress{
266
+
Stage: "running",
267
+
Done: true,
268
+
}
269
+
270
+
assert.True(t, progress.Done)
271
+
}
272
+
273
+
func TestUpgradeProgress_Error(t *testing.T) {
274
+
progress := UpgradeProgress{
275
+
Stage: "upgrading",
276
+
Error: "failed to download image",
277
+
}
278
+
279
+
assert.NotEmpty(t, progress.Error)
280
+
}
281
+
282
+
// ============================================================================
283
+
// Interface Compliance Tests
284
+
// ============================================================================
285
+
286
+
func TestMockClient_ImplementsInterface(t *testing.T) {
287
+
// This test verifies that MockClient implements TalosClientInterface
288
+
var client TalosClientInterface = &MockClient{}
289
+
assert.NotNil(t, client)
290
+
}
291
+
292
+
// ============================================================================
293
+
// Context Handling Tests
294
+
// ============================================================================
295
+
296
+
func TestMockClient_ContextCancellation(t *testing.T) {
297
+
mock := &MockClient{
298
+
WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
299
+
select {
300
+
case <-ctx.Done():
301
+
return ctx.Err()
302
+
case <-time.After(timeout):
303
+
return nil
304
+
}
305
+
},
306
+
}
307
+
308
+
ctx, cancel := context.WithCancel(context.Background())
309
+
cancel() // Cancel immediately
310
+
311
+
err := mock.WaitForNode(ctx, "192.168.1.1", time.Hour)
312
+
assert.Error(t, err)
313
+
assert.Equal(t, context.Canceled, err)
314
+
}
315
+
316
+
func TestMockClient_ContextTimeout(t *testing.T) {
317
+
mock := &MockClient{
318
+
WaitForNodeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration) error {
319
+
select {
320
+
case <-ctx.Done():
321
+
return ctx.Err()
322
+
case <-time.After(time.Second): // Simulates long operation
323
+
return nil
324
+
}
325
+
},
326
+
}
327
+
328
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
329
+
defer cancel()
330
+
331
+
err := mock.WaitForNode(ctx, "192.168.1.1", time.Hour)
332
+
assert.Error(t, err)
333
+
assert.Equal(t, context.DeadlineExceeded, err)
334
+
}
335
+
336
+
// ============================================================================
337
+
// Upgrade Workflow Simulation Tests
338
+
// ============================================================================
339
+
340
+
func TestMockClient_SimulateUpgradeWorkflow(t *testing.T) {
341
+
// Track upgrade state
342
+
upgradeStarted := false
343
+
nodeVersion := "1.6.0"
344
+
345
+
mock := &MockClient{
346
+
GetVersionFunc: func(ctx context.Context, nodeIP string) (string, error) {
347
+
return nodeVersion, nil
348
+
},
349
+
UpgradeFunc: func(ctx context.Context, nodeIP, image string, preserve bool) error {
350
+
upgradeStarted = true
351
+
return nil
352
+
},
353
+
WatchUpgradeFunc: func(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
354
+
if !upgradeStarted {
355
+
return errors.New("upgrade not started")
356
+
}
357
+
// Simulate upgrade completing
358
+
nodeVersion = "1.7.0"
359
+
if onProgress != nil {
360
+
onProgress(UpgradeProgress{Stage: "running", Done: true})
361
+
}
362
+
return nil
363
+
},
364
+
}
365
+
366
+
ctx := context.Background()
367
+
368
+
// Check initial version
369
+
version, err := mock.GetVersion(ctx, "192.168.1.1")
370
+
require.NoError(t, err)
371
+
assert.Equal(t, "1.6.0", version)
372
+
373
+
// Start upgrade
374
+
err = mock.Upgrade(ctx, "192.168.1.1", "image:v1.7.0", true)
375
+
require.NoError(t, err)
376
+
assert.True(t, upgradeStarted)
377
+
378
+
// Watch for completion
379
+
var finalProgress UpgradeProgress
380
+
err = mock.WatchUpgrade(ctx, "192.168.1.1", time.Minute, func(p UpgradeProgress) {
381
+
finalProgress = p
382
+
})
383
+
require.NoError(t, err)
384
+
assert.True(t, finalProgress.Done)
385
+
386
+
// Check new version
387
+
version, err = mock.GetVersion(ctx, "192.168.1.1")
388
+
require.NoError(t, err)
389
+
assert.Equal(t, "1.7.0", version)
390
+
}
391
+
392
+
// ============================================================================
393
+
// Service List Tests
394
+
// ============================================================================
395
+
396
+
func TestGetControlPlaneServices(t *testing.T) {
397
+
services := GetControlPlaneServices()
398
+
expected := []string{"etcd", "kubelet", "apid", "trustd"}
399
+
assert.Equal(t, expected, services)
400
+
assert.Len(t, services, 4)
401
+
}
402
+
403
+
func TestGetWorkerServices(t *testing.T) {
404
+
services := GetWorkerServices()
405
+
expected := []string{"kubelet", "apid", "trustd"}
406
+
assert.Equal(t, expected, services)
407
+
assert.Len(t, services, 3)
408
+
}
409
+
410
+
// ============================================================================
411
+
// parseEvent Tests
412
+
// ============================================================================
413
+
414
+
func TestParseEvent_SequenceEvent(t *testing.T) {
415
+
c := &Client{} // nil talos/k8s clients are fine - parseEvent doesn't use them
416
+
417
+
t.Run("sequence start", func(t *testing.T) {
418
+
event := talosclient.Event{
419
+
Payload: &machine.SequenceEvent{
420
+
Sequence: "upgrade",
421
+
Action: machine.SequenceEvent_START,
422
+
},
423
+
}
424
+
progress := c.parseEvent(event)
425
+
require.NotNil(t, progress)
426
+
assert.Equal(t, "upgrade", progress.Phase)
427
+
assert.Equal(t, "START", progress.Action)
428
+
assert.Empty(t, progress.Error)
429
+
})
430
+
431
+
t.Run("sequence stop", func(t *testing.T) {
432
+
event := talosclient.Event{
433
+
Payload: &machine.SequenceEvent{
434
+
Sequence: "reboot",
435
+
Action: machine.SequenceEvent_STOP,
436
+
},
437
+
}
438
+
progress := c.parseEvent(event)
439
+
require.NotNil(t, progress)
440
+
assert.Equal(t, "reboot", progress.Phase)
441
+
assert.Equal(t, "STOP", progress.Action)
442
+
})
443
+
444
+
t.Run("sequence with error", func(t *testing.T) {
445
+
event := talosclient.Event{
446
+
Payload: &machine.SequenceEvent{
447
+
Sequence: "upgrade",
448
+
Action: machine.SequenceEvent_START,
449
+
Error: &common.Error{
450
+
Message: "upgrade failed: disk full",
451
+
},
452
+
},
453
+
}
454
+
progress := c.parseEvent(event)
455
+
require.NotNil(t, progress)
456
+
assert.Equal(t, "upgrade failed: disk full", progress.Error)
457
+
})
458
+
}
459
+
460
+
func TestParseEvent_PhaseEvent(t *testing.T) {
461
+
c := &Client{}
462
+
463
+
t.Run("phase start", func(t *testing.T) {
464
+
event := talosclient.Event{
465
+
Payload: &machine.PhaseEvent{
466
+
Phase: "install",
467
+
Action: machine.PhaseEvent_START,
468
+
},
469
+
}
470
+
progress := c.parseEvent(event)
471
+
require.NotNil(t, progress)
472
+
assert.Equal(t, "install", progress.Phase)
473
+
assert.Equal(t, "START", progress.Action)
474
+
})
475
+
476
+
t.Run("phase stop", func(t *testing.T) {
477
+
event := talosclient.Event{
478
+
Payload: &machine.PhaseEvent{
479
+
Phase: "boot",
480
+
Action: machine.PhaseEvent_STOP,
481
+
},
482
+
}
483
+
progress := c.parseEvent(event)
484
+
require.NotNil(t, progress)
485
+
assert.Equal(t, "boot", progress.Phase)
486
+
assert.Equal(t, "STOP", progress.Action)
487
+
})
488
+
}
489
+
490
+
func TestParseEvent_TaskEvent(t *testing.T) {
491
+
c := &Client{}
492
+
493
+
t.Run("task start", func(t *testing.T) {
494
+
event := talosclient.Event{
495
+
Payload: &machine.TaskEvent{
496
+
Task: "downloading image",
497
+
Action: machine.TaskEvent_START,
498
+
},
499
+
}
500
+
progress := c.parseEvent(event)
501
+
require.NotNil(t, progress)
502
+
assert.Equal(t, "downloading image", progress.Task)
503
+
assert.Equal(t, "START", progress.Action)
504
+
})
505
+
506
+
t.Run("task stop", func(t *testing.T) {
507
+
event := talosclient.Event{
508
+
Payload: &machine.TaskEvent{
509
+
Task: "writing disk",
510
+
Action: machine.TaskEvent_STOP,
511
+
},
512
+
}
513
+
progress := c.parseEvent(event)
514
+
require.NotNil(t, progress)
515
+
assert.Equal(t, "writing disk", progress.Task)
516
+
assert.Equal(t, "STOP", progress.Action)
517
+
})
518
+
}
519
+
520
+
func TestParseEvent_MachineStatusEvent(t *testing.T) {
521
+
c := &Client{}
522
+
523
+
t.Run("running state", func(t *testing.T) {
524
+
event := talosclient.Event{
525
+
Payload: &machine.MachineStatusEvent{
526
+
Stage: machine.MachineStatusEvent_RUNNING,
527
+
},
528
+
}
529
+
progress := c.parseEvent(event)
530
+
require.NotNil(t, progress)
531
+
assert.Equal(t, "running", progress.Stage)
532
+
// Note: parseEvent no longer sets Done - caller decides based on context
533
+
assert.False(t, progress.Done)
534
+
})
535
+
536
+
t.Run("booting state", func(t *testing.T) {
537
+
event := talosclient.Event{
538
+
Payload: &machine.MachineStatusEvent{
539
+
Stage: machine.MachineStatusEvent_BOOTING,
540
+
},
541
+
}
542
+
progress := c.parseEvent(event)
543
+
require.NotNil(t, progress)
544
+
assert.Equal(t, "booting", progress.Stage)
545
+
assert.False(t, progress.Done)
546
+
})
547
+
548
+
t.Run("upgrading state", func(t *testing.T) {
549
+
event := talosclient.Event{
550
+
Payload: &machine.MachineStatusEvent{
551
+
Stage: machine.MachineStatusEvent_UPGRADING,
552
+
},
553
+
}
554
+
progress := c.parseEvent(event)
555
+
require.NotNil(t, progress)
556
+
assert.Equal(t, "upgrading", progress.Stage)
557
+
assert.False(t, progress.Done)
558
+
})
559
+
560
+
t.Run("rebooting state", func(t *testing.T) {
561
+
event := talosclient.Event{
562
+
Payload: &machine.MachineStatusEvent{
563
+
Stage: machine.MachineStatusEvent_REBOOTING,
564
+
},
565
+
}
566
+
progress := c.parseEvent(event)
567
+
require.NotNil(t, progress)
568
+
assert.Equal(t, "rebooting", progress.Stage)
569
+
assert.False(t, progress.Done)
570
+
})
571
+
}
572
+
573
+
func TestParseEvent_UnknownPayload(t *testing.T) {
574
+
c := &Client{}
575
+
576
+
t.Run("unknown proto message returns nil", func(t *testing.T) {
577
+
// Use a proto message type that isn't handled in the switch
578
+
event := talosclient.Event{
579
+
Payload: &machine.Version{},
580
+
}
581
+
progress := c.parseEvent(event)
582
+
assert.Nil(t, progress)
583
+
})
584
+
585
+
t.Run("nil payload returns nil", func(t *testing.T) {
586
+
event := talosclient.Event{
587
+
Payload: nil,
588
+
}
589
+
progress := c.parseEvent(event)
590
+
assert.Nil(t, progress)
591
+
})
592
+
}
593
+
594
+
// ============================================================================
595
+
// K8s Client Tests (using fake clientset)
596
+
// ============================================================================
597
+
598
+
// makeNode creates a K8s Node for testing
599
+
func makeNode(name, ip string, ready bool) *corev1.Node {
600
+
status := corev1.ConditionFalse
601
+
if ready {
602
+
status = corev1.ConditionTrue
603
+
}
604
+
return &corev1.Node{
605
+
ObjectMeta: metav1.ObjectMeta{Name: name},
606
+
Status: corev1.NodeStatus{
607
+
Addresses: []corev1.NodeAddress{
608
+
{Type: corev1.NodeInternalIP, Address: ip},
609
+
},
610
+
Conditions: []corev1.NodeCondition{
611
+
{Type: corev1.NodeReady, Status: status},
612
+
},
613
+
},
614
+
}
615
+
}
616
+
617
+
func TestIsK8sNodeReady(t *testing.T) {
618
+
ctx := context.Background()
619
+
620
+
t.Run("nil k8s client returns true", func(t *testing.T) {
621
+
c := &Client{k8s: nil}
622
+
assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
623
+
})
624
+
625
+
t.Run("node ready", func(t *testing.T) {
626
+
fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", true))
627
+
c := &Client{k8s: fakeClient}
628
+
assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
629
+
})
630
+
631
+
t.Run("node not ready", func(t *testing.T) {
632
+
fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", false))
633
+
c := &Client{k8s: fakeClient}
634
+
assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
635
+
})
636
+
637
+
t.Run("node not found", func(t *testing.T) {
638
+
fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.99", true))
639
+
c := &Client{k8s: fakeClient}
640
+
assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
641
+
})
642
+
643
+
t.Run("empty cluster", func(t *testing.T) {
644
+
fakeClient := fake.NewSimpleClientset()
645
+
c := &Client{k8s: fakeClient}
646
+
assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
647
+
})
648
+
649
+
t.Run("multiple nodes finds correct one", func(t *testing.T) {
650
+
fakeClient := fake.NewSimpleClientset(
651
+
makeNode("node1", "192.168.1.1", false),
652
+
makeNode("node2", "192.168.1.2", true),
653
+
makeNode("node3", "192.168.1.3", true),
654
+
)
655
+
c := &Client{k8s: fakeClient}
656
+
assert.False(t, c.isK8sNodeReady(ctx, "192.168.1.1"))
657
+
assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.2"))
658
+
assert.True(t, c.isK8sNodeReady(ctx, "192.168.1.3"))
659
+
})
660
+
}
661
+
662
+
func TestGetK8sNodeName(t *testing.T) {
663
+
ctx := context.Background()
664
+
665
+
t.Run("nil k8s client returns empty", func(t *testing.T) {
666
+
c := &Client{k8s: nil}
667
+
assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.1"))
668
+
})
669
+
670
+
t.Run("node found", func(t *testing.T) {
671
+
fakeClient := fake.NewSimpleClientset(makeNode("my-node", "192.168.1.1", true))
672
+
c := &Client{k8s: fakeClient}
673
+
assert.Equal(t, "my-node", c.GetK8sNodeName(ctx, "192.168.1.1"))
674
+
})
675
+
676
+
t.Run("node not found", func(t *testing.T) {
677
+
fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.99", true))
678
+
c := &Client{k8s: fakeClient}
679
+
assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.1"))
680
+
})
681
+
682
+
t.Run("multiple nodes finds correct one", func(t *testing.T) {
683
+
fakeClient := fake.NewSimpleClientset(
684
+
makeNode("controlplane-1", "192.168.1.1", true),
685
+
makeNode("worker-1", "192.168.1.2", true),
686
+
makeNode("worker-2", "192.168.1.3", true),
687
+
)
688
+
c := &Client{k8s: fakeClient}
689
+
assert.Equal(t, "controlplane-1", c.GetK8sNodeName(ctx, "192.168.1.1"))
690
+
assert.Equal(t, "worker-1", c.GetK8sNodeName(ctx, "192.168.1.2"))
691
+
assert.Equal(t, "worker-2", c.GetK8sNodeName(ctx, "192.168.1.3"))
692
+
assert.Equal(t, "", c.GetK8sNodeName(ctx, "192.168.1.99"))
693
+
})
694
+
}
695
+
696
+
// ============================================================================
697
+
// SDK-Dependent Function Tests (using MockTalosMachineClient)
698
+
// ============================================================================
699
+
700
+
func TestClient_GetVersion(t *testing.T) {
701
+
ctx := context.Background()
702
+
703
+
t.Run("success with v prefix", func(t *testing.T) {
704
+
mockTalos := &MockTalosMachineClient{
705
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
706
+
return &machine.VersionResponse{
707
+
Messages: []*machine.Version{
708
+
{Version: &machine.VersionInfo{Tag: "v1.8.0"}},
709
+
},
710
+
}, nil
711
+
},
712
+
}
713
+
c := &Client{talos: mockTalos}
714
+
version, err := c.GetVersion(ctx, "192.168.1.1")
715
+
require.NoError(t, err)
716
+
assert.Equal(t, "1.8.0", version)
717
+
})
718
+
719
+
t.Run("success without v prefix", func(t *testing.T) {
720
+
mockTalos := &MockTalosMachineClient{
721
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
722
+
return &machine.VersionResponse{
723
+
Messages: []*machine.Version{
724
+
{Version: &machine.VersionInfo{Tag: "1.7.5"}},
725
+
},
726
+
}, nil
727
+
},
728
+
}
729
+
c := &Client{talos: mockTalos}
730
+
version, err := c.GetVersion(ctx, "192.168.1.1")
731
+
require.NoError(t, err)
732
+
assert.Equal(t, "1.7.5", version)
733
+
})
734
+
735
+
t.Run("error from SDK", func(t *testing.T) {
736
+
mockTalos := &MockTalosMachineClient{
737
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
738
+
return nil, errors.New("connection refused")
739
+
},
740
+
}
741
+
c := &Client{talos: mockTalos}
742
+
_, err := c.GetVersion(ctx, "192.168.1.1")
743
+
require.Error(t, err)
744
+
assert.Contains(t, err.Error(), "failed to get version")
745
+
})
746
+
747
+
t.Run("empty messages", func(t *testing.T) {
748
+
mockTalos := &MockTalosMachineClient{
749
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
750
+
return &machine.VersionResponse{Messages: []*machine.Version{}}, nil
751
+
},
752
+
}
753
+
c := &Client{talos: mockTalos}
754
+
_, err := c.GetVersion(ctx, "192.168.1.1")
755
+
require.Error(t, err)
756
+
assert.Contains(t, err.Error(), "no version in response")
757
+
})
758
+
759
+
t.Run("nil version in message", func(t *testing.T) {
760
+
mockTalos := &MockTalosMachineClient{
761
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
762
+
return &machine.VersionResponse{
763
+
Messages: []*machine.Version{
764
+
{Version: nil},
765
+
},
766
+
}, nil
767
+
},
768
+
}
769
+
c := &Client{talos: mockTalos}
770
+
_, err := c.GetVersion(ctx, "192.168.1.1")
771
+
require.Error(t, err)
772
+
assert.Contains(t, err.Error(), "no version in response")
773
+
})
774
+
775
+
t.Run("empty tag", func(t *testing.T) {
776
+
mockTalos := &MockTalosMachineClient{
777
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
778
+
return &machine.VersionResponse{
779
+
Messages: []*machine.Version{
780
+
{Version: &machine.VersionInfo{Tag: ""}},
781
+
},
782
+
}, nil
783
+
},
784
+
}
785
+
c := &Client{talos: mockTalos}
786
+
version, err := c.GetVersion(ctx, "192.168.1.1")
787
+
require.NoError(t, err)
788
+
assert.Equal(t, "", version)
789
+
})
790
+
}
791
+
792
+
func TestClient_GetMachineType(t *testing.T) {
793
+
ctx := context.Background()
794
+
795
+
t.Run("returns unknown by design", func(t *testing.T) {
796
+
mockTalos := &MockTalosMachineClient{
797
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
798
+
return &machine.VersionResponse{
799
+
Messages: []*machine.Version{
800
+
{
801
+
Version: &machine.VersionInfo{Tag: "v1.7.0"},
802
+
Platform: &machine.PlatformInfo{Name: "metal"},
803
+
},
804
+
},
805
+
}, nil
806
+
},
807
+
}
808
+
c := &Client{talos: mockTalos}
809
+
machineType, err := c.GetMachineType(ctx, "192.168.1.1")
810
+
require.NoError(t, err)
811
+
assert.Equal(t, "unknown", machineType)
812
+
})
813
+
814
+
t.Run("error from SDK", func(t *testing.T) {
815
+
mockTalos := &MockTalosMachineClient{
816
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
817
+
return nil, errors.New("connection refused")
818
+
},
819
+
}
820
+
c := &Client{talos: mockTalos}
821
+
_, err := c.GetMachineType(ctx, "192.168.1.1")
822
+
require.Error(t, err)
823
+
assert.Contains(t, err.Error(), "failed to get version")
824
+
})
825
+
}
826
+
827
+
func TestClient_IsReachable(t *testing.T) {
828
+
ctx := context.Background()
829
+
830
+
t.Run("reachable node", func(t *testing.T) {
831
+
mockTalos := &MockTalosMachineClient{
832
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
833
+
return &machine.VersionResponse{
834
+
Messages: []*machine.Version{
835
+
{Version: &machine.VersionInfo{Tag: "v1.7.0"}},
836
+
},
837
+
}, nil
838
+
},
839
+
}
840
+
c := &Client{talos: mockTalos}
841
+
assert.True(t, c.IsReachable(ctx, "192.168.1.1"))
842
+
})
843
+
844
+
t.Run("unreachable node", func(t *testing.T) {
845
+
mockTalos := &MockTalosMachineClient{
846
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
847
+
return nil, errors.New("connection refused")
848
+
},
849
+
}
850
+
c := &Client{talos: mockTalos}
851
+
assert.False(t, c.IsReachable(ctx, "192.168.1.1"))
852
+
})
853
+
}
854
+
855
+
func TestClient_Upgrade(t *testing.T) {
856
+
ctx := context.Background()
857
+
858
+
t.Run("success", func(t *testing.T) {
859
+
mockTalos := &MockTalosMachineClient{
860
+
UpgradeWithOptionsFunc: func(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
861
+
return &machine.UpgradeResponse{}, nil
862
+
},
863
+
}
864
+
c := &Client{talos: mockTalos}
865
+
err := c.Upgrade(ctx, "192.168.1.1", "ghcr.io/siderolabs/installer:v1.8.0", true)
866
+
require.NoError(t, err)
867
+
})
868
+
869
+
t.Run("error from SDK", func(t *testing.T) {
870
+
mockTalos := &MockTalosMachineClient{
871
+
UpgradeWithOptionsFunc: func(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
872
+
return nil, errors.New("upgrade in progress")
873
+
},
874
+
}
875
+
c := &Client{talos: mockTalos}
876
+
err := c.Upgrade(ctx, "192.168.1.1", "ghcr.io/siderolabs/installer:v1.8.0", false)
877
+
require.Error(t, err)
878
+
assert.Contains(t, err.Error(), "upgrade failed")
879
+
})
880
+
}
881
+
882
+
func TestClient_Close(t *testing.T) {
883
+
t.Run("nil talos client", func(t *testing.T) {
884
+
c := &Client{talos: nil}
885
+
err := c.Close()
886
+
require.NoError(t, err)
887
+
})
888
+
889
+
t.Run("close success", func(t *testing.T) {
890
+
mockTalos := &MockTalosMachineClient{
891
+
CloseFunc: func() error {
892
+
return nil
893
+
},
894
+
}
895
+
c := &Client{talos: mockTalos}
896
+
err := c.Close()
897
+
require.NoError(t, err)
898
+
})
899
+
900
+
t.Run("close error", func(t *testing.T) {
901
+
mockTalos := &MockTalosMachineClient{
902
+
CloseFunc: func() error {
903
+
return errors.New("close failed")
904
+
},
905
+
}
906
+
c := &Client{talos: mockTalos}
907
+
err := c.Close()
908
+
require.Error(t, err)
909
+
assert.Contains(t, err.Error(), "close failed")
910
+
})
911
+
}
912
+
913
+
func TestClient_GetNodeStatus(t *testing.T) {
914
+
ctx := context.Background()
915
+
916
+
t.Run("reachable node with version", func(t *testing.T) {
917
+
mockTalos := &MockTalosMachineClient{
918
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
919
+
return &machine.VersionResponse{
920
+
Messages: []*machine.Version{
921
+
{Version: &machine.VersionInfo{Tag: "v1.8.0"}},
922
+
},
923
+
}, nil
924
+
},
925
+
}
926
+
c := &Client{talos: mockTalos, k8s: nil}
927
+
status := c.GetNodeStatus(ctx, "192.168.1.1", "amd64-intel", "controlplane", true)
928
+
929
+
assert.Equal(t, "192.168.1.1", status.IP)
930
+
assert.Equal(t, "amd64-intel", status.Profile)
931
+
assert.Equal(t, "controlplane", status.Role)
932
+
assert.Equal(t, "1.8.0", status.Version)
933
+
assert.Equal(t, "unknown", status.MachineType)
934
+
assert.True(t, status.Secureboot)
935
+
assert.True(t, status.Reachable)
936
+
})
937
+
938
+
t.Run("unreachable node", func(t *testing.T) {
939
+
mockTalos := &MockTalosMachineClient{
940
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
941
+
return nil, errors.New("connection refused")
942
+
},
943
+
}
944
+
c := &Client{talos: mockTalos, k8s: nil}
945
+
status := c.GetNodeStatus(ctx, "192.168.1.1", "amd64-intel", "worker", false)
946
+
947
+
assert.Equal(t, "192.168.1.1", status.IP)
948
+
assert.Equal(t, "amd64-intel", status.Profile)
949
+
assert.Equal(t, "worker", status.Role)
950
+
assert.Equal(t, "N/A", status.Version)
951
+
assert.Equal(t, "unknown", status.MachineType)
952
+
assert.False(t, status.Secureboot)
953
+
assert.False(t, status.Reachable)
954
+
})
955
+
}
956
+
957
+
// ============================================================================
958
+
// MockTalosMachineClient Tests
959
+
// ============================================================================
960
+
961
+
func TestMockTalosMachineClient_DefaultBehavior(t *testing.T) {
962
+
mock := &MockTalosMachineClient{}
963
+
ctx := context.Background()
964
+
965
+
t.Run("Close returns nil", func(t *testing.T) {
966
+
err := mock.Close()
967
+
assert.NoError(t, err)
968
+
})
969
+
970
+
t.Run("Version returns default", func(t *testing.T) {
971
+
resp, err := mock.Version(ctx)
972
+
require.NoError(t, err)
973
+
require.NotNil(t, resp)
974
+
require.Len(t, resp.Messages, 1)
975
+
assert.Equal(t, "v1.7.0", resp.Messages[0].Version.Tag)
976
+
})
977
+
978
+
t.Run("UpgradeWithOptions returns empty response", func(t *testing.T) {
979
+
resp, err := mock.UpgradeWithOptions(ctx)
980
+
require.NoError(t, err)
981
+
assert.NotNil(t, resp)
982
+
})
983
+
984
+
t.Run("ServiceInfo returns empty slice", func(t *testing.T) {
985
+
resp, err := mock.ServiceInfo(ctx, "etcd")
986
+
require.NoError(t, err)
987
+
assert.Empty(t, resp)
988
+
})
989
+
990
+
t.Run("EventsWatchV2 returns nil", func(t *testing.T) {
991
+
eventCh := make(chan talosclient.EventResult, 10)
992
+
err := mock.EventsWatchV2(ctx, eventCh)
993
+
assert.NoError(t, err)
994
+
})
995
+
996
+
t.Run("COSIList returns empty list", func(t *testing.T) {
997
+
list, err := mock.COSIList(ctx, resource.NewMetadata("test", "type", "id", resource.VersionUndefined))
998
+
require.NoError(t, err)
999
+
assert.Empty(t, list.Items)
1000
+
})
1001
+
}
1002
+
1003
+
func TestMockTalosMachineClient_ImplementsInterface(t *testing.T) {
1004
+
var client TalosMachineClient = &MockTalosMachineClient{}
1005
+
assert.NotNil(t, client)
1006
+
}
1007
+
1008
+
// ============================================================================
1009
+
// Clock-Based Tests for Timeout Functions
1010
+
// ============================================================================
1011
+
1012
+
func TestClient_WaitForNode(t *testing.T) {
1013
+
ctx := context.Background()
1014
+
1015
+
t.Run("returns immediately when node is reachable", func(t *testing.T) {
1016
+
mockTalos := &MockTalosMachineClient{
1017
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1018
+
return &machine.VersionResponse{
1019
+
Messages: []*machine.Version{
1020
+
{Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1021
+
},
1022
+
}, nil
1023
+
},
1024
+
}
1025
+
mockClock := NewMockClock(time.Now())
1026
+
1027
+
c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1028
+
err := c.WaitForNode(ctx, "192.168.1.1", time.Minute)
1029
+
require.NoError(t, err)
1030
+
})
1031
+
1032
+
t.Run("times out when node is unreachable", func(t *testing.T) {
1033
+
mockTalos := &MockTalosMachineClient{
1034
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1035
+
return nil, errors.New("connection refused")
1036
+
},
1037
+
}
1038
+
startTime := time.Now()
1039
+
mockClock := &MockClock{
1040
+
CurrentTime: startTime,
1041
+
AdvanceOnAfter: true,
1042
+
}
1043
+
// Mock clock that advances time on each Sleep call
1044
+
sleepCount := 0
1045
+
mockClock.SleepFunc = func(d time.Duration) {
1046
+
sleepCount++
1047
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1048
+
}
1049
+
1050
+
c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1051
+
err := c.WaitForNode(ctx, "192.168.1.1", 30*time.Second)
1052
+
require.Error(t, err)
1053
+
assert.Contains(t, err.Error(), "timeout waiting for node")
1054
+
// Verify multiple retries occurred (5 second sleep, 30 second timeout = ~6 retries)
1055
+
assert.GreaterOrEqual(t, sleepCount, 5, "expected at least 5 sleep calls")
1056
+
})
1057
+
1058
+
t.Run("context cancellation", func(t *testing.T) {
1059
+
mockTalos := &MockTalosMachineClient{
1060
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1061
+
return nil, errors.New("connection refused")
1062
+
},
1063
+
}
1064
+
mockClock := NewMockClock(time.Now())
1065
+
1066
+
c := &Client{talos: mockTalos, k8s: nil, clock: mockClock}
1067
+
1068
+
canceledCtx, cancel := context.WithCancel(ctx)
1069
+
cancel()
1070
+
1071
+
err := c.WaitForNode(canceledCtx, "192.168.1.1", time.Minute)
1072
+
require.Error(t, err)
1073
+
assert.Equal(t, context.Canceled, err)
1074
+
})
1075
+
1076
+
t.Run("node reachable but k8s not ready then becomes ready", func(t *testing.T) {
1077
+
mockTalos := &MockTalosMachineClient{
1078
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1079
+
return &machine.VersionResponse{
1080
+
Messages: []*machine.Version{
1081
+
{Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1082
+
},
1083
+
}, nil
1084
+
},
1085
+
}
1086
+
1087
+
// Start with unready node, then make it ready
1088
+
k8sReady := false
1089
+
fakeClient := fake.NewSimpleClientset(makeNode("node1", "192.168.1.1", false))
1090
+
1091
+
mockClock := NewMockClock(time.Now())
1092
+
mockClock.SleepFunc = func(d time.Duration) {
1093
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1094
+
// After first sleep, make k8s node ready
1095
+
if !k8sReady {
1096
+
k8sReady = true
1097
+
// Update the fake client's node to be ready
1098
+
node := makeNode("node1", "192.168.1.1", true)
1099
+
fakeClient.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{})
1100
+
}
1101
+
}
1102
+
1103
+
c := &Client{talos: mockTalos, k8s: fakeClient, clock: mockClock}
1104
+
err := c.WaitForNode(ctx, "192.168.1.1", time.Minute)
1105
+
require.NoError(t, err)
1106
+
})
1107
+
}
1108
+
1109
+
func TestClient_WaitForServices(t *testing.T) {
1110
+
ctx := context.Background()
1111
+
1112
+
t.Run("returns immediately when all services are healthy", func(t *testing.T) {
1113
+
mockTalos := &MockTalosMachineClient{
1114
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1115
+
return []talosclient.ServiceInfo{
1116
+
{
1117
+
Service: &machine.ServiceInfo{
1118
+
Id: service,
1119
+
State: "Running",
1120
+
Health: &machine.ServiceHealth{Healthy: true},
1121
+
},
1122
+
},
1123
+
}, nil
1124
+
},
1125
+
}
1126
+
mockClock := NewMockClock(time.Now())
1127
+
1128
+
c := &Client{talos: mockTalos, clock: mockClock}
1129
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd", "kubelet"}, time.Minute)
1130
+
require.NoError(t, err)
1131
+
})
1132
+
1133
+
t.Run("times out when service is unhealthy", func(t *testing.T) {
1134
+
mockTalos := &MockTalosMachineClient{
1135
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1136
+
return []talosclient.ServiceInfo{
1137
+
{
1138
+
Service: &machine.ServiceInfo{
1139
+
Id: service,
1140
+
State: "Running",
1141
+
Health: &machine.ServiceHealth{Healthy: false},
1142
+
},
1143
+
},
1144
+
}, nil
1145
+
},
1146
+
}
1147
+
startTime := time.Now()
1148
+
mockClock := &MockClock{CurrentTime: startTime}
1149
+
mockClock.SleepFunc = func(d time.Duration) {
1150
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1151
+
}
1152
+
1153
+
c := &Client{talos: mockTalos, clock: mockClock}
1154
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1155
+
require.Error(t, err)
1156
+
assert.Contains(t, err.Error(), "timeout waiting for services")
1157
+
})
1158
+
1159
+
t.Run("times out when service is not running", func(t *testing.T) {
1160
+
mockTalos := &MockTalosMachineClient{
1161
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1162
+
return []talosclient.ServiceInfo{
1163
+
{
1164
+
Service: &machine.ServiceInfo{
1165
+
Id: service,
1166
+
State: "Starting",
1167
+
Health: nil,
1168
+
},
1169
+
},
1170
+
}, nil
1171
+
},
1172
+
}
1173
+
startTime := time.Now()
1174
+
mockClock := &MockClock{CurrentTime: startTime}
1175
+
mockClock.SleepFunc = func(d time.Duration) {
1176
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1177
+
}
1178
+
1179
+
c := &Client{talos: mockTalos, clock: mockClock}
1180
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1181
+
require.Error(t, err)
1182
+
assert.Contains(t, err.Error(), "timeout waiting for services")
1183
+
})
1184
+
1185
+
t.Run("times out when service info errors", func(t *testing.T) {
1186
+
mockTalos := &MockTalosMachineClient{
1187
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1188
+
return nil, errors.New("service not found")
1189
+
},
1190
+
}
1191
+
startTime := time.Now()
1192
+
mockClock := &MockClock{CurrentTime: startTime}
1193
+
mockClock.SleepFunc = func(d time.Duration) {
1194
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1195
+
}
1196
+
1197
+
c := &Client{talos: mockTalos, clock: mockClock}
1198
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, 10*time.Second)
1199
+
require.Error(t, err)
1200
+
assert.Contains(t, err.Error(), "timeout waiting for services")
1201
+
})
1202
+
1203
+
t.Run("context cancellation", func(t *testing.T) {
1204
+
mockTalos := &MockTalosMachineClient{
1205
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1206
+
return nil, errors.New("service not found")
1207
+
},
1208
+
}
1209
+
mockClock := NewMockClock(time.Now())
1210
+
1211
+
c := &Client{talos: mockTalos, clock: mockClock}
1212
+
1213
+
canceledCtx, cancel := context.WithCancel(ctx)
1214
+
cancel()
1215
+
1216
+
err := c.WaitForServices(canceledCtx, "192.168.1.1", []string{"etcd"}, time.Minute)
1217
+
require.Error(t, err)
1218
+
assert.Equal(t, context.Canceled, err)
1219
+
})
1220
+
1221
+
t.Run("service becomes healthy after retry", func(t *testing.T) {
1222
+
attempts := 0
1223
+
mockTalos := &MockTalosMachineClient{
1224
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1225
+
attempts++
1226
+
if attempts < 3 {
1227
+
return []talosclient.ServiceInfo{
1228
+
{
1229
+
Service: &machine.ServiceInfo{
1230
+
Id: service,
1231
+
State: "Starting",
1232
+
Health: nil,
1233
+
},
1234
+
},
1235
+
}, nil
1236
+
}
1237
+
return []talosclient.ServiceInfo{
1238
+
{
1239
+
Service: &machine.ServiceInfo{
1240
+
Id: service,
1241
+
State: "Running",
1242
+
Health: &machine.ServiceHealth{Healthy: true},
1243
+
},
1244
+
},
1245
+
}, nil
1246
+
},
1247
+
}
1248
+
mockClock := NewMockClock(time.Now())
1249
+
mockClock.SleepFunc = func(d time.Duration) {
1250
+
mockClock.CurrentTime = mockClock.CurrentTime.Add(d)
1251
+
}
1252
+
1253
+
c := &Client{talos: mockTalos, clock: mockClock}
1254
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, time.Minute)
1255
+
require.NoError(t, err)
1256
+
assert.Equal(t, 3, attempts)
1257
+
})
1258
+
}
1259
+
1260
+
// ============================================================================
1261
+
// MockClock Tests
1262
+
// ============================================================================
1263
+
1264
+
func TestMockClock(t *testing.T) {
1265
+
t.Run("Now returns CurrentTime", func(t *testing.T) {
1266
+
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1267
+
clock := NewMockClock(now)
1268
+
assert.Equal(t, now, clock.Now())
1269
+
})
1270
+
1271
+
t.Run("Advance moves time forward", func(t *testing.T) {
1272
+
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1273
+
clock := NewMockClock(now)
1274
+
clock.Advance(time.Hour)
1275
+
assert.Equal(t, now.Add(time.Hour), clock.Now())
1276
+
})
1277
+
1278
+
t.Run("Sleep advances time", func(t *testing.T) {
1279
+
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1280
+
clock := NewMockClock(now)
1281
+
clock.Sleep(5 * time.Second)
1282
+
assert.Equal(t, now.Add(5*time.Second), clock.Now())
1283
+
})
1284
+
1285
+
t.Run("After returns immediately with value", func(t *testing.T) {
1286
+
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1287
+
clock := NewMockClock(now)
1288
+
1289
+
ch := clock.After(time.Second)
1290
+
select {
1291
+
case receivedTime := <-ch:
1292
+
assert.Equal(t, now, receivedTime)
1293
+
default:
1294
+
assert.Fail(t, "After should return immediately in mock")
1295
+
}
1296
+
})
1297
+
1298
+
t.Run("AdvanceOnAfter option", func(t *testing.T) {
1299
+
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
1300
+
clock := &MockClock{
1301
+
CurrentTime: now,
1302
+
AdvanceOnAfter: true,
1303
+
}
1304
+
1305
+
clock.After(5 * time.Second)
1306
+
assert.Equal(t, now.Add(5*time.Second), clock.Now())
1307
+
})
1308
+
1309
+
t.Run("After with custom AfterFunc", func(t *testing.T) {
1310
+
customTime := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
1311
+
clock := &MockClock{
1312
+
CurrentTime: time.Now(),
1313
+
AfterFunc: func(d time.Duration) <-chan time.Time {
1314
+
ch := make(chan time.Time, 1)
1315
+
ch <- customTime
1316
+
return ch
1317
+
},
1318
+
}
1319
+
1320
+
ch := clock.After(time.Second)
1321
+
select {
1322
+
case receivedTime := <-ch:
1323
+
assert.Equal(t, customTime, receivedTime)
1324
+
default:
1325
+
assert.Fail(t, "After should return immediately")
1326
+
}
1327
+
})
1328
+
}
1329
+
1330
+
// Test nil clock fallback paths
1331
+
func TestClient_NilClockFallback(t *testing.T) {
1332
+
ctx := context.Background()
1333
+
1334
+
t.Run("WaitForServices with nil clock uses real clock", func(t *testing.T) {
1335
+
// This test verifies the nil clock fallback path
1336
+
// We use a very short timeout and a mock that returns healthy immediately
1337
+
mockTalos := &MockTalosMachineClient{
1338
+
ServiceInfoFunc: func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
1339
+
return []talosclient.ServiceInfo{
1340
+
{
1341
+
Service: &machine.ServiceInfo{
1342
+
Id: service,
1343
+
State: "Running",
1344
+
Health: &machine.ServiceHealth{Healthy: true},
1345
+
},
1346
+
},
1347
+
}, nil
1348
+
},
1349
+
}
1350
+
// Note: clock is nil here - exercises the fallback
1351
+
c := &Client{talos: mockTalos, clock: nil}
1352
+
err := c.WaitForServices(ctx, "192.168.1.1", []string{"etcd"}, time.Second)
1353
+
require.NoError(t, err)
1354
+
})
1355
+
1356
+
t.Run("WaitForNode with nil clock uses real clock", func(t *testing.T) {
1357
+
mockTalos := &MockTalosMachineClient{
1358
+
VersionFunc: func(ctx context.Context) (*machine.VersionResponse, error) {
1359
+
return &machine.VersionResponse{
1360
+
Messages: []*machine.Version{
1361
+
{Version: &machine.VersionInfo{Tag: "v1.7.0"}},
1362
+
},
1363
+
}, nil
1364
+
},
1365
+
}
1366
+
// Note: clock is nil here - exercises the fallback
1367
+
c := &Client{talos: mockTalos, k8s: nil, clock: nil}
1368
+
err := c.WaitForNode(ctx, "192.168.1.1", time.Second)
1369
+
require.NoError(t, err)
1370
+
})
1371
+
}
+95
internal/talos/clock.go
+95
internal/talos/clock.go
···
1
+
package talos
2
+
3
+
import "time"
4
+
5
+
// Clock abstracts time operations for testing.
6
+
// This allows deterministic testing of timeout and polling logic.
7
+
type Clock interface {
8
+
// Now returns the current time
9
+
Now() time.Time
10
+
11
+
// After waits for the duration to elapse and returns a channel
12
+
After(d time.Duration) <-chan time.Time
13
+
14
+
// Sleep pauses the current goroutine for the duration
15
+
Sleep(d time.Duration)
16
+
}
17
+
18
+
// realClock implements Clock using the real time package
19
+
type realClock struct{}
20
+
21
+
// newRealClock creates a new real clock instance
22
+
func newRealClock() Clock {
23
+
return realClock{}
24
+
}
25
+
26
+
func (realClock) Now() time.Time {
27
+
return time.Now()
28
+
}
29
+
30
+
func (realClock) After(d time.Duration) <-chan time.Time {
31
+
return time.After(d)
32
+
}
33
+
34
+
func (realClock) Sleep(d time.Duration) {
35
+
time.Sleep(d)
36
+
}
37
+
38
+
// MockClock is a mock implementation of Clock for testing
39
+
type MockClock struct {
40
+
// CurrentTime is the time returned by Now()
41
+
CurrentTime time.Time
42
+
43
+
// AfterFunc is called when After is invoked
44
+
// If nil, returns a channel that receives immediately
45
+
AfterFunc func(d time.Duration) <-chan time.Time
46
+
47
+
// SleepFunc is called when Sleep is invoked
48
+
// If nil, does nothing (returns immediately)
49
+
SleepFunc func(d time.Duration)
50
+
51
+
// AdvanceOnAfter if true, advances CurrentTime by the duration on After calls
52
+
AdvanceOnAfter bool
53
+
}
54
+
55
+
// NewMockClock creates a mock clock with the given start time
56
+
func NewMockClock(startTime time.Time) *MockClock {
57
+
return &MockClock{CurrentTime: startTime}
58
+
}
59
+
60
+
func (m *MockClock) Now() time.Time {
61
+
return m.CurrentTime
62
+
}
63
+
64
+
func (m *MockClock) After(d time.Duration) <-chan time.Time {
65
+
if m.AfterFunc != nil {
66
+
return m.AfterFunc(d)
67
+
}
68
+
if m.AdvanceOnAfter {
69
+
m.CurrentTime = m.CurrentTime.Add(d)
70
+
}
71
+
// Return immediately for testing
72
+
ch := make(chan time.Time, 1)
73
+
ch <- m.CurrentTime
74
+
return ch
75
+
}
76
+
77
+
func (m *MockClock) Sleep(d time.Duration) {
78
+
if m.SleepFunc != nil {
79
+
m.SleepFunc(d)
80
+
return
81
+
}
82
+
// Advance time but don't actually sleep
83
+
m.CurrentTime = m.CurrentTime.Add(d)
84
+
}
85
+
86
+
// Advance moves the clock forward by the specified duration
87
+
func (m *MockClock) Advance(d time.Duration) {
88
+
m.CurrentTime = m.CurrentTime.Add(d)
89
+
}
90
+
91
+
// Ensure realClock implements Clock
92
+
var _ Clock = realClock{}
93
+
94
+
// Ensure MockClock implements Clock
95
+
var _ Clock = (*MockClock)(nil)
+300
internal/talos/interfaces.go
+300
internal/talos/interfaces.go
···
1
+
package talos
2
+
3
+
import (
4
+
"context"
5
+
"time"
6
+
7
+
"github.com/cosi-project/runtime/pkg/resource"
8
+
"github.com/siderolabs/talos/pkg/machinery/api/machine"
9
+
talosclient "github.com/siderolabs/talos/pkg/machinery/client"
10
+
)
11
+
12
+
// ExtensionInfo represents information about an installed extension
13
+
type ExtensionInfo struct {
14
+
Name string // Extension name (e.g., "gasket-driver")
15
+
Version string // Extension version
16
+
Image string // Full image reference
17
+
}
18
+
19
+
// ClusterMember represents a discovered cluster member
20
+
type ClusterMember struct {
21
+
IP string // Primary IP address
22
+
Hostname string // Node hostname
23
+
Role string // "controlplane" or "worker"
24
+
MachineType string // Raw machine type from Talos
25
+
}
26
+
27
+
// HardwareInfo represents hardware information for profile detection
28
+
type HardwareInfo struct {
29
+
SystemManufacturer string // e.g., "raspberrypi", "turing", "Framework"
30
+
SystemProductName string // e.g., "Raspberry Pi Compute Module 4"
31
+
ProcessorManufacturer string // e.g., "Intel(R) Corporation", "Advanced Micro Devices"
32
+
ProcessorProductName string // e.g., "Intel(R) Core(TM) i9-10850K"
33
+
}
34
+
35
+
// TalosClientInterface defines the interface for interacting with Talos nodes.
36
+
// This interface enables mocking the Talos client for testing.
37
+
type TalosClientInterface interface {
38
+
// Close closes the client connection
39
+
Close() error
40
+
41
+
// GetVersion retrieves the Talos version for a node
42
+
GetVersion(ctx context.Context, nodeIP string) (string, error)
43
+
44
+
// GetMachineType retrieves the machine type for a node
45
+
GetMachineType(ctx context.Context, nodeIP string) (string, error)
46
+
47
+
// GetExtensions retrieves the list of installed extensions for a node
48
+
GetExtensions(ctx context.Context, nodeIP string) ([]ExtensionInfo, error)
49
+
50
+
// IsReachable checks if a node is reachable via the Talos API
51
+
IsReachable(ctx context.Context, nodeIP string) bool
52
+
53
+
// Upgrade performs an upgrade on a node
54
+
Upgrade(ctx context.Context, nodeIP, image string, preserve bool) error
55
+
56
+
// WaitForNode waits for a node to be ready after upgrade
57
+
WaitForNode(ctx context.Context, nodeIP string, timeout time.Duration) error
58
+
59
+
// GetNodeStatus retrieves comprehensive status for a node
60
+
GetNodeStatus(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus
61
+
62
+
// WatchUpgrade streams upgrade events
63
+
WatchUpgrade(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error
64
+
65
+
// WaitForServices waits for critical Talos services to be healthy
66
+
WaitForServices(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error
67
+
68
+
// WaitForStaticPods waits for K8s control plane static pods to be healthy
69
+
WaitForStaticPods(ctx context.Context, nodeIP string, timeout time.Duration) error
70
+
71
+
// GetClusterMembers discovers all nodes in the cluster
72
+
GetClusterMembers(ctx context.Context) ([]ClusterMember, error)
73
+
74
+
// GetHardwareInfo retrieves hardware information for profile detection
75
+
GetHardwareInfo(ctx context.Context, nodeIP string) (*HardwareInfo, error)
76
+
77
+
// GetKernelCmdline retrieves the kernel command line from a node
78
+
GetKernelCmdline(ctx context.Context, nodeIP string) (string, error)
79
+
}
80
+
81
+
// Ensure Client implements TalosClientInterface
82
+
var _ TalosClientInterface = (*Client)(nil)
83
+
84
+
// MockClient is a mock implementation of TalosClientInterface for testing
85
+
type MockClient struct {
86
+
// GetVersionFunc is the mock implementation of GetVersion
87
+
GetVersionFunc func(ctx context.Context, nodeIP string) (string, error)
88
+
89
+
// GetMachineTypeFunc is the mock implementation of GetMachineType
90
+
GetMachineTypeFunc func(ctx context.Context, nodeIP string) (string, error)
91
+
92
+
// GetExtensionsFunc is the mock implementation of GetExtensions
93
+
GetExtensionsFunc func(ctx context.Context, nodeIP string) ([]ExtensionInfo, error)
94
+
95
+
// IsReachableFunc is the mock implementation of IsReachable
96
+
IsReachableFunc func(ctx context.Context, nodeIP string) bool
97
+
98
+
// UpgradeFunc is the mock implementation of Upgrade
99
+
UpgradeFunc func(ctx context.Context, nodeIP, image string, preserve bool) error
100
+
101
+
// WaitForNodeFunc is the mock implementation of WaitForNode
102
+
WaitForNodeFunc func(ctx context.Context, nodeIP string, timeout time.Duration) error
103
+
104
+
// GetNodeStatusFunc is the mock implementation of GetNodeStatus
105
+
GetNodeStatusFunc func(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus
106
+
107
+
// WatchUpgradeFunc is the mock implementation of WatchUpgrade
108
+
WatchUpgradeFunc func(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error
109
+
110
+
// WaitForServicesFunc is the mock implementation of WaitForServices
111
+
WaitForServicesFunc func(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error
112
+
113
+
// WaitForStaticPodsFunc is the mock implementation of WaitForStaticPods
114
+
WaitForStaticPodsFunc func(ctx context.Context, nodeIP string, timeout time.Duration) error
115
+
116
+
// GetClusterMembersFunc is the mock implementation of GetClusterMembers
117
+
GetClusterMembersFunc func(ctx context.Context) ([]ClusterMember, error)
118
+
119
+
// GetHardwareInfoFunc is the mock implementation of GetHardwareInfo
120
+
GetHardwareInfoFunc func(ctx context.Context, nodeIP string) (*HardwareInfo, error)
121
+
122
+
// GetKernelCmdlineFunc is the mock implementation of GetKernelCmdline
123
+
GetKernelCmdlineFunc func(ctx context.Context, nodeIP string) (string, error)
124
+
}
125
+
126
+
func (m *MockClient) Close() error {
127
+
return nil
128
+
}
129
+
130
+
func (m *MockClient) GetVersion(ctx context.Context, nodeIP string) (string, error) {
131
+
if m.GetVersionFunc != nil {
132
+
return m.GetVersionFunc(ctx, nodeIP)
133
+
}
134
+
return "1.7.0", nil
135
+
}
136
+
137
+
func (m *MockClient) GetMachineType(ctx context.Context, nodeIP string) (string, error) {
138
+
if m.GetMachineTypeFunc != nil {
139
+
return m.GetMachineTypeFunc(ctx, nodeIP)
140
+
}
141
+
return "unknown", nil
142
+
}
143
+
144
+
func (m *MockClient) GetExtensions(ctx context.Context, nodeIP string) ([]ExtensionInfo, error) {
145
+
if m.GetExtensionsFunc != nil {
146
+
return m.GetExtensionsFunc(ctx, nodeIP)
147
+
}
148
+
return []ExtensionInfo{}, nil
149
+
}
150
+
151
+
func (m *MockClient) IsReachable(ctx context.Context, nodeIP string) bool {
152
+
if m.IsReachableFunc != nil {
153
+
return m.IsReachableFunc(ctx, nodeIP)
154
+
}
155
+
return true
156
+
}
157
+
158
+
func (m *MockClient) Upgrade(ctx context.Context, nodeIP, image string, preserve bool) error {
159
+
if m.UpgradeFunc != nil {
160
+
return m.UpgradeFunc(ctx, nodeIP, image, preserve)
161
+
}
162
+
return nil
163
+
}
164
+
165
+
func (m *MockClient) WaitForNode(ctx context.Context, nodeIP string, timeout time.Duration) error {
166
+
if m.WaitForNodeFunc != nil {
167
+
return m.WaitForNodeFunc(ctx, nodeIP, timeout)
168
+
}
169
+
return nil
170
+
}
171
+
172
+
func (m *MockClient) GetNodeStatus(ctx context.Context, nodeIP, profile, role string, secureboot bool) NodeStatus {
173
+
if m.GetNodeStatusFunc != nil {
174
+
return m.GetNodeStatusFunc(ctx, nodeIP, profile, role, secureboot)
175
+
}
176
+
return NodeStatus{
177
+
IP: nodeIP,
178
+
Profile: profile,
179
+
Role: role,
180
+
Version: "1.7.0",
181
+
MachineType: "unknown",
182
+
Secureboot: secureboot,
183
+
Reachable: true,
184
+
}
185
+
}
186
+
187
+
func (m *MockClient) WatchUpgrade(ctx context.Context, nodeIP string, timeout time.Duration, onProgress ProgressCallback) error {
188
+
if m.WatchUpgradeFunc != nil {
189
+
return m.WatchUpgradeFunc(ctx, nodeIP, timeout, onProgress)
190
+
}
191
+
return nil
192
+
}
193
+
194
+
func (m *MockClient) WaitForServices(ctx context.Context, nodeIP string, services []string, timeout time.Duration) error {
195
+
if m.WaitForServicesFunc != nil {
196
+
return m.WaitForServicesFunc(ctx, nodeIP, services, timeout)
197
+
}
198
+
return nil
199
+
}
200
+
201
+
func (m *MockClient) WaitForStaticPods(ctx context.Context, nodeIP string, timeout time.Duration) error {
202
+
if m.WaitForStaticPodsFunc != nil {
203
+
return m.WaitForStaticPodsFunc(ctx, nodeIP, timeout)
204
+
}
205
+
return nil
206
+
}
207
+
208
+
func (m *MockClient) GetClusterMembers(ctx context.Context) ([]ClusterMember, error) {
209
+
if m.GetClusterMembersFunc != nil {
210
+
return m.GetClusterMembersFunc(ctx)
211
+
}
212
+
return []ClusterMember{}, nil
213
+
}
214
+
215
+
func (m *MockClient) GetHardwareInfo(ctx context.Context, nodeIP string) (*HardwareInfo, error) {
216
+
if m.GetHardwareInfoFunc != nil {
217
+
return m.GetHardwareInfoFunc(ctx, nodeIP)
218
+
}
219
+
return &HardwareInfo{}, nil
220
+
}
221
+
222
+
func (m *MockClient) GetKernelCmdline(ctx context.Context, nodeIP string) (string, error) {
223
+
if m.GetKernelCmdlineFunc != nil {
224
+
return m.GetKernelCmdlineFunc(ctx, nodeIP)
225
+
}
226
+
return "", nil
227
+
}
228
+
229
+
// MockTalosMachineClient is a mock implementation of TalosMachineClient for testing
230
+
// SDK-dependent functions in the Client struct.
231
+
type MockTalosMachineClient struct {
232
+
// CloseFunc is the mock implementation of Close
233
+
CloseFunc func() error
234
+
235
+
// VersionFunc is the mock implementation of Version
236
+
VersionFunc func(ctx context.Context) (*machine.VersionResponse, error)
237
+
238
+
// UpgradeWithOptionsFunc is the mock implementation of UpgradeWithOptions
239
+
UpgradeWithOptionsFunc func(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error)
240
+
241
+
// ServiceInfoFunc is the mock implementation of ServiceInfo
242
+
ServiceInfoFunc func(ctx context.Context, service string) ([]talosclient.ServiceInfo, error)
243
+
244
+
// EventsWatchV2Func is the mock implementation of EventsWatchV2
245
+
EventsWatchV2Func func(ctx context.Context, eventCh chan<- talosclient.EventResult, opts ...talosclient.EventsOptionFunc) error
246
+
247
+
// COSIListFunc is the mock implementation of COSIList
248
+
COSIListFunc func(ctx context.Context, md resource.Metadata) (resource.List, error)
249
+
}
250
+
251
+
// Ensure MockTalosMachineClient implements TalosMachineClient
252
+
var _ TalosMachineClient = (*MockTalosMachineClient)(nil)
253
+
254
+
func (m *MockTalosMachineClient) Close() error {
255
+
if m.CloseFunc != nil {
256
+
return m.CloseFunc()
257
+
}
258
+
return nil
259
+
}
260
+
261
+
func (m *MockTalosMachineClient) Version(ctx context.Context) (*machine.VersionResponse, error) {
262
+
if m.VersionFunc != nil {
263
+
return m.VersionFunc(ctx)
264
+
}
265
+
return &machine.VersionResponse{
266
+
Messages: []*machine.Version{
267
+
{
268
+
Version: &machine.VersionInfo{Tag: "v1.7.0"},
269
+
},
270
+
},
271
+
}, nil
272
+
}
273
+
274
+
func (m *MockTalosMachineClient) UpgradeWithOptions(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
275
+
if m.UpgradeWithOptionsFunc != nil {
276
+
return m.UpgradeWithOptionsFunc(ctx, opts...)
277
+
}
278
+
return &machine.UpgradeResponse{}, nil
279
+
}
280
+
281
+
func (m *MockTalosMachineClient) ServiceInfo(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
282
+
if m.ServiceInfoFunc != nil {
283
+
return m.ServiceInfoFunc(ctx, service)
284
+
}
285
+
return []talosclient.ServiceInfo{}, nil
286
+
}
287
+
288
+
func (m *MockTalosMachineClient) EventsWatchV2(ctx context.Context, eventCh chan<- talosclient.EventResult, opts ...talosclient.EventsOptionFunc) error {
289
+
if m.EventsWatchV2Func != nil {
290
+
return m.EventsWatchV2Func(ctx, eventCh, opts...)
291
+
}
292
+
return nil
293
+
}
294
+
295
+
func (m *MockTalosMachineClient) COSIList(ctx context.Context, md resource.Metadata) (resource.List, error) {
296
+
if m.COSIListFunc != nil {
297
+
return m.COSIListFunc(ctx, md)
298
+
}
299
+
return resource.List{}, nil
300
+
}
+74
internal/talos/talos_client.go
+74
internal/talos/talos_client.go
···
1
+
package talos
2
+
3
+
import (
4
+
"context"
5
+
6
+
"github.com/cosi-project/runtime/pkg/resource"
7
+
"github.com/cosi-project/runtime/pkg/state"
8
+
"github.com/siderolabs/talos/pkg/machinery/api/machine"
9
+
talosclient "github.com/siderolabs/talos/pkg/machinery/client"
10
+
)
11
+
12
+
// TalosMachineClient abstracts Talos SDK operations for testing.
13
+
// This interface allows mocking the Talos SDK client in unit tests.
14
+
type TalosMachineClient interface {
15
+
// Close closes the client connection
16
+
Close() error
17
+
18
+
// Version retrieves the Talos version
19
+
Version(ctx context.Context) (*machine.VersionResponse, error)
20
+
21
+
// UpgradeWithOptions performs an upgrade with the given options
22
+
UpgradeWithOptions(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error)
23
+
24
+
// ServiceInfo retrieves information about a specific service
25
+
ServiceInfo(ctx context.Context, service string) ([]talosclient.ServiceInfo, error)
26
+
27
+
// EventsWatchV2 watches for machine events
28
+
EventsWatchV2(ctx context.Context, eventCh chan<- talosclient.EventResult, opts ...talosclient.EventsOptionFunc) error
29
+
30
+
// COSIList lists COSI resources
31
+
COSIList(ctx context.Context, md resource.Metadata) (resource.List, error)
32
+
}
33
+
34
+
// talosClientWrapper wraps the real SDK client to implement TalosMachineClient
35
+
type talosClientWrapper struct {
36
+
client *talosclient.Client
37
+
}
38
+
39
+
// newTalosClientWrapper creates a wrapper around the SDK client
40
+
func newTalosClientWrapper(client *talosclient.Client) *talosClientWrapper {
41
+
return &talosClientWrapper{client: client}
42
+
}
43
+
44
+
func (w *talosClientWrapper) Close() error {
45
+
return w.client.Close()
46
+
}
47
+
48
+
func (w *talosClientWrapper) Version(ctx context.Context) (*machine.VersionResponse, error) {
49
+
return w.client.Version(ctx)
50
+
}
51
+
52
+
func (w *talosClientWrapper) UpgradeWithOptions(ctx context.Context, opts ...talosclient.UpgradeOption) (*machine.UpgradeResponse, error) {
53
+
return w.client.UpgradeWithOptions(ctx, opts...)
54
+
}
55
+
56
+
func (w *talosClientWrapper) ServiceInfo(ctx context.Context, service string) ([]talosclient.ServiceInfo, error) {
57
+
return w.client.ServiceInfo(ctx, service)
58
+
}
59
+
60
+
func (w *talosClientWrapper) EventsWatchV2(ctx context.Context, eventCh chan<- talosclient.EventResult, opts ...talosclient.EventsOptionFunc) error {
61
+
return w.client.EventsWatchV2(ctx, eventCh, opts...)
62
+
}
63
+
64
+
func (w *talosClientWrapper) COSIList(ctx context.Context, md resource.Metadata) (resource.List, error) {
65
+
return w.client.COSI.List(ctx, md)
66
+
}
67
+
68
+
// Ensure talosClientWrapper implements TalosMachineClient
69
+
var _ TalosMachineClient = (*talosClientWrapper)(nil)
70
+
71
+
// COSIState wraps the COSI state client for advanced operations
72
+
type COSIState interface {
73
+
state.CoreState
74
+
}