1299 lines
44 KiB
Go
1299 lines
44 KiB
Go
// Docker backend as a session-persistent RemoteSandboxClient.
|
|||
|
|
//
|
||
|
|
// One sandbox is one long-lived container: PID 1 sleeps, and every script,
|
||
|
|
// shell command and file operation runs against that container until the
|
||
|
|
// session ends or the idle sweep reclaims it. That is what makes the docker
|
||
|
|
// backend behave like the E2B one — same session state, same shell_exec, same
|
||
|
|
// attachment staging — instead of the previous one-shot `docker run --rm`,
|
||
|
|
// which could not keep anything between two executions.
|
||
|
|
//
|
||
|
|
// The mapping onto the Engine API:
|
||
|
|
//
|
||
|
|
// Create → POST /containers/create + /start, metadata as labels
|
||
|
|
// Connect → GET /containers/{id}/json, restarting a stopped container
|
||
|
|
// Get/List → GET /containers/json?filters=label=…
|
||
|
|
// Delete → DELETE /containers/{id}?force=1
|
||
|
|
// Exec → POST /containers/{id}/exec → /exec/{id}/start (hijack)
|
||
|
|
// Snapshot → POST /commit (skill images under weknora-skill/)
|
||
|
|
//
|
||
|
|
// Every file operation — WriteFile, ReadFile, Stat, MakeDir, Remove, ListDir —
|
||
|
|
// is an exec running as the sandbox account, NOT a call to /archive. The
|
||
|
|
// archive endpoints run as root and resolve symlinks, so a session that plants
|
||
|
|
// a link inside its own workspace could read or overwrite anything in the
|
||
|
|
// container through them. Going through exec puts the kernel back in charge of
|
||
|
|
// who may touch what. Do not "simplify" these back onto /archive.
|
||
|
|
//
|
||
|
|
// ListDir and Stat use `find -printf`, which needs GNU findutils in the image;
|
||
|
|
// the standard WeKnora sandbox image provides it.
|
||
|
|
//
|
||
|
|
// Two Docker facts shape the rest of this file:
|
||
|
|
//
|
||
|
|
// - Cancelling an exec client-side does NOT stop the process inside the
|
||
|
|
// container. Every exec is therefore wrapped in the container's own
|
||
|
|
// timeout(1), which is the only thing that actually kills a runaway script.
|
||
|
|
// - The daemon has no idle timeout. Each exec refreshes an activity marker
|
||
|
|
// file (for free, inside the same wrapper) that dockerIdleSweeper reads to
|
||
|
|
// decide what to reclaim.
|
||
|
|
|
||
|
|
package sandbox
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"path"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||
|
|
"github.com/moby/moby/api/types/container"
|
||
|
|
"github.com/moby/moby/client"
|
||
|
|
)
|
||
|
|
|
||
|
|
// dockerActivityMarker is touched by every exec and read by the idle sweeper.
|
||
|
|
// It lives outside /workspace so a script cannot mistake it for its own data,
|
||
|
|
// and outside /tmp so a tmpfs mount cannot hide it.
|
||
|
|
const dockerActivityMarker = "/var/lib/weknora-sandbox-activity"
|
||
|
|
|
||
|
|
// dockerSandboxEntrypoint keeps the container alive without running anything —
|
||
|
|
// the container is a place to exec into, not a service — and prepares the
|
||
|
|
// activity marker on the way.
|
||
|
|
//
|
||
|
|
// The marker has to be writable by the sandbox account. PID 1 runs as root so
|
||
|
|
// that it can create the file at all, but every exec that would refresh it —
|
||
|
|
// scripts, shell commands, filesystem helpers — runs as the unprivileged user.
|
||
|
|
// Creating it here, in the container's own entrypoint, avoids an extra API
|
||
|
|
// round trip per sandbox, and the chmod that follows is what lets that account
|
||
|
|
// touch it. Without this the idle sweeper would see a session that only ever
|
||
|
|
// ran scripts as untouched, and reclaim it out from under the user.
|
||
|
|
var dockerSandboxEntrypoint = []string{
|
||
|
|
"/bin/sh", "-c",
|
||
|
|
"touch " + dockerActivityMarker + " 2>/dev/null; " +
|
||
|
|
"chmod 666 " + dockerActivityMarker + " 2>/dev/null; " +
|
||
|
|
"exec sleep infinity",
|
||
|
|
}
|
||
|
|
|
||
|
|
// DockerRemoteClient implements RemoteSandboxClient on top of one Docker
|
||
|
|
// daemon. It is safe for concurrent use: the moby client is, and this type
|
||
|
|
// holds no mutable state.
|
||
|
|
type DockerRemoteClient struct {
|
||
|
|
api dockerEngineAPI
|
||
|
|
settings dockerRuntimeSettings
|
||
|
|
|
||
|
|
// sweeper reclaims idle containers. Nil disables idle reclamation, which
|
||
|
|
// is only appropriate for the connectivity-check client.
|
||
|
|
sweeper *dockerIdleSweeper
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerRuntimeSettings is the per-config slice of Config the adapter reads.
|
||
|
|
type dockerRuntimeSettings struct {
|
||
|
|
Image string
|
||
|
|
CPULimit float64
|
||
|
|
MemoryBytes int64
|
||
|
|
PidsLimit int64
|
||
|
|
NetworkMode string
|
||
|
|
Runtime string
|
||
|
|
IdleTTL time.Duration
|
||
|
|
HTTPTimeout time.Duration
|
||
|
|
Endpoint dockerEndpoint
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewDockerRemoteClient builds the adapter for one workspace config, reusing
|
||
|
|
// the shared connection to that daemon.
|
||
|
|
func NewDockerRemoteClient(cfg *Config) (*DockerRemoteClient, error) {
|
||
|
|
settings, err := dockerSettingsFromConfig(cfg)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
api, err := sharedDockerEngineClients.get(settings.Endpoint)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return newDockerRemoteClientWithAPI(withDockerRPCTimeout(api, settings.HTTPTimeout), settings), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewDockerRemoteClientForCheck builds a client for the connectivity check.
|
||
|
|
//
|
||
|
|
// It differs from the resolved client in one way: no idle sweeping. A probe
|
||
|
|
// runs against a config an admin is still editing, and a half-finished config
|
||
|
|
// must never delete containers a working config owns.
|
||
|
|
func NewDockerRemoteClientForCheck(cfg *Config) (*DockerRemoteClient, error) {
|
||
|
|
settings, err := dockerSettingsFromConfig(cfg)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
api, err := sharedDockerEngineClients.get(settings.Endpoint)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
settings.IdleTTL = 0
|
||
|
|
return newDockerRemoteClientWithAPI(withDockerRPCTimeout(api, settings.HTTPTimeout), settings), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// newDockerRemoteClientWithAPI is the seam unit tests use: it takes any
|
||
|
|
// dockerEngineAPI, including an in-memory fake.
|
||
|
|
func newDockerRemoteClientWithAPI(
|
||
|
|
api dockerEngineAPI,
|
||
|
|
settings dockerRuntimeSettings,
|
||
|
|
) *DockerRemoteClient {
|
||
|
|
adapter := &DockerRemoteClient{api: api, settings: settings}
|
||
|
|
if settings.IdleTTL > 0 {
|
||
|
|
adapter.sweeper = newDockerIdleSweeper(adapter, settings.IdleTTL)
|
||
|
|
}
|
||
|
|
return adapter
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerSettingsFromConfig projects Config, applying the built-in defaults for
|
||
|
|
// every value the workspace config leaves unset.
|
||
|
|
func dockerSettingsFromConfig(cfg *Config) (dockerRuntimeSettings, error) {
|
||
|
|
if cfg == nil {
|
||
|
|
return dockerRuntimeSettings{}, errors.New("sandbox: docker client requires a config")
|
||
|
|
}
|
||
|
|
image := strings.TrimSpace(cfg.DockerImage)
|
||
|
|
if image == "" {
|
||
|
|
return dockerRuntimeSettings{}, errors.New("sandbox: docker backend requires an image")
|
||
|
|
}
|
||
|
|
settings := dockerRuntimeSettings{
|
||
|
|
Image: image,
|
||
|
|
CPULimit: cfg.DockerCPULimit,
|
||
|
|
MemoryBytes: cfg.DockerMemoryBytes,
|
||
|
|
PidsLimit: cfg.DockerPidsLimit,
|
||
|
|
NetworkMode: strings.TrimSpace(cfg.DockerNetworkMode),
|
||
|
|
Runtime: strings.TrimSpace(cfg.DockerRuntime),
|
||
|
|
IdleTTL: cfg.DockerIdleTTL,
|
||
|
|
HTTPTimeout: cfg.DockerHTTPTimeout,
|
||
|
|
Endpoint: dockerEndpoint{
|
||
|
|
Host: strings.TrimSpace(cfg.DockerHost),
|
||
|
|
TLSCertPath: strings.TrimSpace(cfg.DockerTLSCertPath),
|
||
|
|
AllowPrivate: cfg.AllowPrivateEndpoints,
|
||
|
|
Timeout: cfg.DockerHTTPTimeout,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
if settings.CPULimit <= 0 {
|
||
|
|
settings.CPULimit = DefaultDockerCPULimit
|
||
|
|
}
|
||
|
|
if settings.MemoryBytes <= 0 {
|
||
|
|
settings.MemoryBytes = DefaultDockerMemoryLimit
|
||
|
|
}
|
||
|
|
if settings.PidsLimit <= 0 {
|
||
|
|
settings.PidsLimit = DefaultDockerPidsLimit
|
||
|
|
}
|
||
|
|
if settings.IdleTTL <= 0 {
|
||
|
|
settings.IdleTTL = DefaultDockerIdleTTL
|
||
|
|
}
|
||
|
|
if settings.HTTPTimeout <= 0 {
|
||
|
|
settings.HTTPTimeout = DefaultDockerHTTPTimeout
|
||
|
|
settings.Endpoint.Timeout = DefaultDockerHTTPTimeout
|
||
|
|
}
|
||
|
|
if err := ValidateDockerNetworkMode(settings.NetworkMode); err != nil {
|
||
|
|
return dockerRuntimeSettings{}, err
|
||
|
|
}
|
||
|
|
if err := ValidateDockerRemoteTLS(settings.Endpoint.Host, settings.Endpoint.TLSCertPath); err != nil {
|
||
|
|
return dockerRuntimeSettings{}, err
|
||
|
|
}
|
||
|
|
return settings, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerSandboxHandle is the opaque reference the manager holds.
|
||
|
|
type dockerSandboxHandle struct {
|
||
|
|
id string
|
||
|
|
metadata map[string]string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *dockerSandboxHandle) ID() string { return h.id }
|
||
|
|
func (h *dockerSandboxHandle) Provider() RemoteProvider { return SandboxTypeDocker }
|
||
|
|
func (h *dockerSandboxHandle) Metadata() map[string]string { return h.metadata }
|
||
|
|
|
||
|
|
// Provider identifies this backend.
|
||
|
|
func (c *DockerRemoteClient) Provider() RemoteProvider { return SandboxTypeDocker }
|
||
|
|
|
||
|
|
// Capabilities reports what this backend can do.
|
||
|
|
//
|
||
|
|
// SupportsTimeoutRefresh is false because the daemon has no timeout to
|
||
|
|
// refresh: idle reclamation is WeKnora's own sweep, not a provider feature.
|
||
|
|
// SupportsVolumes is false until the volume-mount surface is mapped onto
|
||
|
|
// Docker named volumes; advertising it early would let a workspace configure
|
||
|
|
// a mount that silently never appears.
|
||
|
|
func (c *DockerRemoteClient) Capabilities() RemoteSandboxCapabilities {
|
||
|
|
return RemoteSandboxCapabilities{
|
||
|
|
SupportsReconnect: true,
|
||
|
|
SupportsMetadata: true,
|
||
|
|
SupportsListSandboxes: true,
|
||
|
|
SupportsPauseResume: true,
|
||
|
|
SupportsTimeoutRefresh: false,
|
||
|
|
SupportsFilesystemEnumeration: true,
|
||
|
|
// docker commit produces a local image whose tag is a template ID,
|
||
|
|
// which is what skill install uses on Cube/E2B. The snapshot is
|
||
|
|
// filesystem-only (no memory) and lives on this daemon.
|
||
|
|
SupportsSnapshots: true,
|
||
|
|
SupportsVolumes: false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Health pings the daemon.
|
||
|
|
func (c *DockerRemoteClient) Health(ctx context.Context) error {
|
||
|
|
if _, err := c.api.Ping(ctx, client.PingOptions{}); err != nil {
|
||
|
|
return dockerError("Health", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create starts a new container for one sandbox.
|
||
|
|
func (c *DockerRemoteClient) Create(
|
||
|
|
ctx context.Context,
|
||
|
|
req RemoteCreateRequest,
|
||
|
|
) (RemoteSandboxHandle, error) {
|
||
|
|
if len(req.VolumeMounts) > 0 {
|
||
|
|
return nil, &RemoteError{
|
||
|
|
Kind: RemoteErrorKindUnsupported,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "Create",
|
||
|
|
Message: "docker backend does not mount volumes yet",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
image := strings.TrimSpace(req.TemplateID)
|
||
|
|
if image == "" {
|
||
|
|
image = c.settings.Image
|
||
|
|
}
|
||
|
|
if err := c.ensureImage(ctx, image); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
labels := dockerContainerLabels(req.Metadata)
|
||
|
|
labels[dockerIdleTTLLabel] = strconv.Itoa(int(c.effectiveIdleTTL(req.Timeout).Seconds()))
|
||
|
|
created, err := c.api.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||
|
|
Image: image,
|
||
|
|
Config: &container.Config{
|
||
|
|
// The standard image ends with USER user. The entrypoint has to
|
||
|
|
// create and chmod the activity marker under /var/lib, which only
|
||
|
|
// root can do; exec still names DefaultSandboxExecUser per call.
|
||
|
|
User: dockerSandboxPID1User,
|
||
|
|
// Entrypoint rather than Cmd, with Cmd explicitly emptied: the
|
||
|
|
// daemon prepends the image's own ENTRYPOINT to Cmd, so an image
|
||
|
|
// that declares one (the cube target of docker/Dockerfile.sandbox
|
||
|
|
// does, and so do most custom images) would take over PID 1. The
|
||
|
|
// activity marker would then never exist and every exec would run
|
||
|
|
// against whatever that entrypoint started instead.
|
||
|
|
Entrypoint: dockerSandboxEntrypoint,
|
||
|
|
Cmd: []string{},
|
||
|
|
WorkingDir: SessionWorkspaceRoot,
|
||
|
|
Labels: labels,
|
||
|
|
Env: dockerEnvSlice(req.EnvVars),
|
||
|
|
},
|
||
|
|
HostConfig: c.hostConfig(req.Network),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Create", err)
|
||
|
|
}
|
||
|
|
if _, err := c.api.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
|
||
|
|
// A container that cannot start is useless and would otherwise linger
|
||
|
|
// as an unbound leftover the sweep only reclaims much later.
|
||
|
|
c.removeQuietly(ctx, created.ID)
|
||
|
|
return nil, dockerError("Create", err)
|
||
|
|
}
|
||
|
|
// ContainerStart returning is not the same as State.Running: the daemon
|
||
|
|
// accepts the start, then PID 1 still has to replace sh. ExecCreate on a
|
||
|
|
// container that is still "created" comes back as 409 Conflict
|
||
|
|
// ("container is not running"), which used to fail skill install on
|
||
|
|
// the first attempt and succeed on retry.
|
||
|
|
if err := c.waitUntilRunning(ctx, created.ID, "Create"); err != nil {
|
||
|
|
c.removeQuietly(ctx, created.ID)
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
c.sweepInBackground(ctx)
|
||
|
|
return &dockerSandboxHandle{id: created.ID, metadata: dockerSandboxMetadata(labels)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// effectiveIdleTTL resolves how long this sandbox may sit unused.
|
||
|
|
//
|
||
|
|
// The caller's timeout policy is honoured because it is what the session
|
||
|
|
// layer already expresses per provider; the configured value is the fallback.
|
||
|
|
// The action (pause vs kill) is not: Docker's pause keeps the container's
|
||
|
|
// memory resident on the host, so pausing an abandoned sandbox would reclaim
|
||
|
|
// nothing. Idle containers are always deleted, which matches what the
|
||
|
|
// lifecycle does with a sandbox its provider reaped.
|
||
|
|
func (c *DockerRemoteClient) effectiveIdleTTL(policy RemoteTimeoutPolicy) time.Duration {
|
||
|
|
if policy.Mode == RemoteTimeoutExplicit && policy.Value > 0 {
|
||
|
|
return policy.Value
|
||
|
|
}
|
||
|
|
return c.settings.IdleTTL
|
||
|
|
}
|
||
|
|
|
||
|
|
// hostConfig builds the isolation and resource envelope for a new container.
|
||
|
|
func (c *DockerRemoteClient) hostConfig(policy RemoteNetworkPolicy) *container.HostConfig {
|
||
|
|
pids := c.settings.PidsLimit
|
||
|
|
hostConfig := &container.HostConfig{
|
||
|
|
Resources: container.Resources{
|
||
|
|
Memory: c.settings.MemoryBytes,
|
||
|
|
// Equal memory and memory+swap disables swap, so a runaway
|
||
|
|
// allocation is killed instead of thrashing the host's disk.
|
||
|
|
MemorySwap: c.settings.MemoryBytes,
|
||
|
|
NanoCPUs: int64(c.settings.CPULimit * 1e9),
|
||
|
|
PidsLimit: &pids,
|
||
|
|
},
|
||
|
|
CapDrop: []string{"ALL"},
|
||
|
|
CapAdd: dockerSandboxCapabilities,
|
||
|
|
SecurityOpt: []string{"no-new-privileges"},
|
||
|
|
Runtime: c.settings.Runtime,
|
||
|
|
NetworkMode: container.NetworkMode(c.networkMode(policy)),
|
||
|
|
// The entrypoint is a plain `sleep`, which never calls wait(). Without
|
||
|
|
// tini in front of it, a background process outliving the exec that
|
||
|
|
// started it becomes an unreaped zombie on exit, and a long session
|
||
|
|
// accumulates them until PidsLimit is exhausted and every further exec
|
||
|
|
// fails.
|
||
|
|
Init: &dockerInitProcess,
|
||
|
|
}
|
||
|
|
return hostConfig
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerInitProcess is addressable because HostConfig.Init is a *bool: unset
|
||
|
|
// means "follow the daemon default", which is not what this backend wants.
|
||
|
|
var dockerInitProcess = true
|
||
|
|
|
||
|
|
// networkMode resolves the effective Docker network for a sandbox.
|
||
|
|
//
|
||
|
|
// Docker filters at L3/L4 only, so the domain-level allow/deny lists in
|
||
|
|
// RemoteNetworkPolicy cannot be honoured here; the one thing that maps
|
||
|
|
// cleanly is "no egress at all", which AllowInternetAccess=false expresses.
|
||
|
|
// Domain rules are silently not applied — the config surface refuses them
|
||
|
|
// before they get this far (see types.ValidateSandboxNetworkPolicy, which
|
||
|
|
// rejects any allow/deny list or L7 rule on a docker config at save time).
|
||
|
|
func (c *DockerRemoteClient) networkMode(policy RemoteNetworkPolicy) string {
|
||
|
|
if policy.AllowInternetAccess != nil && !*policy.AllowInternetAccess {
|
||
|
|
return "none"
|
||
|
|
}
|
||
|
|
if c.settings.NetworkMode != "" {
|
||
|
|
return c.settings.NetworkMode
|
||
|
|
}
|
||
|
|
return "bridge"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Connect re-attaches to an existing container, resuming it when the daemon
|
||
|
|
// or the host stopped it. This is the docker equivalent of E2B's auto-resume:
|
||
|
|
// a stopped container keeps its filesystem, so the session continues where it
|
||
|
|
// left off instead of losing everything it installed.
|
||
|
|
func (c *DockerRemoteClient) Connect(
|
||
|
|
ctx context.Context,
|
||
|
|
request RemoteConnectRequest,
|
||
|
|
) (RemoteSandboxHandle, error) {
|
||
|
|
// Docker containers are not fronted by a provider gateway, so there is no
|
||
|
|
// inbound credential to restore; TrafficAccessToken is ignored.
|
||
|
|
sandboxID := request.SandboxID
|
||
|
|
inspected, err := c.api.ContainerInspect(ctx, sandboxID, client.ContainerInspectOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Connect", err)
|
||
|
|
}
|
||
|
|
state := inspected.Container.State
|
||
|
|
if state == nil {
|
||
|
|
return nil, dockerError("Connect", errors.New("daemon returned no container state"))
|
||
|
|
}
|
||
|
|
switch dockerStateOf(state.Status) {
|
||
|
|
case RemoteStateTerminal:
|
||
|
|
return nil, &RemoteError{
|
||
|
|
Kind: RemoteErrorKindTerminal,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "Connect",
|
||
|
|
Message: "container is dead",
|
||
|
|
}
|
||
|
|
case RemoteStatePaused:
|
||
|
|
if err := c.resume(ctx, inspected.Container.ID, string(state.Status), "Connect"); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := c.waitUntilRunning(ctx, inspected.Container.ID, "Connect"); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
c.sweepInBackground(ctx)
|
||
|
|
|
||
|
|
var labels map[string]string
|
||
|
|
if inspected.Container.Config != nil {
|
||
|
|
labels = inspected.Container.Config.Labels
|
||
|
|
}
|
||
|
|
return &dockerSandboxHandle{
|
||
|
|
id: inspected.Container.ID,
|
||
|
|
metadata: dockerSandboxMetadata(labels),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerStartReadyTimeout bounds how long Create/Connect/Exec wait for PID 1
|
||
|
|
// after the daemon has accepted a start. The window is milliseconds on a
|
||
|
|
// healthy daemon; this is only the ceiling for a wedged one.
|
||
|
|
var dockerStartReadyTimeout = 15 * time.Second
|
||
|
|
|
||
|
|
// dockerStartReadyPoll is the inspect interval inside waitUntilRunning.
|
||
|
|
var dockerStartReadyPoll = 50 * time.Millisecond
|
||
|
|
|
||
|
|
// resume brings a paused or stopped container back to running.
|
||
|
|
func (c *DockerRemoteClient) resume(ctx context.Context, id, status, op string) error {
|
||
|
|
if op == "" {
|
||
|
|
op = "Connect"
|
||
|
|
}
|
||
|
|
if strings.EqualFold(status, "paused") {
|
||
|
|
if _, err := c.api.ContainerUnpause(ctx, id, client.ContainerUnpauseOptions{}); err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if _, err := c.api.ContainerStart(ctx, id, client.ContainerStartOptions{}); err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerContainerNotRunning reports the Engine 409 that ExecCreate returns
|
||
|
|
// when the target is not in State.Running. The message is the signal: Kind
|
||
|
|
// Conflict also covers "already exists" and is not replaceable, so callers
|
||
|
|
// that want to resume have to look here rather than at CanReplaceRemoteBinding.
|
||
|
|
func dockerContainerNotRunning(err error) bool {
|
||
|
|
if err == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return strings.Contains(strings.ToLower(err.Error()), "is not running")
|
||
|
|
}
|
||
|
|
|
||
|
|
// waitUntilRunning polls inspect until the container is running, or until it
|
||
|
|
// is clear waiting will not help (exited, paused, dead).
|
||
|
|
func (c *DockerRemoteClient) waitUntilRunning(ctx context.Context, id, op string) error {
|
||
|
|
deadline := time.Now().Add(dockerStartReadyTimeout)
|
||
|
|
var lastStatus string
|
||
|
|
for {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
inspected, err := c.api.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
state := inspected.Container.State
|
||
|
|
if state == nil {
|
||
|
|
return dockerError(op, errors.New("daemon returned no container state"))
|
||
|
|
}
|
||
|
|
lastStatus = strings.TrimSpace(string(state.Status))
|
||
|
|
switch dockerStateOf(state.Status) {
|
||
|
|
case RemoteStateRunning:
|
||
|
|
return nil
|
||
|
|
case RemoteStateTerminal:
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindTerminal,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: "container is dead",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
switch strings.ToLower(lastStatus) {
|
||
|
|
case "exited", "paused":
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindConflict,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: "container is not running: " + lastStatus,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !time.Now().Before(deadline) {
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindTimeout,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: fmt.Sprintf("container did not reach running (last state %q)", lastStatus),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
timer := time.NewTimer(dockerStartReadyPoll)
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
timer.Stop()
|
||
|
|
return dockerError(op, ctx.Err())
|
||
|
|
case <-timer.C:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ensureRunning resumes a stopped/paused container and waits until exec can
|
||
|
|
// succeed. It is the Exec counterpart of Connect: the first command of a
|
||
|
|
// newly created sandbox used to race PID 1, and a later command can hit a
|
||
|
|
// container the host or the idle sweep stopped.
|
||
|
|
func (c *DockerRemoteClient) ensureRunning(ctx context.Context, id, op string) error {
|
||
|
|
inspected, err := c.api.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
state := inspected.Container.State
|
||
|
|
if state == nil {
|
||
|
|
return dockerError(op, errors.New("daemon returned no container state"))
|
||
|
|
}
|
||
|
|
switch dockerStateOf(state.Status) {
|
||
|
|
case RemoteStateRunning:
|
||
|
|
return nil
|
||
|
|
case RemoteStateTerminal:
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindTerminal,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: "container is dead",
|
||
|
|
}
|
||
|
|
case RemoteStatePaused:
|
||
|
|
if err := c.resume(ctx, inspected.Container.ID, string(state.Status), op); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.waitUntilRunning(ctx, id, op)
|
||
|
|
default:
|
||
|
|
return c.waitUntilRunning(ctx, id, op)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get returns one sandbox summary.
|
||
|
|
func (c *DockerRemoteClient) Get(
|
||
|
|
ctx context.Context,
|
||
|
|
sandboxID string,
|
||
|
|
) (*RemoteSandboxSummary, error) {
|
||
|
|
inspected, err := c.api.ContainerInspect(ctx, sandboxID, client.ContainerInspectOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Get", err)
|
||
|
|
}
|
||
|
|
summary := &RemoteSandboxSummary{
|
||
|
|
ID: inspected.Container.ID,
|
||
|
|
StartedAt: dockerContainerStartedAt(inspected.Container.State),
|
||
|
|
}
|
||
|
|
if inspected.Container.Config != nil {
|
||
|
|
summary.TemplateID = inspected.Container.Config.Image
|
||
|
|
summary.Metadata = dockerSandboxMetadata(inspected.Container.Config.Labels)
|
||
|
|
}
|
||
|
|
if inspected.Container.State != nil {
|
||
|
|
summary.RawState = string(inspected.Container.State.Status)
|
||
|
|
summary.State = dockerStateOf(inspected.Container.State.Status)
|
||
|
|
if finished, err := time.Parse(
|
||
|
|
time.RFC3339Nano, inspected.Container.State.FinishedAt,
|
||
|
|
); err == nil && finished.Year() > 1 {
|
||
|
|
summary.EndAt = finished.UTC()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// List enumerates the containers this backend owns, filtered server-side by
|
||
|
|
// the metadata labels the caller asked for.
|
||
|
|
func (c *DockerRemoteClient) List(
|
||
|
|
ctx context.Context,
|
||
|
|
filter RemoteListFilter,
|
||
|
|
) ([]RemoteSandboxSummary, error) {
|
||
|
|
filters := client.Filters{}.Add("label", dockerManagedLabel+"=true")
|
||
|
|
for key, value := range filter.Metadata {
|
||
|
|
filters = filters.Add("label", key+"="+value)
|
||
|
|
}
|
||
|
|
listed, err := c.api.ContainerList(ctx, client.ContainerListOptions{
|
||
|
|
All: true,
|
||
|
|
Filters: filters,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("List", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
wanted := make(map[RemoteSandboxState]struct{}, len(filter.States))
|
||
|
|
for _, state := range filter.States {
|
||
|
|
wanted[state] = struct{}{}
|
||
|
|
}
|
||
|
|
summaries := make([]RemoteSandboxSummary, 0, len(listed.Items))
|
||
|
|
for _, item := range listed.Items {
|
||
|
|
state := dockerStateOf(item.State)
|
||
|
|
if len(wanted) > 0 {
|
||
|
|
if _, ok := wanted[state]; !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
summaries = append(summaries, RemoteSandboxSummary{
|
||
|
|
ID: item.ID,
|
||
|
|
TemplateID: item.Image,
|
||
|
|
State: state,
|
||
|
|
RawState: string(item.State),
|
||
|
|
Metadata: dockerSandboxMetadata(item.Labels),
|
||
|
|
StartedAt: time.Unix(item.Created, 0).UTC(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return summaries, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete removes a container and its anonymous volumes.
|
||
|
|
func (c *DockerRemoteClient) Delete(ctx context.Context, sandboxID string) error {
|
||
|
|
_, err := c.api.ContainerRemove(ctx, sandboxID, client.ContainerRemoveOptions{
|
||
|
|
Force: true,
|
||
|
|
RemoveVolumes: true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError("Delete", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// removeQuietly deletes a container on a cleanup path where the caller
|
||
|
|
// already has a more interesting error to report.
|
||
|
|
func (c *DockerRemoteClient) removeQuietly(ctx context.Context, id string) {
|
||
|
|
cleanupCtx, cancel := context.WithTimeout(
|
||
|
|
context.WithoutCancel(ctx), remoteCleanupTimeout,
|
||
|
|
)
|
||
|
|
defer cancel()
|
||
|
|
_, _ = c.api.ContainerRemove(cleanupCtx, id, client.ContainerRemoveOptions{
|
||
|
|
Force: true, RemoveVolumes: true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exec runs one command inside the sandbox.
|
||
|
|
//
|
||
|
|
// The command is wrapped so that the container, not the client, enforces the
|
||
|
|
// timeout: cancelling the HTTP request leaves the process running (verified in
|
||
|
|
// docs/poc/docker-sandbox), which would let a runaway script keep burning the
|
||
|
|
// host's CPU long after WeKnora reported a timeout to the user.
|
||
|
|
func (c *DockerRemoteClient) Exec(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
req RemoteExecRequest,
|
||
|
|
) (*RemoteExecResult, error) {
|
||
|
|
id, err := dockerHandleID("Exec", handle)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if req.Shell && len(req.Args) > 0 {
|
||
|
|
return nil, dockerInvalidRequest("Exec", "shell requests must not carry args")
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(req.Command) == "" {
|
||
|
|
return nil, dockerInvalidRequest("Exec", "command is required")
|
||
|
|
}
|
||
|
|
|
||
|
|
timeout := req.Timeout
|
||
|
|
if timeout <= 0 {
|
||
|
|
timeout = DefaultTimeout
|
||
|
|
}
|
||
|
|
// The client deadline is deliberately looser than the in-container one so
|
||
|
|
// the wrapper gets to report the kill itself, which is what turns a
|
||
|
|
// timeout into Killed=true rather than a transport error.
|
||
|
|
execCtx, cancel := context.WithTimeout(ctx, timeout+dockerExecGrace)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
execOpts := client.ExecCreateOptions{
|
||
|
|
Cmd: dockerExecCommand(req, timeout),
|
||
|
|
User: dockerExecUser(req.User),
|
||
|
|
WorkingDir: req.WorkDir,
|
||
|
|
Env: dockerEnvSlice(req.Env),
|
||
|
|
AttachStdin: req.Stdin != "",
|
||
|
|
AttachStdout: true,
|
||
|
|
AttachStderr: true,
|
||
|
|
}
|
||
|
|
created, err := c.api.ExecCreate(execCtx, id, execOpts)
|
||
|
|
if err != nil && dockerContainerNotRunning(err) {
|
||
|
|
if readyErr := c.ensureRunning(execCtx, id, "Exec"); readyErr != nil {
|
||
|
|
return nil, readyErr
|
||
|
|
}
|
||
|
|
created, err = c.api.ExecCreate(execCtx, id, execOpts)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Exec", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
start := time.Now()
|
||
|
|
stdout, stderr, err := c.streamExec(execCtx, created.ID, req.Stdin)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
inspected, err := c.api.ExecInspect(execCtx, created.ID, client.ExecInspectOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Exec", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
result := &RemoteExecResult{
|
||
|
|
Stdout: stdout,
|
||
|
|
Stderr: stderr,
|
||
|
|
ExitCode: inspected.ExitCode,
|
||
|
|
Duration: time.Since(start),
|
||
|
|
}
|
||
|
|
if dockerExecWasKilled(inspected.ExitCode) {
|
||
|
|
result.Killed = true
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerExecGrace is the slack between the in-container timeout and the
|
||
|
|
// client-side deadline. It covers the round-trip and the wrapper's own
|
||
|
|
// teardown so a script killed at the deadline is still reported as Killed
|
||
|
|
// instead of surfacing as a transport timeout.
|
||
|
|
const dockerExecGrace = 10 * time.Second
|
||
|
|
|
||
|
|
// streamExec starts the exec, writes stdin, and demultiplexes the output.
|
||
|
|
func (c *DockerRemoteClient) streamExec(
|
||
|
|
ctx context.Context,
|
||
|
|
execID string,
|
||
|
|
stdin string,
|
||
|
|
) (string, string, error) {
|
||
|
|
attached, err := c.api.ExecAttach(ctx, execID, client.ExecAttachOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return "", "", dockerError("Exec", err)
|
||
|
|
}
|
||
|
|
// Closing is what unblocks the copy goroutine below, so the cancellation
|
||
|
|
// path needs to do it before the deferred close would.
|
||
|
|
var closeOnce sync.Once
|
||
|
|
closeStream := func() { closeOnce.Do(attached.Close) }
|
||
|
|
defer closeStream()
|
||
|
|
|
||
|
|
if stdin != "" {
|
||
|
|
if _, err := attached.Conn.Write([]byte(stdin)); err != nil {
|
||
|
|
return "", "", dockerError("Exec", fmt.Errorf("write stdin: %w", err))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Always half-close: a command reading stdin (cat, python -) hangs
|
||
|
|
// forever on an open write side, even when nothing was sent.
|
||
|
|
if closer, ok := attached.Conn.(interface{ CloseWrite() error }); ok {
|
||
|
|
_ = closer.CloseWrite()
|
||
|
|
}
|
||
|
|
|
||
|
|
// These buffers belong to the copy goroutine until it reports on done.
|
||
|
|
// Reading them any earlier races with StdCopy writing into them, which is
|
||
|
|
// why the cancellation path closes the stream and waits instead of
|
||
|
|
// returning whatever happens to be there.
|
||
|
|
var stdout, stderr bytes.Buffer
|
||
|
|
done := make(chan error, 1)
|
||
|
|
go func() {
|
||
|
|
_, copyErr := stdcopy.StdCopy(&stdout, &stderr, attached.Reader)
|
||
|
|
done <- copyErr
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case copyErr := <-done:
|
||
|
|
if copyErr != nil && !errors.Is(copyErr, io.EOF) {
|
||
|
|
return stdout.String(), stderr.String(), dockerError("Exec", copyErr)
|
||
|
|
}
|
||
|
|
return stdout.String(), stderr.String(), nil
|
||
|
|
case <-ctx.Done():
|
||
|
|
closeStream()
|
||
|
|
select {
|
||
|
|
case <-done:
|
||
|
|
return stdout.String(), stderr.String(), dockerError("Exec", ctx.Err())
|
||
|
|
case <-time.After(dockerExecDrainGrace):
|
||
|
|
// StdCopy did not return even with the stream closed, so it still
|
||
|
|
// owns the buffers and the partial output has to be dropped.
|
||
|
|
return "", "", dockerError("Exec", ctx.Err())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerExecDrainGrace bounds how long a cancelled exec waits for its output
|
||
|
|
// copier to notice the closed stream.
|
||
|
|
const dockerExecDrainGrace = 5 * time.Second
|
||
|
|
|
||
|
|
// dockerExecCommand builds the argv actually handed to the daemon.
|
||
|
|
//
|
||
|
|
// The wrapper does two things no Engine API call can: it refreshes the
|
||
|
|
// activity marker the idle sweep reads, and it enforces the timeout inside
|
||
|
|
// the container. Positional arguments ("$@" / "$1") carry the caller's
|
||
|
|
// command through the shell without any quoting, so a script containing
|
||
|
|
// quotes or newlines cannot change what runs.
|
||
|
|
func dockerExecCommand(req RemoteExecRequest, timeout time.Duration) []string {
|
||
|
|
seconds := strconv.Itoa(int(timeout.Round(time.Second).Seconds()))
|
||
|
|
if seconds == "0" {
|
||
|
|
seconds = "1"
|
||
|
|
}
|
||
|
|
touch := "touch " + dockerActivityMarker + " 2>/dev/null || true; "
|
||
|
|
if req.Shell {
|
||
|
|
return []string{
|
||
|
|
"/bin/sh", "-c",
|
||
|
|
touch + `exec timeout -s KILL ` + seconds + ` /bin/sh -c "$1"`,
|
||
|
|
"weknora-exec", req.Command,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
argv := []string{
|
||
|
|
"/bin/sh", "-c",
|
||
|
|
touch + `exec timeout -s KILL ` + seconds + ` "$@"`,
|
||
|
|
"weknora-exec", req.Command,
|
||
|
|
}
|
||
|
|
return append(argv, req.Args...)
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerExecUser resolves which account a command runs as.
|
||
|
|
//
|
||
|
|
// A blank user resolves to the sandbox account. It must never resolve to root:
|
||
|
|
// this function is the single choke point for every exec the daemon runs, so a
|
||
|
|
// caller that forgets to name an account has to lose privileges here, not
|
||
|
|
// silently gain them. It also makes the backends agree — E2B authenticates its
|
||
|
|
// data plane as DefaultSandboxExecUser and Cube hands a blank field to envd,
|
||
|
|
// which defaults the same way.
|
||
|
|
//
|
||
|
|
// This used to fall back to root for the manager's artifact-directory
|
||
|
|
// bootstrap. That was a container-escape primitive: chown follows symlinks, so
|
||
|
|
// a session that replaced its own artifact directory with a link to /etc got
|
||
|
|
// the root-run bootstrap to hand it ownership of /etc, and from there uid 0 by
|
||
|
|
// rewriting passwd. The bootstrap now names the account like everyone else and
|
||
|
|
// simply fails when it is aimed at something the account does not own.
|
||
|
|
func dockerExecUser(user string) string {
|
||
|
|
if trimmed := strings.TrimSpace(user); trimmed != "" {
|
||
|
|
return trimmed
|
||
|
|
}
|
||
|
|
return DefaultSandboxExecUser
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerExecWasKilled reports whether an exit code means the wrapper killed
|
||
|
|
// the process. 137 is SIGKILL (timeout -s KILL), 124 is timeout(1) reporting
|
||
|
|
// that it had to intervene.
|
||
|
|
func dockerExecWasKilled(exitCode int) bool {
|
||
|
|
return exitCode == 137 || exitCode == 124
|
||
|
|
}
|
||
|
|
|
||
|
|
// WriteFile writes one file as the sandbox account.
|
||
|
|
//
|
||
|
|
// This and its Read/Stat counterparts deliberately avoid the Engine's archive
|
||
|
|
// endpoints (CopyToContainer, CopyFromContainer, ContainerStatPath). Those are
|
||
|
|
// served by the daemon, which means two things at once: they ignore the exec
|
||
|
|
// user and act as root, and they resolve symlinks on the way. That combination
|
||
|
|
// is unsafe here, because the sandbox account can write anywhere under
|
||
|
|
// /workspace while every caller-facing path guard in this repository is a
|
||
|
|
// string prefix test. A model that runs `ln -s /root /workspace/output/esc`
|
||
|
|
// leaves /workspace/output/esc/secret.txt passing those guards, and the daemon
|
||
|
|
// then reads it out as root — confirmed against a real daemon, not theorised.
|
||
|
|
//
|
||
|
|
// Running these as DefaultSandboxExecUser hands the decision to the kernel
|
||
|
|
// instead: a path the sandbox account cannot reach on its own stays
|
||
|
|
// unreachable regardless of what a link points at, and there is no window
|
||
|
|
// between checking and using in which the link could be repointed.
|
||
|
|
func (c *DockerRemoteClient) WriteFile(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
filePath string,
|
||
|
|
content []byte,
|
||
|
|
) error {
|
||
|
|
id, err := dockerHandleID("WriteFile", handle)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("WriteFile", filePath)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := c.makeDir(ctx, id, path.Dir(clean), "WriteFile"); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// The path travels as a positional argument so a name containing shell
|
||
|
|
// metacharacters cannot change what the redirect targets.
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "sh",
|
||
|
|
Args: []string{"-c", `cat > "$1"`, "weknora-write", clean},
|
||
|
|
Stdin: string(content),
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError("WriteFile", err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
return dockerFileOpError("WriteFile", clean, result.Stderr)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerSandboxPID1User overrides the image USER so the entrypoint can create
|
||
|
|
// the world-writable activity marker. Numeric 0 does not depend on a root
|
||
|
|
// account existing by name in a custom image.
|
||
|
|
const dockerSandboxPID1User = "0"
|
||
|
|
|
||
|
|
// ReadFile reads one file as the sandbox account. See WriteFile for why this
|
||
|
|
// does not use the archive endpoint.
|
||
|
|
func (c *DockerRemoteClient) ReadFile(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
filePath string,
|
||
|
|
) ([]byte, error) {
|
||
|
|
id, err := dockerHandleID("ReadFile", handle)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("ReadFile", filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "cat",
|
||
|
|
Args: []string{"--", clean},
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("ReadFile", err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
return nil, dockerFileOpError("ReadFile", clean, result.Stderr)
|
||
|
|
}
|
||
|
|
return []byte(result.Stdout), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stat returns metadata for one path.
|
||
|
|
//
|
||
|
|
// find reports the FINAL component without following it, so a path that names a
|
||
|
|
// link reports RemoteEntryOther rather than whatever it points at, and callers
|
||
|
|
// that only accept regular files refuse it before any read is attempted.
|
||
|
|
//
|
||
|
|
// That guarantee stops at the final component. Intermediate components are
|
||
|
|
// resolved by the kernel during path lookup, exactly as they are for any other
|
||
|
|
// process, so `/workspace/output/link-to-etc/passwd` stats as a regular file —
|
||
|
|
// verified against a real daemon. Reads through such a path are not a
|
||
|
|
// privilege boundary being crossed, only the "stay inside the artifact
|
||
|
|
// directory" convention: ReadFile still runs as the sandbox account, so it
|
||
|
|
// returns what that account could have read anyway with shell_exec.
|
||
|
|
func (c *DockerRemoteClient) Stat(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
filePath string,
|
||
|
|
) (*RemoteStatEntry, error) {
|
||
|
|
id, err := dockerHandleID("Stat", handle)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("Stat", filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "find",
|
||
|
|
Args: []string{
|
||
|
|
clean, "-maxdepth", "0",
|
||
|
|
"-printf", `%y\t%s\t%T@\t%p\n`,
|
||
|
|
},
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("Stat", err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
return nil, dockerFileOpError("Stat", clean, result.Stderr)
|
||
|
|
}
|
||
|
|
entries := parseDockerFindOutput(result.Stdout)
|
||
|
|
if len(entries) == 0 {
|
||
|
|
return nil, &RemoteError{
|
||
|
|
Kind: RemoteErrorKindNotFound,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "Stat",
|
||
|
|
Message: clean + " does not exist",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return &RemoteStatEntry{
|
||
|
|
Path: entries[0].Path,
|
||
|
|
Type: entries[0].Type,
|
||
|
|
Size: entries[0].Size,
|
||
|
|
ModTime: entries[0].ModTime,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerFileOpError classifies a failed filesystem helper. A missing path is
|
||
|
|
// NotFound so callers can treat it as "nothing there"; everything else,
|
||
|
|
// permission denials included, is an invalid request carrying the tool's own
|
||
|
|
// complaint rather than a synthesised one.
|
||
|
|
func dockerFileOpError(op, clean, stderr string) error {
|
||
|
|
if strings.Contains(stderr, "No such file or directory") {
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindNotFound,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: clean + " does not exist",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindInvalidRequest,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: fmt.Sprintf("%s %s: %s", op, clean, firstNonEmptyLine(stderr)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MakeDir creates a directory (and its parents) inside the sandbox.
|
||
|
|
func (c *DockerRemoteClient) MakeDir(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
dirPath string,
|
||
|
|
) error {
|
||
|
|
id, err := dockerHandleID("MakeDir", handle)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("MakeDir", dirPath)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return c.makeDir(ctx, id, clean, "MakeDir")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *DockerRemoteClient) makeDir(ctx context.Context, id, dir, op string) error {
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "mkdir",
|
||
|
|
Args: []string{"-p", dir},
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError(op, err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindInvalidRequest,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: op,
|
||
|
|
Message: fmt.Sprintf("mkdir -p %s: %s", dir, firstNonEmptyLine(result.Stderr)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove deletes a path recursively.
|
||
|
|
func (c *DockerRemoteClient) Remove(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
targetPath string,
|
||
|
|
) error {
|
||
|
|
id, err := dockerHandleID("Remove", handle)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("Remove", targetPath)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if clean == "/" {
|
||
|
|
return dockerInvalidRequest("Remove", "refusing to remove the container root")
|
||
|
|
}
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "rm",
|
||
|
|
Args: []string{"-rf", clean},
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError("Remove", err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
return &RemoteError{
|
||
|
|
Kind: RemoteErrorKindInvalidRequest,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "Remove",
|
||
|
|
Message: fmt.Sprintf("rm -rf %s: %s", clean, firstNonEmptyLine(result.Stderr)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListDir lists one directory level.
|
||
|
|
//
|
||
|
|
// find(1) is used rather than ls because a single -printf format yields the
|
||
|
|
// type, size, mtime and path the caller needs, with a tab separator that
|
||
|
|
// cannot appear in find's own output fields.
|
||
|
|
func (c *DockerRemoteClient) ListDir(
|
||
|
|
ctx context.Context,
|
||
|
|
handle RemoteSandboxHandle,
|
||
|
|
dirPath string,
|
||
|
|
) ([]RemoteDirEntry, error) {
|
||
|
|
id, err := dockerHandleID("ListDir", handle)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
clean, err := dockerCleanPath("ListDir", dirPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
result, err := c.Exec(ctx, &dockerSandboxHandle{id: id}, RemoteExecRequest{
|
||
|
|
Command: "find",
|
||
|
|
Args: []string{
|
||
|
|
clean, "-mindepth", "1", "-maxdepth", "1",
|
||
|
|
"-printf", `%y\t%s\t%T@\t%p\n`,
|
||
|
|
},
|
||
|
|
User: DefaultSandboxExecUser,
|
||
|
|
Timeout: dockerFilesystemOpTimeout,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, dockerError("ListDir", err)
|
||
|
|
}
|
||
|
|
if result.ExitCode != 0 {
|
||
|
|
if strings.Contains(result.Stderr, "No such file or directory") {
|
||
|
|
return nil, &RemoteError{
|
||
|
|
Kind: RemoteErrorKindNotFound,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "ListDir",
|
||
|
|
Message: clean + " does not exist",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, &RemoteError{
|
||
|
|
Kind: RemoteErrorKindInternal,
|
||
|
|
Provider: SandboxTypeDocker,
|
||
|
|
Op: "ListDir",
|
||
|
|
Message: fmt.Sprintf("find %s: %s", clean, firstNonEmptyLine(result.Stderr)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return parseDockerFindOutput(result.Stdout), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerFilesystemOpTimeout bounds the exec-backed filesystem helpers. They
|
||
|
|
// are all single syscalls in practice; a longer budget would only delay the
|
||
|
|
// report of a wedged container.
|
||
|
|
const dockerFilesystemOpTimeout = 30 * time.Second
|
||
|
|
|
||
|
|
// parseDockerFindOutput turns `find -printf '%y\t%s\t%T@\t%p\n'` lines into
|
||
|
|
// directory entries, skipping anything malformed rather than failing the
|
||
|
|
// whole listing: one unreadable entry must not hide the rest of a directory.
|
||
|
|
func parseDockerFindOutput(output string) []RemoteDirEntry {
|
||
|
|
var entries []RemoteDirEntry
|
||
|
|
for _, line := range strings.Split(output, "\n") {
|
||
|
|
fields := strings.SplitN(strings.TrimRight(line, "\r"), "\t", 4)
|
||
|
|
if len(fields) != 4 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
entry := RemoteDirEntry{
|
||
|
|
Path: fields[3],
|
||
|
|
Name: path.Base(fields[3]),
|
||
|
|
Type: dockerEntryType(fields[0]),
|
||
|
|
}
|
||
|
|
if size, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||
|
|
entry.Size = size
|
||
|
|
}
|
||
|
|
if seconds, err := strconv.ParseFloat(fields[2], 64); err == nil {
|
||
|
|
entry.ModTime = time.Unix(
|
||
|
|
int64(seconds), int64((seconds-float64(int64(seconds)))*1e9),
|
||
|
|
).UTC()
|
||
|
|
}
|
||
|
|
entries = append(entries, entry)
|
||
|
|
}
|
||
|
|
return entries
|
||
|
|
}
|
||
|
|
|
||
|
|
func dockerEntryType(findType string) RemoteDirEntryType {
|
||
|
|
switch findType {
|
||
|
|
case "f":
|
||
|
|
return RemoteEntryFile
|
||
|
|
case "d":
|
||
|
|
return RemoteEntryDir
|
||
|
|
default:
|
||
|
|
return RemoteEntryOther
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ensureImage pulls the template image when the daemon does not have it.
|
||
|
|
//
|
||
|
|
// The pull is bounded by dockerImagePullBudget, not by the caller's deadline:
|
||
|
|
// a cold pull of the sandbox image takes minutes, and the settings wizard
|
||
|
|
// starts that pull in the background so a later Create usually just inspects.
|
||
|
|
func (c *DockerRemoteClient) ensureImage(ctx context.Context, image string) error {
|
||
|
|
if _, err := c.api.ImageInspect(ctx, image); err == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
// Skill snapshots are daemon-local commits, not registry tags. Pulling
|
||
|
|
// one would hit Docker Hub for a name we minted and never pushed, and a
|
||
|
|
// miss here means "this daemon does not have the image", not "fetch it".
|
||
|
|
if dockerIsSkillSnapshotRef(image) {
|
||
|
|
return dockerInvalidRequest("Create", "skill snapshot image "+image+" is not on this daemon")
|
||
|
|
}
|
||
|
|
pullCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerImagePullBudget)
|
||
|
|
defer cancel()
|
||
|
|
body, err := c.api.ImagePull(pullCtx, image, client.ImagePullOptions{})
|
||
|
|
if err != nil {
|
||
|
|
return dockerError("Create", fmt.Errorf("pull image %s: %w", image, err))
|
||
|
|
}
|
||
|
|
if err := awaitImagePull(pullCtx, body); err != nil {
|
||
|
|
return dockerError("Create", fmt.Errorf("pull image %s: %w", image, err))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// sweepInBackground triggers a rate-limited idle sweep without adding latency
|
||
|
|
// to the request that happened to trigger it.
|
||
|
|
func (c *DockerRemoteClient) sweepInBackground(ctx context.Context) {
|
||
|
|
if c.sweeper == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.sweeper.trigger(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerHandleID validates a handle belongs to this backend.
|
||
|
|
func dockerHandleID(op string, handle RemoteSandboxHandle) (string, error) {
|
||
|
|
if handle == nil {
|
||
|
|
return "", dockerInvalidRequest(op, "sandbox handle is required")
|
||
|
|
}
|
||
|
|
if handle.Provider() != SandboxTypeDocker {
|
||
|
|
return "", dockerInvalidRequest(op, "handle belongs to provider "+string(handle.Provider()))
|
||
|
|
}
|
||
|
|
id := strings.TrimSpace(handle.ID())
|
||
|
|
if id == "" {
|
||
|
|
return "", dockerInvalidRequest(op, "sandbox handle has no ID")
|
||
|
|
}
|
||
|
|
return id, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerCleanPath normalizes an absolute in-sandbox path. Relative paths are
|
||
|
|
// refused: they would resolve against the container's working directory,
|
||
|
|
// which differs between exec-backed and archive-backed operations.
|
||
|
|
func dockerCleanPath(op, raw string) (string, error) {
|
||
|
|
trimmed := strings.TrimSpace(raw)
|
||
|
|
if trimmed == "" {
|
||
|
|
return "", dockerInvalidRequest(op, "path is required")
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(trimmed, "/") {
|
||
|
|
return "", dockerInvalidRequest(op, "path must be absolute: "+raw)
|
||
|
|
}
|
||
|
|
clean := path.Clean(trimmed)
|
||
|
|
if reserved := dockerReservedPath(clean); reserved != "" {
|
||
|
|
return "", dockerInvalidRequest(op, "path is not addressable: "+reserved)
|
||
|
|
}
|
||
|
|
return clean, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerReservedPathPrefixes are refused for every file operation.
|
||
|
|
//
|
||
|
|
// File operations run as the sandbox account (see WriteFile), so the kernel
|
||
|
|
// already decides what is reachable and this list is not what keeps /etc or
|
||
|
|
// another session's data safe. It exists for the paths the sandbox account
|
||
|
|
// legitimately can touch but never should through this API: /proc and /sys
|
||
|
|
// expose the container's own runtime state, and the activity marker is the
|
||
|
|
// sweeper's bookkeeping, which a session must not be able to backdate.
|
||
|
|
var dockerReservedPathPrefixes = []string{
|
||
|
|
"/proc",
|
||
|
|
"/sys",
|
||
|
|
"/dev",
|
||
|
|
dockerActivityMarker,
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerReservedPath returns the prefix clean is refused for, or "" when the
|
||
|
|
// path is addressable. clean must already be path.Clean'd.
|
||
|
|
func dockerReservedPath(clean string) string {
|
||
|
|
for _, prefix := range dockerReservedPathPrefixes {
|
||
|
|
if clean == prefix || strings.HasPrefix(clean, prefix+"/") {
|
||
|
|
return prefix
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// dockerEnvSlice converts an env map into Docker's KEY=VALUE form.
|
||
|
|
func dockerEnvSlice(env map[string]string) []string {
|
||
|
|
if len(env) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
pairs := make([]string, 0, len(env))
|
||
|
|
for key, value := range env {
|
||
|
|
pairs = append(pairs, key+"="+value)
|
||
|
|
}
|
||
|
|
return pairs
|
||
|
|
}
|
||
|
|
|
||
|
|
// firstNonEmptyLine trims a command's stderr down to something a user-facing
|
||
|
|
// error can carry.
|
||
|
|
func firstNonEmptyLine(output string) string {
|
||
|
|
for _, line := range strings.Split(output, "\n") {
|
||
|
|
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
||
|
|
if len(trimmed) > 200 {
|
||
|
|
return trimmed[:200] + "…"
|
||
|
|
}
|
||
|
|
return trimmed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
_ RemoteSandboxClient = (*DockerRemoteClient)(nil)
|
||
|
|
_ RemoteSnapshotManager = (*DockerRemoteClient)(nil)
|
||
|
|
)
|