diff --git a/cloudemu_test.go b/cloudemu_test.go index dae2266..8db6476 100644 --- a/cloudemu_test.go +++ b/cloudemu_test.go @@ -9283,3 +9283,211 @@ func TestTopologyCanConnectGCP(t *testing.T) { ctx := context.Background() testTopologyCanConnect(t, ctx, p.GCE, p.VPC, p.CloudDNS) } + +// --------------------------------------------------------------------------- +// Dependency Graph integration tests +// --------------------------------------------------------------------------- + +func testDependencyGraph( + t *testing.T, + ctx context.Context, + comp computedriver.Compute, + net netdriver.Networking, + dns dnsdriver.DNS, +) { + t.Helper() + + engine := topology.New(comp, net, dns) + + v, err := net.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + if err != nil { + t.Fatalf("CreateVPC: %v", err) + } + + subnet, err := net.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: v.ID, CIDRBlock: "10.0.0.0/16", AvailabilityZone: "zone-a", + }) + if err != nil { + t.Fatalf("CreateSubnet: %v", err) + } + + sg, err := net.CreateSecurityGroup(ctx, netdriver.SecurityGroupConfig{ + VPCID: v.ID, Name: "depgraph-sg", Description: "depgraph test", + }) + if err != nil { + t.Fatalf("CreateSecurityGroup: %v", err) + } + + instances, err := comp.RunInstances(ctx, computedriver.InstanceConfig{ + ImageID: "img-test", InstanceType: "standard", + SubnetID: subnet.ID, SecurityGroups: []string{sg.ID}, + }, 1) + if err != nil { + t.Fatalf("RunInstances: %v", err) + } + + if setter, ok := comp.(instanceVPCSetter); ok { + if err := setter.SetInstanceVPC(instances[0].ID, v.ID); err != nil { + t.Fatalf("SetInstanceVPC: %v", err) + } + } + + graph, err := engine.BuildDependencyGraph(ctx) + if err != nil { + t.Fatalf("BuildDependencyGraph: %v", err) + } + + // At minimum: 1 VPC + 1 subnet + 1 SG + 1 instance = 4 resources. + if len(graph.Resources) < 4 { + t.Errorf("expected at least 4 resources, got %d", len(graph.Resources)) + } + + // At minimum: subnet->VPC + SG->VPC + instance->VPC + instance->subnet + instance->SG = 5 deps. + if len(graph.Dependencies) < 5 { + t.Errorf("expected at least 5 dependencies, got %d", len(graph.Dependencies)) + } +} + +func TestDependencyGraphAWS(t *testing.T) { + p := NewAWS() + ctx := context.Background() + testDependencyGraph(t, ctx, p.EC2, p.VPC, p.Route53) +} + +func TestDependencyGraphAzure(t *testing.T) { + p := NewAzure() + ctx := context.Background() + testDependencyGraph(t, ctx, p.VirtualMachines, p.VNet, p.DNS) +} + +func TestDependencyGraphGCP(t *testing.T) { + p := NewGCP() + ctx := context.Background() + testDependencyGraph(t, ctx, p.GCE, p.VPC, p.CloudDNS) +} + +// --------------------------------------------------------------------------- +// Cross-service dependency graph integration tests +// --------------------------------------------------------------------------- + +func testCrossServiceDependencyGraph( + t *testing.T, + ctx context.Context, + comp computedriver.Compute, + net netdriver.Networking, + dns dnsdriver.DNS, + lb lbdriver.LoadBalancer, + provider string, +) { + t.Helper() + + engine := topology.New(comp, net, dns, + topology.WithLoadBalancer(lb), + topology.WithProvider(provider), + ) + + v, err := net.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + if err != nil { + t.Fatalf("CreateVPC: %v", err) + } + + subnet, err := net.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: v.ID, CIDRBlock: "10.0.0.0/16", AvailabilityZone: "zone-a", + }) + if err != nil { + t.Fatalf("CreateSubnet: %v", err) + } + + sg, err := net.CreateSecurityGroup(ctx, netdriver.SecurityGroupConfig{ + VPCID: v.ID, Name: "cross-sg", Description: "cross-service test", + }) + if err != nil { + t.Fatalf("CreateSecurityGroup: %v", err) + } + + instances, err := comp.RunInstances(ctx, computedriver.InstanceConfig{ + ImageID: "img-test", InstanceType: "standard", + SubnetID: subnet.ID, SecurityGroups: []string{sg.ID}, + }, 1) + if err != nil { + t.Fatalf("RunInstances: %v", err) + } + + if setter, ok := comp.(instanceVPCSetter); ok { + if err := setter.SetInstanceVPC(instances[0].ID, v.ID); err != nil { + t.Fatalf("SetInstanceVPC: %v", err) + } + } + + createdLB, err := lb.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "int-lb", Type: "application", Scheme: "internal", + Subnets: []string{subnet.ID}, + }) + if err != nil { + t.Fatalf("CreateLoadBalancer: %v", err) + } + + tg, err := lb.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "int-tg", Protocol: "HTTP", Port: 80, VPCID: v.ID, + }) + if err != nil { + t.Fatalf("CreateTargetGroup: %v", err) + } + + _, err = lb.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: createdLB.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + if err != nil { + t.Fatalf("CreateListener: %v", err) + } + + err = lb.RegisterTargets(ctx, tg.ARN, []lbdriver.Target{{ID: instances[0].ID, Port: 80}}) + if err != nil { + t.Fatalf("RegisterTargets: %v", err) + } + + graph, err := engine.BuildDependencyGraph(ctx) + if err != nil { + t.Fatalf("BuildDependencyGraph: %v", err) + } + + // Should have core resources + LB resources. + if len(graph.Resources) < 7 { + t.Errorf("expected at least 7 resources, got %d", len(graph.Resources)) + } + + // Verify provider field is populated. + for _, r := range graph.Resources { + if r.Provider != provider { + t.Errorf("resource %s has provider %q, want %q", r.ID, r.Provider, provider) + } + } + + // Verify WhatIf works on the LB. + report, err := engine.WhatIf(ctx, "delete", createdLB.ARN) + if err != nil { + t.Fatalf("WhatIf: %v", err) + } + + if report.Action != "delete" { + t.Errorf("expected action 'delete', got %q", report.Action) + } +} + +func TestCrossServiceDependencyGraphAWS(t *testing.T) { + p := NewAWS() + ctx := context.Background() + testCrossServiceDependencyGraph(t, ctx, p.EC2, p.VPC, p.Route53, p.ELB, "aws") +} + +func TestCrossServiceDependencyGraphAzure(t *testing.T) { + p := NewAzure() + ctx := context.Background() + testCrossServiceDependencyGraph(t, ctx, p.VirtualMachines, p.VNet, p.DNS, p.LB, "azure") +} + +func TestCrossServiceDependencyGraphGCP(t *testing.T) { + p := NewGCP() + ctx := context.Background() + testCrossServiceDependencyGraph(t, ctx, p.GCE, p.VPC, p.CloudDNS, p.LB, "gcp") +} diff --git a/topology/blast_radius.go b/topology/blast_radius.go new file mode 100644 index 0000000..cd4382e --- /dev/null +++ b/topology/blast_radius.go @@ -0,0 +1,314 @@ +package topology + +import ( + "context" + "fmt" + + cerrors "github.com/stackshy/cloudemu/errors" +) + +// blastRadius computes the impact of removing or modifying the given resource. +func blastRadius(ctx context.Context, e *Engine, resourceID string) (*ImpactReport, error) { + graph, err := e.BuildDependencyGraph(ctx) + if err != nil { + return nil, err + } + + target, found := findResource(graph, resourceID) + if !found { + return nil, cerrors.Newf(cerrors.NotFound, "resource %s not found in graph", resourceID) + } + + direct := findDirectDependents(graph, resourceID) + transitive := findTransitiveDependents(graph, resourceID, direct) + broken := findBrokenConnections(graph, resourceID) + + allAffected := collectAffectedIDs(resourceID, direct, transitive) + orphaned := findOrphanedResources(graph, allAffected) + + return &ImpactReport{ + Target: target, + Action: "delete", + DirectlyAffected: direct, + TransitiveImpact: transitive, + OrphanedResources: orphaned, + BrokenConnections: broken, + Summary: fmt.Sprintf( + "deleting %s (%s) affects %d direct and %d transitive resources", + target.ID, target.Type, len(direct), len(transitive), + ), + }, nil +} + +// dependsOn returns all resources that the given resource directly depends on. +func dependsOn(ctx context.Context, e *Engine, resourceID string) ([]ResourceRef, error) { + graph, err := e.BuildDependencyGraph(ctx) + if err != nil { + return nil, err + } + + if _, found := findResource(graph, resourceID); !found { + return nil, cerrors.Newf(cerrors.NotFound, "resource %s not found in graph", resourceID) + } + + refs := collectDepsFrom(graph, resourceID) + + return refs, nil +} + +// collectDepsFrom returns the To side of every dependency whose From matches the ID. +func collectDepsFrom(graph *DependencyGraph, resourceID string) []ResourceRef { + var refs []ResourceRef + + for i := range graph.Dependencies { + dep := &graph.Dependencies[i] + if dep.From.ID == resourceID { + refs = append(refs, dep.To) + } + } + + return refs +} + +// dependedBy returns all resources that directly depend on the given resource. +func dependedBy(ctx context.Context, e *Engine, resourceID string) ([]ResourceRef, error) { + graph, err := e.BuildDependencyGraph(ctx) + if err != nil { + return nil, err + } + + if _, found := findResource(graph, resourceID); !found { + return nil, cerrors.Newf(cerrors.NotFound, "resource %s not found in graph", resourceID) + } + + return findDirectDependents(graph, resourceID), nil +} + +// findResource locates a resource by ID in the graph. +func findResource(graph *DependencyGraph, resourceID string) (ResourceRef, bool) { + for _, r := range graph.Resources { + if r.ID == resourceID { + return r, true + } + } + + return ResourceRef{}, false +} + +// findDirectDependents returns resources whose dependencies point TO the given resource. +func findDirectDependents(graph *DependencyGraph, resourceID string) []ResourceRef { + seen := make(map[string]bool) + + var refs []ResourceRef + + for i := range graph.Dependencies { + dep := &graph.Dependencies[i] + if dep.To.ID != resourceID || seen[dep.From.ID] { + continue + } + + seen[dep.From.ID] = true + + refs = append(refs, dep.From) + } + + return refs +} + +// findTransitiveDependents walks the graph beyond direct dependents to find +// resources that are transitively affected. +func findTransitiveDependents( + graph *DependencyGraph, + rootID string, + direct []ResourceRef, +) []ResourceRef { + visited := make(map[string]bool) + visited[rootID] = true + + for _, d := range direct { + visited[d.ID] = true + } + + queue := make([]string, 0, len(direct)) + for _, d := range direct { + queue = append(queue, d.ID) + } + + var transitive []ResourceRef + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + queue, transitive = walkDependents(graph, current, visited, queue, transitive) + } + + return transitive +} + +// walkDependents processes one BFS level, appending newly discovered dependents. +func walkDependents( + graph *DependencyGraph, + current string, + visited map[string]bool, + queue []string, + transitive []ResourceRef, +) ([]string, []ResourceRef) { + for i := range graph.Dependencies { + dep := &graph.Dependencies[i] + if dep.To.ID != current || visited[dep.From.ID] { + continue + } + + visited[dep.From.ID] = true + + transitive = append(transitive, dep.From) + queue = append(queue, dep.From.ID) + } + + return queue, transitive +} + +// findBrokenConnections returns all dependencies that reference the given resource. +func findBrokenConnections(graph *DependencyGraph, resourceID string) []Dependency { + var broken []Dependency + + for i := range graph.Dependencies { + dep := &graph.Dependencies[i] + if dep.From.ID == resourceID || dep.To.ID == resourceID { + broken = append(broken, *dep) + } + } + + return broken +} + +// collectAffectedIDs gathers the target + direct + transitive IDs into a set. +func collectAffectedIDs(rootID string, direct, transitive []ResourceRef) map[string]bool { + affected := map[string]bool{rootID: true} + + for _, r := range direct { + affected[r.ID] = true + } + + for _, r := range transitive { + affected[r.ID] = true + } + + return affected +} + +// findOrphanedResources returns resources outside the affected set whose +// every parent dependency points into the affected set. +func findOrphanedResources(graph *DependencyGraph, affected map[string]bool) []ResourceRef { + var orphaned []ResourceRef + + for _, r := range graph.Resources { + if affected[r.ID] { + continue + } + + if isOrphaned(graph, r.ID, affected) { + orphaned = append(orphaned, r) + } + } + + return orphaned +} + +// isOrphaned checks whether all of a resource's parent dependencies point +// into the affected set (meaning it would lose every parent). +func isOrphaned(graph *DependencyGraph, resourceID string, affected map[string]bool) bool { + hasParent := false + + for i := range graph.Dependencies { + dep := &graph.Dependencies[i] + if dep.From.ID != resourceID { + continue + } + + if dep.Type != "member-of" && dep.Type != "attached-to" && dep.Type != "belongs-to" { + continue + } + + hasParent = true + + if !affected[dep.To.ID] { + return false + } + } + + return hasParent +} + +// whatIf previews the impact of an action on a resource. +func whatIf(ctx context.Context, e *Engine, action, resourceID string) (*ImpactReport, error) { + switch action { + case "delete": + return blastRadius(ctx, e, resourceID) + case "stop": + return whatIfStop(ctx, e, resourceID) + case "disconnect": + return whatIfDisconnect(ctx, e, resourceID) + default: + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action) + } +} + +// whatIfStop computes the impact of stopping a resource (e.g. an instance). +// Stopping doesn't remove dependencies but breaks connections that require a running state. +func whatIfStop(ctx context.Context, e *Engine, resourceID string) (*ImpactReport, error) { + graph, err := e.BuildDependencyGraph(ctx) + if err != nil { + return nil, err + } + + target, found := findResource(graph, resourceID) + if !found { + return nil, cerrors.Newf(cerrors.NotFound, "resource %s not found in graph", resourceID) + } + + broken := findBrokenConnections(graph, resourceID) + direct := findDirectDependents(graph, resourceID) + + return &ImpactReport{ + Target: target, + Action: "stop", + DirectlyAffected: direct, + BrokenConnections: broken, + Summary: fmt.Sprintf( + "stopping %s (%s) breaks %d connections", + target.ID, target.Type, len(broken), + ), + }, nil +} + +// whatIfDisconnect computes the impact of disconnecting a resource +// (e.g. removing a peering connection or detaching an internet gateway). +func whatIfDisconnect(ctx context.Context, e *Engine, resourceID string) (*ImpactReport, error) { + graph, err := e.BuildDependencyGraph(ctx) + if err != nil { + return nil, err + } + + target, found := findResource(graph, resourceID) + if !found { + return nil, cerrors.Newf(cerrors.NotFound, "resource %s not found in graph", resourceID) + } + + broken := findBrokenConnections(graph, resourceID) + direct := findDirectDependents(graph, resourceID) + affected := collectAffectedIDs(resourceID, direct, nil) + orphaned := findOrphanedResources(graph, affected) + + return &ImpactReport{ + Target: target, + Action: "disconnect", + DirectlyAffected: direct, + OrphanedResources: orphaned, + BrokenConnections: broken, + Summary: fmt.Sprintf( + "disconnecting %s (%s) breaks %d connections, orphans %d resources", + target.ID, target.Type, len(broken), len(orphaned), + ), + }, nil +} diff --git a/topology/depgraph.go b/topology/depgraph.go new file mode 100644 index 0000000..233a039 --- /dev/null +++ b/topology/depgraph.go @@ -0,0 +1,87 @@ +package topology + +import "context" + +// ResourceRef identifies a cloud resource in the dependency graph. +type ResourceRef struct { + ID string + Type string // "vpc", "subnet", "instance", "security-group", etc. + Name string + Provider string // "aws", "azure", "gcp" (empty if unknown) +} + +// Dependency represents a directed relationship between two resources. +type Dependency struct { + From ResourceRef + To ResourceRef + Type string // "member-of", "attached-to", "secured-by", "peers-with" +} + +// DependencyGraph holds all discovered resources and their relationships. +type DependencyGraph struct { + Resources []ResourceRef + Dependencies []Dependency +} + +// ImpactReport describes the blast radius when a resource is modified or deleted. +type ImpactReport struct { + Target ResourceRef + Action string + DirectlyAffected []ResourceRef + TransitiveImpact []ResourceRef + OrphanedResources []ResourceRef + BrokenConnections []Dependency + Summary string +} + +// BuildDependencyGraph scans all compute, networking, and DNS resources +// and returns a graph of their relationships. +func (e *Engine) BuildDependencyGraph(ctx context.Context) (*DependencyGraph, error) { + return buildGraph(ctx, e) +} + +// GetDependencyGraph is an alias for BuildDependencyGraph. +func (e *Engine) GetDependencyGraph(ctx context.Context) (*DependencyGraph, error) { + return buildGraph(ctx, e) +} + +// BlastRadius computes the impact of removing or modifying the given resource. +func (e *Engine) BlastRadius(ctx context.Context, resourceID string) (*ImpactReport, error) { + return blastRadius(ctx, e, resourceID) +} + +// WhatIf previews the impact of an action on a resource without performing it. +// Supported actions: "delete", "stop", "disconnect". +func (e *Engine) WhatIf(ctx context.Context, action, resourceID string) (*ImpactReport, error) { + return whatIf(ctx, e, action, resourceID) +} + +// DependsOn returns all resources that the given resource directly depends on. +func (e *Engine) DependsOn(ctx context.Context, resourceID string) ([]ResourceRef, error) { + return dependsOn(ctx, e, resourceID) +} + +// DependedBy returns all resources that directly depend on the given resource. +func (e *Engine) DependedBy(ctx context.Context, resourceID string) ([]ResourceRef, error) { + return dependedBy(ctx, e, resourceID) +} + +// ExportDOT builds the dependency graph and returns it in Graphviz DOT format. +func (e *Engine) ExportDOT(ctx context.Context) (string, error) { + graph, err := buildGraph(ctx, e) + if err != nil { + return "", err + } + + return graph.ExportDOT(), nil +} + +// ExportMermaid builds the dependency graph and returns it in Mermaid format. +func (e *Engine) ExportMermaid(ctx context.Context) (string, error) { + graph, err := buildGraph(ctx, e) + if err != nil { + return "", err + } + + return graph.ExportMermaid(), nil +} diff --git a/topology/export.go b/topology/export.go new file mode 100644 index 0000000..163703f --- /dev/null +++ b/topology/export.go @@ -0,0 +1,63 @@ +package topology + +import ( + "fmt" + "strings" +) + +// ExportDOT generates a Graphviz DOT representation of the dependency graph. +func (g *DependencyGraph) ExportDOT() string { + var b strings.Builder + + b.WriteString("digraph CloudEmu {\n") + + for _, r := range g.Resources { + label := fmt.Sprintf("%s\\n%s", r.Type, r.ID) + fmt.Fprintf(&b, " %q [label=%q];\n", r.ID, label) + } + + for i := range g.Dependencies { + d := &g.Dependencies[i] + fmt.Fprintf(&b, " %q -> %q [label=%q];\n", d.From.ID, d.To.ID, d.Type) + } + + b.WriteString("}\n") + + return b.String() +} + +// ExportMermaid generates a Mermaid diagram representation of the dependency graph. +func (g *DependencyGraph) ExportMermaid() string { + var b strings.Builder + + b.WriteString("graph TD\n") + + for _, r := range g.Resources { + label := fmt.Sprintf("%s: %s", r.Type, r.ID) + fmt.Fprintf(&b, " %s[\"%s\"]\n", sanitizeMermaidID(r.ID), label) + } + + for i := range g.Dependencies { + d := &g.Dependencies[i] + fmt.Fprintf( + &b, " %s -->|%s| %s\n", + sanitizeMermaidID(d.From.ID), + d.Type, + sanitizeMermaidID(d.To.ID), + ) + } + + return b.String() +} + +// sanitizeMermaidID replaces characters that are invalid in Mermaid node IDs. +func sanitizeMermaidID(id string) string { + replacer := strings.NewReplacer( + "-", "_", + "/", "_", + ".", "_", + ":", "_", + ) + + return replacer.Replace(id) +} diff --git a/topology/graph_builder.go b/topology/graph_builder.go new file mode 100644 index 0000000..44f741a --- /dev/null +++ b/topology/graph_builder.go @@ -0,0 +1,589 @@ +package topology + +import ( + "context" + + lbdriver "github.com/stackshy/cloudemu/loadbalancer/driver" +) + +// graphAdder is a function that adds resources to a dependency graph. +type graphAdder func(ctx context.Context, e *Engine, g *DependencyGraph) error + +// graphAdders returns the ordered list of functions that populate the graph. +func graphAdders() []graphAdder { + return []graphAdder{ + // Core networking + compute + DNS. + addVPCs, + addSubnets, + addSecurityGroups, + addInstances, + addVolumes, + addRouteTables, + addNATGateways, + addInternetGateways, + addPeeringConnections, + addNetworkACLs, + addDNSRecords, + // Cross-service (no-op when driver is nil). + addLoadBalancers, + addTargetGroups, + addListeners, + addFunctions, + addQueues, + addAlarms, + addNotificationChannels, + addInstanceProfiles, + } +} + +// buildGraph constructs a full DependencyGraph by scanning all services. +func buildGraph(ctx context.Context, e *Engine) (*DependencyGraph, error) { + g := &DependencyGraph{} + + for _, add := range graphAdders() { + if err := add(ctx, e, g); err != nil { + return nil, err + } + } + + setProvider(g, e.provider) + + return g, nil +} + +// setProvider stamps every ResourceRef with the engine's provider name. +func setProvider(g *DependencyGraph, provider string) { + if provider == "" { + return + } + + for i := range g.Resources { + g.Resources[i].Provider = provider + } +} + +func addVPCs(ctx context.Context, e *Engine, g *DependencyGraph) error { + vpcs, err := e.networking.DescribeVPCs(ctx, nil) + if err != nil { + return err + } + + for _, v := range vpcs { + g.Resources = append(g.Resources, ResourceRef{ + ID: v.ID, Type: "vpc", Name: v.CIDRBlock, + }) + } + + return nil +} + +// addVPCMemberResources is a generic helper for resources that belong to a VPC +// via a simple "member-of" dependency (subnets, SGs, route tables, ACLs). +func addVPCMemberResources( + g *DependencyGraph, + resourceID, resourceType, resourceName, vpcID string, +) { + ref := ResourceRef{ID: resourceID, Type: resourceType, Name: resourceName} + g.Resources = append(g.Resources, ref) + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: vpcID, Type: "vpc"}, + Type: "member-of", + }) +} + +func addSubnets(ctx context.Context, e *Engine, g *DependencyGraph) error { + subnets, err := e.networking.DescribeSubnets(ctx, nil) + if err != nil { + return err + } + + for _, s := range subnets { + addVPCMemberResources(g, s.ID, "subnet", s.CIDRBlock, s.VPCID) + } + + return nil +} + +func addSecurityGroups(ctx context.Context, e *Engine, g *DependencyGraph) error { + sgs, err := e.networking.DescribeSecurityGroups(ctx, nil) + if err != nil { + return err + } + + for _, sg := range sgs { + addVPCMemberResources(g, sg.ID, "security-group", sg.Name, sg.VPCID) + } + + return nil +} + +func addInstances(ctx context.Context, e *Engine, g *DependencyGraph) error { + instances, err := e.compute.DescribeInstances(ctx, nil, nil) + if err != nil { + return err + } + + for i := range instances { + inst := &instances[i] + ref := ResourceRef{ID: inst.ID, Type: "instance", Name: inst.InstanceType} + g.Resources = append(g.Resources, ref) + addInstanceDeps(g, ref, inst.VPCID, inst.SubnetID, inst.SecurityGroups) + } + + return nil +} + +func addInstanceDeps( + g *DependencyGraph, + ref ResourceRef, + vpcID, subnetID string, + sgIDs []string, +) { + if vpcID != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: vpcID, Type: "vpc"}, + Type: "member-of", + }) + } + + if subnetID != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: subnetID, Type: "subnet"}, + Type: "member-of", + }) + } + + for _, sgID := range sgIDs { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: sgID, Type: "security-group"}, + Type: "secured-by", + }) + } +} + +func addRouteTables(ctx context.Context, e *Engine, g *DependencyGraph) error { + rts, err := e.networking.DescribeRouteTables(ctx, nil) + if err != nil { + return err + } + + for _, rt := range rts { + addVPCMemberResources(g, rt.ID, "route-table", "", rt.VPCID) + } + + return nil +} + +func addNATGateways(ctx context.Context, e *Engine, g *DependencyGraph) error { + nats, err := e.networking.DescribeNATGateways(ctx, nil) + if err != nil { + return err + } + + for _, nat := range nats { + ref := ResourceRef{ID: nat.ID, Type: "nat-gateway"} + g.Resources = append(g.Resources, ref) + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: nat.VPCID, Type: "vpc"}, + Type: "member-of", + }) + + if nat.SubnetID != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: nat.SubnetID, Type: "subnet"}, + Type: "member-of", + }) + } + } + + return nil +} + +func addInternetGateways(ctx context.Context, e *Engine, g *DependencyGraph) error { + igws, err := e.networking.DescribeInternetGateways(ctx, nil) + if err != nil { + return err + } + + for _, igw := range igws { + ref := ResourceRef{ID: igw.ID, Type: "internet-gateway"} + g.Resources = append(g.Resources, ref) + + if igw.VpcID != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: igw.VpcID, Type: "vpc"}, + Type: "attached-to", + }) + } + } + + return nil +} + +func addPeeringConnections(ctx context.Context, e *Engine, g *DependencyGraph) error { + peerings, err := e.networking.DescribePeeringConnections(ctx, nil) + if err != nil { + return err + } + + for _, p := range peerings { + ref := ResourceRef{ID: p.ID, Type: "peering-connection", Name: p.Status} + g.Resources = append(g.Resources, ref) + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: p.RequesterVPC, Type: "vpc"}, + Type: "peers-with", + }) + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: p.AccepterVPC, Type: "vpc"}, + Type: "peers-with", + }) + } + + return nil +} + +func addNetworkACLs(ctx context.Context, e *Engine, g *DependencyGraph) error { + acls, err := e.networking.DescribeNetworkACLs(ctx, nil) + if err != nil { + return err + } + + for _, acl := range acls { + addVPCMemberResources(g, acl.ID, "network-acl", "", acl.VPCID) + } + + return nil +} + +func addDNSRecords(ctx context.Context, e *Engine, g *DependencyGraph) error { + zones, err := e.dns.ListZones(ctx) + if err != nil { + return err + } + + for _, zone := range zones { + zoneRef := ResourceRef{ID: zone.ID, Type: "dns-zone", Name: zone.Name} + g.Resources = append(g.Resources, zoneRef) + + if err := addZoneRecords(ctx, e, g, zone.ID, zoneRef); err != nil { + return err + } + } + + return nil +} + +func addZoneRecords( + ctx context.Context, + e *Engine, + g *DependencyGraph, + zoneID string, + zoneRef ResourceRef, +) error { + records, err := e.dns.ListRecords(ctx, zoneID) + if err != nil { + return err + } + + for _, rec := range records { + recRef := ResourceRef{ID: rec.Name, Type: "dns-record", Name: rec.Type} + g.Resources = append(g.Resources, recRef) + g.Dependencies = append(g.Dependencies, Dependency{ + From: recRef, + To: zoneRef, + Type: "member-of", + }) + } + + return nil +} + +func addVolumes(ctx context.Context, e *Engine, g *DependencyGraph) error { + volumes, err := e.compute.DescribeVolumes(ctx, nil) + if err != nil { + return err + } + + for i := range volumes { + vol := &volumes[i] + ref := ResourceRef{ID: vol.ID, Type: "volume", Name: vol.VolumeType} + g.Resources = append(g.Resources, ref) + + if vol.AttachedTo != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: vol.AttachedTo, Type: "instance"}, + Type: "attached-to", + }) + } + } + + return nil +} + +func addLoadBalancers(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.loadbalancer == nil { + return nil + } + + lbs, err := e.loadbalancer.DescribeLoadBalancers(ctx, nil) + if err != nil { + return err + } + + for i := range lbs { + lb := &lbs[i] + ref := ResourceRef{ID: lb.ARN, Type: "load-balancer", Name: lb.Name} + g.Resources = append(g.Resources, ref) + + for _, subnetID := range lb.Subnets { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: subnetID, Type: "subnet"}, + Type: "member-of", + }) + } + } + + return nil +} + +func addTargetGroups(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.loadbalancer == nil { + return nil + } + + tgs, err := e.loadbalancer.DescribeTargetGroups(ctx, nil) + if err != nil { + return err + } + + for _, tg := range tgs { + ref := ResourceRef{ID: tg.ARN, Type: "target-group", Name: tg.Name} + g.Resources = append(g.Resources, ref) + + if tg.VPCID != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: tg.VPCID, Type: "vpc"}, + Type: "member-of", + }) + } + + addTargetHealthDeps(ctx, e, g, tg.ARN, ref) + } + + return nil +} + +func addTargetHealthDeps( + ctx context.Context, + e *Engine, + g *DependencyGraph, + tgARN string, + tgRef ResourceRef, +) { + targets, err := e.loadbalancer.DescribeTargetHealth(ctx, tgARN) + if err != nil { + return + } + + for _, th := range targets { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ResourceRef{ID: th.Target.ID, Type: "instance"}, + To: tgRef, + Type: "member-of", + }) + } +} + +func addListeners(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.loadbalancer == nil { + return nil + } + + lbs, err := e.loadbalancer.DescribeLoadBalancers(ctx, nil) + if err != nil { + return err + } + + for i := range lbs { + if err := addLBListeners(ctx, e.loadbalancer, g, lbs[i].ARN); err != nil { + return err + } + } + + return nil +} + +func addLBListeners( + ctx context.Context, + lb lbdriver.LoadBalancer, + g *DependencyGraph, + lbARN string, +) error { + listeners, err := lb.DescribeListeners(ctx, lbARN) + if err != nil { + return err + } + + for _, lis := range listeners { + ref := ResourceRef{ID: lis.ARN, Type: "listener"} + g.Resources = append(g.Resources, ref) + + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: lis.LBARN, Type: "load-balancer"}, + Type: "belongs-to", + }) + + if lis.TargetGroupARN != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: lis.TargetGroupARN, Type: "target-group"}, + Type: "routes-to", + }) + } + } + + return nil +} + +func addFunctions(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.serverless == nil { + return nil + } + + fns, err := e.serverless.ListFunctions(ctx) + if err != nil { + return err + } + + for i := range fns { + fn := &fns[i] + fnRef := ResourceRef{ID: fn.ARN, Type: "function", Name: fn.Name} + g.Resources = append(g.Resources, fnRef) + + addESMDeps(ctx, e, g, fn.Name, fnRef) + } + + return nil +} + +func addESMDeps(ctx context.Context, e *Engine, g *DependencyGraph, fnName string, fnRef ResourceRef) { + esms, err := e.serverless.ListEventSourceMappings(ctx, fnName) + if err != nil { + return + } + + for _, esm := range esms { + esmRef := ResourceRef{ID: esm.UUID, Type: "event-source-mapping"} + g.Resources = append(g.Resources, esmRef) + + g.Dependencies = append(g.Dependencies, Dependency{ + From: esmRef, + To: fnRef, + Type: "triggers", + }) + + if esm.EventSourceArn != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: esmRef, + To: ResourceRef{ID: esm.EventSourceArn, Type: "queue"}, + Type: "triggered-by", + }) + } + } +} + +func addQueues(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.messagequeue == nil { + return nil + } + + queues, err := e.messagequeue.ListQueues(ctx, "") + if err != nil { + return err + } + + for _, q := range queues { + g.Resources = append(g.Resources, ResourceRef{ + ID: q.ARN, Type: "queue", Name: q.Name, + }) + } + + return nil +} + +func addAlarms(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.monitoring == nil { + return nil + } + + alarms, err := e.monitoring.DescribeAlarms(ctx, nil) + if err != nil { + return err + } + + for _, alarm := range alarms { + g.Resources = append(g.Resources, ResourceRef{ + ID: alarm.Name, Type: "alarm", Name: alarm.MetricName, + }) + } + + return nil +} + +func addNotificationChannels(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.monitoring == nil { + return nil + } + + channels, err := e.monitoring.ListNotificationChannels(ctx) + if err != nil { + return err + } + + for _, ch := range channels { + g.Resources = append(g.Resources, ResourceRef{ + ID: ch.ID, Type: "notification-channel", Name: ch.Name, + }) + } + + return nil +} + +func addInstanceProfiles(ctx context.Context, e *Engine, g *DependencyGraph) error { + if e.iam == nil { + return nil + } + + profiles, err := e.iam.ListInstanceProfiles(ctx) + if err != nil { + return err + } + + for _, p := range profiles { + ref := ResourceRef{ID: p.Name, Type: "instance-profile", Name: p.RoleName} + g.Resources = append(g.Resources, ref) + + if p.RoleName != "" { + g.Dependencies = append(g.Dependencies, Dependency{ + From: ref, + To: ResourceRef{ID: p.RoleName, Type: "role"}, + Type: "uses", + }) + } + } + + return nil +} diff --git a/topology/topology.go b/topology/topology.go index d4c3413..4467b0f 100644 --- a/topology/topology.go +++ b/topology/topology.go @@ -3,7 +3,12 @@ package topology import ( computedriver "github.com/stackshy/cloudemu/compute/driver" dnsdriver "github.com/stackshy/cloudemu/dns/driver" + iamdriver "github.com/stackshy/cloudemu/iam/driver" + lbdriver "github.com/stackshy/cloudemu/loadbalancer/driver" + mqdriver "github.com/stackshy/cloudemu/messagequeue/driver" + monitoringdriver "github.com/stackshy/cloudemu/monitoring/driver" netdriver "github.com/stackshy/cloudemu/networking/driver" + serverlessdriver "github.com/stackshy/cloudemu/serverless/driver" ) // Engine evaluates network topology and connectivity between cloud resources. @@ -11,18 +16,67 @@ type Engine struct { compute computedriver.Compute networking netdriver.Networking dns dnsdriver.DNS + + // Optional cross-service drivers. + loadbalancer lbdriver.LoadBalancer + serverless serverlessdriver.Serverless + messagequeue mqdriver.MessageQueue + monitoring monitoringdriver.Monitoring + iam iamdriver.IAM + provider string +} + +// Option configures optional Engine capabilities. +type Option func(*Engine) + +// WithLoadBalancer adds load balancer support to the topology engine. +func WithLoadBalancer(lb lbdriver.LoadBalancer) Option { + return func(e *Engine) { e.loadbalancer = lb } +} + +// WithServerless adds serverless function support to the topology engine. +func WithServerless(s serverlessdriver.Serverless) Option { + return func(e *Engine) { e.serverless = s } +} + +// WithMessageQueue adds message queue support to the topology engine. +func WithMessageQueue(mq mqdriver.MessageQueue) Option { + return func(e *Engine) { e.messagequeue = mq } +} + +// WithMonitoring adds monitoring support to the topology engine. +func WithMonitoring(m monitoringdriver.Monitoring) Option { + return func(e *Engine) { e.monitoring = m } +} + +// WithIAM adds IAM support to the topology engine. +func WithIAM(i iamdriver.IAM) Option { + return func(e *Engine) { e.iam = i } +} + +// WithProvider sets the provider name used in ResourceRef.Provider fields. +func WithProvider(name string) Option { + return func(e *Engine) { e.provider = name } } // New creates a new topology Engine that reads state from the provided -// compute, networking, and DNS services. +// compute, networking, and DNS services. Additional drivers can be added +// via functional options. func New( compute computedriver.Compute, networking netdriver.Networking, dns dnsdriver.DNS, + opts ...Option, ) *Engine { - return &Engine{ + e := &Engine{ compute: compute, networking: networking, dns: dns, } + + for _, opt := range opts { + opt(e) + } + + return e } diff --git a/topology/topology_test.go b/topology/topology_test.go index 03f631c..6e38b9d 100644 --- a/topology/topology_test.go +++ b/topology/topology_test.go @@ -8,9 +8,19 @@ import ( computedriver "github.com/stackshy/cloudemu/compute/driver" "github.com/stackshy/cloudemu/config" dnsdriver "github.com/stackshy/cloudemu/dns/driver" + lbdriver "github.com/stackshy/cloudemu/loadbalancer/driver" + iamdriver "github.com/stackshy/cloudemu/iam/driver" + monitoringdriver "github.com/stackshy/cloudemu/monitoring/driver" + mqdriver "github.com/stackshy/cloudemu/messagequeue/driver" netdriver "github.com/stackshy/cloudemu/networking/driver" + serverlessdriver "github.com/stackshy/cloudemu/serverless/driver" + "github.com/stackshy/cloudemu/providers/aws/awsiam" + "github.com/stackshy/cloudemu/providers/aws/cloudwatch" "github.com/stackshy/cloudemu/providers/aws/ec2" + "github.com/stackshy/cloudemu/providers/aws/elb" + "github.com/stackshy/cloudemu/providers/aws/lambda" "github.com/stackshy/cloudemu/providers/aws/route53" + "github.com/stackshy/cloudemu/providers/aws/sqs" "github.com/stackshy/cloudemu/providers/aws/vpc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -634,3 +644,549 @@ func TestResolveNotFound(t *testing.T) { require.NoError(t, err) assert.Nil(t, values) } + +// createGraphResources is a helper that creates a VPC, subnet, SG, and instance. +// It returns their IDs for graph verification. +func createGraphResources( + t *testing.T, + ctx context.Context, + ec2Mock *ec2.Mock, + vpcMock *vpc.Mock, +) (vpcID, subnetID, sgID, instanceID string) { + t.Helper() + + v, err := vpcMock.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + require.NoError(t, err) + + subnet, err := vpcMock.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: v.ID, CIDRBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a", + }) + require.NoError(t, err) + + sg, err := vpcMock.CreateSecurityGroup(ctx, netdriver.SecurityGroupConfig{ + VPCID: v.ID, Name: "graph-sg", Description: "graph test", + }) + require.NoError(t, err) + + instances, err := ec2Mock.RunInstances(ctx, computedriver.InstanceConfig{ + ImageID: "ami-test", InstanceType: "t2.micro", + SubnetID: subnet.ID, SecurityGroups: []string{sg.ID}, + }, 1) + require.NoError(t, err) + + err = ec2Mock.SetInstanceVPC(instances[0].ID, v.ID) + require.NoError(t, err) + + return v.ID, subnet.ID, sg.ID, instances[0].ID +} + +func TestBuildDependencyGraph(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, sgID, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + require.NotNil(t, graph) + + // Verify all resources are present. + ids := resourceIDs(graph) + assert.Contains(t, ids, vpcID) + assert.Contains(t, ids, subnetID) + assert.Contains(t, ids, sgID) + assert.Contains(t, ids, instanceID) + + // Verify dependencies exist. + assert.True(t, hasDependency(graph, subnetID, vpcID, "member-of")) + assert.True(t, hasDependency(graph, sgID, vpcID, "member-of")) + assert.True(t, hasDependency(graph, instanceID, vpcID, "member-of")) + assert.True(t, hasDependency(graph, instanceID, subnetID, "member-of")) + assert.True(t, hasDependency(graph, instanceID, sgID, "secured-by")) +} + +func TestBlastRadius(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, sgID, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + report, err := engine.BlastRadius(ctx, vpcID) + require.NoError(t, err) + require.NotNil(t, report) + + assert.Equal(t, vpcID, report.Target.ID) + assert.Equal(t, "delete", report.Action) + + // Subnet and SG are direct dependents of VPC. + directIDs := refIDs(report.DirectlyAffected) + assert.Contains(t, directIDs, subnetID) + assert.Contains(t, directIDs, sgID) + + // Instance depends on subnet and SG, so it should appear in transitive impact. + allImpacted := append(refIDs(report.DirectlyAffected), refIDs(report.TransitiveImpact)...) + assert.Contains(t, allImpacted, instanceID) + assert.NotEmpty(t, report.BrokenConnections) + assert.NotEmpty(t, report.Summary) +} + +func TestDependsOn(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, sgID, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + deps, err := engine.DependsOn(ctx, instanceID) + require.NoError(t, err) + + depIDs := refIDs(deps) + assert.Contains(t, depIDs, vpcID) + assert.Contains(t, depIDs, subnetID) + assert.Contains(t, depIDs, sgID) +} + +func TestDependedBy(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, sgID, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + dependents, err := engine.DependedBy(ctx, vpcID) + require.NoError(t, err) + + depIDs := refIDs(dependents) + assert.Contains(t, depIDs, subnetID) + assert.Contains(t, depIDs, sgID) + assert.Contains(t, depIDs, instanceID) +} + +func TestExportDOT(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, _, _ := createGraphResources(t, ctx, ec2Mock, vpcMock) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + dot := graph.ExportDOT() + assert.Contains(t, dot, "digraph CloudEmu") + assert.Contains(t, dot, vpcID) + assert.Contains(t, dot, subnetID) + assert.Contains(t, dot, "member-of") +} + +func TestExportMermaid(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, _, _ := createGraphResources(t, ctx, ec2Mock, vpcMock) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + mermaid := graph.ExportMermaid() + assert.Contains(t, mermaid, "graph TD") + assert.Contains(t, mermaid, vpcID) + assert.Contains(t, mermaid, subnetID) + assert.Contains(t, mermaid, "member-of") +} + +func TestBlastRadiusNotFound(t *testing.T) { + engine, _, _, _ := newTestEngine() + ctx := context.Background() + + _, err := engine.BlastRadius(ctx, "nonexistent-resource") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func resourceIDs(g *DependencyGraph) []string { + ids := make([]string, 0, len(g.Resources)) + for _, r := range g.Resources { + ids = append(ids, r.ID) + } + + return ids +} + +func refIDs(refs []ResourceRef) []string { + ids := make([]string, 0, len(refs)) + for _, r := range refs { + ids = append(ids, r.ID) + } + + return ids +} + +func hasDependency(g *DependencyGraph, fromID, toID, depType string) bool { + for _, d := range g.Dependencies { + if d.From.ID == fromID && d.To.ID == toID && d.Type == depType { + return true + } + } + + return false +} + +func hasResourceType(g *DependencyGraph, resType string) bool { + for _, r := range g.Resources { + if r.Type == resType { + return true + } + } + + return false +} + +// newTestOpts returns shared config options for tests. +func newTestOpts() *config.Options { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + return config.NewOptions(config.WithClock(fc), config.WithRegion("us-east-1")) +} + +func TestVolumeInGraph(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, sgID, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + _ = vpcID + _ = subnetID + _ = sgID + + vol, err := ec2Mock.CreateVolume(ctx, computedriver.VolumeConfig{ + Size: 100, VolumeType: "gp2", AvailabilityZone: "us-east-1a", + }) + require.NoError(t, err) + + err = ec2Mock.AttachVolume(ctx, vol.ID, instanceID, "/dev/sdf") + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + assert.Contains(t, resourceIDs(graph), vol.ID) + assert.True(t, hasDependency(graph, vol.ID, instanceID, "attached-to")) +} + +func TestBlastRadiusWithVolumes(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + _, _, _, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + vol, err := ec2Mock.CreateVolume(ctx, computedriver.VolumeConfig{ + Size: 50, VolumeType: "gp2", AvailabilityZone: "us-east-1a", + }) + require.NoError(t, err) + + err = ec2Mock.AttachVolume(ctx, vol.ID, instanceID, "/dev/sdf") + require.NoError(t, err) + + report, err := engine.BlastRadius(ctx, instanceID) + require.NoError(t, err) + + // The volume depends on the instance, so it's a direct dependent. + directIDs := refIDs(report.DirectlyAffected) + assert.Contains(t, directIDs, vol.ID) +} + +func TestLBChainGraph(t *testing.T) { + opts := newTestOpts() + ec2Mock := ec2.New(opts) + vpcMock := vpc.New(opts) + dnsMock := route53.New(opts) + elbMock := elb.New(opts) + engine := New(ec2Mock, vpcMock, dnsMock, WithLoadBalancer(elbMock)) + ctx := context.Background() + + v, err := vpcMock.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + require.NoError(t, err) + + subnet, err := vpcMock.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: v.ID, CIDRBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a", + }) + require.NoError(t, err) + + sg, err := vpcMock.CreateSecurityGroup(ctx, netdriver.SecurityGroupConfig{ + VPCID: v.ID, Name: "lb-sg", Description: "lb test", + }) + require.NoError(t, err) + + instances, err := ec2Mock.RunInstances(ctx, computedriver.InstanceConfig{ + ImageID: "ami-test", InstanceType: "t2.micro", + SubnetID: subnet.ID, SecurityGroups: []string{sg.ID}, + }, 1) + require.NoError(t, err) + + err = ec2Mock.SetInstanceVPC(instances[0].ID, v.ID) + require.NoError(t, err) + + lb, err := elbMock.CreateLoadBalancer(ctx, lbdriver.LBConfig{ + Name: "test-lb", Type: "application", Scheme: "internal", + Subnets: []string{subnet.ID}, + }) + require.NoError(t, err) + + tg, err := elbMock.CreateTargetGroup(ctx, lbdriver.TargetGroupConfig{ + Name: "test-tg", Protocol: "HTTP", Port: 80, VPCID: v.ID, + }) + require.NoError(t, err) + + lis, err := elbMock.CreateListener(ctx, lbdriver.ListenerConfig{ + LBARN: lb.ARN, Protocol: "HTTP", Port: 80, TargetGroupARN: tg.ARN, + }) + require.NoError(t, err) + + err = elbMock.RegisterTargets(ctx, tg.ARN, []lbdriver.Target{ + {ID: instances[0].ID, Port: 80}, + }) + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + // Verify LB chain resources exist. + ids := resourceIDs(graph) + assert.Contains(t, ids, lb.ARN) + assert.Contains(t, ids, tg.ARN) + assert.Contains(t, ids, lis.ARN) + + // Verify LB chain dependencies. + assert.True(t, hasDependency(graph, lb.ARN, subnet.ID, "member-of")) + assert.True(t, hasDependency(graph, tg.ARN, v.ID, "member-of")) + assert.True(t, hasDependency(graph, lis.ARN, lb.ARN, "belongs-to")) + assert.True(t, hasDependency(graph, lis.ARN, tg.ARN, "routes-to")) + assert.True(t, hasDependency(graph, instances[0].ID, tg.ARN, "member-of")) +} + +func TestFunctionESMGraph(t *testing.T) { + opts := newTestOpts() + ec2Mock := ec2.New(opts) + vpcMock := vpc.New(opts) + dnsMock := route53.New(opts) + lambdaMock := lambda.New(opts) + sqsMock := sqs.New(opts) + engine := New(ec2Mock, vpcMock, dnsMock, + WithServerless(lambdaMock), + WithMessageQueue(sqsMock), + ) + ctx := context.Background() + + fn, err := lambdaMock.CreateFunction(ctx, serverlessdriver.FunctionConfig{ + Name: "my-func", Runtime: "go1.x", Handler: "main", + }) + require.NoError(t, err) + + q, err := sqsMock.CreateQueue(ctx, mqdriver.QueueConfig{Name: "my-queue"}) + require.NoError(t, err) + + esm, err := lambdaMock.CreateEventSourceMapping(ctx, serverlessdriver.EventSourceMappingConfig{ + EventSourceArn: q.ARN, FunctionName: fn.Name, BatchSize: 10, Enabled: true, + }) + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + ids := resourceIDs(graph) + assert.Contains(t, ids, fn.ARN) + assert.Contains(t, ids, q.ARN) + assert.Contains(t, ids, esm.UUID) + + // ESM triggers the function. + assert.True(t, hasDependency(graph, esm.UUID, fn.ARN, "triggers")) + // ESM is triggered by the queue. + assert.True(t, hasDependency(graph, esm.UUID, q.ARN, "triggered-by")) +} + +func TestAlarmNotificationGraph(t *testing.T) { + opts := newTestOpts() + ec2Mock := ec2.New(opts) + vpcMock := vpc.New(opts) + dnsMock := route53.New(opts) + cwMock := cloudwatch.New(opts) + engine := New(ec2Mock, vpcMock, dnsMock, WithMonitoring(cwMock)) + ctx := context.Background() + + ch, err := cwMock.CreateNotificationChannel(ctx, monitoringdriver.NotificationChannelConfig{ + Name: "ops-channel", Type: "email", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + err = cwMock.CreateAlarm(ctx, monitoringdriver.AlarmConfig{ + Name: "high-cpu", Namespace: "AWS/EC2", MetricName: "CPUUtilization", + ComparisonOperator: "GreaterThanThreshold", Threshold: 80, + Period: 300, EvaluationPeriods: 1, Stat: "Average", + AlarmActions: []string{ch.ID}, + }) + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + assert.True(t, hasResourceType(graph, "alarm")) + assert.True(t, hasResourceType(graph, "notification-channel")) +} + +func TestInstanceProfileGraph(t *testing.T) { + opts := newTestOpts() + ec2Mock := ec2.New(opts) + vpcMock := vpc.New(opts) + dnsMock := route53.New(opts) + iamMock := awsiam.New(opts) + engine := New(ec2Mock, vpcMock, dnsMock, WithIAM(iamMock)) + ctx := context.Background() + + _, err := iamMock.CreateRole(ctx, iamdriver.RoleConfig{Name: "test-role"}) + require.NoError(t, err) + + profile, err := iamMock.CreateInstanceProfile(ctx, iamdriver.InstanceProfileConfig{ + Name: "test-profile", RoleName: "test-role", + }) + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + assert.Contains(t, resourceIDs(graph), profile.Name) + assert.True(t, hasDependency(graph, profile.Name, "test-role", "uses")) +} + +func TestWhatIfDelete(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, _, _, _ := createGraphResources(t, ctx, ec2Mock, vpcMock) + + report, err := engine.WhatIf(ctx, "delete", vpcID) + require.NoError(t, err) + assert.Equal(t, "delete", report.Action) + assert.NotEmpty(t, report.DirectlyAffected) +} + +func TestWhatIfStop(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + _, _, _, instanceID := createGraphResources(t, ctx, ec2Mock, vpcMock) + + report, err := engine.WhatIf(ctx, "stop", instanceID) + require.NoError(t, err) + assert.Equal(t, "stop", report.Action) + assert.Equal(t, instanceID, report.Target.ID) + assert.NotEmpty(t, report.BrokenConnections) + assert.Contains(t, report.Summary, "stopping") +} + +func TestWhatIfDisconnect(t *testing.T) { + engine, _, vpcMock, _ := newTestEngine() + ctx := context.Background() + + v1, err := vpcMock.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + require.NoError(t, err) + + v2, err := vpcMock.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.1.0.0/16"}) + require.NoError(t, err) + + peering, err := vpcMock.CreatePeeringConnection(ctx, netdriver.PeeringConfig{ + RequesterVPC: v1.ID, AccepterVPC: v2.ID, + }) + require.NoError(t, err) + + err = vpcMock.AcceptPeeringConnection(ctx, peering.ID) + require.NoError(t, err) + + report, err := engine.WhatIf(ctx, "disconnect", peering.ID) + require.NoError(t, err) + assert.Equal(t, "disconnect", report.Action) + assert.NotEmpty(t, report.BrokenConnections) + assert.Contains(t, report.Summary, "disconnecting") +} + +func TestWhatIfInvalidAction(t *testing.T) { + engine, _, _, _ := newTestEngine() + ctx := context.Background() + + _, err := engine.WhatIf(ctx, "explode", "fake-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported action") +} + +func TestOrphanedResources(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + vpcID, subnetID, _, _ := createGraphResources(t, ctx, ec2Mock, vpcMock) + + // Create a second subnet in the same VPC. + subnet2, err := vpcMock.CreateSubnet(ctx, netdriver.SubnetConfig{ + VPCID: vpcID, CIDRBlock: "10.0.2.0/24", AvailabilityZone: "us-east-1b", + }) + require.NoError(t, err) + + _ = subnetID + + // Deleting the VPC should orphan nothing (subnets are direct dependents). + // But let's check that blast radius includes orphaned info. + report, err := engine.BlastRadius(ctx, vpcID) + require.NoError(t, err) + assert.NotNil(t, report) + + // Both subnets should be direct dependents. + directIDs := refIDs(report.DirectlyAffected) + assert.Contains(t, directIDs, subnetID) + assert.Contains(t, directIDs, subnet2.ID) +} + +func TestProviderField(t *testing.T) { + opts := newTestOpts() + ec2Mock := ec2.New(opts) + vpcMock := vpc.New(opts) + dnsMock := route53.New(opts) + engine := New(ec2Mock, vpcMock, dnsMock, WithProvider("aws")) + ctx := context.Background() + + _, err := vpcMock.CreateVPC(ctx, netdriver.VPCConfig{CIDRBlock: "10.0.0.0/16"}) + require.NoError(t, err) + + graph, err := engine.BuildDependencyGraph(ctx) + require.NoError(t, err) + + for _, r := range graph.Resources { + assert.Equal(t, "aws", r.Provider) + } +} + +func TestGetDependencyGraph(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + createGraphResources(t, ctx, ec2Mock, vpcMock) + + graph, err := engine.GetDependencyGraph(ctx) + require.NoError(t, err) + assert.NotEmpty(t, graph.Resources) +} + +func TestEngineExportDOT(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + createGraphResources(t, ctx, ec2Mock, vpcMock) + + dot, err := engine.ExportDOT(ctx) + require.NoError(t, err) + assert.Contains(t, dot, "digraph CloudEmu") +} + +func TestEngineExportMermaid(t *testing.T) { + engine, ec2Mock, vpcMock, _ := newTestEngine() + ctx := context.Background() + + createGraphResources(t, ctx, ec2Mock, vpcMock) + + mermaid, err := engine.ExportMermaid(ctx) + require.NoError(t, err) + assert.Contains(t, mermaid, "graph TD") +}