Document the PHP api layer and fix what documenting it exposed

Writing down what each endpoint actually guarantees made the places it
didn't obvious — shell arguments reaching a crontab or a bash -c
unescaped, master.conf written without tmp+rename, and conf edits that
could be saved without ever being parsed.
This commit is contained in:
Gmer4Lfe
2026-08-02 10:11:39 -04:00
parent 6a959fb5e4
commit 987313e7dc
55 changed files with 3972 additions and 95 deletions
+91 -4
View File
@@ -1,7 +1,94 @@
<?php
// Background worker: docker pull → compare image ID → rebuild if updated.
// Called via: php docker_pull_worker.php <name> <jobFile> <oldId> <image> <rebuild>
[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5);
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// 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);
@@ -21,7 +108,7 @@ if ($oldId && $newId && $oldId === $newId) {
jw($jobFile, ['ok' => true, 'status' => 'rebuilding']);
if ($rebuild && is_executable($rebuild)) {
exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc);
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);