Add Create Container UI and Templates (Unraid XML) feature
Lets users create containers from the WebUI (image/name/ports/volumes/ env/network mode/restart policy/privileged) instead of only managing existing ones, and adds a Templates panel to save/reuse those configs as Unraid-Docker-compatible template XML, including browsing and importing the host's own existing Docker Manager templates directly. Also fixes bugs found via live testing along the way: container names with spaces/invalid characters now get a clear client- and server-side error with a suggested fix instead of podman's raw API error, the New Container modal's backdrop no longer renders transparent (was being appended outside the .podman-plugin CSS scope), and modal buttons now have real visual hierarchy (ghost/primary/danger) after Unraid's own site-wide button theme was found to override plain single-class rules. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/templates.php
|
||||
*
|
||||
* Backs the Templates panel — reusable container configs, saved and
|
||||
* loaded as XML in the same schema Unraid's own Docker Manager uses for
|
||||
* its Community Applications templates (<Container version="2"> with
|
||||
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
|
||||
* schema, not a podman-specific one of our own: it's the one users and
|
||||
* template authors already know, and it means a template exported here
|
||||
* carries over the fields (image, ports, paths, variables, icon,
|
||||
* category, overview) a Docker template would too, even though the two
|
||||
* ecosystems' XML isn't fully interchangeable (this plugin's Config
|
||||
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
|
||||
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
|
||||
*
|
||||
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
|
||||
* reasoning as compose/autostart/networks (see ajax/compose.php).
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> [{name, image, icon, category, overview}, ...]
|
||||
* get GET (&name=...) -> full parsed config, for prefilling the
|
||||
* Create Container form ("Use template")
|
||||
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
|
||||
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
|
||||
* "overview": "...", "networkMode": "...", "privileged": false,
|
||||
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
|
||||
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
|
||||
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
|
||||
* dockerMan template directories (real existing Docker
|
||||
* templates the user already has — see
|
||||
* local_dockerman_templates_list()), for a "browse local
|
||||
* templates" picker instead of copy-pasting XML by hand
|
||||
* import_local POST {"file": "gitea.xml"} -> imports one by filename
|
||||
* (validated against the same directories list_local
|
||||
* scanned, never an arbitrary path from the client)
|
||||
* remove POST {"name": "..."}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$templatesDir = $podmanConfig->bootDir . '/templates';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(templates_list($templatesDir));
|
||||
break;
|
||||
|
||||
case 'get':
|
||||
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
|
||||
break;
|
||||
|
||||
case 'export':
|
||||
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
|
||||
podman_json_response(['xml' => file_get_contents($path)]);
|
||||
break;
|
||||
|
||||
case 'save':
|
||||
$body = podman_read_json_body();
|
||||
$name = trim((string) ($body['name'] ?? ''));
|
||||
if (!is_valid_template_name($name)) {
|
||||
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
|
||||
}
|
||||
template_write($templatesDir, $name, $body);
|
||||
podman_json_response(['status' => 'saved', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'import':
|
||||
$body = podman_read_json_body();
|
||||
$xml = (string) ($body['xml'] ?? '');
|
||||
if (trim($xml) === '') {
|
||||
podman_json_error('Missing xml in request body', 400);
|
||||
}
|
||||
$name = template_import($templatesDir, $xml);
|
||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'list_local':
|
||||
podman_json_response(local_dockerman_templates_list());
|
||||
break;
|
||||
|
||||
case 'import_local':
|
||||
$body = podman_read_json_body();
|
||||
$file = (string) ($body['file'] ?? '');
|
||||
$name = template_import($templatesDir, local_dockerman_template_read($file));
|
||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
|
||||
unlink($path);
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template names become filenames — restricted to a fixed safe character
|
||||
* set BEFORE ever being used to build a filesystem path, the same
|
||||
* pattern ajax/compose.php's is_valid_project_name() uses for the same
|
||||
* reason.
|
||||
*/
|
||||
function is_valid_template_name(string $name): bool
|
||||
{
|
||||
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
|
||||
}
|
||||
|
||||
function require_template_path(string $templatesDir, string $name): string
|
||||
{
|
||||
if (!is_valid_template_name($name)) {
|
||||
podman_json_error('Invalid template name', 400);
|
||||
}
|
||||
$path = $templatesDir . '/' . $name . '.xml';
|
||||
if (!is_file($path)) {
|
||||
podman_json_error("Template '{$name}' not found", 404);
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function templates_list(string $templatesDir): array
|
||||
{
|
||||
if (!is_dir($templatesDir)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (scandir($templatesDir) ?: [] as $entry) {
|
||||
if (!str_ends_with($entry, '.xml')) {
|
||||
continue;
|
||||
}
|
||||
$name = substr($entry, 0, -4);
|
||||
if (!is_valid_template_name($name)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$parsed = template_read($templatesDir . '/' . $entry);
|
||||
} catch (\Throwable $e) {
|
||||
continue; // a hand-edited/corrupt file shouldn't break the whole list
|
||||
}
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'image' => $parsed['image'],
|
||||
'icon' => $parsed['icon'],
|
||||
'category' => $parsed['category'],
|
||||
'overview' => $parsed['overview'],
|
||||
];
|
||||
}
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one template XML file into the same shape
|
||||
* ajax/containers.php's build_container_spec() (Create Container form)
|
||||
* consumes, so "Use template" can feed straight into that form/action.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function template_read(string $path): array
|
||||
{
|
||||
$xml = @simplexml_load_file($path);
|
||||
if ($xml === false) {
|
||||
podman_json_error("Could not parse template XML: {$path}", 500);
|
||||
}
|
||||
|
||||
$ports = [];
|
||||
$volumes = [];
|
||||
$env = [];
|
||||
foreach ($xml->Config as $cfg) {
|
||||
$attrs = $cfg->attributes();
|
||||
$type = (string) ($attrs['Type'] ?? '');
|
||||
$value = trim((string) $cfg);
|
||||
$target = (string) ($attrs['Target'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
switch ($type) {
|
||||
case 'Port':
|
||||
$ports[] = [
|
||||
'hostPort' => (int) $value,
|
||||
'containerPort' => (int) ($target !== '' ? $target : $value),
|
||||
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
|
||||
];
|
||||
break;
|
||||
case 'Path':
|
||||
$volumes[] = [
|
||||
'kind' => 'path',
|
||||
'source' => $value,
|
||||
'containerPath' => $target !== '' ? $target : $value,
|
||||
];
|
||||
break;
|
||||
case 'Variable':
|
||||
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'image' => (string) $xml->Repository,
|
||||
'networkMode' => (string) ($xml->Network ?: 'bridge'),
|
||||
'privileged' => strtolower((string) $xml->Privileged) === 'true',
|
||||
'icon' => (string) $xml->Icon,
|
||||
'category' => (string) $xml->Category,
|
||||
'overview' => (string) $xml->Overview,
|
||||
'ports' => $ports,
|
||||
'volumes' => $volumes,
|
||||
'env' => $env,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a template XML file from the Create Container form's field
|
||||
* shapes (same as build_container_spec() in ajax/containers.php takes)
|
||||
* plus template-only metadata (icon/category/overview). Uses DOMDocument
|
||||
* rather than string concatenation so every value is properly escaped —
|
||||
* no risk of a "<" or "&" in an image name/description breaking the XML.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*/
|
||||
function template_write(string $templatesDir, string $name, array $body): void
|
||||
{
|
||||
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
||||
podman_json_error("Could not create {$templatesDir}", 500);
|
||||
}
|
||||
|
||||
$doc = new \DOMDocument('1.0');
|
||||
$doc->formatOutput = true;
|
||||
$root = $doc->createElement('Container');
|
||||
$root->setAttribute('version', '2');
|
||||
$doc->appendChild($root);
|
||||
|
||||
$append = static function (string $tag, string $value) use ($doc, $root): void {
|
||||
$root->appendChild($doc->createElement($tag, $value));
|
||||
};
|
||||
$append('Name', $name);
|
||||
$append('Repository', trim((string) ($body['image'] ?? '')));
|
||||
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
|
||||
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
|
||||
$append('Overview', (string) ($body['overview'] ?? ''));
|
||||
$append('Category', (string) ($body['category'] ?? ''));
|
||||
$append('Icon', (string) ($body['icon'] ?? ''));
|
||||
|
||||
foreach (($body['ports'] ?? []) as $row) {
|
||||
$hostPort = (string) ($row['hostPort'] ?? '');
|
||||
$containerPort = (string) ($row['containerPort'] ?? '');
|
||||
if ($hostPort === '' || $containerPort === '') {
|
||||
continue;
|
||||
}
|
||||
$cfg = $doc->createElement('Config', $hostPort);
|
||||
$cfg->setAttribute('Name', 'Port ' . $containerPort);
|
||||
$cfg->setAttribute('Target', $containerPort);
|
||||
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
|
||||
$cfg->setAttribute('Type', 'Port');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
foreach (($body['volumes'] ?? []) as $row) {
|
||||
$source = (string) ($row['source'] ?? '');
|
||||
$containerPath = (string) ($row['containerPath'] ?? '');
|
||||
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
|
||||
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
|
||||
}
|
||||
$cfg = $doc->createElement('Config', $source);
|
||||
$cfg->setAttribute('Name', basename($containerPath));
|
||||
$cfg->setAttribute('Target', $containerPath);
|
||||
$cfg->setAttribute('Mode', 'rw');
|
||||
$cfg->setAttribute('Type', 'Path');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
foreach (($body['env'] ?? []) as $row) {
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
|
||||
$cfg->setAttribute('Name', $key);
|
||||
$cfg->setAttribute('Target', $key);
|
||||
$cfg->setAttribute('Type', 'Variable');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
|
||||
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
|
||||
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a pasted/uploaded template XML (this plugin's own schema, or
|
||||
* a plain Unraid dockerMan template — both use the same <Container>/
|
||||
* <Config> shape). Validates it parses and has a usable <Name> before
|
||||
* writing it under our own templates dir with a sanitized filename.
|
||||
*/
|
||||
function template_import(string $templatesDir, string $xmlText): string
|
||||
{
|
||||
$xml = @simplexml_load_string($xmlText);
|
||||
if ($xml === false || $xml->getName() !== 'Container') {
|
||||
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
|
||||
}
|
||||
|
||||
$rawName = trim((string) $xml->Name);
|
||||
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
|
||||
if (!is_valid_template_name((string) $name)) {
|
||||
podman_json_error('Template XML has no usable <Name>', 400);
|
||||
}
|
||||
|
||||
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
||||
podman_json_error("Could not create {$templatesDir}", 500);
|
||||
}
|
||||
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
|
||||
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
||||
}
|
||||
return (string) $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unraid's own Docker Manager plugin stores every template a user has
|
||||
* ever saved/customized under templates-user/, plus a local cache of
|
||||
* Community Applications' own catalog under templates-community/ (may
|
||||
* be empty/absent if CA was never installed) — both in the same
|
||||
* <Container>/<Config> XML shape this plugin reads. Read-only: this
|
||||
* plugin never writes into dockerMan's own directories.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
function dockerman_template_dirs(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
'/boot/config/plugins/dockerMan/templates-user',
|
||||
'/boot/config/plugins/dockerMan/templates-community',
|
||||
], 'is_dir'));
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,string>> */
|
||||
function local_dockerman_templates_list(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (dockerman_template_dirs() as $dir) {
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if (!str_ends_with($entry, '.xml')) {
|
||||
continue;
|
||||
}
|
||||
$xml = @simplexml_load_file($dir . '/' . $entry);
|
||||
if ($xml === false) {
|
||||
continue; // skip anything that doesn't parse rather than failing the whole list
|
||||
}
|
||||
$out[] = [
|
||||
'file' => $entry,
|
||||
'name' => (string) $xml->Name,
|
||||
'image' => (string) $xml->Repository,
|
||||
'icon' => (string) $xml->Icon,
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one local dockerMan template's raw XML by filename. $file comes
|
||||
* straight from client input — reduced to its basename and required to
|
||||
* actually exist in one of dockerman_template_dirs() (the same list
|
||||
* list_local scanned), never treated as an arbitrary path.
|
||||
*/
|
||||
function local_dockerman_template_read(string $file): string
|
||||
{
|
||||
$file = basename($file);
|
||||
if (!str_ends_with($file, '.xml')) {
|
||||
podman_json_error('Invalid template file', 400);
|
||||
}
|
||||
foreach (dockerman_template_dirs() as $dir) {
|
||||
$path = $dir . '/' . $file;
|
||||
if (is_file($path)) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content !== false) {
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
podman_json_error("Local template '{$file}' not found", 404);
|
||||
}
|
||||
Reference in New Issue
Block a user