Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 231 additions & 40 deletions ci_cd_sanity_tests/pkg/sanity/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/report"
)

Expand All @@ -19,6 +23,19 @@ type Options struct {
Timeout time.Duration
}

// azureSubscriptionInfo holds the subscription/tenant fields extracted from the
// armsubscriptions API response. This mirrors the fields previously parsed from
// "az account show -o json" so that validateAccountExpectations is unchanged.
type azureSubscriptionInfo struct {
ID string
TenantID string
Name string
State string
}

// azAccountShow is the JSON shape produced by "az account show -o json". It is
// retained only to support the existing validateAccountExpectations function
// which the unit tests exercise via its JSON parsing path.
type azAccountShow struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Expand All @@ -30,11 +47,11 @@ type azAccountShow struct {
} `json:"user"`
}

func truncate(s string, limit int) string {
if len(s) <= limit {
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:limit] + "...(truncated)"
return s[:maxLen] + "...(truncated)"
}

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

// encodeAccountJSON serializes azureSubscriptionInfo into the same JSON shape
// that "az account show -o json" produced so that validateAccountExpectations
// can be reused without modification. The struct is composed of plain strings
// so json.Marshal cannot realistically fail; a nil return on the impossible
// error path lets the caller skip the expected-checks step rather than feed
// validateAccountExpectations a partially-encoded payload.
func encodeAccountJSON(info azureSubscriptionInfo) []byte {
a := azAccountShow{
ID: info.ID,
TenantID: info.TenantID,
Name: info.Name,
State: info.State,
}
b, err := json.Marshal(a)
if err != nil {
return nil
}
return b
}

// newCheckResult returns a CheckResult with name and timing already set.
func newCheckResult(name string, start time.Time) report.CheckResult {
return report.CheckResult{
Name: name,
StartedAt: start,
Details: map[string]string{},
}
}

// checkPass records a passing check with an optional detail message and
// returns it ready to be added to the report.
func checkPass(cr *report.CheckResult, detail string) report.CheckResult {
cr.EndedAt = time.Now().UTC()
cr.Status = report.StatusPass
if detail != "" {
cr.Details["result"] = detail
}
return *cr
}

// checkFail records a failing check and returns it.
func checkFail(cr *report.CheckResult, msg string) report.CheckResult {
cr.EndedAt = time.Now().UTC()
cr.Status = report.StatusFail
cr.Message = msg
return *cr
}

// runGroupListCheck lists up to 10 resource groups in the subscription.
func runGroupListCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
cr := newCheckResult("azure:group:list(sample)", time.Now().UTC())
cr.Details["subscriptionID"] = subscriptionID

rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil)
if err != nil {
return checkFail(&cr, fmt.Sprintf("failed to create resource-groups client: %v", err))
}

pager := rgClient.NewListPager(nil)
var names []string
for pager.More() && len(names) < 10 {
page, pageErr := pager.NextPage(ctx)
if pageErr != nil {
return checkFail(&cr, pageErr.Error())
}
for _, rg := range page.Value {
if rg.Name != nil && rg.Location != nil {
names = append(names, fmt.Sprintf("%s (%s)", *rg.Name, *rg.Location))
}
if len(names) >= 10 {
break
}
}
}
cr.Details["result"] = truncate(strings.Join(names, ", "), 2048)
return checkPass(&cr, "")
}

// resourceGroupFromID extracts the resource group name from an Azure resource ID.
// The ID format is: .../resourceGroups/<name>/...
func resourceGroupFromID(id string) string {
parts := strings.Split(id, "/")
for i, p := range parts {
if strings.EqualFold(p, "resourceGroups") && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

// vmSummary returns a short display string for a virtual machine.
func vmSummary(vm *armcompute.VirtualMachine) string {
name := ""
rg := ""
loc := ""
if vm.Name != nil {
name = *vm.Name
}
if vm.Location != nil {
loc = *vm.Location
}
if vm.ID != nil {
rg = resourceGroupFromID(*vm.ID)
}
return fmt.Sprintf("%s (rg:%s loc:%s)", name, rg, loc)
}

// runVMListCheck lists up to 10 virtual machines in the subscription.
func runVMListCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
cr := newCheckResult("azure:vm:list(sample)", time.Now().UTC())
cr.Details["subscriptionID"] = subscriptionID

vmClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, cred, nil)
if err != nil {
return checkFail(&cr, fmt.Sprintf("failed to create virtual-machines client: %v", err))
}

pager := vmClient.NewListAllPager(nil)
var items []string
for pager.More() && len(items) < 10 {
page, pageErr := pager.NextPage(ctx)
if pageErr != nil {
return checkFail(&cr, pageErr.Error())
}
for _, vm := range page.Value {
items = append(items, vmSummary(vm))
if len(items) >= 10 {
break
}
}
}
cr.Details["result"] = truncate(strings.Join(items, ", "), 2048)
return checkPass(&cr, "")
}

// runAccountSetCheck verifies that the given subscription ID is reachable.
func runAccountSetCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) report.CheckResult {
cr := newCheckResult("azure:account:set", time.Now().UTC())
cr.Details["subscriptionID"] = subscriptionID

subClient, err := armsubscriptions.NewClient(cred, nil)
if err != nil {
return checkFail(&cr, fmt.Sprintf("failed to create subscriptions client: %v", err))
}

if _, err := subClient.Get(ctx, subscriptionID, nil); err != nil {
return checkFail(&cr, err.Error())
}
return checkPass(&cr, "subscription reachable")
}

// runAccountShowCheck retrieves subscription identity information.
// It returns the check result and the JSON-encoded account info (for use by
// validateAccountExpectations). The JSON is empty on failure.
func runAccountShowCheck(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) (result report.CheckResult, accountJSON []byte) {
cr := newCheckResult("azure:account:show", time.Now().UTC())
cr.Details["subscriptionID"] = subscriptionID

subClient, err := armsubscriptions.NewClient(cred, nil)
if err != nil {
return checkFail(&cr, fmt.Sprintf("failed to create subscriptions client: %v", err)), nil
}

resp, err := subClient.Get(ctx, subscriptionID, nil)
if err != nil {
return checkFail(&cr, err.Error()), nil
}

sub := resp.Subscription
info := azureSubscriptionInfo{}
if sub.State != nil {
info.State = string(*sub.State)
}
if sub.SubscriptionID != nil {
info.ID = *sub.SubscriptionID
}
if sub.TenantID != nil {
info.TenantID = *sub.TenantID
}
if sub.DisplayName != nil {
info.Name = *sub.DisplayName
}

cr.Details["id"] = info.ID
cr.Details["tenantId"] = info.TenantID
cr.Details["name"] = info.Name
cr.Details["state"] = info.State
return checkPass(&cr, "account info retrieved"), encodeAccountJSON(info)
}

// Run performs read-only Azure sanity checks using native SDK calls.
//
// Auth: DefaultAzureCredential is used throughout. In CI this resolves via the
// AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET environment
// variables (service-principal flow). On an operator workstation it falls back
// to AzureCLICredential (i.e. the session established by "az login"), so the
// behavior is identical to the previous CLI-based implementation.
func Run(ctx context.Context, opts Options) (*report.Report, error) {
if opts.SubscriptionID == "" {
opts.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID")
Expand All @@ -101,50 +315,27 @@ func Run(ctx context.Context, opts Options) (*report.Report, error) {
StartedAt: time.Now().UTC(),
}

runCmd := func(name string, args ...string) ([]byte, report.CheckResult) {
start := time.Now().UTC()
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
out, err := cmd.CombinedOutput()
end := time.Now().UTC()

cr := report.CheckResult{
Name: name,
StartedAt: start,
EndedAt: end,
Details: map[string]string{
"cmd": "az " + strings.Join(args, " "),
"output": truncate(string(out), 2048),
},
}
if err != nil {
cr.Status = report.StatusFail
cr.Message = err.Error()
} else {
cr.Status = report.StatusPass
}
return out, cr
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
rep.EndedAt = time.Now().UTC()
return nil, fmt.Errorf("azure: failed to build DefaultAzureCredential: %w", err)
}

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

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

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

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

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

rep.EndedAt = time.Now().UTC()
return rep, nil
Expand Down
Loading
Loading