mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
bddb808b25
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example: ``` import ( "context" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "gopkg.in/natefinch/lumberjack.v2" "cdr.dev/slog/v3" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) ``` 3 groups: standard library, 3rd partly libs, Coder libs. This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
484 lines
15 KiB
Go
484 lines
15 KiB
Go
package agentcontainers
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"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"`
|
|
AutoStart bool `json:"autoStart,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 {
|
|
result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
|
if err2 != nil {
|
|
err = errors.Join(err, err2)
|
|
}
|
|
// Return the container ID if available, even if there was an error.
|
|
// This can happen if the container was created successfully but a
|
|
// lifecycle script (e.g. postCreateCommand) failed.
|
|
return result.ContainerID, err
|
|
}
|
|
|
|
result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Check if the result indicates an error (e.g. lifecycle script failure)
|
|
// but still has a container ID, allowing the caller to potentially
|
|
// continue with the container that was created.
|
|
if err := result.Err(); err != nil {
|
|
return result.ContainerID, 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 typically set if outcome is success, but
|
|
// ContainerID may also be present when outcome is error if the
|
|
// container was created but a lifecycle script (e.g. postCreateCommand)
|
|
// failed.
|
|
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
|
|
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
|
|
}
|