diff --git a/cmd/devcloud/main.go b/cmd/devcloud/main.go index 34166bc..9eb9970 100644 --- a/cmd/devcloud/main.go +++ b/cmd/devcloud/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/signal" + "strings" "syscall" "devcloud/internal/app" @@ -31,6 +32,10 @@ func run(args []string) error { if err != nil { return err } + cfg, err = app.ApplyServiceSelection(cfg, args[1:]) + if err != nil { + return err + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return app.NewDaemon(cfg).Run(ctx) @@ -62,8 +67,14 @@ func usage() error { func usageText() string { return `Usage: devcloud init - devcloud up + devcloud up [service ...] devcloud reset devcloud dashboard + +When one or more service names are passed to "up", only those services are +started (overriding services.*.enabled in .devcloud/config.yaml). The +dashboard always starts. + +Known services: ` + strings.Join(app.ServiceNames(), ", ") + ` ` } diff --git a/internal/app/services.go b/internal/app/services.go new file mode 100644 index 0000000..b5bf39a --- /dev/null +++ b/internal/app/services.go @@ -0,0 +1,92 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +// ServiceNames returns the canonical service identifiers accepted by +// ApplyServiceSelection, in alphabetical order. +func ServiceNames() []string { + names := make([]string, 0, len(serviceToggles)) + for name := range serviceToggles { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ApplyServiceSelection returns a copy of cfg with services.*.enabled set so +// that only the services named in selected are enabled. Names are matched +// case-insensitively and accept the aliases listed in serviceAliases. The +// dashboard is always started regardless of selection. An empty or nil +// selection leaves cfg unchanged. +func ApplyServiceSelection(cfg Config, selected []string) (Config, error) { + if len(selected) == 0 { + return cfg, nil + } + + chosen := make(map[string]struct{}, len(selected)) + for _, raw := range selected { + name := strings.ToLower(strings.TrimSpace(raw)) + if name == "" { + continue + } + canonical, ok := serviceAliases[name] + if !ok { + return Config{}, fmt.Errorf("unknown service %q (known: %s)", raw, strings.Join(ServiceNames(), ", ")) + } + chosen[canonical] = struct{}{} + } + + if len(chosen) == 0 { + return cfg, nil + } + + out := cfg + for name, toggle := range serviceToggles { + _, enable := chosen[name] + toggle(&out, enable) + } + return out, nil +} + +// serviceToggles maps canonical service names to functions that set the +// corresponding services.*.Enabled flag on a Config. +var serviceToggles = map[string]func(*Config, bool){ + "mail": func(c *Config, v bool) { c.Services.Mail.Enabled = v }, + "s3": func(c *Config, v bool) { c.Services.S3.Enabled = v }, + "gcs": func(c *Config, v bool) { c.Services.GCS.Enabled = v }, + "dynamodb": func(c *Config, v bool) { c.Services.DynamoDB.Enabled = v }, + "bigquery": func(c *Config, v bool) { c.Services.BigQuery.Enabled = v }, + "redshift": func(c *Config, v bool) { c.Services.Redshift.Enabled = v }, + "redis": func(c *Config, v bool) { c.Services.Redis.Enabled = v }, + "sqs": func(c *Config, v bool) { c.Services.SQS.Enabled = v }, + "pubsub": func(c *Config, v bool) { c.Services.PubSub.Enabled = v }, + "appautoscaling": func(c *Config, v bool) { c.Services.AppAutoScaling.Enabled = v }, +} + +// serviceAliases maps user-facing names (lowercase) to canonical keys in +// serviceToggles. Allows common variants like "smtp" for mail or +// "application-autoscaling" for appautoscaling. +var serviceAliases = map[string]string{ + "mail": "mail", + "smtp": "mail", + "s3": "s3", + "gcs": "gcs", + "dynamodb": "dynamodb", + "ddb": "dynamodb", + "bigquery": "bigquery", + "bq": "bigquery", + "redshift": "redshift", + "redis": "redis", + "sqs": "sqs", + "pubsub": "pubsub", + "pub-sub": "pubsub", + "pub_sub": "pubsub", + "appautoscaling": "appautoscaling", + "app-autoscaling": "appautoscaling", + "app_autoscaling": "appautoscaling", + "applicationautoscaling": "appautoscaling", +} diff --git a/internal/app/services_test.go b/internal/app/services_test.go new file mode 100644 index 0000000..6d0fb82 --- /dev/null +++ b/internal/app/services_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "strings" + "testing" +) + +func TestApplyServiceSelectionEmptyKeepsConfig(t *testing.T) { + cfg := DefaultConfig() + got, err := ApplyServiceSelection(cfg, nil) + if err != nil { + t.Fatalf("ApplyServiceSelection(nil): %v", err) + } + if got != cfg { + t.Fatalf("expected cfg unchanged when selection is empty") + } + + got, err = ApplyServiceSelection(cfg, []string{}) + if err != nil { + t.Fatalf("ApplyServiceSelection([]): %v", err) + } + if got != cfg { + t.Fatalf("expected cfg unchanged when selection is empty slice") + } +} + +func TestApplyServiceSelectionEnablesOnlyChosen(t *testing.T) { + cfg := DefaultConfig() + // Pick something not enabled by default to prove enable+disable both work. + cfg.Services.Redis.Enabled = false + + got, err := ApplyServiceSelection(cfg, []string{"s3", "Redis", "BQ"}) + if err != nil { + t.Fatalf("ApplyServiceSelection: %v", err) + } + + enabled := map[string]bool{ + "mail": got.Services.Mail.Enabled, + "s3": got.Services.S3.Enabled, + "gcs": got.Services.GCS.Enabled, + "dynamodb": got.Services.DynamoDB.Enabled, + "bigquery": got.Services.BigQuery.Enabled, + "redshift": got.Services.Redshift.Enabled, + "redis": got.Services.Redis.Enabled, + "sqs": got.Services.SQS.Enabled, + "pubsub": got.Services.PubSub.Enabled, + "appautoscaling": got.Services.AppAutoScaling.Enabled, + } + want := map[string]bool{ + "s3": true, + "redis": true, + "bigquery": true, + } + for name, on := range enabled { + if on != want[name] { + t.Errorf("service %s enabled=%v, want %v", name, on, want[name]) + } + } +} + +func TestApplyServiceSelectionUnknownService(t *testing.T) { + _, err := ApplyServiceSelection(DefaultConfig(), []string{"s3", "kinesis"}) + if err == nil { + t.Fatal("expected error for unknown service, got nil") + } + if !strings.Contains(err.Error(), "kinesis") { + t.Errorf("error should mention the offending name; got %v", err) + } + if !strings.Contains(err.Error(), "known:") { + t.Errorf("error should list known services; got %v", err) + } +} + +func TestApplyServiceSelectionDoesNotMutateInput(t *testing.T) { + cfg := DefaultConfig() + original := cfg + if _, err := ApplyServiceSelection(cfg, []string{"s3"}); err != nil { + t.Fatalf("ApplyServiceSelection: %v", err) + } + if cfg != original { + t.Fatalf("input cfg was mutated") + } +} + +func TestServiceNamesCoversAllToggles(t *testing.T) { + names := ServiceNames() + if len(names) != len(serviceToggles) { + t.Fatalf("ServiceNames length=%d, serviceToggles=%d", len(names), len(serviceToggles)) + } + seen := make(map[string]bool, len(names)) + for _, n := range names { + if seen[n] { + t.Errorf("duplicate service name: %s", n) + } + seen[n] = true + if _, ok := serviceToggles[n]; !ok { + t.Errorf("ServiceNames contains %q which is not in serviceToggles", n) + } + } +}