mirror of
https://github.com/hhftechnology/Dock-Dploy.git
synced 2026-11-12 03:47:58 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c0f82351 |
Generated
+27
-4
@@ -1237,6 +1237,7 @@
|
||||
"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"
|
||||
@@ -2408,6 +2409,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2424,6 +2426,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2440,6 +2443,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2456,6 +2460,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2472,6 +2477,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2488,6 +2494,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2504,6 +2511,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2520,6 +2528,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2536,6 +2545,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2552,6 +2562,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2568,6 +2579,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2584,6 +2596,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2600,6 +2613,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2618,6 +2632,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2634,6 +2649,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3345,7 +3361,7 @@
|
||||
"version": "25.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
|
||||
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.21.0"
|
||||
@@ -3355,7 +3371,7 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3365,7 +3381,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==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -4346,6 +4362,7 @@
|
||||
"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": {
|
||||
@@ -5828,6 +5845,7 @@
|
||||
"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",
|
||||
@@ -5986,6 +6004,7 @@
|
||||
"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",
|
||||
@@ -6222,6 +6241,7 @@
|
||||
"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",
|
||||
@@ -6255,6 +6275,7 @@
|
||||
"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": {
|
||||
@@ -6675,7 +6696,7 @@
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
||||
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
@@ -6800,6 +6821,7 @@
|
||||
"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",
|
||||
@@ -6877,6 +6899,7 @@
|
||||
"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"
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
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) => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./ui/dialog";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { Label } from "./ui/label";
|
||||
import { Copy, Download, AlertCircle } from "lucide-react";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { copyToClipboard, downloadFile } from "../utils/clipboard";
|
||||
import type { ConversionType } from "../hooks/useConversionDialog";
|
||||
|
||||
@@ -17,7 +17,6 @@ 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;
|
||||
@@ -52,7 +51,6 @@ export function ConversionDialog({
|
||||
onOpenChange,
|
||||
conversionType,
|
||||
conversionOutput,
|
||||
conversionValid = true,
|
||||
clearEnvAfterDownload = false,
|
||||
onClearEnvChange,
|
||||
onToast,
|
||||
@@ -62,7 +60,6 @@ export function ConversionDialog({
|
||||
const mimeType = CONVERSION_MIME_TYPES[conversionType] || "text/plain";
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!conversionValid) return;
|
||||
try {
|
||||
await copyToClipboard(conversionOutput);
|
||||
onToast?.({
|
||||
@@ -70,7 +67,7 @@ export function ConversionDialog({
|
||||
description: `${title} has been copied to your clipboard.`,
|
||||
variant: "success",
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
onToast?.({
|
||||
title: "Failed to copy",
|
||||
description: "Could not copy to clipboard. Please try again.",
|
||||
@@ -80,7 +77,6 @@ export function ConversionDialog({
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!conversionValid) return;
|
||||
try {
|
||||
downloadFile(conversionOutput, filename, mimeType);
|
||||
onToast?.({
|
||||
@@ -88,7 +84,7 @@ export function ConversionDialog({
|
||||
description: `Downloading ${filename}`,
|
||||
variant: "success",
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
onToast?.({
|
||||
title: "Download failed",
|
||||
description: "Could not download file. Please try again.",
|
||||
@@ -104,13 +100,6 @@ 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}
|
||||
@@ -131,11 +120,11 @@ export function ConversionDialog({
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCopy} disabled={!conversionValid}>
|
||||
<Button variant="outline" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={handleDownload} disabled={!conversionValid}>
|
||||
<Button onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function Header({ onOpenMarket }: HeaderProps) {
|
||||
</a>
|
||||
<a
|
||||
className="icon-btn"
|
||||
href="https://github.com/hhftechnology/Dock-Dploy"
|
||||
href="https://github.com/hhftechnologies/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 the last remaining service", () => {
|
||||
it("hides the trash icon on an unnamed service", () => {
|
||||
render(
|
||||
<ServicesTab
|
||||
services={[defaultService()]}
|
||||
@@ -22,11 +22,12 @@ describe("ServicesTab", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trash icons for manually added blank services when more than one exists", () => {
|
||||
it("shows the trash icon once a service is named", () => {
|
||||
const svc = { ...defaultService(), name: "web", image: "nginx" };
|
||||
render(
|
||||
<ServicesTab
|
||||
services={[defaultService(), defaultService()]}
|
||||
selectedIdx={1}
|
||||
services={[svc]}
|
||||
selectedIdx={0}
|
||||
selectedType="service"
|
||||
onSelectService={() => {}}
|
||||
onAddService={() => {}}
|
||||
@@ -35,8 +36,8 @@ describe("ServicesTab", () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /remove service/i }),
|
||||
).toHaveLength(2);
|
||||
screen.getByRole("button", { name: /remove service/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onRemoveService when the trash is clicked", async () => {
|
||||
@@ -44,7 +45,7 @@ describe("ServicesTab", () => {
|
||||
const svc = { ...defaultService(), name: "web", image: "nginx" };
|
||||
render(
|
||||
<ServicesTab
|
||||
services={[svc, defaultService()]}
|
||||
services={[svc]}
|
||||
selectedIdx={0}
|
||||
selectedType="service"
|
||||
onSelectService={() => {}}
|
||||
@@ -54,7 +55,7 @@ describe("ServicesTab", () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /remove service web/i }),
|
||||
screen.getByRole("button", { name: /remove service/i }),
|
||||
);
|
||||
expect(onRemove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
@@ -6,14 +6,15 @@ import type {
|
||||
VolumeConfig,
|
||||
} from "../../types/compose";
|
||||
import type { VPNConfig } from "../../types/vpn-configs";
|
||||
import { copyToClipboard } from "../../utils/clipboard";
|
||||
import {
|
||||
createValidatedComposeOutput,
|
||||
type ComposeOutputFormat,
|
||||
} from "../../utils/validation/outputs";
|
||||
import { formatValidationIssues } from "../../utils/validation/issues";
|
||||
convertToDockerRun,
|
||||
convertToSystemd,
|
||||
generateKomodoToml,
|
||||
generateEnvFile,
|
||||
} from "../../utils/converters";
|
||||
import { copyToClipboard } from "../../utils/clipboard";
|
||||
|
||||
type FormatId = Exclude<ComposeOutputFormat, "redact">;
|
||||
type FormatId = "compose" | "docker-run" | "systemd" | "env" | "komodo";
|
||||
|
||||
interface FormatTab {
|
||||
id: FormatId;
|
||||
@@ -43,8 +44,6 @@ export interface CodePanelProps {
|
||||
export function CodePanel({
|
||||
yaml,
|
||||
services,
|
||||
networks,
|
||||
volumes,
|
||||
vpn,
|
||||
validationError,
|
||||
validationSuccess: _validationSuccess,
|
||||
@@ -53,21 +52,22 @@ export function CodePanel({
|
||||
const [format, setFormat] = useState<FormatId>("compose");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
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 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 filename =
|
||||
FORMATS.find((f) => f.id === format)?.filename || "compose.yml";
|
||||
@@ -77,23 +77,18 @@ 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(validatedOutput.output);
|
||||
await copyToClipboard(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">
|
||||
@@ -107,7 +102,7 @@ export function CodePanel({
|
||||
onClick={onValidate}
|
||||
title="Validate & reformat"
|
||||
>
|
||||
{activeValidationError ? (
|
||||
{validationError ? (
|
||||
<>
|
||||
<AlertCircle size={14} />
|
||||
Re-validate
|
||||
@@ -141,7 +136,6 @@ export function CodePanel({
|
||||
type="button"
|
||||
className="code-copy"
|
||||
onClick={handleCopy}
|
||||
disabled={!canCopy}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
@@ -150,11 +144,11 @@ export function CodePanel({
|
||||
<CodeView text={output} />
|
||||
<div className="code-statusbar">
|
||||
<span
|
||||
className={"status-tag" + (activeValidationError ? " err" : "")}
|
||||
className={"status-tag" + (validationError ? " err" : "")}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={"status-dot " + statusKind} aria-hidden />
|
||||
{activeValidationError ? activeValidationError : "Valid"}
|
||||
{validationError ? validationError : "Valid"}
|
||||
</span>
|
||||
<span className="status-mono">
|
||||
{lineCount} {lineCount === 1 ? "line" : "lines"} · UTF-8 · LF
|
||||
@@ -166,20 +160,10 @@ 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,9 +55,10 @@ export function ServicesTab({
|
||||
const displayName = svc.name?.trim() || "";
|
||||
const imageName = svc.image?.trim() || "";
|
||||
const meta = imageName || "no image specified";
|
||||
// Allow deleting manually added blank rows. Only the last
|
||||
// remaining service is protected so the builder is never stranded.
|
||||
const showTrash = services.length > 1;
|
||||
// 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);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -29,7 +28,6 @@ export interface TemplateDetails {
|
||||
docs?: string;
|
||||
};
|
||||
composeContent?: string;
|
||||
composeVersionRemoved?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateDetailModalProps {
|
||||
@@ -51,25 +49,18 @@ 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 (!composeContent) return { ok: true, errors: [] as string[] };
|
||||
return validateComposeYaml(composeContent);
|
||||
}, [composeContent]);
|
||||
if (!template?.composeContent) return { ok: true, errors: [] as string[] };
|
||||
return validateComposeYaml(template.composeContent);
|
||||
}, [template?.composeContent]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!template || !composeContent) return;
|
||||
if (!template?.composeContent) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(composeContent);
|
||||
await copyToClipboard(template.composeContent);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description: "Docker Compose content has been copied",
|
||||
@@ -85,11 +76,11 @@ export function TemplateDetailModal({
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!template || !composeContent) return;
|
||||
if (!template?.composeContent) return;
|
||||
|
||||
try {
|
||||
downloadFile(
|
||||
composeContent,
|
||||
template.composeContent,
|
||||
`${template.name.toLowerCase().replace(/\s+/g, "-")}-compose.yml`,
|
||||
"text/yaml"
|
||||
);
|
||||
@@ -120,7 +111,7 @@ export function TemplateDetailModal({
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
await onImport({ ...template, composeContent });
|
||||
await onImport(template);
|
||||
toast({
|
||||
title: "Template imported",
|
||||
description: `${template.name} has been imported successfully`,
|
||||
@@ -296,12 +287,12 @@ export function TemplateDetailModal({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compose" className="py-4">
|
||||
{composeContent ? (
|
||||
{template.composeContent ? (
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="editor-h-lg dialog-h-short">
|
||||
<CodeEditor
|
||||
content={composeContent}
|
||||
content={template.composeContent}
|
||||
onContentChange={() => {}}
|
||||
height={undefined}
|
||||
width={undefined}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"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",
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -70,16 +70,16 @@ function ToastContainer() {
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
const variantStyles = {
|
||||
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",
|
||||
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",
|
||||
}
|
||||
|
||||
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-2xl 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-lg 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">{toast.description}</div>
|
||||
<div className="text-sm opacity-90">{toast.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{toast.action}
|
||||
|
||||
@@ -51,31 +51,6 @@ 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);
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type {
|
||||
NetworkConfig,
|
||||
ServiceConfig,
|
||||
VolumeConfig,
|
||||
} from "../types/compose";
|
||||
import type { ServiceConfig } from "../types/compose";
|
||||
import type { VPNConfig } from "../types/vpn-configs";
|
||||
import {
|
||||
createValidatedComposeOutput,
|
||||
invalidOutputMessage,
|
||||
type ComposeOutputFormat,
|
||||
} from "../utils/validation/outputs";
|
||||
convertToDockerRun,
|
||||
convertToSystemd,
|
||||
generateKomodoToml,
|
||||
generateEnvFile,
|
||||
} from "../utils/converters";
|
||||
import { redactSensitiveData } from "../utils/validation";
|
||||
|
||||
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;
|
||||
@@ -24,8 +20,6 @@ export interface UseConversionDialogOptions {
|
||||
|
||||
export function useConversionDialog({
|
||||
services,
|
||||
networks = [],
|
||||
volumes = [],
|
||||
selectedIdx,
|
||||
yaml,
|
||||
vpnConfig,
|
||||
@@ -33,26 +27,48 @@ 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;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
},
|
||||
[services, networks, volumes, selectedIdx, yaml, vpnConfig],
|
||||
[services, selectedIdx, yaml, vpnConfig]
|
||||
);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
@@ -64,7 +80,6 @@ export function useConversionDialog({
|
||||
setConversionDialogOpen,
|
||||
conversionType,
|
||||
conversionOutput,
|
||||
conversionValid,
|
||||
handleConversion,
|
||||
closeDialog,
|
||||
};
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
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";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
type Template = TemplateMeta;
|
||||
type SelectedTemplate = TemplateDetails | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Template = any;
|
||||
|
||||
export interface UseTemplateStoreReturn {
|
||||
templateStoreOpen: boolean;
|
||||
setTemplateStoreOpen: (open: boolean) => void;
|
||||
templates: Template[];
|
||||
setTemplates: Dispatch<SetStateAction<Template[]>>;
|
||||
setTemplates: React.Dispatch<React.SetStateAction<Template[]>>;
|
||||
templateLoading: boolean;
|
||||
setTemplateLoading: (loading: boolean) => void;
|
||||
templateError: string | null;
|
||||
setTemplateError: (error: string | null) => void;
|
||||
templateSearch: string;
|
||||
setTemplateSearch: (search: string) => void;
|
||||
selectedTemplate: SelectedTemplate;
|
||||
setSelectedTemplate: (template: SelectedTemplate) => void;
|
||||
selectedTemplate: Template;
|
||||
setSelectedTemplate: (template: Template) => void;
|
||||
templateDetailOpen: boolean;
|
||||
setTemplateDetailOpen: (open: boolean) => void;
|
||||
templateDetailTab: "overview" | "compose";
|
||||
@@ -36,7 +23,7 @@ export interface UseTemplateStoreReturn {
|
||||
templateCache: Template[];
|
||||
templateCacheTimestamp: number | null;
|
||||
fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise<void>;
|
||||
fetchTemplateDetails: (templateId: string) => Promise<TemplateDetails>;
|
||||
fetchTemplateDetails: (templateId: string) => Promise<Template>;
|
||||
refreshTemplateStore: () => void;
|
||||
}
|
||||
|
||||
@@ -53,17 +40,9 @@ 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: validation.data,
|
||||
ts: Number.isFinite(parsedTs) ? parsedTs : null,
|
||||
templates: t ? JSON.parse(t) : [],
|
||||
ts: ts ? parseInt(ts, 10) : null,
|
||||
};
|
||||
} catch {
|
||||
return { templates: [], ts: null };
|
||||
@@ -77,7 +56,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<SelectedTemplate>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template>(null);
|
||||
const [templateDetailOpen, setTemplateDetailOpen] = useState(false);
|
||||
const [templateDetailTab, setTemplateDetailTab] = useState<
|
||||
"overview" | "compose"
|
||||
@@ -99,19 +78,13 @@ export function useTemplateStore(): UseTemplateStoreReturn {
|
||||
if (!metaResponse.ok) {
|
||||
throw new Error(`Failed to fetch templates: ${metaResponse.statusText}`);
|
||||
}
|
||||
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 templatesMeta: Template[] = await metaResponse.json();
|
||||
setTemplates(templatesMeta);
|
||||
setTemplateCache(templatesMeta);
|
||||
const now = Date.now();
|
||||
setTemplateCacheTimestamp(now);
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(validation.data));
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(templatesMeta));
|
||||
localStorage.setItem(CACHE_TS_KEY, String(now));
|
||||
} catch {
|
||||
// localStorage may be disabled; non-fatal.
|
||||
@@ -120,6 +93,7 @@ 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);
|
||||
@@ -130,6 +104,7 @@ export function useTemplateStore(): UseTemplateStoreReturn {
|
||||
[],
|
||||
);
|
||||
|
||||
// Open-the-store side effect handled in the setter — event-driven, not useEffect.
|
||||
const setTemplateStoreOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
setTemplateStoreOpenState(open);
|
||||
@@ -143,6 +118,7 @@ export function useTemplateStore(): UseTemplateStoreReturn {
|
||||
setTemplates(templateCache);
|
||||
setTemplateLoading(false);
|
||||
setTemplateError(null);
|
||||
// Background refresh — fire and forget.
|
||||
void fetchTemplatesFromGitHub(true);
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +128,7 @@ export function useTemplateStore(): UseTemplateStoreReturn {
|
||||
);
|
||||
|
||||
const fetchTemplateDetails = useCallback(
|
||||
async (templateId: string): Promise<TemplateDetails> => {
|
||||
async (templateId: string): Promise<Template> => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template) throw new Error(`Template ${templateId} not found`);
|
||||
|
||||
@@ -165,26 +141,13 @@ export function useTemplateStore(): UseTemplateStoreReturn {
|
||||
`Failed to fetch docker-compose.yml: ${composeResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
const rawComposeContent = await composeResponse.text();
|
||||
const sanitizedCompose = sanitizeComposeVersion(rawComposeContent);
|
||||
const composeContent = sanitizedCompose.content;
|
||||
const composeContent = await composeResponse.text();
|
||||
const logoUrl = template.logo
|
||||
? `${GITHUB_RAW_BASE}/${GITHUB_OWNER}/${GITHUB_REPO}/${GITHUB_BRANCH}/${basePath}/${template.logo}`
|
||||
: 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;
|
||||
: null;
|
||||
return { ...template, composeContent, logoUrl };
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error fetching template details for ${templateId}:`,
|
||||
error,
|
||||
|
||||
+243
-718
File diff suppressed because it is too large
Load Diff
@@ -5,24 +5,21 @@ 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 HomepageConfigState {
|
||||
interface ConfigItem {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface HomepageConfig {
|
||||
items: ConfigItem[];
|
||||
}
|
||||
|
||||
@@ -31,17 +28,18 @@ type ConfigType = "homepage" | "custom";
|
||||
function ConfigBuilderRoute() {
|
||||
const navigate = useNavigate();
|
||||
const [configType, setConfigType] = useState<ConfigType>("homepage");
|
||||
const [config, setConfig] = useState<HomepageConfigState>({ items: [] });
|
||||
const [config, setConfig] = useState<HomepageConfig>({ items: [] });
|
||||
const [customOutput, setCustomOutput] = useState<string>("");
|
||||
const [currentItem, setCurrentItem] = useState<ConfigItemInput>(blankItem());
|
||||
const [currentItem, setCurrentItem] = useState<ConfigItem>(blankItem());
|
||||
|
||||
const generateHomepageConfig = useCallback(
|
||||
(items: ConfigItem[]): string => {
|
||||
const obj = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj: any = {
|
||||
services: items.map((item) => ({
|
||||
Name: item.name,
|
||||
...(item.description && { Description: item.description }),
|
||||
...(item.icon && { Icon: item.icon }),
|
||||
Description: item.description,
|
||||
Icon: item.icon,
|
||||
URL: item.url,
|
||||
...(item.category && { Category: item.category }),
|
||||
...(item.tags && item.tags.length > 0 && { Tags: item.tags }),
|
||||
@@ -52,30 +50,14 @@ 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, outputValidation]);
|
||||
}, [configType, config.items, customOutput, generateHomepageConfig]);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
const validation = validateConfigItem(currentItem);
|
||||
if (!validation.ok || !validation.data) return;
|
||||
setConfig({ items: [...config.items, validation.data] });
|
||||
if (!currentItem.name || !currentItem.url) return;
|
||||
setConfig({ items: [...config.items, { ...currentItem }] });
|
||||
setCurrentItem(blankItem());
|
||||
}, [currentItem, config.items]);
|
||||
|
||||
@@ -92,19 +74,11 @@ 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">
|
||||
@@ -150,7 +124,7 @@ function ConfigBuilderRoute() {
|
||||
{config.items.length > 0 && (
|
||||
<div className="service-list">
|
||||
{config.items.map((item, idx) => (
|
||||
<div className="service-row" key={`${item.name}-${idx}`}>
|
||||
<div className="service-row" key={idx}>
|
||||
<span className="status-dot ok" aria-hidden />
|
||||
<span className="service-row-text">
|
||||
<span className="service-row-name">{item.name}</span>
|
||||
@@ -173,14 +147,15 @@ function ConfigBuilderRoute() {
|
||||
) : (
|
||||
<div className="tab-content">
|
||||
<p className="tab-hint">
|
||||
Paste YAML or text-based config. YAML syntax is validated before
|
||||
the output can be copied.
|
||||
Paste any YAML or text-based config and the preview will mirror
|
||||
it exactly.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Item / textarea editor */}
|
||||
<section className="service-col">
|
||||
<div className="col-head">
|
||||
<h2 className="col-head-title">
|
||||
@@ -191,80 +166,71 @@ function ConfigBuilderRoute() {
|
||||
|
||||
{configType === "homepage" ? (
|
||||
<div className="svc-tab">
|
||||
<Field label="Name" required error={fieldError("name")}>
|
||||
<Field label="Name" required>
|
||||
<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" error={fieldError("description")}>
|
||||
<Field label="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" error={fieldError("icon")}>
|
||||
<Field label="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 error={fieldError("url")}>
|
||||
<Field label="URL" required>
|
||||
<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" error={fieldError("category")}>
|
||||
<Field label="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" error={fieldError("tags")}>
|
||||
<Field label="Tags" hint="Comma-separated">
|
||||
<input
|
||||
className="input"
|
||||
value={currentItem.tags?.join(", ") || ""}
|
||||
onChange={(e) =>
|
||||
setCurrentItem({
|
||||
...currentItem,
|
||||
tags: e.target.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
tags: e.target.value.split(",").map((t) => t.trim()),
|
||||
})
|
||||
}
|
||||
aria-invalid={fieldError("tags") ? true : undefined}
|
||||
placeholder="streaming, video"
|
||||
/>
|
||||
</Field>
|
||||
@@ -272,7 +238,7 @@ function ConfigBuilderRoute() {
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
onClick={addItem}
|
||||
disabled={!currentItemValidation.ok}
|
||||
disabled={!currentItem.name || !currentItem.url}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
@@ -281,15 +247,13 @@ function ConfigBuilderRoute() {
|
||||
<div className="svc-tab">
|
||||
<Field
|
||||
label="Custom YAML"
|
||||
hint="The preview mirrors valid YAML exactly."
|
||||
error={customYamlError}
|
||||
hint="Anything goes — the preview shows it verbatim."
|
||||
>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={20}
|
||||
value={customOutput}
|
||||
onChange={(e) => setCustomOutput(e.target.value)}
|
||||
aria-invalid={customYamlError ? true : undefined}
|
||||
placeholder="key: value"
|
||||
/>
|
||||
</Field>
|
||||
@@ -297,6 +261,7 @@ function ConfigBuilderRoute() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Output column */}
|
||||
<section className="code-col">
|
||||
<div className="code-head">
|
||||
<h2 className="code-title">
|
||||
@@ -311,17 +276,12 @@ 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" + (!outputValidation.ok ? " err" : "")}
|
||||
>
|
||||
<span
|
||||
className={"status-dot " + (outputValidation.ok ? "ok" : "err")}
|
||||
aria-hidden
|
||||
/>
|
||||
{outputValidation.ok ? "Valid" : "Blocked"}
|
||||
<span className="status-tag">
|
||||
<span className="status-dot ok" aria-hidden />
|
||||
{output ? "OK" : "Empty"}
|
||||
</span>
|
||||
<span className="status-mono">
|
||||
{output.split("\n").length} lines · UTF-8 · LF
|
||||
@@ -336,17 +296,7 @@ function ConfigBuilderRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
function blankItem(): ConfigItem {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
|
||||
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -59,7 +58,6 @@ type Any = any;
|
||||
|
||||
function ComposeBuilderRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [services, setServices] = useState<ServiceConfig[]>([defaultService()]);
|
||||
|
||||
const {
|
||||
@@ -114,8 +112,6 @@ function ComposeBuilderRoute() {
|
||||
|
||||
const conversionDialog = useConversionDialog({
|
||||
services,
|
||||
networks,
|
||||
volumes,
|
||||
selectedIdx,
|
||||
yaml,
|
||||
vpnConfig,
|
||||
@@ -411,17 +407,22 @@ 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) {
|
||||
selectService(nextLength > 0 ? Math.min(idx, nextLength - 1) : null);
|
||||
// Fall back to the previous row, or 0 when the deleted row was
|
||||
// replaced by a fresh default.
|
||||
selectService(Math.max(0, idx - 1));
|
||||
} else if (selectedIdx > idx) {
|
||||
selectService(selectedIdx - 1);
|
||||
}
|
||||
@@ -430,36 +431,29 @@ 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);
|
||||
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);
|
||||
for (const svc of parsed.services) {
|
||||
importComposeService(svc, parsed.networks, parsed.volumes);
|
||||
}
|
||||
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
|
||||
@@ -473,10 +467,10 @@ function ComposeBuilderRoute() {
|
||||
const currentService =
|
||||
typeof selectedIdx === "number" && services[selectedIdx]
|
||||
? services[selectedIdx]
|
||||
: null;
|
||||
: services[0];
|
||||
|
||||
const formApi: ServiceFormApi = {
|
||||
service: currentService ?? defaultService(),
|
||||
service: currentService,
|
||||
restartOptions: RESTART_OPTIONS,
|
||||
updateServiceField,
|
||||
updatePortField,
|
||||
@@ -532,7 +526,7 @@ function ComposeBuilderRoute() {
|
||||
}
|
||||
};
|
||||
|
||||
const showServiceForm = selectedType === "service" && currentService !== null;
|
||||
const showServiceForm = selectedType === "service";
|
||||
const showNetworkForm =
|
||||
selectedType === "network" &&
|
||||
selectedNetworkIdx !== null &&
|
||||
@@ -659,14 +653,6 @@ 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);
|
||||
@@ -695,7 +681,6 @@ function ComposeBuilderRoute() {
|
||||
onOpenChange={conversionDialog.setConversionDialogOpen}
|
||||
conversionType={conversionDialog.conversionType || ""}
|
||||
conversionOutput={conversionDialog.conversionOutput}
|
||||
conversionValid={conversionDialog.conversionValid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,26 +3,28 @@ 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<ScheduleConfigInput>({
|
||||
const [config, setConfig] = useState<ScheduleConfig>({
|
||||
type: "cron",
|
||||
name: "",
|
||||
schedule: "",
|
||||
@@ -32,7 +34,7 @@ function SchedulerBuilderRoute() {
|
||||
user: "",
|
||||
workingDir: "",
|
||||
});
|
||||
const effectiveConfig: ScheduleConfigInput = { ...config, type: scheduleType };
|
||||
const effectiveConfig: ScheduleConfig = { ...config, type: scheduleType };
|
||||
|
||||
const generateCron = useCallback((cfg: ScheduleConfig): string => {
|
||||
const user = cfg.user || "root";
|
||||
@@ -54,8 +56,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ${cfg.description || "Run command"}
|
||||
run: |
|
||||
${cfg.command}
|
||||
run: ${cfg.command}
|
||||
`;
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +95,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.replace(/'/g, "'\\''")}'
|
||||
${cfg.user ? `User=${cfg.user}\n` : ""}${cfg.workingDir ? `WorkingDirectory=${cfg.workingDir}\n` : ""}ExecStart=/bin/bash -c '${cfg.command}'
|
||||
`;
|
||||
|
||||
return `# ${timerName}
|
||||
@@ -105,40 +106,25 @@ ${serviceUnit}
|
||||
`;
|
||||
}, []);
|
||||
|
||||
const configValidation = useMemo(
|
||||
() => validateScheduleConfig(effectiveConfig),
|
||||
[effectiveConfig],
|
||||
);
|
||||
|
||||
const generatedOutput = useMemo(() => {
|
||||
if (!configValidation.ok || !configValidation.data) return "";
|
||||
const output = useMemo(() => {
|
||||
switch (scheduleType) {
|
||||
case "cron":
|
||||
return generateCron(configValidation.data);
|
||||
return generateCron(effectiveConfig);
|
||||
case "github-actions":
|
||||
return generateGitHubActions(configValidation.data);
|
||||
return generateGitHubActions(effectiveConfig);
|
||||
case "systemd-timer":
|
||||
return generateSystemdTimer(configValidation.data);
|
||||
return generateSystemdTimer(effectiveConfig);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [
|
||||
scheduleType,
|
||||
configValidation,
|
||||
effectiveConfig,
|
||||
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":
|
||||
@@ -158,23 +144,6 @@ ${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} />
|
||||
@@ -193,7 +162,7 @@ ${serviceUnit}
|
||||
className={
|
||||
"config-tab" + (scheduleType === "cron" ? " active" : "")
|
||||
}
|
||||
onClick={() => handleScheduleTypeChange("cron")}
|
||||
onClick={() => setScheduleType("cron")}
|
||||
>
|
||||
Cron
|
||||
</button>
|
||||
@@ -203,7 +172,7 @@ ${serviceUnit}
|
||||
"config-tab" +
|
||||
(scheduleType === "github-actions" ? " active" : "")
|
||||
}
|
||||
onClick={() => handleScheduleTypeChange("github-actions")}
|
||||
onClick={() => setScheduleType("github-actions")}
|
||||
>
|
||||
GitHub Actions
|
||||
</button>
|
||||
@@ -213,7 +182,7 @@ ${serviceUnit}
|
||||
"config-tab" +
|
||||
(scheduleType === "systemd-timer" ? " active" : "")
|
||||
}
|
||||
onClick={() => handleScheduleTypeChange("systemd-timer")}
|
||||
onClick={() => setScheduleType("systemd-timer")}
|
||||
>
|
||||
systemd
|
||||
</button>
|
||||
@@ -239,6 +208,7 @@ ${serviceUnit}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Form column */}
|
||||
<section className="service-col">
|
||||
<div className="col-head">
|
||||
<h2 className="col-head-title">
|
||||
@@ -247,21 +217,19 @@ ${serviceUnit}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="svc-tab">
|
||||
<Field label="Name" required error={fieldError("name")}>
|
||||
<Field label="Name" required>
|
||||
<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"
|
||||
@@ -269,12 +237,11 @@ ${serviceUnit}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, schedule: e.target.value })
|
||||
}
|
||||
aria-invalid={fieldError("schedule") ? true : undefined}
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Command" required error={fieldError("command")}>
|
||||
<Field label="Command" required>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={4}
|
||||
@@ -282,44 +249,40 @@ ${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" error={fieldError("description")}>
|
||||
<Field label="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" error={fieldError("user")}>
|
||||
<Field label="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" error={fieldError("workingDir")}>
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="input"
|
||||
value={config.workingDir || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, workingDir: e.target.value })
|
||||
}
|
||||
aria-invalid={fieldError("workingDir") ? true : undefined}
|
||||
placeholder="/srv/app"
|
||||
/>
|
||||
</Field>
|
||||
@@ -345,15 +308,9 @@ ${serviceUnit}
|
||||
<code>{output || "# Fill the form to generate output."}</code>
|
||||
</pre>
|
||||
<div className="code-statusbar">
|
||||
<span
|
||||
className={"status-tag" + (outputError ? " err" : "")}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
className={"status-dot " + (outputError ? "err" : "ok")}
|
||||
aria-hidden
|
||||
/>
|
||||
{outputError || "Valid"}
|
||||
<span className="status-tag">
|
||||
<span className="status-dot ok" aria-hidden />
|
||||
Ready
|
||||
</span>
|
||||
<span className="status-mono">
|
||||
{output.split("\n").length} lines · UTF-8 · LF
|
||||
@@ -365,13 +322,3 @@ ${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");
|
||||
}
|
||||
|
||||
+6
-110
@@ -3,71 +3,14 @@
|
||||
// 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" | "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 type BlueprintProtocol = "http" | "https" | "tcp" | "udp";
|
||||
|
||||
export interface BlueprintTarget {
|
||||
site?: string;
|
||||
method?: BlueprintTargetMethod;
|
||||
method: BlueprintProtocol;
|
||||
// Optional override — Pangolin defaults to the container_name.
|
||||
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 {
|
||||
@@ -92,71 +35,24 @@ 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;
|
||||
// Pangolin public resource protocol.
|
||||
// Protocol used by the auto-derived target[0] (and the resource label).
|
||||
protocol: BlueprintProtocol;
|
||||
enabled: boolean;
|
||||
ssl: boolean;
|
||||
hostHeader: string;
|
||||
tlsServerName: string;
|
||||
headers: BlueprintHeader[];
|
||||
rules: BlueprintRule[];
|
||||
maintenance: BlueprintMaintenance;
|
||||
targetSite?: string;
|
||||
targetMethod: BlueprintTargetMethod;
|
||||
// Optional explicit target[0] hostname/port overrides.
|
||||
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;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,18 +2,6 @@ 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:
|
||||
|
||||
@@ -2,16 +2,14 @@ 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 compose service", () => {
|
||||
it("creates one resource per service with exposed ports", () => {
|
||||
const compose = `version: "3.8"
|
||||
services:
|
||||
web:
|
||||
@@ -22,41 +20,23 @@ services:
|
||||
image: postgres:16
|
||||
`;
|
||||
const bp = fromCompose(compose, "example.com");
|
||||
expect(bp.resources.length).toBe(2);
|
||||
expect(bp.resources.length).toBe(1);
|
||||
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("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", () => {
|
||||
it("uses https for port 443 / 8443", () => {
|
||||
const bp = fromCompose(`services:
|
||||
s:
|
||||
image: x
|
||||
ports:
|
||||
- "443:443"
|
||||
`);
|
||||
expect(bp.resources[0].protocol).toBe("http");
|
||||
expect(bp.resources[0].targetMethod).toBe("https");
|
||||
expect(bp.resources[0].protocol).toBe("https");
|
||||
});
|
||||
|
||||
it("keeps the raw compose document for round-trip", () => {
|
||||
@@ -94,58 +74,16 @@ 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");
|
||||
});
|
||||
|
||||
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",
|
||||
expect(yaml).toContain(
|
||||
"pangolin.public-resources.web.targets[0].method=http",
|
||||
);
|
||||
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", () => {
|
||||
it("adds the external pangolin network block", () => {
|
||||
const bp = fromCompose(
|
||||
`services:
|
||||
web:
|
||||
@@ -155,14 +93,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(pangolin.name).toBe("custom_pangolin");
|
||||
expect(String(pangolin.name)).not.toContain("PANGOLIN_DOCKER_NETWORK");
|
||||
expect(String(pangolin.name)).toContain("PANGOLIN_DOCKER_NETWORK");
|
||||
});
|
||||
|
||||
it("adds the pangolin network to the service's networks list", () => {
|
||||
@@ -194,7 +132,9 @@ 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");
|
||||
});
|
||||
|
||||
@@ -211,73 +151,30 @@ 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.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}");
|
||||
expect(yaml).toContain(
|
||||
"auth.pincode=${RESOURCE_AUTH_PINCODE}",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits extra HTTP targets", () => {
|
||||
it("emits extra targets", () => {
|
||||
const bp = fromCompose(`services:
|
||||
s:
|
||||
image: x
|
||||
ports:
|
||||
- "80"
|
||||
`);
|
||||
bp.resources[0].extraTargets.push({ method: "https", port: 5432 });
|
||||
bp.resources[0].extraTargets.push({ method: "tcp", port: 5432 });
|
||||
const yaml = toComposeYaml(bp);
|
||||
expect(yaml).toContain("pangolin.public-resources.s.targets[1].method=https");
|
||||
expect(yaml).toContain(
|
||||
"pangolin.public-resources.s.targets[1].method=tcp",
|
||||
);
|
||||
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:
|
||||
@@ -294,7 +191,7 @@ describe("toComposeYaml", () => {
|
||||
});
|
||||
|
||||
describe("toEnvExample", () => {
|
||||
it("includes shared and resource keys", () => {
|
||||
it("includes the six required keys", () => {
|
||||
const bp = fromCompose(`services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
@@ -302,16 +199,12 @@ 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", () => {
|
||||
@@ -326,8 +219,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", () => {
|
||||
@@ -340,7 +233,6 @@ 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);
|
||||
});
|
||||
|
||||
@@ -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,7 +21,8 @@ describe("golden — nginx → pangolin blueprint", () => {
|
||||
it("compose output snapshot", () => {
|
||||
const bp = fromCompose(NGINX_COMPOSE, "example.com");
|
||||
expect(toComposeYaml(bp)).toMatchInlineSnapshot(`
|
||||
"services:
|
||||
"version: "3.8"
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: nginx
|
||||
@@ -32,15 +33,15 @@ describe("golden — nginx → pangolin blueprint", () => {
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
labels:
|
||||
- pangolin.public-resources.nginx.name=Nginx
|
||||
- pangolin.public-resources.nginx.protocol=http
|
||||
- pangolin.public-resources.nginx.full-domain=nginx.example.com
|
||||
- pangolin.public-resources.nginx.protocol=http
|
||||
- pangolin.public-resources.nginx.targets[0].method=http
|
||||
networks:
|
||||
- pangolin
|
||||
networks:
|
||||
pangolin:
|
||||
external: true
|
||||
name: pangolin_default
|
||||
name: \${PANGOLIN_DOCKER_NETWORK:-pangolin_default}
|
||||
"
|
||||
`);
|
||||
});
|
||||
@@ -48,35 +49,13 @@ describe("golden — nginx → pangolin blueprint", () => {
|
||||
it(".env.example snapshot", () => {
|
||||
const bp = fromCompose(NGINX_COMPOSE, "example.com");
|
||||
expect(toEnvExample(bp)).toMatchInlineSnapshot(`
|
||||
"# 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
|
||||
"BLUEPRINT_NAME=nginx
|
||||
RESOURCE_NAME=Nginx
|
||||
SERVICE_SUBDOMAIN=nginx
|
||||
SERVICE_CONTAINER_NAME=nginx
|
||||
SERVICE_PORT=8080
|
||||
APP_IMAGE=nginx:1.27-alpine
|
||||
|
||||
# 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.
|
||||
# Optional resource auth overrides — uncomment and set as needed.
|
||||
# RESOURCE_AUTH_PINCODE=
|
||||
# RESOURCE_AUTH_PASSWORD=
|
||||
# RESOURCE_AUTH_BASIC_USER=
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
validateServicePort,
|
||||
validateSubdomain,
|
||||
} from "../validator";
|
||||
import { defaultBlueprint, defaultPrivateResource, fromCompose } from "../generator";
|
||||
import { defaultBlueprint, 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 until HTTP resources are present)", () => {
|
||||
it("base domain (optional FQDN)", () => {
|
||||
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 when base domain is set", () => {
|
||||
it("imported nginx-like compose passes", () => {
|
||||
const bp = fromCompose(
|
||||
`services:
|
||||
web:
|
||||
@@ -74,27 +74,6 @@ 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:
|
||||
@@ -105,83 +84,25 @@ describe("validateBlueprint", () => {
|
||||
image: y
|
||||
ports:
|
||||
- "81"
|
||||
`, "example.com");
|
||||
`);
|
||||
// Force the duplicate
|
||||
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", () => {
|
||||
@@ -190,65 +111,9 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,22 +8,16 @@
|
||||
// `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 {
|
||||
@@ -38,114 +32,33 @@ 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: DEFAULT_SERVICE_PORT,
|
||||
proxyPort: undefined,
|
||||
servicePort: 80,
|
||||
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
|
||||
@@ -210,51 +123,15 @@ function firstServicePort(svc: Record<string, unknown>): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function targetMethodFor(port: number): BlueprintTargetMethod {
|
||||
function protocolFor(port: number): BlueprintProtocol {
|
||||
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(sanitizeComposeVersion(yamlContent).content);
|
||||
doc = jsyaml.load(yamlContent);
|
||||
} catch {
|
||||
return defaultBlueprint();
|
||||
}
|
||||
@@ -270,21 +147,28 @@ 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 resource = resourceFromComposeService(svcKey, raw);
|
||||
resource.blueprintName = uniqueBlueprintName(
|
||||
resource.blueprintName,
|
||||
seenBlueprintNames,
|
||||
);
|
||||
resource.subdomain = resource.blueprintName;
|
||||
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),
|
||||
};
|
||||
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);
|
||||
@@ -306,285 +190,98 @@ function dedupePreserveOrder<T>(items: T[]): T[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
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[] {
|
||||
function labelsFor(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}`);
|
||||
|
||||
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);
|
||||
});
|
||||
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}`);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
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}`),
|
||||
);
|
||||
|
||||
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) {
|
||||
applyLabelsToService(
|
||||
services,
|
||||
r.serviceContainerName,
|
||||
labelsForPublicResource(bp, r),
|
||||
[`pangolin.public-resources.${r.blueprintName}.`],
|
||||
true,
|
||||
);
|
||||
}
|
||||
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)]);
|
||||
|
||||
for (const privateResource of bp.privateResources ?? []) {
|
||||
applyLabelsToService(
|
||||
services,
|
||||
privateResource.serviceContainerName,
|
||||
labelsForPrivateResource(privateResource),
|
||||
[`pangolin.private-resources.${privateResource.key}.`],
|
||||
false,
|
||||
);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
for (const site of bp.sites ?? []) {
|
||||
applyLabelsToService(
|
||||
services,
|
||||
site.serviceContainerName,
|
||||
labelsForSite(site),
|
||||
[`pangolin.sites.${site.key}.`],
|
||||
false,
|
||||
);
|
||||
services[r.serviceContainerName] = svc;
|
||||
}
|
||||
|
||||
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: bp.pangolinNetwork,
|
||||
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}}`,
|
||||
};
|
||||
baseDoc.networks = networksTop;
|
||||
|
||||
delete baseDoc.version;
|
||||
// Always emit at least version key for clarity.
|
||||
if (!baseDoc.version) baseDoc.version = "3.8";
|
||||
|
||||
return jsyaml.dump(baseDoc, {
|
||||
indent: 2,
|
||||
@@ -594,41 +291,15 @@ export function toComposeYaml(bp: Blueprint): string {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- blueprint -> .env.example ----------
|
||||
// ---------- blueprint → .env.example ----------
|
||||
|
||||
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.
|
||||
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.
|
||||
# RESOURCE_AUTH_PINCODE=
|
||||
# RESOURCE_AUTH_PASSWORD=
|
||||
# RESOURCE_AUTH_BASIC_USER=
|
||||
@@ -637,20 +308,22 @@ APP_IMAGE=${r.image}
|
||||
# RESOURCE_AUTH_SSO_ROLE_0=
|
||||
# RESOURCE_AUTH_SSO_USER_0=
|
||||
# RESOURCE_AUTH_WHITELIST_USER_0=`;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
bp.resources.forEach((r) => {
|
||||
blocks.push(envResourceBlock(r, bp.resources.length > 1));
|
||||
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("");
|
||||
});
|
||||
return `${blocks.join("\n\n")}\n`;
|
||||
if (blocks.length === 0) {
|
||||
return `# Import a docker-compose template to populate this file.\n`;
|
||||
}
|
||||
return blocks.join("\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;
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
// Validation for the fosrl/blueprints-compatible Blueprint structure.
|
||||
|
||||
import type {
|
||||
Blueprint,
|
||||
BlueprintHealthcheck,
|
||||
BlueprintPrivateResource,
|
||||
BlueprintResource,
|
||||
BlueprintSite,
|
||||
BlueprintTarget,
|
||||
} from "../../types/blueprint";
|
||||
import type { Blueprint, BlueprintResource } from "../../types/blueprint";
|
||||
import {
|
||||
validateHostname,
|
||||
validateIpv4Cidr,
|
||||
validateLabelKey,
|
||||
validatePort,
|
||||
validateLabelKey,
|
||||
} from "../validation";
|
||||
|
||||
const BLUEPRINT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||
@@ -21,43 +13,12 @@ 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 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"]);
|
||||
const PROTOCOLS = new Set(["http", "https", "tcp", "udp"]);
|
||||
|
||||
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))
|
||||
@@ -77,17 +38,11 @@ export function validateSubdomain(v: string): string | null {
|
||||
}
|
||||
|
||||
export function validateBaseDomain(v: string): string | null {
|
||||
if (!v) return null;
|
||||
if (!v) return null; // optional — labels emit subdomain bare when missing
|
||||
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));
|
||||
}
|
||||
@@ -100,12 +55,8 @@ export function validatePangolinNetwork(v: string): string | null {
|
||||
}
|
||||
|
||||
export function validateProtocol(v: string): string | null {
|
||||
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";
|
||||
if (!PROTOCOLS.has(v))
|
||||
return "Protocol must be http, https, tcp, or udp";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -115,94 +66,7 @@ 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>,
|
||||
@@ -212,6 +76,7 @@ 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));
|
||||
|
||||
@@ -226,115 +91,28 @@ function validateResource(
|
||||
);
|
||||
}
|
||||
|
||||
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`);
|
||||
});
|
||||
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)));
|
||||
}
|
||||
|
||||
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.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)));
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
// 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)),
|
||||
);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -353,50 +131,19 @@ 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(bp, r, i, composeServiceKeys));
|
||||
errors.push(...validateResource(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;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import jsyaml from "js-yaml";
|
||||
import { sanitizeComposeVersion } from "./compose-version";
|
||||
import type { ServiceConfig, NetworkConfig, VolumeConfig } from "../types/compose";
|
||||
import { defaultService } from "./default-configs";
|
||||
|
||||
@@ -376,7 +375,7 @@ export function parseComposeTemplate(composeContent: string): {
|
||||
} {
|
||||
let doc: any;
|
||||
try {
|
||||
doc = jsyaml.load(sanitizeComposeVersion(composeContent).content);
|
||||
doc = jsyaml.load(composeContent);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Invalid YAML format: ${err.message}`, { cause: err });
|
||||
}
|
||||
|
||||
@@ -2,15 +2,6 @@ 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);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -6,7 +6,6 @@
|
||||
// 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,
|
||||
@@ -32,17 +31,9 @@ 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(sanitized);
|
||||
parsed = jsyaml.load(yaml);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, errors: [`Invalid YAML: ${msg}`] };
|
||||
@@ -66,7 +57,7 @@ export function validateComposeYaml(yaml: string): ComposeValidationResult {
|
||||
let volumesParsed: ReturnType<typeof parseComposeService>["volumes"] = [];
|
||||
|
||||
try {
|
||||
const result = parseComposeTemplate(sanitized);
|
||||
const result = parseComposeTemplate(yaml);
|
||||
const allNetworks = isPlainObject((parsed as Record<string, unknown>).networks)
|
||||
? ((parsed as Record<string, unknown>).networks as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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",
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user