Skip to content

Commit 2653895

Browse files
committed
refactor(configure): replace az/gcloud CLI shell-outs with native SDK calls
Replace exec.Command shell-outs to cloud CLIs with native SDK calls where the replacement is sound and does not require new heavyweight dependencies: Azure (ci_cd_sanity_tests/): - az account set/show -> armsubscriptions.Client.Get (programmatic CI path) - az group list -> armresources.ResourceGroupsClient.NewListPager - az vm list -> armcompute.VirtualMachinesClient.NewListAllPager Azure (cmd/configure_azure.go): - az account list -> armsubscriptions.Client.NewListPager (with CLI fallback) GCP (cmd/configure_gcp.go): - gcloud projects list -> cloudresourcemanager/v1 Projects.List - gcloud iam service-accounts create -> iam/v1 Projects.ServiceAccounts.Create - gcloud projects add-iam-policy-binding -> cloudresourcemanager/v1 SetIamPolicy - gcloud iam service-accounts keys create -> iam/v1 SA Keys.Create (writes key data to file; key is base64-decoded from PrivateKeyData response field) Calls left as CLI (documented in each function): - az login / gcloud auth login: interactive browser OAuth; no SDK equivalent for an operator establishing an initial credential. - gcloud config set project: writes to local gcloud config file; no API equivalent. - az ad sp create-for-rbac: would require msgraph-sdk-go + armauthorization/v2, neither of which is in the module graph; the interactive wizard path does not justify adding two new heavy SDKs. Auth model: DefaultAzureCredential (azure.go / configure_azure.go) picks up the az login session via its AzureCLICredential leg. google.DefaultTokenSource (configure_gcp.go) picks up gcloud application-default credentials; the wizard now notes that operators must also run 'gcloud auth application-default login' in addition to 'gcloud auth login' for the SDK steps to work. go.mod: armcompute/v5 and armsubscriptions promoted from indirect to direct; armresources v1.2.0 promoted from transitive to direct. No new SDK versions or packages outside the existing module graph were added (google.golang.org/api iam/v1 and cloudresourcemanager/v1 are sub-packages of the already-required google.golang.org/api v0.274.0). Removes the gosec G204 subprocess findings from ci_cd_sanity_tests/azure.go (the only nolint-free exec.Command site in the CI path). The remaining exec.Command calls in configure_azure.go and configure_gcp.go are in interactive wizard paths where the CLI is intentionally required; those are addressed separately.
1 parent 451a70f commit 2653895

6 files changed

Lines changed: 711 additions & 122 deletions

File tree

ci_cd_sanity_tests/pkg/sanity/azure/azure.go

Lines changed: 219 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8-
"os/exec"
98
"strings"
109
"time"
1110

11+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
12+
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
13+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5"
14+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
15+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
1216
"github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/report"
1317
)
1418

@@ -19,6 +23,19 @@ type Options struct {
1923
Timeout time.Duration
2024
}
2125

26+
// azureSubscriptionInfo holds the subscription/tenant fields extracted from the
27+
// armsubscriptions API response. This mirrors the fields previously parsed from
28+
// "az account show -o json" so that validateAccountExpectations is unchanged.
29+
type azureSubscriptionInfo struct {
30+
ID string
31+
TenantID string
32+
Name string
33+
State string
34+
}
35+
36+
// azAccountShow is the JSON shape produced by "az account show -o json". It is
37+
// retained only to support the existing validateAccountExpectations function
38+
// which the unit tests exercise via its JSON parsing path.
2239
type azAccountShow struct {
2340
ID string `json:"id"`
2441
TenantID string `json:"tenantId"`
@@ -80,6 +97,194 @@ func validateAccountExpectations(opts Options, accountOut []byte) report.CheckRe
8097
return check
8198
}
8299

100+
// encodeAccountJSON serialises azureSubscriptionInfo into the same JSON shape
101+
// that "az account show -o json" produced so that validateAccountExpectations
102+
// can be reused without modification.
103+
func encodeAccountJSON(info azureSubscriptionInfo) []byte {
104+
a := azAccountShow{
105+
ID: info.ID,
106+
TenantID: info.TenantID,
107+
Name: info.Name,
108+
State: info.State,
109+
}
110+
b, _ := json.Marshal(a)
111+
return b
112+
}
113+
114+
// newCheckResult returns a CheckResult with name and timing already set.
115+
func newCheckResult(name string, start time.Time) report.CheckResult {
116+
return report.CheckResult{
117+
Name: name,
118+
StartedAt: start,
119+
Details: map[string]string{},
120+
}
121+
}
122+
123+
// checkPass records a passing check with an optional detail message and
124+
// returns it ready to be added to the report.
125+
func checkPass(cr *report.CheckResult, detail string) report.CheckResult {
126+
cr.EndedAt = time.Now().UTC()
127+
cr.Status = report.StatusPass
128+
if detail != "" {
129+
cr.Details["result"] = detail
130+
}
131+
return *cr
132+
}
133+
134+
// checkFail records a failing check and returns it.
135+
func checkFail(cr *report.CheckResult, msg string) report.CheckResult {
136+
cr.EndedAt = time.Now().UTC()
137+
cr.Status = report.StatusFail
138+
cr.Message = msg
139+
return *cr
140+
}
141+
142+
// runGroupListCheck lists up to 10 resource groups in the subscription.
143+
func runGroupListCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
144+
cr := newCheckResult("azure:group:list(sample)", time.Now().UTC())
145+
cr.Details["subscriptionID"] = subscriptionID
146+
147+
rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil)
148+
if err != nil {
149+
return checkFail(&cr, fmt.Sprintf("failed to create resource-groups client: %v", err))
150+
}
151+
152+
pager := rgClient.NewListPager(nil)
153+
var names []string
154+
for pager.More() && len(names) < 10 {
155+
page, pageErr := pager.NextPage(ctx)
156+
if pageErr != nil {
157+
return checkFail(&cr, pageErr.Error())
158+
}
159+
for _, rg := range page.Value {
160+
if rg.Name != nil && rg.Location != nil {
161+
names = append(names, fmt.Sprintf("%s (%s)", *rg.Name, *rg.Location))
162+
}
163+
if len(names) >= 10 {
164+
break
165+
}
166+
}
167+
}
168+
cr.Details["result"] = truncate(strings.Join(names, ", "), 2048)
169+
return checkPass(&cr, "")
170+
}
171+
172+
// resourceGroupFromID extracts the resource group name from an Azure resource ID.
173+
// The ID format is: .../resourceGroups/<name>/...
174+
func resourceGroupFromID(id string) string {
175+
parts := strings.Split(id, "/")
176+
for i, p := range parts {
177+
if strings.EqualFold(p, "resourceGroups") && i+1 < len(parts) {
178+
return parts[i+1]
179+
}
180+
}
181+
return ""
182+
}
183+
184+
// vmSummary returns a short display string for a virtual machine.
185+
func vmSummary(vm *armcompute.VirtualMachine) string {
186+
name := ""
187+
rg := ""
188+
loc := ""
189+
if vm.Name != nil {
190+
name = *vm.Name
191+
}
192+
if vm.Location != nil {
193+
loc = *vm.Location
194+
}
195+
if vm.ID != nil {
196+
rg = resourceGroupFromID(*vm.ID)
197+
}
198+
return fmt.Sprintf("%s (rg:%s loc:%s)", name, rg, loc)
199+
}
200+
201+
// runVMListCheck lists up to 10 virtual machines in the subscription.
202+
func runVMListCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
203+
cr := newCheckResult("azure:vm:list(sample)", time.Now().UTC())
204+
cr.Details["subscriptionID"] = subscriptionID
205+
206+
vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil)
207+
if err != nil {
208+
return checkFail(&cr, fmt.Sprintf("failed to create virtual-machines client: %v", err))
209+
}
210+
211+
pager := vmClient.NewListAllPager(nil)
212+
var items []string
213+
for pager.More() && len(items) < 10 {
214+
page, pageErr := pager.NextPage(ctx)
215+
if pageErr != nil {
216+
return checkFail(&cr, pageErr.Error())
217+
}
218+
for _, vm := range page.Value {
219+
items = append(items, vmSummary(vm))
220+
if len(items) >= 10 {
221+
break
222+
}
223+
}
224+
}
225+
cr.Details["result"] = truncate(strings.Join(items, ", "), 2048)
226+
return checkPass(&cr, "")
227+
}
228+
229+
// runAccountSetCheck verifies that the given subscription ID is reachable.
230+
func runAccountSetCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
231+
cr := newCheckResult("azure:account:set", time.Now().UTC())
232+
cr.Details["subscriptionID"] = subscriptionID
233+
234+
subClient, err := armsubscriptions.NewClient(cred, nil)
235+
if err != nil {
236+
return checkFail(&cr, fmt.Sprintf("failed to create subscriptions client: %v", err))
237+
}
238+
239+
if _, err := subClient.Get(ctx, subscriptionID, nil); err != nil {
240+
return checkFail(&cr, err.Error())
241+
}
242+
return checkPass(&cr, "subscription reachable")
243+
}
244+
245+
// runAccountShowCheck retrieves subscription identity information.
246+
// It returns the check result and the JSON-encoded account info (for use by
247+
// validateAccountExpectations). The JSON is empty on failure.
248+
func runAccountShowCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) (report.CheckResult, []byte) {
249+
cr := newCheckResult("azure:account:show", time.Now().UTC())
250+
cr.Details["subscriptionID"] = subscriptionID
251+
252+
subClient, err := armsubscriptions.NewClient(cred, nil)
253+
if err != nil {
254+
return checkFail(&cr, fmt.Sprintf("failed to create subscriptions client: %v", err)), nil
255+
}
256+
257+
resp, err := subClient.Get(ctx, subscriptionID, nil)
258+
if err != nil {
259+
return checkFail(&cr, err.Error()), nil
260+
}
261+
262+
sub := resp.Subscription
263+
info := azureSubscriptionInfo{State: string(*sub.State)}
264+
if sub.SubscriptionID != nil {
265+
info.ID = *sub.SubscriptionID
266+
}
267+
if sub.TenantID != nil {
268+
info.TenantID = *sub.TenantID
269+
}
270+
if sub.DisplayName != nil {
271+
info.Name = *sub.DisplayName
272+
}
273+
274+
cr.Details["id"] = info.ID
275+
cr.Details["tenantId"] = info.TenantID
276+
cr.Details["name"] = info.Name
277+
cr.Details["state"] = info.State
278+
return checkPass(&cr, "account info retrieved"), encodeAccountJSON(info)
279+
}
280+
281+
// Run performs read-only Azure sanity checks using native SDK calls.
282+
//
283+
// Auth: DefaultAzureCredential is used throughout. In CI this resolves via the
284+
// AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET environment
285+
// variables (service-principal flow). On an operator workstation it falls back
286+
// to AzureCLICredential (i.e. the session established by "az login"), so the
287+
// behaviour is identical to the previous CLI-based implementation.
83288
func Run(ctx context.Context, opts Options) (*report.Report, error) {
84289
if opts.SubscriptionID == "" {
85290
opts.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID")
@@ -101,50 +306,27 @@ func Run(ctx context.Context, opts Options) (*report.Report, error) {
101306
StartedAt: time.Now().UTC(),
102307
}
103308

104-
runCmd := func(name string, args ...string) ([]byte, report.CheckResult) {
105-
start := time.Now().UTC()
106-
cmd := exec.CommandContext(rctx, "az", args...)
107-
out, err := cmd.CombinedOutput()
108-
end := time.Now().UTC()
109-
110-
cr := report.CheckResult{
111-
Name: name,
112-
StartedAt: start,
113-
EndedAt: end,
114-
Details: map[string]string{
115-
"cmd": "az " + strings.Join(args, " "),
116-
"output": truncate(string(out), 2048),
117-
},
118-
}
119-
if err != nil {
120-
cr.Status = report.StatusFail
121-
cr.Message = err.Error()
122-
} else {
123-
cr.Status = report.StatusPass
124-
}
125-
return out, cr
309+
cred, err := azidentity.NewDefaultAzureCredential(nil)
310+
if err != nil {
311+
rep.EndedAt = time.Now().UTC()
312+
return nil, fmt.Errorf("azure: failed to build DefaultAzureCredential: %w", err)
126313
}
127314

128-
// Ensure subscription context (read-only)
129-
_, cr := runCmd("azure:account:set", "account", "set", "--subscription", opts.SubscriptionID)
130-
rep.Add(cr)
315+
rep.Add(runAccountSetCheck(rctx, opts.SubscriptionID, cred))
131316

132-
// Read-only identity/subscription info (only call once; reuse output)
133-
accountOut, cr := runCmd("azure:account:show", "account", "show", "-o", "json")
134-
rep.Add(cr)
317+
accountShowResult, accountOut := runAccountShowCheck(rctx, opts.SubscriptionID, cred)
318+
rep.Add(accountShowResult)
135319

136-
if opts.ExpectedSubID != "" || opts.ExpectedTenantID != "" {
320+
// --- azure:account:expected_checks ---
321+
if (opts.ExpectedSubID != "" || opts.ExpectedTenantID != "") && len(accountOut) > 0 {
137322
rep.Add(validateAccountExpectations(opts, accountOut))
138323
}
139324

140-
// Read-only lists (sample)
141-
_, cr = runCmd("azure:group:list(sample)", "group", "list",
142-
"--query", "[0:10].{name:name, location:location}", "-o", "json")
143-
rep.Add(cr)
325+
// --- azure:group:list(sample) ---
326+
rep.Add(runGroupListCheck(rctx, opts.SubscriptionID, cred))
144327

145-
_, cr = runCmd("azure:vm:list(sample)", "vm", "list",
146-
"--query", "[0:10].{name:name, resourceGroup:resourceGroup, location:location}", "-o", "json")
147-
rep.Add(cr)
328+
// --- azure:vm:list(sample) ---
329+
rep.Add(runVMListCheck(rctx, opts.SubscriptionID, cred))
148330

149331
rep.EndedAt = time.Now().UTC()
150332
return rep, nil

ci_cd_sanity_tests/pkg/sanity/azure/azure_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,45 @@ import (
99
"github.com/stretchr/testify/require"
1010
)
1111

12+
// TestEncodeAccountJSON verifies that encodeAccountJSON produces bytes that are
13+
// accepted by validateAccountExpectations without error, and that the ID /
14+
// TenantID fields survive the round-trip. This guards the SDK->JSON->validate
15+
// path introduced when replacing the "az account show" CLI call.
16+
func TestEncodeAccountJSON(t *testing.T) {
17+
info := azureSubscriptionInfo{
18+
ID: "aaaabbbb-1111-2222-3333-ccccddddeeee",
19+
TenantID: "ffffgggg-5555-6666-7777-hhhh88889999",
20+
Name: "My Test Sub",
21+
State: "Enabled",
22+
}
23+
24+
encoded := encodeAccountJSON(info)
25+
require.NotEmpty(t, encoded, "encoded JSON must not be empty")
26+
27+
// Must parse back as azAccountShow without error.
28+
var parsed azAccountShow
29+
require.NoError(t, json.Unmarshal(encoded, &parsed))
30+
assert.Equal(t, info.ID, parsed.ID)
31+
assert.Equal(t, info.TenantID, parsed.TenantID)
32+
assert.Equal(t, info.Name, parsed.Name)
33+
assert.Equal(t, info.State, parsed.State)
34+
35+
// Round-trip through validateAccountExpectations with matching expectations.
36+
opts := Options{
37+
ExpectedSubID: info.ID,
38+
ExpectedTenantID: info.TenantID,
39+
}
40+
result := validateAccountExpectations(opts, encoded)
41+
assert.Equal(t, report.StatusPass, result.Status, "expected PASS for matching IDs, got: %s", result.Message)
42+
}
43+
44+
// TestTruncate verifies truncate boundary conditions.
45+
func TestTruncate(t *testing.T) {
46+
assert.Equal(t, "ab", truncate("ab", 5))
47+
assert.Equal(t, "abcde", truncate("abcde", 5))
48+
assert.Equal(t, "abcde...(truncated)", truncate("abcdef", 5))
49+
}
50+
1251
func accountJSON(id, tenantID, name, state string) []byte {
1352
b, err := json.Marshal(azAccountShow{
1453
ID: id,

0 commit comments

Comments
 (0)