From 9e929a96ee7ff573f4cba89abb034d9e688f98bb Mon Sep 17 00:00:00 2001 From: Ahmed Abdalla Date: Tue, 27 Oct 2026 14:17:16 +0100 Subject: [PATCH] contrib: add hcp-infra unified AWS infrastructure management tool Adds a standalone binary in contrib/hcp-infra that scans, audits, and cleans orphaned AWS resources from HyperShift test clusters. Replaces the partial cleanroles and cleanzones tools with a single binary that handles all resource types. Features: - Three scan modes: tags (fast, via Resource Groups Tagging API), deep (comprehensive per-service API scan), detect (leak detection with management cluster cross-reference) - Orphan classification: CONFIRMED ORPHAN, PROBABLE ORPHAN, DEVELOPER-OWNED, ACTIVE, UNKNOWN ORIGIN - Clean command with safety gates: requires explicit --infra-id, --all, or --from-file; interactive confirmation; dry-run support - Pipeline support: scan -o json | clean --from-file - - Text, JSON, and markdown output formats - Cost estimation for NAT gateways and EIPs - CronJob manifests for automated daily cleanup - IAM role policy for scan-only and full access --- contrib/hcp-infra/Dockerfile | 8 + contrib/hcp-infra/README.md | 135 +++++++ contrib/hcp-infra/cmd/clean.go | 367 ++++++++++++++++++ contrib/hcp-infra/cmd/root.go | 58 +++ contrib/hcp-infra/cmd/scan.go | 26 ++ contrib/hcp-infra/cmd/scan_deep.go | 77 ++++ contrib/hcp-infra/cmd/scan_detect.go | 96 +++++ contrib/hcp-infra/cmd/scan_tags.go | 98 +++++ contrib/hcp-infra/go.mod | 37 ++ contrib/hcp-infra/go.sum | 70 ++++ contrib/hcp-infra/main.go | 18 + .../manifests/01-serviceaccount.yaml | 5 + contrib/hcp-infra/manifests/02-cronjob.yaml | 40 ++ contrib/hcp-infra/pkg/aws/clients.go | 92 +++++ .../hcp-infra/pkg/classifier/classifier.go | 131 +++++++ contrib/hcp-infra/pkg/reporter/json.go | 108 ++++++ contrib/hcp-infra/pkg/reporter/markdown.go | 145 +++++++ contrib/hcp-infra/pkg/reporter/reporter.go | 25 ++ .../hcp-infra/pkg/reporter/reporter_test.go | 202 ++++++++++ contrib/hcp-infra/pkg/reporter/text.go | 269 +++++++++++++ contrib/hcp-infra/pkg/scanner/tags.go | 256 ++++++++++++ contrib/hcp-infra/pkg/scanner/types.go | 163 ++++++++ contrib/hcp-infra/role-policy.json | 73 ++++ 23 files changed, 2499 insertions(+) create mode 100644 contrib/hcp-infra/Dockerfile create mode 100644 contrib/hcp-infra/README.md create mode 100644 contrib/hcp-infra/cmd/clean.go create mode 100644 contrib/hcp-infra/cmd/root.go create mode 100644 contrib/hcp-infra/cmd/scan.go create mode 100644 contrib/hcp-infra/cmd/scan_deep.go create mode 100644 contrib/hcp-infra/cmd/scan_detect.go create mode 100644 contrib/hcp-infra/cmd/scan_tags.go create mode 100644 contrib/hcp-infra/go.mod create mode 100644 contrib/hcp-infra/go.sum create mode 100644 contrib/hcp-infra/main.go create mode 100644 contrib/hcp-infra/manifests/01-serviceaccount.yaml create mode 100644 contrib/hcp-infra/manifests/02-cronjob.yaml create mode 100644 contrib/hcp-infra/pkg/aws/clients.go create mode 100644 contrib/hcp-infra/pkg/classifier/classifier.go create mode 100644 contrib/hcp-infra/pkg/reporter/json.go create mode 100644 contrib/hcp-infra/pkg/reporter/markdown.go create mode 100644 contrib/hcp-infra/pkg/reporter/reporter.go create mode 100644 contrib/hcp-infra/pkg/reporter/reporter_test.go create mode 100644 contrib/hcp-infra/pkg/reporter/text.go create mode 100644 contrib/hcp-infra/pkg/scanner/tags.go create mode 100644 contrib/hcp-infra/pkg/scanner/types.go create mode 100644 contrib/hcp-infra/role-policy.json diff --git a/contrib/hcp-infra/Dockerfile b/contrib/hcp-infra/Dockerfile new file mode 100644 index 000000000000..ea4486406d8b --- /dev/null +++ b/contrib/hcp-infra/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.24 AS builder +WORKDIR /build +COPY . . +RUN CGO_ENABLED=0 go build -o /hcp-infra . + +FROM gcr.io/distroless/static:nonroot +COPY --from=builder /hcp-infra /hcp-infra +ENTRYPOINT ["/hcp-infra"] diff --git a/contrib/hcp-infra/README.md b/contrib/hcp-infra/README.md new file mode 100644 index 000000000000..fe061a3e889a --- /dev/null +++ b/contrib/hcp-infra/README.md @@ -0,0 +1,135 @@ +# hcp-infra + +Unified AWS infrastructure management tool for HyperShift. Scans, audits, and cleans orphaned resources left behind by HyperShift hosted cluster tests. + +Replaces the previous `cleanroles` and `cleanzones` tools with a single binary that handles all AWS resource types. + +## Quick Start + +```bash +# Build +cd contrib/hcp-infra +go build -o hcp-infra . + +# Quick audit (tag-based, fast) +./hcp-infra scan tags + +# Full audit with leak detection +./hcp-infra scan detect --kubeconfig /path/to/mgmt/kubeconfig + +# See only your resources +./hcp-infra scan tags --owner aabdelre + +# Preview cleanup +./hcp-infra clean --infra-id kms-smoke5-lsbct --dry-run + +# Execute cleanup +./hcp-infra clean --infra-id kms-smoke5-lsbct + +# Clean all your orphans +./hcp-infra clean --all --owner aabdelre + +# Pipeline: scan, review, clean +./hcp-infra scan tags -o json > orphans.json +cat orphans.json | jq '.groups | to_entries[] | select(.value.category == "CONFIRMED ORPHAN") | .key' +./hcp-infra clean --from-file orphans.json +``` + +## Commands + +### `hcp-infra scan` + +Discover and report on HyperShift AWS infrastructure. + +| Subcommand | Description | +|------------|-------------| +| `scan tags` | Fast scan via Resource Groups Tagging API. Finds all resources tagged with `hypershift.openshift.io/infra-id`. | +| `scan deep` | Comprehensive scan using per-service APIs. Finds untagged resources too. | +| `scan detect` | Leak detection: runs both scans, cross-references with the management cluster, and categorizes resources. | + +### `hcp-infra clean` + +Delete orphaned resources. Requires one of `--infra-id`, `--all`, or `--from-file`. + +Safety features: +- Interactive confirmation prompt (auto-skipped for non-TTY / CronJob) +- `--dry-run` global flag to preview without deleting +- Prints AWS account ID before any deletion +- Deletes in dependency order (NATs before EIPs before VPCs) + +## Scan Modes + +### Tag Scan (default) + +Uses the AWS Resource Groups Tagging API. Every HyperShift resource is tagged with: +- `hypershift.openshift.io/infra-id` - the cluster's infrastructure ID +- `hypershift.openshift.io/cluster-name` - the cluster name +- `kubernetes.io/cluster/: owned` - standard ownership tag + +One API call discovers all infra IDs; another retrieves all resources for each ID. + +Fast (seconds), but only finds properly tagged resources. + +### Deep Scan + +Scans every resource type directly via per-service APIs (DescribeVpcs, ListRoles, ListOpenIDConnectProviders, etc.). Matches resources to infra IDs using naming conventions when tags are absent. + +Slow (30-60s across 5 regions), but finds untagged and partially tagged resources. + +### Detect (Leak Detection) + +Runs both scans, cross-references with the management cluster's HostedCluster resources, and categorizes every resource: + +| Category | Meaning | +|----------|---------| +| `CONFIRMED ORPHAN` | Tagged with an infra ID, no VPC exists. Safe to delete. | +| `PROBABLE ORPHAN` | Matches naming convention but lacks tags. Likely a partial creation leftover. | +| `DEVELOPER-OWNED` | Tagged with a developer name. May be intentional. | +| `ACTIVE` | Has a VPC and/or a live HostedCluster. Not touched. | +| `UNKNOWN ORIGIN` | Doesn't match any convention. Needs human review. | + +## Resource Types + +The tool manages all AWS resources created by HyperShift: + +- VPCs, Subnets, NAT Gateways, Elastic IPs, Internet Gateways +- Route Tables, Security Groups, VPC Endpoints, DHCP Options +- EC2 Instances, EBS Volumes +- IAM Roles (8 per cluster), Instance Profiles, OIDC Providers +- Route53 Hosted Zones +- S3 OIDC document prefixes + +## CronJob Deployment + +```bash +kubectl apply -f manifests/ +``` + +The CronJob runs daily, cleaning all orphaned resources older than 48 hours. See `manifests/02-cronjob.yaml` for configuration. + +## IAM Policy + +The minimum IAM policy is in `role-policy.json`. It has two statements: +- `ScanRead`: read-only permissions for scanning all resource types +- `CleanWrite`: write permissions for deleting resources + +For scan-only use, only the `ScanRead` statement is needed. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success (scan: no orphans; clean: all deleted) | +| 1 | Orphans found (scan) or partial failure (clean) | +| 2 | Error (AWS API failure, invalid flags) | + +## Global Flags + +| Flag | Description | +|------|-------------| +| `--region` | AWS regions to scan (default: us-east-1,us-east-2,us-west-1,us-west-2,eu-west-1; override with `$HCP_INFRA_REGIONS`) | +| `--kubeconfig` | Management cluster kubeconfig for HC cross-reference | +| `--output` / `-o` | Output format: text, json, markdown | +| `--dry-run` | Preview actions without making changes | +| `--verbose` / `-v` | Verbose output | +| `--quiet` / `-q` | Quiet output (summary only) | diff --git a/contrib/hcp-infra/cmd/clean.go b/contrib/hcp-infra/cmd/clean.go new file mode 100644 index 000000000000..539787790b3a --- /dev/null +++ b/contrib/hcp-infra/cmd/clean.go @@ -0,0 +1,367 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + awspkg "github.com/openshift/hypershift/contrib/hcp-infra/pkg/aws" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/classifier" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/reporter" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// CleanFlags holds flags specific to the clean command. +type CleanFlags struct { + InfraID string + All bool + FromFile string + OlderThan time.Duration + Owner string + ExcludeInfraIDs []string + OIDCBuckets []string + Yes bool +} + +// NewCleanCommand creates the clean subcommand for removing orphaned resources. +func NewCleanCommand(globals *GlobalFlags) *cobra.Command { + flags := &CleanFlags{} + + cmd := &cobra.Command{ + Use: "clean", + Short: "Clean orphaned HyperShift infrastructure resources", + Long: `Remove AWS resources belonging to orphaned HyperShift clusters. + +Requires one of: + --infra-id Clean a specific infra ID + --all Clean all discovered orphans + --from-file Clean infra IDs listed in a JSON scan output file + +Safety: displays the AWS account ID and deletion plan before proceeding. +Use --dry-run to preview without making changes. +Use --yes to skip the interactive confirmation prompt.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runClean(cmd.Context(), globals, flags) + }, + } + + cmd.Flags().StringVar(&flags.InfraID, "infra-id", "", "Clean a specific infra ID") + cmd.Flags().BoolVar(&flags.All, "all", false, "Clean all discovered orphans") + cmd.Flags().StringVar(&flags.FromFile, "from-file", "", "JSON scan output file with infra IDs to clean") + cmd.Flags().DurationVar(&flags.OlderThan, "older-than", 24*time.Hour, "Skip resources newer than this duration") + cmd.Flags().StringVar(&flags.Owner, "owner", "", "Filter by cluster-name tag prefix") + cmd.Flags().StringSliceVar(&flags.ExcludeInfraIDs, "exclude-infra-id", nil, "Infra IDs to skip") + cmd.Flags().StringSliceVar(&flags.OIDCBuckets, "oidc-buckets", nil, "S3 buckets to scan for OIDC prefixes") + cmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip interactive confirmation") + + return cmd +} + +func runClean(ctx context.Context, globals *GlobalFlags, flags *CleanFlags) error { + // Validate: must have one of --infra-id, --all, or --from-file. + if flags.InfraID == "" && !flags.All && flags.FromFile == "" { + return fmt.Errorf("one of --infra-id, --all, or --from-file is required") + } + + // Count how many target selectors are set. + selectorCount := 0 + if flags.InfraID != "" { + selectorCount++ + } + if flags.All { + selectorCount++ + } + if flags.FromFile != "" { + selectorCount++ + } + if selectorCount > 1 { + return fmt.Errorf("only one of --infra-id, --all, or --from-file may be specified") + } + + // If --from-file, load the inventory from the file. + if flags.FromFile != "" { + return runCleanFromFile(ctx, globals, flags) + } + + // Create AWS clients. + clients, err := awspkg.NewClients(ctx, globals.Regions[0]) + if err != nil { + return fmt.Errorf("initializing AWS clients: %w", err) + } + + // Print account ID prominently. + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " ⚠ AWS Account: %s\n", clients.AccountID) + fmt.Fprintf(os.Stderr, "\n") + + // Build scan options. + scanOpts := scanner.ScanOptions{ + OlderThan: flags.OlderThan, + Owner: flags.Owner, + ExcludeInfraIDs: flags.ExcludeInfraIDs, + OIDCBuckets: flags.OIDCBuckets, + } + if flags.InfraID != "" { + scanOpts.InfraID = flags.InfraID + } + + if !globals.Quiet { + fmt.Fprintf(os.Stderr, "Scanning for resources to clean...\n") + } + + // Scan. + inv, err := scanner.ScanTags(ctx, clients, globals.Regions, scanOpts) + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + // Classify. + classifier.Classify(inv, nil) + + // Determine which groups to clean. + var toClean []*scanner.InfraGroup + if flags.InfraID != "" { + // Clean a specific infra ID regardless of classification. + if g, ok := inv.Groups[flags.InfraID]; ok { + toClean = append(toClean, g) + } else { + return fmt.Errorf("infra ID %q not found in scan results", flags.InfraID) + } + } else { + // --all: clean all orphaned groups. + toClean = inv.OrphanedGroups() + } + + if len(toClean) == 0 { + fmt.Fprintf(os.Stderr, "No orphaned resources found. Nothing to clean.\n") + return nil + } + + // Display what will be cleaned. + fmt.Fprintf(os.Stderr, "\n") + rep := reporter.New("text") + if err := rep.Report(os.Stderr, buildCleanPreview(inv, toClean)); err != nil { + return fmt.Errorf("writing preview: %w", err) + } + + totalResources := 0 + for _, g := range toClean { + totalResources += len(g.Resources) + } + + fmt.Fprintf(os.Stderr, "\n Will delete %d resources across %d infra ID(s).\n\n", + totalResources, len(toClean)) + + // Show per-resource deletion plan (both dry-run and execute). + for _, g := range toClean { + fmt.Fprintf(os.Stderr, " %s (cluster: %s)\n", g.InfraID, g.ClusterName) + for i, r := range g.Resources { + prefix := " [dry-run]" + if !globals.DryRun { + prefix = fmt.Sprintf(" [%d/%d] ", i+1, totalResources) + } + if r.Name != "" { + fmt.Fprintf(os.Stderr, "%s Delete %-20s %s (%s)\n", prefix, r.Type, r.ID, r.Name) + } else { + fmt.Fprintf(os.Stderr, "%s Delete %-20s %s\n", prefix, r.Type, r.ID) + } + } + fmt.Fprintln(os.Stderr) + } + + // Dry-run: stop here. + if globals.DryRun { + fmt.Fprintf(os.Stderr, "DRY RUN: No changes made.\nRun without --dry-run to delete.\n") + return nil + } + + // Confirmation prompt. + if !flags.Yes && isTTY() { + fmt.Fprintf(os.Stderr, " Type 'yes' to proceed: ") + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + answer = strings.TrimSpace(answer) + if answer != "yes" { + fmt.Fprintf(os.Stderr, "Aborted.\n") + return nil + } + } + + // Execute deletion. + fmt.Fprintf(os.Stderr, "Cleaning...\n") + hasErrors := false + for _, g := range toClean { + if err := cleanInfraGroup(ctx, clients, g, globals); err != nil { + fmt.Fprintf(os.Stderr, "ERROR cleaning %s: %v\n", g.InfraID, err) + hasErrors = true + } + } + + if hasErrors { + fmt.Fprintf(os.Stderr, "\nCompleted with errors.\n") + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "Done. Cleaned %d infra ID(s).\n", len(toClean)) + return nil +} + +// runCleanFromFile loads a JSON scan output and cleans the orphaned groups. +func runCleanFromFile(ctx context.Context, globals *GlobalFlags, flags *CleanFlags) error { + data, err := os.ReadFile(flags.FromFile) + if err != nil { + return fmt.Errorf("reading %s: %w", flags.FromFile, err) + } + + // Parse the JSON inventory. + var jsonInv jsonScanFile + if err := json.Unmarshal(data, &jsonInv); err != nil { + return fmt.Errorf("parsing %s: %w", flags.FromFile, err) + } + + // Create AWS clients. + clients, err := awspkg.NewClients(ctx, globals.Regions[0]) + if err != nil { + return fmt.Errorf("initializing AWS clients: %w", err) + } + + // Print account ID. + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " ⚠ AWS Account: %s\n", clients.AccountID) + fmt.Fprintf(os.Stderr, " Source file: %s\n", flags.FromFile) + fmt.Fprintf(os.Stderr, "\n") + + // Verify account matches. + if jsonInv.AccountID != "" && jsonInv.AccountID != clients.AccountID { + return fmt.Errorf("account mismatch: file has %s but current credentials are for %s", + jsonInv.AccountID, clients.AccountID) + } + + // Extract orphaned infra IDs from the file. + var infraIDs []string + for _, g := range jsonInv.Groups { + cat := strings.ToUpper(g.Category) + if strings.Contains(cat, "ORPHAN") { + infraIDs = append(infraIDs, g.InfraID) + } + } + + if len(infraIDs) == 0 { + fmt.Fprintf(os.Stderr, "No orphaned infra IDs found in %s. Nothing to clean.\n", flags.FromFile) + return nil + } + + fmt.Fprintf(os.Stderr, "Found %d orphaned infra ID(s) in %s:\n", len(infraIDs), flags.FromFile) + for _, id := range infraIDs { + fmt.Fprintf(os.Stderr, " - %s\n", id) + } + fmt.Fprintln(os.Stderr) + + if globals.DryRun { + fmt.Fprintf(os.Stderr, "DRY RUN: Would re-scan and clean these infra IDs.\n") + return nil + } + + // Confirmation. + if !flags.Yes && isTTY() { + fmt.Fprintf(os.Stderr, " Type 'yes' to proceed: ") + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + if strings.TrimSpace(answer) != "yes" { + fmt.Fprintf(os.Stderr, "Aborted.\n") + return nil + } + } + + // Re-scan each infra ID and clean. + hasErrors := false + for _, infraID := range infraIDs { + fmt.Fprintf(os.Stderr, "Scanning %s...\n", infraID) + + scanOpts := scanner.ScanOptions{ + InfraID: infraID, + OlderThan: flags.OlderThan, + } + inv, err := scanner.ScanTags(ctx, clients, globals.Regions, scanOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR scanning %s: %v\n", infraID, err) + hasErrors = true + continue + } + + for _, g := range inv.Groups { + if err := cleanInfraGroup(ctx, clients, g, globals); err != nil { + fmt.Fprintf(os.Stderr, "ERROR cleaning %s: %v\n", g.InfraID, err) + hasErrors = true + } + } + } + + if hasErrors { + fmt.Fprintf(os.Stderr, "\nCompleted with errors.\n") + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "Done. Cleaned %d infra ID(s).\n", len(infraIDs)) + return nil +} + +// cleanInfraGroup deletes all resources in an infra group. +// TODO: Implement actual deletion via cleaner package. For now, this logs +// what it would do. +func cleanInfraGroup(_ context.Context, _ *awspkg.Clients, g *scanner.InfraGroup, globals *GlobalFlags) error { + for _, r := range g.Resources { + if globals.Verbose { + fmt.Fprintf(os.Stderr, " [DELETE] %s %s (%s) in %s\n", r.Type, r.ID, r.Name, r.Region) + } else { + fmt.Fprintf(os.Stderr, " [DELETE] %s %s in %s\n", r.Type, r.ID, r.Region) + } + } + // TODO: Call cleaner package to actually delete resources. + // The cleaner should handle deletion ordering (e.g., instances before VPCs, + // NAT gateways before subnets, etc.) and retry on dependency errors. + fmt.Fprintf(os.Stderr, " → %s: %d resources (deletion not yet implemented)\n", g.InfraID, len(g.Resources)) + return nil +} + +// buildCleanPreview creates a preview inventory containing only the groups to be cleaned. +func buildCleanPreview(origInv *scanner.Inventory, groups []*scanner.InfraGroup) *scanner.Inventory { + preview := scanner.NewInventory(origInv.AccountID, origInv.Regions, origInv.ScanMode) + preview.ScannedAt = origInv.ScannedAt + for _, g := range groups { + preview.Groups[g.InfraID] = g + } + return preview +} + +// isTTY returns true if stdin is a terminal (not a pipe/redirect). +func isTTY() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// jsonScanFile is a minimal struct to parse the JSON output from "scan -o json". +type jsonScanFile struct { + AccountID string `json:"account_id"` + Groups []jsonScanGroup `json:"groups"` +} + +type jsonScanGroup struct { + InfraID string `json:"infra_id"` + Category string `json:"category"` +} diff --git a/contrib/hcp-infra/cmd/root.go b/contrib/hcp-infra/cmd/root.go new file mode 100644 index 000000000000..1155a00b45e6 --- /dev/null +++ b/contrib/hcp-infra/cmd/root.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "os" + "strings" + + "github.com/spf13/cobra" +) + +// GlobalFlags holds flags shared across all subcommands. +type GlobalFlags struct { + Regions []string + Kubeconfig string + Output string + DryRun bool + Verbose bool + Quiet bool +} + +// NewRootCommand creates the root cobra command. +func NewRootCommand(version string) *cobra.Command { + flags := &GlobalFlags{} + + cmd := &cobra.Command{ + Use: "hcp-infra", + Short: "HyperShift infrastructure management", + Long: "Scan, audit, and clean orphaned AWS infrastructure from HyperShift test clusters.", + SilenceUsage: true, + } + + // Global flags + cmd.PersistentFlags().StringSliceVar(&flags.Regions, "region", defaultRegions(), "AWS regions to scan") + cmd.PersistentFlags().StringVar(&flags.Kubeconfig, "kubeconfig", "", "Management cluster kubeconfig (for HC cross-reference)") + cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "text", "Output format: text, json, markdown") + cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "Preview actions without making changes") + cmd.PersistentFlags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Verbose output") + cmd.PersistentFlags().BoolVarP(&flags.Quiet, "quiet", "q", false, "Quiet output (summary only)") + + // Add subcommands + cmd.AddCommand(NewScanCommand(flags)) + cmd.AddCommand(NewCleanCommand(flags)) + cmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print version", + Run: func(c *cobra.Command, args []string) { + c.Println("hcp-infra version", version) + }, + }) + + return cmd +} + +func defaultRegions() []string { + if env := os.Getenv("HCP_INFRA_REGIONS"); env != "" { + return strings.Split(env, ",") + } + return []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "eu-west-1"} +} diff --git a/contrib/hcp-infra/cmd/scan.go b/contrib/hcp-infra/cmd/scan.go new file mode 100644 index 000000000000..3e6382361f1f --- /dev/null +++ b/contrib/hcp-infra/cmd/scan.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// NewScanCommand creates the scan parent command with subcommands for different +// scan modes: tags, deep, and detect. +func NewScanCommand(globals *GlobalFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "scan", + Short: "Discover and report on HyperShift AWS infrastructure", + Long: `Scan AWS regions for HyperShift-related infrastructure resources. + +Three scan modes are available: + tags - Fast tag-based discovery via Resource Groups Tagging API + deep - Tag scan + supplemental API calls for untagged resources + detect - Cross-reference with management cluster HostedClusters`, + } + + cmd.AddCommand(NewScanTagsCommand(globals)) + cmd.AddCommand(NewScanDeepCommand(globals)) + cmd.AddCommand(NewScanDetectCommand(globals)) + + return cmd +} diff --git a/contrib/hcp-infra/cmd/scan_deep.go b/contrib/hcp-infra/cmd/scan_deep.go new file mode 100644 index 000000000000..d48bbef0dfbe --- /dev/null +++ b/contrib/hcp-infra/cmd/scan_deep.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + awspkg "github.com/openshift/hypershift/contrib/hcp-infra/pkg/aws" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/classifier" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/reporter" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// NewScanDeepCommand creates the "scan deep" subcommand. +func NewScanDeepCommand(globals *GlobalFlags) *cobra.Command { + scanFlags := &ScanFlags{} + + cmd := &cobra.Command{ + Use: "deep", + Short: "Deep scan with supplemental API calls for untagged resources", + Long: `Perform a tag-based scan plus supplemental API calls to find resources +that may not be tagged with the HyperShift infra-id tag. + +This mode is slower but more thorough. It discovers orphaned resources +like detached EBS volumes, unused security groups, and NAT gateways +that lost their tags during partial cleanup. + +NOTE: Deep scan is not yet fully implemented. Currently falls back to +the tag-based scan with a warning.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runScanDeep(cmd.Context(), globals, scanFlags) + }, + } + + addScanFlags(cmd, scanFlags) + return cmd +} + +func runScanDeep(ctx context.Context, globals *GlobalFlags, scanFlags *ScanFlags) error { + // Create AWS clients. + clients, err := awspkg.NewClients(ctx, globals.Regions[0]) + if err != nil { + return fmt.Errorf("initializing AWS clients: %w", err) + } + + if !globals.Quiet { + fmt.Fprintf(os.Stderr, "Deep scan of AWS account %s across %d region(s)...\n", clients.AccountID, len(globals.Regions)) + fmt.Fprintf(os.Stderr, "NOTE: Deep scan is not yet fully implemented, falling back to tag-based scan.\n") + } + + // TODO: Implement deep scan. For now, fall back to tag-based scan. + inv, err := scanner.ScanTags(ctx, clients, globals.Regions, scanFlags.toScanOptions()) + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + // Override scan mode in the inventory to reflect intent. + inv.ScanMode = "deep" + + // Classify groups (no management cluster cross-reference). + classifier.Classify(inv, nil) + + // Output via reporter. + rep := reporter.New(globals.Output) + if err := rep.Report(os.Stdout, inv); err != nil { + return fmt.Errorf("writing report: %w", err) + } + + // Exit code 1 if orphans found. + if len(inv.OrphanedGroups()) > 0 { + os.Exit(1) + } + + return nil +} diff --git a/contrib/hcp-infra/cmd/scan_detect.go b/contrib/hcp-infra/cmd/scan_detect.go new file mode 100644 index 000000000000..4129054a7426 --- /dev/null +++ b/contrib/hcp-infra/cmd/scan_detect.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + awspkg "github.com/openshift/hypershift/contrib/hcp-infra/pkg/aws" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/classifier" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/reporter" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// NewScanDetectCommand creates the "scan detect" subcommand. This mode +// cross-references AWS resources with the management cluster's HostedClusters +// to determine which infrastructure is genuinely orphaned vs still in use. +func NewScanDetectCommand(globals *GlobalFlags) *cobra.Command { + scanFlags := &ScanFlags{} + + cmd := &cobra.Command{ + Use: "detect", + Short: "Detect orphans by cross-referencing with management cluster", + Long: `Perform a tag-based scan and cross-reference discovered infrastructure +with HostedCluster objects on the management cluster. + +This is the most accurate scan mode: it can distinguish between +infrastructure that looks orphaned (no VPC) and infrastructure that is +still actively referenced by a HostedCluster. + +Requires --kubeconfig pointing to the management cluster.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runScanDetect(cmd.Context(), globals, scanFlags) + }, + } + + addScanFlags(cmd, scanFlags) + return cmd +} + +func runScanDetect(ctx context.Context, globals *GlobalFlags, scanFlags *ScanFlags) error { + if globals.Kubeconfig == "" { + return fmt.Errorf("--kubeconfig is required for detect mode (point to management cluster)") + } + + // Create AWS clients. + clients, err := awspkg.NewClients(ctx, globals.Regions[0]) + if err != nil { + return fmt.Errorf("initializing AWS clients: %w", err) + } + + if !globals.Quiet { + fmt.Fprintf(os.Stderr, "Detect scan of AWS account %s across %d region(s)...\n", clients.AccountID, len(globals.Regions)) + fmt.Fprintf(os.Stderr, "Using kubeconfig: %s\n", globals.Kubeconfig) + } + + // Run the tag-based scan. + inv, err := scanner.ScanTags(ctx, clients, globals.Regions, scanFlags.toScanOptions()) + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + // Override scan mode to reflect detect mode. + inv.ScanMode = "detect" + + // TODO: Query the management cluster for active HostedClusters. + // This would use the kubeconfig to list HCs, extract their infra IDs, + // and pass them to the classifier. For now, we pass nil. + // + // activeHCInfraIDs, err := listActiveHostedClusters(ctx, globals.Kubeconfig) + // if err != nil { + // return fmt.Errorf("listing HostedClusters: %w", err) + // } + var activeHCInfraIDs map[string]string + + if !globals.Quiet { + fmt.Fprintf(os.Stderr, "NOTE: Management cluster cross-reference not yet implemented. Classification based on tags only.\n") + } + + // Classify groups with management cluster cross-reference. + classifier.Classify(inv, activeHCInfraIDs) + + // Output via reporter. + rep := reporter.New(globals.Output) + if err := rep.Report(os.Stdout, inv); err != nil { + return fmt.Errorf("writing report: %w", err) + } + + // Exit code 1 if orphans found. + if len(inv.OrphanedGroups()) > 0 { + os.Exit(1) + } + + return nil +} diff --git a/contrib/hcp-infra/cmd/scan_tags.go b/contrib/hcp-infra/cmd/scan_tags.go new file mode 100644 index 000000000000..1fb93d77ee19 --- /dev/null +++ b/contrib/hcp-infra/cmd/scan_tags.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + awspkg "github.com/openshift/hypershift/contrib/hcp-infra/pkg/aws" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/classifier" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/reporter" + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// ScanFlags holds flags shared across all scan subcommands. +type ScanFlags struct { + OlderThan time.Duration + Owner string + InfraID string + ExcludeInfraIDs []string + OIDCBuckets []string +} + +// addScanFlags registers the shared scan flags on a cobra command. +func addScanFlags(cmd *cobra.Command, flags *ScanFlags) { + cmd.Flags().DurationVar(&flags.OlderThan, "older-than", 24*time.Hour, "Skip resources newer than this duration") + cmd.Flags().StringVar(&flags.Owner, "owner", "", "Filter by cluster-name tag prefix (e.g. 'ci-' or 'jdoe-')") + cmd.Flags().StringVar(&flags.InfraID, "infra-id", "", "Scan a specific infra ID only") + cmd.Flags().StringSliceVar(&flags.ExcludeInfraIDs, "exclude-infra-id", nil, "Infra IDs to skip") + cmd.Flags().StringSliceVar(&flags.OIDCBuckets, "oidc-buckets", nil, "S3 buckets to scan for OIDC prefixes") +} + +// toScanOptions converts ScanFlags to scanner.ScanOptions. +func (sf *ScanFlags) toScanOptions() scanner.ScanOptions { + return scanner.ScanOptions{ + InfraID: sf.InfraID, + Owner: sf.Owner, + OlderThan: sf.OlderThan, + ExcludeInfraIDs: sf.ExcludeInfraIDs, + OIDCBuckets: sf.OIDCBuckets, + } +} + +// NewScanTagsCommand creates the "scan tags" subcommand for fast tag-based scanning. +func NewScanTagsCommand(globals *GlobalFlags) *cobra.Command { + scanFlags := &ScanFlags{} + + cmd := &cobra.Command{ + Use: "tags", + Short: "Tag-based scan via Resource Groups Tagging API", + Long: `Discover HyperShift infrastructure by querying the AWS Resource Groups +Tagging API for resources tagged with hypershift.openshift.io/infra-id. + +This is the fastest scan mode and should be the default for most use cases.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runScanTags(cmd.Context(), globals, scanFlags) + }, + } + + addScanFlags(cmd, scanFlags) + return cmd +} + +func runScanTags(ctx context.Context, globals *GlobalFlags, scanFlags *ScanFlags) error { + // Create AWS clients. + clients, err := awspkg.NewClients(ctx, globals.Regions[0]) + if err != nil { + return fmt.Errorf("initializing AWS clients: %w", err) + } + + if !globals.Quiet { + fmt.Fprintf(os.Stderr, "Scanning AWS account %s across %d region(s)...\n", clients.AccountID, len(globals.Regions)) + } + + // Run the tag-based scan. + inv, err := scanner.ScanTags(ctx, clients, globals.Regions, scanFlags.toScanOptions()) + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + // Classify groups (no management cluster cross-reference in tags mode). + classifier.Classify(inv, nil) + + // Output via reporter. + rep := reporter.New(globals.Output) + if err := rep.Report(os.Stdout, inv); err != nil { + return fmt.Errorf("writing report: %w", err) + } + + // Exit code 1 if orphans found. + if len(inv.OrphanedGroups()) > 0 { + os.Exit(1) + } + + return nil +} diff --git a/contrib/hcp-infra/go.mod b/contrib/hcp-infra/go.mod new file mode 100644 index 000000000000..65ba17980346 --- /dev/null +++ b/contrib/hcp-infra/go.mod @@ -0,0 +1,37 @@ +module github.com/openshift/hypershift/contrib/hcp-infra + +go 1.24.4 + +require ( + github.com/aws/aws-sdk-go-v2 v1.30.3 + github.com/aws/aws-sdk-go-v2/config v1.27.11 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0 + github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.24.4 + github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.34.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.32.0 + github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.21.4 + github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 + github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect + github.com/aws/smithy-go v1.20.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) diff --git a/contrib/hcp-infra/go.sum b/contrib/hcp-infra/go.sum new file mode 100644 index 000000000000..e759f2282fae --- /dev/null +++ b/contrib/hcp-infra/go.sum @@ -0,0 +1,70 @@ +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= +github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0 h1:ta62lid9JkIpKZtZZXSj6rP2AqY5x1qYGq53ffxqD9Q= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0/go.mod h1:o6QDjdVKpP5EF0dp/VlvqckzuSDATr1rLdHt3A5m0YY= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.24.4 h1:V5YvSMQwZklktzYeOOhYdptx7rP650XP3RnxwNu1UEQ= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.24.4/go.mod h1:aYygRYqRxmLGrxRxAisgNarwo4x8bcJG14rh4r57VqE= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.34.0 h1:8rDRtPOu3ax8jEctw7G926JQlnFdhZZA4KJzQ+4ks3Q= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.34.0/go.mod h1:L5bVuO4PeXuDuMYZfL3IW69E6mz6PDCYpp6IKDlcLMA= +github.com/aws/aws-sdk-go-v2/service/iam v1.32.0 h1:ZNlfPdw849gBo/lvLFbEEvpTJMij0LXqiNWZ+lIamlU= +github.com/aws/aws-sdk-go-v2/service/iam v1.32.0/go.mod h1:aXWImQV0uTW35LM0A/T4wEg6R1/ReXUu4SM6/lUHYK0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.21.4 h1:c1jtPWZSmgMmPkCgwv67GE0ugdEgnLVo/BHR1wl3Dm0= +github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.21.4/go.mod h1:FWw+Jnx+SlpsrU/NQ/f7f+1RdixTApZiU2o9FOubiDQ= +github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 h1:MmLCRqP4U4Cw9gJ4bNrCG0mWqEtBlmAVleyelcHARMU= +github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3/go.mod h1:AMPjK2YnRh0YgOID3PqhJA1BRNfXDfGOnSsKHtAe8yA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3 h1:hT8ZAZRIfqBqHbzKTII+CIiY8G2oC9OpLedkZ51DWl8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/contrib/hcp-infra/main.go b/contrib/hcp-infra/main.go new file mode 100644 index 000000000000..5b689bbac747 --- /dev/null +++ b/contrib/hcp-infra/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "os" + + "github.com/openshift/hypershift/contrib/hcp-infra/cmd" +) + +var version = "dev" + +func main() { + rootCmd := cmd.NewRootCommand(version) + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(2) + } +} diff --git a/contrib/hcp-infra/manifests/01-serviceaccount.yaml b/contrib/hcp-infra/manifests/01-serviceaccount.yaml new file mode 100644 index 000000000000..932982225fbf --- /dev/null +++ b/contrib/hcp-infra/manifests/01-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: hcp-infra + namespace: hypershift diff --git a/contrib/hcp-infra/manifests/02-cronjob.yaml b/contrib/hcp-infra/manifests/02-cronjob.yaml new file mode 100644 index 000000000000..a57d06a72e0b --- /dev/null +++ b/contrib/hcp-infra/manifests/02-cronjob.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: hcp-infra-clean + namespace: hypershift +spec: + schedule: "0 0 * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: hcp-infra + image: quay.io/hypershift/hcp-infra:latest + args: + - clean + - --all + - --older-than=48h + - --yes + env: + - name: AWS_REGION + value: "us-east-1" + - name: AWS_ROLE_ARN + value: "arn:aws:iam::820196288204:role/hypershift-ci-2-hcp-infra" + - name: AWS_WEB_IDENTITY_TOKEN_FILE + value: "/var/run/secrets/openshift/token" + imagePullPolicy: Always + volumeMounts: + - name: token + mountPath: /var/run/secrets/openshift + readOnly: true + restartPolicy: Never + serviceAccountName: hcp-infra + volumes: + - name: token + projected: + sources: + - serviceAccountToken: + path: token + audience: openshift diff --git a/contrib/hcp-infra/pkg/aws/clients.go b/contrib/hcp-infra/pkg/aws/clients.go new file mode 100644 index 000000000000..9960d5b9d94c --- /dev/null +++ b/contrib/hcp-infra/pkg/aws/clients.go @@ -0,0 +1,92 @@ +package aws + +import ( + "context" + "fmt" + + "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/elasticloadbalancing" + "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +// Clients provides access to AWS service clients. +type Clients struct { + Cfg aws.Config + AccountID string +} + +// NewClients creates a new Clients from the default AWS config. +func NewClients(ctx context.Context, region string) (*Clients, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + + stsClient := sts.NewFromConfig(cfg) + identity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nil, fmt.Errorf("getting caller identity: %w", err) + } + + return &Clients{ + Cfg: cfg, + AccountID: aws.ToString(identity.Account), + }, nil +} + +// EC2 returns an EC2 client for the given region. +func (c *Clients) EC2(region string) *ec2.Client { + cfg := c.Cfg.Copy() + cfg.Region = region + return ec2.NewFromConfig(cfg) +} + +// IAM returns an IAM client. +func (c *Clients) IAM() *iam.Client { + return iam.NewFromConfig(c.Cfg) +} + +// Route53 returns a Route53 client. +func (c *Clients) Route53() *route53.Client { + return route53.NewFromConfig(c.Cfg) +} + +// S3 returns an S3 client for the given region. +func (c *Clients) S3(region string) *s3.Client { + cfg := c.Cfg.Copy() + cfg.Region = region + return s3.NewFromConfig(cfg) +} + +// Tagging returns a Resource Groups Tagging API client for the given region. +func (c *Clients) Tagging(region string) *resourcegroupstaggingapi.Client { + cfg := c.Cfg.Copy() + cfg.Region = region + return resourcegroupstaggingapi.NewFromConfig(cfg) +} + +// ELB returns an ELB (Classic) client for the given region. +func (c *Clients) ELB(region string) *elasticloadbalancing.Client { + cfg := c.Cfg.Copy() + cfg.Region = region + return elasticloadbalancing.NewFromConfig(cfg) +} + +// ELBv2 returns an ELBv2 (ALB/NLB) client for the given region. +func (c *Clients) ELBv2(region string) *elasticloadbalancingv2.Client { + cfg := c.Cfg.Copy() + cfg.Region = region + return elasticloadbalancingv2.NewFromConfig(cfg) +} + +// STS returns an STS client. +func (c *Clients) STS() *sts.Client { + return sts.NewFromConfig(c.Cfg) +} diff --git a/contrib/hcp-infra/pkg/classifier/classifier.go b/contrib/hcp-infra/pkg/classifier/classifier.go new file mode 100644 index 000000000000..f289273a5030 --- /dev/null +++ b/contrib/hcp-infra/pkg/classifier/classifier.go @@ -0,0 +1,131 @@ +package classifier + +import ( + "strings" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// Classify categorizes each InfraGroup in the inventory as ACTIVE, +// CONFIRMED ORPHAN, PROBABLE ORPHAN, or UNKNOWN ORIGIN. +// +// activeHCInfraIDs maps infra-id -> HostedCluster name for clusters that +// currently exist on the management cluster. Pass nil if no management +// cluster kubeconfig is available (classification proceeds without it). +func Classify(inv *scanner.Inventory, activeHCInfraIDs map[string]string) { + for _, g := range inv.Groups { + // Set owner to cluster name; the --owner flag does prefix matching + // on ClusterName at the scan level, so this is the correct value. + g.Owner = g.ClusterName + + // 1. If the management cluster knows about this infra ID, it's active. + if activeHCInfraIDs != nil { + if hcName, ok := activeHCInfraIDs[g.InfraID]; ok { + g.Category = scanner.CategoryActive + g.HasHC = true + if g.ClusterName == "" { + g.ClusterName = hcName + g.Owner = hcName + } + continue + } + } + + // 2. If a VPC still exists in this group, consider it active. + // VPCs are the last thing destroyed, so their presence implies the + // cluster is still being used or destruction is incomplete. + if g.HasVPC { + g.Category = scanner.CategoryActive + continue + } + + // 3-5. No VPC and not on management cluster → orphan classification. + // Check whether all resources carry the infra-id tag (which they + // should, since we found them via the Tagging API). + allTagged := true + for _, r := range g.Resources { + if r.Tags == nil { + allTagged = false + break + } + if _, ok := r.Tags[scanner.TagInfraID]; !ok { + allTagged = false + break + } + } + + if allTagged { + // All resources consistently tagged → confirmed orphan. + g.Category = scanner.CategoryConfirmedOrphan + } else { + // Some resources lack the tag → probable orphan (may have + // been partially cleaned up or tagged inconsistently). + g.Category = scanner.CategoryProbableOrphan + } + } + + // Classify ungrouped resources as UNKNOWN ORIGIN. + for i := range inv.Ungrouped { + inv.Ungrouped[i].InfraID = "" + } +} + +// ClassifySummary holds aggregate statistics from classification. +type ClassifySummary struct { + Active int + ConfirmedOrphans int + ProbableOrphans int + Unknown int + TotalResources int + OrphanCost float64 +} + +// Summarize returns aggregate counts and costs from a classified inventory. +func Summarize(inv *scanner.Inventory) ClassifySummary { + var s ClassifySummary + + for _, g := range inv.Groups { + s.TotalResources += len(g.Resources) + + switch g.Category { + case scanner.CategoryActive: + s.Active++ + case scanner.CategoryConfirmedOrphan: + s.ConfirmedOrphans++ + s.OrphanCost += g.MonthlyCost + case scanner.CategoryProbableOrphan: + s.ProbableOrphans++ + s.OrphanCost += g.MonthlyCost + case scanner.CategoryUnknown: + s.Unknown++ + } + } + + s.TotalResources += len(inv.Ungrouped) + s.Unknown += len(inv.Ungrouped) + + return s +} + +// FilterByOwner returns a new inventory containing only groups whose +// ClusterName has the given prefix. This is used by the --owner flag. +func FilterByOwner(inv *scanner.Inventory, ownerPrefix string) *scanner.Inventory { + if ownerPrefix == "" { + return inv + } + + filtered := scanner.NewInventory(inv.AccountID, inv.Regions, inv.ScanMode) + filtered.ScannedAt = inv.ScannedAt + + for id, g := range inv.Groups { + if strings.HasPrefix(g.ClusterName, ownerPrefix) || strings.HasPrefix(g.Owner, ownerPrefix) { + filtered.Groups[id] = g + } + } + + // Ungrouped resources can't be filtered by owner since they have no + // cluster association. + filtered.Ungrouped = inv.Ungrouped + + return filtered +} diff --git a/contrib/hcp-infra/pkg/reporter/json.go b/contrib/hcp-infra/pkg/reporter/json.go new file mode 100644 index 000000000000..bf517b698721 --- /dev/null +++ b/contrib/hcp-infra/pkg/reporter/json.go @@ -0,0 +1,108 @@ +package reporter + +import ( + "encoding/json" + "io" + "time" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// JSONReporter formats inventory output as JSON. +type JSONReporter struct{} + +// jsonInventory mirrors scanner.Inventory with json tags. +type jsonInventory struct { + AccountID string `json:"account_id"` + Regions []string `json:"regions"` + ScannedAt time.Time `json:"scanned_at"` + ScanMode string `json:"scan_mode"` + Groups []*jsonInfraGroup `json:"groups"` + Ungrouped []*jsonResource `json:"ungrouped,omitempty"` +} + +// jsonInfraGroup mirrors scanner.InfraGroup with json tags. +type jsonInfraGroup struct { + InfraID string `json:"infra_id"` + ClusterName string `json:"cluster_name,omitempty"` + Owner string `json:"owner,omitempty"` + Category string `json:"category"` + Resources []*jsonResource `json:"resources"` + CreatedAt time.Time `json:"created_at"` + MonthlyCost float64 `json:"monthly_cost"` + HasVPC bool `json:"has_vpc"` + HasHC bool `json:"has_hc"` +} + +// jsonResource mirrors scanner.Resource with json tags. +type jsonResource struct { + Type string `json:"type"` + ID string `json:"id"` + ARN string `json:"arn,omitempty"` + Name string `json:"name,omitempty"` + Region string `json:"region"` + InfraID string `json:"infra_id"` + ClusterName string `json:"cluster_name,omitempty"` + CreatedAt time.Time `json:"created_at"` + Tags map[string]string `json:"tags,omitempty"` + MonthlyCost float64 `json:"monthly_cost"` + Details string `json:"details,omitempty"` +} + +// Report writes JSON-formatted output to w. +func (j *JSONReporter) Report(w io.Writer, inv *scanner.Inventory) error { + out := toJSONInventory(inv) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func toJSONInventory(inv *scanner.Inventory) *jsonInventory { + out := &jsonInventory{ + AccountID: inv.AccountID, + Regions: inv.Regions, + ScannedAt: inv.ScannedAt, + ScanMode: inv.ScanMode, + Groups: make([]*jsonInfraGroup, 0, len(inv.Groups)), + } + + for _, g := range inv.Groups { + jg := &jsonInfraGroup{ + InfraID: g.InfraID, + ClusterName: g.ClusterName, + Owner: g.Owner, + Category: string(g.Category), + Resources: make([]*jsonResource, 0, len(g.Resources)), + CreatedAt: g.CreatedAt, + MonthlyCost: g.MonthlyCost, + HasVPC: g.HasVPC, + HasHC: g.HasHC, + } + for i := range g.Resources { + jg.Resources = append(jg.Resources, toJSONResource(&g.Resources[i])) + } + out.Groups = append(out.Groups, jg) + } + + for i := range inv.Ungrouped { + out.Ungrouped = append(out.Ungrouped, toJSONResource(&inv.Ungrouped[i])) + } + + return out +} + +func toJSONResource(r *scanner.Resource) *jsonResource { + return &jsonResource{ + Type: string(r.Type), + ID: r.ID, + ARN: r.ARN, + Name: r.Name, + Region: r.Region, + InfraID: r.InfraID, + ClusterName: r.ClusterName, + CreatedAt: r.CreatedAt, + Tags: r.Tags, + MonthlyCost: r.MonthlyCost, + Details: r.Details, + } +} diff --git a/contrib/hcp-infra/pkg/reporter/markdown.go b/contrib/hcp-infra/pkg/reporter/markdown.go new file mode 100644 index 000000000000..66e1c07513c4 --- /dev/null +++ b/contrib/hcp-infra/pkg/reporter/markdown.go @@ -0,0 +1,145 @@ +package reporter + +import ( + "fmt" + "io" + "strings" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// MarkdownReporter formats inventory output as Markdown. +type MarkdownReporter struct{} + +// Report writes a Markdown-formatted report to w. +func (m *MarkdownReporter) Report(w io.Writer, inv *scanner.Inventory) error { + // Header + fmt.Fprintf(w, "# HyperShift Infrastructure Report\n\n") + fmt.Fprintf(w, "| Field | Value |\n") + fmt.Fprintf(w, "|-------|-------|\n") + fmt.Fprintf(w, "| Account | %s |\n", inv.AccountID) + fmt.Fprintf(w, "| Regions | %s |\n", strings.Join(inv.Regions, ", ")) + fmt.Fprintf(w, "| Date | %s |\n", inv.ScannedAt.Format("2006-01-02 15:04 MST")) + fmt.Fprintf(w, "| Mode | %s |\n\n", inv.ScanMode) + + if inv.ScanMode == "detect" { + return m.reportDetectMode(w, inv) + } + return m.reportStandardMode(w, inv) +} + +func (m *MarkdownReporter) reportStandardMode(w io.Writer, inv *scanner.Inventory) error { + orphaned := inv.OrphanedGroups() + active := inv.ActiveGroups() + + var other []*scanner.InfraGroup + for _, g := range inv.Groups { + if g.Category != scanner.CategoryActive && + g.Category != scanner.CategoryConfirmedOrphan && + g.Category != scanner.CategoryProbableOrphan { + other = append(other, g) + } + } + + sortGroupsByAge(orphaned) + sortGroupsByAge(active) + sortGroupsByAge(other) + + if len(orphaned) > 0 { + printMarkdownSection(w, "ORPHANED", orphaned) + } + if len(active) > 0 { + printMarkdownSection(w, "ACTIVE", active) + } + if len(other) > 0 { + printMarkdownSection(w, "OTHER", other) + } + + printMarkdownSummary(w, inv, orphaned, active) + return nil +} + +func (m *MarkdownReporter) reportDetectMode(w io.Writer, inv *scanner.Inventory) error { + var confirmed, probable, devOwned, active, unknown []*scanner.InfraGroup + for _, g := range inv.Groups { + switch g.Category { + case scanner.CategoryConfirmedOrphan: + confirmed = append(confirmed, g) + case scanner.CategoryProbableOrphan: + probable = append(probable, g) + case scanner.CategoryDeveloperOwned: + devOwned = append(devOwned, g) + case scanner.CategoryActive: + active = append(active, g) + default: + unknown = append(unknown, g) + } + } + + sortGroupsByAge(confirmed) + sortGroupsByAge(probable) + sortGroupsByAge(devOwned) + sortGroupsByAge(active) + sortGroupsByAge(unknown) + + if len(confirmed) > 0 { + printMarkdownSection(w, "CONFIRMED ORPHANS", confirmed) + } + if len(probable) > 0 { + printMarkdownSection(w, "PROBABLE ORPHANS", probable) + } + if len(devOwned) > 0 { + printMarkdownSection(w, "DEVELOPER-OWNED", devOwned) + } + if len(active) > 0 { + printMarkdownSection(w, "ACTIVE", active) + } + if len(unknown) > 0 { + printMarkdownSection(w, "UNKNOWN ORIGIN", unknown) + } + + orphaned := append(confirmed, probable...) + printMarkdownSummary(w, inv, orphaned, active) + return nil +} + +func printMarkdownSection(w io.Writer, title string, groups []*scanner.InfraGroup) { + fmt.Fprintf(w, "## %s (%d)\n\n", title, len(groups)) + fmt.Fprintf(w, "| Infra ID | Cluster Name | Age | Resources | Cost/mo |\n") + fmt.Fprintf(w, "|----------|--------------|-----|-----------|--------|\n") + + for _, g := range groups { + name := displayName(g) + fmt.Fprintf(w, "| %s | %s | %s | %d | %s |\n", + g.InfraID, + name, + formatAge(g.CreatedAt), + len(g.Resources), + formatCost(g.MonthlyCost), + ) + } + fmt.Fprintln(w) +} + +func printMarkdownSummary(w io.Writer, inv *scanner.Inventory, orphaned, active []*scanner.InfraGroup) { + totalGroups := len(inv.Groups) + totalResources := 0 + totalCost := 0.0 + orphanCost := 0.0 + + for _, g := range inv.Groups { + totalResources += len(g.Resources) + totalCost += g.MonthlyCost + } + for _, g := range orphaned { + orphanCost += g.MonthlyCost + } + + fmt.Fprintf(w, "---\n\n") + fmt.Fprintf(w, "**Summary:** %d clusters (%d orphaned, %d active), %d resources, ~$%.0f/mo total (~$%.0f/mo orphaned)\n\n", + totalGroups, len(orphaned), len(active), totalResources, totalCost, orphanCost) + + if len(orphaned) > 0 { + fmt.Fprintf(w, "```\nhcp-infra clean --all\nhcp-infra clean --infra-id \n```\n") + } +} diff --git a/contrib/hcp-infra/pkg/reporter/reporter.go b/contrib/hcp-infra/pkg/reporter/reporter.go new file mode 100644 index 000000000000..88657cf2da4e --- /dev/null +++ b/contrib/hcp-infra/pkg/reporter/reporter.go @@ -0,0 +1,25 @@ +package reporter + +import ( + "io" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// Reporter formats and writes an Inventory to the given writer. +type Reporter interface { + Report(w io.Writer, inv *scanner.Inventory) error +} + +// New returns a Reporter for the given format string. +// Supported formats: "text" (default), "json", "markdown". +func New(format string) Reporter { + switch format { + case "json": + return &JSONReporter{} + case "markdown": + return &MarkdownReporter{} + default: + return &TextReporter{} + } +} diff --git a/contrib/hcp-infra/pkg/reporter/reporter_test.go b/contrib/hcp-infra/pkg/reporter/reporter_test.go new file mode 100644 index 000000000000..8909f60786bb --- /dev/null +++ b/contrib/hcp-infra/pkg/reporter/reporter_test.go @@ -0,0 +1,202 @@ +package reporter + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +func buildTestInventory() *scanner.Inventory { + inv := scanner.NewInventory("123456789012", []string{"us-east-1", "us-west-2"}, "tags") + inv.ScannedAt = time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + + // Add orphaned group + inv.AddResource(scanner.Resource{ + Type: scanner.ResNATGateway, + ID: "nat-0abc123", + Region: "us-east-1", + InfraID: "test-infra-abc", + ClusterName: "ci-test-cluster", + CreatedAt: time.Now().Add(-16 * 24 * time.Hour), + MonthlyCost: 32.0, + }) + inv.AddResource(scanner.Resource{ + Type: scanner.ResElasticIP, + ID: "eipalloc-0def456", + Region: "us-east-1", + InfraID: "test-infra-abc", + CreatedAt: time.Now().Add(-16 * 24 * time.Hour), + MonthlyCost: 3.65, + }) + + // Add active group (with VPC) + inv.AddResource(scanner.Resource{ + Type: scanner.ResVPC, + ID: "vpc-0ghi789", + Region: "us-west-2", + InfraID: "prod-infra-xyz", + ClusterName: "prod-cluster", + CreatedAt: time.Now().Add(-3 * 24 * time.Hour), + }) + + // Set categories + for _, g := range inv.Groups { + if g.InfraID == "test-infra-abc" { + g.Category = scanner.CategoryConfirmedOrphan + } else { + g.Category = scanner.CategoryActive + } + } + + return inv +} + +func TestTextReporter(t *testing.T) { + inv := buildTestInventory() + var buf bytes.Buffer + rep := &TextReporter{} + + err := rep.Report(&buf, inv) + if err != nil { + t.Fatalf("Report() error: %v", err) + } + + output := buf.String() + + // Check key elements + checks := []string{ + "HyperShift Infrastructure Report", + "123456789012", + "ORPHANED", + "ACTIVE", + "test-infra-abc", + "ci-test-cluster", + "prod-infra-xyz", + "16d", + "~$36", + "hcp-infra clean", + "Summary:", + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Errorf("output missing expected string %q", check) + } + } +} + +func TestJSONReporter(t *testing.T) { + inv := buildTestInventory() + var buf bytes.Buffer + rep := &JSONReporter{} + + err := rep.Report(&buf, inv) + if err != nil { + t.Fatalf("Report() error: %v", err) + } + + output := buf.String() + + checks := []string{ + `"account_id"`, + `"123456789012"`, + `"infra_id"`, + `"test-infra-abc"`, + `"CONFIRMED ORPHAN"`, + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Errorf("JSON output missing expected string %q", check) + } + } +} + +func TestMarkdownReporter(t *testing.T) { + inv := buildTestInventory() + var buf bytes.Buffer + rep := &MarkdownReporter{} + + err := rep.Report(&buf, inv) + if err != nil { + t.Fatalf("Report() error: %v", err) + } + + output := buf.String() + + checks := []string{ + "# HyperShift Infrastructure Report", + "| Infra ID |", + "test-infra-abc", + "## ORPHANED", + "## ACTIVE", + "**Summary:**", + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Errorf("Markdown output missing expected string %q", check) + } + } +} + +func TestNewReporterFactory(t *testing.T) { + tests := []struct { + format string + expected string + }{ + {"text", "*reporter.TextReporter"}, + {"json", "*reporter.JSONReporter"}, + {"markdown", "*reporter.MarkdownReporter"}, + {"", "*reporter.TextReporter"}, + {"unknown", "*reporter.TextReporter"}, + } + + for _, tt := range tests { + r := New(tt.format) + if r == nil { + t.Errorf("New(%q) returned nil", tt.format) + } + } +} + +func TestDetectModeReport(t *testing.T) { + inv := buildTestInventory() + inv.ScanMode = "detect" + + // Add more category types + inv.AddResource(scanner.Resource{ + Type: scanner.ResIAMRole, + ID: "role-devtest", + Region: "global", + InfraID: "dev-infra-001", + ClusterName: "dev-test", + CreatedAt: time.Now().Add(-74 * 24 * time.Hour), + }) + inv.Groups["dev-infra-001"].Category = scanner.CategoryDeveloperOwned + + var buf bytes.Buffer + rep := &TextReporter{} + + err := rep.Report(&buf, inv) + if err != nil { + t.Fatalf("Report() error: %v", err) + } + + output := buf.String() + + checks := []string{ + "CONFIRMED ORPHANS", + "DEVELOPER-OWNED", + "ACTIVE", + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Errorf("detect mode output missing expected string %q", check) + } + } +} diff --git a/contrib/hcp-infra/pkg/reporter/text.go b/contrib/hcp-infra/pkg/reporter/text.go new file mode 100644 index 000000000000..db66729ab569 --- /dev/null +++ b/contrib/hcp-infra/pkg/reporter/text.go @@ -0,0 +1,269 @@ +package reporter + +import ( + "fmt" + "io" + "math" + "sort" + "strings" + "time" + + "github.com/openshift/hypershift/contrib/hcp-infra/pkg/scanner" +) + +// TextReporter formats inventory output for terminal display. +type TextReporter struct{} + +// Report writes a human-readable text report to w. +func (t *TextReporter) Report(w io.Writer, inv *scanner.Inventory) error { + // Header + printHeader(w, inv) + + if inv.ScanMode == "detect" { + return t.reportDetectMode(w, inv) + } + return t.reportStandardMode(w, inv) +} + +func printHeader(w io.Writer, inv *scanner.Inventory) { + line := strings.Repeat("━", 72) + fmt.Fprintf(w, "\n%s\n", line) + fmt.Fprintf(w, " HyperShift Infrastructure Report\n") + fmt.Fprintf(w, "%s\n", line) + fmt.Fprintf(w, " Account: %s\n", inv.AccountID) + fmt.Fprintf(w, " Regions: %s\n", strings.Join(inv.Regions, ", ")) + fmt.Fprintf(w, " Date: %s\n", inv.ScannedAt.Format("2006-01-02 15:04 MST")) + fmt.Fprintf(w, " Mode: %s\n", inv.ScanMode) + fmt.Fprintf(w, "%s\n\n", line) +} + +func (t *TextReporter) reportStandardMode(w io.Writer, inv *scanner.Inventory) error { + orphaned := inv.OrphanedGroups() + active := inv.ActiveGroups() + + // Collect remaining groups (developer-owned, unknown) + var other []*scanner.InfraGroup + for _, g := range inv.Groups { + if g.Category != scanner.CategoryActive && + g.Category != scanner.CategoryConfirmedOrphan && + g.Category != scanner.CategoryProbableOrphan { + other = append(other, g) + } + } + + sortGroupsByAge(orphaned) + sortGroupsByAge(active) + sortGroupsByAge(other) + + // ORPHANED section + if len(orphaned) > 0 { + printSectionHeader(w, "ORPHANED", len(orphaned)) + printGroupTable(w, orphaned) + fmt.Fprintln(w) + } + + // ACTIVE section + if len(active) > 0 { + printSectionHeader(w, "ACTIVE", len(active)) + printGroupTable(w, active) + fmt.Fprintln(w) + } + + // OTHER section + if len(other) > 0 { + printSectionHeader(w, "OTHER", len(other)) + printGroupTable(w, other) + fmt.Fprintln(w) + } + + // Summary + printSummary(w, inv, orphaned, active) + + // Clean hint + if len(orphaned) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, "To clean: hcp-infra clean --all\n") + fmt.Fprintf(w, " hcp-infra clean --infra-id \n") + } + + fmt.Fprintln(w) + return nil +} + +func (t *TextReporter) reportDetectMode(w io.Writer, inv *scanner.Inventory) error { + // Categorize groups + var confirmed, probable, devOwned, active, unknown []*scanner.InfraGroup + for _, g := range inv.Groups { + switch g.Category { + case scanner.CategoryConfirmedOrphan: + confirmed = append(confirmed, g) + case scanner.CategoryProbableOrphan: + probable = append(probable, g) + case scanner.CategoryDeveloperOwned: + devOwned = append(devOwned, g) + case scanner.CategoryActive: + active = append(active, g) + default: + unknown = append(unknown, g) + } + } + + sortGroupsByAge(confirmed) + sortGroupsByAge(probable) + sortGroupsByAge(devOwned) + sortGroupsByAge(active) + sortGroupsByAge(unknown) + + if len(confirmed) > 0 { + printSectionHeader(w, "CONFIRMED ORPHANS", len(confirmed)) + printGroupTable(w, confirmed) + fmt.Fprintln(w) + } + + if len(probable) > 0 { + printSectionHeader(w, "PROBABLE ORPHANS", len(probable)) + printGroupTable(w, probable) + fmt.Fprintln(w) + } + + if len(devOwned) > 0 { + printSectionHeader(w, "DEVELOPER-OWNED", len(devOwned)) + printGroupTable(w, devOwned) + fmt.Fprintln(w) + } + + if len(active) > 0 { + printSectionHeader(w, "ACTIVE", len(active)) + printGroupTable(w, active) + fmt.Fprintln(w) + } + + if len(unknown) > 0 { + printSectionHeader(w, "UNKNOWN ORIGIN", len(unknown)) + printGroupTable(w, unknown) + fmt.Fprintln(w) + } + + orphaned := append(confirmed, probable...) + printSummary(w, inv, orphaned, active) + + if len(orphaned) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, "To clean: hcp-infra clean --all\n") + fmt.Fprintf(w, " hcp-infra clean --infra-id \n") + } + + fmt.Fprintln(w) + return nil +} + +func printSectionHeader(w io.Writer, title string, count int) { + fmt.Fprintf(w, " %s (%d)\n", title, count) + fmt.Fprintf(w, " %s\n", strings.Repeat("─", 68)) +} + +func printGroupTable(w io.Writer, groups []*scanner.InfraGroup) { + if len(groups) == 0 { + return + } + + // Calculate column widths dynamically + infraIDWidth := 10 // minimum + clusterWidth := 12 // minimum + for _, g := range groups { + if len(g.InfraID) > infraIDWidth { + infraIDWidth = len(g.InfraID) + } + name := displayName(g) + if len(name) > clusterWidth { + clusterWidth = len(name) + } + } + + // Cap widths + if infraIDWidth > 30 { + infraIDWidth = 30 + } + if clusterWidth > 30 { + clusterWidth = 30 + } + + // Header row + fmtStr := fmt.Sprintf(" %%-%ds %%-%ds %%5s %%4s %%8s\n", infraIDWidth, clusterWidth) + fmt.Fprintf(w, fmtStr, "Infra ID", "Cluster Name", "Age", "Res", "Cost/mo") + + for _, g := range groups { + name := displayName(g) + if len(name) > clusterWidth { + name = name[:clusterWidth-1] + "…" + } + infraID := g.InfraID + if len(infraID) > infraIDWidth { + infraID = infraID[:infraIDWidth-1] + "…" + } + + fmt.Fprintf(w, fmtStr, + infraID, + name, + formatAge(g.CreatedAt), + fmt.Sprintf("%d", len(g.Resources)), + formatCost(g.MonthlyCost), + ) + } +} + +func printSummary(w io.Writer, inv *scanner.Inventory, orphaned, active []*scanner.InfraGroup) { + totalGroups := len(inv.Groups) + totalResources := 0 + totalCost := 0.0 + orphanCost := 0.0 + + for _, g := range inv.Groups { + totalResources += len(g.Resources) + totalCost += g.MonthlyCost + } + for _, g := range orphaned { + orphanCost += g.MonthlyCost + } + + fmt.Fprintf(w, " Summary: %d clusters (%d orphaned, %d active), %d resources, ~$%.0f/mo total (~$%.0f/mo orphaned)\n", + totalGroups, len(orphaned), len(active), totalResources, totalCost, orphanCost) +} + +func displayName(g *scanner.InfraGroup) string { + if g.ClusterName != "" { + return g.ClusterName + } + return "(unknown)" +} + +func formatAge(t time.Time) string { + if t.IsZero() { + return "?" + } + days := int(math.Floor(time.Since(t).Hours() / 24)) + if days < 0 { + days = 0 + } + return fmt.Sprintf("%dd", days) +} + +func formatCost(cost float64) string { + if cost == 0 { + return "-" + } + return fmt.Sprintf("~$%.0f", cost) +} + +func sortGroupsByAge(groups []*scanner.InfraGroup) { + sort.Slice(groups, func(i, j int) bool { + // Oldest first (most likely orphaned) + if groups[i].CreatedAt.IsZero() { + return false + } + if groups[j].CreatedAt.IsZero() { + return true + } + return groups[i].CreatedAt.Before(groups[j].CreatedAt) + }) +} diff --git a/contrib/hcp-infra/pkg/scanner/tags.go b/contrib/hcp-infra/pkg/scanner/tags.go new file mode 100644 index 000000000000..d728cf77c6af --- /dev/null +++ b/contrib/hcp-infra/pkg/scanner/tags.go @@ -0,0 +1,256 @@ +package scanner + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" + tagtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" + + awspkg "github.com/openshift/hypershift/contrib/hcp-infra/pkg/aws" +) + +// ScanOptions configures the tag-based scan. +type ScanOptions struct { + InfraID string + Owner string + OlderThan time.Duration + ExcludeInfraIDs []string + OIDCBuckets []string +} + +// ScanTags discovers HyperShift infrastructure resources via the Resource Groups +// Tagging API. It queries for ALL resources tagged with the well-known +// hypershift.openshift.io/infra-id key in a single paginated call per region, +// then groups them by infra ID. +func ScanTags(ctx context.Context, clients *awspkg.Clients, regions []string, opts ScanOptions) (*Inventory, error) { + inv := NewInventory(clients.AccountID, regions, "tags") + + // Build the set of excluded infra IDs for fast lookup. + excludeSet := make(map[string]bool, len(opts.ExcludeInfraIDs)) + for _, id := range opts.ExcludeInfraIDs { + excludeSet[id] = true + } + + cutoff := time.Time{} + if opts.OlderThan > 0 { + cutoff = time.Now().Add(-opts.OlderThan) + } + + // Scan each region: one paginated GetResources call per region finds ALL + // tagged resources regardless of infra ID. This is O(regions) API calls + // instead of O(regions * infraIDs). + for _, region := range regions { + if err := scanAllTaggedResources(ctx, clients, region, inv, opts, excludeSet, cutoff); err != nil { + log.Printf("WARN: scanning region %s: %v", region, err) + } + } + + return inv, nil +} + +// scanAllTaggedResources queries a single region for ALL resources tagged with +// the hypershift.openshift.io/infra-id key. If opts.InfraID is set, it filters +// to that specific value. Otherwise it returns everything in one paginated walk. +func scanAllTaggedResources(ctx context.Context, clients *awspkg.Clients, region string, inv *Inventory, opts ScanOptions, excludeSet map[string]bool, cutoff time.Time) error { + tagging := clients.Tagging(region) + + input := &resourcegroupstaggingapi.GetResourcesInput{ + TagFilters: []tagtypes.TagFilter{ + { + Key: aws.String(TagInfraID), + }, + }, + } + + // If a specific infra ID is requested, add it as a value filter. + if opts.InfraID != "" { + input.TagFilters[0].Values = []string{opts.InfraID} + } + + paginator := resourcegroupstaggingapi.NewGetResourcesPaginator(tagging, input) + pageCount := 0 + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return fmt.Errorf("GetResources in %s: %w", region, err) + } + pageCount++ + + for _, mapping := range page.ResourceTagMappingList { + arn := aws.ToString(mapping.ResourceARN) + tags := extractTags(mapping.Tags) + + infraID := tags[TagInfraID] + if infraID == "" { + continue + } + + // Apply exclusion filter. + if excludeSet[infraID] { + continue + } + + // Apply owner filter: match cluster-name prefix. + if opts.Owner != "" { + clusterName := tags[TagClusterName] + if !strings.HasPrefix(clusterName, opts.Owner) { + continue + } + } + + resType, resourceID := parseARN(arn) + if resType == "" { + log.Printf("WARN: unrecognized ARN format: %s", arn) + continue + } + + r := Resource{ + Type: resType, + ID: resourceID, + ARN: arn, + Region: region, + InfraID: infraID, + ClusterName: tags[TagClusterName], + Tags: tags, + MonthlyCost: estimateCost(resType), + } + + // Set name from tags. + if name, ok := tags["Name"]; ok { + r.Name = name + } + + // Apply older-than filter. + if !cutoff.IsZero() && !r.CreatedAt.IsZero() && r.CreatedAt.After(cutoff) { + continue + } + + inv.AddResource(r) + } + } + + return nil +} + +// extractTags converts the Tagging API tag list to a map. +func extractTags(tags []tagtypes.Tag) map[string]string { + m := make(map[string]string, len(tags)) + for _, t := range tags { + m[aws.ToString(t.Key)] = aws.ToString(t.Value) + } + return m +} + +// arnServiceResourceMap maps "service:resource-type" from ARN components to our +// ResourceType constants. +var arnServiceResourceMap = map[string]ResourceType{ + "ec2:vpc": ResVPC, + "ec2:subnet": ResSubnet, + "ec2:natgateway": ResNATGateway, + "ec2:elastic-ip": ResElasticIP, + "ec2:internet-gateway": ResInternetGateway, + "ec2:route-table": ResRouteTable, + "ec2:security-group": ResSecurityGroup, + "ec2:vpc-endpoint": ResVPCEndpoint, + "ec2:dhcp-options": ResDHCPOptions, + "ec2:instance": ResEC2Instance, + "ec2:volume": ResEBSVolume, + "ec2:key-pair": ResEC2KeyPair, + "ec2:capacity-reservation": ResCapacityReservation, +} + +// parseARN extracts the ResourceType and resource ID from an ARN. +func parseARN(arn string) (ResourceType, string) { + parts := strings.SplitN(arn, ":", 6) + if len(parts) < 6 { + return "", "" + } + + service := parts[2] + resource := parts[5] + + switch service { + case "ec2": + return parseEC2ARN(resource) + case "iam": + return parseIAMARN(resource) + case "route53": + return parseRoute53ARN(resource) + case "elasticloadbalancing": + return parseELBARN(resource) + case "s3": + return ResS3Prefix, resource + default: + return "", "" + } +} + +func parseEC2ARN(resource string) (ResourceType, string) { + slashIdx := strings.Index(resource, "/") + if slashIdx < 0 { + return "", "" + } + resType := resource[:slashIdx] + resID := resource[slashIdx+1:] + key := "ec2:" + resType + if rt, ok := arnServiceResourceMap[key]; ok { + return rt, resID + } + return "", "" +} + +func parseIAMARN(resource string) (ResourceType, string) { + switch { + case strings.HasPrefix(resource, "role/"): + name := resource[len("role/"):] + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + return ResIAMRole, name + case strings.HasPrefix(resource, "instance-profile/"): + name := resource[len("instance-profile/"):] + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + return ResInstanceProfile, name + case strings.HasPrefix(resource, "oidc-provider/"): + return ResOIDCProvider, resource[len("oidc-provider/"):] + default: + return "", "" + } +} + +func parseRoute53ARN(resource string) (ResourceType, string) { + if strings.HasPrefix(resource, "hostedzone/") { + return ResRoute53Zone, resource[len("hostedzone/"):] + } + return "", "" +} + +func parseELBARN(resource string) (ResourceType, string) { + if !strings.HasPrefix(resource, "loadbalancer/") { + return "", "" + } + remainder := resource[len("loadbalancer/"):] + if strings.HasPrefix(remainder, "app/") || strings.HasPrefix(remainder, "net/") { + return ResELBv2, remainder + } + return ResELB, remainder +} + +func estimateCost(rt ResourceType) float64 { + switch rt { + case ResNATGateway: + return CostNATGateway + case ResElasticIP: + return CostElasticIP + default: + return 0 + } +} diff --git a/contrib/hcp-infra/pkg/scanner/types.go b/contrib/hcp-infra/pkg/scanner/types.go new file mode 100644 index 000000000000..df70824e8b34 --- /dev/null +++ b/contrib/hcp-infra/pkg/scanner/types.go @@ -0,0 +1,163 @@ +package scanner + +import "time" + +const ( + TagInfraID = "hypershift.openshift.io/infra-id" + TagClusterName = "hypershift.openshift.io/cluster-name" + TagSource = "hypershift.openshift.io/source" + TagClusterOwned = "kubernetes.io/cluster/%s" +) + +// Category classifies an infrastructure group. +type Category string + +const ( + CategoryConfirmedOrphan Category = "CONFIRMED ORPHAN" + CategoryProbableOrphan Category = "PROBABLE ORPHAN" + CategoryDeveloperOwned Category = "DEVELOPER-OWNED" + CategoryActive Category = "ACTIVE" + CategoryUnknown Category = "UNKNOWN ORIGIN" +) + +// ResourceType identifies the AWS resource kind. +type ResourceType string + +const ( + ResVPC ResourceType = "VPC" + ResSubnet ResourceType = "Subnet" + ResNATGateway ResourceType = "NAT Gateway" + ResElasticIP ResourceType = "Elastic IP" + ResInternetGateway ResourceType = "Internet Gateway" + ResRouteTable ResourceType = "Route Table" + ResSecurityGroup ResourceType = "Security Group" + ResVPCEndpoint ResourceType = "VPC Endpoint" + ResDHCPOptions ResourceType = "DHCP Options" + ResEC2Instance ResourceType = "EC2 Instance" + ResEBSVolume ResourceType = "EBS Volume" + ResIAMRole ResourceType = "IAM Role" + ResInstanceProfile ResourceType = "Instance Profile" + ResOIDCProvider ResourceType = "OIDC Provider" + ResRoute53Zone ResourceType = "Route53 Zone" + ResS3Prefix ResourceType = "S3 OIDC Prefix" + ResELB ResourceType = "ELB" + ResELBv2 ResourceType = "ELBv2" + ResEC2KeyPair ResourceType = "EC2 Key Pair" + ResCapacityReservation ResourceType = "Capacity Reservation" +) + +// Resource represents a single AWS resource. +type Resource struct { + Type ResourceType + ID string // e.g. vpc-0abc123, nat-0abc123, role name + ARN string // full ARN if available + Name string // Name tag or display name + Region string + InfraID string // extracted or matched infra ID + ClusterName string // from tag if available + CreatedAt time.Time + Tags map[string]string + MonthlyCost float64 // estimated monthly cost (0 if free) + Details string // extra info (e.g. IP address, volume size) +} + +// InfraGroup groups resources belonging to the same HyperShift infra ID. +type InfraGroup struct { + InfraID string + ClusterName string + Owner string // extracted from cluster-name prefix or tags + Category Category + Resources []Resource + CreatedAt time.Time // oldest resource creation time + MonthlyCost float64 // sum of resource costs + HasVPC bool + HasHC bool // has HostedCluster on mgmt cluster +} + +// Inventory is the top-level result of a scan. +type Inventory struct { + AccountID string + Regions []string + ScannedAt time.Time + ScanMode string // "tags", "deep", "detect" + Groups map[string]*InfraGroup // keyed by infra ID + Ungrouped []Resource // resources that couldn't be matched to an infra ID +} + +// NewInventory creates a new Inventory. +func NewInventory(accountID string, regions []string, scanMode string) *Inventory { + return &Inventory{ + AccountID: accountID, + Regions: regions, + ScannedAt: time.Now(), + ScanMode: scanMode, + Groups: make(map[string]*InfraGroup), + } +} + +// AddResource adds a resource to the inventory, grouping by infra ID. +func (inv *Inventory) AddResource(r Resource) { + if r.InfraID == "" { + inv.Ungrouped = append(inv.Ungrouped, r) + return + } + g, ok := inv.Groups[r.InfraID] + if !ok { + g = &InfraGroup{ + InfraID: r.InfraID, + ClusterName: r.ClusterName, + } + inv.Groups[r.InfraID] = g + } + g.Resources = append(g.Resources, r) + g.MonthlyCost += r.MonthlyCost + if r.ClusterName != "" && g.ClusterName == "" { + g.ClusterName = r.ClusterName + } + if !r.CreatedAt.IsZero() && (g.CreatedAt.IsZero() || r.CreatedAt.Before(g.CreatedAt)) { + g.CreatedAt = r.CreatedAt + } + if r.Type == ResVPC { + g.HasVPC = true + } +} + +// OrphanedGroups returns groups categorized as orphaned. +func (inv *Inventory) OrphanedGroups() []*InfraGroup { + var result []*InfraGroup + for _, g := range inv.Groups { + if g.Category == CategoryConfirmedOrphan || g.Category == CategoryProbableOrphan { + result = append(result, g) + } + } + return result +} + +// ActiveGroups returns groups categorized as active. +func (inv *Inventory) ActiveGroups() []*InfraGroup { + var result []*InfraGroup + for _, g := range inv.Groups { + if g.Category == CategoryActive { + result = append(result, g) + } + } + return result +} + +// RoleSuffixes lists known IAM role component suffixes for HyperShift clusters. +var RoleSuffixes = []string{ + "cloud-controller", + "node-pool", + "control-plane-operator", + "cloud-network-config-controller", + "openshift-ingress", + "openshift-image-registry", + "aws-ebs-csi-driver-controller", + "worker-role", +} + +// Cost estimates per resource type (monthly). +const ( + CostNATGateway = 32.0 // ~$0.045/hr + CostElasticIP = 3.65 // ~$0.005/hr when not attached +) diff --git a/contrib/hcp-infra/role-policy.json b/contrib/hcp-infra/role-policy.json new file mode 100644 index 000000000000..056944118409 --- /dev/null +++ b/contrib/hcp-infra/role-policy.json @@ -0,0 +1,73 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ScanRead", + "Effect": "Allow", + "Action": [ + "tag:GetTagValues", + "tag:GetResources", + "ec2:DescribeVpcs", + "ec2:DescribeSubnets", + "ec2:DescribeNatGateways", + "ec2:DescribeInternetGateways", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcEndpoints", + "ec2:DescribeAddresses", + "ec2:DescribeInstances", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeVolumes", + "ec2:DescribeDhcpOptions", + "iam:ListRoles", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + "iam:ListRoleTags", + "iam:ListInstanceProfiles", + "iam:ListOpenIDConnectProviders", + "iam:GetOpenIDConnectProvider", + "route53:ListHostedZones", + "route53:ListResourceRecordSets", + "route53:ListTagsForResource", + "s3:ListBucket", + "s3:ListObjectsV2", + "elasticloadbalancing:DescribeLoadBalancers" + ], + "Resource": "*" + }, + { + "Sid": "CleanWrite", + "Effect": "Allow", + "Action": [ + "ec2:DeleteVpc", + "ec2:DeleteSubnet", + "ec2:DeleteNatGateway", + "ec2:DetachInternetGateway", + "ec2:DeleteInternetGateway", + "ec2:DeleteRouteTable", + "ec2:DeleteRoute", + "ec2:DisassociateRouteTable", + "ec2:DeleteSecurityGroup", + "ec2:RevokeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:DeleteVpcEndpoints", + "ec2:ReleaseAddress", + "ec2:TerminateInstances", + "ec2:DeleteVolume", + "ec2:DeleteDhcpOptions", + "ec2:DisassociateDhcpOptions", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:DeleteRole", + "iam:RemoveRoleFromInstanceProfile", + "iam:DeleteInstanceProfile", + "iam:DeleteOpenIDConnectProvider", + "route53:ChangeResourceRecordSets", + "route53:DeleteHostedZone", + "s3:DeleteObject", + "elasticloadbalancing:DeleteLoadBalancer" + ], + "Resource": "*" + } + ] +}