diff --git a/.github/workflows/aws_sanity.yml b/.github/workflows/aws_sanity.yml new file mode 100644 index 00000000..ee3554a6 --- /dev/null +++ b/.github/workflows/aws_sanity.yml @@ -0,0 +1,78 @@ +name: AWS Sanity (Read-only Dry Run) + +on: + pull_request: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + sanity: + runs-on: ubuntu-latest + env: + AWS_REGION: us-east-1 + REPORT_PATH: sanity_report.json + EXPECTED_ACCOUNT: ${{ secrets.AWS_EXPECTED_ACCOUNT_ID }} + + steps: + - name: Precheck secrets (skip if not configured) + id: precheck + shell: bash + env: + AWS_CICD_READONLY_ROLE_ARN: ${{ secrets.AWS_CICD_READONLY_ROLE_ARN }} + AWS_EXPECTED_ACCOUNT_ID: ${{ secrets.AWS_EXPECTED_ACCOUNT_ID }} + run: | + if [[ -z "${AWS_CICD_READONLY_ROLE_ARN}" || -z "${AWS_EXPECTED_ACCOUNT_ID}" ]]; then + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "Missing AWS secrets. Skipping AWS sanity." + else + echo "should_run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.precheck.outputs.should_run == 'true' + uses: actions/checkout@v4 + + - name: Setup Go + if: steps.precheck.outputs.should_run == 'true' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Configure AWS credentials via OIDC (read-only role) + if: steps.precheck.outputs.should_run == 'true' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_CICD_READONLY_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: cudly-sanity + + - name: Build + if: steps.precheck.outputs.should_run == 'true' + run: | + go test ./ci_cd_sanity_tests/... -count=1 + go build -o sanity ./ci_cd_sanity_tests/cmd/sanity + + - name: Run sanity (read-only) + if: steps.precheck.outputs.should_run == 'true' + run: | + ./sanity \ + --region "${AWS_REGION}" \ + --expected-account "${EXPECTED_ACCOUNT}" \ + --out "${REPORT_PATH}" + + - name: Upload report artifact + if: always() && steps.precheck.outputs.should_run == 'true' + uses: actions/upload-artifact@v4 + with: + name: aws-sanity-report + path: ${{ env.REPORT_PATH }} + if-no-files-found: ignore + + - name: Skipped summary + if: steps.precheck.outputs.should_run != 'true' + run: echo "AWS sanity skipped because required secrets are not configured." diff --git a/.github/workflows/azure_sanity.yml b/.github/workflows/azure_sanity.yml new file mode 100644 index 00000000..891d5b4c --- /dev/null +++ b/.github/workflows/azure_sanity.yml @@ -0,0 +1,77 @@ +name: Azure Sanity (Read-only Dry Run) + +on: + pull_request: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + sanity: + runs-on: ubuntu-latest + env: + REPORT_PATH: azure_sanity_report.json + + steps: + - name: Precheck secrets (skip if not configured) + id: precheck + shell: bash + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + run: | + if [[ -z "${AZURE_CLIENT_ID}" || -z "${AZURE_TENANT_ID}" || -z "${AZURE_SUBSCRIPTION_ID}" ]]; then + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "Missing Azure secrets. Skipping Azure sanity." + else + echo "should_run=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.precheck.outputs.should_run == 'true' + uses: actions/checkout@v4 + + - name: Setup Go + if: steps.precheck.outputs.should_run == 'true' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Azure Login (OIDC) + if: steps.precheck.outputs.should_run == 'true' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Build + Run Azure sanity + if: steps.precheck.outputs.should_run == 'true' + env: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + run: | + go test ./ci_cd_sanity_tests/... -count=1 + go build -o azure-sanity ./ci_cd_sanity_tests/cmd/azure_sanity + ./azure-sanity \ + --subscription-id "${AZURE_SUBSCRIPTION_ID}" \ + --expected-subscription "${AZURE_SUBSCRIPTION_ID}" \ + --expected-tenant "${AZURE_TENANT_ID}" \ + --out "${REPORT_PATH}" + + - name: Upload report artifact + if: always() && steps.precheck.outputs.should_run == 'true' + uses: actions/upload-artifact@v4 + with: + name: azure-sanity-report + path: ${{ env.REPORT_PATH }} + if-no-files-found: ignore + + - name: Skipped summary + if: steps.precheck.outputs.should_run != 'true' + run: echo "Azure sanity skipped because required secrets are not configured." diff --git a/.gitignore b/.gitignore index b956d125..447fd9b2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,15 @@ IMPLEMENTATION*.md MULTI_CLOUD*.md MYSQL*.md *_SUMMARY.md -*_INDEX.md \ No newline at end of file +*_INDEX.md + +#build artifacts (root-level binaries only) +/sanity +/azure-sanity +/ri-exchange + +#sanity reports / artifacts +*sanity_report.json +azure_sanity_report.json +ri-exchange_*.json +sanity_report*.json diff --git a/ci_cd_sanity_tests/cmd/azure_sanity/main.go b/ci_cd_sanity_tests/cmd/azure_sanity/main.go new file mode 100644 index 00000000..44ea9d45 --- /dev/null +++ b/ci_cd_sanity_tests/cmd/azure_sanity/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/azure" +) + +func main() { + var ( + subID = flag.String("subscription-id", "", "Azure subscription ID (or set AZURE_SUBSCRIPTION_ID)") + expectedTenant = flag.String("expected-tenant", "", "Expected Azure tenant ID (optional)") + expectedSub = flag.String("expected-subscription", "", "Expected Azure subscription ID (optional)") + outPath = flag.String("out", "azure_sanity_report.json", "Output JSON report path") + timeoutSec = flag.Int("timeout-sec", 120, "Timeout seconds") + ) + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec)*time.Second) + defer cancel() + + rep, err := azure.Run(ctx, azure.Options{ + SubscriptionID: *subID, + ExpectedTenantID: *expectedTenant, + ExpectedSubID: *expectedSub, + Timeout: time.Duration(*timeoutSec) * time.Second, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "azure sanity run failed: %v\n", err) + os.Exit(2) + } + + if err := rep.WriteJSON(*outPath); err != nil { + fmt.Fprintf(os.Stderr, "write report failed: %v\n", err) + os.Exit(2) + } + + if rep.HasFailures() { + fmt.Fprintf(os.Stderr, "azure sanity: FAIL (see %s)\n", *outPath) + os.Exit(1) + } + + fmt.Printf("azure sanity: PASS (see %s)\n", *outPath) +} diff --git a/ci_cd_sanity_tests/cmd/ri-exchange/main.go b/ci_cd_sanity_tests/cmd/ri-exchange/main.go new file mode 100644 index 00000000..cd4bb017 --- /dev/null +++ b/ci_cd_sanity_tests/cmd/ri-exchange/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + commitaws "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/commitments/aws" +) + +type Output struct { + Mode string `json:"mode"` // dry-run | execute + Region string `json:"region"` + AccountChk string `json:"expected_account,omitempty"` + + ReservedIDs []string `json:"reserved_instance_ids"` + TargetOfferingID string `json:"target_offering_id"` + TargetCount int32 `json:"target_count"` + MaxPaymentDueUSD string `json:"max_payment_due_usd,omitempty"` + ExchangeID string `json:"exchange_id,omitempty"` + Quote any `json:"quote"` + Error string `json:"error,omitempty"` +} + +func parseIDs(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func main() { + var ( + region = flag.String("region", "us-east-1", "AWS region") + expectedAccount = flag.String("expected-account", "", "Safety check: expected AWS account ID (optional)") + + riIDsCSV = flag.String("ri-ids", "", "Comma-separated Convertible Reserved Instance IDs to exchange (required)") + targetOffering = flag.String("target-offering-id", "", "Target RI offering ID (required)") + targetCount = flag.Int("target-count", 1, "Target instance count (default 1)") + + // Execution gating + execute = flag.Bool("execute", false, "Actually execute the exchange (default false = quote only)") + ack = flag.String("ack", "", "Must be 'YES' to execute (safety)") + maxPaymentDue = flag.String("max-payment-due-usd", "", "Max allowed paymentDue from quote (required for execute). Example: 5.00") + + outPath = flag.String("out", "ri_exchange_result.json", "Output JSON path") + timeoutSec = flag.Int("timeout-sec", 180, "Timeout seconds") + ) + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec)*time.Second) + defer cancel() + + ids := parseIDs(*riIDsCSV) + if len(ids) == 0 { + fmt.Fprintln(os.Stderr, "ERROR: --ri-ids is required (comma-separated)") + os.Exit(2) + } + if strings.TrimSpace(*targetOffering) == "" { + fmt.Fprintln(os.Stderr, "ERROR: --target-offering-id is required") + os.Exit(2) + } + + o := Output{ + Region: *region, + AccountChk: *expectedAccount, + ReservedIDs: ids, + TargetOfferingID: *targetOffering, + TargetCount: int32(*targetCount), + } + + if !*execute { + o.Mode = "dry-run" + q, err := commitaws.GetExchangeQuote(ctx, commitaws.ExchangeQuoteRequest{ + Region: *region, + ExpectedAccount: *expectedAccount, + ReservedIDs: ids, + TargetOfferingID: *targetOffering, + TargetCount: int32(*targetCount), + DryRun: false, // false = real quote; true would only check IAM permissions + }) + if err != nil { + o.Error = err.Error() + o.Quote = q + write(o, *outPath) + fmt.Fprintf(os.Stderr, "quote: FAIL (see %s)\n", *outPath) + os.Exit(1) + } + o.Quote = q + write(o, *outPath) + + if !q.IsValidExchange { + fmt.Fprintf(os.Stderr, "quote: INVALID (%s) (see %s)\n", q.ValidationFailureReason, *outPath) + os.Exit(1) + } + fmt.Printf("quote: OK (valid=%v, paymentDue=%s %s) (see %s)\n", q.IsValidExchange, q.PaymentDueRaw, q.CurrencyCode, *outPath) + os.Exit(0) + } + + // Execute path + o.Mode = "execute" + if strings.TrimSpace(*ack) != "YES" { + o.Error = "refusing to execute: pass --ack YES" + write(o, *outPath) + fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", *outPath) + os.Exit(2) + } + if strings.TrimSpace(*maxPaymentDue) == "" { + o.Error = "refusing to execute: --max-payment-due-usd is required as a safety cap" + write(o, *outPath) + fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", *outPath) + os.Exit(2) + } + maxRat, err := commitaws.ParseDecimalRat(*maxPaymentDue) + if err != nil { + o.Error = err.Error() + write(o, *outPath) + fmt.Fprintf(os.Stderr, "execute: BAD INPUT (see %s)\n", *outPath) + os.Exit(2) + } + o.MaxPaymentDueUSD = maxRat.FloatString(2) + + exID, q, err := commitaws.ExecuteExchange(ctx, commitaws.ExchangeExecuteRequest{ + Region: *region, + ExpectedAccount: *expectedAccount, + ReservedIDs: ids, + TargetOfferingID: *targetOffering, + TargetCount: int32(*targetCount), + MaxPaymentDueUSD: maxRat, + }) + o.Quote = q + if err != nil { + o.Error = err.Error() + write(o, *outPath) + fmt.Fprintf(os.Stderr, "execute: FAIL (see %s)\n", *outPath) + os.Exit(1) + } + + o.ExchangeID = exID + write(o, *outPath) + fmt.Printf("execute: OK exchangeId=%s (see %s)\n", exID, *outPath) +} + +func write(v any, path string) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal json for %s: %v\n", path, err) + return + } + if err := os.WriteFile(path, b, 0600); err != nil { + fmt.Fprintf(os.Stderr, "failed to write %s: %v\n", path, err) + } +} diff --git a/ci_cd_sanity_tests/cmd/sanity/main.go b/ci_cd_sanity_tests/cmd/sanity/main.go new file mode 100644 index 00000000..8c9afc69 --- /dev/null +++ b/ci_cd_sanity_tests/cmd/sanity/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/aws" +) + +func main() { + var ( + region = flag.String("region", "us-east-1", "AWS region for sanity checks") + expectedAccount = flag.String("expected-account", "", "Expected AWS Account ID (optional)") + maxList = flag.Int("max-list", 5, "Max instances to list for EC2 sample (default 5). RDS uses 20..100.") + outPath = flag.String("out", "sanity_report.json", "Output JSON report path") + ) + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + rep, err := aws.Run(ctx, aws.Options{ + Region: *region, + ExpectedAccount: *expectedAccount, + MaxList: int32(*maxList), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "sanity run failed: %v\n", err) + os.Exit(2) + } + + if err := rep.WriteJSON(*outPath); err != nil { + fmt.Fprintf(os.Stderr, "write report failed: %v\n", err) + os.Exit(2) + } + + if rep.HasFailures() { + fmt.Fprintf(os.Stderr, "sanity checks: FAIL (see %s)\n", *outPath) + os.Exit(1) + } + + fmt.Printf("sanity checks: PASS (see %s)\n", *outPath) +} diff --git a/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange.go b/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange.go new file mode 100644 index 00000000..60b19b77 --- /dev/null +++ b/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange.go @@ -0,0 +1,226 @@ +package aws + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + sdkaws "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +// ExchangeQuoteSummary is a small, stable summary we can log/guard on. +type ExchangeQuoteSummary struct { + IsValidExchange bool + ValidationFailureReason string + CurrencyCode string + + PaymentDueRaw string // as returned by AWS (string) + PaymentDueUSD *big.Rat // parsed numeric (optional) + + OutputReservedInstancesExp *time.Time + + // Rollups (strings in AWS response) + SourceHourlyPriceRaw string + SourceRemainingUpfrontRaw string + SourceRemainingTotalRaw string + TargetHourlyPriceRaw string + TargetRemainingUpfrontRaw string + TargetRemainingTotalRaw string +} + +// ParseDecimalRat parses AWS decimal strings like "123.45" or "-0.018000" into big.Rat. +func ParseDecimalRat(s string) (*big.Rat, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, fmt.Errorf("empty decimal string") + } + r := new(big.Rat) + if _, ok := r.SetString(s); !ok { + return nil, fmt.Errorf("invalid decimal: %q", s) + } + return r, nil +} + +type ExchangeQuoteRequest struct { + Region string + ExpectedAccount string // optional safety check + ReservedIDs []string + + TargetOfferingID string + TargetCount int32 + + // DryRun here uses the AWS API DryRun parameter (permission check). + // The quote call itself never performs an exchange. + DryRun bool +} + +type ExchangeExecuteRequest struct { + Region string + ExpectedAccount string // optional safety check + ReservedIDs []string + + TargetOfferingID string + TargetCount int32 + + // Guardrail: require PaymentDue <= MaxPaymentDueUSD to execute. + // If nil, execution is refused. + MaxPaymentDueUSD *big.Rat +} + +func loadCfg(ctx context.Context, region string) (sdkaws.Config, error) { + if region == "" { + region = "us-east-1" + } + return config.LoadDefaultConfig(ctx, config.WithRegion(region)) +} + +func assertAccount(ctx context.Context, cfg sdkaws.Config, expected string) error { + if expected == "" { + return nil + } + out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return err + } + if sdkaws.ToString(out.Account) != expected { + return fmt.Errorf("unexpected AWS account: got %s want %s", sdkaws.ToString(out.Account), expected) + } + return nil +} + +func GetExchangeQuote(ctx context.Context, req ExchangeQuoteRequest) (*ExchangeQuoteSummary, error) { + cfg, err := loadCfg(ctx, req.Region) + if err != nil { + return nil, err + } + if err := assertAccount(ctx, cfg, req.ExpectedAccount); err != nil { + return nil, err + } + return getQuoteWithClient(ctx, ec2.NewFromConfig(cfg), req) +} + +// getQuoteWithClient performs the quote call using a pre-configured EC2 client, +// allowing ExecuteExchange to reuse the same client for both quote and accept. +func getQuoteWithClient(ctx context.Context, client *ec2.Client, req ExchangeQuoteRequest) (*ExchangeQuoteSummary, error) { + if len(req.ReservedIDs) == 0 { + return nil, fmt.Errorf("must provide at least one --ri-ids") + } + if strings.TrimSpace(req.TargetOfferingID) == "" { + return nil, fmt.Errorf("must provide --target-offering-id") + } + if req.TargetCount <= 0 { + req.TargetCount = 1 + } + + in := &ec2.GetReservedInstancesExchangeQuoteInput{ + DryRun: sdkaws.Bool(req.DryRun), + ReservedInstanceIds: req.ReservedIDs, + TargetConfigurations: []ec2types.TargetConfigurationRequest{ + { + OfferingId: sdkaws.String(req.TargetOfferingID), + InstanceCount: sdkaws.Int32(req.TargetCount), + }, + }, + } + + out, err := client.GetReservedInstancesExchangeQuote(ctx, in) + if err != nil { + return nil, err + } + + s := &ExchangeQuoteSummary{ + IsValidExchange: sdkaws.ToBool(out.IsValidExchange), + ValidationFailureReason: sdkaws.ToString(out.ValidationFailureReason), + CurrencyCode: sdkaws.ToString(out.CurrencyCode), + PaymentDueRaw: sdkaws.ToString(out.PaymentDue), + } + + if out.OutputReservedInstancesWillExpireAt != nil { + t := *out.OutputReservedInstancesWillExpireAt + s.OutputReservedInstancesExp = &t + } + + if s.PaymentDueRaw != "" { + p, perr := ParseDecimalRat(s.PaymentDueRaw) + if perr != nil { + return nil, fmt.Errorf("quote returned invalid paymentDue %q: %w", s.PaymentDueRaw, perr) + } + s.PaymentDueUSD = p + } + + // Rollups (optional but useful for debugging) + if out.ReservedInstanceValueRollup != nil { + s.SourceHourlyPriceRaw = sdkaws.ToString(out.ReservedInstanceValueRollup.HourlyPrice) + s.SourceRemainingUpfrontRaw = sdkaws.ToString(out.ReservedInstanceValueRollup.RemainingUpfrontValue) + s.SourceRemainingTotalRaw = sdkaws.ToString(out.ReservedInstanceValueRollup.RemainingTotalValue) + } + if out.TargetConfigurationValueRollup != nil { + s.TargetHourlyPriceRaw = sdkaws.ToString(out.TargetConfigurationValueRollup.HourlyPrice) + s.TargetRemainingUpfrontRaw = sdkaws.ToString(out.TargetConfigurationValueRollup.RemainingUpfrontValue) + s.TargetRemainingTotalRaw = sdkaws.ToString(out.TargetConfigurationValueRollup.RemainingTotalValue) + } + + return s, nil +} + +func ExecuteExchange(ctx context.Context, req ExchangeExecuteRequest) (exchangeID string, quote *ExchangeQuoteSummary, err error) { + if req.MaxPaymentDueUSD == nil { + return "", nil, fmt.Errorf("refusing to execute without --max-payment-due-usd guardrail") + } + + cfg, err := loadCfg(ctx, req.Region) + if err != nil { + return "", nil, err + } + if err := assertAccount(ctx, cfg, req.ExpectedAccount); err != nil { + return "", nil, err + } + + client := ec2.NewFromConfig(cfg) + + q, err := getQuoteWithClient(ctx, client, ExchangeQuoteRequest{ + Region: req.Region, + ExpectedAccount: req.ExpectedAccount, + ReservedIDs: req.ReservedIDs, + TargetOfferingID: req.TargetOfferingID, + TargetCount: req.TargetCount, + DryRun: false, + }) + if err != nil { + return "", nil, err + } + + if !q.IsValidExchange { + return "", q, fmt.Errorf("exchange is not valid: %s", q.ValidationFailureReason) + } + + if q.PaymentDueUSD == nil { + return "", q, fmt.Errorf("quote did not return a parseable paymentDue; refusing to execute without cost verification") + } + + // paymentDue > max => refuse + if q.PaymentDueUSD.Cmp(req.MaxPaymentDueUSD) == 1 { + return "", q, fmt.Errorf("paymentDue %s exceeds max %s", q.PaymentDueUSD.FloatString(2), req.MaxPaymentDueUSD.FloatString(2)) + } + + out, err := client.AcceptReservedInstancesExchangeQuote(ctx, &ec2.AcceptReservedInstancesExchangeQuoteInput{ + ReservedInstanceIds: req.ReservedIDs, + TargetConfigurations: []ec2types.TargetConfigurationRequest{ + { + OfferingId: sdkaws.String(req.TargetOfferingID), + InstanceCount: sdkaws.Int32(req.TargetCount), + }, + }, + }) + if err != nil { + return "", q, err + } + + return sdkaws.ToString(out.ExchangeId), q, nil +} diff --git a/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange_test.go b/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange_test.go new file mode 100644 index 00000000..3810ef9f --- /dev/null +++ b/ci_cd_sanity_tests/pkg/commitments/aws/ri_exchange_test.go @@ -0,0 +1,58 @@ +package aws + +import ( + "math/big" + "testing" +) + +func TestParseDecimalRat(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"5.00", "5", false}, + {"0.10", "1/10", false}, + {"-1.25", "-5/4", false}, + {"", "", true}, + {"abc", "", true}, + } + + for _, c := range cases { + got, err := ParseDecimalRat(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("expected error for %q", c.in) + } + continue + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", c.in, err) + } + if got.RatString() != c.want { + t.Fatalf("ParseDecimalRat(%q)=%s want %s", c.in, got.RatString(), c.want) + } + } +} + +func TestSpendCapComparison(t *testing.T) { + // paymentDue > cap => reject + payment := new(big.Rat).SetInt64(6) + cap := new(big.Rat).SetInt64(5) + + if payment.Cmp(cap) != 1 { + t.Fatalf("expected payment > cap") + } + + // equal => ok + payment2 := new(big.Rat).SetInt64(5) + if payment2.Cmp(cap) != 0 { + t.Fatalf("expected payment == cap") + } + + // less => ok + payment3 := new(big.Rat).SetInt64(4) + if payment3.Cmp(cap) != -1 { + t.Fatalf("expected payment < cap") + } +} diff --git a/ci_cd_sanity_tests/pkg/sanity/aws/aws.go b/ci_cd_sanity_tests/pkg/sanity/aws/aws.go new file mode 100644 index 00000000..a28de76c --- /dev/null +++ b/ci_cd_sanity_tests/pkg/sanity/aws/aws.go @@ -0,0 +1,122 @@ +package aws + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/sts" + + "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/report" +) + +type Options struct { + Region string + ExpectedAccount string // optional safety check + MaxList int32 // used for EC2; RDS will clamp to valid range +} + +func Run(ctx context.Context, opts Options) (*report.Report, error) { + if opts.Region == "" { + opts.Region = "us-east-1" + } + if opts.MaxList <= 0 { + opts.MaxList = 5 + } + + rep := &report.Report{ + RunID: fmt.Sprintf("aws-%d", time.Now().Unix()), + Cloud: "aws", + Mode: "dry-run", + StartedAt: time.Now().UTC(), + } + + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.Region)) + if err != nil { + return nil, err + } + + runCheck := func(name string, fn func(context.Context, aws.Config) (map[string]string, error)) { + start := time.Now().UTC() + details, e := fn(ctx, cfg) + end := time.Now().UTC() + + cr := report.CheckResult{Name: name, StartedAt: start, EndedAt: end} + if e == nil { + cr.Status = report.StatusPass + cr.Details = details + } else { + cr.Status = report.StatusFail + cr.Message = e.Error() + cr.Details = details + } + rep.Add(cr) + } + + // READ ONLY: identity check + runCheck("sts:GetCallerIdentity", func(ctx context.Context, cfg aws.Config) (map[string]string, error) { + out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nil, err + } + d := map[string]string{ + "account": aws.ToString(out.Account), + "arn": aws.ToString(out.Arn), + "user_id": aws.ToString(out.UserId), + } + if opts.ExpectedAccount != "" && aws.ToString(out.Account) != opts.ExpectedAccount { + return d, fmt.Errorf("unexpected AWS account: got %s want %s", aws.ToString(out.Account), opts.ExpectedAccount) + } + return d, nil + }) + + // READ ONLY: regions + runCheck("ec2:DescribeRegions", func(ctx context.Context, cfg aws.Config) (map[string]string, error) { + out, err := ec2.NewFromConfig(cfg).DescribeRegions(ctx, &ec2.DescribeRegionsInput{}) + if err != nil { + return nil, err + } + return map[string]string{"regions_count": fmt.Sprintf("%d", len(out.Regions))}, nil + }) + + // READ ONLY: instances (sample) + runCheck("ec2:DescribeInstances (sample)", func(ctx context.Context, cfg aws.Config) (map[string]string, error) { + out, err := ec2.NewFromConfig(cfg).DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + MaxResults: aws.Int32(opts.MaxList), + }) + if err != nil { + return nil, err + } + instances := 0 + for _, r := range out.Reservations { + instances += len(r.Instances) + } + return map[string]string{"instances_seen": fmt.Sprintf("%d", instances)}, nil + }) + + // READ ONLY: RDS (sample) — MaxRecords must be 20..100 + runCheck("rds:DescribeDBInstances (sample)", func(ctx context.Context, cfg aws.Config) (map[string]string, error) { + max := opts.MaxList + if max < 20 { + max = 20 + } + if max > 100 { + max = 100 + } + + out, err := rds.NewFromConfig(cfg).DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + MaxRecords: aws.Int32(max), + }) + if err != nil { + return nil, err + } + return map[string]string{"db_instances_seen": fmt.Sprintf("%d", len(out.DBInstances))}, nil + }) + + rep.EndedAt = time.Now().UTC() + return rep, nil +} diff --git a/ci_cd_sanity_tests/pkg/sanity/azure/azure.go b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go new file mode 100644 index 00000000..d9098454 --- /dev/null +++ b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go @@ -0,0 +1,152 @@ +package azure + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/LeanerCloud/CUDly/ci_cd_sanity_tests/pkg/sanity/report" +) + +type Options struct { + SubscriptionID string + ExpectedTenantID string // optional + ExpectedSubID string // optional + Timeout time.Duration +} + +type azAccountShow struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + Name string `json:"name"` + State string `json:"state"` + User struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"user"` +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "...(truncated)" +} + +func Run(ctx context.Context, opts Options) (*report.Report, error) { + if opts.SubscriptionID == "" { + opts.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") + } + if opts.SubscriptionID == "" { + return nil, fmt.Errorf("missing Azure subscription id: set AZURE_SUBSCRIPTION_ID or pass --subscription-id") + } + if opts.Timeout <= 0 { + opts.Timeout = 2 * time.Minute + } + + rctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + rep := &report.Report{ + RunID: fmt.Sprintf("azure-%d", time.Now().Unix()), + Cloud: "azure", + Mode: "dry-run", + StartedAt: time.Now().UTC(), + } + + runCmd := func(name string, args ...string) ([]byte, report.CheckResult) { + start := time.Now().UTC() + cmd := exec.CommandContext(rctx, "az", args...) + 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 + } + + // Ensure subscription context (read-only) + _, cr := runCmd("azure:account:set", "account", "set", "--subscription", opts.SubscriptionID) + rep.Add(cr) + + // Read-only identity/subscription info (only call once; reuse output) + accountOut, cr := runCmd("azure:account:show", "account", "show", "-o", "json") + rep.Add(cr) + + // Robust expected checks via JSON parsing (no fragile string matching) + if opts.ExpectedSubID != "" || opts.ExpectedTenantID != "" { + start := time.Now().UTC() + check := report.CheckResult{ + Name: "azure:account:expected_checks", + StartedAt: start, + Details: map[string]string{}, + } + + var a azAccountShow + err := json.Unmarshal(accountOut, &a) + end := time.Now().UTC() + check.EndedAt = end + + if err != nil { + check.Status = report.StatusFail + check.Message = fmt.Sprintf("failed to parse az account show JSON: %v", err) + check.Details["raw"] = string(accountOut) + rep.Add(check) + } else { + check.Details["id"] = a.ID + check.Details["tenantId"] = a.TenantID + check.Details["name"] = a.Name + check.Details["state"] = a.State + check.Details["user"] = a.User.Name + + ok := true + msg := "" + + if opts.ExpectedSubID != "" && a.ID != opts.ExpectedSubID { + ok = false + msg += fmt.Sprintf("unexpected subscription: got %s want %s; ", a.ID, opts.ExpectedSubID) + } + if opts.ExpectedTenantID != "" && a.TenantID != opts.ExpectedTenantID { + ok = false + msg += fmt.Sprintf("unexpected tenant: got %s want %s; ", a.TenantID, opts.ExpectedTenantID) + } + + if ok { + check.Status = report.StatusPass + } else { + check.Status = report.StatusFail + check.Message = strings.TrimSpace(msg) + } + rep.Add(check) + } + } + + // Read-only lists (sample) + _, cr = runCmd("azure:group:list(sample)", "group", "list", + "--query", "[0:10].{name:name, location:location}", "-o", "json") + rep.Add(cr) + + _, cr = runCmd("azure:vm:list(sample)", "vm", "list", + "--query", "[0:10].{name:name, resourceGroup:resourceGroup, location:location}", "-o", "json") + rep.Add(cr) + + rep.EndedAt = time.Now().UTC() + return rep, nil +} diff --git a/ci_cd_sanity_tests/pkg/sanity/report/report.go b/ci_cd_sanity_tests/pkg/sanity/report/report.go new file mode 100644 index 00000000..03d57629 --- /dev/null +++ b/ci_cd_sanity_tests/pkg/sanity/report/report.go @@ -0,0 +1,54 @@ +package report + +import ( + "encoding/json" + "os" + "time" +) + +type Status string + +const ( + StatusPass Status = "PASS" + StatusFail Status = "FAIL" + StatusSkip Status = "SKIP" +) + +type CheckResult struct { + Name string `json:"name"` + Status Status `json:"status"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` +} + +type Report struct { + RunID string `json:"run_id"` + Cloud string `json:"cloud"` + Mode string `json:"mode"` // dry-run + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Results []CheckResult `json:"results"` +} + +func (r *Report) Add(res CheckResult) { + r.Results = append(r.Results, res) +} + +func (r *Report) HasFailures() bool { + for _, rr := range r.Results { + if rr.Status == StatusFail { + return true + } + } + return false +} + +func (r *Report) WriteJSON(path string) error { + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0600) +} diff --git a/go.mod b/go.mod index a10d736f..2add5009 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/savingsplans v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -93,6 +92,7 @@ require ( github.com/LeanerCloud/CUDly/providers/azure v0.0.0 github.com/LeanerCloud/CUDly/providers/gcp v0.0.0 github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 github.com/google/uuid v1.6.0 )