Make Compose panel editable: create/edit/delete projects
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>
This commit is contained in:
@@ -27,6 +27,8 @@
|
||||
* 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": "..."}
|
||||
@@ -49,6 +51,15 @@ switch ($action) {
|
||||
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;
|
||||
@@ -136,6 +147,79 @@ function compose_status(string $composeDir, string $project): string
|
||||
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)) {
|
||||
@@ -168,9 +252,9 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
|
||||
* @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
|
||||
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
|
||||
{
|
||||
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
|
||||
$yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
|
||||
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
|
||||
|
||||
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||
@@ -187,8 +271,12 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
||||
$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)];
|
||||
// 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)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user