Files
Varaverk/Plugin/unraid/api/docker_action.php
T
Gmer4Lfe 86a0ba55fe Include the config the container endpoint now depends on
vv_cache_clear() went in without it, so every start and stop fatalled on the line after the
docker command had already run — the action succeeded and the page was told it failed.
2026-08-07 10:33:08 -04:00

191 lines
10 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Container control. Start, stop, restart, tail logs, and pull-and-rebuild one container —
// the action buttons on the docker tab.
//
// OPERATIONAL MODEL
// Four of the five actions are synchronous and answer within the request. pull_rebuild is
// not: a pull can take minutes, so it spawns docker_pull_worker.php detached, returns a job
// id immediately, and the page polls job_status until the worker writes a terminal state.
// The job file in /tmp is the only channel between the two.
//
// Deliberately separate from docker.php. That endpoint moves containers between folders —
// metadata only. This one starts and stops them. Keeping the destructive verbs in their own
// file is what lets the UI put a confirmation in front of exactly these and not the others.
//
// Loads no library at all. Every operation here is a docker CLI call, and pulling in the
// config layer would add failure modes to the endpoint most likely to be used while
// something else is broken.
//
// DESIGN PRINCIPLES
// Prefers Unraid's own rebuild helper over stop/start.
// restart and pull_rebuild both try rebuild_container first, because recreating a
// container correctly means reapplying its full template — ports, mounts, variables. The
// stop/start fallback exists for when that helper is absent and is explicitly the lesser
// option.
//
// The image is resolved from the container, never supplied.
// pull_rebuild reads both the current image id and the configured image reference via
// docker inspect. The request names a container; it cannot name what to pull into it.
//
// Job ids are random, not sequential.
// bin2hex(random_bytes(8)) — a job's status is readable by anyone who can guess its id,
// so the id is not guessable.
//
// Every action reports docker's own output.
// Combined stdout and stderr are returned rather than a generic failure message. When a
// container will not start, the reason is in that text and nowhere else.
//
// OPERATIONAL SAFEGUARDS
// The container name is pattern-matched and then confirmed to exist.
// ^[a-zA-Z0-9_.-]+$ excludes every shell metacharacter, and a `docker ps -a` lookup with
// an anchored filter must return that exact name before any container-scoped action
// runs. The existence check is what stops a valid-looking name from reaching an action
// at all — and it is anchored (^name$) so a prefix cannot select a different container.
//
// Every value interpolated into a shell command is escaped.
// Container name, image, job file, worker path and the rebuild helper all pass through
// escapeshellarg(), on top of the pattern check rather than instead of it.
//
// job_status is reachable without a container name, and is validated separately.
// It returns before the name check, because the container it refers to may have been
// recreated by then. Its id must match ^[0-9a-f]+$, so it cannot escape the job
// directory or name a file outside it.
//
// An unknown job id reports pending, not missing.
// The worker writes its first state after the request returns, so a poll that arrives in
// between must not be told the job does not exist.
//
// The job directory is private and created on demand.
// mkdir 0700 under /tmp — a hardcoded literal, not a config value, so no conf edit can
// redirect these writes. /tmp is tmpfs, so abandoned jobs clear on reboot.
//
// pull_rebuild refuses to start without an image.
// An empty docker inspect result aborts before the worker is spawned, so a container
// whose image cannot be determined is never stopped in pursuit of an update.
//
// Unknown actions fall through to an explicit error, so a typo cannot reach a container.
//
// REQUEST
// POST action=start|stop|restart name=<container>
// POST action=logs name=<container> last 200 lines, timestamped
// POST action=pull_rebuild name=<container> spawns the worker, returns job_id
// POST action=job_status job_id=<hex> poll a pull_rebuild job
//
// RESPONSE
// {"ok":bool,"output":"<docker output>"} start / stop / restart
// {"ok":true,"logs":"…"} logs
// {"ok":true,"status":"started","job_id":"…"} pull_rebuild
// {"ok":true,"status":"pending"} or the worker's job file verbatim
// {"ok":false,"error":"invalid name"|"invalid job_id"|"Container not found"
// |"Could not determine image"|"Unknown action"}
//
// DEPENDS ON
// include/config.php vv_cache_clear()
// api/docker_pull_worker.php detached worker for the pull_rebuild path
// docker CLI ps, logs, start, stop, inspect
// dynamix.docker.manager rebuild_container, when present
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
// This file was self-contained until the cache invalidation below needed a shared helper, and the
// call went in without the include. The fatal landed *after* docker stop had already run, so the
// container went down and the page was told the action failed — the worst shape a bug here can
// take, because the operator's next move is to try again on a machine that already did it.
require_once dirname(__DIR__) . '/include/config.php';
define('VV_JOB_DIR', '/tmp/varaverk_dk_jobs');
$action = trim($_POST['action'] ?? '');
$name = trim($_POST['name'] ?? '');
$jobId = trim($_POST['job_id'] ?? '');
// ── Job status (no name required) ─────────────────────────────────────────────
if ($action === 'job_status') {
if (!$jobId || !preg_match('/^[0-9a-f]+$/', $jobId)) {
echo json_encode(['ok' => false, 'error' => 'invalid job_id']); exit;
}
$file = VV_JOB_DIR . '/' . $jobId . '.json';
if (!file_exists($file)) {
echo json_encode(['ok' => true, 'status' => 'pending']); exit;
}
echo file_get_contents($file); exit;
}
// ── Logs ──────────────────────────────────────────────────────────────────────
if ($action === 'logs') {
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
}
$out = shell_exec('docker logs --tail 200 --timestamps ' . escapeshellarg($name) . ' 2>&1');
echo json_encode(['ok' => true, 'logs' => $out ?? '']); exit;
}
// ── Container-scoped actions ──────────────────────────────────────────────────
if (!$name || !preg_match('/^[a-zA-Z0-9_.-]+$/', $name)) {
echo json_encode(['ok' => false, 'error' => 'invalid name']); exit;
}
$check = trim(shell_exec(
'docker ps -a --filter ' . escapeshellarg('name=^' . $name . '$') . " --format '{{.Names}}' 2>/dev/null"
) ?? '');
if ($check !== $name) {
echo json_encode(['ok' => false, 'error' => 'Container not found']); exit;
}
// Every arm below drops the monitor cache once the container state has actually changed. That
// payload carries the container list the dashboard draws, is served with a 300s window, and is
// otherwise only rewritten by the once-a-minute cache writer — so without this the operator stops
// a container, watches the card, and sees it running for up to a minute with no hint as to why.
// Cleared after the command rather than before, so a failed action does not throw away a payload
// that is still accurate.
if ($action === 'start' || $action === 'stop') {
exec(($action === 'start' ? 'docker start' : 'docker stop') . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
if ($rc === 0) vv_cache_clear('monitor');
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
if ($action === 'restart') {
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
if (is_executable($rebuild)) {
exec(escapeshellarg($rebuild) . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
} else {
exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1);
exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2);
$out = array_merge($o1, $o2);
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
if ($rc === 0) vv_cache_clear('monitor');
echo json_encode(['ok' => $rc === 0, 'output' => implode("\n", $out)]); exit;
}
if ($action === 'pull_rebuild') {
@mkdir(VV_JOB_DIR, 0700, true);
$jobId = bin2hex(random_bytes(8));
$jobFile = VV_JOB_DIR . '/' . $jobId . '.json';
$worker = __DIR__ . '/docker_pull_worker.php';
$rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container';
$oldId = trim(shell_exec('docker inspect --format={{.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
$image = trim(shell_exec('docker inspect --format={{.Config.Image}} ' . escapeshellarg($name) . ' 2>/dev/null') ?: '');
if (!$image) {
echo json_encode(['ok' => false, 'error' => 'Could not determine image']); exit;
}
file_put_contents($jobFile, json_encode(['ok' => true, 'status' => 'pulling', 'container' => $name]));
$cmd = 'php ' . escapeshellarg($worker) . ' ' .
escapeshellarg($name) . ' ' .
escapeshellarg($jobFile) . ' ' .
escapeshellarg($oldId) . ' ' .
escapeshellarg($image) . ' ' .
escapeshellarg($rebuild) . ' >/dev/null 2>&1 &';
exec($cmd);
echo json_encode(['ok' => true, 'status' => 'started', 'job_id' => $jobId]); exit;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);