diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 206ee388df7..a65a6541493 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,12 +16,6 @@ This is the Azure Developer CLI - a Go-based CLI tool for managing Azure applica ``` ### Development flow -**Prerequisites:** -- [Go](https://go.dev/dl/) 1.24 -- [cspell](https://github.com/streetsidesoftware/cspell) -- [golangci-lint](https://golangci-lint.run/) - -If any of these tools aren't installed, install them first before proceeding. **Build `azd` binary:** ```bash @@ -61,11 +55,6 @@ When preparing a new release changelog, update `cli/azd/CHANGELOG.md` and `cli/v Rename any existing `## 1.x.x-beta.1 (Unreleased)` section to the version being released, without the `-beta.1` and `Unreleased` parts. Do the same for `cli/version.txt`. ### Step 2: Gather commits -**IMPORTANT**: Ensure you have the latest commits from main by first running these `git fetch` commands: -```bash -git fetch --unshallow origin && git fetch origin main:refs/remotes/origin/main -``` - **Find cutoff commit**: ```bash git --no-pager log --grep="Increment CLI version" --invert-grep -n 3 --follow -p -- cli/azd/CHANGELOG.md diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000000..ca7bf9695cc --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,51 @@ +name: "Copilot Setup Steps" + +# This workflow is used to set up the environment for GitHub Copilot coding agent. +# https://docs.github.com/copilot/customizing-copilot/customizing-the-development-environment-for-copilot-coding-agent + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + # To clone repo + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Fetch full history for writing changelogs + fetch-depth: 0 + + # Workaround for fetch-depth being overridden by coding agent orchestration + - run: git fetch --unshallow origin && git fetch origin main:refs/remotes/origin/main + continue-on-error: true + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "^1.24" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install golangci-lint + run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.0.0 + + - name: Install cspell + run: npm install -g cspell@8.13.1 diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 0fb4fe464b4..e2f7dcbe075 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -8,6 +8,7 @@ words: - chinacloudapi - Codespace - Codespaces + - containerd - devcontainers - extendee - eiannone diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 29d4c127c8d..38b9fd5a046 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/blang/semver/v4" @@ -118,6 +119,17 @@ func (d *Cli) Build( _, err = d.commandRunner.Run(ctx, runArgs) if err != nil { + // Check if this is a containerd-related error + if d.isContainerdBuildError(err) { + containerdEnabled, checkErr := d.IsContainerdEnabled(ctx) + if checkErr == nil && containerdEnabled { + return "", &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("building image: %w", err), + //nolint:lll + Suggestion: "Docker build failed with containerd image store enabled. Try disabling the containerd image store in Docker Desktop settings under 'Features in development' and run 'azd package' again", + } + } + } return "", fmt.Errorf("building image: %w", err) } @@ -164,6 +176,26 @@ func (d *Cli) Inspect(ctx context.Context, imageName string, format string) (str return out.Stdout, nil } +// IsContainerdEnabled checks if Docker is using containerd as the image store +func (d *Cli) IsContainerdEnabled(ctx context.Context) (bool, error) { + out, err := d.executeCommand(ctx, "", "info", "-f", "{{ .DriverStatus }}") + if err != nil { + return false, fmt.Errorf("checking docker driver status: %w", err) + } + + // When containerd is enabled, the output contains "driver-type io.containerd.snapshotter.v1" + return strings.Contains(out.Stdout, "driver-type io.containerd.snapshotter.v1"), nil +} + +// isContainerdBuildError checks if the error is related to containerd image store issues +func (d *Cli) isContainerdBuildError(err error) bool { + errStr := err.Error() + // Check for the specific error pattern that occurs with containerd enabled + return strings.Contains(errStr, "saving image") && + strings.Contains(errStr, "No such image") && + strings.Contains(errStr, "pack.local/builder") +} + func (d *Cli) versionInfo() tools.VersionInfo { return tools.VersionInfo{ MinimumVersion: semver.Version{ diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index bcb780e89e6..aeee83bf187 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" @@ -18,6 +19,14 @@ import ( const mockedDockerImgId = "fake-docker-image-id" +const containerdBuildError = "ERROR: failed to build: failed to write image to the following tags: " + + "[pack.local/builder/657662746b6877776b68:latest: saving image " + + "\"pack.local/builder/657662746b6877776b68:latest\": Error response from daemon: " + + "No such image: sha256:1a3f079e7ffed5eb4c02ecf6fdcc38c8fe459b021b4803471703dbded90181c4]" + +const dockerInfoContainerdDisabled = "[[Backing Filesystem extfs] [Supports d_type true] " + + "[Using metacopy false] [Native Overlay Diff true] [userxattr false]]" + func Test_DockerBuild(t *testing.T) { cwd := "." dockerFile := "./Dockerfile" @@ -596,3 +605,212 @@ func TestSplitDockerImage(t *testing.T) { }) } } + +func TestIsContainerdEnabled(t *testing.T) { + t.Run("ContainerdEnabled", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + require.Equal(t, "docker", args.Cmd) + require.Equal(t, []string{"info", "-f", "{{ .DriverStatus }}"}, args.Args) + + return exec.RunResult{ + Stdout: "[[driver-type io.containerd.snapshotter.v1]]", + Stderr: "", + ExitCode: 0, + }, nil + }) + + enabled, err := docker.IsContainerdEnabled(context.Background()) + require.NoError(t, err) + require.True(t, enabled) + }) + + t.Run("ContainerdDisabled", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + require.Equal(t, "docker", args.Cmd) + require.Equal(t, []string{"info", "-f", "{{ .DriverStatus }}"}, args.Args) + + return exec.RunResult{ + Stdout: dockerInfoContainerdDisabled, + Stderr: "", + ExitCode: 0, + }, nil + }) + + enabled, err := docker.IsContainerdEnabled(context.Background()) + require.NoError(t, err) + require.False(t, enabled) + }) + + t.Run("ErrorExecutingCommand", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + }, errors.New("docker daemon not running") + }) + + enabled, err := docker.IsContainerdEnabled(context.Background()) + require.Error(t, err) + require.False(t, enabled) + require.Contains(t, err.Error(), "checking docker driver status") + }) +} + +func TestBuildWithContainerdError(t *testing.T) { + cwd := "." + dockerFile := "./Dockerfile" + dockerContext := "../" + platform := DefaultPlatform + imageName := "IMAGE_NAME" + + t.Run("ContainerdBuildErrorWithSuggestion", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock the docker build command to return containerd-related error + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker build") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + }, errors.New(containerdBuildError) + }) + + // Mock the docker info command to return containerd enabled + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: "[[driver-type io.containerd.snapshotter.v1]]", + Stderr: "", + ExitCode: 0, + }, nil + }) + + _, err := docker.Build( + context.Background(), + cwd, + dockerFile, + platform, + "", + dockerContext, + imageName, + []string{}, + []string{}, + []string{}, + nil, + ) + + require.Error(t, err) + + // Check if it's an ErrorWithSuggestion + var errWithSuggestion *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &errWithSuggestion)) + require.Contains(t, errWithSuggestion.Suggestion, "containerd image store") + require.Contains(t, errWithSuggestion.Suggestion, "Docker Desktop settings") + }) + + t.Run("ContainerdBuildErrorButContainerdCheckFails", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock the docker build command to return containerd-related error + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker build") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + }, errors.New(containerdBuildError) + }) + + // Mock the docker info command to fail + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + }, errors.New("docker daemon not running") + }) + + _, err := docker.Build( + context.Background(), + cwd, + dockerFile, + platform, + "", + dockerContext, + imageName, + []string{}, + []string{}, + []string{}, + nil, + ) + + require.Error(t, err) + + // Should not be ErrorWithSuggestion since containerd check failed + var errWithSuggestion *internal.ErrorWithSuggestion + require.False(t, errors.As(err, &errWithSuggestion)) + require.Contains(t, err.Error(), "building image") + }) + + t.Run("ContainerdBuildErrorButContainerdDisabled", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + docker := NewCli(mockContext.CommandRunner) + + // Mock the docker build command to return containerd-related error + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker build") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + }, errors.New(containerdBuildError) + }) + + // Mock the docker info command to return containerd disabled + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "docker info") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + Stdout: dockerInfoContainerdDisabled, + Stderr: "", + ExitCode: 0, + }, nil + }) + + _, err := docker.Build( + context.Background(), + cwd, + dockerFile, + platform, + "", + dockerContext, + imageName, + []string{}, + []string{}, + []string{}, + nil, + ) + + require.Error(t, err) + + // Should not be ErrorWithSuggestion since containerd is disabled + var errWithSuggestion *internal.ErrorWithSuggestion + require.False(t, errors.As(err, &errWithSuggestion)) + require.Contains(t, err.Error(), "building image") + }) +}