feat(templates): v2 — multi-service graph templates, expressions, compose import

Config v2 adds services[] to TemplateConfig: members declare key, type
(web|worker|cron|database), runtime, environment, volumes and depends_on.
Deploy topo-sorts the graph, creates databases first (managed DBs attached
to the project network under their key as DNS alias), then services with
resolved env, and enqueues a real deployment per service.

Expressions: {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}},
{{VAR:-default}} and {{service.<key>.<prop>}} with props host, port, url,
user, password, database. Credential-bearing refs (password/url) mark the
consuming variable secret — stored encrypted, masked in plan output.
Single-service template secret vars are now encrypted at rest too.

New endpoints: POST /templates/:id/plan (dry-resolve with missing/
unresolved reporting), POST /templates/import/compose (compose YAML →
v2 config; known db images become managed databases, build: flagged in
warnings), POST /templates/deploy (ad-hoc graph deploy — the compose
import companion).

Surfaces: CLI gains templates plan/import-compose/deploy-graph plus
--name/--var on deploy; 3 new MCP tools (88 total); OpenAPI + generated
frontend types; TemplatesPage shows stack members, deploys graphs without
a name field, renders the created resource list, and ships an
Import Compose modal feeding the template editor.

Seeded: WordPress + MySQL and n8n + PostgreSQL graph templates.

Partial-failure cleanup: resources created by a failed graph deploy are
removed (rows, containers, volumes).

Verified live: wordpress-stack deploy created a managed MySQL attached to
the project network as `db`; an inline pg+redis+nginx graph deployed with
DATABASE_URL=postgresql://app:<gen>@pg:5432/shop resolvable in-container.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 23:42:08 +02:00
1 parent 15045b8dd3
commit b4baa853b2
16 files changed
+2479 -48

No files matched your search

+122 -8
View File
@@ -164,7 +164,20 @@ export type TemplateEntity = {
updatedAt?: string;
};
export type TemplateServiceSpecEntity = {
key: string;
name?: string;
type: string;
runtime?: string;
repo?: string;
branch?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplateConfigEntity = {
version?: number;
type: string;
runtime: string;
buildCommand: string;
@@ -174,6 +187,7 @@ export type TemplateConfigEntity = {
environment: Record<string, string>;
dockerfile?: string;
nixpacksConfig: Record<string, string>;
services?: TemplateServiceSpecEntity[];
};
export type TemplateVariableEntity = {
@@ -197,12 +211,15 @@ export type ListTemplatesInput = {
export type DeployTemplateInput = {
projectId: string;
name: string;
name?: string;
variables?: Record<string, string>;
};
export type DeployTemplateResult = {
serviceId: string;
resource: string;
serviceId?: string;
databaseId?: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
message: string;
};
@@ -696,7 +713,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
}
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
const services = Array.isArray(config?.services)
? config.services.map((s) => ({
key: s?.key ?? '',
name: s?.name ?? undefined,
type: s?.type ?? 'web',
runtime: s?.runtime ?? undefined,
repo: s?.repo ?? undefined,
branch: s?.branch ?? undefined,
port: s?.port ?? undefined,
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
environment: normalizeStringRecord(s?.environment),
}))
: undefined;
return {
version: config?.version ?? undefined,
type: config?.type ?? '',
runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '',
@@ -706,6 +737,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
services,
};
}
@@ -1586,21 +1618,103 @@ export async function deployTemplate(
method: 'POST',
body: JSON.stringify({
project_id: input.projectId,
name: input.name,
name: input.name ?? '',
variables: input.variables ?? {},
}),
});
if (!payload.service_id) {
throw new ApiError('Template deployment response is invalid', 500);
}
const created = Array.isArray(payload.created)
? payload.created
.filter((m) => m && typeof m.id === 'string')
.map((m) => ({
key: m.key ?? '',
kind: m.kind ?? '',
id: m.id ?? '',
name: m.name ?? '',
deploymentId: m.deployment_id ?? undefined,
}))
: undefined;
return {
serviceId: payload.service_id,
message: payload.message ?? 'Service created from template',
resource: payload.resource ?? 'service',
serviceId: payload.service_id ?? undefined,
databaseId: payload.database_id ?? undefined,
created,
message: payload.message ?? 'Template deployed',
};
}
export type TemplatePlanMember = {
key: string;
name: string;
type: string;
runtime?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplatePlanResult = {
members: TemplatePlanMember[];
missingVariables: string[];
unresolved: string[];
};
export async function planTemplate(
templateId: string,
input: { projectId?: string; variables?: Record<string, string> },
): Promise<TemplatePlanResult> {
const payload = await requestJson<{
members?: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: Record<string, string>;
}[];
missing_variables?: string[];
unresolved?: string[];
}>(`/templates/${templateId}/plan`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId ?? '',
variables: input.variables ?? {},
}),
});
return {
members: (payload.members ?? []).map((m) => ({
key: m.key ?? '',
name: m.name ?? '',
type: m.type ?? '',
runtime: m.runtime ?? undefined,
port: m.port ?? undefined,
dependsOn: m.depends_on ?? undefined,
environment: normalizeStringRecord(m.environment),
})),
missingVariables: payload.missing_variables ?? [],
unresolved: payload.unresolved ?? [],
};
}
export type ComposeImportResult = {
config: Record<string, unknown>;
warnings: string[];
};
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/compose',
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Compose import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':