panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,992 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/build"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/moby/go-archive"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// DockerRuntime launches instances as Docker containers on the local daemon.
|
||||
// Container IDs (the string returned by Create) are Docker's full container IDs.
|
||||
type DockerRuntime struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
// NewDocker connects to the local Docker daemon using environment-configured
|
||||
// settings (DOCKER_HOST, DOCKER_CERT_PATH, …) and negotiates an API version.
|
||||
func NewDocker() (*DockerRuntime, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
return &DockerRuntime{cli: cli}, nil
|
||||
}
|
||||
|
||||
// Close releases daemon resources.
|
||||
func (r *DockerRuntime) Close() error {
|
||||
if r.cli == nil {
|
||||
return nil
|
||||
}
|
||||
return r.cli.Close()
|
||||
}
|
||||
|
||||
// Name identifies the runtime kind.
|
||||
func (r *DockerRuntime) Name() string { return "docker" }
|
||||
|
||||
// Ping verifies we can reach the Docker daemon.
|
||||
func (r *DockerRuntime) Ping(ctx context.Context) error {
|
||||
_, err := r.cli.Ping(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create pulls the image (if missing locally) and creates the container.
|
||||
// The container is NOT started — call Start afterward.
|
||||
func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string, error) {
|
||||
if spec.InstanceID == "" {
|
||||
return "", errors.New("spec.InstanceID is required")
|
||||
}
|
||||
if spec.Image == "" {
|
||||
return "", errors.New("spec.Image is required")
|
||||
}
|
||||
|
||||
if err := r.acquireImage(ctx, spec.Image, spec.BuildContext, spec.LogSink); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
exposed, bindings, err := buildPortBindings(spec.Ports)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Docker rejects PortBindings + ExposedPorts when NetworkMode=host —
|
||||
// the container shares the host's network namespace so publish-port
|
||||
// plumbing is meaningless there.
|
||||
if spec.NetworkMode == "host" {
|
||||
bindings = nil
|
||||
exposed = nil
|
||||
}
|
||||
|
||||
envSlice := make([]string, 0, len(spec.Env))
|
||||
for k, v := range spec.Env {
|
||||
envSlice = append(envSlice, k+"="+v)
|
||||
}
|
||||
|
||||
mounts := make([]mount.Mount, 0, len(spec.Volumes))
|
||||
for _, v := range spec.Volumes {
|
||||
m := mount.Mount{
|
||||
Target: v.ContainerPath,
|
||||
ReadOnly: v.ReadOnly,
|
||||
}
|
||||
switch v.Type {
|
||||
case "volume":
|
||||
if v.VolumeName == "" {
|
||||
return "", fmt.Errorf("volume mount %q: Name required", v.ContainerPath)
|
||||
}
|
||||
if err := r.ensureVolume(ctx, v.VolumeName, spec.InstanceID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.Type = mount.TypeVolume
|
||||
m.Source = v.VolumeName
|
||||
default: // "" or "bind"
|
||||
if v.HostPath == "" {
|
||||
return "", fmt.Errorf("bind mount %q: HostPath required", v.ContainerPath)
|
||||
}
|
||||
// Docker CLI auto-creates missing bind sources, but the Go API
|
||||
// does not — it errors with "bind source path does not exist"
|
||||
// and refuses to start the container. Modules declaring subdir
|
||||
// binds like `$DATA_PATH/logs` rely on the source existing.
|
||||
// Two cases:
|
||||
// - Path exists as a file (panel-rendered config like
|
||||
// serverconfig.xml.rendered): leave it alone, it's a
|
||||
// file bind.
|
||||
// - Path missing or already a directory: mkdir -p so docker
|
||||
// bind-mount has something to mount.
|
||||
if st, err := os.Stat(v.HostPath); err == nil && !st.IsDir() {
|
||||
// existing file → file bind, no mkdir
|
||||
} else if err := os.MkdirAll(v.HostPath, 0o755); err != nil {
|
||||
return "", fmt.Errorf("bind mount %q: mkdir source %q: %w", v.ContainerPath, v.HostPath, err)
|
||||
}
|
||||
m.Type = mount.TypeBind
|
||||
m.Source = v.HostPath
|
||||
}
|
||||
mounts = append(mounts, m)
|
||||
}
|
||||
|
||||
restart := spec.RestartPolicy
|
||||
if restart == "" {
|
||||
restart = "unless-stopped"
|
||||
}
|
||||
|
||||
hostCfg := &container.HostConfig{
|
||||
Mounts: mounts,
|
||||
PortBindings: bindings,
|
||||
NetworkMode: container.NetworkMode(spec.NetworkMode),
|
||||
SecurityOpt: spec.SecurityOpts,
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyMode(restart),
|
||||
},
|
||||
Resources: container.Resources{
|
||||
CPUShares: int64(spec.Resources.CPUShares),
|
||||
Memory: int64(spec.Resources.MemoryBytes),
|
||||
NanoCPUs: spec.Resources.NanoCPUs,
|
||||
PidsLimit: pidsPtr(spec.Resources.PidsLimit),
|
||||
},
|
||||
}
|
||||
|
||||
cfg := &container.Config{
|
||||
Image: spec.Image,
|
||||
Env: envSlice,
|
||||
Entrypoint: spec.Entrypoint,
|
||||
Cmd: spec.Command,
|
||||
User: spec.User,
|
||||
WorkingDir: spec.WorkingDir,
|
||||
ExposedPorts: exposed,
|
||||
Tty: false,
|
||||
// OpenStdin + StdinOnce=false = stdin stays open across reconnects.
|
||||
// The stdio RCON adapter attaches to stdin later via ContainerAttach.
|
||||
OpenStdin: spec.OpenStdin,
|
||||
StdinOnce: false,
|
||||
Labels: map[string]string{
|
||||
"panel.instance_id": spec.InstanceID,
|
||||
"panel.role": roleLabel(spec.IsSidecar),
|
||||
},
|
||||
}
|
||||
|
||||
name := "panel-" + spec.InstanceID
|
||||
resp, err := r.cli.ContainerCreate(ctx, cfg, hostCfg, &network.NetworkingConfig{}, nil, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("container create: %w", err)
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// Start starts a previously-created container.
|
||||
func (r *DockerRuntime) Start(ctx context.Context, id string) error {
|
||||
if err := r.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
|
||||
return fmt.Errorf("container start: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the container, waiting up to grace before SIGKILL.
|
||||
func (r *DockerRuntime) Stop(ctx context.Context, id string, grace time.Duration) error {
|
||||
secs := int(grace.Seconds())
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
opts := container.StopOptions{Timeout: &secs}
|
||||
if err := r.cli.ContainerStop(ctx, id, opts); err != nil {
|
||||
return fmt.Errorf("container stop: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart restarts the container. Same StopOptions semantics as Stop —
|
||||
// engine waits up to grace seconds before SIGKILL, then the container is
|
||||
// started fresh. The container's RestartPolicy is irrelevant here;
|
||||
// ContainerRestart always brings it back regardless of policy.
|
||||
func (r *DockerRuntime) Restart(ctx context.Context, id string, grace time.Duration) error {
|
||||
secs := int(grace.Seconds())
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
opts := container.StopOptions{Timeout: &secs}
|
||||
if err := r.cli.ContainerRestart(ctx, id, opts); err != nil {
|
||||
return fmt.Errorf("container restart: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes the container. Force=true kills if still running.
|
||||
func (r *DockerRuntime) Remove(ctx context.Context, id string) error {
|
||||
err := r.cli.ContainerRemove(ctx, id, container.RemoveOptions{
|
||||
Force: true,
|
||||
RemoveVolumes: false,
|
||||
})
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("container remove: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamLogs follows stdout+stderr and invokes handler for each line.
|
||||
// Blocks until ctx is cancelled, the container exits, or the connection errors.
|
||||
func (r *DockerRuntime) StreamLogs(ctx context.Context, id string, handler LogHandler) error {
|
||||
rc, err := r.cli.ContainerLogs(ctx, id, container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: true,
|
||||
// Timestamps:true prefixes each line with docker's own RFC3339Nano
|
||||
// time. We parse that prefix below and pass it as the line's `at`
|
||||
// instead of time.Now(). Critical for the readiness gate: the
|
||||
// docker log file persists across container restarts, so a Tail:400
|
||||
// replay can include the PREVIOUS run's "Server has completed
|
||||
// startup" line. Without docker's real timestamp on each line, the
|
||||
// frontend can't tell stale replays from current-run lines and
|
||||
// the pill flips to "running" the instant the buffer is fetched.
|
||||
Timestamps: true,
|
||||
// Start with a little backlog so the console isn't blank if someone
|
||||
// opens the modal mid-run. 400 lines is ~5s of chatter at the busiest
|
||||
// and is trivial to stream.
|
||||
Tail: "400",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("container logs: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
outPr, outPw := io.Pipe()
|
||||
errPr, errPw := io.Pipe()
|
||||
defer outPw.Close()
|
||||
defer errPw.Close()
|
||||
|
||||
done := make(chan error, 3)
|
||||
|
||||
// Demux multiplexed docker log stream into separate stdout/stderr pipes.
|
||||
go func() {
|
||||
_, err := stdcopy.StdCopy(outPw, errPw, rc)
|
||||
_ = outPw.Close()
|
||||
_ = errPw.Close()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
scan := func(r io.Reader, stream LogStream) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
// Split on BOTH \n and \r so terminal-style progress updates
|
||||
// (SteamCMD "Update state ... progress: X%" uses \r to overwrite
|
||||
// the same line) show up as they happen instead of buffering
|
||||
// until the next real newline minutes later.
|
||||
sc.Split(scanLogLine)
|
||||
for sc.Scan() {
|
||||
raw := stripANSI(sc.Text())
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
// Strip docker's RFC3339Nano timestamp prefix and use it as
|
||||
// the line's authoritative time. Format is exact:
|
||||
// "2026-04-29T17:46:53.123456789Z <line content>"
|
||||
// On parse failure we fall back to time.Now() so a malformed
|
||||
// line still gets streamed (better wrong timestamp than
|
||||
// dropped log line).
|
||||
line, ts := splitDockerTimestamp(raw)
|
||||
handler(stream, line, ts)
|
||||
}
|
||||
done <- sc.Err()
|
||||
}
|
||||
go scan(outPr, LogStreamStdout)
|
||||
go scan(errPr, LogStreamStderr)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVolumesByInstance returns every volume labeled panel.instance_id=id.
|
||||
// Used by the delete-with-purge path to clean up the instance's storage.
|
||||
func (r *DockerRuntime) ListVolumesByInstance(ctx context.Context, instanceID string) ([]string, error) {
|
||||
lst, err := r.cli.VolumeList(ctx, volume.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("volume list: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, v := range lst.Volumes {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if v.Labels["panel.instance_id"] == instanceID {
|
||||
names = append(names, v.Name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// RemoveVolume removes a Docker volume by name. force=true removes even
|
||||
// if in-use references exist (shouldn't happen for us since we remove
|
||||
// containers first).
|
||||
func (r *DockerRuntime) RemoveVolume(ctx context.Context, name string, force bool) error {
|
||||
if err := r.cli.VolumeRemove(ctx, name, force); err != nil {
|
||||
return fmt.Errorf("volume remove %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListContainersHoldingInstanceVolumes returns container IDs for any
|
||||
// container (running OR exited) whose mount list references one of
|
||||
// the panel volumes labeled for the given instance.
|
||||
//
|
||||
// Used by the delete path to sweep stranded sidecars — exited steamcmd
|
||||
// or backup containers that the agent crashed before removing.
|
||||
// They keep volumes "in use" for `docker volume rm` purposes even when
|
||||
// they're not running, blocking instance delete-with-purge forever.
|
||||
//
|
||||
// We list ALL containers (including exited) and filter by mount name
|
||||
// rather than relying on the container's own panel label, because
|
||||
// updater sidecars don't carry the instance label — they're spawned
|
||||
// by the alpine helper which doesn't know about panel.* labels.
|
||||
func (r *DockerRuntime) ListContainersHoldingInstanceVolumes(ctx context.Context, instanceID string) ([]string, error) {
|
||||
// Build the set of volume names this instance owns.
|
||||
vols, err := r.ListVolumesByInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vols) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
wanted := make(map[string]bool, len(vols))
|
||||
for _, v := range vols {
|
||||
wanted[v] = true
|
||||
}
|
||||
// Cheap heuristic: panel-<id>- prefix on the container name catches
|
||||
// most sidecars (panel-X-steamcmd-N, panel-X-fs, panel-X-backup).
|
||||
// We still cross-check via mounts so renamed/old containers don't
|
||||
// slip through.
|
||||
prefix := "panel-" + instanceID + "-"
|
||||
|
||||
cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("container list: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
// Match by mount.
|
||||
matched := false
|
||||
for _, m := range c.Mounts {
|
||||
if m.Type == mount.TypeVolume && wanted[m.Name] {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Or by name prefix.
|
||||
if !matched {
|
||||
for _, n := range c.Names {
|
||||
name := strings.TrimPrefix(n, "/")
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
out = append(out, c.ID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// roleLabel maps the sidecar flag to the "panel.role" label value.
|
||||
func roleLabel(isSidecar bool) string {
|
||||
if isSidecar {
|
||||
return "sidecar"
|
||||
}
|
||||
return "instance"
|
||||
}
|
||||
|
||||
// ListMainInstanceNames returns the instance IDs of every main
|
||||
// game-server container (panel.role=instance), excluding the transient
|
||||
// fs / steamcmd / backup / etc. helper sidecars. Used by Rehydrate to
|
||||
// detect orphans: a main container with no matching sidecar metadata,
|
||||
// which would otherwise be silently invisible to the agent.
|
||||
//
|
||||
// Filtering is by the "panel.role" label, set at create time, not by
|
||||
// name-suffix heuristics — so new sidecar kinds can't be misclassified
|
||||
// as instances. Containers predating the label (no panel.role) fall
|
||||
// back to a name check: anything matching a known sidecar suffix is
|
||||
// excluded, everything else under the panel- prefix is treated as a
|
||||
// main instance (the conservative choice — better to surface a stray
|
||||
// helper as a candidate orphan than to hide a real instance).
|
||||
func (r *DockerRuntime) ListMainInstanceNames(ctx context.Context) ([]string, error) {
|
||||
cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("container list: %w", err)
|
||||
}
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
// Prefer the explicit label.
|
||||
if role, ok := c.Labels["panel.role"]; ok {
|
||||
if role != "instance" {
|
||||
continue
|
||||
}
|
||||
if id := c.Labels["panel.instance_id"]; id != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Legacy fallback for containers created before panel.role existed.
|
||||
for _, n := range c.Names {
|
||||
name := strings.TrimPrefix(n, "/")
|
||||
id, ok := strings.CutPrefix(name, "panel-")
|
||||
if !ok || legacyIsSidecarName(id) {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// legacyIsSidecarName classifies pre-"panel.role" containers by their
|
||||
// reserved trailing role. Instance ids themselves can contain hyphens
|
||||
// (e.g. "scorched-earth"), so we match known suffixes rather than
|
||||
// splitting on "-".
|
||||
func legacyIsSidecarName(afterPrefix string) bool {
|
||||
for _, suf := range []string{"-fs", "-backup", "-restore", "-wipe", "-basemods", "-warmseed"} {
|
||||
if strings.HasSuffix(afterPrefix, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// steamcmd sidecars are panel-<id>-steamcmd-<n>.
|
||||
if strings.Contains(afterPrefix, "-steamcmd-") || strings.HasSuffix(afterPrefix, "-steamcmd") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ForceRemoveContainer is `docker rm -f` — kills a running container
|
||||
// and removes it. Used by the orphan-sidecar sweep on delete; the
|
||||
// sidecar's exit-code state doesn't matter since we're nuking it.
|
||||
func (r *DockerRuntime) ForceRemoveContainer(ctx context.Context, containerID string) error {
|
||||
return r.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
Force: true,
|
||||
RemoveVolumes: false, // we'll remove volumes separately
|
||||
})
|
||||
}
|
||||
|
||||
// ensureVolume creates the named volume if it doesn't exist. Labels it so
|
||||
// operators can identify panel-owned volumes (`docker volume ls -f
|
||||
// label=panel.instance_id=ark-test`).
|
||||
func (r *DockerRuntime) ensureVolume(ctx context.Context, name, instanceID string) error {
|
||||
_, err := r.cli.VolumeInspect(ctx, name)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("inspect volume %q: %w", name, err)
|
||||
}
|
||||
_, err = r.cli.VolumeCreate(ctx, volume.CreateOptions{
|
||||
Name: name,
|
||||
Driver: "local",
|
||||
Labels: map[string]string{
|
||||
"panel.instance_id": instanceID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create volume %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InspectByName looks up a container by its Docker name (leading "/"
|
||||
// stripped by the API) and returns its state. Used by the Agent on cold
|
||||
// start to check whether a sidecar-metadata file still points at a live
|
||||
// container.
|
||||
func (r *DockerRuntime) InspectByName(ctx context.Context, name string) (RuntimeInstanceState, error) {
|
||||
c, err := r.cli.ContainerInspect(ctx, name)
|
||||
if err != nil {
|
||||
return RuntimeInstanceState{}, err
|
||||
}
|
||||
st := RuntimeInstanceState{ContainerID: c.ID}
|
||||
if c.State != nil {
|
||||
st.Status = c.State.Status
|
||||
st.ExitCode = int32(c.State.ExitCode)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// ContainerMounts returns the mount points of a container by name. Works
|
||||
// on stopped containers. Used to resolve a 7DTD instance's /cluster bind
|
||||
// to its host path for the cluster player-save snapshotter.
|
||||
func (r *DockerRuntime) ContainerMounts(ctx context.Context, name string) ([]MountInfo, error) {
|
||||
c, err := r.cli.ContainerInspect(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]MountInfo, 0, len(c.Mounts))
|
||||
for _, m := range c.Mounts {
|
||||
out = append(out, MountInfo{
|
||||
Type: string(m.Type),
|
||||
Source: m.Source,
|
||||
Destination: m.Destination,
|
||||
Name: m.Name,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ContainerExists reports definitive existence vs. an indeterminate
|
||||
// query failure. A not-found error from the daemon means the container
|
||||
// is gone (exists=false, err=nil); any other error (daemon unreachable,
|
||||
// connection dropped mid-call during agent shutdown, ctx cancelled)
|
||||
// leaves existence UNKNOWN and is returned verbatim so callers don't
|
||||
// mistake "couldn't ask" for "doesn't exist".
|
||||
func (r *DockerRuntime) ContainerExists(ctx context.Context, name string) (bool, error) {
|
||||
_, err := r.cli.ContainerInspect(ctx, name)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if client.IsErrNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// StatsStream opens Docker's ContainerStats (streaming) and returns the
|
||||
// raw JSON reader. Each line is a stats frame — caller parses + throttles.
|
||||
func (r *DockerRuntime) StatsStream(ctx context.Context, id string) (io.ReadCloser, error) {
|
||||
resp, err := r.cli.ContainerStats(ctx, id, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("container stats: %w", err)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// Wait blocks until the container exits and returns its exit code.
|
||||
func (r *DockerRuntime) Wait(ctx context.Context, id string) (int, error) {
|
||||
statusCh, errCh := r.cli.ContainerWait(ctx, id, container.WaitConditionNotRunning)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return -1, ctx.Err()
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("container wait: %w", err)
|
||||
}
|
||||
return -1, nil
|
||||
case s := <-statusCh:
|
||||
if s.Error != nil {
|
||||
return int(s.StatusCode), fmt.Errorf("wait: %s", s.Error.Message)
|
||||
}
|
||||
return int(s.StatusCode), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Container-path file ops (used by the panel's file manager) ----
|
||||
|
||||
// ExecCapture runs cmd inside the given running container and returns its
|
||||
// captured stdout + stderr + exit code.
|
||||
func (r *DockerRuntime) ExecCapture(ctx context.Context, id string, cmd []string) ([]byte, []byte, int, error) {
|
||||
resp, err := r.cli.ContainerExecCreate(ctx, id, container.ExecOptions{
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, -1, fmt.Errorf("exec create: %w", err)
|
||||
}
|
||||
att, err := r.cli.ContainerExecAttach(ctx, resp.ID, container.ExecStartOptions{})
|
||||
if err != nil {
|
||||
return nil, nil, -1, fmt.Errorf("exec attach: %w", err)
|
||||
}
|
||||
defer att.Close()
|
||||
|
||||
// StdCopy reads the hijacked exec stream, which is NOT bound by ctx — a
|
||||
// container caught mid-restart can leave this Read blocked forever, leaking
|
||||
// the underlying docker connection. Unbounded, that once cascaded into every
|
||||
// runtime op hanging (config file-reads 504'd; a log stream stopped
|
||||
// re-attaching). Run StdCopy off a goroutine and enforce ctx ourselves: on
|
||||
// timeout, Close() the attach to unblock the Read, then drain the goroutine
|
||||
// so the buffers are race-free to read.
|
||||
var stdout, stderr bytes.Buffer
|
||||
copyDone := make(chan error, 1)
|
||||
go func() { _, e := stdcopy.StdCopy(&stdout, &stderr, att.Reader); copyDone <- e }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
att.Close() // unblock the goroutine's Read (defer Close is a safe no-op double-close)
|
||||
<-copyDone
|
||||
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", ctx.Err())
|
||||
case err := <-copyDone:
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", err)
|
||||
}
|
||||
}
|
||||
insp, err := r.cli.ContainerExecInspect(ctx, resp.ID)
|
||||
if err != nil {
|
||||
return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec inspect: %w", err)
|
||||
}
|
||||
return stdout.Bytes(), stderr.Bytes(), insp.ExitCode, nil
|
||||
}
|
||||
|
||||
// CopyFileFromContainer returns the raw bytes of the file at containerPath.
|
||||
// Works on both running and stopped containers. Follows symlinks (Docker's
|
||||
// archive API tars a symlink as a symlink without dereferencing — needed
|
||||
// for cases like the 7DTD module's /game/serverconfig.xml -> /game-saves/…).
|
||||
func (r *DockerRuntime) CopyFileFromContainer(ctx context.Context, id, containerPath string) ([]byte, error) {
|
||||
return r.copyFileFromContainerHop(ctx, id, containerPath, 0)
|
||||
}
|
||||
|
||||
// maxCopyFileSymlinkHops bounds symlink resolution depth so a malicious or
|
||||
// accidental symlink loop can't pin an agent goroutine. 8 is way more than
|
||||
// any legitimate chain we've seen; bump it if a real use-case demands.
|
||||
const maxCopyFileSymlinkHops = 8
|
||||
|
||||
func (r *DockerRuntime) copyFileFromContainerHop(ctx context.Context, id, containerPath string, depth int) ([]byte, error) {
|
||||
if depth >= maxCopyFileSymlinkHops {
|
||||
return nil, fmt.Errorf("symlink chain too deep (>%d hops) at %s", maxCopyFileSymlinkHops, containerPath)
|
||||
}
|
||||
rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy from container: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
tr := tar.NewReader(rc)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("file not found in archive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar next: %w", err)
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
return io.ReadAll(tr)
|
||||
case tar.TypeDir:
|
||||
return nil, fmt.Errorf("path is a directory")
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
// Resolve the target. Docker hands back the symlink itself —
|
||||
// it never dereferences. Linkname can be absolute or relative;
|
||||
// relative is resolved against the link's containing dir, not
|
||||
// our cwd, mirroring how the kernel resolves it inside the FS.
|
||||
target := hdr.Linkname
|
||||
if !path.IsAbs(target) {
|
||||
target = path.Join(path.Dir(containerPath), target)
|
||||
}
|
||||
return r.copyFileFromContainerHop(ctx, id, target, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AttachStdio attaches to the container's multiplexed stdin/stdout/stderr
|
||||
// stream and returns a single ReadWriteCloser. Writes go to stdin; reads
|
||||
// are the raw multiplexed frame stream (our stdio RCON adapter drains and
|
||||
// discards the reader — responses reach the panel via docker logs instead,
|
||||
// so we don't need to demux here).
|
||||
func (r *DockerRuntime) AttachStdio(ctx context.Context, id string) (io.ReadWriteCloser, error) {
|
||||
resp, err := r.cli.ContainerAttach(ctx, id, container.AttachOptions{
|
||||
Stream: true,
|
||||
Stdin: true,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("container attach: %w", err)
|
||||
}
|
||||
return &hijackedStream{resp: resp}, nil
|
||||
}
|
||||
|
||||
// hijackedStream adapts Docker's HijackedResponse (a bidirectional net.Conn
|
||||
// plus a buffered reader) to io.ReadWriteCloser so the stdio adapter can
|
||||
// treat it uniformly.
|
||||
type hijackedStream struct {
|
||||
resp types.HijackedResponse
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (h *hijackedStream) Write(p []byte) (int, error) {
|
||||
return h.resp.Conn.Write(p)
|
||||
}
|
||||
func (h *hijackedStream) Read(p []byte) (int, error) {
|
||||
return h.resp.Reader.Read(p)
|
||||
}
|
||||
func (h *hijackedStream) Close() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.resp.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyFileToContainer writes content to containerPath inside the container.
|
||||
// Parent dir must exist (Docker's archive API doesn't create them).
|
||||
func (r *DockerRuntime) CopyFileToContainer(ctx context.Context, id, containerPath string, content []byte) error {
|
||||
dir := path.Dir(containerPath)
|
||||
base := path.Base(containerPath)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: base,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(content)),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("tar header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return fmt.Errorf("tar body: %w", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return fmt.Errorf("tar close: %w", err)
|
||||
}
|
||||
|
||||
return r.cli.CopyToContainer(ctx, id, dir, &buf, container.CopyToContainerOptions{AllowOverwriteDirWithFile: false})
|
||||
}
|
||||
|
||||
// CopyTarToContainer writes a raw tar stream onto dstDir. Caller is
|
||||
// responsible for the tar formatting (one Header+body per entry, then
|
||||
// Close()). Docker extracts in place. Used by the file manager's
|
||||
// extract endpoint so we can ship a whole archive's contents in one
|
||||
// API call instead of N per-file CopyFileToContainer round-trips.
|
||||
func (r *DockerRuntime) CopyTarToContainer(ctx context.Context, id, dstDir string, tarStream io.Reader) error {
|
||||
return r.cli.CopyToContainer(ctx, id, dstDir, tarStream, container.CopyToContainerOptions{AllowOverwriteDirWithFile: true})
|
||||
}
|
||||
|
||||
// CopyDirFromContainer returns a tar stream rooted at containerPath.
|
||||
// Caller must Close() the reader. Used by the file manager's compress
|
||||
// endpoint to walk a directory's contents without per-file API calls.
|
||||
func (r *DockerRuntime) CopyDirFromContainer(ctx context.Context, id, containerPath string) (io.ReadCloser, error) {
|
||||
rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy from container: %w", err)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// acquireImage ensures the requested image is present locally. Fast path:
|
||||
// already on disk → return. Otherwise:
|
||||
//
|
||||
// - `panel-*` images are NEVER published to a registry. We auto-build
|
||||
// from buildContext/Dockerfile when buildContext is set. This is the
|
||||
// "drop in a new agent and any module just works" behavior — first
|
||||
// instance create on a fresh agent triggers the local build of the
|
||||
// module's runtime image. Subsequent creates hit the inspect-success
|
||||
// fast path.
|
||||
//
|
||||
// - Non-panel images (alpine, acekorneya/asa-linux-server, etc.) are
|
||||
// pulled from the registry as usual.
|
||||
//
|
||||
// If the image is `panel-*` and buildContext is empty (no Dockerfile in
|
||||
// the module dir), we surface the same "build it on the agent host"
|
||||
// message as before — the operator's module is broken, not the agent.
|
||||
func (r *DockerRuntime) acquireImage(ctx context.Context, ref, buildContext string, sink func(string)) error {
|
||||
emit := func(line string) {
|
||||
if sink != nil {
|
||||
sink(line)
|
||||
}
|
||||
}
|
||||
if _, err := r.cli.ImageInspect(ctx, ref); err == nil {
|
||||
return nil
|
||||
} else if !client.IsErrNotFound(err) {
|
||||
return fmt.Errorf("image inspect: %w", err)
|
||||
}
|
||||
|
||||
isPanel := strings.HasPrefix(ref, "panel-") && !strings.Contains(ref, "/")
|
||||
if isPanel {
|
||||
if buildContext == "" {
|
||||
return fmt.Errorf("module image %q not built on this agent and no build context provided", ref)
|
||||
}
|
||||
dockerfile := filepath.Join(buildContext, "Dockerfile")
|
||||
if _, err := os.Stat(dockerfile); err != nil {
|
||||
return fmt.Errorf("module image %q missing on this agent and no Dockerfile at %s", ref, dockerfile)
|
||||
}
|
||||
emit(fmt.Sprintf("[image] %s missing on this agent — building from %s/Dockerfile (first-run; takes 30s–3min)", ref, buildContext))
|
||||
return r.buildLocalImage(ctx, ref, buildContext, emit)
|
||||
}
|
||||
|
||||
// Public image — pull from registry.
|
||||
emit(fmt.Sprintf("[image] pulling %s from registry…", ref))
|
||||
rc, err := r.cli.ImagePull(ctx, ref, image.PullOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("image pull: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
streamDockerJSON(rc, emit)
|
||||
emit(fmt.Sprintf("[image] %s ready", ref))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLocalImage runs `docker build -t <ref> <ctxDir>` via the Docker
|
||||
// daemon's API. Build output is parsed (NDJSON) and forwarded to `emit`
|
||||
// so the controller's Console tab gets live progress while the operator
|
||||
// waits for first-time builds.
|
||||
func (r *DockerRuntime) buildLocalImage(ctx context.Context, ref, ctxDir string, emit func(string)) error {
|
||||
tarball, err := archive.TarWithOptions(ctxDir, &archive.TarOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar build context: %w", err)
|
||||
}
|
||||
defer tarball.Close()
|
||||
resp, err := r.cli.ImageBuild(ctx, tarball, build.ImageBuildOptions{
|
||||
Tags: []string{ref},
|
||||
Dockerfile: "Dockerfile",
|
||||
Remove: true,
|
||||
ForceRemove: true,
|
||||
PullParent: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("image build start: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if buildErr := streamDockerJSON(resp.Body, emit); buildErr != nil {
|
||||
return fmt.Errorf("image build: %w", buildErr)
|
||||
}
|
||||
if _, err := r.cli.ImageInspect(ctx, ref); err != nil {
|
||||
return fmt.Errorf("image build %q produced no tag: %w", ref, err)
|
||||
}
|
||||
emit(fmt.Sprintf("[image] %s built and tagged", ref))
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamDockerJSON parses Docker's streaming JSON output (one object per
|
||||
// line, fields like {"stream":"...","status":"...","error":"...","errorDetail":{...}})
|
||||
// and forwards meaningful lines to emit. Returns a non-nil error iff the
|
||||
// daemon reported an error in the stream.
|
||||
func streamDockerJSON(rc io.Reader, emit func(string)) error {
|
||||
dec := json.NewDecoder(rc)
|
||||
for {
|
||||
var msg struct {
|
||||
Stream string `json:"stream"`
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
ErrorDetail json.RawMessage `json:"errorDetail"`
|
||||
}
|
||||
if err := dec.Decode(&msg); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
// Non-JSON tail / partial trailer — stop parsing but don't
|
||||
// fail the build; the post-build ImageInspect will catch
|
||||
// any tag-missing case.
|
||||
return nil
|
||||
}
|
||||
if msg.Error != "" {
|
||||
return errors.New(msg.Error)
|
||||
}
|
||||
// Build → "stream" lines (Step 1/12 : FROM debian:slim, etc).
|
||||
if s := strings.TrimRight(msg.Stream, "\r\n"); s != "" {
|
||||
emit(s)
|
||||
}
|
||||
// Pull → "status" lines, sometimes paired with progress for
|
||||
// each layer. Skip the high-frequency "Downloading" updates
|
||||
// (would spam the console at 10–30 Hz) — keep "Pulling /
|
||||
// Pulled" markers and final messages.
|
||||
if msg.Status != "" {
|
||||
st := msg.Status
|
||||
if strings.HasPrefix(st, "Downloading") || strings.HasPrefix(st, "Extracting") || strings.HasPrefix(st, "Verifying") {
|
||||
continue
|
||||
}
|
||||
if msg.ID != "" {
|
||||
emit(fmt.Sprintf("%s: %s", msg.ID, st))
|
||||
} else {
|
||||
emit(st)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildPortBindings translates PortSpec to Docker's nat.PortSet / PortMap.
|
||||
func buildPortBindings(ports []PortSpec) (nat.PortSet, nat.PortMap, error) {
|
||||
exposed := nat.PortSet{}
|
||||
bindings := nat.PortMap{}
|
||||
for _, p := range ports {
|
||||
proto := strings.ToLower(p.Proto)
|
||||
if proto != "tcp" && proto != "udp" {
|
||||
return nil, nil, fmt.Errorf("port %d/%s: proto must be tcp or udp", p.ContainerPort, p.Proto)
|
||||
}
|
||||
natPort, err := nat.NewPort(proto, strconv.Itoa(int(p.ContainerPort)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid port %d/%s: %w", p.ContainerPort, proto, err)
|
||||
}
|
||||
exposed[natPort] = struct{}{}
|
||||
hostPort := p.HostPort
|
||||
if hostPort == 0 {
|
||||
hostPort = p.ContainerPort
|
||||
}
|
||||
bindings[natPort] = []nat.PortBinding{{
|
||||
HostIP: p.HostIP,
|
||||
HostPort: strconv.Itoa(int(hostPort)),
|
||||
}}
|
||||
}
|
||||
return exposed, bindings, nil
|
||||
}
|
||||
|
||||
func pidsPtr(v int64) *int64 {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// splitDockerTimestamp pulls docker's RFC3339Nano timestamp prefix off a
|
||||
// log line and returns the remainder + parsed time. Docker emits each
|
||||
// line as "<rfc3339nano-z> <line>". A space immediately follows the Z;
|
||||
// anything else (including missing/garbled prefix) falls back to
|
||||
// time.Now() so we never drop the line.
|
||||
func splitDockerTimestamp(s string) (string, time.Time) {
|
||||
// Minimum valid prefix: "2026-01-01T00:00:00Z " — 21 chars. With nano
|
||||
// precision it's longer; the space after Z is what we look for.
|
||||
sp := strings.IndexByte(s, ' ')
|
||||
if sp < 20 || sp > 35 {
|
||||
return s, time.Now()
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, s[:sp])
|
||||
if err != nil {
|
||||
return s, time.Now()
|
||||
}
|
||||
return s[sp+1:], t
|
||||
}
|
||||
|
||||
// scanLogLine is a bufio.SplitFunc that treats \n, \r, and \r\n as line
|
||||
// terminators. The stdlib default (bufio.ScanLines) only splits on \n and
|
||||
// will buffer everything between \r-overwrites until it sees a real newline
|
||||
// — that's how SteamCMD's "Update state downloading progress: X%" bars
|
||||
// used to sit silent for minutes before dumping in one chunk.
|
||||
func scanLogLine(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
// Swallow a trailing \n after \r so "\r\n" yields one line not two.
|
||||
advance = i + 1
|
||||
if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
|
||||
advance++
|
||||
}
|
||||
return advance, bytes.TrimRight(data[:i], "\r\n"), nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// ansiRE matches ANSI CSI sequences (colour, cursor movement, erase-line, …).
|
||||
// vinanrra's LGSM wrapper for 7DTD emits these liberally; they're noise in a
|
||||
// browser console and garble line-collapsing logic on the UI side.
|
||||
var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
if !strings.ContainsRune(s, 0x1b) {
|
||||
return s
|
||||
}
|
||||
return ansiRE.ReplaceAllString(s, "")
|
||||
}
|
||||
Reference in New Issue
Block a user