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
29 changes: 29 additions & 0 deletions cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| `remove` | Remove a service from azure.yaml | |
| `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) |
Expand Down Expand Up @@ -307,6 +308,34 @@ azd app add redis --output json

---

## `azd app remove`

Remove a service from the services section of azure.yaml. This is the inverse of `azd app add`. It deletes the named service entry and leaves every other service in the file untouched.

### Usage

```bash
azd app remove [service]
```

### Examples

```bash
# Remove the redis service
azd app remove redis

# JSON output
azd app remove redis --output json
```

### Behavior

- Removing a service that is not present fails and lists the current service names.
- The rest of azure.yaml is preserved; only the named service entry is deleted.
- Supports the global `--output json` flag for scripting.

---

## `azd app run`

Starts your development environment based on project type with support for multi-service orchestration.
Expand Down
170 changes: 170 additions & 0 deletions cli/src/cmd/app/commands/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package commands

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/jongio/azd-core/cliout"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

// NewRemoveCommand creates the remove command.
func NewRemoveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove [service]",
Short: "Remove a service from azure.yaml",
Long: `Remove a service from the services section of azure.yaml.

This is the inverse of "azd app add". It deletes the named service entry and
leaves every other service in the file untouched. Use it to undo an add or to
drop a service you no longer run.

Examples:
# Remove the redis service
azd app remove redis

# JSON output
azd app remove redis --output json`,
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: runRemove,
ValidArgsFunction: completeServiceArgs,
}

return cmd
}

// RemoveResult represents the result of removing a service, mirroring AddResult.
type RemoveResult struct {
Service string `json:"service"`
Removed bool `json:"removed"`
Message string `json:"message,omitempty"`
}

func runRemove(_ *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("specify a service name to remove")
}

serviceName := args[0]

azureYamlPath, err := findAzureYamlForAdd()
if err != nil {
return fmt.Errorf("find azure.yaml: %w", err)
}

cliout.CommandHeader("remove", fmt.Sprintf("Remove %s", serviceName))

removed, remaining, err := removeServiceFromYaml(azureYamlPath, serviceName)
if err != nil {
return fmt.Errorf("failed to remove service: %w", err)
}

if !removed {
return fmt.Errorf("%s", serviceNotFoundMessage(serviceName, remaining))
}

if cliout.IsJSON() {
return cliout.PrintJSON(RemoveResult{
Service: serviceName,
Removed: true,
Message: fmt.Sprintf("Removed %s from azure.yaml", serviceName),
})
}

cliout.Success("Removed %s from azure.yaml", serviceName)
return nil
}

// removeServiceFromYaml deletes serviceName from the services mapping in the
// azure.yaml at path. It returns whether the service was found and removed, plus
// the sorted names of the services that remain (used to build a helpful error
// when the service was not found). Every other part of the file is preserved by
// editing the parsed node tree in place.
func removeServiceFromYaml(path, serviceName string) (bool, []string, error) {
cleanPath := filepath.Clean(path)
data, err := os.ReadFile(cleanPath)
if err != nil {
return false, nil, err
}

var doc yaml.Node
if err = yaml.Unmarshal(data, &doc); err != nil {
return false, nil, err
}

if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return false, nil, fmt.Errorf("invalid azure.yaml structure")
}

root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return false, nil, fmt.Errorf("azure.yaml root must be a mapping")
}

// Find the services mapping.
var servicesNode *yaml.Node
for i := 0; i < len(root.Content)-1; i += 2 {
if root.Content[i].Value == "services" {
servicesNode = root.Content[i+1]
break
}
}
if servicesNode == nil || servicesNode.Kind != yaml.MappingNode {
return false, nil, nil
}

// Locate the service key/value pair.
removeIdx := -1
for j := 0; j < len(servicesNode.Content)-1; j += 2 {
if servicesNode.Content[j].Value == serviceName {
removeIdx = j
break
}
}

if removeIdx < 0 {
return false, serviceNamesFromMapping(servicesNode), nil
}

// Drop the key node and its value node (two consecutive entries).
servicesNode.Content = append(servicesNode.Content[:removeIdx], servicesNode.Content[removeIdx+2:]...)

yamlOutput, err := yaml.Marshal(&doc)
if err != nil {
return false, nil, err
}

// #nosec G306 -- azure.yaml needs to be readable
if err := os.WriteFile(path, yamlOutput, 0o644); err != nil {
return false, nil, err
}

return true, serviceNamesFromMapping(servicesNode), nil
}

// serviceNamesFromMapping returns the sorted service names in a services mapping
// node.
func serviceNamesFromMapping(servicesNode *yaml.Node) []string {
names := make([]string, 0, len(servicesNode.Content)/2)
for j := 0; j < len(servicesNode.Content)-1; j += 2 {
names = append(names, servicesNode.Content[j].Value)
}
sort.Strings(names)
return names
}

// serviceNotFoundMessage builds the error text shown when a service to remove is
// not present, listing the current service names when there are any.
func serviceNotFoundMessage(serviceName string, remaining []string) string {
if len(remaining) == 0 {
return fmt.Sprintf("service %q not found in azure.yaml", serviceName)
}
return fmt.Sprintf("service %q not found in azure.yaml. Current services: %s",
serviceName, strings.Join(remaining, ", "))
}
182 changes: 182 additions & 0 deletions cli/src/cmd/app/commands/remove_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package commands

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func writeTempAzureYaml(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "azure.yaml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write azure.yaml: %v", err)
}
return path
}

func TestRemoveServiceFromYaml(t *testing.T) {
content := `name: test-app
services:
api:
language: python
project: ./api
redis:
host: containerapp
image: redis:7-alpine
ports:
- "6379:6379"
web:
language: js
project: ./web
`
path := writeTempAzureYaml(t, content)

removed, remaining, err := removeServiceFromYaml(path, "redis")
if err != nil {
t.Fatalf("removeServiceFromYaml() error: %v", err)
}
if !removed {
t.Fatal("removeServiceFromYaml() removed = false, want true")
}

wantRemaining := []string{"api", "web"}
if strings.Join(remaining, ",") != strings.Join(wantRemaining, ",") {
t.Errorf("remaining = %v, want %v", remaining, wantRemaining)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read azure.yaml: %v", err)
}
got := string(data)

if strings.Contains(got, "redis:") {
t.Errorf("azure.yaml still contains redis after removal:\n%s", got)
}
if strings.Contains(got, "6379:6379") {
t.Errorf("azure.yaml still contains redis ports after removal:\n%s", got)
}
// Siblings and top-level keys are preserved.
for _, want := range []string{"name: test-app", "api:", "web:"} {
if !strings.Contains(got, want) {
t.Errorf("azure.yaml missing %q after removal:\n%s", want, got)
}
}
}

func TestRemoveServiceFromYamlUnknown(t *testing.T) {
content := `name: test-app
services:
api:
language: python
web:
language: js
`
path := writeTempAzureYaml(t, content)

removed, remaining, err := removeServiceFromYaml(path, "redis")
if err != nil {
t.Fatalf("removeServiceFromYaml() error: %v", err)
}
if removed {
t.Error("removeServiceFromYaml() removed = true for unknown service, want false")
}

want := []string{"api", "web"}
if strings.Join(remaining, ",") != strings.Join(want, ",") {
t.Errorf("remaining = %v, want %v", remaining, want)
}

// The file must be untouched when nothing is removed.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read azure.yaml: %v", err)
}
if string(data) != content {
t.Errorf("azure.yaml changed on a no-op removal:\n%s", string(data))
}
}

func TestRemoveServiceFromYamlNoServicesSection(t *testing.T) {
content := `name: test-app
`
path := writeTempAzureYaml(t, content)

removed, remaining, err := removeServiceFromYaml(path, "redis")
if err != nil {
t.Fatalf("removeServiceFromYaml() error: %v", err)
}
if removed {
t.Error("removeServiceFromYaml() removed = true with no services section, want false")
}
if len(remaining) != 0 {
t.Errorf("remaining = %v, want empty", remaining)
}
}

func TestServiceNotFoundMessage(t *testing.T) {
tests := []struct {
name string
service string
remaining []string
wantParts []string
}{
{
name: "with remaining services",
service: "redis",
remaining: []string{"api", "web"},
wantParts: []string{`"redis" not found`, "api, web"},
},
{
name: "no remaining services",
service: "redis",
remaining: nil,
wantParts: []string{`"redis" not found`},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := serviceNotFoundMessage(tt.service, tt.remaining)
for _, part := range tt.wantParts {
if !strings.Contains(msg, part) {
t.Errorf("message %q missing %q", msg, part)
}
}
if tt.remaining == nil && strings.Contains(msg, "Current services") {
t.Errorf("message should not list services when none remain: %q", msg)
}
})
}
}

func TestRemoveResultJSON(t *testing.T) {
b, err := json.Marshal(RemoveResult{
Service: "redis",
Removed: true,
Message: "Removed redis from azure.yaml",
})
if err != nil {
t.Fatalf("marshal RemoveResult: %v", err)
}
got := string(b)
for _, want := range []string{`"service":"redis"`, `"removed":true`, `"message":"Removed redis from azure.yaml"`} {
if !strings.Contains(got, want) {
t.Errorf("RemoveResult JSON %s missing %q", got, want)
}
}
}

func TestRunRemoveNoArgs(t *testing.T) {
err := runRemove(nil, nil)
if err == nil {
t.Fatal("runRemove() with no args returned nil, want error")
}
if !strings.Contains(err.Error(), "specify a service name") {
t.Errorf("runRemove() error = %q, want it to mention specifying a service name", err.Error())
}
}
Loading
Loading