Files
unraid-podman/webui/plugins/podman/ajax/compose.php
T
maggesandClaude Sonnet 5 5b47b4cc0a Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall
  backend), docker-compose (external compose provider for `podman compose`)
  — all vendored prebuilt binaries, versions.env pinned, propagated through
  build-packages.sh/release.sh/podman.plg/verify+update-packages.sh.
- Fix WebUI: every POST action was silently failing (empty response body)
  because Unraid's own CSRF protection was never satisfied — app.js now
  sends the page's csrf_token as X-CSRF-Token.
- Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but
  /images/pull actually streams newline-delimited JSON — every successful
  pull was throwing "Expected a JSON object/array response".
- Fix WebUI: compose.php's up/down status detection had the same
  single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was
  corrupting the parse.
- Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a
  redeployed JS/CSS fix isn't served stale from browser cache.
- Add a reusable modal dialog (app.js openFormModal) replacing
  prompt()/alert() for New Volume/Network/Pull Image.
- Add host-path (bind-mount) support when creating a named volume.
- Add Create Container (image, name, network mode incl. custom networks,
  ports, volumes, env, restart policy, privileged, start-after-create),
  auto-pulling the image on first use since /containers/create doesn't.

All fixes verified live against a real podman system service and, where
reachable, via the actual WebUI over the real socket — not just unit-level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:51:17 +00:00

195 lines
7.3 KiB
PHP

<?php
/**
* ajax/compose.php
*
* Backs the Compose panel — and is the ONE deliberate exception to
* "talk to podman.sock, never shell out" in this entire WebUI.
*
* Why: libpod's REST API has no endpoint that understands
* docker-compose.yml/compose.yaml at all. The closest thing,
* POST /libpod/kube/play, takes Kubernetes YAML, not Compose YAML, and
* correctly translating arbitrary Compose files into Kube manifests is a
* substantial project of its own (this is exactly what `podman compose` /
* `podman-compose` already do). Reimplementing that translation from
* scratch here — instead of using the `podman compose` CLI, which IS the
* project's own supported way to run Compose files — would be more
* fragile, not more "API-native": there is no API to be native to for
* this one feature. So this file, and only this file, runs the
* `podman compose` CLI via proc_open(), with strict input validation
* (project names are matched against a fixed pattern, never interpolated
* into a shell string) rather than passing user input through a shell.
*
* Compose projects live under $bootDir/compose/<project>/compose.yaml —
* see docs/ARCHITECTURE.md; this mirrors how autostart/networks/backups
* are all rooted under /boot/config/plugins/podman/ for the same
* boot-persistence reasons.
*
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$composeDir = $podmanConfig->bootDir . '/compose';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(compose_list($composeDir));
break;
case 'get':
$project = (string) ($_GET['project'] ?? '');
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
case 'down':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['down']));
break;
case 'pull':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['pull']));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @param array<string,mixed> $body */
function require_project(array $body): string
{
$project = (string) ($body['project'] ?? '');
if (!is_valid_project_name($project)) {
podman_json_error('Missing or invalid project name', 400);
}
return $project;
}
/**
* Project names come from the user (a form field when creating a new
* compose project, or a value round-tripped from list()). Restricting
* them to a fixed safe character set here — BEFORE they're ever used to
* build a filesystem path or a command argument — is what makes it safe
* to pass them to proc_open() at all.
*/
function is_valid_project_name(string $name): bool
{
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
/** @return array<int,array<string,mixed>> */
function compose_list(string $composeDir): array
{
if (!is_dir($composeDir)) {
return [];
}
$projects = [];
foreach (scandir($composeDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !is_valid_project_name($entry)) {
continue;
}
$yamlPath = $composeDir . '/' . $entry . '/compose.yaml';
if (!is_file($yamlPath)) {
continue;
}
$projects[] = [
'name' => $entry,
'path' => $yamlPath,
'status' => compose_status($composeDir, $entry),
];
}
usort($projects, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $projects;
}
/** Best-effort "up"/"down" status via `podman compose ps`; degrades to "unknown" rather than failing the whole list. */
function compose_status(string $composeDir, string $project): string
{
$result = run_compose_command($composeDir, $project, ['ps', '--format', 'json'], 5);
if ($result['exitCode'] !== 0) {
return 'unknown';
}
// `podman compose ps --format json` emits one JSON object PER LINE
// (JSONL), not a single JSON array — decoding the whole blob in one
// json_decode() call fails silently (-> null) as soon as a project has
// more than one service (verified live with a 2-service project).
// stdout only, too: the "external compose provider" banner goes to
// stderr and would otherwise corrupt this either way.
$running = 0;
foreach (explode("\n", trim($result['stdout'])) as $line) {
if (trim($line) !== '' && is_array(json_decode($line, true))) {
$running++;
}
}
return $running > 0 ? 'up' : 'down';
}
function compose_read(string $composeDir, string $project): string
{
if (!is_valid_project_name($project)) {
podman_json_error('Invalid project name', 400);
}
$path = $composeDir . '/' . $project . '/compose.yaml';
$content = is_file($path) ? file_get_contents($path) : false;
if ($content === false) {
podman_json_error("compose.yaml not found for project '{$project}'", 404);
}
return $content;
}
/** @return array<string,mixed> */
function compose_run(string $composeDir, string $project, array $subcommand): array
{
$result = run_compose_command($composeDir, $project, $subcommand, 300);
if ($result['exitCode'] !== 0) {
podman_json_error("podman compose " . implode(' ', $subcommand) . " failed:\n" . $result['output'], 502);
}
return ['output' => $result['output']];
}
/**
* Runs `podman compose -f <project>/compose.yaml <subcommand...>` via
* proc_open with an argv array (never a shell string — proc_open with an
* array argument bypasses the shell entirely, so there is no injection
* surface even though $project has already been validated above too).
*
* @param array<int,string> $subcommand
* @return array{exitCode:int,stdout:string,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
if (!is_resource($process)) {
return ['exitCode' => 127, 'stdout' => '', 'output' => 'Could not start podman compose process'];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
// 'stdout' (raw) for callers that need to parse machine-readable
// output (e.g. compose_status()'s JSON); 'output' (combined,
// trimmed) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context.
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)];
}