Utility tool for upgrading talos nodes.

initial commit

+1
.gitignore
··· 1 + bin/
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + profiles: 2 + basic: 3 + arch: amd64 4 + platform: metal 5 + secureboot: false 6 + extensions: [] 7 + 8 + nodes: 9 + - ip: 192.168.1.1 10 + profile: basic 11 + role: master
+10
internal/config/testdata/invalid-detection-empty-profile.yaml
··· 1 + profiles: 2 + test: 3 + arch: amd64 4 + platform: metal 5 + 6 + detection: 7 + rules: 8 + - profile: "" 9 + match: 10 + system_manufacturer: Test
+9
internal/config/testdata/invalid-detection-no-match.yaml
··· 1 + profiles: 2 + test: 3 + arch: amd64 4 + platform: metal 5 + 6 + detection: 7 + rules: 8 + - profile: test 9 + match: {}
+10
internal/config/testdata/invalid-detection-unknown-profile.yaml
··· 1 + profiles: 2 + test: 3 + arch: amd64 4 + platform: metal 5 + 6 + detection: 7 + rules: 8 + - profile: nonexistent 9 + match: 10 + system_manufacturer: Test
+10
internal/config/testdata/invalid-missing-arch.yaml
··· 1 + profiles: 2 + basic: 3 + platform: metal 4 + secureboot: false 5 + extensions: [] 6 + 7 + nodes: 8 + - ip: 192.168.1.1 9 + profile: basic 10 + role: controlplane
+8
internal/config/testdata/invalid-no-nodes.yaml
··· 1 + profiles: 2 + basic: 3 + arch: amd64 4 + platform: metal 5 + secureboot: false 6 + extensions: [] 7 + 8 + nodes: []
+6
internal/config/testdata/invalid-no-profiles.yaml
··· 1 + profiles: {} 2 + 3 + nodes: 4 + - ip: 192.168.1.1 5 + profile: basic 6 + role: controlplane
+11
internal/config/testdata/invalid-unknown-profile.yaml
··· 1 + profiles: 2 + basic: 3 + arch: amd64 4 + platform: metal 5 + secureboot: false 6 + extensions: [] 7 + 8 + nodes: 9 + - ip: 192.168.1.1 10 + profile: nonexistent 11 + role: controlplane
+4
internal/config/testdata/malformed.yaml
··· 1 + profiles: 2 + basic 3 + arch: amd64 4 + invalid yaml here
+11
internal/config/testdata/minimal-config.yaml
··· 1 + profiles: 2 + basic: 3 + arch: amd64 4 + platform: metal 5 + secureboot: false 6 + extensions: [] 7 + 8 + nodes: 9 + - ip: 192.168.1.1 10 + profile: basic 11 + role: controlplane
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }