@@ -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.
2239type 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.
83297func 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