Skip to content

Commit 25e3e86

Browse files
authored
Merge pull request #1279 from LeanerCloud/refactor/az-cli-to-sdk
refactor(configure): replace az/gcloud CLI shell-outs with native SDK calls
2 parents 473f69b + 0fe3107 commit 25e3e86

9 files changed

Lines changed: 2030 additions & 220 deletions

File tree

‎ci_cd_sanity_tests/pkg/sanity/azure/azure.go‎

Lines changed: 231 additions & 40 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"`
@@ -30,11 +47,11 @@ type azAccountShow struct {
3047
} `json:"user"`
3148
}
3249

33-
func truncate(s string, limit int) string {
34-
if len(s) <= limit {
50+
func truncate(s string, maxLen int) string {
51+
if len(s) <= maxLen {
3552
return s
3653
}
37-
return s[:limit] + "...(truncated)"
54+
return s[:maxLen] + "...(truncated)"
3855
}
3956

4057
// validateAccountExpectations parses "az account show" JSON output and checks
@@ -80,6 +97,203 @@ func validateAccountExpectations(opts Options, accountOut []byte) report.CheckRe
8097
return check
8198
}
8299

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

104-
runCmd := func(name string, args ...string) ([]byte, report.CheckResult) {
105-
start := time.Now().UTC()
106-
cmd := exec.CommandContext(rctx, "az", args...) // #nosec G702,G204 -- CI sanity test tooling; binary is hardcoded "az" (Azure CLI). Args are Azure CLI subcommands constructed in test code plus opts.SubscriptionID from config/CLI, which exec.CommandContext passes as a single argv value (no shell interpretation), so it cannot inject commands
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
318+
cred, err := azidentity.NewDefaultAzureCredential(nil)
319+
if err != nil {
320+
rep.EndedAt = time.Now().UTC()
321+
return nil, fmt.Errorf("azure: failed to build DefaultAzureCredential: %w", err)
126322
}
127323

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

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)
326+
accountShowResult, accountOut := runAccountShowCheck(rctx, opts.SubscriptionID, cred)
327+
rep.Add(accountShowResult)
135328

136-
if opts.ExpectedSubID != "" || opts.ExpectedTenantID != "" {
329+
// --- azure:account:expected_checks ---
330+
if (opts.ExpectedSubID != "" || opts.ExpectedTenantID != "") && len(accountOut) > 0 {
137331
rep.Add(validateAccountExpectations(opts, accountOut))
138332
}
139333

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)
334+
// --- azure:group:list(sample) ---
335+
rep.Add(runGroupListCheck(rctx, opts.SubscriptionID, cred))
144336

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)
337+
// --- azure:vm:list(sample) ---
338+
rep.Add(runVMListCheck(rctx, opts.SubscriptionID, cred))
148339

149340
rep.EndedAt = time.Now().UTC()
150341
return rep, nil

0 commit comments

Comments
 (0)