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
78 changes: 78 additions & 0 deletions .github/workflows/aws_sanity.yml
Original file line number Diff line number Diff line change
@@ -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."
77 changes: 77 additions & 0 deletions .github/workflows/azure_sanity.yml
Original file line number Diff line number Diff line change
@@ -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."
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,15 @@ IMPLEMENTATION*.md
MULTI_CLOUD*.md
MYSQL*.md
*_SUMMARY.md
*_INDEX.md
*_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
48 changes: 48 additions & 0 deletions ci_cd_sanity_tests/cmd/azure_sanity/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
161 changes: 161 additions & 0 deletions ci_cd_sanity_tests/cmd/ri-exchange/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading