6 Commits
Author SHA1 Message Date
hhftechnologies 2d0b8a4918 bugfixes-ui-updates 2026-05-19 22:13:03 +05:30
hhftechnologies 9ae2847552 updates 2026-05-19 21:33:58 +05:30
hhftechnologies d01fcf31db bugfixes 2026-05-19 18:42:44 +05:30
hhftechnologies a70c6f8c8f bugfixes-ui-valadations 2026-05-19 16:53:12 +05:30
hhftechnologies 65fda48f7d testfiles 2026-05-15 21:42:48 +05:30
hhftechnologies 8bcc55193b Import compose services and blueprint fixes
Add support for importing individual Docker Compose services into blueprints and improve generator behavior. Introduces resourceFromComposeService, default service port handling, and unique blueprint name generation; preserves disabled compose services (no Pangolin labels) and retains existing network syntax when injecting the Pangolin network. Update Blueprint and Compose builders to batch-import services/networks/volumes, expose available compose services UI, and fix selection/indexing when adding/removing resources or services. Add tests for template modal and blueprint generator/validator changes, and adjust env output header formatting and template-store state updates after import.
2026-05-15 20:36:44 +05:30
41 changed files with 3531 additions and 694 deletions
+4 -27
View File
@@ -1237,7 +1237,6 @@
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -2409,7 +2408,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2426,7 +2424,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2443,7 +2440,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2460,7 +2456,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2477,7 +2472,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2494,7 +2488,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2511,7 +2504,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2528,7 +2520,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2545,7 +2536,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2562,7 +2552,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2579,7 +2568,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2596,7 +2584,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2613,7 +2600,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2632,7 +2618,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2649,7 +2634,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3361,7 +3345,7 @@
"version": "25.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.21.0"
@@ -3371,7 +3355,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3381,7 +3365,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -4362,7 +4346,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -5845,7 +5828,6 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6004,7 +5986,6 @@
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6241,7 +6222,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.129.0",
@@ -6275,7 +6255,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -6696,7 +6675,7 @@
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
@@ -6821,7 +6800,6 @@
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
@@ -6899,7 +6877,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
+34
View File
@@ -1,5 +1,39 @@
import "@testing-library/jest-dom/vitest";
// Some runners expose a partial localStorage object when the native backing
// file is unavailable. Tests need the full Storage surface.
if (typeof window.localStorage?.clear !== "function") {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
}
// matchMedia polyfill for jsdom
if (!window.matchMedia) {
window.matchMedia = (query: string) => ({
+16 -5
View File
@@ -8,7 +8,7 @@ import {
} from "./ui/dialog";
import { Checkbox } from "./ui/checkbox";
import { Label } from "./ui/label";
import { Copy, Download } from "lucide-react";
import { Copy, Download, AlertCircle } from "lucide-react";
import { copyToClipboard, downloadFile } from "../utils/clipboard";
import type { ConversionType } from "../hooks/useConversionDialog";
@@ -17,6 +17,7 @@ export interface ConversionDialogProps {
onOpenChange: (open: boolean) => void;
conversionType: ConversionType | string;
conversionOutput: string;
conversionValid?: boolean;
clearEnvAfterDownload?: boolean;
onClearEnvChange?: (checked: boolean) => void;
onToast?: (message: { title: string; description?: string; variant?: "default" | "success" | "error" }) => void;
@@ -51,6 +52,7 @@ export function ConversionDialog({
onOpenChange,
conversionType,
conversionOutput,
conversionValid = true,
clearEnvAfterDownload = false,
onClearEnvChange,
onToast,
@@ -60,6 +62,7 @@ export function ConversionDialog({
const mimeType = CONVERSION_MIME_TYPES[conversionType] || "text/plain";
const handleCopy = async () => {
if (!conversionValid) return;
try {
await copyToClipboard(conversionOutput);
onToast?.({
@@ -67,7 +70,7 @@ export function ConversionDialog({
description: `${title} has been copied to your clipboard.`,
variant: "success",
});
} catch (error) {
} catch {
onToast?.({
title: "Failed to copy",
description: "Could not copy to clipboard. Please try again.",
@@ -77,6 +80,7 @@ export function ConversionDialog({
};
const handleDownload = () => {
if (!conversionValid) return;
try {
downloadFile(conversionOutput, filename, mimeType);
onToast?.({
@@ -84,7 +88,7 @@ export function ConversionDialog({
description: `Downloading ${filename}`,
variant: "success",
});
} catch (error) {
} catch {
onToast?.({
title: "Download failed",
description: "Could not download file. Please try again.",
@@ -100,6 +104,13 @@ export function ConversionDialog({
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{!conversionValid && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
Output is blocked until validation errors are fixed.
</div>
)}
<div className="flex-1 overflow-auto">
<pre className="p-4 bg-muted rounded-md text-sm font-mono whitespace-pre-wrap break-all">
{conversionOutput}
@@ -120,11 +131,11 @@ export function ConversionDialog({
)}
<DialogFooter className="flex-row justify-end gap-2">
<Button variant="outline" onClick={handleCopy}>
<Button variant="outline" onClick={handleCopy} disabled={!conversionValid}>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button onClick={handleDownload}>
<Button onClick={handleDownload} disabled={!conversionValid}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
+1 -1
View File
@@ -86,7 +86,7 @@ export function Header({ onOpenMarket }: HeaderProps) {
</a>
<a
className="icon-btn"
href="https://github.com/hhftechnologies/Dock-Dploy"
href="https://github.com/hhftechnology/Dock-Dploy"
target="_blank"
rel="noreferrer"
aria-label="GitHub"
@@ -5,7 +5,7 @@ import { ServicesTab } from "../compose-builder/ConfigColumn/ServicesTab";
import { defaultService } from "../../utils/default-configs";
describe("ServicesTab", () => {
it("hides the trash icon on an unnamed service", () => {
it("hides the trash icon on the last remaining service", () => {
render(
<ServicesTab
services={[defaultService()]}
@@ -22,12 +22,11 @@ describe("ServicesTab", () => {
).not.toBeInTheDocument();
});
it("shows the trash icon once a service is named", () => {
const svc = { ...defaultService(), name: "web", image: "nginx" };
it("shows trash icons for manually added blank services when more than one exists", () => {
render(
<ServicesTab
services={[svc]}
selectedIdx={0}
services={[defaultService(), defaultService()]}
selectedIdx={1}
selectedType="service"
onSelectService={() => {}}
onAddService={() => {}}
@@ -36,8 +35,8 @@ describe("ServicesTab", () => {
/>,
);
expect(
screen.getByRole("button", { name: /remove service/i }),
).toBeInTheDocument();
screen.getAllByRole("button", { name: /remove service/i }),
).toHaveLength(2);
});
it("fires onRemoveService when the trash is clicked", async () => {
@@ -45,7 +44,7 @@ describe("ServicesTab", () => {
const svc = { ...defaultService(), name: "web", image: "nginx" };
render(
<ServicesTab
services={[svc]}
services={[svc, defaultService()]}
selectedIdx={0}
selectedType="service"
onSelectService={() => {}}
@@ -55,7 +54,7 @@ describe("ServicesTab", () => {
/>,
);
await userEvent.click(
screen.getByRole("button", { name: /remove service/i }),
screen.getByRole("button", { name: /remove service web/i }),
);
expect(onRemove).toHaveBeenCalledWith(0);
});
+47 -31
View File
@@ -6,15 +6,14 @@ import type {
VolumeConfig,
} from "../../types/compose";
import type { VPNConfig } from "../../types/vpn-configs";
import {
convertToDockerRun,
convertToSystemd,
generateKomodoToml,
generateEnvFile,
} from "../../utils/converters";
import { copyToClipboard } from "../../utils/clipboard";
import {
createValidatedComposeOutput,
type ComposeOutputFormat,
} from "../../utils/validation/outputs";
import { formatValidationIssues } from "../../utils/validation/issues";
type FormatId = "compose" | "docker-run" | "systemd" | "env" | "komodo";
type FormatId = Exclude<ComposeOutputFormat, "redact">;
interface FormatTab {
id: FormatId;
@@ -44,6 +43,8 @@ export interface CodePanelProps {
export function CodePanel({
yaml,
services,
networks,
volumes,
vpn,
validationError,
validationSuccess: _validationSuccess,
@@ -52,22 +53,21 @@ export function CodePanel({
const [format, setFormat] = useState<FormatId>("compose");
const [copied, setCopied] = useState(false);
const output = useMemo(() => {
switch (format) {
case "compose":
return yaml;
case "docker-run":
return services.map((s) => convertToDockerRun(s)).join("\n\n");
case "systemd":
return services.map((s) => convertToSystemd(s)).join("\n\n");
case "env":
return generateEnvFile(services, vpn);
case "komodo":
return generateKomodoToml(yaml);
default:
return "";
}
}, [format, yaml, services, vpn]);
const validatedOutput = useMemo(
() =>
createValidatedComposeOutput(format, {
services,
networks,
volumes,
vpn,
yaml,
}),
[format, services, networks, volumes, vpn, yaml],
);
const output = validatedOutput.ok
? validatedOutput.output
: invalidPanelOutput(validatedOutput.issues);
const filename =
FORMATS.find((f) => f.id === format)?.filename || "compose.yml";
@@ -77,18 +77,23 @@ export function CodePanel({
[output],
);
const activeValidationError =
validationError ||
(!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null);
const canCopy = validatedOutput.ok && !activeValidationError;
const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok";
const handleCopy = async () => {
if (!canCopy) return;
try {
await copyToClipboard(output);
await copyToClipboard(validatedOutput.output);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore — browser may have denied clipboard access
// ignore - browser may have denied clipboard access
}
};
const statusKind: "ok" | "err" = validationError ? "err" : "ok";
return (
<section className="code-col">
<div className="code-head">
@@ -102,7 +107,7 @@ export function CodePanel({
onClick={onValidate}
title="Validate & reformat"
>
{validationError ? (
{activeValidationError ? (
<>
<AlertCircle size={14} />
Re-validate
@@ -136,6 +141,7 @@ export function CodePanel({
type="button"
className="code-copy"
onClick={handleCopy}
disabled={!canCopy}
aria-label="Copy to clipboard"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
@@ -144,11 +150,11 @@ export function CodePanel({
<CodeView text={output} />
<div className="code-statusbar">
<span
className={"status-tag" + (validationError ? " err" : "")}
className={"status-tag" + (activeValidationError ? " err" : "")}
aria-live="polite"
>
<span className={"status-dot " + statusKind} aria-hidden />
{validationError ? validationError : "Valid"}
{activeValidationError ? activeValidationError : "Valid"}
</span>
<span className="status-mono">
{lineCount} {lineCount === 1 ? "line" : "lines"} · UTF-8 · LF
@@ -160,10 +166,20 @@ export function CodePanel({
);
}
function invalidPanelOutput(issues: { path: string; message: string }[]): string {
return [
"# Output blocked by validation",
...issues.map((issue) => {
const label = issue.path ? `${issue.path}: ` : "";
return `# ${label}${issue.message}`;
}),
].join("\n");
}
/**
* Lightweight syntax-aware preview. Uses simple regex to highlight YAML keys
* and values without pulling in CodeMirror (which is reserved for the
* editable surfaces — TemplateDetailModal and the convert dialog).
* editable surfaces - TemplateDetailModal and the convert dialog).
*/
function CodeView({ text }: { text: string }) {
const lines = useMemo(() => text.split("\n"), [text]);
@@ -55,10 +55,9 @@ export function ServicesTab({
const displayName = svc.name?.trim() || "";
const imageName = svc.image?.trim() || "";
const meta = imageName || "no image specified";
// Unnamed-and-imageless rows are the placeholder slot; the user
// can't usefully delete it (we'd just regenerate one). Hide the
// trash icon to keep that affordance honest.
const showTrash = Boolean(displayName || imageName);
// Allow deleting manually added blank rows. Only the last
// remaining service is protected so the builder is never stranded.
const showTrash = services.length > 1;
return (
<div
key={idx}
@@ -14,6 +14,7 @@ import { GithubIcon as Github } from "../icons/BrandIcons";
import { copyToClipboard, downloadFile } from "../../utils/clipboard";
import { useToast } from "../ui/toast";
import { validateComposeYaml } from "../../utils/validation/compose";
import { sanitizeComposeVersion } from "../../utils/compose-version";
export interface TemplateDetails {
id: string;
@@ -28,6 +29,7 @@ export interface TemplateDetails {
docs?: string;
};
composeContent?: string;
composeVersionRemoved?: boolean;
}
export interface TemplateDetailModalProps {
@@ -49,18 +51,25 @@ export function TemplateDetailModal({
const [importing, setImporting] = useState(false);
const { toast } = useToast();
const sanitizedCompose = useMemo(() => {
if (!template?.composeContent) return { content: "", removed: false };
return sanitizeComposeVersion(template.composeContent);
}, [template?.composeContent]);
const composeContent = sanitizedCompose.content;
// Pre-import validation: parse the compose YAML and flag any problems
// before the user can pull it into their workspace.
const validation = useMemo(() => {
if (!template?.composeContent) return { ok: true, errors: [] as string[] };
return validateComposeYaml(template.composeContent);
}, [template?.composeContent]);
if (!composeContent) return { ok: true, errors: [] as string[] };
return validateComposeYaml(composeContent);
}, [composeContent]);
const handleCopy = async () => {
if (!template?.composeContent) return;
if (!template || !composeContent) return;
try {
await copyToClipboard(template.composeContent);
await copyToClipboard(composeContent);
toast({
title: "Copied to clipboard",
description: "Docker Compose content has been copied",
@@ -76,11 +85,11 @@ export function TemplateDetailModal({
};
const handleDownload = () => {
if (!template?.composeContent) return;
if (!template || !composeContent) return;
try {
downloadFile(
template.composeContent,
composeContent,
`${template.name.toLowerCase().replace(/\s+/g, "-")}-compose.yml`,
"text/yaml"
);
@@ -111,7 +120,7 @@ export function TemplateDetailModal({
setImporting(true);
try {
await onImport(template);
await onImport({ ...template, composeContent });
toast({
title: "Template imported",
description: `${template.name} has been imported successfully`,
@@ -287,12 +296,12 @@ export function TemplateDetailModal({
</TabsContent>
<TabsContent value="compose" className="py-4">
{template.composeContent ? (
{composeContent ? (
<div className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<div className="editor-h-lg dialog-h-short">
<CodeEditor
content={template.composeContent}
content={composeContent}
onContentChange={() => {}}
height={undefined}
width={undefined}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TemplateDetailModal } from "../TemplateDetailModal";
import { ToastProvider } from "../../ui/toast";
const VALID_TEMPLATE = {
id: "nginx",
name: "Nginx",
composeContent: `services:
nginx:
image: nginx:latest
ports:
- "8080:80"
`,
};
function renderModal(props: Partial<Parameters<typeof TemplateDetailModal>[0]> = {}) {
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
template: VALID_TEMPLATE,
onImport: vi.fn().mockResolvedValue(undefined),
};
const merged = { ...defaultProps, ...props };
render(
<ToastProvider>
<TemplateDetailModal {...merged} />
</ToastProvider>,
);
return merged;
}
describe("TemplateDetailModal", () => {
it("closes after a successful import", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
const onImport = vi.fn().mockResolvedValue(undefined);
renderModal({ onOpenChange, onImport });
await user.click(screen.getByRole("button", { name: /import template/i }));
expect(onImport).toHaveBeenCalledWith(VALID_TEMPLATE);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
"border-red-600 bg-red-50 text-red-950 dark:border-red-400 dark:bg-red-950 dark:text-red-50 [&>svg]:text-current *:data-[slot=alert-description]:text-red-900 dark:*:data-[slot=alert-description]:text-red-100",
},
},
defaultVariants: {
+6 -6
View File
@@ -70,16 +70,16 @@ function ToastContainer() {
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const variantStyles = {
default: "bg-background border-border",
success: "bg-green-500/10 border-green-500/50 text-green-700 dark:text-green-400",
error: "bg-red-500/10 border-red-500/50 text-red-700 dark:text-red-400",
warning: "bg-yellow-500/10 border-yellow-500/50 text-yellow-700 dark:text-yellow-400",
default: "bg-background border-border text-foreground",
success: "bg-emerald-50 border-emerald-600 text-emerald-950 dark:bg-emerald-950 dark:border-emerald-400 dark:text-emerald-50",
error: "bg-red-50 border-red-600 text-red-950 dark:bg-red-950 dark:border-red-400 dark:text-red-50",
warning: "bg-amber-50 border-amber-600 text-amber-950 dark:bg-amber-950 dark:border-amber-400 dark:text-amber-50",
}
return (
<div
className={cn(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-2xl transition-all",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full",
"data-[state=open]:slide-in-from-bottom-full data-[state=open]:sm:slide-in-from-top-full",
@@ -92,7 +92,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string)
<div className="text-sm font-semibold">{toast.title}</div>
)}
{toast.description && (
<div className="text-sm opacity-90">{toast.description}</div>
<div className="text-sm">{toast.description}</div>
)}
</div>
{toast.action}
@@ -51,6 +51,31 @@ describe("useTemplateStore", () => {
expect(fetchMock.mock.calls[0][0]).toContain("/meta.json");
});
it("loads marketplace metadata when optional links are invalid", async () => {
const payload = [
{
id: "plex",
name: "Plex",
links: { website: "", docs: "not-a-url" },
},
];
const fetchMock = mockFetchOk(payload);
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useTemplateStore());
await act(async () => {
result.current.setTemplateStoreOpen(true);
});
await waitFor(() => {
expect(result.current.templates).toHaveLength(1);
});
expect(result.current.templates[0].links).toBeUndefined();
expect(result.current.templateError).toBeNull();
});
it("surfaces an error message on fetch failure", async () => {
const fetchMock = mockFetchFail("Not Found");
vi.stubGlobal("fetch", fetchMock);
+28 -43
View File
@@ -1,18 +1,22 @@
import { useState, useCallback } from "react";
import type { ServiceConfig } from "../types/compose";
import type {
NetworkConfig,
ServiceConfig,
VolumeConfig,
} from "../types/compose";
import type { VPNConfig } from "../types/vpn-configs";
import {
convertToDockerRun,
convertToSystemd,
generateKomodoToml,
generateEnvFile,
} from "../utils/converters";
import { redactSensitiveData } from "../utils/validation";
createValidatedComposeOutput,
invalidOutputMessage,
type ComposeOutputFormat,
} from "../utils/validation/outputs";
export type ConversionType = "docker-run" | "systemd" | "env" | "redact" | "komodo";
export interface UseConversionDialogOptions {
services: ServiceConfig[];
networks?: NetworkConfig[];
volumes?: VolumeConfig[];
selectedIdx: number | null;
yaml: string;
vpnConfig: VPNConfig;
@@ -20,6 +24,8 @@ export interface UseConversionDialogOptions {
export function useConversionDialog({
services,
networks = [],
volumes = [],
selectedIdx,
yaml,
vpnConfig,
@@ -27,48 +33,26 @@ export function useConversionDialog({
const [conversionDialogOpen, setConversionDialogOpen] = useState(false);
const [conversionType, setConversionType] = useState<ConversionType | "">("");
const [conversionOutput, setConversionOutput] = useState<string>("");
const [conversionValid, setConversionValid] = useState(true);
const handleConversion = useCallback(
(type: ConversionType) => {
setConversionType(type);
let output: string;
try {
switch (type) {
case "docker-run":
if (selectedIdx !== null && services[selectedIdx]) {
output = convertToDockerRun(services[selectedIdx]);
} else {
output = services.map((s) => convertToDockerRun(s)).join("\n\n");
}
break;
case "systemd":
if (selectedIdx !== null && services[selectedIdx]) {
output = convertToSystemd(services[selectedIdx]);
} else {
output = services.map((s) => convertToSystemd(s)).join("\n\n");
}
break;
case "env":
output = generateEnvFile(services, vpnConfig);
break;
case "redact":
output = redactSensitiveData(yaml);
break;
case "komodo":
output = generateKomodoToml(yaml);
break;
default:
output = "Unknown conversion type";
}
setConversionOutput(output);
setConversionDialogOpen(true);
} catch (error: any) {
setConversionOutput(`Error: ${error.message}`);
setConversionDialogOpen(true);
}
const result = createValidatedComposeOutput(type as ComposeOutputFormat, {
services,
networks,
volumes,
selectedIdx,
yaml,
vpn: vpnConfig,
});
setConversionValid(result.ok);
setConversionOutput(result.ok ? result.output : invalidOutputMessage(result));
setConversionDialogOpen(true);
},
[services, selectedIdx, yaml, vpnConfig]
[services, networks, volumes, selectedIdx, yaml, vpnConfig],
);
const closeDialog = useCallback(() => {
@@ -80,6 +64,7 @@ export function useConversionDialog({
setConversionDialogOpen,
conversionType,
conversionOutput,
conversionValid,
handleConversion,
closeDialog,
};
+59 -22
View File
@@ -1,21 +1,34 @@
import { useCallback, useState } from "react";
import {
useCallback,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import {
validateTemplateDetails,
validateTemplateMetaList,
type TemplateDetails,
type TemplateMeta,
} from "../utils/validation/templates";
import { formatValidationIssues } from "../utils/validation/issues";
import { sanitizeComposeVersion } from "../utils/compose-version";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Template = any;
type Template = TemplateMeta;
type SelectedTemplate = TemplateDetails | null;
export interface UseTemplateStoreReturn {
templateStoreOpen: boolean;
setTemplateStoreOpen: (open: boolean) => void;
templates: Template[];
setTemplates: React.Dispatch<React.SetStateAction<Template[]>>;
setTemplates: Dispatch<SetStateAction<Template[]>>;
templateLoading: boolean;
setTemplateLoading: (loading: boolean) => void;
templateError: string | null;
setTemplateError: (error: string | null) => void;
templateSearch: string;
setTemplateSearch: (search: string) => void;
selectedTemplate: Template;
setSelectedTemplate: (template: Template) => void;
selectedTemplate: SelectedTemplate;
setSelectedTemplate: (template: SelectedTemplate) => void;
templateDetailOpen: boolean;
setTemplateDetailOpen: (open: boolean) => void;
templateDetailTab: "overview" | "compose";
@@ -23,7 +36,7 @@ export interface UseTemplateStoreReturn {
templateCache: Template[];
templateCacheTimestamp: number | null;
fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise<void>;
fetchTemplateDetails: (templateId: string) => Promise<Template>;
fetchTemplateDetails: (templateId: string) => Promise<TemplateDetails>;
refreshTemplateStore: () => void;
}
@@ -40,9 +53,17 @@ function readCache(): { templates: Template[]; ts: number | null } {
try {
const t = localStorage.getItem(CACHE_KEY);
const ts = localStorage.getItem(CACHE_TS_KEY);
const parsed = t ? JSON.parse(t) : [];
const validation = validateTemplateMetaList(parsed);
if (!validation.ok || !validation.data) {
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CACHE_TS_KEY);
return { templates: [], ts: null };
}
const parsedTs = ts ? parseInt(ts, 10) : null;
return {
templates: t ? JSON.parse(t) : [],
ts: ts ? parseInt(ts, 10) : null,
templates: validation.data,
ts: Number.isFinite(parsedTs) ? parsedTs : null,
};
} catch {
return { templates: [], ts: null };
@@ -56,7 +77,7 @@ export function useTemplateStore(): UseTemplateStoreReturn {
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templateSearch, setTemplateSearch] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<Template>(null);
const [selectedTemplate, setSelectedTemplate] = useState<SelectedTemplate>(null);
const [templateDetailOpen, setTemplateDetailOpen] = useState(false);
const [templateDetailTab, setTemplateDetailTab] = useState<
"overview" | "compose"
@@ -78,13 +99,19 @@ export function useTemplateStore(): UseTemplateStoreReturn {
if (!metaResponse.ok) {
throw new Error(`Failed to fetch templates: ${metaResponse.statusText}`);
}
const templatesMeta: Template[] = await metaResponse.json();
setTemplates(templatesMeta);
setTemplateCache(templatesMeta);
const templatesMeta = await metaResponse.json();
const validation = validateTemplateMetaList(templatesMeta);
if (!validation.ok || !validation.data) {
throw new Error(
`Template metadata failed validation: ${formatValidationIssues(validation.issues)}`,
);
}
setTemplates(validation.data);
setTemplateCache(validation.data);
const now = Date.now();
setTemplateCacheTimestamp(now);
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(templatesMeta));
localStorage.setItem(CACHE_KEY, JSON.stringify(validation.data));
localStorage.setItem(CACHE_TS_KEY, String(now));
} catch {
// localStorage may be disabled; non-fatal.
@@ -93,7 +120,6 @@ export function useTemplateStore(): UseTemplateStoreReturn {
} catch (error: unknown) {
const msg =
error instanceof Error ? error.message : "Failed to load templates";
// eslint-disable-next-line no-console
console.error("Error fetching templates:", error);
if (!backgroundUpdate) {
setTemplateLoading(false);
@@ -104,7 +130,6 @@ export function useTemplateStore(): UseTemplateStoreReturn {
[],
);
// Open-the-store side effect handled in the setter — event-driven, not useEffect.
const setTemplateStoreOpen = useCallback(
(open: boolean) => {
setTemplateStoreOpenState(open);
@@ -118,7 +143,6 @@ export function useTemplateStore(): UseTemplateStoreReturn {
setTemplates(templateCache);
setTemplateLoading(false);
setTemplateError(null);
// Background refresh — fire and forget.
void fetchTemplatesFromGitHub(true);
return;
}
@@ -128,7 +152,7 @@ export function useTemplateStore(): UseTemplateStoreReturn {
);
const fetchTemplateDetails = useCallback(
async (templateId: string): Promise<Template> => {
async (templateId: string): Promise<TemplateDetails> => {
const template = templates.find((t) => t.id === templateId);
if (!template) throw new Error(`Template ${templateId} not found`);
@@ -141,13 +165,26 @@ export function useTemplateStore(): UseTemplateStoreReturn {
`Failed to fetch docker-compose.yml: ${composeResponse.statusText}`,
);
}
const composeContent = await composeResponse.text();
const rawComposeContent = await composeResponse.text();
const sanitizedCompose = sanitizeComposeVersion(rawComposeContent);
const composeContent = sanitizedCompose.content;
const logoUrl = template.logo
? `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/${basePath}/${template.logo}`
: null;
return { ...template, composeContent, logoUrl };
: undefined;
const details = {
...template,
composeContent,
composeVersionRemoved: sanitizedCompose.removed,
logoUrl,
};
const validation = validateTemplateDetails(details);
if (!validation.ok || !validation.data) {
throw new Error(
`Template details failed validation: ${formatValidationIssues(validation.issues)}`,
);
}
return validation.data;
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error(
`Error fetching template details for ${templateId}:`,
error,
File diff suppressed because it is too large Load Diff
+91 -41
View File
@@ -5,21 +5,24 @@ import { Trash2 } from "lucide-react";
import { SetupSidebar, type BuilderView } from "../components/SetupSidebar";
import { Field } from "../components/compose-builder/ServiceForm/Field";
import {
validateConfigItem,
validateCustomYaml,
validateHomepageConfig,
validateHomepageOutput,
type ConfigItem,
type ConfigItemInput,
} from "../utils/validation/config";
import {
formatValidationIssues,
type ValidationIssue,
} from "../utils/validation/issues";
export const Route = createFileRoute("/config-builder")({
component: ConfigBuilderRoute,
});
interface ConfigItem {
name: string;
description: string;
icon: string;
url: string;
category?: string;
tags?: string[];
}
interface HomepageConfig {
interface HomepageConfigState {
items: ConfigItem[];
}
@@ -28,18 +31,17 @@ type ConfigType = "homepage" | "custom";
function ConfigBuilderRoute() {
const navigate = useNavigate();
const [configType, setConfigType] = useState<ConfigType>("homepage");
const [config, setConfig] = useState<HomepageConfig>({ items: [] });
const [config, setConfig] = useState<HomepageConfigState>({ items: [] });
const [customOutput, setCustomOutput] = useState<string>("");
const [currentItem, setCurrentItem] = useState<ConfigItem>(blankItem());
const [currentItem, setCurrentItem] = useState<ConfigItemInput>(blankItem());
const generateHomepageConfig = useCallback(
(items: ConfigItem[]): string => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = {
const obj = {
services: items.map((item) => ({
Name: item.name,
Description: item.description,
Icon: item.icon,
...(item.description && { Description: item.description }),
...(item.icon && { Icon: item.icon }),
URL: item.url,
...(item.category && { Category: item.category }),
...(item.tags && item.tags.length > 0 && { Tags: item.tags }),
@@ -50,14 +52,30 @@ function ConfigBuilderRoute() {
[],
);
const currentItemValidation = useMemo(
() => validateConfigItem(currentItem),
[currentItem],
);
const outputValidation = useMemo(() => {
if (configType === "custom") return validateCustomYaml(customOutput);
const configResult = validateHomepageConfig(config);
if (!configResult.ok) return configResult;
return validateHomepageOutput(generateHomepageConfig(config.items));
}, [configType, customOutput, config, generateHomepageConfig]);
const output = useMemo(() => {
if (!outputValidation.ok) {
return invalidBuilderOutput(outputValidation.issues);
}
if (configType === "homepage") return generateHomepageConfig(config.items);
return customOutput;
}, [configType, config.items, customOutput, generateHomepageConfig]);
}, [configType, config.items, customOutput, generateHomepageConfig, outputValidation]);
const addItem = useCallback(() => {
if (!currentItem.name || !currentItem.url) return;
setConfig({ items: [...config.items, { ...currentItem }] });
const validation = validateConfigItem(currentItem);
if (!validation.ok || !validation.data) return;
setConfig({ items: [...config.items, validation.data] });
setCurrentItem(blankItem());
}, [currentItem, config.items]);
@@ -74,11 +92,19 @@ function ConfigBuilderRoute() {
else if (view === "blueprint") navigate({ to: "/blueprint-builder" });
};
const fieldError = (path: string) =>
currentItemValidation.issues.find((issue) => issue.path === path)?.message ??
null;
const customYamlError =
configType === "custom" && !outputValidation.ok
? formatValidationIssues(outputValidation.issues)
: null;
return (
<div className="builder-shell builder-shell--three">
<SetupSidebar view="config" onNav={handleNav} />
{/* Configuration column — type picker + item form */}
<section className="config-col">
<div className="col-head">
<h2 className="col-head-title">
@@ -124,7 +150,7 @@ function ConfigBuilderRoute() {
{config.items.length > 0 && (
<div className="service-list">
{config.items.map((item, idx) => (
<div className="service-row" key={idx}>
<div className="service-row" key={`${item.name}-${idx}`}>
<span className="status-dot ok" aria-hidden />
<span className="service-row-text">
<span className="service-row-name">{item.name}</span>
@@ -147,15 +173,14 @@ function ConfigBuilderRoute() {
) : (
<div className="tab-content">
<p className="tab-hint">
Paste any YAML or text-based config and the preview will mirror
it exactly.
Paste YAML or text-based config. YAML syntax is validated before
the output can be copied.
</p>
</div>
)}
</div>
</section>
{/* Item / textarea editor */}
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
@@ -166,71 +191,80 @@ function ConfigBuilderRoute() {
{configType === "homepage" ? (
<div className="svc-tab">
<Field label="Name" required>
<Field label="Name" required error={fieldError("name")}>
<input
className="input"
value={currentItem.name}
onChange={(e) =>
setCurrentItem({ ...currentItem, name: e.target.value })
}
aria-invalid={fieldError("name") ? true : undefined}
placeholder="Plex"
/>
</Field>
<Field label="Description">
<Field label="Description" error={fieldError("description")}>
<input
className="input"
value={currentItem.description}
value={currentItem.description || ""}
onChange={(e) =>
setCurrentItem({
...currentItem,
description: e.target.value,
})
}
aria-invalid={fieldError("description") ? true : undefined}
placeholder="Media server"
/>
</Field>
<div className="svc-grid-2">
<Field label="Icon">
<Field label="Icon" error={fieldError("icon")}>
<input
className="input"
value={currentItem.icon}
value={currentItem.icon || ""}
onChange={(e) =>
setCurrentItem({ ...currentItem, icon: e.target.value })
}
aria-invalid={fieldError("icon") ? true : undefined}
placeholder="plex.png"
/>
</Field>
<Field label="URL" required>
<Field label="URL" required error={fieldError("url")}>
<input
className="input"
value={currentItem.url}
onChange={(e) =>
setCurrentItem({ ...currentItem, url: e.target.value })
}
aria-invalid={fieldError("url") ? true : undefined}
placeholder="https://plex.local"
/>
</Field>
</div>
<Field label="Category">
<Field label="Category" error={fieldError("category")}>
<input
className="input"
value={currentItem.category || ""}
onChange={(e) =>
setCurrentItem({ ...currentItem, category: e.target.value })
}
aria-invalid={fieldError("category") ? true : undefined}
placeholder="Media"
/>
</Field>
<Field label="Tags" hint="Comma-separated">
<Field label="Tags" hint="Comma-separated" error={fieldError("tags")}>
<input
className="input"
value={currentItem.tags?.join(", ") || ""}
onChange={(e) =>
setCurrentItem({
...currentItem,
tags: e.target.value.split(",").map((t) => t.trim()),
tags: e.target.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
})
}
aria-invalid={fieldError("tags") ? true : undefined}
placeholder="streaming, video"
/>
</Field>
@@ -238,7 +272,7 @@ function ConfigBuilderRoute() {
type="button"
className="btn btn-primary btn-block"
onClick={addItem}
disabled={!currentItem.name || !currentItem.url}
disabled={!currentItemValidation.ok}
>
+ Add Item
</button>
@@ -247,13 +281,15 @@ function ConfigBuilderRoute() {
<div className="svc-tab">
<Field
label="Custom YAML"
hint="Anything goes — the preview shows it verbatim."
hint="The preview mirrors valid YAML exactly."
error={customYamlError}
>
<textarea
className="input"
rows={20}
value={customOutput}
onChange={(e) => setCustomOutput(e.target.value)}
aria-invalid={customYamlError ? true : undefined}
placeholder="key: value"
/>
</Field>
@@ -261,7 +297,6 @@ function ConfigBuilderRoute() {
)}
</section>
{/* Output column */}
<section className="code-col">
<div className="code-head">
<h2 className="code-title">
@@ -276,12 +311,17 @@ function ConfigBuilderRoute() {
</div>
<div className="code-window">
<pre className="code-pre">
<code>{output || "# Output will appear here…"}</code>
<code>{output || "# Output will appear here..."}</code>
</pre>
<div className="code-statusbar">
<span className="status-tag">
<span className="status-dot ok" aria-hidden />
{output ? "OK" : "Empty"}
<span
className={"status-tag" + (!outputValidation.ok ? " err" : "")}
>
<span
className={"status-dot " + (outputValidation.ok ? "ok" : "err")}
aria-hidden
/>
{outputValidation.ok ? "Valid" : "Blocked"}
</span>
<span className="status-mono">
{output.split("\n").length} lines · UTF-8 · LF
@@ -296,7 +336,17 @@ function ConfigBuilderRoute() {
);
}
function blankItem(): ConfigItem {
function invalidBuilderOutput(issues: ValidationIssue[]): string {
return [
"# Output blocked by validation",
...issues.map((issue) => {
const label = issue.path ? `${issue.path}: ` : "";
return `# ${label}${issue.message}`;
}),
].join("\n");
}
function blankItem(): ConfigItemInput {
return {
name: "",
description: "",
+42 -27
View File
@@ -11,6 +11,7 @@ import { VolumeForm } from "../../components/compose-builder/VolumeForm";
import { TemplateStoreModal } from "../../components/templates/TemplateStoreModal";
import { TemplateDetailModal } from "../../components/templates/TemplateDetailModal";
import { ConversionDialog } from "../../components/ConversionDialog";
import { useToast } from "../../components/ui/toast";
import { useSelectionState } from "../../hooks/useSelectionState";
import { useVpnConfig } from "../../hooks/useVpnConfig";
@@ -58,6 +59,7 @@ type Any = any;
function ComposeBuilderRoute() {
const navigate = useNavigate();
const { toast } = useToast();
const [services, setServices] = useState<ServiceConfig[]>([defaultService()]);
const {
@@ -112,6 +114,8 @@ function ComposeBuilderRoute() {
const conversionDialog = useConversionDialog({
services,
networks,
volumes,
selectedIdx,
yaml,
vpnConfig,
@@ -407,22 +411,17 @@ function ComposeBuilderRoute() {
}
function removeService(idx: number) {
const nextLength = Math.max(services.length - 1, 0);
setServices((prev) => {
if (idx < 0 || idx >= prev.length) return prev;
const next = [...prev];
next.splice(idx, 1);
// Invariant: always keep at least one service so the ServiceForm has
// something to render. Removing the last row replaces it with a fresh
// unnamed default.
if (next.length === 0) next.push(defaultService());
return next;
});
if (selectedType === "service" && selectedIdx !== null) {
if (selectedIdx === idx) {
// Fall back to the previous row, or 0 when the deleted row was
// replaced by a fresh default.
selectService(Math.max(0, idx - 1));
selectService(nextLength > 0 ? Math.min(idx, nextLength - 1) : null);
} else if (selectedIdx > idx) {
selectService(selectedIdx - 1);
}
@@ -431,29 +430,36 @@ function ComposeBuilderRoute() {
// ---------------- template import ----------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function importComposeService(svc: any, allNetworks: any, allVolumes: any) {
const parsed = parseComposeService(svc, allNetworks, allVolumes);
const filtered = services.filter((s) => s.name && s.name.trim() !== "");
const idx = filtered.length;
setServices((prev) => {
const f = prev.filter((s) => s.name && s.name.trim() !== "");
return [...f, parsed.service];
});
if (parsed.networks.length > 0) bulkAddNetworks(parsed.networks);
if (parsed.volumes.length > 0) bulkAddVolumes(parsed.volumes);
selectService(idx);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function importTemplate(template: any) {
try {
const parsed = parseComposeTemplate(template.composeContent);
for (const svc of parsed.services) {
importComposeService(svc, parsed.networks, parsed.volumes);
}
const parsedServices = parsed.services.map((svc) =>
parseComposeService(svc, parsed.networks, parsed.volumes),
);
const importedServices = parsedServices.map((item) => item.service);
const firstImportedIdx = services.filter(
(service) => service.name && service.name.trim() !== "",
).length;
setServices((prev) => {
const existing = prev.filter(
(service) => service.name && service.name.trim() !== "",
);
const next = [...existing, ...importedServices];
return next.length > 0 ? next : [defaultService()];
});
const importedNetworks = parsedServices.flatMap((item) => item.networks);
const importedVolumes = parsedServices.flatMap((item) => item.volumes);
if (importedNetworks.length > 0) bulkAddNetworks(importedNetworks);
if (importedVolumes.length > 0) bulkAddVolumes(importedVolumes);
selectService(importedServices.length > 0 ? firstImportedIdx : null);
templateStore.setTemplateDetailOpen(false);
templateStore.setTemplateStoreOpen(false);
templateStore.setSelectedTemplate(null);
templateStore.setTemplateDetailTab("overview");
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
@@ -467,10 +473,10 @@ function ComposeBuilderRoute() {
const currentService =
typeof selectedIdx === "number" && services[selectedIdx]
? services[selectedIdx]
: services[0];
: null;
const formApi: ServiceFormApi = {
service: currentService,
service: currentService ?? defaultService(),
restartOptions: RESTART_OPTIONS,
updateServiceField,
updatePortField,
@@ -526,7 +532,7 @@ function ComposeBuilderRoute() {
}
};
const showServiceForm = selectedType === "service";
const showServiceForm = selectedType === "service" && currentService !== null;
const showNetworkForm =
selectedType === "network" &&
selectedNetworkIdx !== null &&
@@ -653,6 +659,14 @@ function ComposeBuilderRoute() {
);
templateStore.setSelectedTemplate(details);
templateStore.setTemplateDetailOpen(true);
if (details.composeVersionRemoved) {
toast({
title: "Obsolete Compose version removed",
description: "The top-level version field is obsolete in Docker Compose V2 and was removed from this template.",
variant: "warning",
duration: 4500,
});
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
@@ -681,6 +695,7 @@ function ComposeBuilderRoute() {
onOpenChange={conversionDialog.setConversionDialogOpen}
conversionType={conversionDialog.conversionType || ""}
conversionOutput={conversionDialog.conversionOutput}
conversionValid={conversionDialog.conversionValid}
/>
</div>
);
+88 -35
View File
@@ -3,28 +3,26 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { SetupSidebar, type BuilderView } from "../components/SetupSidebar";
import { Field } from "../components/compose-builder/ServiceForm/Field";
import {
validateScheduleConfig,
validateSchedulerOutput,
type ScheduleConfig,
type ScheduleConfigInput,
type ScheduleType,
} from "../utils/validation/scheduler";
import {
formatValidationIssues,
type ValidationIssue,
} from "../utils/validation/issues";
export const Route = createFileRoute("/scheduler-builder")({
component: SchedulerBuilderRoute,
});
type ScheduleType = "cron" | "github-actions" | "systemd-timer";
interface ScheduleConfig {
type: ScheduleType;
name: string;
schedule: string;
command: string;
description?: string;
enabled?: boolean;
user?: string;
workingDir?: string;
}
function SchedulerBuilderRoute() {
const navigate = useNavigate();
const [scheduleType, setScheduleType] = useState<ScheduleType>("cron");
const [config, setConfig] = useState<ScheduleConfig>({
const [config, setConfig] = useState<ScheduleConfigInput>({
type: "cron",
name: "",
schedule: "",
@@ -34,7 +32,7 @@ function SchedulerBuilderRoute() {
user: "",
workingDir: "",
});
const effectiveConfig: ScheduleConfig = { ...config, type: scheduleType };
const effectiveConfig: ScheduleConfigInput = { ...config, type: scheduleType };
const generateCron = useCallback((cfg: ScheduleConfig): string => {
const user = cfg.user || "root";
@@ -56,7 +54,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: ${cfg.description || "Run command"}
run: ${cfg.command}
run: |
${cfg.command}
`;
}, []);
@@ -95,7 +94,7 @@ After=network.target
[Service]
Type=oneshot
${cfg.user ? `User=${cfg.user}\n` : ""}${cfg.workingDir ? `WorkingDirectory=${cfg.workingDir}\n` : ""}ExecStart=/bin/bash -c '${cfg.command}'
${cfg.user ? `User=${cfg.user}\n` : ""}${cfg.workingDir ? `WorkingDirectory=${cfg.workingDir}\n` : ""}ExecStart=/bin/bash -c '${cfg.command.replace(/'/g, "'\\''")}'
`;
return `# ${timerName}
@@ -106,25 +105,40 @@ ${serviceUnit}
`;
}, []);
const output = useMemo(() => {
const configValidation = useMemo(
() => validateScheduleConfig(effectiveConfig),
[effectiveConfig],
);
const generatedOutput = useMemo(() => {
if (!configValidation.ok || !configValidation.data) return "";
switch (scheduleType) {
case "cron":
return generateCron(effectiveConfig);
return generateCron(configValidation.data);
case "github-actions":
return generateGitHubActions(effectiveConfig);
return generateGitHubActions(configValidation.data);
case "systemd-timer":
return generateSystemdTimer(effectiveConfig);
return generateSystemdTimer(configValidation.data);
default:
return "";
}
}, [
scheduleType,
effectiveConfig,
configValidation,
generateCron,
generateGitHubActions,
generateSystemdTimer,
]);
const outputValidation = useMemo(() => {
if (!configValidation.ok) return configValidation;
return validateSchedulerOutput(scheduleType, generatedOutput);
}, [configValidation, generatedOutput, scheduleType]);
const output = outputValidation.ok
? generatedOutput
: invalidBuilderOutput(outputValidation.issues);
const filename = useMemo(() => {
switch (scheduleType) {
case "cron":
@@ -144,6 +158,23 @@ ${serviceUnit}
else if (view === "blueprint") navigate({ to: "/blueprint-builder" });
};
const handleScheduleTypeChange = (next: ScheduleType) => {
setScheduleType(next);
setConfig((prev) => ({
...prev,
type: next,
...(next === "github-actions" ? { user: "", workingDir: "" } : {}),
}));
};
const fieldError = (path: string) =>
configValidation.issues.find((issue) => issue.path === path)?.message ??
null;
const outputError = !outputValidation.ok
? formatValidationIssues(outputValidation.issues)
: null;
return (
<div className="builder-shell builder-shell--three">
<SetupSidebar view="scheduler" onNav={handleNav} />
@@ -162,7 +193,7 @@ ${serviceUnit}
className={
"config-tab" + (scheduleType === "cron" ? " active" : "")
}
onClick={() => setScheduleType("cron")}
onClick={() => handleScheduleTypeChange("cron")}
>
Cron
</button>
@@ -172,7 +203,7 @@ ${serviceUnit}
"config-tab" +
(scheduleType === "github-actions" ? " active" : "")
}
onClick={() => setScheduleType("github-actions")}
onClick={() => handleScheduleTypeChange("github-actions")}
>
GitHub Actions
</button>
@@ -182,7 +213,7 @@ ${serviceUnit}
"config-tab" +
(scheduleType === "systemd-timer" ? " active" : "")
}
onClick={() => setScheduleType("systemd-timer")}
onClick={() => handleScheduleTypeChange("systemd-timer")}
>
systemd
</button>
@@ -208,7 +239,6 @@ ${serviceUnit}
</div>
</section>
{/* Form column */}
<section className="service-col">
<div className="col-head">
<h2 className="col-head-title">
@@ -217,19 +247,21 @@ ${serviceUnit}
</h2>
</div>
<div className="svc-tab">
<Field label="Name" required>
<Field label="Name" required error={fieldError("name")}>
<input
className="input"
value={config.name}
onChange={(e) => setConfig({ ...config, name: e.target.value })}
aria-invalid={fieldError("name") ? true : undefined}
placeholder="nightly-backup"
/>
</Field>
<Field
label="Schedule"
hint='Cron format: "minute hour day month weekday" — e.g. "0 0 * * *"'
hint='Cron format: "minute hour day month weekday" - e.g. "0 0 * * *"'
required
error={fieldError("schedule")}
>
<input
className="input"
@@ -237,11 +269,12 @@ ${serviceUnit}
onChange={(e) =>
setConfig({ ...config, schedule: e.target.value })
}
aria-invalid={fieldError("schedule") ? true : undefined}
placeholder="0 0 * * *"
/>
</Field>
<Field label="Command" required>
<Field label="Command" required error={fieldError("command")}>
<textarea
className="input"
rows={4}
@@ -249,40 +282,44 @@ ${serviceUnit}
onChange={(e) =>
setConfig({ ...config, command: e.target.value })
}
aria-invalid={fieldError("command") ? true : undefined}
placeholder="/usr/local/bin/backup.sh"
/>
</Field>
<Field label="Description">
<Field label="Description" error={fieldError("description")}>
<input
className="input"
value={config.description || ""}
onChange={(e) =>
setConfig({ ...config, description: e.target.value })
}
aria-invalid={fieldError("description") ? true : undefined}
placeholder="What does this task do?"
/>
</Field>
{(scheduleType === "cron" || scheduleType === "systemd-timer") && (
<div className="svc-grid-2">
<Field label="User">
<Field label="User" error={fieldError("user")}>
<input
className="input"
value={config.user || ""}
onChange={(e) =>
setConfig({ ...config, user: e.target.value })
}
aria-invalid={fieldError("user") ? true : undefined}
placeholder="root"
/>
</Field>
<Field label="Working directory">
<Field label="Working directory" error={fieldError("workingDir")}>
<input
className="input"
value={config.workingDir || ""}
onChange={(e) =>
setConfig({ ...config, workingDir: e.target.value })
}
aria-invalid={fieldError("workingDir") ? true : undefined}
placeholder="/srv/app"
/>
</Field>
@@ -308,9 +345,15 @@ ${serviceUnit}
<code>{output || "# Fill the form to generate output."}</code>
</pre>
<div className="code-statusbar">
<span className="status-tag">
<span className="status-dot ok" aria-hidden />
Ready
<span
className={"status-tag" + (outputError ? " err" : "")}
aria-live="polite"
>
<span
className={"status-dot " + (outputError ? "err" : "ok")}
aria-hidden
/>
{outputError || "Valid"}
</span>
<span className="status-mono">
{output.split("\n").length} lines · UTF-8 · LF
@@ -322,3 +365,13 @@ ${serviceUnit}
</div>
);
}
function invalidBuilderOutput(issues: ValidationIssue[]): string {
return [
"# Output blocked by validation",
...issues.map((issue) => {
const label = issue.path ? `${issue.path}: ` : "";
return `# ${label}${issue.message}`;
}),
].join("\n");
}
+110 -6
View File
@@ -3,14 +3,71 @@
// labels + a sibling .env.example. Each Resource targets a service key in the
// underlying compose document and emits labels under a `blueprintName` slug.
export type BlueprintProtocol = "http" | "https" | "tcp" | "udp";
export type BlueprintProtocol = "http" | "tcp" | "udp";
export type BlueprintTargetMethod = "http" | "https" | "h2c";
export type BlueprintPathMatch = "exact" | "prefix" | "regex";
export type BlueprintRewriteMatch = BlueprintPathMatch | "stripPrefix";
export type BlueprintRuleAction = "allow" | "deny" | "pass";
export type BlueprintRuleMatch =
| "cidr"
| "path"
| "ip"
| "country"
| "asn"
| "region";
export type BlueprintPrivateMode = "host" | "cidr" | "http";
export type BlueprintPrivateScheme = "http" | "https";
export type BlueprintMaintenanceType = "forced" | "automatic";
export interface BlueprintHeader {
name: string;
value: string;
}
export interface BlueprintRule {
action: BlueprintRuleAction;
match: BlueprintRuleMatch;
value: string;
priority?: number;
}
export interface BlueprintHealthcheck {
enabled?: boolean;
hostname: string;
port?: number;
path: string;
scheme?: BlueprintPrivateScheme;
mode?: string;
interval?: number;
unhealthyInterval?: number;
timeout?: number;
headers: BlueprintHeader[];
followRedirects?: boolean;
method?: string;
status?: number;
}
export interface BlueprintMaintenance {
enabled: boolean;
type: BlueprintMaintenanceType;
title: string;
message: string;
estimatedTime: string;
}
export interface BlueprintTarget {
method: BlueprintProtocol;
// Optional override — Pangolin defaults to the container_name.
site?: string;
method?: BlueprintTargetMethod;
hostname?: string;
// Optional override — Pangolin defaults to the expose port.
port?: number;
enabled?: boolean;
internalPort?: number;
path?: string;
pathMatch?: BlueprintPathMatch;
rewritePath?: string;
rewriteMatch?: BlueprintRewriteMatch;
priority?: number;
healthcheck?: BlueprintHealthcheck;
}
export interface BlueprintAuth {
@@ -35,24 +92,71 @@ export interface BlueprintResource {
subdomain: string;
// Internal port Pangolin should reach.
servicePort: number;
// Public proxy port for raw TCP/UDP public resources.
proxyPort?: number;
// Image kept verbatim from the imported compose, used in the .env.example.
image: string;
// Protocol used by the auto-derived target[0] (and the resource label).
// Pangolin public resource protocol.
protocol: BlueprintProtocol;
// Optional explicit target[0] hostname/port overrides.
enabled: boolean;
ssl: boolean;
hostHeader: string;
tlsServerName: string;
headers: BlueprintHeader[];
rules: BlueprintRule[];
maintenance: BlueprintMaintenance;
targetSite?: string;
targetMethod: BlueprintTargetMethod;
targetHostname?: string;
targetPort?: number;
targetEnabled?: boolean;
targetInternalPort?: number;
targetPath?: string;
targetPathMatch?: BlueprintPathMatch;
targetRewritePath?: string;
targetRewriteMatch?: BlueprintRewriteMatch;
targetPriority?: number;
targetHealthcheck?: BlueprintHealthcheck;
// Additional targets beyond targets[0].
extraTargets: BlueprintTarget[];
// Resource-level auth additions/overrides.
auth: BlueprintAuth;
}
export interface BlueprintPrivateResource {
serviceContainerName: string;
key: string;
name: string;
mode: BlueprintPrivateMode;
sites: string[];
destination: string;
destinationPort?: number;
fullDomain: string;
ssl: boolean;
scheme: BlueprintPrivateScheme;
tcpPorts: string;
udpPorts: string;
disableIcmp: boolean;
alias: string;
roles: string[];
users: string[];
machines: string[];
}
export interface BlueprintSite {
serviceContainerName: string;
key: string;
name: string;
dockerSocketEnabled: boolean;
}
export interface Blueprint {
version: 1;
baseDomain: string;
pangolinNetwork: string; // default "pangolin_default"
resources: BlueprintResource[];
privateResources: BlueprintPrivateResource[];
sites: BlueprintSite[];
// Raw compose document parsed from the imported template — preserved so
// we re-emit it verbatim with labels injected. null = empty / no import.
composeDocument: Record<string, unknown> | null;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
hasObsoleteComposeVersion,
sanitizeComposeVersion,
} from "../compose-version";
describe("sanitizeComposeVersion", () => {
it("removes top-level quoted version lines", () => {
const result = sanitizeComposeVersion(`version: "3.8"
services:
web:
image: nginx
`);
expect(result.removed).toBe(true);
expect(result.content).toBe(`services:
web:
image: nginx
`);
});
it("removes top-level unquoted version lines with comments", () => {
const result = sanitizeComposeVersion(`version: 3.9 # obsolete
services:
web:
image: nginx
`);
expect(result.removed).toBe(true);
expect(result.content).not.toMatch(/^version:/m);
});
it("does not remove nested service fields named version", () => {
const input = `services:
app:
image: example/app
environment:
version: "3.8"
`;
expect(hasObsoleteComposeVersion(input)).toBe(false);
expect(sanitizeComposeVersion(input)).toEqual({
content: input,
removed: false,
});
});
});
@@ -2,6 +2,18 @@ import { describe, expect, it } from "vitest";
import { parseComposeService, parseComposeTemplate } from "../template-import";
describe("parseComposeTemplate", () => {
it("sanitizes obsolete top-level compose version during import", () => {
const yaml = `version: "3.8"
services:
web:
image: nginx:latest
`;
const result = parseComposeTemplate(yaml);
expect(result.services).toHaveLength(1);
expect(result.services[0].name).toBe("web");
});
it("parses a single-service compose", () => {
const yaml = `
services:
+136 -28
View File
@@ -2,14 +2,16 @@ import { describe, expect, it } from "vitest";
import jsyaml from "js-yaml";
import {
defaultBlueprint,
defaultPrivateResource,
defaultResource,
defaultSite,
fromCompose,
toComposeYaml,
toEnvExample,
} from "../generator";
describe("fromCompose", () => {
it("creates one resource per service with exposed ports", () => {
it("creates one resource per compose service", () => {
const compose = `version: "3.8"
services:
web:
@@ -20,23 +22,41 @@ services:
image: postgres:16
`;
const bp = fromCompose(compose, "example.com");
expect(bp.resources.length).toBe(1);
expect(bp.resources.length).toBe(2);
expect(bp.resources[0].serviceContainerName).toBe("web");
expect(bp.resources[0].blueprintName).toBe("web");
expect(bp.resources[0].subdomain).toBe("web");
expect(bp.resources[0].servicePort).toBe(8080);
expect(bp.resources[0].protocol).toBe("http");
expect(bp.resources[0].targetMethod).toBe("http");
expect(bp.resources[0].image).toBe("nginx:latest");
expect(bp.resources[1].serviceContainerName).toBe("db");
expect(bp.resources[1].servicePort).toBe(80);
expect(bp.resources[1].image).toBe("postgres:16");
});
it("uses https for port 443 / 8443", () => {
it("keeps uniquified blueprint names and subdomains aligned", () => {
const bp = fromCompose(`services:
app:
image: nginx
app_:
image: nginx
`);
expect(bp.resources[0].blueprintName).toBe("app");
expect(bp.resources[0].subdomain).toBe("app");
expect(bp.resources[1].blueprintName).toBe("app-2");
expect(bp.resources[1].subdomain).toBe("app-2");
});
it("uses an https target method for port 443 / 8443", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "443:443"
`);
expect(bp.resources[0].protocol).toBe("https");
expect(bp.resources[0].protocol).toBe("http");
expect(bp.resources[0].targetMethod).toBe("https");
});
it("keeps the raw compose document for round-trip", () => {
@@ -74,16 +94,58 @@ describe("toComposeYaml", () => {
);
const yaml = toComposeYaml(bp);
expect(yaml).toContain("pangolin.public-resources.web.name=Web");
expect(yaml).toContain(
"pangolin.public-resources.web.full-domain=web.example.com",
);
expect(yaml).toContain("pangolin.public-resources.web.full-domain=web.example.com");
expect(yaml).toContain("pangolin.public-resources.web.protocol=http");
expect(yaml).toContain(
"pangolin.public-resources.web.targets[0].method=http",
);
expect(yaml).toContain("pangolin.public-resources.web.targets[0].method=http");
});
it("adds the external pangolin network block", () => {
it("leaves disabled compose services unlabelled", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
worker:
image: busybox
`);
bp.resources = bp.resources.filter((r) => r.serviceContainerName !== "worker");
const yaml = toComposeYaml(bp);
const parsed = jsyaml.load(yaml) as Record<string, unknown>;
const services = parsed.services as Record<string, Record<string, unknown>>;
expect(services.worker).toBeTruthy();
expect(services.worker.labels).toBeUndefined();
expect(String(yaml)).not.toContain("pangolin.public-resources.worker");
});
it("removes Pangolin labels from resources disabled after re-import", () => {
const bp = fromCompose(
`services:
web:
image: nginx
ports:
- "80"
worker:
image: busybox
ports:
- "8080"
`,
"example.com",
);
const yaml = toComposeYaml(bp);
expect(yaml).toContain("pangolin.public-resources.web");
expect(yaml).toContain("pangolin.public-resources.worker");
const bp2 = fromCompose(yaml, "example.com");
bp2.resources = bp2.resources.filter(
(resource) => resource.serviceContainerName !== "worker",
);
const finalYaml = toComposeYaml(bp2);
expect(finalYaml).toContain("pangolin.public-resources.web");
expect(finalYaml).not.toContain("pangolin.public-resources.worker");
});
it("adds the external pangolin network block with a literal name", () => {
const bp = fromCompose(
`services:
web:
@@ -93,14 +155,14 @@ describe("toComposeYaml", () => {
`,
"example.com",
);
bp.pangolinNetwork = "custom_pangolin";
const yaml = toComposeYaml(bp);
const parsed = jsyaml.load(yaml) as Record<string, unknown>;
const networks = parsed.networks as Record<string, unknown>;
expect(networks).toBeTruthy();
expect(networks.pangolin).toBeTruthy();
const pangolin = networks.pangolin as Record<string, unknown>;
expect(pangolin.external).toBe(true);
expect(String(pangolin.name)).toContain("PANGOLIN_DOCKER_NETWORK");
expect(pangolin.name).toBe("custom_pangolin");
expect(String(pangolin.name)).not.toContain("PANGOLIN_DOCKER_NETWORK");
});
it("adds the pangolin network to the service's networks list", () => {
@@ -132,9 +194,7 @@ describe("toComposeYaml", () => {
bp.resources[0].targetHostname = "my-container";
bp.resources[0].targetPort = 9000;
yaml = toComposeYaml(bp);
expect(yaml).toContain(
"pangolin.public-resources.s.targets[0].hostname=my-container",
);
expect(yaml).toContain("pangolin.public-resources.s.targets[0].hostname=my-container");
expect(yaml).toContain("pangolin.public-resources.s.targets[0].port=9000");
});
@@ -151,30 +211,73 @@ describe("toComposeYaml", () => {
bp.resources[0].auth.ssoEnabled = true;
bp.resources[0].auth.ssoRoles = ["admin"];
bp.resources[0].auth.basicUser = "${RESOURCE_AUTH_BASIC_USER}";
bp.resources[0].auth.basicPassword = "${RESOURCE_AUTH_BASIC_PASSWORD}";
bp.resources[0].auth.pincode = "${RESOURCE_AUTH_PINCODE}";
yaml = toComposeYaml(bp);
expect(yaml).toContain("auth.sso-enabled=true");
expect(yaml).toContain("auth.sso-roles[0]=admin");
expect(yaml).toContain(
"auth.pincode=${RESOURCE_AUTH_PINCODE}",
);
expect(yaml).toContain("auth.basic-auth.user=${RESOURCE_AUTH_BASIC_USER}");
expect(yaml).toContain("auth.basic-auth.password=${RESOURCE_AUTH_BASIC_PASSWORD}");
expect(yaml).toContain("auth.pincode=${RESOURCE_AUTH_PINCODE}");
});
it("emits extra targets", () => {
it("emits extra HTTP targets", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "80"
`);
bp.resources[0].extraTargets.push({ method: "tcp", port: 5432 });
bp.resources[0].extraTargets.push({ method: "https", port: 5432 });
const yaml = toComposeYaml(bp);
expect(yaml).toContain(
"pangolin.public-resources.s.targets[1].method=tcp",
);
expect(yaml).toContain("pangolin.public-resources.s.targets[1].method=https");
expect(yaml).toContain("pangolin.public-resources.s.targets[1].port=5432");
});
it("emits TCP/UDP proxy-port without target method or auth", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "25565"
`);
bp.resources[0].protocol = "tcp";
bp.resources[0].proxyPort = 25565;
bp.resources[0].auth.pincode = "1234";
const yaml = toComposeYaml(bp);
expect(yaml).toContain("pangolin.public-resources.s.protocol=tcp");
expect(yaml).toContain("pangolin.public-resources.s.proxy-port=25565");
expect(yaml).not.toContain("pangolin.public-resources.s.targets[0].method");
expect(yaml).not.toContain("auth.pincode");
});
it("emits private resource and site label namespaces", () => {
const bp = fromCompose(`services:
s:
image: x
ports:
- "80"
`);
bp.sites.push({ ...defaultSite("s"), key: "main", name: "Main", dockerSocketEnabled: true });
bp.privateResources.push({
...defaultPrivateResource("s"),
key: "internal",
name: "Internal",
mode: "http",
destination: "internal.local",
destinationPort: 80,
fullDomain: "internal.example.com",
alias: "internal",
sites: ["main"],
});
const yaml = toComposeYaml(bp);
expect(yaml).toContain("pangolin.sites.main.name=Main");
expect(yaml).toContain("pangolin.sites.main.docker-socket-enabled=true");
expect(yaml).toContain("pangolin.private-resources.internal.name=Internal");
expect(yaml).toContain("pangolin.private-resources.internal.sites[0]=main");
});
it("preserves existing labels on the service", () => {
const bp = fromCompose(`services:
s:
@@ -191,7 +294,7 @@ describe("toComposeYaml", () => {
});
describe("toEnvExample", () => {
it("includes the six required keys", () => {
it("includes shared and resource keys", () => {
const bp = fromCompose(`services:
web:
image: nginx:latest
@@ -199,12 +302,16 @@ describe("toEnvExample", () => {
- "8080:80"
`);
const env = toEnvExample(bp);
expect(env).toContain("BASE_DOMAIN=CHANGE_ME");
expect(env).toContain("PANGOLIN_DOCKER_NETWORK=pangolin_default");
expect(env).toContain("GLOBAL_AUTH_SSO_ENABLED=true");
expect(env).toContain("BLUEPRINT_NAME=web");
expect(env).toContain("RESOURCE_NAME=Web");
expect(env).toContain("SERVICE_SUBDOMAIN=web");
expect(env).toContain("SERVICE_CONTAINER_NAME=web");
expect(env).toContain("SERVICE_PORT=8080");
expect(env).toContain("APP_IMAGE=nginx:latest");
expect(env).toContain("# RESOURCE_AUTH_BASIC_PASSWORD=");
});
it("emits per-resource blocks for multi-service compose", () => {
@@ -219,8 +326,8 @@ describe("toEnvExample", () => {
- "3000"
`);
const env = toEnvExample(bp);
expect(env).toMatch(/─── web ───/);
expect(env).toMatch(/─── api ───/);
expect(env).toMatch(/--- web ---/);
expect(env).toMatch(/--- api ---/);
});
it("returns a placeholder when no resources are present", () => {
@@ -233,6 +340,7 @@ describe("defaults", () => {
it("defaultResource sensible values", () => {
const r = defaultResource();
expect(r.protocol).toBe("http");
expect(r.targetMethod).toBe("http");
expect(r.servicePort).toBe(80);
expect(r.auth.ssoEnabled).toBe(false);
});
+28 -7
View File
@@ -1,4 +1,4 @@
// Golden tests for a realistic compose → blueprint conversion.
// Golden tests for a realistic compose -> blueprint conversion.
// Pins the label structure so changes to the generator can't silently break
// fosrl/blueprints compatibility.
@@ -21,8 +21,7 @@ describe("golden — nginx → pangolin blueprint", () => {
it("compose output snapshot", () => {
const bp = fromCompose(NGINX_COMPOSE, "example.com");
expect(toComposeYaml(bp)).toMatchInlineSnapshot(`
"version: "3.8"
services:
"services:
nginx:
image: nginx:1.27-alpine
container_name: nginx
@@ -33,15 +32,15 @@ describe("golden — nginx → pangolin blueprint", () => {
- ./html:/usr/share/nginx/html:ro
labels:
- pangolin.public-resources.nginx.name=Nginx
- pangolin.public-resources.nginx.full-domain=nginx.example.com
- pangolin.public-resources.nginx.protocol=http
- pangolin.public-resources.nginx.full-domain=nginx.example.com
- pangolin.public-resources.nginx.targets[0].method=http
networks:
- pangolin
networks:
pangolin:
external: true
name: \${PANGOLIN_DOCKER_NETWORK:-pangolin_default}
name: pangolin_default
"
`);
});
@@ -49,13 +48,35 @@ describe("golden — nginx → pangolin blueprint", () => {
it(".env.example snapshot", () => {
const bp = fromCompose(NGINX_COMPOSE, "example.com");
expect(toEnvExample(bp)).toMatchInlineSnapshot(`
"BLUEPRINT_NAME=nginx
"# Shared DNS root used by all blueprints.
BASE_DOMAIN=example.com
# Shared Docker network that Newt and public-facing blueprint containers join.
PANGOLIN_DOCKER_NETWORK=pangolin_default
# Optional global auth defaults applied to all blueprint resources.
GLOBAL_AUTH_PINCODE=
GLOBAL_AUTH_PASSWORD=
GLOBAL_AUTH_BASIC_USER=
GLOBAL_AUTH_BASIC_PASSWORD=
GLOBAL_AUTH_SSO_ENABLED=true
GLOBAL_AUTH_SSO_ROLE_0=
GLOBAL_AUTH_SSO_ROLE_1=
GLOBAL_AUTH_SSO_USER_0=
GLOBAL_AUTH_SSO_USER_1=
GLOBAL_AUTH_WHITELIST_USER_0=
GLOBAL_AUTH_WHITELIST_USER_1=
BLUEPRINT_NAME=nginx
RESOURCE_NAME=Nginx
SERVICE_SUBDOMAIN=nginx
SERVICE_CONTAINER_NAME=nginx
SERVICE_PORT=8080
APP_IMAGE=nginx:1.27-alpine
# Optional resource auth overrides — uncomment and set as needed.
# Optional resource-specific auth overrides and additions.
# Scalars override GLOBAL_AUTH_* values from the root .env.
# Indexed arrays are appended after the GLOBAL_AUTH_* arrays.
# RESOURCE_AUTH_PINCODE=
# RESOURCE_AUTH_PASSWORD=
# RESOURCE_AUTH_BASIC_USER=
+146 -11
View File
@@ -9,7 +9,7 @@ import {
validateServicePort,
validateSubdomain,
} from "../validator";
import { defaultBlueprint, fromCompose } from "../generator";
import { defaultBlueprint, defaultPrivateResource, fromCompose } from "../generator";
describe("blueprint field validators", () => {
it("blueprint name must be kebab-case slug", () => {
@@ -35,7 +35,7 @@ describe("blueprint field validators", () => {
expect(validateServicePort(70000)).toBeTruthy();
});
it("base domain (optional FQDN)", () => {
it("base domain (optional until HTTP resources are present)", () => {
expect(validateBaseDomain("")).toBeNull();
expect(validateBaseDomain("example.com")).toBeNull();
expect(validateBaseDomain("not-a-domain")).toBeTruthy();
@@ -49,9 +49,9 @@ describe("blueprint field validators", () => {
it("protocol enum", () => {
expect(validateProtocol("http")).toBeNull();
expect(validateProtocol("https")).toBeNull();
expect(validateProtocol("tcp")).toBeNull();
expect(validateProtocol("udp")).toBeNull();
expect(validateProtocol("https")).toBeTruthy();
expect(validateProtocol("ftp")).toBeTruthy();
});
});
@@ -61,7 +61,7 @@ describe("validateBlueprint", () => {
expect(validateBlueprint(defaultBlueprint())).toEqual([]);
});
it("imported nginx-like compose passes", () => {
it("imported nginx-like compose passes when base domain is set", () => {
const bp = fromCompose(
`services:
web:
@@ -74,6 +74,27 @@ describe("validateBlueprint", () => {
expect(validateBlueprint(bp)).toEqual([]);
});
it("requires an HTTP full-domain", () => {
const bp = fromCompose(`services:
web:
image: nginx:latest
ports:
- "8080:80"
`);
const errs = validateBlueprint(bp);
expect(errs.some((e) => /full-domain/i.test(e))).toBe(true);
});
it("validates a marketplace service without ports using defaults", () => {
const bp = fromCompose(`services:
worker:
image: busybox
`, "example.com");
expect(bp.resources).toHaveLength(1);
expect(bp.resources[0].servicePort).toBe(80);
expect(validateBlueprint(bp)).toEqual([]);
});
it("flags duplicate blueprintNames", () => {
const bp = fromCompose(`services:
a:
@@ -84,25 +105,83 @@ describe("validateBlueprint", () => {
image: y
ports:
- "81"
`);
// Force the duplicate
`, "example.com");
bp.resources[1].blueprintName = bp.resources[0].blueprintName;
const errs = validateBlueprint(bp);
expect(errs.some((e) => /Duplicate blueprintName/i.test(e))).toBe(true);
});
it("flags duplicate full domains", () => {
const bp = fromCompose(`services:
a:
image: x
ports:
- "80"
b:
image: y
ports:
- "81"
`, "example.com");
bp.resources[1].subdomain = bp.resources[0].subdomain;
const errs = validateBlueprint(bp);
expect(errs.some((e) => /Duplicate full-domain/i.test(e))).toBe(true);
});
it("flags duplicate proxy ports per protocol", () => {
const bp = fromCompose(`services:
a:
image: x
ports:
- "25565"
b:
image: y
ports:
- "25566"
`, "example.com");
bp.resources[0].protocol = "tcp";
bp.resources[0].proxyPort = 25565;
bp.resources[1].protocol = "tcp";
bp.resources[1].proxyPort = 25565;
const errs = validateBlueprint(bp);
expect(errs.some((e) => /Duplicate tcp proxy-port/i.test(e))).toBe(true);
});
it("requires TCP/UDP proxy-port and rejects auth", () => {
const bp = fromCompose(`services:
a:
image: x
ports:
- "25565"
`, "example.com");
bp.resources[0].protocol = "tcp";
bp.resources[0].auth.pincode = "1234";
const errs = validateBlueprint(bp);
expect(errs.some((e) => /proxy-port/i.test(e))).toBe(true);
expect(errs.some((e) => /must not define auth/i.test(e))).toBe(true);
});
it("rejects Admin as an SSO role", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
`, "example.com");
bp.resources[0].auth.ssoRoles = ["Admin"];
const errs = validateBlueprint(bp);
expect(errs.some((e) => /Admin role is reserved/i.test(e))).toBe(true);
});
it("flags a resource whose service is missing from the compose", () => {
const bp = fromCompose(`services:
web:
image: nginx
ports:
- "80"
`);
`, "example.com");
bp.resources[0].serviceContainerName = "missing";
const errs = validateBlueprint(bp);
expect(errs.some((e) => /not found in the imported compose/i.test(e))).toBe(
true,
);
expect(errs.some((e) => /not found in the imported compose/i.test(e))).toBe(true);
});
it("flags invalid blueprint name", () => {
@@ -111,9 +190,65 @@ describe("validateBlueprint", () => {
image: nginx
ports:
- "80"
`);
`, "example.com");
bp.resources[0].blueprintName = "Bad Name";
const errs = validateBlueprint(bp);
expect(errs.some((e) => /kebab-case/i.test(e))).toBe(true);
});
it("validates private resources and duplicate aliases", () => {
const bp = fromCompose(`services:
labeler:
image: x
ports:
- "80"
`, "example.com");
bp.privateResources = [
{
...defaultPrivateResource("labeler"),
key: "first",
name: "First",
mode: "cidr",
destination: "not-a-cidr",
alias: "internal",
roles: ["Admin"],
},
{
...defaultPrivateResource("labeler"),
key: "second",
name: "Second",
mode: "host",
destination: "host.local",
destinationPort: 80,
alias: "internal",
},
];
const errs = validateBlueprint(bp);
expect(errs.some((e) => /destination/i.test(e))).toBe(true);
expect(errs.some((e) => /Admin role is reserved/i.test(e))).toBe(true);
expect(errs.some((e) => /Duplicate private resource alias/i.test(e))).toBe(true);
});
it("validates private http domain and port requirements", () => {
const bp = fromCompose(`services:
labeler:
image: x
ports:
- "80"
`, "example.com");
bp.privateResources = [
{
...defaultPrivateResource("labeler"),
key: "http-private",
name: "HTTP Private",
mode: "http",
destination: "internal.local",
fullDomain: "not-a-domain",
alias: "http-private",
},
];
const errs = validateBlueprint(bp);
expect(errs.some((e) => /full-domain/i.test(e))).toBe(true);
expect(errs.some((e) => /destination-port/i.test(e))).toBe(true);
});
});
+427 -100
View File
@@ -8,16 +8,22 @@
// `toEnvExample(bp)` returns the matching .env.example.
import jsyaml from "js-yaml";
import { sanitizeComposeVersion } from "../compose-version";
import type {
Blueprint,
BlueprintAuth,
BlueprintHealthcheck,
BlueprintMaintenance,
BlueprintPrivateResource,
BlueprintProtocol,
BlueprintResource,
BlueprintSite,
BlueprintTarget,
BlueprintTargetMethod,
} from "../../types/blueprint";
const ENV_REF = (k: string) => `\${${k}}`;
const ENV_REQUIRED = (k: string, msg: string) => `\${${k}:?${msg}}`;
const DEFAULT_NETWORK = "pangolin_default";
const DEFAULT_SERVICE_PORT = 80;
export function defaultAuth(): BlueprintAuth {
return {
@@ -32,33 +38,114 @@ export function defaultAuth(): BlueprintAuth {
};
}
export function defaultHealthcheck(): BlueprintHealthcheck {
return {
enabled: undefined,
hostname: "",
port: undefined,
path: "",
scheme: "http",
mode: "",
interval: undefined,
unhealthyInterval: undefined,
timeout: undefined,
headers: [],
followRedirects: undefined,
method: "",
status: undefined,
};
}
export function defaultMaintenance(): BlueprintMaintenance {
return {
enabled: false,
type: "forced",
title: "",
message: "",
estimatedTime: "",
};
}
export function defaultResource(): BlueprintResource {
return {
serviceContainerName: "",
blueprintName: "",
resourceName: "",
subdomain: "",
servicePort: 80,
servicePort: DEFAULT_SERVICE_PORT,
proxyPort: undefined,
image: "",
protocol: "http",
enabled: true,
ssl: false,
hostHeader: "",
tlsServerName: "",
headers: [],
rules: [],
maintenance: defaultMaintenance(),
targetSite: undefined,
targetMethod: "http",
targetHostname: undefined,
targetPort: undefined,
targetEnabled: undefined,
targetInternalPort: undefined,
targetPath: undefined,
targetPathMatch: undefined,
targetRewritePath: undefined,
targetRewriteMatch: undefined,
targetPriority: undefined,
targetHealthcheck: undefined,
extraTargets: [],
auth: defaultAuth(),
};
}
export function defaultPrivateResource(
serviceContainerName = "",
): BlueprintPrivateResource {
return {
serviceContainerName,
key: "",
name: "",
mode: "host",
sites: [],
destination: "",
destinationPort: undefined,
fullDomain: "",
ssl: false,
scheme: "http",
tcpPorts: "",
udpPorts: "",
disableIcmp: false,
alias: "",
roles: [],
users: [],
machines: [],
};
}
export function defaultSite(serviceContainerName = ""): BlueprintSite {
return {
serviceContainerName,
key: "",
name: "",
dockerSocketEnabled: false,
};
}
export function defaultBlueprint(): Blueprint {
return {
version: 1,
baseDomain: "",
pangolinNetwork: DEFAULT_NETWORK,
resources: [],
privateResources: [],
sites: [],
composeDocument: null,
};
}
// ---------- compose → blueprint ----------
// ---------- compose -> blueprint ----------
function slug(s: string): string {
return s
@@ -123,15 +210,51 @@ function firstServicePort(svc: Record<string, unknown>): number | null {
return null;
}
function protocolFor(port: number): BlueprintProtocol {
function targetMethodFor(port: number): BlueprintTargetMethod {
if (port === 443 || port === 8443) return "https";
return "http";
}
export function resourceFromComposeService(
serviceKey: string,
rawService: unknown,
): BlueprintResource {
const svc =
rawService && typeof rawService === "object"
? (rawService as Record<string, unknown>)
: {};
const port = firstServicePort(svc) ?? DEFAULT_SERVICE_PORT;
const blueprintName = slug(serviceKey) || serviceKey.toLowerCase() || "service";
return {
...defaultResource(),
serviceContainerName: serviceKey,
blueprintName,
resourceName: titleCase(serviceKey) || serviceKey,
subdomain: blueprintName,
servicePort: port,
image: typeof svc.image === "string" ? svc.image : "",
protocol: "http",
targetMethod: targetMethodFor(port),
};
}
function uniqueBlueprintName(base: string, seen: Set<string>): string {
if (!seen.has(base)) {
seen.add(base);
return base;
}
let i = 2;
while (seen.has(`${base}-${i}`)) i += 1;
const next = `${base}-${i}`;
seen.add(next);
return next;
}
export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint {
let doc: unknown;
try {
doc = jsyaml.load(yamlContent);
doc = jsyaml.load(sanitizeComposeVersion(yamlContent).content);
} catch {
return defaultBlueprint();
}
@@ -147,28 +270,21 @@ export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint {
bp.baseDomain = baseDomain;
bp.composeDocument = root;
const seenBlueprintNames = new Set<string>();
for (const [svcKey, raw] of Object.entries(services)) {
if (!raw || typeof raw !== "object") continue;
const svc = raw as Record<string, unknown>;
const port = firstServicePort(svc);
if (port === null) continue;
const sluggedKey = slug(svcKey) || svcKey;
const resource: BlueprintResource = {
...defaultResource(),
serviceContainerName: svcKey,
blueprintName: sluggedKey,
resourceName: titleCase(svcKey) || svcKey,
subdomain: sluggedKey,
servicePort: port,
image: typeof svc.image === "string" ? svc.image : "",
protocol: protocolFor(port),
};
const resource = resourceFromComposeService(svcKey, raw);
resource.blueprintName = uniqueBlueprintName(
resource.blueprintName,
seenBlueprintNames,
);
resource.subdomain = resource.blueprintName;
bp.resources.push(resource);
}
return bp;
}
// ---------- blueprint → compose YAML ----------
// ---------- blueprint -> compose YAML ----------
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -190,98 +306,285 @@ function dedupePreserveOrder<T>(items: T[]): T[] {
return out;
}
function labelsFor(bp: Blueprint, r: BlueprintResource): string[] {
function labelsToArray(labels: unknown): string[] {
if (Array.isArray(labels)) {
return labels.map((v) => String(v));
}
if (isPlainObject(labels)) {
return Object.entries(labels).map(([key, value]) => `${key}=${String(value)}`);
}
return [];
}
function removePangolinLabels(labels: string[], prefixes: string[]): string[] {
return labels.filter((label) => !prefixes.some((prefix) => label.startsWith(prefix)));
}
function pushLabel(labels: string[], key: string, value: unknown) {
if (value === undefined || value === null || value === "") return;
labels.push(`${key}=${String(value)}`);
}
function pushBoolLabel(
labels: string[],
key: string,
value: boolean | undefined,
defaultValue: boolean,
) {
if (value === undefined || value === defaultValue) return;
labels.push(`${key}=${value ? "true" : "false"}`);
}
function pushArrayLabels(labels: string[], prefix: string, values: string[]) {
values.filter(Boolean).forEach((value, i) => pushLabel(labels, `${prefix}[${i}]`, value));
}
function pushHeaderLabels(labels: string[], prefix: string, headers: Blueprint["resources"][number]["headers"]) {
headers.filter((h) => h.name && h.value).forEach((h, i) => {
pushLabel(labels, `${prefix}[${i}].name`, h.name);
pushLabel(labels, `${prefix}[${i}].value`, h.value);
});
}
function pushHealthcheckLabels(
labels: string[],
prefix: string,
hc: BlueprintHealthcheck | undefined,
) {
if (!hc) return;
pushBoolLabel(labels, `${prefix}.enabled`, hc.enabled, true);
pushLabel(labels, `${prefix}.hostname`, hc.hostname);
pushLabel(labels, `${prefix}.port`, hc.port);
pushLabel(labels, `${prefix}.path`, hc.path);
pushLabel(labels, `${prefix}.scheme`, hc.scheme);
pushLabel(labels, `${prefix}.mode`, hc.mode);
pushLabel(labels, `${prefix}.interval`, hc.interval);
pushLabel(labels, `${prefix}.unhealthy-interval`, hc.unhealthyInterval);
pushLabel(labels, `${prefix}.timeout`, hc.timeout);
pushHeaderLabels(labels, `${prefix}.headers`, hc.headers);
pushBoolLabel(labels, `${prefix}.follow-redirects`, hc.followRedirects, true);
pushLabel(labels, `${prefix}.method`, hc.method);
pushLabel(labels, `${prefix}.status`, hc.status);
}
function pushTargetLabels(
labels: string[],
prefix: string,
protocol: BlueprintProtocol,
target: BlueprintTarget,
) {
if (protocol === "http") pushLabel(labels, `${prefix}.method`, target.method ?? "http");
pushLabel(labels, `${prefix}.site`, target.site);
pushLabel(labels, `${prefix}.hostname`, target.hostname);
pushLabel(labels, `${prefix}.port`, target.port);
pushBoolLabel(labels, `${prefix}.enabled`, target.enabled, true);
pushLabel(labels, `${prefix}.internal-port`, target.internalPort);
pushLabel(labels, `${prefix}.path`, target.path);
pushLabel(labels, `${prefix}.path-match`, target.pathMatch);
pushLabel(labels, `${prefix}.rewrite-path`, target.rewritePath);
pushLabel(labels, `${prefix}.rewrite-match`, target.rewriteMatch);
pushLabel(labels, `${prefix}.priority`, target.priority);
pushHealthcheckLabels(labels, `${prefix}.healthcheck`, target.healthcheck);
}
function labelsForPublicResource(bp: Blueprint, r: BlueprintResource): string[] {
const ns = `pangolin.public-resources.${r.blueprintName}`;
const fullDomain = bp.baseDomain
? `${r.subdomain}.${bp.baseDomain}`
: r.subdomain;
const labels: string[] = [
`${ns}.name=${r.resourceName}`,
`${ns}.full-domain=${fullDomain}`,
`${ns}.protocol=${r.protocol}`,
`${ns}.targets[0].method=${r.protocol}`,
];
if (r.targetHostname) labels.push(`${ns}.targets[0].hostname=${r.targetHostname}`);
if (r.targetPort !== undefined) labels.push(`${ns}.targets[0].port=${r.targetPort}`);
r.extraTargets.forEach((t, i) => {
const idx = i + 1;
labels.push(`${ns}.targets[${idx}].method=${t.method}`);
if (t.hostname) labels.push(`${ns}.targets[${idx}].hostname=${t.hostname}`);
if (t.port !== undefined) labels.push(`${ns}.targets[${idx}].port=${t.port}`);
});
pushBoolLabel(labels, `${ns}.enabled`, r.enabled, true);
if (r.protocol === "http") {
pushLabel(labels, `${ns}.full-domain`, fullDomain);
pushBoolLabel(labels, `${ns}.ssl`, r.ssl, false);
pushLabel(labels, `${ns}.host-header`, r.hostHeader);
pushLabel(labels, `${ns}.tls-server-name`, r.tlsServerName);
pushTargetLabels(labels, `${ns}.targets[0]`, r.protocol, {
site: r.targetSite,
method: r.targetMethod,
hostname: r.targetHostname,
port: r.targetPort,
enabled: r.targetEnabled,
internalPort: r.targetInternalPort,
path: r.targetPath,
pathMatch: r.targetPathMatch,
rewritePath: r.targetRewritePath,
rewriteMatch: r.targetRewriteMatch,
priority: r.targetPriority,
healthcheck: r.targetHealthcheck,
});
r.extraTargets.forEach((t, i) => {
pushTargetLabels(labels, `${ns}.targets[${i + 1}]`, r.protocol, t);
});
const a = r.auth;
if (a.pincode) labels.push(`${ns}.auth.pincode=${a.pincode}`);
if (a.password) labels.push(`${ns}.auth.password=${a.password}`);
if (a.basicUser) labels.push(`${ns}.auth.basic-user=${a.basicUser}`);
if (a.basicPassword) labels.push(`${ns}.auth.basic-password=${a.basicPassword}`);
if (a.ssoEnabled) labels.push(`${ns}.auth.sso-enabled=true`);
a.ssoRoles.filter(Boolean).forEach((role, i) =>
labels.push(`${ns}.auth.sso-roles[${i}]=${role}`),
);
a.ssoUsers.filter(Boolean).forEach((user, i) =>
labels.push(`${ns}.auth.sso-users[${i}]=${user}`),
);
a.whitelistUsers.filter(Boolean).forEach((user, i) =>
labels.push(`${ns}.auth.whitelist-users[${i}]=${user}`),
);
pushHeaderLabels(labels, `${ns}.headers`, r.headers);
r.rules.filter((rule) => rule.action && rule.match && rule.value).forEach((rule, i) => {
pushLabel(labels, `${ns}.rules[${i}].action`, rule.action);
pushLabel(labels, `${ns}.rules[${i}].match`, rule.match);
pushLabel(labels, `${ns}.rules[${i}].value`, rule.value);
pushLabel(labels, `${ns}.rules[${i}].priority`, rule.priority);
});
if (r.maintenance.enabled) {
pushLabel(labels, `${ns}.maintenance.enabled`, true);
pushLabel(labels, `${ns}.maintenance.type`, r.maintenance.type);
pushLabel(labels, `${ns}.maintenance.title`, r.maintenance.title);
pushLabel(labels, `${ns}.maintenance.message`, r.maintenance.message);
pushLabel(labels, `${ns}.maintenance.estimated-time`, r.maintenance.estimatedTime);
}
const a = r.auth;
pushLabel(labels, `${ns}.auth.pincode`, a.pincode);
pushLabel(labels, `${ns}.auth.password`, a.password);
pushLabel(labels, `${ns}.auth.basic-auth.user`, a.basicUser);
pushLabel(labels, `${ns}.auth.basic-auth.password`, a.basicPassword);
if (a.ssoEnabled) pushLabel(labels, `${ns}.auth.sso-enabled`, true);
pushArrayLabels(labels, `${ns}.auth.sso-roles`, a.ssoRoles);
pushArrayLabels(labels, `${ns}.auth.sso-users`, a.ssoUsers);
pushArrayLabels(labels, `${ns}.auth.whitelist-users`, a.whitelistUsers);
} else {
pushLabel(labels, `${ns}.proxy-port`, r.proxyPort ?? r.servicePort);
}
return labels;
}
function labelsForPrivateResource(r: BlueprintPrivateResource): string[] {
const ns = `pangolin.private-resources.${r.key}`;
const labels: string[] = [];
pushLabel(labels, `${ns}.name`, r.name);
pushLabel(labels, `${ns}.mode`, r.mode);
pushArrayLabels(labels, `${ns}.sites`, r.sites);
pushLabel(labels, `${ns}.destination`, r.destination);
pushLabel(labels, `${ns}.destination-port`, r.destinationPort);
pushLabel(labels, `${ns}.full-domain`, r.fullDomain);
pushBoolLabel(labels, `${ns}.ssl`, r.ssl, false);
pushLabel(labels, `${ns}.scheme`, r.scheme);
pushLabel(labels, `${ns}.tcp-ports`, r.tcpPorts);
pushLabel(labels, `${ns}.udp-ports`, r.udpPorts);
pushBoolLabel(labels, `${ns}.disable-icmp`, r.disableIcmp, false);
pushLabel(labels, `${ns}.alias`, r.alias);
pushArrayLabels(labels, `${ns}.roles`, r.roles);
pushArrayLabels(labels, `${ns}.users`, r.users);
pushArrayLabels(labels, `${ns}.machines`, r.machines);
return labels;
}
function labelsForSite(site: BlueprintSite): string[] {
const ns = `pangolin.sites.${site.key}`;
const labels: string[] = [];
pushLabel(labels, `${ns}.name`, site.name);
pushBoolLabel(labels, `${ns}.docker-socket-enabled`, site.dockerSocketEnabled, false);
return labels;
}
function ensureService(
services: Record<string, unknown>,
serviceName: string,
): Record<string, unknown> {
const svc = isPlainObject(services[serviceName])
? (services[serviceName] as Record<string, unknown>)
: {};
services[serviceName] = svc;
return svc;
}
function removeLabelsFromService(
services: Record<string, unknown>,
serviceName: string,
removePrefixes: string[],
) {
if (!serviceName || !isPlainObject(services[serviceName])) return;
const svc = services[serviceName] as Record<string, unknown>;
const existing = removePangolinLabels(labelsToArray(svc.labels), removePrefixes);
if (existing.length > 0) {
svc.labels = existing;
} else {
delete svc.labels;
}
}
function applyLabelsToService(
services: Record<string, unknown>,
serviceName: string,
labels: string[],
removePrefixes: string[],
addNetwork: boolean,
) {
if (!serviceName || labels.length === 0) return;
const svc = ensureService(services, serviceName);
const existing = removePangolinLabels(labelsToArray(svc.labels), removePrefixes);
svc.labels = dedupePreserveOrder([...existing, ...labels]);
if (!addNetwork) return;
if (Array.isArray(svc.networks)) {
const nets = svc.networks as unknown[];
svc.networks = nets.includes("pangolin") ? nets : [...nets, "pangolin"];
} else if (isPlainObject(svc.networks)) {
svc.networks = { ...svc.networks, pangolin: {} };
} else {
svc.networks = ["pangolin"];
}
}
export function toComposeYaml(bp: Blueprint): string {
const baseDoc: Record<string, unknown> = bp.composeDocument
? deepClone(bp.composeDocument)
: {};
// Ensure services map.
const services = isPlainObject(baseDoc.services)
? (baseDoc.services as Record<string, unknown>)
: ({} as Record<string, unknown>);
Object.keys(services).forEach((serviceName) => {
removeLabelsFromService(services, serviceName, ["pangolin.public-resources."]);
});
for (const r of bp.resources) {
const svc = isPlainObject(services[r.serviceContainerName])
? (services[r.serviceContainerName] as Record<string, unknown>)
: {};
// Inject labels — merge with anything already there, de-dupe.
const existing: string[] = Array.isArray(svc.labels)
? (svc.labels as unknown[]).map((v) => String(v))
: [];
svc.labels = dedupePreserveOrder([...existing, ...labelsFor(bp, r)]);
// Ensure pangolin network membership.
const nets: unknown[] = Array.isArray(svc.networks)
? (svc.networks as unknown[])
: [];
if (!nets.includes("pangolin")) {
svc.networks = [...nets, "pangolin"];
} else {
svc.networks = nets;
}
services[r.serviceContainerName] = svc;
applyLabelsToService(
services,
r.serviceContainerName,
labelsForPublicResource(bp, r),
[`pangolin.public-resources.${r.blueprintName}.`],
true,
);
}
for (const privateResource of bp.privateResources ?? []) {
applyLabelsToService(
services,
privateResource.serviceContainerName,
labelsForPrivateResource(privateResource),
[`pangolin.private-resources.${privateResource.key}.`],
false,
);
}
for (const site of bp.sites ?? []) {
applyLabelsToService(
services,
site.serviceContainerName,
labelsForSite(site),
[`pangolin.sites.${site.key}.`],
false,
);
}
baseDoc.services = services;
// Ensure top-level pangolin network block.
const networksTop = isPlainObject(baseDoc.networks)
? (baseDoc.networks as Record<string, unknown>)
: {};
networksTop.pangolin = {
external: true,
name: ENV_REF("PANGOLIN_DOCKER_NETWORK") + ":-" + bp.pangolinNetwork,
};
// The dump trick above creates `${PANGOLIN_DOCKER_NETWORK}:-...` which is
// wrong; compose-style default syntax is `${VAR:-default}`. Fix in place:
networksTop.pangolin = {
external: true,
name: `\${PANGOLIN_DOCKER_NETWORK:-${bp.pangolinNetwork}}`,
name: bp.pangolinNetwork,
};
baseDoc.networks = networksTop;
// Always emit at least version key for clarity.
if (!baseDoc.version) baseDoc.version = "3.8";
delete baseDoc.version;
return jsyaml.dump(baseDoc, {
indent: 2,
@@ -291,15 +594,41 @@ export function toComposeYaml(bp: Blueprint): string {
});
}
// ---------- blueprint → .env.example ----------
// ---------- blueprint -> .env.example ----------
export function toEnvExample(bp: Blueprint): string {
const blocks: string[] = [];
bp.resources.forEach((r, i) => {
const header =
bp.resources.length > 1 ? `# ─── ${r.serviceContainerName} ───\n` : "";
const auth = `
# Optional resource auth overrides — uncomment and set as needed.
function envRootBlock(bp: Blueprint): string {
return `# Shared DNS root used by all blueprints.
BASE_DOMAIN=${bp.baseDomain || "CHANGE_ME"}
# Shared Docker network that Newt and public-facing blueprint containers join.
PANGOLIN_DOCKER_NETWORK=${bp.pangolinNetwork}
# Optional global auth defaults applied to all blueprint resources.
GLOBAL_AUTH_PINCODE=
GLOBAL_AUTH_PASSWORD=
GLOBAL_AUTH_BASIC_USER=
GLOBAL_AUTH_BASIC_PASSWORD=
GLOBAL_AUTH_SSO_ENABLED=true
GLOBAL_AUTH_SSO_ROLE_0=
GLOBAL_AUTH_SSO_ROLE_1=
GLOBAL_AUTH_SSO_USER_0=
GLOBAL_AUTH_SSO_USER_1=
GLOBAL_AUTH_WHITELIST_USER_0=
GLOBAL_AUTH_WHITELIST_USER_1=`;
}
function envResourceBlock(r: BlueprintResource, includeHeader: boolean): string {
const header = includeHeader ? `# --- ${r.serviceContainerName} ---\n` : "";
return `${header}BLUEPRINT_NAME=${r.blueprintName}
RESOURCE_NAME=${r.resourceName}
SERVICE_SUBDOMAIN=${r.subdomain}
SERVICE_CONTAINER_NAME=${r.serviceContainerName}
SERVICE_PORT=${r.servicePort}
APP_IMAGE=${r.image}
# Optional resource-specific auth overrides and additions.
# Scalars override GLOBAL_AUTH_* values from the root .env.
# Indexed arrays are appended after the GLOBAL_AUTH_* arrays.
# RESOURCE_AUTH_PINCODE=
# RESOURCE_AUTH_PASSWORD=
# RESOURCE_AUTH_BASIC_USER=
@@ -308,22 +637,20 @@ export function toEnvExample(bp: Blueprint): string {
# RESOURCE_AUTH_SSO_ROLE_0=
# RESOURCE_AUTH_SSO_USER_0=
# RESOURCE_AUTH_WHITELIST_USER_0=`;
const body = `BLUEPRINT_NAME=${r.blueprintName}
RESOURCE_NAME=${r.resourceName}
SERVICE_SUBDOMAIN=${r.subdomain}
SERVICE_CONTAINER_NAME=${r.serviceContainerName}
SERVICE_PORT=${r.servicePort}
APP_IMAGE=${r.image}`;
blocks.push(header + body + auth);
if (i < bp.resources.length - 1) blocks.push("");
});
if (blocks.length === 0) {
return `# Import a docker-compose template to populate this file.\n`;
}
export function toEnvExample(bp: Blueprint): string {
const blocks: string[] = [envRootBlock(bp)];
if (bp.resources.length === 0) {
blocks.push("# Import a docker-compose template to populate resource inputs.");
return `${blocks.join("\n\n")}\n`;
}
return blocks.join("\n") + "\n";
bp.resources.forEach((r) => {
blocks.push(envResourceBlock(r, bp.resources.length > 1));
});
return `${blocks.join("\n\n")}\n`;
}
// Re-exported sentinel so callers don't accidentally shadow with their own.
export const PANGOLIN_NETWORK_DEFAULT = DEFAULT_NETWORK;
// silence unused-var hint for the helper above
void ENV_REQUIRED;
+285 -32
View File
@@ -1,10 +1,18 @@
// Validation for the fosrl/blueprints-compatible Blueprint structure.
import type { Blueprint, BlueprintResource } from "../../types/blueprint";
import type {
Blueprint,
BlueprintHealthcheck,
BlueprintPrivateResource,
BlueprintResource,
BlueprintSite,
BlueprintTarget,
} from "../../types/blueprint";
import {
validateHostname,
validatePort,
validateIpv4Cidr,
validateLabelKey,
validatePort,
} from "../validation";
const BLUEPRINT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
@@ -13,12 +21,43 @@ const FQDN_RE =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
const NETWORK_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PROTOCOLS = new Set(["http", "https", "tcp", "udp"]);
const IP_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const PROTOCOLS = new Set(["http", "tcp", "udp"]);
const TARGET_METHODS = new Set(["http", "https", "h2c"]);
const PATH_MATCHES = new Set(["exact", "prefix", "regex"]);
const REWRITE_MATCHES = new Set(["exact", "prefix", "regex", "stripPrefix"]);
const RULE_ACTIONS = new Set(["allow", "deny", "pass"]);
const RULE_MATCHES = new Set(["cidr", "path", "ip", "country", "asn", "region"]);
const PRIVATE_MODES = new Set(["host", "cidr", "http"]);
const PRIVATE_SCHEMES = new Set(["http", "https"]);
function push(errors: string[], prefix: string, err: string | null) {
if (err) errors.push(`${prefix}: ${err}`);
}
function fullDomainFor(bp: Blueprint, r: BlueprintResource): string {
return bp.baseDomain ? `${r.subdomain}.${bp.baseDomain}` : r.subdomain;
}
function isValidHostOrIp(value: string): boolean {
return IP_RE.test(value) || validateHostname(value) === null || FQDN_RE.test(value);
}
function hasAuth(r: BlueprintResource): boolean {
const auth = r.auth;
return Boolean(
auth.pincode ||
auth.password ||
auth.basicUser ||
auth.basicPassword ||
auth.ssoEnabled ||
auth.ssoRoles.length > 0 ||
auth.ssoUsers.length > 0 ||
auth.whitelistUsers.length > 0,
);
}
export function validateBlueprintName(v: string): string | null {
if (!v) return "Blueprint name is required";
if (!BLUEPRINT_NAME_RE.test(v))
@@ -38,11 +77,17 @@ export function validateSubdomain(v: string): string | null {
}
export function validateBaseDomain(v: string): string | null {
if (!v) return null; // optional — labels emit subdomain bare when missing
if (!v) return null;
if (!FQDN_RE.test(v)) return "Base domain must be a valid FQDN";
return null;
}
export function validateFullDomain(v: string): string | null {
if (!v) return "Full domain is required";
if (!FQDN_RE.test(v)) return "Full domain must be a valid FQDN";
return null;
}
export function validateServicePort(port: number | string): string | null {
return validatePort(String(port));
}
@@ -55,8 +100,12 @@ export function validatePangolinNetwork(v: string): string | null {
}
export function validateProtocol(v: string): string | null {
if (!PROTOCOLS.has(v))
return "Protocol must be http, https, tcp, or udp";
if (!PROTOCOLS.has(v)) return "Protocol must be http, tcp, or udp";
return null;
}
export function validateTargetMethod(v: string): string | null {
if (!TARGET_METHODS.has(v)) return "Target method must be http, https, or h2c";
return null;
}
@@ -66,7 +115,94 @@ export function validateEmail(v: string): string | null {
return null;
}
function validateHeaderList(
headers: Array<{ name: string; value: string }>,
prefix: string,
): string[] {
const errors: string[] = [];
headers.forEach((h, i) => {
if (!h.name && !h.value) return;
if (!h.name || !HEADER_NAME_RE.test(h.name)) {
errors.push(`${prefix}[${i}].name: Header name must be a valid HTTP token`);
}
if (!h.value) errors.push(`${prefix}[${i}].value: Header value is required`);
});
return errors;
}
function validateHealthcheck(
hc: BlueprintHealthcheck | undefined,
prefix: string,
): string[] {
const errors: string[] = [];
if (!hc) return errors;
if (hc.hostname && !isValidHostOrIp(hc.hostname)) {
errors.push(`${prefix}.hostname: Must be a valid hostname or IP address`);
}
if (hc.port !== undefined) push(errors, `${prefix}.port`, validatePort(String(hc.port)));
if (hc.path && !hc.path.startsWith("/")) {
errors.push(`${prefix}.path: Path must start with /`);
}
if (hc.scheme && !PRIVATE_SCHEMES.has(hc.scheme)) {
errors.push(`${prefix}.scheme: Scheme must be http or https`);
}
[
["interval", hc.interval],
["unhealthy-interval", hc.unhealthyInterval],
["timeout", hc.timeout],
].forEach(([name, value]) => {
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
errors.push(`${prefix}.${name}: Must be a positive integer`);
}
});
if (hc.status !== undefined && (!Number.isInteger(hc.status) || hc.status < 100 || hc.status > 599)) {
errors.push(`${prefix}.status: Status must be an HTTP status code`);
}
errors.push(...validateHeaderList(hc.headers, `${prefix}.headers`));
return errors;
}
function validateTarget(
t: BlueprintTarget,
idx: number,
protocol: BlueprintResource["protocol"],
prefix: string,
): string[] {
const errors: string[] = [];
if (protocol === "http") {
if (!t.method) errors.push(`${prefix} targets[${idx}].method: Target method is required`);
else push(errors, `${prefix} targets[${idx}].method`, validateTargetMethod(t.method));
} else if (t.method) {
errors.push(`${prefix} targets[${idx}].method: TCP/UDP resources must not define target method`);
}
if (t.hostname && !isValidHostOrIp(t.hostname)) {
errors.push(`${prefix} targets[${idx}].hostname: Must be a valid hostname or IP address`);
}
if (t.port !== undefined) push(errors, `${prefix} targets[${idx}].port`, validatePort(String(t.port)));
if (t.internalPort !== undefined) {
push(errors, `${prefix} targets[${idx}].internal-port`, validatePort(String(t.internalPort)));
}
if (t.path && !t.path.startsWith("/")) {
errors.push(`${prefix} targets[${idx}].path: Path must start with /`);
}
if (t.pathMatch && !PATH_MATCHES.has(t.pathMatch)) {
errors.push(`${prefix} targets[${idx}].path-match: Must be exact, prefix, or regex`);
}
if (t.rewritePath && !t.rewritePath.startsWith("/")) {
errors.push(`${prefix} targets[${idx}].rewrite-path: Rewrite path must start with /`);
}
if (t.rewriteMatch && !REWRITE_MATCHES.has(t.rewriteMatch)) {
errors.push(`${prefix} targets[${idx}].rewrite-match: Must be exact, prefix, regex, or stripPrefix`);
}
if (t.priority !== undefined && (!Number.isInteger(t.priority) || t.priority < 0)) {
errors.push(`${prefix} targets[${idx}].priority: Priority must be a non-negative integer`);
}
errors.push(...validateHealthcheck(t.healthcheck, `${prefix} targets[${idx}].healthcheck`));
return errors;
}
function validateResource(
bp: Blueprint,
r: BlueprintResource,
idx: number,
composeServiceKeys: Set<string>,
@@ -76,7 +212,6 @@ function validateResource(
push(errors, `${prefix} blueprintName`, validateBlueprintName(r.blueprintName));
push(errors, `${prefix} resourceName`, validateResourceName(r.resourceName));
push(errors, `${prefix} subdomain`, validateSubdomain(r.subdomain));
push(errors, `${prefix} servicePort`, validateServicePort(r.servicePort));
push(errors, `${prefix} protocol`, validateProtocol(r.protocol));
@@ -91,28 +226,115 @@ function validateResource(
);
}
if (r.targetHostname !== undefined && r.targetHostname !== "") {
push(errors, `${prefix} targets[0].hostname`, validateHostname(r.targetHostname));
}
if (r.targetPort !== undefined) {
push(errors, `${prefix} targets[0].port`, validatePort(String(r.targetPort)));
if (r.protocol === "http") {
push(errors, `${prefix} subdomain`, validateSubdomain(r.subdomain));
push(errors, `${prefix} full-domain`, validateFullDomain(fullDomainFor(bp, r)));
errors.push(...validateTarget(
{
site: r.targetSite,
method: r.targetMethod,
hostname: r.targetHostname,
port: r.targetPort,
enabled: r.targetEnabled,
internalPort: r.targetInternalPort,
path: r.targetPath,
pathMatch: r.targetPathMatch,
rewritePath: r.targetRewritePath,
rewriteMatch: r.targetRewriteMatch,
priority: r.targetPriority,
healthcheck: r.targetHealthcheck,
},
0,
r.protocol,
prefix,
));
r.extraTargets.forEach((t, i) => {
errors.push(...validateTarget(t, i + 1, r.protocol, prefix));
});
errors.push(...validateHeaderList(r.headers, `${prefix} headers`));
r.rules.forEach((rule, i) => {
if (!RULE_ACTIONS.has(rule.action)) errors.push(`${prefix} rules[${i}].action: Invalid rule action`);
if (!RULE_MATCHES.has(rule.match)) errors.push(`${prefix} rules[${i}].match: Invalid rule match`);
if (!rule.value) errors.push(`${prefix} rules[${i}].value: Rule value is required`);
if (rule.priority !== undefined && (!Number.isInteger(rule.priority) || rule.priority < 0)) {
errors.push(`${prefix} rules[${i}].priority: Priority must be a non-negative integer`);
}
});
r.auth.ssoRoles.filter(Boolean).forEach((role, i) => {
if (role === "Admin") errors.push(`${prefix} auth.sso-roles[${i}]: Admin role is reserved`);
push(errors, `${prefix} auth.sso-roles[${i}]`, validateLabelKey(role));
});
r.auth.ssoUsers.filter(Boolean).forEach((user, i) => {
push(errors, `${prefix} auth.sso-users[${i}]`, validateEmail(user));
});
r.auth.whitelistUsers.filter(Boolean).forEach((user, i) => {
push(errors, `${prefix} auth.whitelist-users[${i}]`, validateEmail(user));
});
} else {
if (r.proxyPort === undefined) errors.push(`${prefix} proxy-port: TCP/UDP resources require proxy-port`);
else push(errors, `${prefix} proxy-port`, validatePort(String(r.proxyPort)));
if (hasAuth(r)) errors.push(`${prefix}: TCP/UDP resources must not define auth labels`);
r.extraTargets.forEach((t, i) => {
if (t.method) errors.push(`${prefix} targets[${i + 1}].method: TCP/UDP resources must not define target method`);
});
}
r.extraTargets.forEach((t, i) => {
const tp = `${prefix} targets[${i + 1}]`;
push(errors, `${tp} method`, validateProtocol(t.method));
if (t.hostname) push(errors, `${tp} hostname`, validateHostname(t.hostname));
if (t.port !== undefined)
push(errors, `${tp} port`, validatePort(String(t.port)));
return errors;
}
function validatePrivateResource(
r: BlueprintPrivateResource,
idx: number,
composeServiceKeys: Set<string>,
): string[] {
const errors: string[] = [];
const prefix = `Private resource "${r.key || idx + 1}"`;
push(errors, `${prefix} key`, validateBlueprintName(r.key));
push(errors, `${prefix} name`, validateResourceName(r.name));
if (!PRIVATE_MODES.has(r.mode)) errors.push(`${prefix} mode: Must be host, cidr, or http`);
if (!r.serviceContainerName) errors.push(`${prefix}: serviceContainerName is required`);
else if (composeServiceKeys.size > 0 && !composeServiceKeys.has(r.serviceContainerName)) {
errors.push(`${prefix}: service "${r.serviceContainerName}" not found in the imported compose document`);
}
if (r.mode === "cidr") {
push(errors, `${prefix} destination`, validateIpv4Cidr(r.destination));
} else if (!r.destination || !isValidHostOrIp(r.destination)) {
errors.push(`${prefix} destination: Must be a valid hostname, FQDN, or IP address`);
}
if (r.mode === "host" || r.mode === "http") {
if (r.destinationPort === undefined) errors.push(`${prefix} destination-port: Port is required`);
else push(errors, `${prefix} destination-port`, validatePort(String(r.destinationPort)));
}
if (r.mode === "http") {
push(errors, `${prefix} full-domain`, validateFullDomain(r.fullDomain));
if (!PRIVATE_SCHEMES.has(r.scheme)) errors.push(`${prefix} scheme: Scheme must be http or https`);
}
if (!r.alias) errors.push(`${prefix} alias: Alias is required`);
else push(errors, `${prefix} alias`, validateLabelKey(r.alias));
r.roles.filter(Boolean).forEach((role, i) => {
if (role === "Admin") errors.push(`${prefix} roles[${i}]: Admin role is reserved`);
push(errors, `${prefix} roles[${i}]`, validateLabelKey(role));
});
r.users.filter(Boolean).forEach((user, i) => push(errors, `${prefix} users[${i}]`, validateEmail(user)));
r.machines.filter(Boolean).forEach((machine, i) => push(errors, `${prefix} machines[${i}]`, validateLabelKey(machine)));
return errors;
}
// SSO list entries: each non-empty role/user is a free-form label slug.
r.auth.ssoRoles
.filter(Boolean)
.forEach((role, i) =>
push(errors, `${prefix} auth.sso-roles[${i}]`, validateLabelKey(role)),
);
function validateSite(
site: BlueprintSite,
idx: number,
composeServiceKeys: Set<string>,
): string[] {
const errors: string[] = [];
const prefix = `Site "${site.key || idx + 1}"`;
push(errors, `${prefix} key`, validateBlueprintName(site.key));
push(errors, `${prefix} name`, validateResourceName(site.name));
if (!site.serviceContainerName) errors.push(`${prefix}: serviceContainerName is required`);
else if (composeServiceKeys.size > 0 && !composeServiceKeys.has(site.serviceContainerName)) {
errors.push(`${prefix}: service "${site.serviceContainerName}" not found in the imported compose document`);
}
return errors;
}
@@ -131,19 +353,50 @@ export function validateBlueprint(bp: Blueprint): string[] {
: [],
);
if (bp.resources.length === 0) {
// Empty blueprint is valid — user may still be filling things out — but
// hint at the next step.
return errors;
}
const seenNames = new Set<string>();
const seenDomains = new Set<string>();
const seenProxyPorts = new Set<string>();
bp.resources.forEach((r, i) => {
errors.push(...validateResource(r, i, composeServiceKeys));
errors.push(...validateResource(bp, r, i, composeServiceKeys));
if (r.blueprintName && seenNames.has(r.blueprintName)) {
errors.push(`Duplicate blueprintName: "${r.blueprintName}"`);
}
seenNames.add(r.blueprintName);
if (r.protocol === "http") {
const fullDomain = fullDomainFor(bp, r);
if (fullDomain && seenDomains.has(fullDomain)) {
errors.push(`Duplicate full-domain: "${fullDomain}"`);
}
seenDomains.add(fullDomain);
} else if (r.proxyPort !== undefined) {
const key = `${r.protocol}:${r.proxyPort}`;
if (seenProxyPorts.has(key)) errors.push(`Duplicate ${r.protocol} proxy-port: "${r.proxyPort}"`);
seenProxyPorts.add(key);
}
});
const seenPrivateAliases = new Set<string>();
const seenPrivateKeys = new Set<string>();
(bp.privateResources ?? []).forEach((privateResource, i) => {
errors.push(...validatePrivateResource(privateResource, i, composeServiceKeys));
if (privateResource.key && seenPrivateKeys.has(privateResource.key)) {
errors.push(`Duplicate private resource key: "${privateResource.key}"`);
}
seenPrivateKeys.add(privateResource.key);
if (privateResource.alias && seenPrivateAliases.has(privateResource.alias)) {
errors.push(`Duplicate private resource alias: "${privateResource.alias}"`);
}
seenPrivateAliases.add(privateResource.alias);
});
const seenSiteKeys = new Set<string>();
(bp.sites ?? []).forEach((site, i) => {
errors.push(...validateSite(site, i, composeServiceKeys));
if (site.key && seenSiteKeys.has(site.key)) {
errors.push(`Duplicate site key: "${site.key}"`);
}
seenSiteKeys.add(site.key);
});
return errors;
+18
View File
@@ -0,0 +1,18 @@
export interface ComposeVersionSanitization {
content: string;
removed: boolean;
}
const TOP_LEVEL_VERSION_RE = /^(?:\uFEFF)?version\s*:\s*(?:"[^"]*"|'[^']*'|[^\r\n#]*)?(?:\s+#.*)?(?:\r?\n|$)/m;
export function sanitizeComposeVersion(content: string): ComposeVersionSanitization {
const sanitized = content.replace(TOP_LEVEL_VERSION_RE, "");
return {
content: sanitized,
removed: sanitized !== content,
};
}
export function hasObsoleteComposeVersion(content: string): boolean {
return TOP_LEVEL_VERSION_RE.test(content);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import jsyaml from "js-yaml";
import { sanitizeComposeVersion } from "./compose-version";
import type { ServiceConfig, NetworkConfig, VolumeConfig } from "../types/compose";
import { defaultService } from "./default-configs";
@@ -375,7 +376,7 @@ export function parseComposeTemplate(composeContent: string): {
} {
let doc: any;
try {
doc = jsyaml.load(composeContent);
doc = jsyaml.load(sanitizeComposeVersion(composeContent).content);
} catch (err: any) {
throw new Error(`Invalid YAML format: ${err.message}`, { cause: err });
}
@@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest";
import { validateComposeYaml } from "../compose";
describe("validateComposeYaml", () => {
it("accepts and sanitizes obsolete top-level compose version", () => {
const r = validateComposeYaml(`version: "3.8"
services:
web:
image: nginx:latest
`);
expect(r.ok).toBe(true);
});
it("rejects empty input", () => {
const r = validateComposeYaml("");
expect(r.ok).toBe(false);
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
validateConfigItem,
validateCustomYaml,
validateHomepageConfig,
validateHomepageOutput,
} from "../config";
describe("config builder validation", () => {
it("accepts a valid Homepage item and trims safe string input", () => {
const result = validateConfigItem({
name: " Plex ",
description: "Media",
icon: "plex.png",
url: "https://plex.local",
category: "Media",
tags: [" streaming "],
});
expect(result.ok).toBe(true);
expect(result.data?.name).toBe("Plex");
expect(result.data?.tags).toEqual(["streaming"]);
});
it("rejects missing names and non-http URLs", () => {
const result = validateConfigItem({
name: "",
url: "ftp://plex.local",
tags: [],
});
expect(result.ok).toBe(false);
expect(result.issues.some((issue) => issue.path === "name")).toBe(true);
expect(result.issues.some((issue) => issue.path === "url")).toBe(true);
});
it("validates the Homepage aggregate and generated output shape", () => {
expect(
validateHomepageConfig({
items: [{ name: "Plex", url: "https://plex.local", tags: [] }],
}).ok,
).toBe(true);
expect(
validateHomepageOutput(`services:
- Name: Plex
URL: https://plex.local
`).ok,
).toBe(true);
});
it("rejects malformed custom YAML before output use", () => {
const result = validateCustomYaml("name: [unterminated");
expect(result.ok).toBe(false);
expect(result.issues[0]?.code).toBe("invalid_yaml");
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { validateComposeOutput } from "../outputs";
import {
validateCustomYaml,
validateHomepageOutput,
} from "../config";
import { validateSchedulerOutput } from "../scheduler";
describe("validated output edge cases", () => {
it("accepts valid compose output and rejects malformed compose output", () => {
expect(
validateComposeOutput(
"compose",
`services:
web:
image: nginx
`,
).ok,
).toBe(true);
expect(validateComposeOutput("compose", "services: [").ok).toBe(false);
});
it("rejects malformed docker-run, systemd, and komodo output", () => {
expect(validateComposeOutput("docker-run", "podman run nginx").ok).toBe(
false,
);
expect(validateComposeOutput("systemd", "[Service]\nExecStop=/bin/true").ok)
.toBe(false);
expect(
validateComposeOutput(
"komodo",
"# Note: Error parsing configuration: bad yaml",
).ok,
).toBe(false);
});
it("validates successful GitHub Actions scheduler output", () => {
expect(
validateSchedulerOutput(
"github-actions",
`name: Nightly
on:
schedule:
- cron: '0 0 * * *'
jobs:
scheduled:
runs-on: ubuntu-latest
steps:
- run: echo ok
`,
).ok,
).toBe(true);
});
it("rejects GitHub Actions output without a scheduled cron trigger", () => {
expect(
validateSchedulerOutput(
"github-actions",
`name: Manual
on:
workflow_dispatch:
jobs:
scheduled:
runs-on: ubuntu-latest
steps:
- run: echo ok
`,
).ok,
).toBe(false);
});
it("rejects invalid generated config outputs", () => {
expect(validateCustomYaml("valid: true").ok).toBe(true);
expect(validateHomepageOutput("services:\n - Name: Bad\n").ok).toBe(false);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { defaultService, defaultVPNConfig } from "../../default-configs";
import {
createValidatedComposeOutput,
invalidOutputMessage,
validateComposeOutput,
} from "../outputs";
describe("validated compose outputs", () => {
it("blocks conversion output when project validation fails", () => {
const result = createValidatedComposeOutput("docker-run", {
services: [{ ...defaultService(), name: "web", image: "" }],
networks: [],
volumes: [],
vpn: defaultVPNConfig(),
});
expect(result.ok).toBe(false);
expect(result.output).toBe("");
expect(invalidOutputMessage(result)).toMatch(/Validation blocked output/);
});
it("allows docker-run output for valid services", () => {
const result = createValidatedComposeOutput("docker-run", {
services: [{ ...defaultService(), name: "web", image: "nginx:latest" }],
networks: [],
volumes: [],
vpn: defaultVPNConfig(),
});
expect(result.ok).toBe(true);
expect(result.output).toContain("docker run");
expect(result.output).toContain("nginx:latest");
});
it("rejects redacted output that still leaks sensitive-looking values", () => {
const result = validateComposeOutput(
"redact",
`services:
web:
image: nginx
environment:
API_TOKEN: abc123
`,
);
expect(result.ok).toBe(false);
expect(result.issues.some((issue) => issue.path === "redact")).toBe(true);
});
it("validates .env placeholder output", () => {
expect(validateComposeOutput("env", "DATABASE_URL=\nTOKEN=").ok).toBe(true);
expect(validateComposeOutput("env", "DATABASE_URL=value").ok).toBe(false);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
isValidCronExpression,
validateScheduleConfig,
validateSchedulerOutput,
} from "../scheduler";
describe("scheduler validation", () => {
it("accepts common five-field cron syntax", () => {
expect(isValidCronExpression("0 0 * * *")).toBe(true);
expect(isValidCronExpression("*/15 1-5 * * 1,3,5")).toBe(true);
});
it("rejects malformed or out-of-range cron syntax", () => {
expect(isValidCronExpression("0 24 * * *")).toBe(false);
expect(isValidCronExpression("0 0 * *")).toBe(false);
expect(isValidCronExpression("nope")).toBe(false);
});
it("rejects invalid schedule fields and unsafe multiline commands", () => {
const result = validateScheduleConfig({
type: "cron",
name: "nightly",
schedule: "0 0 * * *",
command: "echo ok\nrm -rf /",
user: "root",
workingDir: "/srv/app",
});
expect(result.ok).toBe(false);
expect(result.issues.some((issue) => issue.path === "command")).toBe(true);
});
it("rejects GitHub Actions-only incompatible fields", () => {
const result = validateScheduleConfig({
type: "github-actions",
name: "nightly",
schedule: "0 0 * * *",
command: "pnpm test",
user: "root",
workingDir: "/srv/app",
});
expect(result.ok).toBe(false);
expect(result.issues.some((issue) => issue.path === "user")).toBe(true);
expect(result.issues.some((issue) => issue.path === "workingDir")).toBe(
true,
);
});
it("validates generated output by target format", () => {
expect(
validateSchedulerOutput(
"systemd-timer",
"[Timer]\nOnCalendar=*-*-* 00:00:00\n[Service]\nExecStart=/bin/true",
).ok,
).toBe(true);
expect(validateSchedulerOutput("systemd-timer", "ExecStart=/bin/true").ok)
.toBe(false);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
validateTemplateDetails,
validateTemplateMetaList,
} from "../templates";
describe("template validation", () => {
it("accepts valid marketplace metadata", () => {
const result = validateTemplateMetaList([
{
id: "nginx",
name: "Nginx",
description: "Reverse proxy",
tags: ["proxy"],
},
]);
expect(result.ok).toBe(true);
expect(result.data?.[0]?.id).toBe("nginx");
});
it("rejects invalid cached marketplace metadata", () => {
const result = validateTemplateMetaList([
{ id: "../escape", name: "Bad" },
{ id: "missing-name" },
]);
expect(result.ok).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
});
it("drops invalid optional links without rejecting marketplace metadata", () => {
const result = validateTemplateMetaList([
{
id: "plex",
name: "Plex",
links: {
website: "",
docs: "not-a-url",
github: "https://github.com/example/plex",
},
},
]);
expect(result.ok).toBe(true);
expect(result.data?.[0]?.links).toEqual({
github: "https://github.com/example/plex",
});
});
it("requires compose content for template details", () => {
const result = validateTemplateDetails({
id: "nginx",
name: "Nginx",
composeContent: "",
});
expect(result.ok).toBe(false);
expect(result.issues.some((issue) => issue.path === "composeContent")).toBe(
true,
);
});
it("allows template details to report sanitized obsolete compose version", () => {
const result = validateTemplateDetails({
id: "nginx",
name: "Nginx",
composeContent: "services:\n web:\n image: nginx\n",
composeVersionRemoved: true,
});
expect(result.ok).toBe(true);
expect(result.data?.composeVersionRemoved).toBe(true);
});
});
+11 -2
View File
@@ -6,6 +6,7 @@
// Returns a list of human-readable error strings (empty = clean).
import jsyaml from "js-yaml";
import { sanitizeComposeVersion } from "../compose-version";
import type { ServiceConfig } from "../../types/compose";
import {
parseComposeService,
@@ -31,9 +32,17 @@ export function validateComposeYaml(yaml: string): ComposeValidationResult {
return { ok: false, errors: ["Compose file is empty"] };
}
const sanitized = sanitizeComposeVersion(yaml).content;
if (!sanitized.trim()) {
return {
ok: false,
errors: ["Compose file must declare at least one service under `services:`"],
};
}
let parsed: unknown;
try {
parsed = jsyaml.load(yaml);
parsed = jsyaml.load(sanitized);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, errors: [`Invalid YAML: ${msg}`] };
@@ -57,7 +66,7 @@ export function validateComposeYaml(yaml: string): ComposeValidationResult {
let volumesParsed: ReturnType<typeof parseComposeService>["volumes"] = [];
try {
const result = parseComposeTemplate(yaml);
const result = parseComposeTemplate(sanitized);
const allNetworks = isPlainObject((parsed as Record<string, unknown>).networks)
? ((parsed as Record<string, unknown>).networks as Record<string, unknown>)
: {};
+118
View File
@@ -0,0 +1,118 @@
import yaml from "js-yaml";
import { z } from "zod";
import {
issuesToResult,
validateWithSchema,
validationError,
type ValidationResult,
} from "./issues";
const optionalTrimmed = (max: number, label: string) =>
z
.string()
.trim()
.max(max, `${label} must be ${max} characters or fewer`)
.optional()
.or(z.literal(""));
export const configItemSchema = z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.max(80, "Name must be 80 characters or fewer"),
description: optionalTrimmed(240, "Description"),
icon: optionalTrimmed(160, "Icon"),
url: z
.string()
.trim()
.min(1, "URL is required")
.url("URL must be absolute, for example https://app.local")
.refine((value) => /^https?:\/\//i.test(value), {
message: "URL must start with http:// or https://",
}),
category: optionalTrimmed(80, "Category"),
tags: z
.array(
z
.string()
.trim()
.min(1, "Tag cannot be empty")
.max(40, "Tag must be 40 characters or fewer"),
)
.max(20, "Use 20 tags or fewer")
.default([]),
});
export const homepageConfigSchema = z.object({
items: z.array(configItemSchema).max(200, "Use 200 homepage items or fewer"),
});
export const homepageOutputSchema = z.object({
services: z
.array(
z.object({
Name: z.string().min(1),
Description: z.string().optional(),
Icon: z.string().optional(),
URL: z.string().url(),
Category: z.string().optional(),
Tags: z.array(z.string().min(1)).optional(),
}),
)
.max(200),
});
export type ConfigItemInput = z.input<typeof configItemSchema>;
export type ConfigItem = z.output<typeof configItemSchema>;
export type HomepageConfig = z.output<typeof homepageConfigSchema>;
export function validateConfigItem(item: unknown): ValidationResult & {
data?: ConfigItem;
} {
return validateWithSchema(configItemSchema, item, "config.item");
}
export function validateHomepageConfig(config: unknown): ValidationResult & {
data?: HomepageConfig;
} {
return validateWithSchema(homepageConfigSchema, config, "config.homepage");
}
export function validateCustomYaml(text: string): ValidationResult {
if (!text.trim()) return { ok: true, issues: [] };
try {
yaml.load(text);
return { ok: true, issues: [] };
} catch (error: unknown) {
return issuesToResult([
validationError(
"config.customYaml",
"customOutput",
error instanceof Error ? error.message : "Custom YAML is invalid",
"invalid_yaml",
"Fix the YAML syntax before copying or exporting it.",
),
]);
}
}
export function validateHomepageOutput(output: string): ValidationResult {
try {
const parsed = yaml.load(output);
return validateWithSchema(
homepageOutputSchema,
parsed,
"config.homepageOutput",
);
} catch (error: unknown) {
return issuesToResult([
validationError(
"config.homepageOutput",
"output",
error instanceof Error ? error.message : "Generated YAML is invalid",
"invalid_yaml",
),
]);
}
}
+78
View File
@@ -0,0 +1,78 @@
import { z } from "zod";
export type ValidationSeverity = "error" | "warning";
export interface ValidationIssue {
path: string;
code: string;
message: string;
severity: ValidationSeverity;
source: string;
suggestedFix?: string;
}
export interface ValidationResult {
ok: boolean;
issues: ValidationIssue[];
}
export function validationOk(): ValidationResult {
return { ok: true, issues: [] };
}
export function validationError(
source: string,
path: string,
message: string,
code = "invalid_value",
suggestedFix?: string,
): ValidationIssue {
return {
source,
path,
message,
code,
severity: "error",
...(suggestedFix ? { suggestedFix } : {}),
};
}
export function issuesToResult(issues: ValidationIssue[]): ValidationResult {
return { ok: issues.length === 0, issues };
}
export function formatValidationIssues(issues: ValidationIssue[]): string {
return issues
.map((issue) => {
const label = issue.path ? `${issue.path}: ` : "";
return `${label}${issue.message}`;
})
.join("; ");
}
export function zodIssuesToValidationIssues(
error: z.ZodError,
source: string,
): ValidationIssue[] {
return error.issues.map((issue) =>
validationError(
source,
issue.path.map(String).join("."),
issue.message,
issue.code,
),
);
}
export function validateWithSchema<T>(
schema: z.ZodType<T>,
value: unknown,
source: string,
): ValidationResult & { data?: T } {
const result = schema.safeParse(value);
if (result.success) return { ...validationOk(), data: result.data };
return {
ok: false,
issues: zodIssuesToValidationIssues(result.error, source),
};
}
+219
View File
@@ -0,0 +1,219 @@
import type {
NetworkConfig,
ServiceConfig,
VolumeConfig,
} from "../../types/compose";
import type { VPNConfig } from "../../types/vpn-configs";
import {
convertToDockerRun,
convertToSystemd,
generateEnvFile,
generateKomodoToml,
} from "../converters";
import { defaultVPNConfig } from "../default-configs";
import { generateYaml } from "../yaml-generator";
import {
redactSensitiveData,
validateNetworks,
validateServices,
validateVolumes,
} from "../validation";
import { validateVpnConfig } from "./vpn";
import { validateComposeYaml } from "./compose";
import {
formatValidationIssues,
issuesToResult,
validationError,
type ValidationIssue,
type ValidationResult,
} from "./issues";
export type ComposeOutputFormat =
| "compose"
| "docker-run"
| "systemd"
| "env"
| "redact"
| "komodo";
export interface ComposeOutputContext {
services: ServiceConfig[];
networks: NetworkConfig[];
volumes: VolumeConfig[];
vpn?: VPNConfig;
yaml?: string;
selectedIdx?: number | null;
}
export interface ValidatedOutput extends ValidationResult {
output: string;
}
function legacyErrorsToIssues(
errors: string[],
source: string,
): ValidationIssue[] {
return errors.map((message, index) =>
validationError(source, String(index), message),
);
}
export function validateComposeProject({
services,
networks,
volumes,
vpn,
}: ComposeOutputContext): ValidationResult {
const vpnConfig = vpn ?? defaultVPNConfig();
return issuesToResult([
...legacyErrorsToIssues(validateServices(services), "compose.services"),
...legacyErrorsToIssues(validateNetworks(networks), "compose.networks"),
...legacyErrorsToIssues(validateVolumes(volumes), "compose.volumes"),
...legacyErrorsToIssues(validateVpnConfig(vpnConfig), "compose.vpn"),
]);
}
function validateEnvOutput(output: string): ValidationIssue[] {
if (!output.trim()) return [];
return output
.split("\n")
.flatMap((line, index) =>
/^[A-Z_][A-Z0-9_]*=$/i.test(line)
? []
: [
validationError(
"compose.output",
`env.${index + 1}`,
".env output lines must be KEY= placeholders",
),
],
);
}
function validateRedactedOutput(output: string): ValidationIssue[] {
const leaked = /(password|secret|api[_-]?key|token|auth[_-]?token|access[_-]?key|private[_-]?key)\s*[:=]\s*["']?(?!\*\*\*REDACTED\*\*\*)[^"'\n]+/i.test(
output,
);
return leaked
? [
validationError(
"compose.output",
"redact",
"Redacted output still contains sensitive-looking values",
),
]
: [];
}
export function validateComposeOutput(
format: ComposeOutputFormat,
output: string,
): ValidationResult {
const issues: ValidationIssue[] = [];
if (format === "compose" || format === "redact") {
const result = validateComposeYaml(output);
issues.push(
...legacyErrorsToIssues(
result.errors,
format === "compose" ? "compose.output" : "compose.redactedOutput",
),
);
}
if (format === "docker-run" && !/^docker run\b/m.test(output)) {
issues.push(
validationError(
"compose.output",
"dockerRun",
"Docker run output must contain a docker run command",
),
);
}
if (
format === "systemd" &&
(!output.includes("[Unit]") || !output.includes("ExecStart="))
) {
issues.push(
validationError(
"compose.output",
"systemd",
"systemd output must include a unit and ExecStart",
),
);
}
if (format === "env") {
issues.push(...validateEnvOutput(output));
}
if (format === "redact") {
issues.push(...validateRedactedOutput(output));
}
if (format === "komodo" && output.includes("Error parsing configuration")) {
issues.push(
validationError(
"compose.output",
"komodo",
"Komodo TOML cannot be generated from invalid Compose YAML",
),
);
}
return issuesToResult(issues);
}
export function createValidatedComposeOutput(
format: ComposeOutputFormat,
context: ComposeOutputContext,
): ValidatedOutput {
const project = validateComposeProject(context);
if (!project.ok) return { ...project, output: "" };
const services =
context.selectedIdx !== undefined &&
context.selectedIdx !== null &&
context.services[context.selectedIdx]
? [context.services[context.selectedIdx]]
: context.services;
const yaml =
context.yaml ??
generateYaml(
context.services,
context.networks,
context.volumes,
context.vpn,
);
let output = "";
switch (format) {
case "compose":
output = yaml;
break;
case "docker-run":
output = services.map((service) => convertToDockerRun(service)).join("\n\n");
break;
case "systemd":
output = services.map((service) => convertToSystemd(service)).join("\n\n");
break;
case "env":
output = generateEnvFile(context.services, context.vpn);
break;
case "redact":
output = redactSensitiveData(yaml);
break;
case "komodo":
output = generateKomodoToml(yaml);
break;
}
const outputValidation = validateComposeOutput(format, output);
return { ...outputValidation, output };
}
export function invalidOutputMessage(result: ValidationResult): string {
return `Validation blocked output: ${formatValidationIssues(result.issues)}`;
}
+215
View File
@@ -0,0 +1,215 @@
import yaml from "js-yaml";
import { z } from "zod";
import {
issuesToResult,
validateWithSchema,
validationError,
type ValidationResult,
} from "./issues";
export const scheduleTypeSchema = z.enum([
"cron",
"github-actions",
"systemd-timer",
]);
const CRON_RANGES = [
[0, 59],
[0, 23],
[1, 31],
[1, 12],
[0, 7],
] as const;
const USER_RE = /^(?:\d+|[a-z_][a-z0-9_-]*)(?::(?:\d+|[a-z_][a-z0-9_-]*))?$/i;
const ABS_UNIX_PATH_RE = /^\/[^\0\n\r]*$/;
function isCronAtom(atom: string, min: number, max: number): boolean {
if (atom === "*") return true;
const stepped = atom.split("/");
if (stepped.length > 2) return false;
if (stepped[1] !== undefined) {
const step = Number(stepped[1]);
if (!Number.isInteger(step) || step < 1 || step > max) return false;
}
const range = stepped[0];
if (!range) return false;
if (range === "*") return true;
if (range.includes("-")) {
const [start, end] = range.split("-").map(Number);
return (
Number.isInteger(start) &&
Number.isInteger(end) &&
start >= min &&
end <= max &&
start <= end
);
}
const value = Number(range);
return Number.isInteger(value) && value >= min && value <= max;
}
function isCronField(field: string, min: number, max: number): boolean {
return field.split(",").every((atom) => isCronAtom(atom, min, max));
}
export function isValidCronExpression(value: string): boolean {
const parts = value.trim().split(/\s+/);
if (parts.length !== 5) return false;
return parts.every((part, index) => {
const [min, max] = CRON_RANGES[index];
return isCronField(part, min, max);
});
}
export const scheduleConfigSchema = z
.object({
type: scheduleTypeSchema,
name: z
.string()
.trim()
.min(1, "Name is required")
.max(80, "Name must be 80 characters or fewer")
.regex(
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
"Name must start with a letter or digit and contain only letters, digits, dots, dashes, or underscores",
),
schedule: z
.string()
.trim()
.min(1, "Schedule is required")
.refine(isValidCronExpression, {
message: "Schedule must be a valid five-field cron expression",
}),
command: z
.string()
.trim()
.min(1, "Command is required")
.max(1000, "Command must be 1000 characters or fewer")
.refine((value) => !/[\0\r\n]/.test(value), {
message: "Command must be a single line without control characters",
}),
description: z
.string()
.trim()
.max(160, "Description must be 160 characters or fewer")
.optional()
.or(z.literal("")),
enabled: z.boolean().optional(),
user: z
.string()
.trim()
.regex(USER_RE, "User must be uid[:gid] or username[:group]")
.optional()
.or(z.literal("")),
workingDir: z
.string()
.trim()
.regex(ABS_UNIX_PATH_RE, "Working directory must be an absolute Unix path")
.optional()
.or(z.literal("")),
})
.superRefine((cfg, ctx) => {
if (cfg.type === "github-actions" && cfg.user) {
ctx.addIssue({
code: "custom",
path: ["user"],
message: "GitHub Actions schedules do not support a cron user field",
});
}
if (cfg.type === "github-actions" && cfg.workingDir) {
ctx.addIssue({
code: "custom",
path: ["workingDir"],
message:
"GitHub Actions output does not support this working directory field",
});
}
});
export type ScheduleType = z.output<typeof scheduleTypeSchema>;
export type ScheduleConfig = z.output<typeof scheduleConfigSchema>;
export type ScheduleConfigInput = z.input<typeof scheduleConfigSchema>;
export function validateScheduleConfig(config: unknown): ValidationResult & {
data?: ScheduleConfig;
} {
return validateWithSchema(scheduleConfigSchema, config, "scheduler.config");
}
export function validateSchedulerOutput(
type: ScheduleType,
output: string,
): ValidationResult {
const issues = [];
if (!output.trim()) {
issues.push(
validationError("scheduler.output", "output", "Output is empty"),
);
}
if (type === "cron" && output.trim().split(/\s+/).length < 7) {
issues.push(
validationError(
"scheduler.output",
"cron",
"Cron output must include schedule, user, and command",
),
);
}
if (type === "github-actions") {
try {
const parsed = yaml.load(output) as Record<string, unknown>;
const onValue =
parsed && typeof parsed === "object" ? parsed.on : undefined;
const schedule =
onValue && typeof onValue === "object" && !Array.isArray(onValue)
? (onValue as Record<string, unknown>).schedule
: undefined;
const hasScheduleCron =
Array.isArray(schedule) &&
schedule.some(
(item) =>
item !== null &&
typeof item === "object" &&
!Array.isArray(item) &&
typeof (item as Record<string, unknown>).cron === "string",
);
if (!hasScheduleCron) {
issues.push(
validationError(
"scheduler.output",
"githubActions",
"GitHub Actions output must include an on.schedule trigger",
),
);
}
} catch (error: unknown) {
issues.push(
validationError(
"scheduler.output",
"githubActions",
error instanceof Error ? error.message : "Generated workflow YAML is invalid",
"invalid_yaml",
),
);
}
}
if (
type === "systemd-timer" &&
(!output.includes("[Timer]") || !output.includes("[Service]"))
) {
issues.push(
validationError(
"scheduler.output",
"systemd",
"systemd output must include timer and service units",
),
);
}
return issuesToResult(issues);
}
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
const optionalUrl = z.preprocess((value) => {
if (typeof value !== "string" || value.trim() === "") return undefined;
const result = z.string().url().safeParse(value);
return result.success ? result.data : undefined;
}, z.string().url().optional());
import {
validateWithSchema,
type ValidationResult,
} from "./issues";
export const templateMetaSchema = z.object({
id: z
.string()
.trim()
.min(1, "Template id is required")
.max(120, "Template id is too long")
.regex(
/^[a-zA-Z0-9_.-]+$/,
"Template id may only contain letters, digits, dots, dashes, and underscores",
),
name: z.string().trim().min(1, "Template name is required").max(120),
description: z.string().trim().max(500).optional(),
version: z.string().trim().max(80).optional(),
logo: z.string().trim().max(240).optional(),
tags: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
links: z
.object({
github: optionalUrl,
website: optionalUrl,
docs: optionalUrl,
})
.partial()
.transform((links) => {
const cleaned = Object.fromEntries(
Object.entries(links).filter(([, value]) => value !== undefined),
);
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
})
.optional(),
});
export const templateMetaListSchema = z
.array(templateMetaSchema)
.max(1000, "Template list is too large");
export const templateDetailsSchema = templateMetaSchema.extend({
composeContent: z.string().min(1, "Template compose content is required"),
composeVersionRemoved: z.boolean().optional(),
logoUrl: z.string().url().optional(),
});
export type TemplateMeta = z.output<typeof templateMetaSchema>;
export type TemplateDetails = z.output<typeof templateDetailsSchema>;
export function validateTemplateMetaList(value: unknown): ValidationResult & {
data?: TemplateMeta[];
} {
return validateWithSchema(
templateMetaListSchema,
value,
"templates.meta",
);
}
export function validateTemplateDetails(value: unknown): ValidationResult & {
data?: TemplateDetails;
} {
return validateWithSchema(
templateDetailsSchema,
value,
"templates.details",
);
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/__tests__/setup.ts"],
include: [
"src/utils/validation/**/*.{test,spec}.ts",
"src/utils/__tests__/generators.golden.test.ts",
],
coverage: {
provider: "v8",
reporter: ["text"],
include: [
"src/utils/validation/**/*.ts",
"src/utils/converters.ts",
],
exclude: ["src/utils/validation/**/*.{test,spec}.ts"],
thresholds: {
lines: 80,
functions: 80,
branches: 65,
statements: 80,
},
},
},
});