diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index 545ae68df..b93ec37e6 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -67,6 +67,7 @@ azd app run --environment production | `reqs` | Check and verify required tools and optionally auto-generate requirements | [→ Full Spec](commands/reqs.md) | | `deps` | Install dependencies for detected projects | [→ Full Spec](commands/deps.md) | | `add` | Add a well-known container service to azure.yaml | [→ Full Spec](commands/add.md) | +| `config` | Show the effective resolved configuration for each service | [→ Full Spec](commands/config.md) | | `run` | Run the development environment with service orchestration and lifecycle hooks | [→ Full Spec](commands/run.md) | | `test` | Run tests for all services with coverage aggregation | [→ Full Spec](commands/test.md) | | `start` | Start stopped services | [→ Full Spec](commands/start.md) | @@ -307,6 +308,46 @@ azd app add redis --output json --- +## `azd app config` + +Show the configuration azd app resolved from azure.yaml for each service. + +### Usage + +```bash +azd app config [service] [flags] +``` + +### Examples + +```bash +# Configuration for every service +azd app config + +# Configuration for a single service +azd app config api + +# JSON object keyed by service name +azd app config --output json +``` + +### Flags + +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--output` | `-o` | string | `default` | Output format (default, json) | + +### Behavior + +- Prints the host, effective service type (marked `explicit` or `inferred`), language, project path, run command, image, ports, and dependencies (uses) for each service. +- Lists which optional blocks are configured on a service: `docker`, `healthcheck`, `restart`, `resources`, `logs`, `local`, `azure`. +- Pass a service name to limit output to that service. An unknown name returns an error listing the available services. +- `--output json` emits an object keyed by service name. + +**→ [See full config command specification](commands/config.md)** for details. + +--- + ## `azd app run` Starts your development environment based on project type with support for multi-service orchestration. diff --git a/cli/docs/commands/config.md b/cli/docs/commands/config.md new file mode 100644 index 000000000..3969486b6 --- /dev/null +++ b/cli/docs/commands/config.md @@ -0,0 +1,58 @@ +# azd app config + +Show the configuration azd app resolved from azure.yaml for each service. + +## Synopsis + +``` +azd app config [service] [flags] +``` + +## Description + +The `config` command reads azure.yaml and prints the configuration for each +service the way azd app resolves it. This is a static view of the parsed model, +so you can confirm what the tool sees without starting anything. + +For every service it shows: + +- `host` (when set) +- `type`, marked `explicit` when set in azure.yaml or `inferred` when derived from the service shape +- `language`, `project`, `command`, and `image` (when set) +- `ports` and `uses` (when set) +- `configured`: the optional blocks present on the service + +The optional blocks reported are `docker`, `healthcheck`, `restart`, +`resources`, `logs`, `local`, and `azure`. + +## Flags + +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format (default, json) | + +## Examples + +### Configuration for every service + +```bash +azd app config +``` + +### Configuration for a single service + +```bash +azd app config api +``` + +### JSON object keyed by service name + +```bash +azd app config --output json +``` + +## Notes + +- Passing an unknown service name returns an error that lists the available services. +- `--output json` emits an object keyed by service name, which pairs well with tools like `jq`. +- Service-level hooks are not part of the azure.yaml schema. Lifecycle hooks are defined at the project level. diff --git a/cli/src/cmd/app/commands/config.go b/cli/src/cmd/app/commands/config.go new file mode 100644 index 000000000..12edb8803 --- /dev/null +++ b/cli/src/cmd/app/commands/config.go @@ -0,0 +1,209 @@ +package commands + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/jongio/azd-app/cli/src/internal/service" + "github.com/jongio/azd-core/cliout" + + "github.com/spf13/cobra" +) + +// NewConfigCommand creates the config command. +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config [service]", + Short: "Show the effective configuration for each service", + Long: `Print the configuration azd app resolved from azure.yaml for each service. + +For every service this shows the host, the effective service type (explicit or +inferred), language, project path, run command, image, ports, and dependencies +(uses), plus which optional blocks are configured (docker, healthcheck, restart, +resources, logs, local, azure). + +Run without arguments to print every service, or pass a service name to print +just that one. Use --output json for a machine-readable object keyed by service +name. + +Examples: + # Configuration for every service + azd app config + + # Configuration for a single service + azd app config api + + # JSON object keyed by service name + azd app config --output json`, + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: runConfig, + ValidArgsFunction: completeServiceArgs, + } + + return cmd +} + +// serviceConfig is the resolved configuration for a single service. In JSON +// output it is the value of a map keyed by service name, so the name itself is +// not repeated here. +type serviceConfig struct { + Host string `json:"host,omitempty"` + Type string `json:"type"` + TypeSource string `json:"typeSource"` // "explicit" when set in azure.yaml, otherwise "inferred" + Language string `json:"language,omitempty"` + Project string `json:"project,omitempty"` + Command string `json:"command,omitempty"` + Image string `json:"image,omitempty"` + Ports []string `json:"ports,omitempty"` + Uses []string `json:"uses,omitempty"` + Blocks []string `json:"blocks,omitempty"` // optional blocks configured on this service +} + +func runConfig(_ *cobra.Command, args []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) + } + + configs, order, err := selectServiceConfigs(azureYaml, args) + if err != nil { + return err + } + + if cliout.IsJSON() { + return cliout.PrintJSON(configs) + } + + if len(order) == 0 { + cliout.Info("No services are defined in azure.yaml") + return nil + } + + cliout.CommandHeader("config", "Effective service configuration") + for i, name := range order { + if i > 0 { + cliout.Newline() + } + printServiceConfig(name, configs[name]) + } + return nil +} + +// selectServiceConfigs resolves the configs to display. With no arguments it +// returns every service in name order; with a single argument it returns just +// that service, or an error listing the available services when the name is +// unknown. The returned slice is the display order for text output. +func selectServiceConfigs(azureYaml *service.AzureYaml, args []string) (map[string]serviceConfig, []string, error) { + names := make([]string, 0, len(azureYaml.Services)) + for name := range azureYaml.Services { + names = append(names, name) + } + sort.Strings(names) + + order := names + if len(args) == 1 { + name := args[0] + if _, ok := azureYaml.Services[name]; !ok { + if len(names) == 0 { + return nil, nil, fmt.Errorf("service %q not found. No services are defined in azure.yaml", name) + } + return nil, nil, fmt.Errorf("service %q not found. Available services: %s", name, strings.Join(names, ", ")) + } + order = []string{name} + } + + configs := make(map[string]serviceConfig, len(order)) + for _, name := range order { + configs[name] = buildServiceConfig(azureYaml.Services[name]) + } + return configs, order, nil +} + +// buildServiceConfig captures the resolved configuration for a service, using +// GetServiceType to report the effective type when it is not set explicitly. +func buildServiceConfig(svc service.Service) serviceConfig { + cfg := serviceConfig{ + Host: svc.Host, + Type: svc.GetServiceType(), + Language: svc.Language, + Project: svc.Project, + Command: svc.Command, + Image: svc.Image, + Ports: svc.Ports, + Uses: svc.Uses, + Blocks: configuredBlocks(svc), + } + if svc.Type != "" { + cfg.TypeSource = "explicit" + } else { + cfg.TypeSource = "inferred" + } + return cfg +} + +// configuredBlocks returns the names of the optional service blocks that are set +// in azure.yaml, in a stable order. +func configuredBlocks(svc service.Service) []string { + var blocks []string + if svc.Docker != nil { + blocks = append(blocks, "docker") + } + if svc.Healthcheck != nil { + blocks = append(blocks, "healthcheck") + } + if svc.Restart != nil { + blocks = append(blocks, "restart") + } + if svc.Resources != nil { + blocks = append(blocks, "resources") + } + if svc.Logs != nil { + blocks = append(blocks, "logs") + } + if svc.Local != nil { + blocks = append(blocks, "local") + } + if svc.Azure != nil { + blocks = append(blocks, "azure") + } + return blocks +} + +// printServiceConfig writes one service's resolved configuration as aligned +// label lines, omitting fields that are not set. +func printServiceConfig(name string, cfg serviceConfig) { + cliout.Info("%s", name) + if cfg.Host != "" { + cliout.Label("host", cfg.Host) + } + cliout.Label("type", fmt.Sprintf("%s (%s)", cfg.Type, cfg.TypeSource)) + if cfg.Language != "" { + cliout.Label("language", cfg.Language) + } + if cfg.Project != "" { + cliout.Label("project", cfg.Project) + } + if cfg.Command != "" { + cliout.Label("command", cfg.Command) + } + if cfg.Image != "" { + cliout.Label("image", cfg.Image) + } + if len(cfg.Ports) > 0 { + cliout.Label("ports", strings.Join(cfg.Ports, ", ")) + } + if len(cfg.Uses) > 0 { + cliout.Label("uses", strings.Join(cfg.Uses, ", ")) + } + if len(cfg.Blocks) > 0 { + cliout.Label("configured", strings.Join(cfg.Blocks, ", ")) + } +} diff --git a/cli/src/cmd/app/commands/config_test.go b/cli/src/cmd/app/commands/config_test.go new file mode 100644 index 000000000..1b59d0c56 --- /dev/null +++ b/cli/src/cmd/app/commands/config_test.go @@ -0,0 +1,202 @@ +package commands + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +func TestBuildServiceConfigInferredType(t *testing.T) { + svc := service.Service{ + Language: "js", + Project: "./web", + Command: "npm run dev", + Ports: []string{"3000:8080"}, + Uses: []string{"redis"}, + } + + cfg := buildServiceConfig(svc) + + if cfg.Type != "http" { + t.Errorf("expected inferred type http, got %q", cfg.Type) + } + if cfg.TypeSource != "inferred" { + t.Errorf("expected typeSource inferred, got %q", cfg.TypeSource) + } + if cfg.Language != "js" || cfg.Project != "./web" || cfg.Command != "npm run dev" { + t.Errorf("unexpected passthrough fields: %+v", cfg) + } + if len(cfg.Ports) != 1 || cfg.Ports[0] != "3000:8080" { + t.Errorf("expected ports [3000:8080], got %v", cfg.Ports) + } + if len(cfg.Uses) != 1 || cfg.Uses[0] != "redis" { + t.Errorf("expected uses [redis], got %v", cfg.Uses) + } + if len(cfg.Blocks) != 0 { + t.Errorf("expected no configured blocks, got %v", cfg.Blocks) + } +} + +func TestBuildServiceConfigExplicitType(t *testing.T) { + svc := service.Service{Type: "process", Command: "worker"} + + cfg := buildServiceConfig(svc) + + if cfg.Type != "process" { + t.Errorf("expected type process, got %q", cfg.Type) + } + if cfg.TypeSource != "explicit" { + t.Errorf("expected typeSource explicit, got %q", cfg.TypeSource) + } +} + +func TestConfiguredBlocks(t *testing.T) { + svc := service.Service{ + Docker: &service.DockerConfig{}, + Healthcheck: &service.HealthcheckConfig{}, + Restart: &service.RestartConfig{}, + Resources: &service.ResourceThresholds{}, + Logs: &service.ServiceLogsConfig{}, + Local: &service.LocalServiceConfig{}, + Azure: &service.AzureServiceConfig{}, + } + + blocks := configuredBlocks(svc) + + want := []string{"docker", "healthcheck", "restart", "resources", "logs", "local", "azure"} + if len(blocks) != len(want) { + t.Fatalf("expected %d blocks, got %v", len(want), blocks) + } + for i, b := range want { + if blocks[i] != b { + t.Errorf("block %d: expected %q, got %q", i, b, blocks[i]) + } + } +} + +func TestConfiguredBlocksEmpty(t *testing.T) { + if blocks := configuredBlocks(service.Service{}); len(blocks) != 0 { + t.Errorf("expected no blocks for empty service, got %v", blocks) + } +} + +func TestSelectServiceConfigsAll(t *testing.T) { + azureYaml := &service.AzureYaml{ + Services: map[string]service.Service{ + "web": {Language: "js", Ports: []string{"3000"}}, + "api": {Type: "process", Command: "worker"}, + }, + } + + configs, order, err := selectServiceConfigs(azureYaml, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(configs) != 2 { + t.Fatalf("expected 2 configs, got %d", len(configs)) + } + if len(order) != 2 || order[0] != "api" || order[1] != "web" { + t.Errorf("expected sorted order [api web], got %v", order) + } + if configs["api"].Type != "process" || configs["web"].Type != "http" { + t.Errorf("unexpected resolved types: %+v", configs) + } +} + +func TestSelectServiceConfigsSingle(t *testing.T) { + azureYaml := &service.AzureYaml{ + Services: map[string]service.Service{ + "web": {Ports: []string{"3000"}}, + "api": {Type: "process"}, + }, + } + + configs, order, err := selectServiceConfigs(azureYaml, []string{"api"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(configs) != 1 || len(order) != 1 || order[0] != "api" { + t.Fatalf("expected only api selected, got order %v configs %v", order, configs) + } + if _, ok := configs["web"]; ok { + t.Error("expected web to be excluded") + } +} + +func TestSelectServiceConfigsUnknown(t *testing.T) { + azureYaml := &service.AzureYaml{ + Services: map[string]service.Service{ + "web": {Ports: []string{"3000"}}, + "api": {Type: "process"}, + }, + } + + _, _, err := selectServiceConfigs(azureYaml, []string{"nope"}) + if err == nil { + t.Fatal("expected error for unknown service") + } + msg := err.Error() + if !strings.Contains(msg, "not found") || !strings.Contains(msg, "api") || !strings.Contains(msg, "web") { + t.Errorf("error should list available services, got %q", msg) + } +} + +func TestSelectServiceConfigsNoServices(t *testing.T) { + azureYaml := &service.AzureYaml{Services: map[string]service.Service{}} + + // No arg: empty selection, no error. + configs, order, err := selectServiceConfigs(azureYaml, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(configs) != 0 || len(order) != 0 { + t.Errorf("expected empty selection, got order %v configs %v", order, configs) + } + + // Named arg against an empty project reports the no-services message. + _, _, err = selectServiceConfigs(azureYaml, []string{"api"}) + if err == nil || !strings.Contains(err.Error(), "No services are defined") { + t.Errorf("expected no-services error, got %v", err) + } +} + +func TestServiceConfigJSONShape(t *testing.T) { + configs := map[string]serviceConfig{ + "api": buildServiceConfig(service.Service{ + Type: "http", + Language: "python", + Ports: []string{"8000"}, + Healthcheck: &service.HealthcheckConfig{}, + }), + } + + data, err := json.Marshal(configs) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + api, ok := decoded["api"] + if !ok { + t.Fatal("expected object keyed by service name") + } + if api["type"] != "http" { + t.Errorf("expected type http, got %v", api["type"]) + } + if api["typeSource"] != "explicit" { + t.Errorf("expected typeSource explicit, got %v", api["typeSource"]) + } + blocks, ok := api["blocks"].([]any) + if !ok || len(blocks) != 1 || blocks[0] != "healthcheck" { + t.Errorf("expected blocks [healthcheck], got %v", api["blocks"]) + } + // Unset optional fields must be omitted. + if _, ok := api["project"]; ok { + t.Error("expected project to be omitted when empty") + } +} diff --git a/cli/src/cmd/app/main.go b/cli/src/cmd/app/main.go index abc6493aa..bfa81d91c 100644 --- a/cli/src/cmd/app/main.go +++ b/cli/src/cmd/app/main.go @@ -104,6 +104,7 @@ func main() { commands.NewProxyCommand(), commands.NewCertCommand(), commands.NewAddCommand(), + commands.NewConfigCommand(), commands.NewSupportBundleCommand(), commands.NewGraphCommand(), commands.NewMetadataCommand(func() *cobra.Command { return rootCmd }),