mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
15045b8dd3
commit
b4baa853b2
16 files changed
+2479
-48
No files matched your search
@@ -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':
|
||||
|
||||
Reference in new issue
Block a user