+1
internal/cmd/status.go
+1
internal/cmd/status.go
+1
internal/cmd/upgrade.go
+1
internal/cmd/upgrade.go
+1
internal/cmd/upgrade_node.go
+1
internal/cmd/upgrade_node.go
+18
-2
internal/config/config.go
+18
-2
internal/config/config.go
···
116
116
if _, ok := c.Profiles[rule.Profile]; !ok {
117
117
return fmt.Errorf("detection rule %d: references unknown profile %s", i, rule.Profile)
118
118
}
119
-
if rule.Match.SystemManufacturer == "" && rule.Match.ProcessorManufacturer == "" {
120
-
return fmt.Errorf("detection rule %d: at least one match criterion required", i)
119
+
120
+
// Must have either match or match_either
121
+
if rule.Match == nil && len(rule.MatchEither) == 0 {
122
+
return fmt.Errorf("detection rule %d: must have match or match_either", i)
123
+
}
124
+
125
+
// Validate match criteria
126
+
if rule.Match != nil {
127
+
if rule.Match.SystemManufacturer == "" && rule.Match.ProcessorManufacturer == "" && rule.Match.Arch == "" {
128
+
return fmt.Errorf("detection rule %d: at least one match criterion required", i)
129
+
}
130
+
}
131
+
132
+
// Validate match_either criteria
133
+
for j, m := range rule.MatchEither {
134
+
if m.SystemManufacturer == "" && m.ProcessorManufacturer == "" && m.Arch == "" {
135
+
return fmt.Errorf("detection rule %d, match_either %d: at least one match criterion required", i, j)
136
+
}
121
137
}
122
138
}
123
139
}
+287
-12
internal/config/config_test.go
+287
-12
internal/config/config_test.go
···
683
683
Rules: []DetectionRule{
684
684
{
685
685
Profile: "test",
686
-
Match: DetectionMatch{SystemManufacturer: "Dell"},
686
+
Match: &DetectionMatch{SystemManufacturer: "Dell"},
687
687
},
688
688
},
689
689
},
···
700
700
Rules: []DetectionRule{
701
701
{
702
702
Profile: "",
703
-
Match: DetectionMatch{SystemManufacturer: "Dell"},
703
+
Match: &DetectionMatch{SystemManufacturer: "Dell"},
704
704
},
705
705
},
706
706
},
···
719
719
Rules: []DetectionRule{
720
720
{
721
721
Profile: "nonexistent",
722
-
Match: DetectionMatch{SystemManufacturer: "Dell"},
722
+
Match: &DetectionMatch{SystemManufacturer: "Dell"},
723
723
},
724
724
},
725
725
},
···
738
738
Rules: []DetectionRule{
739
739
{
740
740
Profile: "test",
741
-
Match: DetectionMatch{},
741
+
Match: &DetectionMatch{},
742
742
},
743
743
},
744
744
},
···
757
757
cfg := &Config{
758
758
Detection: &Detection{
759
759
Rules: []DetectionRule{
760
-
{Profile: "test", Match: DetectionMatch{SystemManufacturer: "Dell"}},
760
+
{Profile: "test", Match: &DetectionMatch{SystemManufacturer: "Dell"}},
761
761
},
762
762
},
763
763
}
···
794
794
Rules: []DetectionRule{
795
795
{
796
796
Profile: "intel-profile",
797
-
Match: DetectionMatch{ProcessorManufacturer: "Intel"},
797
+
Match: &DetectionMatch{ProcessorManufacturer: "Intel"},
798
798
},
799
799
{
800
800
Profile: "amd-profile",
801
-
Match: DetectionMatch{ProcessorManufacturer: "AMD"},
801
+
Match: &DetectionMatch{ProcessorManufacturer: "AMD"},
802
802
},
803
803
{
804
804
Profile: "dell-profile",
805
-
Match: DetectionMatch{SystemManufacturer: "Dell"},
805
+
Match: &DetectionMatch{SystemManufacturer: "Dell"},
806
806
},
807
807
},
808
808
},
···
866
866
},
867
867
Detection: &Detection{
868
868
Rules: []DetectionRule{
869
-
{Profile: "first", Match: DetectionMatch{SystemManufacturer: "Dell"}},
870
-
{Profile: "second", Match: DetectionMatch{SystemManufacturer: "Dell"}},
869
+
{Profile: "first", Match: &DetectionMatch{SystemManufacturer: "Dell"}},
870
+
{Profile: "second", Match: &DetectionMatch{SystemManufacturer: "Dell"}},
871
871
},
872
872
},
873
873
}
···
883
883
},
884
884
Detection: &Detection{
885
885
Rules: []DetectionRule{
886
-
{Profile: "missing", Match: DetectionMatch{SystemManufacturer: "Dell"}},
886
+
{Profile: "missing", Match: &DetectionMatch{SystemManufacturer: "Dell"}},
887
887
},
888
888
},
889
889
}
···
903
903
Rules: []DetectionRule{
904
904
{
905
905
Profile: "dell-intel",
906
-
Match: DetectionMatch{
906
+
Match: &DetectionMatch{
907
907
SystemManufacturer: "Dell",
908
908
ProcessorManufacturer: "Intel",
909
909
},
···
942
942
assert.Nil(t, profile)
943
943
})
944
944
}
945
+
946
+
// ============================================================================
947
+
// Arch Matching Tests
948
+
// ============================================================================
949
+
950
+
func TestDetectProfile_ArchMatch(t *testing.T) {
951
+
cfg := &Config{
952
+
Profiles: map[string]Profile{
953
+
"amd64-profile": {Arch: "amd64", Platform: "metal"},
954
+
"arm64-profile": {Arch: "arm64", Platform: "metal"},
955
+
},
956
+
Detection: &Detection{
957
+
Rules: []DetectionRule{
958
+
{
959
+
Profile: "amd64-profile",
960
+
Match: &DetectionMatch{Arch: "amd64"},
961
+
},
962
+
{
963
+
Profile: "arm64-profile",
964
+
Match: &DetectionMatch{Arch: "arm64"},
965
+
},
966
+
},
967
+
},
968
+
}
969
+
970
+
t.Run("match amd64 arch", func(t *testing.T) {
971
+
hw := &HardwareInfo{Arch: "amd64"}
972
+
name, profile := cfg.DetectProfile(hw)
973
+
assert.Equal(t, "amd64-profile", name)
974
+
require.NotNil(t, profile)
975
+
})
976
+
977
+
t.Run("match arm64 arch", func(t *testing.T) {
978
+
hw := &HardwareInfo{Arch: "arm64"}
979
+
name, profile := cfg.DetectProfile(hw)
980
+
assert.Equal(t, "arm64-profile", name)
981
+
require.NotNil(t, profile)
982
+
})
983
+
984
+
t.Run("arch case insensitive", func(t *testing.T) {
985
+
hw := &HardwareInfo{Arch: "AMD64"}
986
+
name, profile := cfg.DetectProfile(hw)
987
+
assert.Equal(t, "amd64-profile", name)
988
+
require.NotNil(t, profile)
989
+
})
990
+
991
+
t.Run("no arch match", func(t *testing.T) {
992
+
hw := &HardwareInfo{Arch: "riscv64"}
993
+
name, profile := cfg.DetectProfile(hw)
994
+
assert.Empty(t, name)
995
+
assert.Nil(t, profile)
996
+
})
997
+
}
998
+
999
+
func TestDetectProfile_ArchAndManufacturer(t *testing.T) {
1000
+
cfg := &Config{
1001
+
Profiles: map[string]Profile{
1002
+
"arm64-rpi": {Arch: "arm64", Platform: "metal"},
1003
+
},
1004
+
Detection: &Detection{
1005
+
Rules: []DetectionRule{
1006
+
{
1007
+
Profile: "arm64-rpi",
1008
+
Match: &DetectionMatch{
1009
+
Arch: "arm64",
1010
+
SystemManufacturer: "raspberrypi",
1011
+
},
1012
+
},
1013
+
},
1014
+
},
1015
+
}
1016
+
1017
+
t.Run("both arch and manufacturer match", func(t *testing.T) {
1018
+
hw := &HardwareInfo{
1019
+
Arch: "arm64",
1020
+
SystemManufacturer: "raspberrypi",
1021
+
}
1022
+
name, profile := cfg.DetectProfile(hw)
1023
+
assert.Equal(t, "arm64-rpi", name)
1024
+
require.NotNil(t, profile)
1025
+
})
1026
+
1027
+
t.Run("arch matches but manufacturer doesn't", func(t *testing.T) {
1028
+
hw := &HardwareInfo{
1029
+
Arch: "arm64",
1030
+
SystemManufacturer: "turing",
1031
+
}
1032
+
name, profile := cfg.DetectProfile(hw)
1033
+
assert.Empty(t, name)
1034
+
assert.Nil(t, profile)
1035
+
})
1036
+
1037
+
t.Run("manufacturer matches but arch doesn't", func(t *testing.T) {
1038
+
hw := &HardwareInfo{
1039
+
Arch: "amd64",
1040
+
SystemManufacturer: "raspberrypi",
1041
+
}
1042
+
name, profile := cfg.DetectProfile(hw)
1043
+
assert.Empty(t, name)
1044
+
assert.Nil(t, profile)
1045
+
})
1046
+
}
1047
+
1048
+
// ============================================================================
1049
+
// MatchEither (OR Logic) Tests
1050
+
// ============================================================================
1051
+
1052
+
func TestDetectProfile_MatchEither(t *testing.T) {
1053
+
cfg := &Config{
1054
+
Profiles: map[string]Profile{
1055
+
"amd64-profile": {Arch: "amd64", Platform: "metal"},
1056
+
},
1057
+
Detection: &Detection{
1058
+
Rules: []DetectionRule{
1059
+
{
1060
+
Profile: "amd64-profile",
1061
+
MatchEither: []DetectionMatch{
1062
+
{ProcessorManufacturer: "Intel"},
1063
+
{ProcessorManufacturer: "Advanced Micro Devices"},
1064
+
},
1065
+
},
1066
+
},
1067
+
},
1068
+
}
1069
+
1070
+
t.Run("matches first option (Intel)", func(t *testing.T) {
1071
+
hw := &HardwareInfo{ProcessorManufacturer: "Intel Corporation"}
1072
+
name, profile := cfg.DetectProfile(hw)
1073
+
assert.Equal(t, "amd64-profile", name)
1074
+
require.NotNil(t, profile)
1075
+
})
1076
+
1077
+
t.Run("matches second option (AMD)", func(t *testing.T) {
1078
+
hw := &HardwareInfo{ProcessorManufacturer: "Advanced Micro Devices, Inc."}
1079
+
name, profile := cfg.DetectProfile(hw)
1080
+
assert.Equal(t, "amd64-profile", name)
1081
+
require.NotNil(t, profile)
1082
+
})
1083
+
1084
+
t.Run("no match for ARM", func(t *testing.T) {
1085
+
hw := &HardwareInfo{ProcessorManufacturer: "ARM Holdings"}
1086
+
name, profile := cfg.DetectProfile(hw)
1087
+
assert.Empty(t, name)
1088
+
assert.Nil(t, profile)
1089
+
})
1090
+
}
1091
+
1092
+
func TestDetectProfile_MatchEitherWithArch(t *testing.T) {
1093
+
cfg := &Config{
1094
+
Profiles: map[string]Profile{
1095
+
"arm64-sbc": {Arch: "arm64", Platform: "metal"},
1096
+
},
1097
+
Detection: &Detection{
1098
+
Rules: []DetectionRule{
1099
+
{
1100
+
Profile: "arm64-sbc",
1101
+
MatchEither: []DetectionMatch{
1102
+
{Arch: "arm64", SystemManufacturer: "raspberrypi"},
1103
+
{Arch: "arm64", SystemManufacturer: "turing"},
1104
+
},
1105
+
},
1106
+
},
1107
+
},
1108
+
}
1109
+
1110
+
t.Run("matches raspberry pi", func(t *testing.T) {
1111
+
hw := &HardwareInfo{
1112
+
Arch: "arm64",
1113
+
SystemManufacturer: "raspberrypi",
1114
+
}
1115
+
name, profile := cfg.DetectProfile(hw)
1116
+
assert.Equal(t, "arm64-sbc", name)
1117
+
require.NotNil(t, profile)
1118
+
})
1119
+
1120
+
t.Run("matches turing", func(t *testing.T) {
1121
+
hw := &HardwareInfo{
1122
+
Arch: "arm64",
1123
+
SystemManufacturer: "turing",
1124
+
}
1125
+
name, profile := cfg.DetectProfile(hw)
1126
+
assert.Equal(t, "arm64-sbc", name)
1127
+
require.NotNil(t, profile)
1128
+
})
1129
+
1130
+
t.Run("wrong arch even if manufacturer matches", func(t *testing.T) {
1131
+
hw := &HardwareInfo{
1132
+
Arch: "amd64",
1133
+
SystemManufacturer: "raspberrypi",
1134
+
}
1135
+
name, profile := cfg.DetectProfile(hw)
1136
+
assert.Empty(t, name)
1137
+
assert.Nil(t, profile)
1138
+
})
1139
+
}
1140
+
1141
+
func TestValidate_MatchEither(t *testing.T) {
1142
+
t.Run("valid match_either", func(t *testing.T) {
1143
+
cfg := &Config{
1144
+
Profiles: map[string]Profile{
1145
+
"test": {Arch: "amd64", Platform: "metal"},
1146
+
},
1147
+
Detection: &Detection{
1148
+
Rules: []DetectionRule{
1149
+
{
1150
+
Profile: "test",
1151
+
MatchEither: []DetectionMatch{
1152
+
{ProcessorManufacturer: "Intel"},
1153
+
{ProcessorManufacturer: "AMD"},
1154
+
},
1155
+
},
1156
+
},
1157
+
},
1158
+
}
1159
+
assert.NoError(t, cfg.Validate())
1160
+
})
1161
+
1162
+
t.Run("empty match_either item fails", func(t *testing.T) {
1163
+
cfg := &Config{
1164
+
Profiles: map[string]Profile{
1165
+
"test": {Arch: "amd64", Platform: "metal"},
1166
+
},
1167
+
Detection: &Detection{
1168
+
Rules: []DetectionRule{
1169
+
{
1170
+
Profile: "test",
1171
+
MatchEither: []DetectionMatch{
1172
+
{ProcessorManufacturer: "Intel"},
1173
+
{}, // Empty match
1174
+
},
1175
+
},
1176
+
},
1177
+
},
1178
+
}
1179
+
err := cfg.Validate()
1180
+
assert.Error(t, err)
1181
+
assert.Contains(t, err.Error(), "match_either 1: at least one match criterion required")
1182
+
})
1183
+
1184
+
t.Run("no match or match_either fails", func(t *testing.T) {
1185
+
cfg := &Config{
1186
+
Profiles: map[string]Profile{
1187
+
"test": {Arch: "amd64", Platform: "metal"},
1188
+
},
1189
+
Detection: &Detection{
1190
+
Rules: []DetectionRule{
1191
+
{
1192
+
Profile: "test",
1193
+
// Neither Match nor MatchEither set
1194
+
},
1195
+
},
1196
+
},
1197
+
}
1198
+
err := cfg.Validate()
1199
+
assert.Error(t, err)
1200
+
assert.Contains(t, err.Error(), "must have match or match_either")
1201
+
})
1202
+
}
1203
+
1204
+
func TestValidate_ArchOnly(t *testing.T) {
1205
+
cfg := &Config{
1206
+
Profiles: map[string]Profile{
1207
+
"test": {Arch: "amd64", Platform: "metal"},
1208
+
},
1209
+
Detection: &Detection{
1210
+
Rules: []DetectionRule{
1211
+
{
1212
+
Profile: "test",
1213
+
Match: &DetectionMatch{Arch: "amd64"},
1214
+
},
1215
+
},
1216
+
},
1217
+
}
1218
+
assert.NoError(t, cfg.Validate())
1219
+
}
+31
-4
internal/config/types.go
+31
-4
internal/config/types.go
···
49
49
50
50
// DetectionRule maps hardware characteristics to a profile
51
51
type DetectionRule struct {
52
-
Profile string `yaml:"profile"`
53
-
Match DetectionMatch `yaml:"match"`
52
+
Profile string `yaml:"profile"`
53
+
Match *DetectionMatch `yaml:"match,omitempty"` // AND logic: all conditions must match
54
+
MatchEither []DetectionMatch `yaml:"match_either,omitempty"` // OR logic: any condition can match
54
55
}
55
56
56
57
// DetectionMatch defines the hardware characteristics to match
57
58
type DetectionMatch struct {
58
59
SystemManufacturer string `yaml:"system_manufacturer,omitempty"`
59
60
ProcessorManufacturer string `yaml:"processor_manufacturer,omitempty"`
61
+
Arch string `yaml:"arch,omitempty"` // e.g., "amd64", "arm64"
60
62
}
61
63
62
64
// HardwareInfo represents detected hardware information
···
65
67
SystemProductName string
66
68
ProcessorManufacturer string
67
69
ProcessorProductName string
70
+
Arch string // e.g., "amd64", "arm64"
68
71
}
69
72
70
73
// DetectProfile finds the matching profile for given hardware info
···
74
77
}
75
78
76
79
for _, rule := range c.Detection.Rules {
77
-
if matchesRule(hw, &rule.Match) {
80
+
matched := false
81
+
82
+
// Check Match (AND logic - all conditions must match)
83
+
if rule.Match != nil && matchesRule(hw, rule.Match) {
84
+
matched = true
85
+
}
86
+
87
+
// Check MatchEither (OR logic - any condition can match)
88
+
if !matched && len(rule.MatchEither) > 0 {
89
+
for i := range rule.MatchEither {
90
+
if matchesRule(hw, &rule.MatchEither[i]) {
91
+
matched = true
92
+
break
93
+
}
94
+
}
95
+
}
96
+
97
+
if matched {
78
98
if profile, ok := c.Profiles[rule.Profile]; ok {
79
99
return rule.Profile, &profile
80
100
}
···
85
105
86
106
// matchesRule checks if hardware info matches a detection rule
87
107
func matchesRule(hw *HardwareInfo, match *DetectionMatch) bool {
108
+
// Check arch (case-insensitive exact match)
109
+
if match.Arch != "" {
110
+
if !strings.EqualFold(hw.Arch, match.Arch) {
111
+
return false
112
+
}
113
+
}
114
+
88
115
// Check system manufacturer (case-insensitive contains match)
89
116
if match.SystemManufacturer != "" {
90
117
if !strings.Contains(strings.ToLower(hw.SystemManufacturer), strings.ToLower(match.SystemManufacturer)) {
···
100
127
}
101
128
102
129
// At least one match criterion must be specified
103
-
if match.SystemManufacturer == "" && match.ProcessorManufacturer == "" {
130
+
if match.SystemManufacturer == "" && match.ProcessorManufacturer == "" && match.Arch == "" {
104
131
return false
105
132
}
106
133
+11
internal/talos/client.go
+11
internal/talos/client.go
···
739
739
nodeCtx := talosclient.WithNode(ctx, nodeIP)
740
740
info := &HardwareInfo{}
741
741
742
+
// Get architecture from Version API
743
+
versionResp, err := c.talos.Version(nodeCtx)
744
+
if err == nil {
745
+
for _, msg := range versionResp.Messages {
746
+
if msg.Version != nil && msg.Version.Arch != "" {
747
+
info.Arch = msg.Version.Arch
748
+
break
749
+
}
750
+
}
751
+
}
752
+
742
753
// Query SystemInformation resource
743
754
sysInfoList, err := c.talos.COSIList(nodeCtx, resource.NewMetadata(hardware.NamespaceName, hardware.SystemInformationType, "", resource.VersionUndefined))
744
755
if err != nil {
+1
internal/talos/interfaces.go
+1
internal/talos/interfaces.go
···
30
30
SystemProductName string // e.g., "Raspberry Pi Compute Module 4"
31
31
ProcessorManufacturer string // e.g., "Intel(R) Corporation", "Advanced Micro Devices"
32
32
ProcessorProductName string // e.g., "Intel(R) Core(TM) i9-10850K"
33
+
Arch string // e.g., "amd64", "arm64"
33
34
}
34
35
35
36
// TalosClientInterface defines the interface for interacting with Talos nodes.