Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions cli/docs/commands/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
59 changes: 58 additions & 1 deletion cli/src/cmd/app/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (

var (
runServiceFilter string
runExcept string
runScale []string
runEnvFile string
runVerbose bool
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.
Expand Down
157 changes: 157 additions & 0 deletions cli/src/cmd/app/commands/run_except_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading