Skip to content
Closed
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
24 changes: 11 additions & 13 deletions cmd/docker-deploy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import (

var version = "dev"

// sshDialTimeout is the maximum time to wait for an SSH connection to establish.
// This timeout covers the TCP dial phase; SSH protocol negotiation and authentication
// may take additional time (IN-01).
const sshDialTimeout = 10 * time.Second

func main() {
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
var host string
Expand Down Expand Up @@ -62,6 +67,9 @@ func main() {
}

// runDryRun implements the --dry-run flow: Resolve() -> Dial() -> print summary or error.
// The composeFile parameter is accepted for API symmetry with runDeploy but is not
// used during dry-run, since dry-run only verifies SSH connectivity and config resolution
// (IN-02).
func runDryRun(host, path string, excludes []string, force bool, composeFile string) error {
// 1. Determine projectName from the working directory basename.
cwd, err := os.Getwd()
Expand All @@ -77,13 +85,12 @@ func runDryRun(host, path string, excludes []string, force bool, composeFile str
}

// 3. Resolve config with flag > file > default precedence.
// composeFile is not resolved for dry-run; validation happens in runDeploy.
// A sentinel composeFile value is passed to skip auto-detection for dry-run.
// 0, 0 for health flags — not registered as CLI flags in Phase 5 (deploy.yaml only).
resolved, err := config.Resolve(host, path, excludes, force, "docker-compose.yml" /* sentinel: skips auto-detect; value is unused in dry-run */, 0, 0, fileConfig, projectName, cwd)
if err != nil {
return fmt.Errorf("resolving config: %w", err)
}
_ = composeFile // dry-run does not execute compose

// 4. Validate that a host was resolved.
if resolved.Host.Hostname == "" {
Expand All @@ -99,7 +106,7 @@ func runDryRun(host, path string, excludes []string, force bool, composeFile str
User: resolved.Host.User,
Hostname: resolved.Host.Hostname,
Port: port,
Timeout: 10 * time.Second,
Timeout: sshDialTimeout,
Stdin: os.Stdin,
Stdout: os.Stderr,
}
Expand Down Expand Up @@ -167,15 +174,6 @@ func runDeploy(host, path string, excludes []string, force bool, composeFile str
return fmt.Errorf("compose file must be a filename, not a path: %q", resolved.ComposeFile)
}

// 4c. Validate that the remote path is absolute (WR-03).
// ShellQuote prevents the shell from interpreting the path as a command, but
// it does not prevent filesystem-level traversal if the path is relative
// (e.g. "../../../etc"). Requiring a leading '/' ensures the path is anchored
// to the filesystem root and cannot escape the intended deploy root.
if !strings.HasPrefix(resolved.Path, "/") {
return fmt.Errorf("remote path must be absolute (start with /), got: %q", resolved.Path)
}

// 5. Build ssh.DialConfig from the resolved config.
port := resolved.Host.Port
if port == 0 {
Expand All @@ -185,7 +183,7 @@ func runDeploy(host, path string, excludes []string, force bool, composeFile str
User: resolved.Host.User,
Hostname: resolved.Host.Hostname,
Port: port,
Timeout: 10 * time.Second,
Timeout: sshDialTimeout,
Stdin: os.Stdin,
Stdout: os.Stderr,
}
Expand Down
21 changes: 15 additions & 6 deletions internal/compose/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"os"
"sync"
"syscall"
"unicode"

gossh "golang.org/x/crypto/ssh"
Expand Down Expand Up @@ -51,31 +52,39 @@ func RunCompose(ctx context.Context, client *gossh.Client, remotePath, composeFi

// Construct the remote command. Both remotePath and composeFile are
// shell-quoted so that neither can inject shell metacharacters.
cmd := "docker compose -f " + filetransfer.ShellQuote(remotePath+"/"+composeFile) + " up -d --remove-orphans"
cmd := "docker compose -f " + filetransfer.ShellQuote(remotePath) + "/" + filetransfer.ShellQuote(composeFile) + " up -d --remove-orphans"

// Open a dedicated session per CLAUDE.md Rule 3 (sessions are NOT reusable).
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("opening compose session: %w", err)
}
// Derive a child context so the cancellation watcher goroutine below can be
// stopped cleanly when RunCompose returns normally (WR-01).
// stopped cleanly when RunCompose returns normally (WR-02).
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Watch for context cancellation and close the SSH session so that
// session.Wait() and io.Copy unblock promptly (e.g. on Ctrl-C).
// Use WaitGroup to ensure the goroutine exits before RunCompose returns (WR-02).
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
session.Close() //nolint:errcheck
}()
defer wg.Wait()
// The deferred close below handles the normal (non-cancelled) exit path.
// When the context is cancelled the goroutine above closes the session first;
// the subsequent defer call is a no-op (double-close is safe for gossh).
// io.EOF is expected after session.Wait() drains the remote process; any
// other error is unexpected and logged to stderr for diagnosis (WR-04).
// io.EOF and syscall.EPIPE are expected errors when closing after the remote
// process has exited or when the connection is broken; suppress them to avoid
// noisy output in concurrent deployments (WR-04).
defer func() {
if closeErr := session.Close(); closeErr != nil && !errors.Is(closeErr, io.EOF) {
fmt.Fprintf(os.Stderr, "warning: session close: %v\n", closeErr)
if closeErr := session.Close(); closeErr != nil {
if !errors.Is(closeErr, io.EOF) && !errors.Is(closeErr, syscall.EPIPE) {
fmt.Fprintf(os.Stderr, "warning: session close: %v\n", closeErr)
}
}
}()

Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,5 +290,14 @@ func Resolve(flagHost, flagPath string, flagExcludes []string, flagForce bool, f
cfg.HealthInterval = 5
}

// Validate that the remote path is absolute (WR-03).
// ShellQuote prevents the shell from interpreting the path as a command, but
// it does not prevent filesystem-level traversal if the path is relative
// (e.g. "../../../etc"). Requiring a leading '/' ensures the path is anchored
// to the filesystem root and cannot escape the intended deploy root.
if cfg.Path != "" && !filepath.IsAbs(cfg.Path) {
return Config{}, fmt.Errorf("remote path must be absolute (start with /), got: %q", cfg.Path)
}

return cfg, nil
}
9 changes: 4 additions & 5 deletions internal/filetransfer/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,16 @@ func Upload(ctx context.Context, client *gossh.Client, localDir, remoteBase stri
if err != nil {
return 0, fmt.Errorf("opening SFTP session: %w", err)
}
defer sftpClient.Close()

// Step 4: Derive staging directory in the remote /tmp (always writable).
timestamp := fmt.Sprintf("%d", time.Now().Unix())
// Use nanosecond precision to avoid collisions in concurrent deployments
// to the same remote in the same second (IN-03).
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
stagingDir := "/tmp/docker-deploy-" + timestamp

// Step 5: Create staging directory.
if err := sftpClient.MkdirAll(stagingDir); err != nil {
sftpClient.Close()
return 0, fmt.Errorf("creating staging directory %s: %w", stagingDir, err)
}

Expand Down Expand Up @@ -149,9 +151,6 @@ func Upload(ctx context.Context, client *gossh.Client, localDir, remoteBase stri
return nil
}()

// Step 7: Close SFTP session before running SSH mv/rename commands.
sftpClient.Close()

if uploadErr != nil {
// Upload failed mid-way — staging dir is partial/unusable, clean it up.
_ = sshExec(client, fmt.Sprintf("rm -rf %s", ShellQuote(stagingDir)))
Expand Down
Loading