Files
Varaverk/Plugin/unraid/api/docker_pull_worker.php
Gmer4Lfe b445ddc2c8 Make a container action tell the truth about what happened
The response was discarded, so a refused stop looked like a completed one, and the payload the
card redraws from is cached for 300s with no invalidation — the container carried on showing as
running until the once-a-minute writer caught up. Stop now confirms; start still does not.
2026-08-07 10:20:11 -04:00

130 lines
6.6 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Container update worker. Pulls one image, decides whether it actually changed, and
// recreates the container if it did — reporting progress through a job file the docker tab
// polls.
//
// OPERATIONAL MODEL
// Not an HTTP endpoint. This runs as a detached CLI process, spawned by docker_action.php,
// because a pull can take minutes and no web request should be held open for it. It lives
// under api/ because it is part of that endpoint's implementation, not because it is
// reachable over HTTP — and it refuses to run if it ever is.
//
// Progress is a file, not a return value. The parent request returns immediately with a job
// id; this process writes the current state to that file as it goes, and the page polls it.
// The file is the only channel between the two.
//
// Called as: php docker_pull_worker.php <name> <jobFile> <oldId> <image> <rebuild>
//
// DESIGN PRINCIPLES
// Compares image ids, not pull output.
// `docker pull` reports success whether or not anything changed. The image id before
// and after is the only reliable signal, and it is what decides whether the container
// is disturbed at all — an up-to-date container is never restarted.
//
// Prefers Unraid's own rebuild path.
// When a rebuild helper is supplied and executable it is used, because recreating a
// container correctly means reapplying its full template — ports, mounts, variables.
// The stop/start fallback exists for the case where that helper is unavailable, and is
// explicitly the lesser option: it picks up a new image only if the container was
// already configured to be recreated on start.
//
// Every exit writes a terminal state.
// Both outcomes end with a job-file write, so the poller always converges. A worker that
// died without writing would leave the page spinning indefinitely.
//
// OPERATIONAL SAFEGUARDS
// Refuses to run under a web server.
// PHP_SAPI is checked first and a non-CLI invocation is answered with a 404 and no
// output. Without that, requesting this file over HTTP would evaluate it with no $argv
// at all — and it is a script whose entire job is to stop and restart containers.
//
// Required arguments are checked before anything runs.
// Missing name, job file, or image exits non-zero before the first docker call, so a
// malformed spawn cannot pull or restart anything.
//
// Every value interpolated into a shell command is escaped.
// Image, container name and the rebuild helper path all go through escapeshellarg(),
// even though they originate from docker_action.php rather than from a request. The
// escaping is what stays correct if that caller ever changes.
//
// The rebuild helper is confirmed executable before it is invoked.
// is_executable() gates it, so a missing or non-executable helper falls back to
// stop/start rather than failing the update with a shell error.
//
// Failure is reported as failure.
// A non-zero rebuild status writes ok:false with an explicit message. The image has
// already been pulled at that point, so silently reporting success would leave a
// container running an old image that the page claims was updated.
//
// Docker output is captured, never echoed.
// Every call redirects stderr and the output is discarded or kept locally. This process
// has no stdout consumer; writing to it would only risk corrupting the job file if the
// two were ever pointed at the same place.
//
// ARGUMENTS
// 1 name container name
// 2 jobFile path the progress JSON is written to
// 3 oldId image id before the pull, for the changed/unchanged comparison
// 4 image image reference to pull
// 5 rebuild optional path to Unraid's container rebuild helper
//
// JOB FILE STATES
// {"ok":true,"status":"done","updated":false,"message":"Already up to date"}
// {"ok":true,"status":"rebuilding"}
// {"ok":true,"status":"done","updated":true,"message":"Updated and rebuilt"}
// {"ok":false,"status":"done","error":"Rebuild failed after pull"}
//
// DEPENDS ON
// api/docker_action.php spawns this worker and creates the job file path
// docker CLI pull, image inspect, stop, start
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// This file stops and starts containers. It is a CLI worker and must never be reachable as a
// web request — over HTTP there is no $argv, and every argument below would be undefined.
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5) + array_fill(0, 5, '');
if (!$name || !$jobFile || !$image) exit(1);
// Only for vv_cache_clear() below. Required after the CLI guard, so a stray web request is turned
// away before this process loads anything at all.
require_once dirname(__DIR__) . '/include/config.php';
function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); }
shell_exec('docker pull ' . escapeshellarg($image) . ' 2>&1');
$rawInfo = shell_exec('docker image inspect ' . escapeshellarg($image) . ' 2>/dev/null') ?: '[]';
$info = json_decode($rawInfo, true) ?: [];
$newId = $info[0]['Id'] ?? '';
if ($oldId && $newId && $oldId === $newId) {
jw($jobFile, ['ok' => true, 'status' => 'done', 'updated' => false, 'message' => 'Already up to date']);
exit;
}
jw($jobFile, ['ok' => true, 'status' => 'rebuilding']);
if ($rebuild && 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);
$rc = ($rc1 === 0 && $rc2 === 0) ? 0 : 1;
}
// The container was stopped and started to get here whether or not the rebuild reported success,
// so the cached container list is out of date either way.
vv_cache_clear('monitor');
jw($jobFile, $rc === 0
? ['ok' => true, 'status' => 'done', 'updated' => true, 'message' => 'Updated and rebuilt']
: ['ok' => false, 'status' => 'done', 'error' => 'Rebuild failed after pull']
);