Skip to content

Commit df3c6fb

Browse files
Add machine type in zone validator (GoogleCloudPlatform#5099)
1 parent 5857355 commit df3c6fb

7 files changed

Lines changed: 421 additions & 3 deletions

File tree

docs/blueprint-validation.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ Each validator is described below:
5959
region
6060
* Common failure: changing 1 value but not the other
6161
* Manual test: `gcloud compute regions describe us-central1 --format="text(zones)" --project $(vars.project_id)`
62+
* `test_machine_type_in_zone`
63+
* Inputs: `project_id` (string), `zone` (string), `machine_type` (string)
64+
* PASS: If the machine type is available in the specified zone and project.
65+
* SKIP (Soft Warning): If the Compute Engine API is disabled or the credentials lack `compute.machineTypes.get` permissions, the validator prints a warning and the check is skipped.
66+
* FAIL: If the machine type is invalid or unavailable in that zone.
67+
* Note: To explicitly verify multiple machine types in a zone, add this validator to the blueprint multiple times.
68+
* Manual test: `gcloud compute machine-types describe $(vars.machine_type) --zone $(vars.zone) --project $(vars.project_id)`
6269
* `test_module_not_used`
6370
* Inputs: none; reads whole blueprint
6471
* PASS: if all instances of use keyword pass matching variables
@@ -100,6 +107,11 @@ validators:
100107
project_id: $(vars.project_id)
101108
region: $(vars.region)
102109
zone: $(vars.zone)
110+
- validator: test_machine_type_in_zone
111+
inputs:
112+
project_id: $(vars.project_id)
113+
zone: $(vars.zone)
114+
machine_type: c2-standard-60 # any machine type to verify in the zone
103115
```
104116
105117
## Module-level (Metadata) Validators

pkg/config/errors.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,6 @@ const (
167167
errMsgValueEmptyString = string("value is an empty string")
168168
errMsgLabelValueReqs = string("value can only contain lowercase letters, numeric characters, underscores and dashes, and must be between 0 and 63 characters long")
169169
errMsgSlurmClusterNameReqs = string("must start with a lowercase letter, contain only lowercase letters and numbers, and be between 1 and 10 characters long")
170+
ErrMsgResourceInZone = "%s %q is not available in zone %q in project %q"
171+
ErrMsgResourceInAnyZone = "%s %q is not available in any requested zones [%s] in project %q"
170172
)

pkg/validators/cloud.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ func handleServiceUsageError(err error, pid string) error {
7171
return fmt.Errorf("unhandled error: %s", herr)
7272
}
7373

74+
func isValidatorExplicit(bp config.Blueprint, validatorName string) bool {
75+
for _, v := range bp.Validators {
76+
if v.Validator == validatorName {
77+
return true
78+
}
79+
}
80+
return false
81+
}
82+
7483
// TestApisEnabled tests whether APIs are enabled in given project
7584
func TestApisEnabled(projectID string, requiredAPIs []string) error {
7685
// can return immediately if there are 0 APIs to test
@@ -240,3 +249,45 @@ func testZoneInRegion(bp config.Blueprint, inputs config.Dict) error {
240249
}
241250
return TestZoneInRegion(m["project_id"], m["zone"], m["region"])
242251
}
252+
253+
func testMachineTypeInZoneAvailability(bp config.Blueprint, inputs config.Dict) error {
254+
// 1. Determine if the validator was explicitly added to the blueprint YAML
255+
const validatorName = "test_machine_type_in_zone"
256+
required := []string{"project_id", "zone"}
257+
if isValidatorExplicit(bp, validatorName) {
258+
required = append(required, "machine_type")
259+
}
260+
261+
if err := checkInputs(inputs, required); err != nil {
262+
return err
263+
}
264+
265+
s, err := compute.NewService(context.Background())
266+
if err != nil {
267+
return handleClientError(err)
268+
}
269+
m, err := inputsAsStrings(inputs)
270+
if err != nil {
271+
return err
272+
}
273+
274+
projectID, globalZone, explicitMachineType := m["project_id"], m["zone"], m["machine_type"]
275+
276+
if explicitMachineType != "" {
277+
// When explicitly called, we MUST validate the zone provided in the inputs
278+
if err := TestZoneExists(projectID, globalZone); err != nil {
279+
return err
280+
}
281+
err := validateMachineTypeInZone(s, projectID, globalZone, explicitMachineType, validatorName)
282+
283+
// Catch the sentinel and return nil so the deployment proceeds
284+
if errors.Is(err, errSoftWarning) {
285+
return nil
286+
}
287+
return err
288+
}
289+
290+
return validateSettingsInModules(bp, globalZone, projectID, "machine_type", "machine type", validatorName, func(z, name string, vName string) error {
291+
return validateMachineTypeInZone(s, projectID, z, name, vName)
292+
})
293+
}

pkg/validators/cloud_discovery.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright 2026 "Google LLC"
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package validators
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"hpc-toolkit/pkg/config"
21+
"strings"
22+
23+
"github.com/zclconf/go-cty/cty"
24+
compute "google.golang.org/api/compute/v1"
25+
"google.golang.org/api/googleapi"
26+
)
27+
28+
// errSoftWarning is a private sentinel error used to signal that a soft warning
29+
// was triggered and discovery should stop immediately to avoid console spam.
30+
var errSoftWarning = errors.New("abort")
31+
32+
// getSoftWarningMessage checks if a Google Cloud API error represents a permission issue (403)
33+
// or a disabled API (400). When these occur, it prints a warning to the console
34+
// and returns true, signaling the validator to "skip" the check rather than failing the deployment.
35+
func getSoftWarningMessage(err error, validatorName, projectID, apiName, permission string) (string, bool) {
36+
var gerr *googleapi.Error
37+
if errors.As(err, &gerr) {
38+
// 403 is always a Soft Warning (Permission)
39+
is403 := gerr.Code == 403
40+
41+
// 400 is ONLY a Soft Warning if it's about the API not being enabled/used.
42+
// If it's an "Invalid Value" (like your custom machine error), it should be a Hard Failure.
43+
isAPIOff := gerr.Code == 400 && (strings.Contains(strings.ToLower(gerr.Message), "not enabled") ||
44+
strings.Contains(strings.ToLower(gerr.Message), "not been used"))
45+
46+
if is403 || isAPIOff {
47+
msg := fmt.Sprintf("\n[!] WARNING (%d): validator %q for project %q. Identity lacks permissions. Skipping check.\n", gerr.Code, validatorName, projectID)
48+
msg += fmt.Sprintf(" Hint: Ensure %s is enabled and check IAM permissions (%s).\n", apiName, permission)
49+
return msg, true
50+
}
51+
}
52+
return "", false
53+
}
54+
55+
// 1. Helper for cty resolution
56+
func resolveStringSetting(bp config.Blueprint, val cty.Value) string {
57+
v := val
58+
if resolved, err := bp.Eval(v); err == nil {
59+
v = resolved
60+
}
61+
if v != cty.NilVal && !v.IsNull() && v.Type() == cty.String {
62+
return v.AsString()
63+
}
64+
return ""
65+
}
66+
67+
// extractZones converts a cty.Value (String, List, Set, or Tuple) into a string slice.
68+
// This helper removes complex type-checking branches from the main resolution logic.
69+
func extractZones(val cty.Value) ([]string, error) {
70+
if val.IsNull() || !val.IsKnown() {
71+
return nil, nil
72+
}
73+
74+
var zones []string
75+
// evaluateAndFlatten handles single strings, lists, and tuples for us
76+
for _, v := range evaluateAndFlatten(val) {
77+
if v.Type() == cty.String {
78+
zones = append(zones, v.AsString())
79+
} else {
80+
// Reuse toolkit standard error generation for non-string types
81+
_, err := inputsAsStrings(config.NewDict(map[string]cty.Value{"zone": v}))
82+
return nil, err
83+
}
84+
}
85+
return zones, nil
86+
}
87+
88+
// resolveZones identifies all target zones for a module by scanning its settings.
89+
// It implements a priority system to ensure that specific module placements
90+
// (like 'gpu_zones' or 'zones') take precedence over the inherited singular 'zone' variable.
91+
func resolveZones(blueprint config.Blueprint, module *config.Module, globalZone string) ([]string, error) {
92+
plural, singular := make(map[string]bool), make(map[string]bool)
93+
94+
for key, val := range module.Settings.Items() {
95+
// Identify settings ending in 'zone' or 'zones'.
96+
// strings.HasSuffix also matches the words "zone" and "zones" themselves.
97+
isPlural := strings.HasSuffix(key, "zones")
98+
if !isPlural && !strings.HasSuffix(key, "zone") {
99+
continue
100+
}
101+
102+
resolved, err := blueprint.Eval(val)
103+
if err != nil {
104+
continue
105+
}
106+
107+
// Normalize the value (handling single strings vs. lists) via extractZones
108+
// and categorize the result based on the key suffix.
109+
zones, err := extractZones(resolved)
110+
if err != nil {
111+
return nil, err // Return type error immediately
112+
}
113+
114+
for _, z := range zones {
115+
if isPlural {
116+
plural[z] = true
117+
} else {
118+
singular[z] = true
119+
}
120+
}
121+
}
122+
123+
// Priority: 1. Plural zone list, 2. Singular zone override, 3. Global default
124+
source := plural
125+
if len(plural) == 0 {
126+
source = singular
127+
}
128+
if len(source) == 0 {
129+
return []string{globalZone}, nil
130+
}
131+
132+
zones := make([]string, 0, len(source))
133+
for z := range source {
134+
zones = append(zones, z)
135+
}
136+
return zones, nil
137+
}
138+
139+
// checkResourceInZones implements the "OR" logic: passes if found in at least one valid zone.
140+
func checkResourceInZones(projectID string, zones []string, globalZone, resourceLabel, resourceName string, validatorName string, validateFn func(string, string, string) error) (bool, error) {
141+
var attempted []string
142+
for _, z := range zones {
143+
if z == "" {
144+
continue
145+
}
146+
147+
if z != globalZone {
148+
if err := TestZoneExists(projectID, z); err != nil {
149+
// Check if the zone-check error is actually a permission issue (403)
150+
if msg, isSoft := getSoftWarningMessage(err, validatorName, projectID, "Compute Engine API", "compute.zones.get"); isSoft {
151+
fmt.Println(msg)
152+
return true, errSoftWarning // Trigger the abort sentinel
153+
}
154+
// If it's a real typo (not a 403), return it as a Hard Failure
155+
return false, err
156+
}
157+
}
158+
159+
attempted = append(attempted, z)
160+
err := validateFn(z, resourceName, validatorName)
161+
if err == nil {
162+
return true, nil
163+
}
164+
if errors.Is(err, errSoftWarning) {
165+
return true, errSoftWarning
166+
}
167+
}
168+
169+
if len(attempted) > 0 {
170+
return false, fmt.Errorf(config.ErrMsgResourceInAnyZone, resourceLabel, resourceName, strings.Join(attempted, ", "), projectID)
171+
}
172+
return true, nil
173+
}
174+
175+
// validateSettingsInModules walks through every module in the blueprint,
176+
// identifies settings that match a specific suffix (e.g., "machine_type"),
177+
// and validates them against the zones where that module is allowed to reside.
178+
func validateSettingsInModules(blueprint config.Blueprint, globalZone, projectID, suffix, resourceLabel string, validatorName string, validateResource func(zone string, name string, vName string) error) error {
179+
validationErrors := config.Errors{}
180+
// Anti-Spam Logic: This flag is set if we encounter an environmental issue
181+
// (like a 403 Permission Denied). It allows us to stop making slow API calls
182+
// and stop printing repetitive warnings for the rest of the blueprint walk.
183+
var aborted bool
184+
185+
blueprint.WalkModulesSafe(func(path config.ModulePath, module *config.Module) {
186+
if aborted {
187+
return
188+
}
189+
190+
// Identify which zones this module is targeting.
191+
// This handles singular overrides, plural lists, and global defaults.
192+
// Handle the new error return from resolveZones
193+
targetZones, err := resolveZones(blueprint, module, globalZone)
194+
if err != nil {
195+
validationErrors.Add(fmt.Errorf("in module %q: %w", module.ID, err))
196+
return // Skip discovery for this module due to type error
197+
}
198+
for key, val := range module.Settings.Items() {
199+
if aborted || !strings.HasSuffix(key, suffix) {
200+
continue
201+
}
202+
203+
resourceName := resolveStringSetting(blueprint, val)
204+
if resourceName == "" {
205+
continue
206+
}
207+
208+
found, err := checkResourceInZones(projectID, targetZones, globalZone, resourceLabel, resourceName, validatorName, validateResource)
209+
// If we hit the private sentinel error (403/400), set the abort flag.
210+
if errors.Is(err, errSoftWarning) {
211+
aborted = true
212+
return
213+
}
214+
if !found && err != nil {
215+
validationErrors.Add(fmt.Errorf("in module %q setting %q: %w", module.ID, key, err))
216+
}
217+
}
218+
})
219+
return validationErrors.OrNil()
220+
}
221+
222+
// validateMachineTypeInZone calls the Compute Engine API to verify if a specific
223+
// machine type is available in the given zone and project.
224+
func validateMachineTypeInZone(s *compute.Service, projectID, zone, machineType string, validatorName string) error {
225+
_, err := s.MachineTypes.Get(projectID, zone, machineType).Do()
226+
227+
// Case 1: Success - The machine type exists
228+
if err == nil {
229+
return nil
230+
}
231+
232+
// Case 2: Environmental Issue - API disabled or permissions missing (Soft Warning)
233+
if msg, isSoft := getSoftWarningMessage(err, validatorName, projectID, "Compute Engine API", "compute.machineTypes.get"); isSoft {
234+
fmt.Println(msg)
235+
return errSoftWarning
236+
}
237+
238+
// Case 3: Validation Failure - The machine type is genuinely invalid or unavailable
239+
return fmt.Errorf(config.ErrMsgResourceInZone, "machine type", machineType, zone, projectID)
240+
}

pkg/validators/validators.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const (
5555
testZoneInRegionName = "test_zone_in_region"
5656
testModuleNotUsedName = "test_module_not_used"
5757
testDeploymentVariableNotUsedName = "test_deployment_variable_not_used"
58+
testMachineTypeInZone = "test_machine_type_in_zone"
5859
)
5960

6061
func implementations() map[string]func(config.Blueprint, config.Dict) error {
@@ -66,6 +67,7 @@ func implementations() map[string]func(config.Blueprint, config.Dict) error {
6667
testZoneInRegionName: testZoneInRegion,
6768
testModuleNotUsedName: testModuleNotUsed,
6869
testDeploymentVariableNotUsedName: testDeploymentVariableNotUsed,
70+
testMachineTypeInZone: testMachineTypeInZoneAvailability,
6971
}
7072
}
7173

@@ -237,6 +239,12 @@ func defaults(bp config.Blueprint) []config.Validator {
237239
"project_id": projectRef,
238240
"zone": zoneRef,
239241
}),
242+
}, config.Validator{
243+
Validator: testMachineTypeInZone,
244+
Inputs: config.NewDict(map[string]cty.Value{
245+
"project_id": projectRef,
246+
"zone": zoneRef,
247+
}),
240248
})
241249
}
242250

0 commit comments

Comments
 (0)