Skip to content
Merged
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
119 changes: 119 additions & 0 deletions cmd/agent/workspace/clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package workspace

import (
"context"
"fmt"
"os/exec"
"strings"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/log"
"github.com/spf13/cobra"
)

const (
cleanVolumePrefix = "devsy-agent-"
cleanVolumeMountPath = "/opt/devsy"
cleanBinaryName = "devsy"
cleanHelperImage = "busybox:latest"
cleanDefaultDockerCmd = "docker"
)

// CleanCmd holds the cmd flags.
type CleanCmd struct {
*flags.GlobalFlags

DockerCommand string
HelperImage string
}

// NewCleanCmd creates a new command.
func NewCleanCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
cmd := &CleanCmd{
GlobalFlags: globalFlags,
}
cleanCmd := &cobra.Command{
Use: "clean [workspace-id]",
Short: "Removes the agent binary from the Docker volume for a workspace",
Long: `Removes the agent binary from the Docker named volume for the specified workspace.
This forces a fresh binary injection on the next workspace start.`,
Args: cobra.ExactArgs(1),
RunE: func(cobraCmd *cobra.Command, args []string) error {
return cmd.Run(cobraCmd.Context(), args[0])
},
}
cleanCmd.Flags().
StringVar(&cmd.DockerCommand, "docker-command", cleanDefaultDockerCmd, "Docker command to use")
cleanCmd.Flags().
StringVar(&cmd.HelperImage, "helper-image", cleanHelperImage, "Helper image for volume operations")
return cleanCmd
}

func (cmd *CleanCmd) Run(ctx context.Context, workspaceID string) error {
if workspaceID == "" {
return fmt.Errorf("workspace ID must not be empty")
}

volumeName := cleanVolumePrefix + workspaceID
log.Infof("Removing agent binary from volume %s", volumeName)

if err := cmd.removeBinaryFromVolume(ctx, volumeName); err != nil {
return fmt.Errorf("remove agent binary from volume %s: %w", volumeName, err)
}

log.Infof("Successfully removed agent binary from volume %s", volumeName)
return nil
}

func (cmd *CleanCmd) removeBinaryFromVolume(ctx context.Context, volumeName string) error {
if err := cmd.checkVolumeExists(ctx, volumeName); err != nil {
return err
}

binaryPath := cleanVolumeMountPath + "/" + cleanBinaryName
script := fmt.Sprintf(`rm -f "%s"`, binaryPath)
args := []string{
"run", "--rm",
"-v", volumeName + ":" + cleanVolumeMountPath,
cmd.helperImage(),
"sh", "-c", script,
}

out, err := cmd.dockerCmd(ctx, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("docker run failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}

func (cmd *CleanCmd) checkVolumeExists(ctx context.Context, volumeName string) error {
out, err := cmd.dockerCmd(ctx, "volume", "inspect", volumeName).CombinedOutput()
if err != nil {
return fmt.Errorf(
"volume %s not found (is Docker running?): %s: %w",
volumeName,
strings.TrimSpace(string(out)),
err,
)
}
return nil
}

func (cmd *CleanCmd) dockerCmd(ctx context.Context, args ...string) *exec.Cmd {
// #nosec G204 -- args are constructed internally, not from user input
return exec.CommandContext(ctx, cmd.dockerCommand(), args...)
}

func (cmd *CleanCmd) dockerCommand() string {
if cmd.DockerCommand != "" {
return cmd.DockerCommand
}
return cleanDefaultDockerCmd
}

func (cmd *CleanCmd) helperImage() string {
if cmd.HelperImage != "" {
return cmd.HelperImage
}
return cleanHelperImage
}
124 changes: 124 additions & 0 deletions cmd/agent/workspace/clean_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package workspace

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCleanCmd_EmptyWorkspaceID(t *testing.T) {
cmd := &CleanCmd{GlobalFlags: &flags.GlobalFlags{}}
err := cmd.Run(context.Background(), "")
require.Error(t, err)
assert.Contains(t, err.Error(), "must not be empty")
}

func TestCleanCmd_VolumeNotFound(t *testing.T) {
tmpDir := t.TempDir()

scriptPath := filepath.Join(tmpDir, "fake-docker.sh")
script := "#!/bin/sh\necho 'no such volume' >&2; exit 1\n"
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o600))
// #nosec G302 -- test script must be executable
require.NoError(t, os.Chmod(scriptPath, 0o755))

cmd := &CleanCmd{
GlobalFlags: &flags.GlobalFlags{},
DockerCommand: scriptPath,
}
err := cmd.Run(context.Background(), "nonexistent-ws")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}

func TestCleanCmd_RemoveBinarySuccess(t *testing.T) {
tmpDir := t.TempDir()

scriptPath := filepath.Join(tmpDir, "fake-docker.sh")
markerPath := filepath.Join(tmpDir, "rm-called")
script := "#!/bin/sh\n" +
"case \"$1\" in\n" +
" volume) echo '{}' ;;\n" +
" run) touch \"" + markerPath + "\" ;;\n" +
" *) exit 1 ;;\n" +
"esac\n"
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o600))
// #nosec G302 -- test script must be executable
require.NoError(t, os.Chmod(scriptPath, 0o755))

cmd := &CleanCmd{
GlobalFlags: &flags.GlobalFlags{},
DockerCommand: scriptPath,
}
err := cmd.Run(context.Background(), "test-workspace-123")
require.NoError(t, err)

_, statErr := os.Stat(markerPath)
assert.NoError(t, statErr, "docker run should have been called to remove the binary")
}

func TestCleanCmd_DockerRunFails(t *testing.T) {
tmpDir := t.TempDir()

scriptPath := filepath.Join(tmpDir, "fake-docker.sh")
script := "#!/bin/sh\n" +
"case \"$1\" in\n" +
" volume) echo '{}' ;;\n" +
" run) echo 'container error' >&2; exit 1 ;;\n" +
" *) exit 1 ;;\n" +
"esac\n"
require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o600))
// #nosec G302 -- test script must be executable
require.NoError(t, os.Chmod(scriptPath, 0o755))

cmd := &CleanCmd{
GlobalFlags: &flags.GlobalFlags{},
DockerCommand: scriptPath,
}
err := cmd.Run(context.Background(), "test-workspace")
require.Error(t, err)
assert.Contains(t, err.Error(), "docker run failed")
}

func TestCleanCmd_VolumeName(t *testing.T) {
assert.Equal(t, "devsy-agent-", cleanVolumePrefix)
assert.Equal(t, "devsy-agent-my-ws", cleanVolumePrefix+"my-ws")
}

func TestCleanCmd_HelperImage_Default(t *testing.T) {
cmd := &CleanCmd{GlobalFlags: &flags.GlobalFlags{}}
assert.Equal(t, "busybox:latest", cmd.helperImage())
}

func TestCleanCmd_HelperImage_Custom(t *testing.T) {
cmd := &CleanCmd{
GlobalFlags: &flags.GlobalFlags{},
HelperImage: "alpine:latest",
}
assert.Equal(t, "alpine:latest", cmd.helperImage())
}

func TestCleanCmd_DockerCommand_Default(t *testing.T) {
cmd := &CleanCmd{GlobalFlags: &flags.GlobalFlags{}}
assert.Equal(t, "docker", cmd.dockerCommand())
}

func TestCleanCmd_DockerCommand_Custom(t *testing.T) {
cmd := &CleanCmd{
GlobalFlags: &flags.GlobalFlags{},
DockerCommand: "podman",
}
assert.Equal(t, "podman", cmd.dockerCommand())
}

func TestNewCleanCmd_CobraSetup(t *testing.T) {
cobraCmd := NewCleanCmd(&flags.GlobalFlags{})
assert.Equal(t, "clean [workspace-id]", cobraCmd.Use)
assert.NotEmpty(t, cobraCmd.Short)
assert.NotEmpty(t, cobraCmd.Long)
}
1 change: 1 addition & 0 deletions cmd/agent/workspace/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ func NewWorkspaceCmd(flags *flags.GlobalFlags) *cobra.Command {
workspaceCmd.AddCommand(NewInstallDotfilesCmd(flags))
workspaceCmd.AddCommand(NewSetupGPGCmd(flags))
workspaceCmd.AddCommand(NewLogsCmd(flags))
workspaceCmd.AddCommand(NewCleanCmd(flags))
return workspaceCmd
}
73 changes: 65 additions & 8 deletions pkg/agent/delivery/local_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/devsy-org/devsy/pkg/agent"
"github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/version"
)

var _ AgentDelivery = (*LocalDockerDelivery)(nil)
Expand All @@ -26,9 +27,10 @@ const (
)

type LocalDockerDelivery struct {
DockerCommand string
Environment []string
HelperImage string
DockerCommand string
Environment []string
HelperImage string
ExpectedVersion string
}

func (d *LocalDockerDelivery) Phase() DeliveryPhase {
Expand All @@ -46,11 +48,8 @@ func (d *LocalDockerDelivery) DeliverPreStart(ctx context.Context, opts PreStart
return fmt.Errorf("create agent volume: %w", err)
}

if err := d.populateVolume(ctx, volumeName, opts.BinarySource, opts.Arch); err != nil {
if removeErr := d.removeVolume(ctx, volumeName); removeErr != nil {
log.Debugf("failed to clean up volume after populate failure: %v", removeErr)
}
return fmt.Errorf("populate agent volume: %w", err)
if err := d.ensureCurrentBinary(ctx, volumeName, opts.BinarySource, opts.Arch); err != nil {
return err
}

opts.RunOptions.Mounts = append(opts.RunOptions.Mounts, &config.Mount{
Expand All @@ -75,6 +74,36 @@ func (d *LocalDockerDelivery) Cleanup(ctx context.Context, workspaceID string) e
return d.removeVolume(ctx, workspaceID)
}

func (d *LocalDockerDelivery) ensureCurrentBinary(
ctx context.Context,
volumeName string,
binarySource BinarySourceFunc,
arch string,
) error {
expected := d.expectedVersion()
actual := d.detectVolumeVersion(ctx, volumeName)

if actual != "" && actual == expected {
log.Debugf(
"remote agent version matches expected version %s, skipping delivery",
expected,
)
return nil
}

if actual != "" {
log.Infof("upgraded remote agent from %s → %s", actual, expected)
}

if err := d.populateVolume(ctx, volumeName, binarySource, arch); err != nil {
if removeErr := d.removeVolume(ctx, volumeName); removeErr != nil {
log.Debugf("failed to clean up volume after populate failure: %v", removeErr)
}
return fmt.Errorf("populate agent volume: %w", err)
}
return nil
}

func (d *LocalDockerDelivery) createVolume(ctx context.Context, name string) error {
out, err := d.cmd(ctx, "volume", "create", name).CombinedOutput()
if err != nil {
Expand All @@ -90,6 +119,34 @@ func (d *LocalDockerDelivery) helperImageName() string {
return defaultHelperImage
}

func (d *LocalDockerDelivery) expectedVersion() string {
if d.ExpectedVersion != "" {
return d.ExpectedVersion
}
return version.GetVersion()
}

func (d *LocalDockerDelivery) detectVolumeVersion(ctx context.Context, volumeName string) string {
binaryPath := volumeMountPath + "/" + binaryName()
script := fmt.Sprintf(
`[ -x "%s" ] && "%s" version 2>/dev/null || true`,
binaryPath, binaryPath,
)
args := []string{
"run", "--rm",
"-v", volumeName + ":" + volumeMountPath,
d.helperImageName(),
"sh", "-c", script,
}

out, err := d.cmd(ctx, args...).CombinedOutput()
if err != nil {
log.Debugf("failed to detect agent version in volume: %v", err)
return ""
}
return strings.TrimSpace(string(out))
}

func (d *LocalDockerDelivery) populateVolume(
ctx context.Context,
volumeName string,
Expand Down
Loading
Loading