diff --git a/cmd/cloudemu/lifecycle_other.go b/cmd/cloudemu/lifecycle_other.go index bad6d439..67873d1f 100644 --- a/cmd/cloudemu/lifecycle_other.go +++ b/cmd/cloudemu/lifecycle_other.go @@ -16,3 +16,9 @@ func runLifecycle(_ string, _ []string) error { func runSnapshot(_ []string) error { return errors.New("snapshot is only supported on Unix/macOS") } + +// runNet is unavailable off Unix for the same reason: it talks to the +// background daemon via its run directory. +func runNet(_ []string) error { + return errors.New("net is only supported on Unix/macOS") +} diff --git a/cmd/cloudemu/main.go b/cmd/cloudemu/main.go index 098f8c0f..aeaa7e3e 100644 --- a/cmd/cloudemu/main.go +++ b/cmd/cloudemu/main.go @@ -21,6 +21,7 @@ Usage: cloudemu logs [-f] Print (or follow) the background emulator's log cloudemu delete Stop the emulator and remove its run directory cloudemu snapshot ... Save/load/list/delete named state snapshots + cloudemu net ... Check network reachability (can-connect, trace) cloudemu serve [flags] Run the server in the foreground (see: cloudemu serve -h) cloudemu version Print the version cloudemu help Show this message @@ -38,6 +39,7 @@ const ( cmdLogs = "logs" cmdDelete = "delete" cmdSnapshot = "snapshot" + cmdNet = "net" ) // version is overridable at build time with @@ -66,6 +68,11 @@ func main() { fmt.Fprintln(os.Stderr, "cloudemu:", err) os.Exit(1) } + case cmdNet: + if err := runNet(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "cloudemu:", err) + os.Exit(1) + } case "version", "-v", "--version": fmt.Println("cloudemu", version) case "help", "-h", "--help": diff --git a/cmd/cloudemu/net.go b/cmd/cloudemu/net.go new file mode 100644 index 00000000..d85b2115 --- /dev/null +++ b/cmd/cloudemu/net.go @@ -0,0 +1,220 @@ +//go:build unix + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/stackshy/cloudemu/v2/features/topology" +) + +// netPositionalArgs is the number of positional args both subcommands take +// (can-connect A B; trace A destIP). +const netPositionalArgs = 2 + +var ( + errNetUsage = errors.New("usage: cloudemu net [--port N] [--protocol tcp] | " + + "trace > [--json] [--home dir]") + errNetServer = errors.New("network query failed") +) + +// parseNetFlags splits --home/--port/--protocol/--json out of args, returning +// the remaining positional args. +func parseNetFlags(args []string) (home, port, proto string, jsonOut bool, pos []string) { + home, rest := splitHomeFlag(args) + pos = make([]string, 0, len(rest)) + + for i := 0; i < len(rest); i++ { + switch a := rest[i]; { + case a == "--json": + jsonOut = true + case a == "--port" && i+1 < len(rest): + port = rest[i+1] + i++ + case strings.HasPrefix(a, "--port="): + port = strings.TrimPrefix(a, "--port=") + case a == "--protocol" && i+1 < len(rest): + proto = rest[i+1] + i++ + case strings.HasPrefix(a, "--protocol="): + proto = strings.TrimPrefix(a, "--protocol=") + default: + pos = append(pos, a) + } + } + + return home, port, proto, jsonOut, pos +} + +// runNet dispatches the net can-connect / trace subcommands. +func runNet(args []string) error { + if len(args) == 0 { + return errNetUsage + } + + home, port, proto, jsonOut, pos := parseNetFlags(args[1:]) + if len(pos) != netPositionalArgs { + return errNetUsage + } + + dir, err := runDir(home) + if err != nil { + return err + } + + base, err := adminBaseURL(dir) + if err != nil { + return err + } + + switch args[0] { + case "can-connect": + return netCanConnect(base, pos[0], pos[1], port, proto, jsonOut) + case "trace": + return netTrace(base, pos[0], pos[1], jsonOut) + default: + return errNetUsage + } +} + +func netCanConnect(base, from, to, port, proto string, jsonOut bool) error { + q := url.Values{} + q.Set("from", from) + q.Set("to", to) + + if port != "" { + q.Set("port", port) + } + + if proto != "" { + q.Set("protocol", proto) + } + + body, err := netGET(base, "net/can-connect", q) + if err != nil { + return err + } + + if jsonOut { + fmt.Println(string(body)) + + return nil + } + + var res topology.ConnectivityResult + if err := json.Unmarshal(body, &res); err != nil { + return err + } + + verdict := "NO" + if res.Allowed { + verdict = "YES" + } + + fmt.Printf("%s — %s\n", verdict, res.Reason) + printHops(res.Path) + + return nil +} + +func netTrace(base, from, dest string, jsonOut bool) error { + q := url.Values{} + q.Set("from", from) + q.Set("to", dest) + + body, err := netGET(base, "net/trace", q) + if err != nil { + return err + } + + if jsonOut { + fmt.Println(string(body)) + + return nil + } + + var out struct { + Hops []topology.RouteHop `json:"hops"` + } + + if err := json.Unmarshal(body, &out); err != nil { + return err + } + + printHops(out.Hops) + + return nil +} + +func printHops(hops []topology.RouteHop) { + for i := range hops { + h := &hops[i] + line := " → " + h.Type + + if h.ResourceID != "" { + line += " " + h.ResourceID + } + + if h.Detail != "" { + line += " (" + h.Detail + ")" + } + + fmt.Println(line) + } +} + +// netGET calls a /_cloudemu/ control path and returns the body, +// mapping the control plane's error statuses to clear CLI errors. +func netGET(base, endpoint string, q url.Values) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout) + defer cancel() + + u := base + "/_cloudemu/" + endpoint + if len(q) > 0 { + u += "?" + q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusNotImplemented { + return nil, errSnapAdminOff + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %s", errNetServer, serverErrMsg(b, resp.Status)) + } + + return b, nil +} + +// serverErrMsg extracts the {"error":...} message from a control-plane response, +// falling back to the HTTP status. +func serverErrMsg(body []byte, status string) string { + var e struct { + Error string `json:"error"` + } + + if err := json.Unmarshal(body, &e); err == nil && e.Error != "" { + return e.Error + } + + return status +} diff --git a/cmd/cloudemu/net_serve_test.go b/cmd/cloudemu/net_serve_test.go new file mode 100644 index 00000000..08653aac --- /dev/null +++ b/cmd/cloudemu/net_serve_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/features/topology" + "github.com/stackshy/cloudemu/v2/providers/aws/ec2" + "github.com/stackshy/cloudemu/v2/providers/aws/route53" + "github.com/stackshy/cloudemu/v2/providers/aws/vpc" +) + +func emptyNetEngine() *topology.Engine { + o := config.NewOptions() + + return topology.New(ec2.New(o), vpc.New(o), route53.New(o)) +} + +func TestNetPort(t *testing.T) { + if p, err := netPort(""); err != nil || p != 0 { + t.Fatalf("netPort(empty) = %d, %v", p, err) + } + if p, err := netPort("5432"); err != nil || p != 5432 { + t.Fatalf("netPort(5432) = %d, %v", p, err) + } + if _, err := netPort("nope"); err == nil { + t.Fatal("netPort(nope) = nil error, want error") + } +} + +func TestServeCanConnectValidation(t *testing.T) { + eng := emptyNetEngine() + + // Missing from/to → 400. + rec := httptest.NewRecorder() + serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect", nil), eng) + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing params = %d, want 400", rec.Code) + } + + // Invalid port → 400. + rec = httptest.NewRecorder() + serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect?from=i-a&to=i-b&port=x", nil), eng) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad port = %d, want 400", rec.Code) + } + + // Unknown instance → engine NotFound → 400 with an error body. + rec = httptest.NewRecorder() + serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect?from=i-a&to=i-b&port=80", nil), eng) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "error") { + t.Fatalf("unknown instance = %d %q, want 400 error", rec.Code, rec.Body.String()) + } +} + +func TestServeTraceValidation(t *testing.T) { + eng := emptyNetEngine() + + // Missing destination IP → 400. + rec := httptest.NewRecorder() + serveTrace(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/trace?from=i-a", nil), eng) + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing dest = %d, want 400", rec.Code) + } + + // Unknown instance → engine error → 400. + rec = httptest.NewRecorder() + serveTrace(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/trace?from=i-a&to=10.0.0.5", nil), eng) + if rec.Code != http.StatusBadRequest { + t.Fatalf("unknown instance trace = %d, want 400", rec.Code) + } +} diff --git a/cmd/cloudemu/serve.go b/cmd/cloudemu/serve.go index c398252c..22d138ec 100644 --- a/cmd/cloudemu/serve.go +++ b/cmd/cloudemu/serve.go @@ -13,6 +13,7 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" "sync" "syscall" @@ -20,6 +21,7 @@ import ( "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/features/topology" "github.com/stackshy/cloudemu/v2/persist" eksprov "github.com/stackshy/cloudemu/v2/providers/aws/eks" "github.com/stackshy/cloudemu/v2/seed" @@ -148,6 +150,7 @@ func runServe(args []string) error { var ( rebuildMu sync.Mutex targets map[string]seed.Target // provider → current drivers, for seeding + netEngine *topology.Engine // AWS network-reachability engine (nil if aws not selected) ) rebuild := func() { // Serialise resets so two concurrent /_cloudemu/reset calls can't @@ -180,6 +183,9 @@ func runServe(args []string) error { } fresh := make(map[string]http.Handler, len(sel)) freshTargets := make(map[string]seed.Target, len(sel)) + + var freshEngine *topology.Engine + for _, p := range sel { switch p { case "aws": @@ -193,6 +199,7 @@ func runServe(args []string) error { cloud.EKS.SetK8sAPI(k8s) fresh["aws"] = wrap(awsserver.New(d), "aws", c.logReqs) freshTargets["aws"] = seed.Target{Storage: cloud.S3, Database: cloud.DynamoDB, Secrets: cloud.SecretsManager, Compute: cloud.EC2} + freshEngine = topology.New(cloud.EC2, cloud.VPC, cloud.Route53) case "gcp": cloud := cloudemu.NewGCP(opts...) d := gcpserver.DriversFrom(cloud) @@ -216,6 +223,7 @@ func runServe(args []string) error { backends[p].Swap(h) } targets = freshTargets + netEngine = freshEngine } rebuild() // populate the backends before serving @@ -307,13 +315,38 @@ func runServe(args []string) error { return persist.RestoreAll(context.Background(), &snap, cur) } + // netHandler serves the network-topology control endpoints + // (/_cloudemu/net/*) using the live AWS reachability engine. Topology is an + // AWS concept (VPC/SG/route/NACL), so a nil engine (aws not selected) yields + // a clear error rather than a wrong answer. + netHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rebuildMu.Lock() + eng := netEngine + rebuildMu.Unlock() + + if eng == nil { + writeNetErr(w, http.StatusServiceUnavailable, "network topology requires the aws provider") + + return + } + + switch strings.TrimPrefix(r.URL.Path, admin.Prefix) { + case "net/can-connect": + serveCanConnect(w, r, eng) + case "net/trace": + serveTrace(w, r, eng) + default: + writeNetErr(w, http.StatusNotFound, "unknown control endpoint") + } + }) + // handlerFor fronts a backend with the /_cloudemu control plane. With the // admin API off the backend serves directly, so control paths fall through // to the wire handlers (whatever they return for an unrouted path). seedFn // may be nil (e.g. the Kubernetes port), which disables the seed endpoint. handlerFor := func(b *admin.Backend, seedFn func([]byte) (int, error)) http.Handler { if c.admin { - return admin.NewControl(b, rebuild, seedFn, snapshotFn, restoreFn) + return admin.NewControl(b, rebuild, seedFn, snapshotFn, restoreFn, netHandler) } return b } @@ -458,6 +491,97 @@ func restoreState(ctx context.Context, path string, targets map[string]seed.Targ return persist.RestoreAll(ctx, &snap, targets) } +// serveCanConnect answers GET /_cloudemu/net/can-connect?from&to&port&protocol +// with the engine's ConnectivityResult as JSON. +func serveCanConnect(w http.ResponseWriter, r *http.Request, eng *topology.Engine) { + q := r.URL.Query() + + from, to := q.Get("from"), q.Get("to") + if from == "" || to == "" { + writeNetErr(w, http.StatusBadRequest, "from and to instance IDs are required") + + return + } + + port, err := netPort(q.Get("port")) + if err != nil { + writeNetErr(w, http.StatusBadRequest, err.Error()) + + return + } + + proto := q.Get("protocol") + if proto == "" { + proto = "tcp" + } + + res, err := eng.CanConnect(r.Context(), topology.ConnectivityQuery{ + SrcInstanceID: from, DstInstanceID: to, Port: port, Protocol: proto, + }) + if err != nil { + writeNetErr(w, http.StatusBadRequest, err.Error()) + + return + } + + writeNetJSON(w, res) +} + +// serveTrace answers GET /_cloudemu/net/trace?from&to (to is a destination IP) +// with the route hops as JSON. +func serveTrace(w http.ResponseWriter, r *http.Request, eng *topology.Engine) { + q := r.URL.Query() + + from, dest := q.Get("from"), q.Get("to") + if from == "" || dest == "" { + writeNetErr(w, http.StatusBadRequest, "from instance ID and to IP are required") + + return + } + + hops, err := eng.TraceRoute(r.Context(), from, dest) + if err != nil { + writeNetErr(w, http.StatusBadRequest, err.Error()) + + return + } + + writeNetJSON(w, map[string]any{"hops": hops}) +} + +// netPort parses an optional port query value (empty → 0 = any). +func netPort(s string) (int, error) { + if s == "" { + return 0, nil + } + + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid port %q: %w", s, err) + } + + return n, nil +} + +func writeNetJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + + b, err := json.Marshal(v) + if err != nil { + writeNetErr(w, http.StatusInternalServerError, err.Error()) + + return + } + + _, _ = w.Write(b) +} + +func writeNetErr(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + // applyInitDir applies every *.json fixture in dir (lexical order) to every // running provider on boot, bringing the emulator up to a known state. A // missing dir is a no-op. A parse error fails startup (clear misconfiguration); diff --git a/docs/standalone-server.md b/docs/standalone-server.md index c4e9f73a..9c2a7ab0 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -179,6 +179,37 @@ the same schema as [`/_cloudemu/seed`](#resetting-state-between-tests-_cloudemu) (buckets, tables, secrets, instances). Running setup **scripts** on boot is a planned follow-up. +## Network reachability (`net can-connect` / `net trace`) + +cloudemu doesn't just emulate EC2/VPC APIs — it evaluates whether your security +groups, route tables, NACLs, and VPC peering would *actually* let traffic flow. +The `net` commands surface that engine, so you can answer "will my app reach the +database?" locally, before deploying to real AWS: + +```sh +# after creating VPC/subnets/security-groups/instances (e.g. via Terraform or +# the aws CLI pointed at cloudemu): +cloudemu net can-connect i-app i-db --port 5432 # YES / NO + why +cloudemu net trace i-app 10.0.2.15 # hop-by-hop path +cloudemu net can-connect i-app i-db --port 5432 --json # machine-readable, for CI +``` + +`can-connect` reports whether the two instances can talk on a port/protocol +(default `tcp`), and if not, which rule blocks it. `trace` shows the route a +packet from an instance to a destination IP takes (route table → gateway / NAT / +peering / local), or where it's dropped. + +This is AWS-only (VPC/security-group/route concepts) and needs the `aws` provider +running with the `--admin` control plane (both on by default). No other local +emulator evaluates network reachability — it's cloudemu's standout capability for +catching connectivity misconfigurations before they reach production. + +Note: unlike real AWS, a security group created in cloudemu has **no implicit +allow-all egress** rule, so `can-connect` requires an explicit egress rule on the +source group (`authorize-security-group-egress`) in addition to the ingress rule +on the destination. Launch instances with `--subnet-id` so they inherit the +subnet's VPC (that's what reachability and `trace` resolve against). + ## Ports | Provider | Default | Protocol | Notes | diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 3195db20..305c58c7 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -204,6 +204,7 @@ func New(opts ...config.Option) *Provider { p.RDS.SetMonitoring(p.CloudWatch) p.RDS.SetSubnetResolver(p.VPC) p.ElastiCache.SetSubnetResolver(p.VPC) + p.EC2.SetSubnetResolver(p.VPC) p.SSM.SetInstanceResolver(p.EC2) // ECS-registered container instances surface as managed EC2 instances, so // #159 (ECS) composes with #300 (EC2 managed-resource visibility). diff --git a/providers/aws/ec2/ec2.go b/providers/aws/ec2/ec2.go index fb9ed434..08376aec 100644 --- a/providers/aws/ec2/ec2.go +++ b/providers/aws/ec2/ec2.go @@ -118,6 +118,10 @@ type Mock struct { snapCounter atomic.Int64 amiCounter atomic.Int64 monitoring mondriver.Monitoring + // subnetResolver derives an instance's VPC from its subnet at launch, so + // instances created with a --subnet-id carry the VPCID that connectivity + // analysis and VPC teardown depend on. nil until wired by the provider. + subnetResolver SubnetResolver // mu guards managedResourceVisibility, which is scalar shared state that // (unlike the memstores) has no internal locking of its own. mu sync.RWMutex @@ -270,6 +274,7 @@ func (m *Mock) RunInstances(ctx context.Context, cfg driver.InstanceConfig, coun inst := &instanceData{ ID: id, ImageID: cfg.ImageID, InstanceType: cfg.InstanceType, State: compute.StatePending, PrivateIP: m.nextIP(), SubnetID: cfg.SubnetID, + VPCID: m.resolveSubnetVPC(ctx, cfg.SubnetID), SecurityGroups: sg, Tags: tags, LaunchTime: m.opts.Clock.Now().UTC().Format("2006-01-02T15:04:05Z"), } diff --git a/providers/aws/ec2/subnet_resolver.go b/providers/aws/ec2/subnet_resolver.go new file mode 100644 index 00000000..7a5800bb --- /dev/null +++ b/providers/aws/ec2/subnet_resolver.go @@ -0,0 +1,36 @@ +package ec2 + +import ( + "context" + + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +// SubnetResolver is the slice of the networking mock EC2 needs to derive an +// instance's VPC from its subnet at launch. Real EC2 infers VpcId from the +// subnet rather than taking it as input, and connectivity analysis / VPC +// teardown match on that field — so it has to be resolved, not left blank. +type SubnetResolver interface { + DescribeSubnets(ctx context.Context, ids []string) ([]netdriver.SubnetInfo, error) +} + +// SetSubnetResolver wires the networking mock in. Without it an instance +// launched with a subnet still records the subnet, but its VPCID is empty. +func (m *Mock) SetSubnetResolver(r SubnetResolver) { + m.subnetResolver = r +} + +// resolveSubnetVPC returns the VPC that owns subnetID, or "" when there is no +// subnet, no resolver, or the subnet can't be found. +func (m *Mock) resolveSubnetVPC(ctx context.Context, subnetID string) string { + if subnetID == "" || m.subnetResolver == nil { + return "" + } + + subs, err := m.subnetResolver.DescribeSubnets(ctx, []string{subnetID}) + if err != nil || len(subs) == 0 { + return "" + } + + return subs[0].VPCID +} diff --git a/providers/aws/ec2/subnet_resolver_test.go b/providers/aws/ec2/subnet_resolver_test.go new file mode 100644 index 00000000..ae428784 --- /dev/null +++ b/providers/aws/ec2/subnet_resolver_test.go @@ -0,0 +1,61 @@ +package ec2_test + +import ( + "context" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/aws/ec2" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" + netdriver "github.com/stackshy/cloudemu/v2/services/networking/driver" +) + +type fakeSubnetResolver struct{ vpc string } + +func (f fakeSubnetResolver) DescribeSubnets(_ context.Context, ids []string) ([]netdriver.SubnetInfo, error) { + if len(ids) == 0 { + return nil, nil + } + + return []netdriver.SubnetInfo{{ID: ids[0], VPCID: f.vpc}}, nil +} + +func newEC2(t *testing.T) *ec2.Mock { + t.Helper() + opts := config.NewOptions(config.WithClock(config.NewFakeClock(time.Unix(0, 0)))) + + return ec2.New(opts) +} + +// TestRunInstancesResolvesVPCFromSubnet is what makes the topology feature work +// via the wire: an instance launched with a subnet must carry the subnet's VPC. +func TestRunInstancesResolvesVPCFromSubnet(t *testing.T) { + ctx := context.Background() + m := newEC2(t) + m.SetSubnetResolver(fakeSubnetResolver{vpc: "vpc-abc"}) + + insts, err := m.RunInstances(ctx, computedriver.InstanceConfig{ImageID: "ami-1", SubnetID: "subnet-1"}, 1) + if err != nil || len(insts) != 1 { + t.Fatalf("RunInstances: %v %d", err, len(insts)) + } + if insts[0].VPCID != "vpc-abc" { + t.Fatalf("instance VPCID = %q, want vpc-abc", insts[0].VPCID) + } +} + +// TestRunInstancesNoSubnetNoVPC confirms an instance without a subnet (or with +// no resolver wired) simply has an empty VPCID rather than erroring. +func TestRunInstancesNoSubnetNoVPC(t *testing.T) { + ctx := context.Background() + m := newEC2(t) + m.SetSubnetResolver(fakeSubnetResolver{vpc: "vpc-abc"}) + + insts, err := m.RunInstances(ctx, computedriver.InstanceConfig{ImageID: "ami-1"}, 1) + if err != nil || len(insts) != 1 { + t.Fatalf("RunInstances: %v %d", err, len(insts)) + } + if insts[0].VPCID != "" { + t.Fatalf("instance VPCID = %q, want empty", insts[0].VPCID) + } +} diff --git a/server/admin/admin.go b/server/admin/admin.go index 1f5f6a79..9bb34799 100644 --- a/server/admin/admin.go +++ b/server/admin/admin.go @@ -72,20 +72,24 @@ type Control struct { seed func(fixture []byte) (int, error) snapshot func() ([]byte, error) restore func(snapshot []byte) error + extra http.Handler } // NewControl wraps backend with the control plane. reset must rebuild every // backend (including this one) to a clean state. seed, snapshot, and restore // may each be nil, which disables the corresponding endpoint. snapshot returns -// the whole-emulator state as JSON; restore replaces it from that JSON. +// the whole-emulator state as JSON; restore replaces it from that JSON. extra, +// if non-nil, handles any /_cloudemu/* path the built-in endpoints don't (e.g. +// the network-topology endpoints); a nil extra leaves those paths a 404. func NewControl( backend *Backend, reset func(), seed func(fixture []byte) (int, error), snapshot func() ([]byte, error), restore func(snapshot []byte) error, + extra http.Handler, ) *Control { - return &Control{backend: backend, reset: reset, seed: seed, snapshot: snapshot, restore: restore} + return &Control{backend: backend, reset: reset, seed: seed, snapshot: snapshot, restore: restore, extra: extra} } // ServeHTTP routes control-plane paths to the control handler and everything @@ -138,6 +142,11 @@ func (c *Control) serveControl(w http.ResponseWriter, r *http.Request) { case "snapshot": c.serveSnapshot(w, r) default: + if c.extra != nil { + c.extra.ServeHTTP(w, r) + return + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown control endpoint"}) } } diff --git a/server/admin/admin_test.go b/server/admin/admin_test.go index bdf52e6d..f4df0be8 100644 --- a/server/admin/admin_test.go +++ b/server/admin/admin_test.go @@ -61,7 +61,7 @@ func TestBackendConcurrentSwap(t *testing.T) { func TestControlReset(t *testing.T) { resets := 0 b := admin.NewBackend(handler("backend")) - c := admin.NewControl(b, func() { resets++; b.Swap(handler("rebuilt")) }, nil, nil, nil) + c := admin.NewControl(b, func() { resets++; b.Swap(handler("rebuilt")) }, nil, nil, nil, nil) // Non-control paths pass through to the backend. if got := do(t, c, http.MethodGet, "/some/aws/request"); got != "backend" { @@ -84,7 +84,7 @@ func TestControlReset(t *testing.T) { func TestControlRoutes(t *testing.T) { b := admin.NewBackend(handler("backend")) - c := admin.NewControl(b, func() {}, nil, nil, nil) // nil seed → seed endpoint disabled + c := admin.NewControl(b, func() {}, nil, nil, nil, nil) // nil seed → seed endpoint disabled cases := []struct { method, path string @@ -111,7 +111,7 @@ func TestControlSeed(t *testing.T) { c := admin.NewControl(b, func() {}, func(fixture []byte) (int, error) { gotFixture = string(fixture) return 4, nil - }, nil, nil) + }, nil, nil, nil) rec := httptest.NewRecorder() c.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, admin.Prefix+"seed", strings.NewReader(`{"buckets":[]}`))) @@ -128,7 +128,7 @@ func TestControlSeed(t *testing.T) { // A seeder error surfaces as 400. cErr := admin.NewControl(b, func() {}, func([]byte) (int, error) { return 0, errFixture - }, nil, nil) + }, nil, nil, nil) rec = httptest.NewRecorder() cErr.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, admin.Prefix+"seed", strings.NewReader(`{}`))) if rec.Code != http.StatusBadRequest { @@ -143,6 +143,7 @@ func TestControlSnapshot(t *testing.T) { c := admin.NewControl(b, func() {}, nil, func() ([]byte, error) { return []byte(`{"schemaVersion":1}`), nil }, func(body []byte) error { restored = string(body); return nil }, + nil, ) // GET returns the snapshot bytes verbatim. @@ -160,7 +161,7 @@ func TestControlSnapshot(t *testing.T) { } // With nil snapshot/restore the endpoint is disabled (501). - off := admin.NewControl(b, func() {}, nil, nil, nil) + off := admin.NewControl(b, func() {}, nil, nil, nil, nil) rec = httptest.NewRecorder() off.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, admin.Prefix+"snapshot", nil)) if rec.Code != http.StatusNotImplemented { @@ -168,6 +169,31 @@ func TestControlSnapshot(t *testing.T) { } } +func TestControlExtraHandler(t *testing.T) { + b := admin.NewBackend(handler("backend")) + + extra := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("extra:" + r.URL.Path)) + }) + c := admin.NewControl(b, func() {}, nil, nil, nil, extra) + + // An unknown control path is delegated to the extra handler. + rec := httptest.NewRecorder() + c.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, admin.Prefix+"net/can-connect", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "net/can-connect") { + t.Fatalf("extra delegation = %d %q", rec.Code, rec.Body.String()) + } + + // With no extra handler, an unknown control path is still a 404. + c2 := admin.NewControl(b, func() {}, nil, nil, nil, nil) + rec = httptest.NewRecorder() + c2.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, admin.Prefix+"unknown", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("nil extra unknown path = %d, want 404", rec.Code) + } +} + var errFixture = fmt.Errorf("bad fixture") func do(t *testing.T, h http.Handler, method, path string) string {