diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index 545ae68df..f89386780 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -326,6 +326,9 @@ azd app run # Run specific services only azd app run --service web,api +# Run every service except one +azd app run --except worker + # Use native Aspire dashboard (for .NET Aspire projects) azd app run --runtime aspire @@ -350,6 +353,7 @@ azd app run --force | Flag | Short | Type | Default | Description | |------|-------|------|---------|-------------| | `--service` | `-s` | string | | Run specific service(s) only (comma-separated) | +| `--except` | | string | | Run every service except the named one(s) (comma-separated); cannot be combined with `--service` | | `--runtime` | | string | `azd` | Runtime mode: 'azd' (azd dashboard) or 'aspire' (native Aspire with dotnet run) | | `--env-file` | | string | | Load environment variables from .env file | | `--verbose` | `-v` | bool | `false` | Enable verbose logging | diff --git a/cli/docs/commands/run.md b/cli/docs/commands/run.md index c602520eb..27f5dc35e 100644 --- a/cli/docs/commands/run.md +++ b/cli/docs/commands/run.md @@ -26,6 +26,7 @@ azd app run [flags] | Flag | Short | Type | Default | Description | |------|-------|------|---------|-------------| | `--service` | `-s` | string | | Run specific service(s) only (comma-separated) | +| `--except` | | string | | Run every service except the named one(s) (comma-separated); cannot be combined with `--service` | | `--scale` | | string[] | | Run multiple instances of a service, e.g. --scale worker=3 (repeatable, comma-separated) | | `--runtime` | | string | `azd` | Runtime mode: 'azd' or 'aspire' | | `--env-file` | | string | | Load environment variables from .env file | @@ -774,12 +775,22 @@ azd app run --service web # Run multiple services azd app run --service web,api +# Run everything except one service +azd app run --except worker + +# Run everything except a few services +azd app run --except worker,cache + # Useful for: # - Testing individual services # - Reducing resource usage # - Debugging specific components ``` +`--service` and `--except` are opposites and cannot be combined. Use `--service` +to name what runs, or `--except` to name what to skip. An unknown name passed to +`--except` fails with the list of available services. + **Filter Flow**: ``` azure.yaml services: --service web,api Result: diff --git a/cli/src/cmd/app/commands/run.go b/cli/src/cmd/app/commands/run.go index e8fbeef8c..406341c87 100644 --- a/cli/src/cmd/app/commands/run.go +++ b/cli/src/cmd/app/commands/run.go @@ -29,6 +29,7 @@ const ( var ( runServiceFilter string + runExcept string runScale []string runEnvFile string runVerbose bool @@ -60,6 +61,7 @@ func NewRunCommand() *cobra.Command { // Add flags for service orchestration cmd.Flags().StringVarP(&runServiceFilter, "service", "s", "", "Run specific service(s) only (comma-separated)") + cmd.Flags().StringVar(&runExcept, "except", "", "Run every service except the named one(s) (comma-separated)") cmd.Flags().StringSliceVar(&runScale, "scale", nil, "Run multiple instances of a service, e.g. --scale worker=3 (repeatable, comma-separated)") cmd.Flags().StringVar(&runEnvFile, "env-file", "", "Load environment variables from .env file") cmd.Flags().BoolVarP(&runVerbose, "verbose", "v", false, "Enable verbose logging") @@ -75,6 +77,7 @@ func NewRunCommand() *cobra.Command { cmd.Flags().BoolVar(&runSkipExposureCheck, "skip-exposure-check", false, "Skip the warning shown when a service binds to all network interfaces") registerServiceFlagCompletion(cmd, "service") + registerServiceFlagCompletion(cmd, "except") return cmd } @@ -86,6 +89,12 @@ func runWithServices(ctx context.Context, commandOrchestrator *orchestrator.Orch return err } + // --service and --except are mutually exclusive: one names the services to + // run, the other names the services to skip. + if runServiceFilter != "" && runExcept != "" { + return errors.New("--service and --except cannot be used together") + } + // Locate azure.yaml before spawning any subprocesses so we can compute // the project root for the trust check (CWE-78/94). azureYamlPath, err := findAzureYaml() @@ -286,8 +295,14 @@ func runAzdMode(ctx context.Context, azureYamlPath, azureYamlDir string) error { } // Filter and detect services - services := filterServices(azureYaml) + services, err := selectRunServices(azureYaml) + if err != nil { + return err + } if len(services) == 0 { + if runExcept != "" { + return fmt.Errorf("no services remain after excluding: %s", runExcept) + } return fmt.Errorf("no services match filter: %s", runServiceFilter) } @@ -357,6 +372,48 @@ func filterServices(azureYaml *service.AzureYaml) map[string]service.Service { return service.FilterServices(azureYaml, filterList) } +// selectRunServices resolves which services to run from the --service and +// --except flags. The two are mutually exclusive (checked earlier in +// runWithServices). --except removes the named services and returns the rest; +// naming a service that does not exist is an error so typos do not silently run +// more than intended. +func selectRunServices(azureYaml *service.AzureYaml) (map[string]service.Service, error) { + if runExcept == "" { + return filterServices(azureYaml), nil + } + return excludeServices(azureYaml, strings.Split(runExcept, ",")) +} + +// excludeServices returns every service except the named ones. Unknown names +// produce an error that lists the available services. +func excludeServices(azureYaml *service.AzureYaml, exclude []string) (map[string]service.Service, error) { + excludeSet := make(map[string]bool, len(exclude)) + var unknown []string + for _, name := range exclude { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, ok := azureYaml.Services[name]; !ok { + unknown = append(unknown, name) + } + excludeSet[name] = true + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown service(s) in --except: %s. Available services: %s", + strings.Join(unknown, ", "), strings.Join(sortedServiceNames(azureYaml.Services), ", ")) + } + + remaining := make(map[string]service.Service, len(azureYaml.Services)) + for name, svc := range azureYaml.Services { + if !excludeSet[name] { + remaining[name] = svc + } + } + return remaining, nil +} + // detectServiceRuntimes detects runtime information for all services. // // CONCURRENCY: This function is NOT thread-safe and must be called sequentially. diff --git a/cli/src/cmd/app/commands/run_except_test.go b/cli/src/cmd/app/commands/run_except_test.go new file mode 100644 index 000000000..8d221c05e --- /dev/null +++ b/cli/src/cmd/app/commands/run_except_test.go @@ -0,0 +1,157 @@ +package commands + +import ( + "context" + "strings" + "testing" + + "github.com/jongio/azd-app/cli/src/internal/service" +) + +func newExceptTestYaml() *service.AzureYaml { + return &service.AzureYaml{ + Services: map[string]service.Service{ + "api": {Language: "go"}, + "web": {Language: "js"}, + "worker": {Type: "process"}, + }, + } +} + +func TestExcludeServicesRemovesNamed(t *testing.T) { + remaining, err := excludeServices(newExceptTestYaml(), []string{"web"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(remaining) != 2 { + t.Fatalf("expected 2 services, got %d", len(remaining)) + } + if _, ok := remaining["web"]; ok { + t.Error("expected web to be excluded") + } + if _, ok := remaining["api"]; !ok { + t.Error("expected api to remain") + } + if _, ok := remaining["worker"]; !ok { + t.Error("expected worker to remain") + } +} + +func TestExcludeServicesMultiple(t *testing.T) { + remaining, err := excludeServices(newExceptTestYaml(), []string{"web", "worker"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(remaining) != 1 { + t.Fatalf("expected 1 service, got %d", len(remaining)) + } + if _, ok := remaining["api"]; !ok { + t.Error("expected api to remain") + } +} + +func TestExcludeServicesTrimsWhitespace(t *testing.T) { + remaining, err := excludeServices(newExceptTestYaml(), []string{" web ", "", " "}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(remaining) != 2 { + t.Fatalf("expected 2 services after trimming, got %d", len(remaining)) + } + if _, ok := remaining["web"]; ok { + t.Error("expected trimmed web name to be excluded") + } +} + +func TestExcludeServicesUnknown(t *testing.T) { + _, err := excludeServices(newExceptTestYaml(), []string{"nope"}) + if err == nil { + t.Fatal("expected error for unknown service") + } + msg := err.Error() + if !strings.Contains(msg, "nope") { + t.Errorf("error should name the unknown service, got %q", msg) + } + for _, name := range []string{"api", "web", "worker"} { + if !strings.Contains(msg, name) { + t.Errorf("error should list available service %q, got %q", name, msg) + } + } +} + +func TestExcludeServicesAll(t *testing.T) { + remaining, err := excludeServices(newExceptTestYaml(), []string{"api", "web", "worker"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(remaining) != 0 { + t.Errorf("expected no services to remain, got %d", len(remaining)) + } +} + +func TestSelectRunServicesFlags(t *testing.T) { + savedFilter, savedExcept := runServiceFilter, runExcept + defer func() { + runServiceFilter = savedFilter + runExcept = savedExcept + }() + + yaml := newExceptTestYaml() + + // No filters: every service runs. + runServiceFilter, runExcept = "", "" + all, err := selectRunServices(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(all) != 3 { + t.Errorf("expected all 3 services, got %d", len(all)) + } + + // --service selects only the named service. + runServiceFilter, runExcept = "api", "" + only, err := selectRunServices(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(only) != 1 { + t.Fatalf("expected 1 service, got %d", len(only)) + } + if _, ok := only["api"]; !ok { + t.Error("expected api to be selected") + } + + // --except skips the named service. + runServiceFilter, runExcept = "", "api" + rest, err := selectRunServices(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rest) != 2 { + t.Fatalf("expected 2 services, got %d", len(rest)) + } + if _, ok := rest["api"]; ok { + t.Error("expected api to be excluded") + } +} + +func TestRunCommandExceptFlagMutualExclusion(t *testing.T) { + savedFilter, savedExcept, savedRuntime := runServiceFilter, runExcept, runRuntime + defer func() { + runServiceFilter = savedFilter + runExcept = savedExcept + runRuntime = savedRuntime + }() + + runServiceFilter = "api" + runExcept = "web" + runRuntime = "azd" + + err := runWithServices(context.Background(), nil, nil, nil) + if err == nil { + t.Fatal("expected error when --service and --except are combined") + } + if !strings.Contains(err.Error(), "cannot be used together") { + t.Errorf("expected mutual-exclusion error, got %q", err) + } +}