mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
482 lines
15 KiB
Go
482 lines
15 KiB
Go
package agentcontainers
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// DevcontainerConfig is a wrapper around the output from `read-configuration`.
|
|
// Unfortunately we cannot make use of `dcspec` as the output doesn't appear to
|
|
// match.
|
|
type DevcontainerConfig struct {
|
|
MergedConfiguration DevcontainerMergedConfiguration `json:"mergedConfiguration"`
|
|
Configuration DevcontainerConfiguration `json:"configuration"`
|
|
Workspace DevcontainerWorkspace `json:"workspace"`
|
|
}
|
|
|
|
type DevcontainerMergedConfiguration struct {
|
|
Customizations DevcontainerMergedCustomizations `json:"customizations,omitempty"`
|
|
Features DevcontainerFeatures `json:"features,omitempty"`
|
|
}
|
|
|
|
type DevcontainerMergedCustomizations struct {
|
|
Coder []CoderCustomization `json:"coder,omitempty"`
|
|
}
|
|
|
|
type DevcontainerFeatures map[string]any
|
|
|
|
// OptionsAsEnvs converts the DevcontainerFeatures into a list of
|
|
// environment variables that can be used to set feature options.
|
|
// The format is FEATURE_<FEATURE_NAME>_OPTION_<OPTION_NAME>=<value>.
|
|
// For example, if the feature is:
|
|
//
|
|
// "ghcr.io/coder/devcontainer-features/code-server:1": {
|
|
// "port": 9090,
|
|
// }
|
|
//
|
|
// It will produce:
|
|
//
|
|
// FEATURE_CODE_SERVER_OPTION_PORT=9090
|
|
//
|
|
// Note that the feature name is derived from the last part of the key,
|
|
// so "ghcr.io/coder/devcontainer-features/code-server:1" becomes
|
|
// "CODE_SERVER". The version part (e.g. ":1") is removed, and dashes in
|
|
// the feature and option names are replaced with underscores.
|
|
func (f DevcontainerFeatures) OptionsAsEnvs() []string {
|
|
var env []string
|
|
for k, v := range f {
|
|
vv, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Take the last part of the key as the feature name/path.
|
|
k = k[strings.LastIndex(k, "/")+1:]
|
|
// Remove ":" and anything following it.
|
|
if idx := strings.Index(k, ":"); idx != -1 {
|
|
k = k[:idx]
|
|
}
|
|
k = strings.ReplaceAll(k, "-", "_")
|
|
for k2, v2 := range vv {
|
|
k2 = strings.ReplaceAll(k2, "-", "_")
|
|
env = append(env, fmt.Sprintf("FEATURE_%s_OPTION_%s=%s", strings.ToUpper(k), strings.ToUpper(k2), fmt.Sprintf("%v", v2)))
|
|
}
|
|
}
|
|
slices.Sort(env)
|
|
return env
|
|
}
|
|
|
|
type DevcontainerConfiguration struct {
|
|
Customizations DevcontainerCustomizations `json:"customizations,omitempty"`
|
|
}
|
|
|
|
type DevcontainerCustomizations struct {
|
|
Coder CoderCustomization `json:"coder,omitempty"`
|
|
}
|
|
|
|
type CoderCustomization struct {
|
|
DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"`
|
|
Apps []SubAgentApp `json:"apps,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Ignore bool `json:"ignore,omitempty"`
|
|
}
|
|
|
|
type DevcontainerWorkspace struct {
|
|
WorkspaceFolder string `json:"workspaceFolder"`
|
|
}
|
|
|
|
// 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
|
|
ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// DevcontainerCLIExecOptions are options for the devcontainer CLI ReadConfig
|
|
// command.
|
|
type DevcontainerCLIReadConfigOptions func(*devcontainerCLIReadConfigConfig)
|
|
|
|
type devcontainerCLIReadConfigConfig struct {
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
}
|
|
|
|
// WithReadConfigOutput sets additional stdout and stderr writers for logs
|
|
// during ReadConfig operations.
|
|
func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions {
|
|
return func(o *devcontainerCLIReadConfigConfig) {
|
|
o.stdout = stdout
|
|
o.stderr = stderr
|
|
}
|
|
}
|
|
|
|
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
|
|
conf := devcontainerCLIUpConfig{stdout: io.Discard, stderr: io.Discard}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(&conf)
|
|
}
|
|
}
|
|
return conf
|
|
}
|
|
|
|
func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig {
|
|
conf := devcontainerCLIExecConfig{stdout: io.Discard, stderr: io.Discard}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(&conf)
|
|
}
|
|
}
|
|
return conf
|
|
}
|
|
|
|
func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig {
|
|
conf := devcontainerCLIReadConfigConfig{stdout: io.Discard, stderr: io.Discard}
|
|
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
|
|
cmd.Stdout = io.MultiWriter(
|
|
&stdoutBuf,
|
|
&devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stdout", true)),
|
|
writer: conf.stdout,
|
|
},
|
|
)
|
|
// Stream stderr logs and provided writer if any.
|
|
cmd.Stderr = &devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stderr", true)),
|
|
writer: conf.stderr,
|
|
}
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
_, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
|
if err2 != nil {
|
|
err = errors.Join(err, err2)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](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...)
|
|
|
|
c.Stdout = io.MultiWriter(conf.stdout, &devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stdout", true)),
|
|
writer: io.Discard,
|
|
})
|
|
c.Stderr = io.MultiWriter(conf.stderr, &devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stderr", true)),
|
|
writer: io.Discard,
|
|
})
|
|
|
|
if err := c.Run(); err != nil {
|
|
return xerrors.Errorf("devcontainer exec failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, env []string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) {
|
|
conf := applyDevcontainerCLIReadConfigOptions(opts)
|
|
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath))
|
|
|
|
args := []string{"read-configuration", "--include-merged-configuration"}
|
|
if workspaceFolder != "" {
|
|
args = append(args, "--workspace-folder", workspaceFolder)
|
|
}
|
|
if configPath != "" {
|
|
args = append(args, "--config", configPath)
|
|
}
|
|
|
|
c := d.execer.CommandContext(ctx, "devcontainer", args...)
|
|
c.Env = append(c.Env, env...)
|
|
|
|
var stdoutBuf bytes.Buffer
|
|
c.Stdout = io.MultiWriter(
|
|
&stdoutBuf,
|
|
&devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stdout", true)),
|
|
writer: conf.stdout,
|
|
},
|
|
)
|
|
c.Stderr = &devcontainerCLILogWriter{
|
|
ctx: ctx,
|
|
logger: logger.With(slog.F("stderr", true)),
|
|
writer: conf.stderr,
|
|
}
|
|
|
|
if err := c.Run(); err != nil {
|
|
return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err)
|
|
}
|
|
|
|
config, err := parseDevcontainerCLILastLine[DevcontainerConfig](ctx, logger, stdoutBuf.Bytes())
|
|
if err != nil {
|
|
return DevcontainerConfig{}, err
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output
|
|
// which is a JSON object.
|
|
func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte) (T, error) {
|
|
var result T
|
|
|
|
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, nil
|
|
}
|
|
|
|
// 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) UnmarshalJSON(data []byte) error {
|
|
type wrapperResult devcontainerCLIResult
|
|
|
|
var wrappedResult wrapperResult
|
|
if err := json.Unmarshal(data, &wrappedResult); err != nil {
|
|
return err
|
|
}
|
|
|
|
*r = devcontainerCLIResult(wrappedResult)
|
|
return r.Err()
|
|
}
|
|
|
|
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
|
|
writer io.Writer
|
|
}
|
|
|
|
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)))
|
|
_, _ = l.writer.Write([]byte(strings.TrimSpace(logLine.Text) + "\n"))
|
|
continue
|
|
}
|
|
// If we've successfully parsed the final log line, it will successfully parse
|
|
// but will not fill out any of the fields for `logLine`. In this scenario we
|
|
// assume it is the final log line, unmarshal it as that, and check if the
|
|
// outcome is a non-empty string.
|
|
if logLine.Level == 0 {
|
|
var lastLine devcontainerCLIResult
|
|
if err := json.Unmarshal(line, &lastLine); err == nil && lastLine.Outcome != "" {
|
|
_, _ = l.writer.Write(line)
|
|
_, _ = l.writer.Write([]byte{'\n'})
|
|
}
|
|
}
|
|
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
|
|
}
|