From 89105bc5a31e4cfb639e32da9b455d9bba2556d1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:00:56 -0700 Subject: [PATCH] feat(ports): add ports command to list and check host port bindings Add `azd app ports` to list the host port each service binds, resolved from azure.yaml. Explicit host ports show as their number, auto-assigned ports show as `auto`, and duplicate explicit bindings are flagged with a warning and a non-zero exit so it works as a preflight check before run. Reuses the existing port model (GetPortMappings/ParsePortSpec) so protocol suffixes, ip:host:container forms, and Docker auto-assign resolve the same way run sees them. Adds table-driven unit tests and docs. Closes #509 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cli/docs/cli-reference.md | 35 ++++ cli/docs/commands/ports.md | 80 +++++++++ cli/src/cmd/app/commands/ports.go | 215 +++++++++++++++++++++++++ cli/src/cmd/app/commands/ports_test.go | 181 +++++++++++++++++++++ cli/src/cmd/app/main.go | 1 + 5 files changed, 512 insertions(+) create mode 100644 cli/docs/commands/ports.md create mode 100644 cli/src/cmd/app/commands/ports.go create mode 100644 cli/src/cmd/app/commands/ports_test.go diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index 545ae68df..3dc4be34e 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -75,6 +75,7 @@ azd app run --environment production | `health` | Monitor health status of services (static or streaming mode) | [→ Full Spec](commands/health.md) | | `logs` | View logs from running services | [→ Full Spec](commands/logs.md) | | `info` | Show information about running services | [→ Full Spec](commands/info.md) | +| `ports` | List the host ports each service binds and flag duplicate bindings | [→ Full Spec](commands/ports.md) | | `mcp` | Model Context Protocol server for AI assistant integration | [→ Full Spec](commands/mcp.md) | | `notifications` | Manage process notifications for service state changes | [→ Full Spec](commands/notifications.md) | | `version` | Show version information | [→ Full Spec](commands/version.md) | @@ -1052,6 +1053,40 @@ api --- +## `azd app ports` + +Reads `azure.yaml` and lists the host port each service binds. An explicit host port is shown as its number; a port left for the tool to assign is shown as `auto`. When two bindings claim the same explicit host port the command reports the conflict and exits non-zero, so it works as a preflight check before `azd app run`. + +### Usage + +```bash +azd app ports [flags] +``` + +### Examples + +```bash +# List host ports for every service +azd app ports + +# JSON object keyed by service name +azd app ports --output json +``` + +### Flags + +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--output` | `-o` | string | `text` | Output format: `text` or `json` | + +### Notes + +- Each binding is printed as `host -> container/protocol`. Ports without an explicit host binding show `auto`. +- A host port claimed by more than one binding (across services or within one service) is marked `(conflict)` and listed in a warning. +- The command exits non-zero when any conflict is found, so it can gate a run in scripts and CI. + +--- + ## `azd app mcp` Model Context Protocol (MCP) server for AI assistant integration. Enables AI assistants like Claude Desktop and GitHub Copilot to interact with your azd app projects. diff --git a/cli/docs/commands/ports.md b/cli/docs/commands/ports.md new file mode 100644 index 000000000..77d6522a8 --- /dev/null +++ b/cli/docs/commands/ports.md @@ -0,0 +1,80 @@ +# azd app ports + +List the host port each service binds, resolved from azure.yaml. + +## Synopsis + +``` +azd app ports [flags] +``` + +## Description + +The `ports` command reads `azure.yaml` and prints the host port each service binds. For every service it shows each port binding as `host -> container/protocol`. + +An explicit host port is shown as its number. A port left for the tool to assign (for example a Docker service that only names the container port) is shown as `auto`. When two bindings claim the same explicit host port the command marks the conflict, prints a warning, and exits non-zero. That makes `azd app ports` a quick preflight check before `azd app run`, and it composes well in scripts and CI where a non-zero exit stops the pipeline. + +Only explicit host ports can conflict. Auto-assigned ports never count as a conflict because the runtime picks a free port for each one. + +## Flags + +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format (text, json) | + +## Examples + +### List ports for every service + +```bash +azd app ports +``` + +``` +web + port: 3000 -> 8080/tcp +api + port: auto -> 9090/tcp +``` + +### Report a conflict + +When two services bind the same explicit host port, each conflicting line is marked and a warning is printed: + +``` +web + port: 3000 -> 8080/tcp (conflict) +worker + port: 3000 -> 9090/tcp (conflict) + +Host port 3000 is bound by more than one service: web, worker +``` + +The command exits non-zero in this case. + +### Get JSON output + +```bash +azd app ports --output json +``` + +```json +{ + "api": { + "ports": [ + { "host": "auto", "container": 9090, "protocol": "tcp" } + ] + }, + "web": { + "ports": [ + { "host": "3000", "hostPort": 3000, "container": 8080, "protocol": "tcp" } + ] + } +} +``` + +## See Also + +- [azd app run](run.md) - Start services using these port bindings +- [azd app info](info.md) - Show information about running services +- [azd app add](add.md) - Add a container service with preset ports diff --git a/cli/src/cmd/app/commands/ports.go b/cli/src/cmd/app/commands/ports.go new file mode 100644 index 000000000..b34df3d4f --- /dev/null +++ b/cli/src/cmd/app/commands/ports.go @@ -0,0 +1,215 @@ +package commands + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/jongio/azd-app/cli/src/internal/service" + "github.com/jongio/azd-core/cliout" + + "github.com/spf13/cobra" +) + +// NewPortsCommand creates the ports command. +func NewPortsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "ports", + Short: "List the host ports each service binds", + Long: `List the host port each service binds, resolved from azure.yaml. + +For every service this shows each port binding as "host -> container/protocol". +An explicit host port is shown as its number; a port left for the tool to assign +is shown as "auto". When two bindings claim the same explicit host port the +command reports the conflict and exits non-zero, which makes it useful in a +preflight check. + +Examples: + # Ports for every service + azd app ports + + # JSON object keyed by service name + azd app ports --output json`, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: runPorts, + } + + return cmd +} + +// portBinding describes one host-to-container port binding for a service. +type portBinding struct { + Host string `json:"host"` // explicit host port number, or "auto" + HostPort int `json:"hostPort,omitempty"` // numeric host port when explicit + Container int `json:"container,omitempty"` + Protocol string `json:"protocol,omitempty"` + Conflict bool `json:"conflict,omitempty"` // true when this explicit host port is claimed more than once +} + +// servicePorts is the set of port bindings for a single service. +type servicePorts struct { + Ports []portBinding `json:"ports"` +} + +// portConflict records an explicit host port claimed by more than one binding. +type portConflict struct { + Port int + Owners []string +} + +// portReport is the resolved port view for every service. +type portReport struct { + services map[string]servicePorts + order []string + conflicts []portConflict +} + +func runPorts(_ *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + azureYaml, err := service.ParseAzureYaml(cwd) + if err != nil { + return fmt.Errorf("failed to load azure.yaml: %w", err) + } + + report := collectPortReport(azureYaml) + + if cliout.IsJSON() { + if err := cliout.PrintJSON(report.services); err != nil { + return err + } + } else { + printPortReport(report) + } + + if len(report.conflicts) > 0 { + return fmt.Errorf("duplicate explicit host port(s): %s", conflictSummary(report.conflicts)) + } + return nil +} + +// collectPortReport resolves the port bindings for every service and flags any +// explicit host port that is claimed by more than one binding. +func collectPortReport(azureYaml *service.AzureYaml) portReport { + names := sortedServiceNames(azureYaml.Services) + services := make(map[string]servicePorts, len(names)) + owners := make(map[int][]string) // explicit host port -> service names that bind it + + for _, name := range names { + svc := azureYaml.Services[name] + mappings, _ := svc.GetPortMappings() + bindings := make([]portBinding, 0, len(mappings)) + for _, m := range mappings { + b := portBinding{ + Container: m.ContainerPort, + Protocol: protocolOrDefault(m.Protocol), + } + if m.HostPort > 0 { + b.HostPort = m.HostPort + b.Host = strconv.Itoa(m.HostPort) + owners[m.HostPort] = append(owners[m.HostPort], name) + } else { + b.Host = "auto" + } + bindings = append(bindings, b) + } + services[name] = servicePorts{Ports: bindings} + } + + conflictPorts := make(map[int]bool) + var conflicts []portConflict + for port, own := range owners { + if len(own) > 1 { + conflictPorts[port] = true + conflicts = append(conflicts, portConflict{Port: port, Owners: dedupeStrings(own)}) + } + } + sort.Slice(conflicts, func(i, j int) bool { return conflicts[i].Port < conflicts[j].Port }) + + if len(conflictPorts) > 0 { + for name, sp := range services { + for i := range sp.Ports { + if sp.Ports[i].HostPort > 0 && conflictPorts[sp.Ports[i].HostPort] { + sp.Ports[i].Conflict = true + } + } + services[name] = sp + } + } + + return portReport{services: services, order: names, conflicts: conflicts} +} + +// printPortReport writes the port bindings for each service, then a warning line +// per conflicting host port. +func printPortReport(report portReport) { + if len(report.order) == 0 { + cliout.Info("No services are defined in azure.yaml") + return + } + + cliout.CommandHeader("ports", "Configured host ports") + for i, name := range report.order { + if i > 0 { + cliout.Newline() + } + cliout.Info("%s", name) + sp := report.services[name] + if len(sp.Ports) == 0 { + cliout.Item("no ports configured") + continue + } + for _, b := range sp.Ports { + line := fmt.Sprintf("%s -> %d/%s", b.Host, b.Container, b.Protocol) + if b.Conflict { + line += " (conflict)" + } + cliout.Label("port", line) + } + } + + if len(report.conflicts) > 0 { + cliout.Newline() + for _, c := range report.conflicts { + cliout.Warning("Host port %d is bound by more than one service: %s", c.Port, strings.Join(c.Owners, ", ")) + } + } +} + +// conflictSummary renders the conflicts for the returned error, e.g. +// "3000 (web, worker); 8080 (api, api2)". +func conflictSummary(conflicts []portConflict) string { + parts := make([]string, 0, len(conflicts)) + for _, c := range conflicts { + parts = append(parts, fmt.Sprintf("%d (%s)", c.Port, strings.Join(c.Owners, ", "))) + } + return strings.Join(parts, "; ") +} + +// protocolOrDefault returns the protocol, defaulting to "tcp" when unset. +func protocolOrDefault(protocol string) string { + if protocol == "" { + return "tcp" + } + return protocol +} + +// dedupeStrings returns the input with duplicates removed, preserving order. +func dedupeStrings(in []string) []string { + seen := make(map[string]bool, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} diff --git a/cli/src/cmd/app/commands/ports_test.go b/cli/src/cmd/app/commands/ports_test.go new file mode 100644 index 000000000..332c8e6f0 --- /dev/null +++ b/cli/src/cmd/app/commands/ports_test.go @@ -0,0 +1,181 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +func TestCollectPortReportBasic(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "web": {Ports: []string{"3000:8080"}}, + "worker": {Docker: &service.DockerConfig{}, Ports: []string{"8080"}}, // host auto-assigned + "db": {}, // no ports + }} + + r := collectPortReport(y) + + if len(r.order) != 3 || r.order[0] != "db" || r.order[1] != "web" || r.order[2] != "worker" { + t.Fatalf("expected sorted order [db web worker], got %v", r.order) + } + if len(r.conflicts) != 0 { + t.Errorf("expected no conflicts, got %v", r.conflicts) + } + + web := r.services["web"].Ports + if len(web) != 1 { + t.Fatalf("expected 1 web binding, got %d", len(web)) + } + if web[0].Host != "3000" || web[0].HostPort != 3000 || web[0].Container != 8080 || web[0].Protocol != "tcp" || web[0].Conflict { + t.Errorf("unexpected web binding: %+v", web[0]) + } + + worker := r.services["worker"].Ports + if len(worker) != 1 || worker[0].Host != "auto" || worker[0].HostPort != 0 { + t.Errorf("expected worker host auto, got %+v", worker) + } + + if len(r.services["db"].Ports) != 0 { + t.Errorf("expected db to have no ports, got %+v", r.services["db"].Ports) + } +} + +func TestCollectPortReportConflict(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "web": {Ports: []string{"3000:8080"}}, + "api": {Ports: []string{"3000:9090"}}, + }} + + r := collectPortReport(y) + + if len(r.conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %v", r.conflicts) + } + c := r.conflicts[0] + if c.Port != 3000 { + t.Errorf("expected conflict on port 3000, got %d", c.Port) + } + if len(c.Owners) != 2 { + t.Errorf("expected 2 owners, got %v", c.Owners) + } + if !r.services["web"].Ports[0].Conflict || !r.services["api"].Ports[0].Conflict { + t.Error("expected both conflicting bindings to be marked") + } +} + +func TestCollectPortReportAutoPortsNeverConflict(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "a": {Docker: &service.DockerConfig{}, Ports: []string{"8080"}}, + "b": {Docker: &service.DockerConfig{}, Ports: []string{"8080"}}, + }} + + r := collectPortReport(y) + + if len(r.conflicts) != 0 { + t.Errorf("auto-assigned ports must not conflict, got %v", r.conflicts) + } + if r.services["a"].Ports[0].Conflict || r.services["b"].Ports[0].Conflict { + t.Error("auto bindings should not be flagged as conflicts") + } +} + +func TestCollectPortReportSameServiceDuplicate(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "web": {Ports: []string{"3000:8080", "3000:9090"}}, + }} + + r := collectPortReport(y) + + if len(r.conflicts) != 1 || r.conflicts[0].Port != 3000 { + t.Fatalf("expected a conflict on 3000 within one service, got %v", r.conflicts) + } + if len(r.conflicts[0].Owners) != 1 || r.conflicts[0].Owners[0] != "web" { + t.Errorf("expected deduped owner [web], got %v", r.conflicts[0].Owners) + } + for _, b := range r.services["web"].Ports { + if !b.Conflict { + t.Errorf("expected both web bindings flagged, got %+v", b) + } + } +} + +func TestCollectPortReportUDP(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "dns": {Ports: []string{"5300:53/udp"}}, + }} + + r := collectPortReport(y) + + b := r.services["dns"].Ports[0] + if b.Protocol != "udp" || b.HostPort != 5300 || b.Container != 53 { + t.Errorf("unexpected udp binding: %+v", b) + } +} + +func TestPortReportJSONShape(t *testing.T) { + y := &service.AzureYaml{Services: map[string]service.Service{ + "web": {Ports: []string{"3000:8080"}}, + "api": {Ports: []string{"3000:9090"}}, + }} + + r := collectPortReport(y) + + data, err := json.Marshal(r.services) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]struct { + Ports []struct { + Host string `json:"host"` + HostPort int `json:"hostPort"` + Container int `json:"container"` + Protocol string `json:"protocol"` + Conflict bool `json:"conflict"` + } `json:"ports"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + web, ok := decoded["web"] + if !ok || len(web.Ports) != 1 { + t.Fatalf("expected web with one port, got %+v", decoded) + } + if web.Ports[0].Host != "3000" || !web.Ports[0].Conflict { + t.Errorf("expected web port 3000 flagged as conflict, got %+v", web.Ports[0]) + } +} + +func TestConflictSummary(t *testing.T) { + got := conflictSummary([]portConflict{ + {Port: 3000, Owners: []string{"web", "api"}}, + {Port: 8080, Owners: []string{"svc"}}, + }) + want := "3000 (web, api); 8080 (svc)" + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestProtocolOrDefault(t *testing.T) { + if protocolOrDefault("") != "tcp" { + t.Error("empty protocol should default to tcp") + } + if protocolOrDefault("udp") != "udp" { + t.Error("explicit protocol should be preserved") + } +} + +func TestDedupeStrings(t *testing.T) { + got := dedupeStrings([]string{"a", "b", "a", "c", "b"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], got[i]) + } + } +} diff --git a/cli/src/cmd/app/main.go b/cli/src/cmd/app/main.go index abc6493aa..df0a7dfba 100644 --- a/cli/src/cmd/app/main.go +++ b/cli/src/cmd/app/main.go @@ -106,6 +106,7 @@ func main() { commands.NewAddCommand(), commands.NewSupportBundleCommand(), commands.NewGraphCommand(), + commands.NewPortsCommand(), commands.NewMetadataCommand(func() *cobra.Command { return rootCmd }), )