Add reproducible build system, native Unraid plugin, and WebUI
- versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt, and fuse-overlayfs to verified upstream source checksums; SlackBuild recipes, scripts/build-packages.sh, checksums.sh, release.sh, and update-versions.sh implement the reproducible pipeline; GitHub Actions workflows build in a Slackware container and publish releases without committing any binaries. - plugin/podman.plg installs/updates/removes all eight packages (the seven components plus the plugin's own unraid-podman scaffolding package) via upgradepkg, using the official Unraid array-event hook mechanism (event/disks_mounted, event/stopping) instead of editing /boot/config/go. rc.podman and the sbin/ helper scripts implement storage creation, config seeding/sync, preflight checks, autostart with per-container Safe-Mode, and package verify/update/rollback. - webui/plugins/podman implements the Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal, Compose, and Settings panels against the approved mockup (webui/mockups/prototype.html), talking to podman system service exclusively via PodmanClient.php (libpod REST API over the Unix socket), with two documented exceptions: Terminal's one-shot exec model and Compose's use of the podman compose CLI, since libpod has no REST equivalent for either. - docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions and honest current status (syntax-checked, unit- and integration-tested against fake sockets/servers; not yet run against a real Unraid/Podman/Slackware system). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<?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';
|
||||
}
|
||||
$decoded = json_decode($result['output'], true);
|
||||
return (is_array($decoded) && count($decoded) > 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,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, '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);
|
||||
|
||||
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
|
||||
}
|
||||
Reference in New Issue
Block a user