The YAML view was read-only with no way to create a new project at all. Add save/remove AJAX actions (validated via a real `podman compose ... config` dry-run, written to a .new sibling and only renamed into place on success) and a New Project/Save/Delete UI backed by an editable textarea instead of a <pre>. Also strip ANSI escape codes from compose command output so podman's own provider banner doesn't show as literal garbage in error alerts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
11 KiB
PHP
283 lines
11 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
|
|
* 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<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';
|
|
}
|
|
|
|
/**
|
|
* 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<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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<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, 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)];
|
|
}
|