/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 * save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml * remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory * 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 'save': $body = podman_read_json_body(); podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? ''))); break; case 'remove': podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body()))); 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 $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> */ 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'; } /** * Creates a new project (directory doesn't exist yet) or overwrites an * existing one's compose.yaml. Validated via the real tool — `podman * compose ... config` parses and resolves the file, exiting non-zero with * a specific line/column message on invalid YAML/schema (verified live) * — rather than a hand-rolled YAML parser, since PHP has no YAML * extension available here to begin with. Written to a *.new sibling * file first and only renamed into place once validation passes, so a * bad edit never corrupts a previously-working compose.yaml. * * @return array */ function compose_save(string $composeDir, string $project, string $yaml): array { if (trim($yaml) === '') { podman_json_error('compose.yaml content cannot be empty', 400); } $projectDir = $composeDir . '/' . $project; if (!is_dir($projectDir) && !mkdir($projectDir, 0755, true) && !is_dir($projectDir)) { podman_json_error("Could not create project directory for '{$project}'", 500); } $yamlPath = $projectDir . '/compose.yaml'; $tmpName = 'compose.yaml.new'; if (file_put_contents($projectDir . '/' . $tmpName, $yaml) === false) { podman_json_error('Could not write compose.yaml', 500); } $result = run_compose_command($composeDir, $project, ['config'], 30, $tmpName); if ($result['exitCode'] !== 0) { @unlink($projectDir . '/' . $tmpName); podman_json_error("Invalid compose file:\n" . trim($result['output']), 400); } if (!rename($projectDir . '/' . $tmpName, $yamlPath)) { podman_json_error('Could not save compose.yaml', 500); } return ['status' => 'saved']; } /** * Best-effort `down` (ignored if it fails — e.g. already down, or the * file was mid-edit and invalid) so deleting a running project's files * doesn't leave orphaned containers/networks behind, then deletes just * that one project's own directory. $project is validated by * require_project() before this is ever called, so $projectDir can't * escape $composeDir. * * @return array */ function compose_remove(string $composeDir, string $project): array { $projectDir = $composeDir . '/' . $project; if (!is_dir($projectDir)) { podman_json_error("Project '{$project}' not found", 404); } run_compose_command($composeDir, $project, ['down'], 60); $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($projectDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } rmdir($projectDir); return ['status' => 'removed']; } 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 */ 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 /compose.yaml ` 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 $subcommand * @return array{exitCode:int,stdout:string,output:string} */ function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array { $yamlPath = $composeDir . '/' . $project . '/' . $yamlFile; $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, // ANSI-stripped) for human-facing success/error messages, where seeing // podman's own stderr banner/warnings is actually useful context — // just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found // live: they showed up as literal garbage characters in the WebUI's // error alerts). $combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr); return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)]; }