Files
coder/agent/agentcontainers/devcontainercli.go
T
Mathias Fredriksson fca99174ad feat(agent/agentcontainers): implement sub agent injection (#18245)
This change adds support for sub agent creation and injection into dev
containers.

Updates coder/internal#621
2025-06-10 12:37:54 +03:00

297 lines
9.2 KiB
Go

package agentcontainers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
)
// DevcontainerCLI is an interface for the devcontainer CLI.
type DevcontainerCLI interface {
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error
}
// DevcontainerCLIUpOptions are options for the devcontainer CLI Up
// command.
type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig)
type devcontainerCLIUpConfig struct {
args []string // Additional arguments for the Up command.
stdout io.Writer
stderr io.Writer
}
// WithRemoveExistingContainer is an option to remove the existing
// container.
func WithRemoveExistingContainer() DevcontainerCLIUpOptions {
return func(o *devcontainerCLIUpConfig) {
o.args = append(o.args, "--remove-existing-container")
}
}
// WithUpOutput sets additional stdout and stderr writers for logs
// during Up operations.
func WithUpOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions {
return func(o *devcontainerCLIUpConfig) {
o.stdout = stdout
o.stderr = stderr
}
}
// DevcontainerCLIExecOptions are options for the devcontainer CLI Exec
// command.
type DevcontainerCLIExecOptions func(*devcontainerCLIExecConfig)
type devcontainerCLIExecConfig struct {
args []string // Additional arguments for the Exec command.
stdout io.Writer
stderr io.Writer
}
// WithExecOutput sets additional stdout and stderr writers for logs
// during Exec operations.
func WithExecOutput(stdout, stderr io.Writer) DevcontainerCLIExecOptions {
return func(o *devcontainerCLIExecConfig) {
o.stdout = stdout
o.stderr = stderr
}
}
// WithExecContainerID sets the container ID to target a specific
// container.
func WithExecContainerID(id string) DevcontainerCLIExecOptions {
return func(o *devcontainerCLIExecConfig) {
o.args = append(o.args, "--container-id", id)
}
}
// WithRemoteEnv sets environment variables for the Exec command.
func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions {
return func(o *devcontainerCLIExecConfig) {
for _, e := range env {
o.args = append(o.args, "--remote-env", e)
}
}
}
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
conf := devcontainerCLIUpConfig{}
for _, opt := range opts {
if opt != nil {
opt(&conf)
}
}
return conf
}
func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig {
conf := devcontainerCLIExecConfig{}
for _, opt := range opts {
if opt != nil {
opt(&conf)
}
}
return conf
}
type devcontainerCLI struct {
logger slog.Logger
execer agentexec.Execer
}
var _ DevcontainerCLI = &devcontainerCLI{}
func NewDevcontainerCLI(logger slog.Logger, execer agentexec.Execer) DevcontainerCLI {
return &devcontainerCLI{
execer: execer,
logger: logger,
}
}
func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (string, error) {
conf := applyDevcontainerCLIUpOptions(opts)
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))
args := []string{
"up",
"--log-format", "json",
"--workspace-folder", workspaceFolder,
}
if configPath != "" {
args = append(args, "--config", configPath)
}
args = append(args, conf.args...)
cmd := d.execer.CommandContext(ctx, "devcontainer", args...)
// Capture stdout for parsing and stream logs for both default and provided writers.
var stdoutBuf bytes.Buffer
stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}}
if conf.stdout != nil {
stdoutWriters = append(stdoutWriters, conf.stdout)
}
cmd.Stdout = io.MultiWriter(stdoutWriters...)
// Stream stderr logs and provided writer if any.
stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}}
if conf.stderr != nil {
stderrWriters = append(stderrWriters, conf.stderr)
}
cmd.Stderr = io.MultiWriter(stderrWriters...)
if err := cmd.Run(); err != nil {
if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()); err2 != nil {
err = errors.Join(err, err2)
}
return "", err
}
result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes())
if err != nil {
return "", err
}
return result.ContainerID, nil
}
func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error {
conf := applyDevcontainerCLIExecOptions(opts)
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))
args := []string{"exec"}
// For now, always set workspace folder even if --container-id is provided.
// Otherwise the environment of exec will be incomplete, like `pwd` will be
// /home/coder instead of /workspaces/coder. The downside is that the local
// `devcontainer.json` config will overwrite settings serialized in the
// container label.
if workspaceFolder != "" {
args = append(args, "--workspace-folder", workspaceFolder)
}
if configPath != "" {
args = append(args, "--config", configPath)
}
args = append(args, conf.args...)
args = append(args, cmd)
args = append(args, cmdArgs...)
c := d.execer.CommandContext(ctx, "devcontainer", args...)
stdoutWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}}
if conf.stdout != nil {
stdoutWriters = append(stdoutWriters, conf.stdout)
}
c.Stdout = io.MultiWriter(stdoutWriters...)
stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}}
if conf.stderr != nil {
stderrWriters = append(stderrWriters, conf.stderr)
}
c.Stderr = io.MultiWriter(stderrWriters...)
if err := c.Run(); err != nil {
return xerrors.Errorf("devcontainer exec failed: %w", err)
}
return nil
}
// parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output
// which is a JSON object.
func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) {
s := bufio.NewScanner(bytes.NewReader(p))
var lastLine []byte
for s.Scan() {
b := s.Bytes()
if len(b) == 0 || b[0] != '{' {
continue
}
lastLine = b
}
if err = s.Err(); err != nil {
return result, err
}
if len(lastLine) == 0 || lastLine[0] != '{' {
logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine)))
return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine))
}
if err = json.Unmarshal(lastLine, &result); err != nil {
logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine)))
return result, err
}
return result, result.Err()
}
// devcontainerCLIResult is the result of the devcontainer CLI command.
// It is parsed from the last line of the devcontainer CLI stdout which
// is a JSON object.
type devcontainerCLIResult struct {
Outcome string `json:"outcome"` // "error", "success".
// The following fields are set if outcome is success.
ContainerID string `json:"containerId"`
RemoteUser string `json:"remoteUser"`
RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"`
// The following fields are set if outcome is error.
Message string `json:"message"`
Description string `json:"description"`
}
func (r devcontainerCLIResult) Err() error {
if r.Outcome == "success" {
return nil
}
return xerrors.Errorf("devcontainer up failed: %s (description: %s, message: %s)", r.Outcome, r.Description, r.Message)
}
// devcontainerCLIJSONLogLine is a log line from the devcontainer CLI.
type devcontainerCLIJSONLogLine struct {
Type string `json:"type"` // "progress", "raw", "start", "stop", "text", etc.
Level int `json:"level"` // 1, 2, 3.
Timestamp int `json:"timestamp"` // Unix timestamp in milliseconds.
Text string `json:"text"`
// More fields can be added here as needed.
}
// devcontainerCLILogWriter splits on newlines and logs each line
// separately.
type devcontainerCLILogWriter struct {
ctx context.Context
logger slog.Logger
}
func (l *devcontainerCLILogWriter) Write(p []byte) (n int, err error) {
s := bufio.NewScanner(bytes.NewReader(p))
for s.Scan() {
line := s.Bytes()
if len(line) == 0 {
continue
}
if line[0] != '{' {
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
continue
}
var logLine devcontainerCLIJSONLogLine
if err := json.Unmarshal(line, &logLine); err != nil {
l.logger.Error(l.ctx, "parse devcontainer json log line failed", slog.Error(err), slog.F("line", string(line)))
continue
}
if logLine.Level >= 3 {
l.logger.Info(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
continue
}
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
}
if err := s.Err(); err != nil {
l.logger.Error(l.ctx, "devcontainer log line scan failed", slog.Error(err))
}
return len(p), nil
}