Files
spinifex/cmd/ecs-agent/runtime/containerd.go
T
Josh Siviour 1ab61b0dd5 fix(ecs): enforce or refuse container runtime fields (TFC-0040) (#1053)
* fix(ecs): enforce or refuse container runtime fields (TFC-0040)

user, mountPoints, volumesFrom, readonlyRootFilesystem, privileged,
pseudoTerminal, interactive, linuxParameters, systemControls,
startTimeout and stopTimeout registered successfully but were never
stored, sent to the agent, enforced, or echoed back on describe.

Store and echo the fields the agent can enforce (user, readonly root
filesystem, privileged, pseudo terminal, interactive, systemControls,
capability add/drop, start/stop timeout), apply them through containerd
via OCI spec options and a graceful Stop, and refuse at
RegisterTaskDefinition the fields the agent cannot honor (mountPoints,
volumesFrom, linuxParameters.devices/initProcessEnabled/
sharedMemorySize/tmpfs). Refusal only triggers on a value that asks
for the restriction; empty lists and false/unset flags are accepted
and echoed as before.

* test: move the bus wire-contract test to an external package

* fix(ecs-agent): release an interactive container's stdin so its copier exits

* fix(ecs): return a caller-supplied empty collection instead of omitting it
2026-09-16 21:23:06 +10:00

125 lines
4.0 KiB
Go

package runtime
import (
"context"
"fmt"
"sync"
"time"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/core/remotes"
"github.com/containerd/containerd/v2/core/remotes/docker"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/distribution/reference"
)
// ecsNamespace isolates ecs-agent images/containers in containerd, mirroring the
// "moby"/"k8s.io" convention other runtimes use.
const ecsNamespace = "ecs"
// servingProbeTimeout bounds the boot-time daemon liveness check.
const servingProbeTimeout = 3 * time.Second
// containerdPuller drives a real containerd daemon over its unix socket.
type containerdPuller struct {
client *containerd.Client
// stdinDone holds one channel per interactive container, closed when that
// container is stopped or removed so its stdin reader returns EOF.
mu sync.Mutex
stdinDone map[string]chan struct{}
}
var _ ImagePuller = (*containerdPuller)(nil)
// New connects to the containerd daemon at socket (e.g.
// "/run/containerd/containerd.sock") and returns an ImagePuller. The containerd
// client dials lazily, so New actively probes the daemon with IsServing and
// returns an error if it is unreachable — otherwise a dead socket would only
// surface on the first pull, and the agent's boot-time "containerd unavailable"
// log would never fire.
func New(socket string) (Runtime, error) {
client, err := containerd.New(socket, containerd.WithDefaultNamespace(ecsNamespace))
if err != nil {
return nil, fmt.Errorf("connect containerd %s: %w", socket, err)
}
ctx, cancel := context.WithTimeout(context.Background(), servingProbeTimeout)
defer cancel()
serving, err := client.IsServing(ctx)
if err != nil || !serving {
_ = client.Close()
return nil, fmt.Errorf("containerd %s not serving: %w", socket, err)
}
return &containerdPuller{client: client}, nil
}
// Pull resolves credentials via r, then pulls and unpacks the image.
func (p *containerdPuller) Pull(ctx context.Context, spec PullSpec, r Resolver) (Image, error) {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
ref, err := normalizeRef(spec.Ref)
if err != nil {
return Image{}, err
}
resolver, err := newDockerResolver(ctx, ref, r)
if err != nil {
return Image{}, err
}
img, err := p.client.Pull(ctx, ref,
containerd.WithPullUnpack,
containerd.WithResolver(resolver),
)
if err != nil {
return Image{}, fmt.Errorf("pull %s: %w", ref, err)
}
target := img.Target()
return Image{
Ref: ref,
Digest: target.Digest.String(),
Size: target.Size,
}, nil
}
// normalizeRef expands a task-definition image string into the fully-qualified
// reference containerd's resolver requires: a bare "nginx:alpine" becomes
// "docker.io/library/nginx:alpine" and an untagged ref gets ":latest". Without
// this, containerd parses the bare ref as "dummy://nginx:alpine" and rejects it.
func normalizeRef(ref string) (string, error) {
named, err := reference.ParseNormalizedNamed(ref)
if err != nil {
return "", fmt.Errorf("parse image ref %q: %w", ref, err)
}
return reference.TagNameOnly(named).String(), nil
}
// Close releases the containerd client connection.
func (p *containerdPuller) Close() error {
if p.client == nil {
return nil
}
return p.client.Close()
}
// newDockerResolver builds a containerd remote resolver whose per-host
// credentials come from r (the ECR token resolver). Credentials are fetched once
// for ref and reused for every host containerd asks about during the pull.
func newDockerResolver(ctx context.Context, ref string, r Resolver) (remotes.Resolver, error) {
creds := func(string) (string, string, error) { return "", "", nil }
if r != nil {
user, pass, _, err := r.Authorize(ctx, ref)
if err != nil {
return nil, fmt.Errorf("authorize %s: %w", ref, err)
}
creds = func(string) (string, string, error) { return user, pass, nil }
}
authorizer := docker.NewDockerAuthorizer(docker.WithAuthCreds(creds))
return docker.NewResolver(docker.ResolverOptions{
Hosts: docker.ConfigureDefaultRegistries(docker.WithAuthorizer(authorizer)),
}), nil
}