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
346 changes: 0 additions & 346 deletions cmd/configure_azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@ import (
"io"
"log"
"os"
"os/exec"
"regexp"
"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/resources/armsubscriptions"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
Expand Down Expand Up @@ -336,345 +332,3 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential

return nil
}

// newAzureWizardCredential builds the credential used by the interactive Azure
// setup wizard. It binds explicitly to the Azure CLI session (the "az login"
// the operator runs in Step 1) via AzureCLICredential rather than
// DefaultAzureCredential, whose chain prioritizes environment / workload /
// managed-identity credentials and could otherwise resolve to a different
// principal than the one the operator just signed in as.
func newAzureWizardCredential() (azcore.TokenCredential, error) {
cred, err := azidentity.NewAzureCLICredential(nil)
if err != nil {
return nil, fmt.Errorf("failed to build Azure CLI credential: %w\n"+
"Ensure you are authenticated: run 'az login' (Step 1) before continuing", err)
}
return cred, nil
}

// listAzureSubscriptions retrieves the operator's subscriptions via the ARM
// Subscriptions SDK and prints them in a table matching "az account list"
// output. It uses the Azure CLI credential so the listing matches the
// operator's active "az login" session.
func listAzureSubscriptions(ctx context.Context) error {
cred, err := newAzureWizardCredential()
if err != nil {
return err
}

client, err := armsubscriptions.NewClient(cred, nil)
if err != nil {
return fmt.Errorf("failed to create subscriptions client: %w", err)
}

fmt.Printf("%-40s %-38s %s\n", "Name", "SubscriptionId", "State")
fmt.Println(strings.Repeat("-", 95))

pager := client.NewListPager(nil)
for pager.More() {
page, pageErr := pager.NextPage(ctx)
if pageErr != nil {
return fmt.Errorf("failed to list subscriptions: %w", pageErr)
}
for _, sub := range page.Value {
name := ""
subID := ""
state := ""
if sub.DisplayName != nil {
name = *sub.DisplayName
}
if sub.SubscriptionID != nil {
subID = *sub.SubscriptionID
}
if sub.State != nil {
state = string(*sub.State)
}
fmt.Printf("%-40s %-38s %s\n", name, subID, state)
}
}
return nil
}

// runAzureSetupCommands guides the operator through the Azure setup wizard.
//
// Step 1 (az login): performed via the Azure CLI. "az login" launches an
// interactive browser-based OAuth flow that cannot be replicated through the
// SDK on behalf of a human operator who does not yet have a credential. This
// is the only CLI call retained in this wizard.
//
// Step 2 (list subscriptions): performed via the ARM Subscriptions SDK using
// the Azure CLI credential, which reuses the session that "az login" just
// established. Fails loud if the SDK cannot authenticate (no CLI fallback).
//
// Step 3 (create service principal): performed via the Microsoft Graph SDK
// (application + service principal + password credential) and armauthorization
// (resolve the "Reservations Administrator" role definition and assign it at
// subscription scope). This is the create-for-rbac equivalent and fails loud
// on any SDK error.
//
// It returns the created service principal's credentials (nil when Step 3 was
// skipped) so the caller can store them without echoing the secret.
func runAzureSetupCommands(ctx context.Context, reader *bufio.Reader) (*AzureCredentials, error) {
if err := azureStepLogin(reader); err != nil {
return nil, err
}

subscriptionID, err := azureStepListSubscriptions(ctx, reader)
if err != nil {
return nil, err
}

return azureStepCreateServicePrincipal(ctx, reader, subscriptionID)
}

// azureStepLogin prompts to run "az login".
func azureStepLogin(reader *bufio.Reader) error {
fmt.Println("Step 1: Azure Login")
fmt.Println("-------------------")
fmt.Println("This will open a browser window for Azure authentication.")
fmt.Println()
return promptAndRunExplicitCommand(reader, "Azure Login", "az login", "az", "login")
}

// listAzureSubscriptionsWithTimeout runs the SDK subscription listing under a
// bounded timeout so a hung ARM call cannot stall the wizard.
func listAzureSubscriptionsWithTimeout(ctx context.Context) error {
listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return listAzureSubscriptions(listCtx)
}

// azureStepListSubscriptions optionally lists subscriptions via SDK and prompts
// the operator to enter their subscription ID. The listing is behind a
// [R]un/[S]kip prompt so an operator who already knows their subscription ID
// can proceed even if the SDK listing would fail; when RUN it fails loud (no
// CLI fallback), instructing the operator to run "az login" first.
func azureStepListSubscriptions(ctx context.Context, reader *bufio.Reader) (string, error) {
fmt.Println()
fmt.Println("Step 2: Get Subscription ID")
fmt.Println("---------------------------")

run, err := promptRunOrSkipListing(reader, "the Azure subscription listing (via SDK)")
if err != nil {
return "", err
}
if run {
fmt.Println("Listing your Azure subscriptions via SDK (Azure CLI credential)...")
fmt.Println()
if err = listAzureSubscriptionsWithTimeout(ctx); err != nil {
return "", fmt.Errorf("failed to list Azure subscriptions via SDK: %w\n"+
"Ensure you are authenticated: run 'az login' (Step 1) before continuing", err)
}
fmt.Println()
}

fmt.Print("Enter your Subscription ID: ")
subscriptionID, err := readTrimmedLine(reader)
if err != nil {
return "", fmt.Errorf("failed to read subscription ID: %w", err)
}

if subscriptionID == "" {
return "", fmt.Errorf("subscription ID is required")
}
if err := validateAzureUUID(subscriptionID, "Subscription ID"); err != nil {
return "", err
}
return subscriptionID, nil
}

// resolveAzureTenantID looks up the tenant ID for the given subscription via
// the ARM Subscriptions SDK, using the Azure CLI ("az login") credential.
func resolveAzureTenantID(ctx context.Context, subscriptionID string) (string, error) {
cred, err := newAzureWizardCredential()
if err != nil {
return "", err
}
client, err := armsubscriptions.NewClient(cred, nil)
if err != nil {
return "", fmt.Errorf("failed to create subscriptions client: %w", err)
}
resp, err := client.Get(ctx, subscriptionID, nil)
if err != nil {
return "", fmt.Errorf("failed to get subscription %s: %w", subscriptionID, err)
}
if resp.TenantID == nil || *resp.TenantID == "" {
return "", fmt.Errorf("subscription %s returned no tenant ID", subscriptionID)
}
return *resp.TenantID, nil
}

// resolveAzureTenantIDFn and newAzureSPProvisionerFn are the cloud-facing
// constructors used by azureStepCreateServicePrincipal. They are variables so
// unit tests can substitute fakes and never reach Azure.
var (
resolveAzureTenantIDFn = resolveAzureTenantID
newAzureSPProvisionerFn = func(subscriptionID string) (azureSPProvisioner, error) {
return newGraphSPProvisioner(subscriptionID)
}
)

// azureStepCreateServicePrincipal creates the service principal via the
// Microsoft Graph + armauthorization SDKs (the create-for-rbac equivalent) and
// returns its credentials for direct storage (nil when the operator skips the
// step). The client secret is never printed. It fails loud: any SDK error is
// returned rather than silently falling back to the CLI.
func azureStepCreateServicePrincipal(ctx context.Context, reader *bufio.Reader, subscriptionID string) (*AzureCredentials, error) {
fmt.Println()
fmt.Println("Step 3: Create Service Principal")
fmt.Println("---------------------------------")
fmt.Println("This creates an Azure Service Principal with the")
fmt.Printf("%q role at subscription scope, via the Microsoft Graph SDK.\n", azureSPRoleName)
fmt.Println()
fmt.Printf("Create service principal %q with role %q at /subscriptions/%s?\n", azureSPName, azureSPRoleName, subscriptionID)
fmt.Printf("[r]un, [S]kip (default)? ")

choice, err := readTrimmedLine(reader)
if err != nil {
return nil, fmt.Errorf("failed to read service-principal choice: %w", err)
}
// Creating an Entra app, password and role assignment is not harmless, so
// Enter skips, like the GCP wizard.
choice = strings.ToLower(choice)
if choice != "r" && choice != "run" {
fmt.Println("Skipping Create Service Principal")
fmt.Println()
fmt.Println("Provide the appId, client secret and tenant ID for an existing")
fmt.Println("service principal in the next step.")
fmt.Println()
return nil, nil
}

// The step budget must exceed roleAssignRetryBudget (3 min) plus the
// pre-assignment overhead (tenant resolution + the application / password /
// service-principal / role-definition Graph calls) so the PrincipalNotFound
// retry loop in AssignRole gets its full propagation budget. A tighter
// budget would cut the retry short via ctx cancellation, then the rollback
// would delete the just-created application and reset the AAD replication
// clock on every re-run. See roleAssignRetryBudget in configure_azure_sp.go.
spCtx, cancel := context.WithTimeout(ctx, 6*time.Minute)
defer cancel()

tenantID, err := resolveAzureTenantIDFn(spCtx, subscriptionID)
if err != nil {
return nil, err
}

provisioner, err := newAzureSPProvisionerFn(subscriptionID)
if err != nil {
return nil, fmt.Errorf("failed to initialize Azure SDK clients: %w\n"+
"Ensure you are authenticated: run 'az login' (Step 1) before continuing", err)
}

fmt.Println()
fmt.Println(strings.Repeat("-", 60))
result, err := createAzureServicePrincipal(spCtx, provisioner, subscriptionID, tenantID)
if err != nil {
return nil, err
}
fmt.Println(strings.Repeat("-", 60))

printAzureSPResult(result, subscriptionID, azureOpts.StackName)
return &AzureCredentials{
TenantID: result.TenantID,
ClientID: result.AppID,
ClientSecret: result.ClientSecret,
SubscriptionID: subscriptionID,
}, nil
}

// printAzureSPResult prints the non-secret identifiers of the created service
// principal. The client secret is deliberately NOT printed (not even
// partially): stdout is routinely captured by script/tee, CI logs and terminal
// scrollback, and the wizard hands the secret straight to the Secrets Manager
// store step, so the operator never needs to see or retype it.
func printAzureSPResult(result azureSPResult, subscriptionID, stackName string) {
fmt.Println()
fmt.Println("Service principal created:")
fmt.Println()
fmt.Printf(" appId (Client ID): %s\n", result.AppID)
fmt.Println(" password (Client Secret): [hidden]")
fmt.Printf(" tenant (Tenant ID): %s\n", result.TenantID)
fmt.Printf(" Subscription ID: %s\n", subscriptionID)
fmt.Println()
fmt.Printf("The client secret is not displayed. It will be stored directly in the\n")
fmt.Printf("AWS Secrets Manager secret %q (field client_secret).\n", stackName+"-AzureCredentials")
fmt.Println()
}

// withAzureSPRecoveryHint annotates a failure that happens after the wizard
// created a service principal but before its secret was stored (AWS config
// and credentials are checked before creation, so this covers the remaining
// store-time failures such as ListSecrets/UpdateSecret errors). The secret was
// never displayed, so the operator is told how to mint a replacement instead.
func withAzureSPRecoveryHint(err error, created *AzureCredentials) error {
if created == nil {
return err
}
return fmt.Errorf("%w\nThe service principal (appId %s) was created but its client secret was NOT stored "+
"and was never displayed. Fix the error above, then generate a new secret with "+
"'az ad app credential reset --id %s' and re-run 'cudly configure-azure --skip-setup', "+
"entering it at the hidden Client Secret prompt",
err, created.ClientID, created.ClientID)
}

// promptAndRunExplicitCommand shows a command and asks to run or skip.
// Takes explicit program and args to avoid command injection via string splitting.
func promptAndRunExplicitCommand(reader *bufio.Reader, name, displayCmd, program string, args ...string) error {
fmt.Printf("Command: %s\n", displayCmd)
fmt.Println()
fmt.Printf("[R]un, [S]kip? ")

choice, err := readTrimmedLine(reader)
if err != nil {
return fmt.Errorf("failed to read choice: %w", err)
}
choice = strings.ToLower(choice)

switch choice {
case "r", "run", "":
return executeExplicitCommand(reader, displayCmd, program, args...)
case "s", "skip":
fmt.Printf("Skipping %s\n", name)
return nil
default:
fmt.Printf("Unknown option '%s', skipping\n", choice)
return nil
}
}

// executeExplicitCommand runs a command with explicit program and arguments.
// It is used only for the interactive "az login" auth bootstrap (Step 1),
// which has no SDK equivalent that preserves the cached-credential UX.
// The caller's reader is threaded through to the retry prompt so all input
// is consumed from one consistent buffered stream (a fresh
// bufio.NewReader(os.Stdin) here would drop input already buffered by the
// caller's reader, breaking piped input after earlier prompts).
func executeExplicitCommand(reader *bufio.Reader, displayCmd, program string, args ...string) error {
fmt.Println()
fmt.Printf("Executing: %s\n", displayCmd)
fmt.Println(strings.Repeat("-", 60))

// #nosec G204 -- interactive operator auth (az login): program and args are hardcoded literals from the caller (runAzureSetupCommands passes "az","login"), no shell, not attacker-controlled
cmd := exec.Command(program, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

err := cmd.Run()
fmt.Println(strings.Repeat("-", 60))

if err != nil {
fmt.Printf("Command failed: %v\n", err)
fmt.Print("Continue anyway? [y/N]: ")
response, readErr := readTrimmedLine(reader)
if readErr != nil {
return fmt.Errorf("failed to read response: %w", readErr)
}
if !strings.EqualFold(response, "y") {
return fmt.Errorf("command failed: %w", err)
}
}

return nil
}
Loading
Loading