@msn-control/liftoff 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -7
- package/dist/args.js +2 -2
- package/dist/args.js.map +1 -1
- package/dist/catalogs.d.ts +7 -1
- package/dist/catalogs.js +55 -0
- package/dist/catalogs.js.map +1 -1
- package/dist/commands.js +129 -18
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +43 -0
- package/dist/file-system.js.map +1 -1
- package/dist/genai-templates.d.ts +10 -0
- package/dist/genai-templates.js +10 -0
- package/dist/genai-templates.js.map +1 -0
- package/dist/interactive.js +21 -6
- package/dist/interactive.js.map +1 -1
- package/dist/migrate-plan.d.ts +1 -1
- package/dist/migrate-plan.js +38 -10
- package/dist/migrate-plan.js.map +1 -1
- package/dist/planner.js +86 -8
- package/dist/planner.js.map +1 -1
- package/dist/reconcile.js +0 -0
- package/dist/reconcile.js.map +1 -1
- package/dist/scan.d.ts +2 -1
- package/dist/scan.js +112 -3
- package/dist/scan.js.map +1 -1
- package/dist/standard-templates.d.ts +5 -0
- package/dist/standard-templates.js +797 -0
- package/dist/standard-templates.js.map +1 -0
- package/dist/template-types.d.ts +1 -0
- package/dist/template-types.js +2 -0
- package/dist/template-types.js.map +1 -0
- package/dist/templates.js +452 -80
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +24 -2
- package/package.json +5 -2
package/dist/templates.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { addGenAiExtensionArtifacts } from './genai-templates.js';
|
|
3
|
+
import { addStandardStackArtifacts, renderStandardDockerfile, renderStandardEnv } from './standard-templates.js';
|
|
2
4
|
import { liftoffVersion } from './version.js';
|
|
3
5
|
const contentHash = (content) => `sha256:${createHash('sha256').update(content, 'utf8').digest('hex')}`;
|
|
4
6
|
const pyModule = (value) => value.replace(/-/g, '_');
|
|
5
7
|
const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
+
const sourceString = (value) => JSON.stringify(value);
|
|
9
|
+
const scriptSourceString = (value) => sourceString(value).replaceAll('<', '\\u003c');
|
|
10
|
+
const escapeHtml = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
11
|
+
const genAiPattern = (plan) => {
|
|
12
|
+
if (plan.projectType.id !== 'genai' || !plan.pattern) {
|
|
13
|
+
throw new Error('GenAI template rendering requires a GenAI pattern.');
|
|
14
|
+
}
|
|
15
|
+
return plan.pattern;
|
|
16
|
+
};
|
|
17
|
+
const hasFunctionWorker = (plan) => plan.projectType.id === 'genai' && plan.provider.id === 'azure' && genAiPattern(plan).worker;
|
|
18
|
+
const functionWorkerName = (plan) => `${genAiPattern(plan).id}-worker`;
|
|
8
19
|
export function buildArtifacts(plan) {
|
|
9
20
|
const artifacts = [];
|
|
10
21
|
const add = (logicalName, category, pathParts, content) => {
|
|
11
22
|
artifacts.push({ logicalName, category, pathParts, content: ensureTrailingNewline(content) });
|
|
12
23
|
};
|
|
13
24
|
addBaseArtifacts(add, plan);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
if (plan.projectType.id === 'genai') {
|
|
26
|
+
addGenAiExtensionArtifacts(add, plan, {
|
|
27
|
+
backend: addBackendArtifacts,
|
|
28
|
+
database: addDatabaseArtifacts,
|
|
29
|
+
pattern: addPatternArtifacts,
|
|
30
|
+
functions: addFunctionArtifacts
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
addStandardStackArtifacts(add, plan);
|
|
35
|
+
}
|
|
18
36
|
addEnvironmentArtifacts(add, plan);
|
|
19
37
|
addDockerArtifacts(add, plan);
|
|
20
38
|
addInfrastructureArtifacts(add, plan);
|
|
@@ -38,14 +56,18 @@ export function buildManifest(plan, artifacts) {
|
|
|
38
56
|
liftoffVersion,
|
|
39
57
|
project: {
|
|
40
58
|
name: plan.projectName,
|
|
41
|
-
|
|
59
|
+
projectType: plan.projectType.id,
|
|
60
|
+
apiStack: plan.apiStack.id,
|
|
61
|
+
...(plan.pattern ? { pattern: plan.pattern.id } : {}),
|
|
42
62
|
cloud: plan.provider.id,
|
|
43
63
|
region: plan.region.slug,
|
|
44
64
|
frontend: plan.includeFrontend,
|
|
45
65
|
specWorkflow: plan.specWorkflow.id,
|
|
46
66
|
environments: plan.environments.map((environment) => environment.id)
|
|
47
67
|
},
|
|
48
|
-
artifacts: artifacts
|
|
68
|
+
artifacts: artifacts
|
|
69
|
+
.filter((artifact) => artifact.category !== 'seed') // seed content is written once, never tracked
|
|
70
|
+
.map((artifact) => ({
|
|
49
71
|
logicalName: artifact.logicalName,
|
|
50
72
|
category: artifact.category,
|
|
51
73
|
pathParts: artifact.pathParts,
|
|
@@ -58,7 +80,9 @@ function addBaseArtifacts(add, plan) {
|
|
|
58
80
|
add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
|
|
59
81
|
add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
|
|
60
82
|
projectName: plan.projectName,
|
|
61
|
-
|
|
83
|
+
projectType: plan.projectType.id,
|
|
84
|
+
apiStack: plan.apiStack.id,
|
|
85
|
+
...(plan.pattern ? { pattern: plan.pattern.id } : {}),
|
|
62
86
|
cloud: plan.provider.id,
|
|
63
87
|
region: plan.region.slug,
|
|
64
88
|
includeFrontend: plan.includeFrontend,
|
|
@@ -66,10 +90,10 @@ function addBaseArtifacts(add, plan) {
|
|
|
66
90
|
specWorkflow: plan.specWorkflow.id
|
|
67
91
|
}, null, 2));
|
|
68
92
|
add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
|
|
69
|
-
add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
|
|
93
|
+
add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
|
|
70
94
|
}
|
|
71
95
|
function addBackendArtifacts(add, plan) {
|
|
72
|
-
const routeModule = pyModule(plan.
|
|
96
|
+
const routeModule = pyModule(genAiPattern(plan).id);
|
|
73
97
|
add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
|
|
74
98
|
add('backend-package', 'backend', ['backend', '__init__.py'], '');
|
|
75
99
|
add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
|
|
@@ -95,20 +119,21 @@ function addDatabaseArtifacts(add, plan) {
|
|
|
95
119
|
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
|
|
96
120
|
}
|
|
97
121
|
function addPatternArtifacts(add, plan) {
|
|
98
|
-
const
|
|
122
|
+
const pattern = genAiPattern(plan);
|
|
123
|
+
const routeModule = pyModule(pattern.id);
|
|
99
124
|
add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
|
|
100
|
-
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${
|
|
125
|
+
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
|
|
101
126
|
add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
|
|
102
127
|
add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
|
|
103
|
-
if (
|
|
128
|
+
if (pattern.id === 'rag') {
|
|
104
129
|
add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
|
|
105
130
|
add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
|
|
106
131
|
}
|
|
107
|
-
if (
|
|
132
|
+
if (pattern.worker) {
|
|
108
133
|
add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
|
|
109
134
|
add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
|
|
110
135
|
}
|
|
111
|
-
if (
|
|
136
|
+
if (pattern.id === 'fine-tuned') {
|
|
112
137
|
add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
|
|
113
138
|
}
|
|
114
139
|
}
|
|
@@ -130,7 +155,7 @@ function addFunctionArtifacts(add, plan) {
|
|
|
130
155
|
}
|
|
131
156
|
function addEnvironmentArtifacts(add, plan) {
|
|
132
157
|
for (const environment of plan.environments) {
|
|
133
|
-
add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], renderBackendEnv(plan, environment.id));
|
|
158
|
+
add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], plan.projectType.id === 'genai' ? renderBackendEnv(plan, environment.id) : renderStandardEnv(plan, environment.id));
|
|
134
159
|
if (hasFunctionWorker(plan)) {
|
|
135
160
|
add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
|
|
136
161
|
}
|
|
@@ -157,10 +182,10 @@ function addGovernanceArtifacts(add, plan) {
|
|
|
157
182
|
if (plan.specWorkflow.id === 'openspec') {
|
|
158
183
|
const changeName = `bootstrap-${plan.safeProjectName}`;
|
|
159
184
|
add('openspec-config', 'governance', ['openspec', 'config.yaml'], renderOpenSpecConfig(plan));
|
|
160
|
-
add('openspec-seed-change-metadata', '
|
|
161
|
-
add('openspec-seed-proposal', '
|
|
162
|
-
add('openspec-seed-design', '
|
|
163
|
-
add('openspec-seed-tasks', '
|
|
185
|
+
add('openspec-seed-change-metadata', 'seed', ['openspec', 'changes', changeName, '.openspec.yaml'], 'schema: spec-driven');
|
|
186
|
+
add('openspec-seed-proposal', 'seed', ['openspec', 'changes', changeName, 'proposal.md'], renderSeedProposal(plan));
|
|
187
|
+
add('openspec-seed-design', 'seed', ['openspec', 'changes', changeName, 'design.md'], renderSeedDesign(plan));
|
|
188
|
+
add('openspec-seed-tasks', 'seed', ['openspec', 'changes', changeName, 'tasks.md'], renderSeedTasks());
|
|
164
189
|
add('openspec-spec-placeholder', 'governance', ['openspec', 'specs', '.gitkeep'], '');
|
|
165
190
|
}
|
|
166
191
|
else {
|
|
@@ -181,6 +206,48 @@ function addFrontendArtifacts(add, plan) {
|
|
|
181
206
|
add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
|
|
182
207
|
}
|
|
183
208
|
function renderRootReadme(plan) {
|
|
209
|
+
if (plan.projectType.id === 'standard') {
|
|
210
|
+
return `# ${plan.projectName}
|
|
211
|
+
|
|
212
|
+
Generated by Mission Control Liftoff.
|
|
213
|
+
|
|
214
|
+
## Stack
|
|
215
|
+
|
|
216
|
+
- Project type: Standard application
|
|
217
|
+
- API: ${plan.apiStack.label}
|
|
218
|
+
- Database tooling: ${plan.apiStack.databaseTooling}
|
|
219
|
+
- API reference: Scalar with OpenAPI
|
|
220
|
+
- Cloud: ${plan.provider.label} (${plan.region.slug})
|
|
221
|
+
- Infrastructure: OpenTofu
|
|
222
|
+
- Database: PostgreSQL
|
|
223
|
+
- Cache and local messaging: Redis
|
|
224
|
+
- Local development: Docker Compose
|
|
225
|
+
${plan.includeFrontend ? '- Frontend: Vue 3 with Tailwind\n' : ''}
|
|
226
|
+
## Local Development
|
|
227
|
+
|
|
228
|
+
\`\`\`bash
|
|
229
|
+
docker compose up --build
|
|
230
|
+
\`\`\`
|
|
231
|
+
|
|
232
|
+
The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
|
|
233
|
+
|
|
234
|
+
## Infrastructure
|
|
235
|
+
|
|
236
|
+
\`\`\`bash
|
|
237
|
+
cd infrastructure/opentofu/azure
|
|
238
|
+
tofu init
|
|
239
|
+
tofu plan -var-file=environments/dev.tfvars
|
|
240
|
+
tofu apply -var-file=environments/dev.tfvars
|
|
241
|
+
\`\`\`
|
|
242
|
+
|
|
243
|
+
The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
|
|
244
|
+
|
|
245
|
+
## Spec-Driven Workflow
|
|
246
|
+
|
|
247
|
+
Selected workflow: ${plan.specWorkflow.label}.
|
|
248
|
+
`;
|
|
249
|
+
}
|
|
250
|
+
const pattern = genAiPattern(plan);
|
|
184
251
|
const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
|
|
185
252
|
` : '';
|
|
186
253
|
const functionsSection = hasFunctionWorker(plan) ? `
|
|
@@ -195,10 +262,10 @@ Generated by Mission Control Liftoff.
|
|
|
195
262
|
## Stack
|
|
196
263
|
|
|
197
264
|
- Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
|
|
198
|
-
- Pattern: ${
|
|
265
|
+
- Pattern: ${pattern.label}
|
|
199
266
|
- Cloud: ${plan.provider.label} (${plan.region.slug})
|
|
200
267
|
- Infrastructure: OpenTofu
|
|
201
|
-
- Database: PostgreSQL with Alembic migrations${
|
|
268
|
+
- Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
|
|
202
269
|
- Cache and local messaging: Redis
|
|
203
270
|
- Observability: Langfuse
|
|
204
271
|
- Local development: Docker Compose
|
|
@@ -222,6 +289,8 @@ tofu plan -var-file=environments/dev.tfvars
|
|
|
222
289
|
tofu apply -var-file=environments/dev.tfvars
|
|
223
290
|
\`\`\`
|
|
224
291
|
|
|
292
|
+
The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
|
|
293
|
+
|
|
225
294
|
## Spec-Driven Workflow
|
|
226
295
|
|
|
227
296
|
Selected workflow: ${plan.specWorkflow.label}.
|
|
@@ -242,9 +311,13 @@ migration/legacy/
|
|
|
242
311
|
`;
|
|
243
312
|
}
|
|
244
313
|
function renderEnvExample(plan) {
|
|
314
|
+
if (plan.projectType.id === 'standard') {
|
|
315
|
+
return renderStandardEnv(plan);
|
|
316
|
+
}
|
|
317
|
+
const pattern = genAiPattern(plan);
|
|
245
318
|
return `APP_ENV=dev
|
|
246
319
|
APP_NAME=${plan.safeProjectName}
|
|
247
|
-
GENAI_PATTERN=${
|
|
320
|
+
GENAI_PATTERN=${pattern.id}
|
|
248
321
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
249
322
|
AZURE_REGION=${plan.region.slug}
|
|
250
323
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
@@ -287,6 +360,7 @@ dependencies = [
|
|
|
287
360
|
"scalar-fastapi>=1.0",
|
|
288
361
|
"sqlalchemy[asyncio]>=2.0",
|
|
289
362
|
"asyncpg>=0.29",
|
|
363
|
+
"psycopg[binary]>=3.2",
|
|
290
364
|
"alembic>=1.13",
|
|
291
365
|
"redis>=5.0",
|
|
292
366
|
"langfuse>=2.39",
|
|
@@ -302,6 +376,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
|
|
|
302
376
|
requires = ["setuptools>=70"]
|
|
303
377
|
build-backend = "setuptools.build_meta"
|
|
304
378
|
|
|
379
|
+
[tool.setuptools]
|
|
380
|
+
packages = []
|
|
381
|
+
|
|
305
382
|
[tool.pytest.ini_options]
|
|
306
383
|
pythonpath = [".."]
|
|
307
384
|
testpaths = ["tests"]
|
|
@@ -350,16 +427,17 @@ def ready():
|
|
|
350
427
|
`;
|
|
351
428
|
}
|
|
352
429
|
function renderPatternRoutes(plan) {
|
|
353
|
-
const
|
|
430
|
+
const pattern = genAiPattern(plan);
|
|
431
|
+
const moduleName = pyModule(pattern.id);
|
|
354
432
|
const agentName = `${moduleName}_agent`;
|
|
355
|
-
const prefix =
|
|
356
|
-
if (
|
|
433
|
+
const prefix = pattern.routePrefix;
|
|
434
|
+
if (pattern.id === 'streaming') {
|
|
357
435
|
return `from fastapi import APIRouter
|
|
358
436
|
from fastapi.responses import StreamingResponse
|
|
359
437
|
|
|
360
438
|
from backend.orchestration.agents.${agentName} import stream_response
|
|
361
439
|
|
|
362
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
440
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
363
441
|
|
|
364
442
|
|
|
365
443
|
@router.get("")
|
|
@@ -367,7 +445,7 @@ def stream(prompt: str):
|
|
|
367
445
|
return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
|
|
368
446
|
`;
|
|
369
447
|
}
|
|
370
|
-
if (
|
|
448
|
+
if (pattern.id === 'rag') {
|
|
371
449
|
return `from fastapi import APIRouter
|
|
372
450
|
from pydantic import BaseModel
|
|
373
451
|
|
|
@@ -394,13 +472,13 @@ async def ingest(request: IngestionRequest):
|
|
|
394
472
|
return await enqueue_ingestion(request.source_uri)
|
|
395
473
|
`;
|
|
396
474
|
}
|
|
397
|
-
const bodyClass = `${titleCase(
|
|
475
|
+
const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
|
|
398
476
|
return `from fastapi import APIRouter
|
|
399
477
|
from pydantic import BaseModel
|
|
400
478
|
|
|
401
479
|
from backend.orchestration.agents.${agentName} import run_${moduleName}
|
|
402
480
|
|
|
403
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
481
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
404
482
|
|
|
405
483
|
|
|
406
484
|
class ${bodyClass}(BaseModel):
|
|
@@ -426,6 +504,7 @@ async def get_current_user() -> CurrentUser:
|
|
|
426
504
|
`;
|
|
427
505
|
}
|
|
428
506
|
function renderSettings(plan) {
|
|
507
|
+
const pattern = genAiPattern(plan);
|
|
429
508
|
return `from functools import lru_cache
|
|
430
509
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
431
510
|
|
|
@@ -433,9 +512,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
433
512
|
class Settings(BaseSettings):
|
|
434
513
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
435
514
|
|
|
436
|
-
app_name: str =
|
|
515
|
+
app_name: str = ${sourceString(plan.projectName)}
|
|
437
516
|
app_env: str = "dev"
|
|
438
|
-
genai_pattern: str = "${
|
|
517
|
+
genai_pattern: str = "${pattern.id}"
|
|
439
518
|
cloud_provider: str = "${plan.provider.id}"
|
|
440
519
|
azure_region: str = "${plan.region.slug}"
|
|
441
520
|
database_url: str
|
|
@@ -451,6 +530,7 @@ def get_settings() -> Settings:
|
|
|
451
530
|
`;
|
|
452
531
|
}
|
|
453
532
|
function renderModelConfig(plan) {
|
|
533
|
+
const pattern = genAiPattern(plan);
|
|
454
534
|
return `from pydantic import BaseModel, Field
|
|
455
535
|
|
|
456
536
|
|
|
@@ -458,7 +538,7 @@ class ModelConfig(BaseModel):
|
|
|
458
538
|
provider: str = Field(default="azure-openai")
|
|
459
539
|
deployment_name: str = Field(default="gpt-4.1")
|
|
460
540
|
embedding_deployment_name: str = Field(default="text-embedding-3-large")
|
|
461
|
-
pattern: str = Field(default="${
|
|
541
|
+
pattern: str = Field(default="${pattern.id}")
|
|
462
542
|
`;
|
|
463
543
|
}
|
|
464
544
|
function renderMessagingBoundary() {
|
|
@@ -513,25 +593,35 @@ def test_health():
|
|
|
513
593
|
}
|
|
514
594
|
function renderAlembicIni() {
|
|
515
595
|
return `[alembic]
|
|
516
|
-
script_location = migrations
|
|
596
|
+
script_location = %(here)s/migrations
|
|
517
597
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
518
598
|
`;
|
|
519
599
|
}
|
|
520
600
|
function renderAlembicEnv() {
|
|
521
|
-
return `
|
|
601
|
+
return `import os
|
|
602
|
+
|
|
603
|
+
from alembic import context
|
|
604
|
+
from sqlalchemy import create_engine
|
|
522
605
|
|
|
523
606
|
|
|
524
607
|
def run_migrations_online():
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
608
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
609
|
+
if not database_url:
|
|
610
|
+
raise RuntimeError("DATABASE_URL is required to run migrations")
|
|
611
|
+
database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
612
|
+
database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
613
|
+
connectable = create_engine(database_url)
|
|
614
|
+
with connectable.connect() as connection:
|
|
615
|
+
context.configure(connection=connection, target_metadata=None)
|
|
616
|
+
with context.begin_transaction():
|
|
617
|
+
context.run_migrations()
|
|
528
618
|
|
|
529
619
|
|
|
530
620
|
run_migrations_online()
|
|
531
621
|
`;
|
|
532
622
|
}
|
|
533
623
|
function renderInitialMigration(plan) {
|
|
534
|
-
const vectorExtension = plan.
|
|
624
|
+
const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
|
|
535
625
|
return `from alembic import op
|
|
536
626
|
import sqlalchemy as sa
|
|
537
627
|
|
|
@@ -562,11 +652,12 @@ function renderDatabaseSchema(plan) {
|
|
|
562
652
|
payload JSONB NOT NULL,
|
|
563
653
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
564
654
|
);
|
|
565
|
-
${plan.
|
|
655
|
+
${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
|
|
566
656
|
}
|
|
567
657
|
function renderPatternAgent(plan) {
|
|
568
|
-
const
|
|
569
|
-
|
|
658
|
+
const pattern = genAiPattern(plan);
|
|
659
|
+
const moduleName = pyModule(pattern.id);
|
|
660
|
+
if (pattern.id === 'rag') {
|
|
570
661
|
return `from backend.orchestration.tools.messaging import build_message_publisher
|
|
571
662
|
|
|
572
663
|
|
|
@@ -584,7 +675,7 @@ async def enqueue_ingestion(source_uri: str) -> dict:
|
|
|
584
675
|
return {"status": "queued", "source_uri": source_uri}
|
|
585
676
|
`;
|
|
586
677
|
}
|
|
587
|
-
if (
|
|
678
|
+
if (pattern.id === 'streaming') {
|
|
588
679
|
return `async def stream_response(prompt: str):
|
|
589
680
|
yield f"data: Starting response for {prompt}\\n\\n"
|
|
590
681
|
yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
|
|
@@ -592,15 +683,16 @@ async def enqueue_ingestion(source_uri: str) -> dict:
|
|
|
592
683
|
}
|
|
593
684
|
return `async def run_${moduleName}(input_text: str) -> dict:
|
|
594
685
|
return {
|
|
595
|
-
"result": "Replace this placeholder with ${
|
|
686
|
+
"result": "Replace this placeholder with ${pattern.label} PydanticAI orchestration.",
|
|
596
687
|
"input": input_text,
|
|
597
688
|
}
|
|
598
689
|
`;
|
|
599
690
|
}
|
|
600
691
|
function renderPromptTemplate(plan) {
|
|
601
|
-
|
|
692
|
+
const pattern = genAiPattern(plan);
|
|
693
|
+
return `# ${pattern.label} Prompt
|
|
602
694
|
|
|
603
|
-
You are implementing a ${
|
|
695
|
+
You are implementing a ${pattern.label} generated by Mission Control Liftoff.
|
|
604
696
|
|
|
605
697
|
Use PydanticAI orchestration and return outputs that match the API contract.
|
|
606
698
|
`;
|
|
@@ -626,8 +718,9 @@ class PgVectorStore:
|
|
|
626
718
|
`;
|
|
627
719
|
}
|
|
628
720
|
function renderPatternWorker(plan) {
|
|
721
|
+
const pattern = genAiPattern(plan);
|
|
629
722
|
return `async def run_worker() -> None:
|
|
630
|
-
# Consume ${
|
|
723
|
+
# Consume ${pattern.label} jobs from the configured messaging boundary.
|
|
631
724
|
return None
|
|
632
725
|
`;
|
|
633
726
|
}
|
|
@@ -641,9 +734,10 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
|
|
|
641
734
|
}
|
|
642
735
|
function renderFunctionWorkerReadme(plan) {
|
|
643
736
|
const workerName = functionWorkerName(plan);
|
|
737
|
+
const pattern = genAiPattern(plan);
|
|
644
738
|
return `# ${workerName}
|
|
645
739
|
|
|
646
|
-
Azure Functions worker scaffold for ${
|
|
740
|
+
Azure Functions worker scaffold for ${pattern.label}.
|
|
647
741
|
|
|
648
742
|
This Function app uses the Python v2 decorator programming model and a Service Bus queue trigger. The trigger adapter should stay thin: decode the message, validate the envelope, and call shared code from \`backend/orchestration\` after that shared code is packaged with the Function app.
|
|
649
743
|
|
|
@@ -670,6 +764,7 @@ function renderFunctionHostJson() {
|
|
|
670
764
|
}, null, 2);
|
|
671
765
|
}
|
|
672
766
|
function renderFunctionLocalSettings(plan) {
|
|
767
|
+
const pattern = genAiPattern(plan);
|
|
673
768
|
return JSON.stringify({
|
|
674
769
|
IsEncrypted: false,
|
|
675
770
|
Values: {
|
|
@@ -677,7 +772,7 @@ function renderFunctionLocalSettings(plan) {
|
|
|
677
772
|
FUNCTIONS_WORKER_RUNTIME: 'python',
|
|
678
773
|
SERVICEBUS_QUEUE_NAME: 'events',
|
|
679
774
|
ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
|
|
680
|
-
GENAI_PATTERN:
|
|
775
|
+
GENAI_PATTERN: pattern.id,
|
|
681
776
|
SHARED_ORCHESTRATION_ROOT: '../../backend'
|
|
682
777
|
}
|
|
683
778
|
}, null, 2);
|
|
@@ -688,7 +783,8 @@ pytest>=8.2
|
|
|
688
783
|
`;
|
|
689
784
|
}
|
|
690
785
|
function renderFunctionApp(plan) {
|
|
691
|
-
const
|
|
786
|
+
const pattern = genAiPattern(plan);
|
|
787
|
+
const moduleName = pyModule(pattern.id);
|
|
692
788
|
return `import json
|
|
693
789
|
import logging
|
|
694
790
|
|
|
@@ -715,7 +811,7 @@ def decode_message_payload(body: str) -> dict:
|
|
|
715
811
|
)
|
|
716
812
|
def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
|
|
717
813
|
payload = decode_message_payload(message.get_body().decode("utf-8"))
|
|
718
|
-
logging.info("Received ${
|
|
814
|
+
logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
|
|
719
815
|
# Keep this adapter thin; call backend.orchestration code from packaged shared modules.
|
|
720
816
|
`;
|
|
721
817
|
}
|
|
@@ -749,10 +845,11 @@ local.settings.json
|
|
|
749
845
|
`;
|
|
750
846
|
}
|
|
751
847
|
function renderBackendEnv(plan, environment) {
|
|
848
|
+
const pattern = genAiPattern(plan);
|
|
752
849
|
const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
|
|
753
850
|
return `APP_ENV=${environment}
|
|
754
851
|
APP_NAME=${plan.safeProjectName}
|
|
755
|
-
GENAI_PATTERN=${
|
|
852
|
+
GENAI_PATTERN=${pattern.id}
|
|
756
853
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
757
854
|
AZURE_REGION=${plan.region.slug}
|
|
758
855
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
@@ -763,9 +860,10 @@ LANGFUSE_HOST=
|
|
|
763
860
|
`;
|
|
764
861
|
}
|
|
765
862
|
function renderFunctionsEnv(plan, environment) {
|
|
863
|
+
const pattern = genAiPattern(plan);
|
|
766
864
|
return `APP_ENV=${environment}
|
|
767
865
|
APP_NAME=${plan.safeProjectName}
|
|
768
|
-
GENAI_PATTERN=${
|
|
866
|
+
GENAI_PATTERN=${pattern.id}
|
|
769
867
|
FUNCTIONS_WORKER_RUNTIME=python
|
|
770
868
|
SERVICEBUS_QUEUE_NAME=events
|
|
771
869
|
ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
|
|
@@ -774,6 +872,7 @@ SHARED_ORCHESTRATION_ROOT=../../backend
|
|
|
774
872
|
`;
|
|
775
873
|
}
|
|
776
874
|
function renderDockerCompose(plan) {
|
|
875
|
+
const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
|
|
777
876
|
const frontendService = plan.includeFrontend ? `
|
|
778
877
|
frontend:
|
|
779
878
|
build:
|
|
@@ -783,13 +882,17 @@ function renderDockerCompose(plan) {
|
|
|
783
882
|
depends_on:
|
|
784
883
|
- backend
|
|
785
884
|
` : '';
|
|
885
|
+
const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
|
|
786
886
|
return `services:
|
|
787
887
|
backend:
|
|
788
888
|
build:
|
|
789
889
|
context: .
|
|
790
890
|
dockerfile: Dockerfile
|
|
791
891
|
env_file:
|
|
792
|
-
- ./environments/
|
|
892
|
+
- ./environments/${localEnvironment.id}/backend.env
|
|
893
|
+
environment:
|
|
894
|
+
MESSAGING_TRANSPORT: redis-streams
|
|
895
|
+
BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
|
|
793
896
|
ports:
|
|
794
897
|
- "8000:8000"
|
|
795
898
|
depends_on:
|
|
@@ -799,7 +902,7 @@ function renderDockerCompose(plan) {
|
|
|
799
902
|
- mailpit
|
|
800
903
|
${frontendService}
|
|
801
904
|
postgres:
|
|
802
|
-
image:
|
|
905
|
+
image: ${postgresImage}
|
|
803
906
|
environment:
|
|
804
907
|
POSTGRES_USER: postgres
|
|
805
908
|
POSTGRES_PASSWORD: postgres
|
|
@@ -823,7 +926,7 @@ ${frontendService}
|
|
|
823
926
|
ports:
|
|
824
927
|
- "8025:8025"
|
|
825
928
|
|
|
826
|
-
langfuse:
|
|
929
|
+
${plan.projectType.id === 'genai' ? ` langfuse:
|
|
827
930
|
image: langfuse/langfuse:2
|
|
828
931
|
profiles:
|
|
829
932
|
- observability
|
|
@@ -833,6 +936,7 @@ ${frontendService}
|
|
|
833
936
|
SALT: local-development-placeholder
|
|
834
937
|
ports:
|
|
835
938
|
- "3000:3000"
|
|
939
|
+
` : ''}
|
|
836
940
|
`;
|
|
837
941
|
}
|
|
838
942
|
function renderTofuVersions() {
|
|
@@ -854,6 +958,13 @@ function renderTofuProviders() {
|
|
|
854
958
|
`;
|
|
855
959
|
}
|
|
856
960
|
function renderTofuVariables(plan) {
|
|
961
|
+
const frontendVariables = plan.includeFrontend ? `
|
|
962
|
+
variable "frontend_image" {
|
|
963
|
+
type = string
|
|
964
|
+
default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
|
|
965
|
+
description = "Frontend image. Replace the bootstrap image with the generated frontend image after pushing it to ACR."
|
|
966
|
+
}
|
|
967
|
+
` : '';
|
|
857
968
|
const functionVariables = hasFunctionWorker(plan) ? `
|
|
858
969
|
variable "function_worker_queue_name" {
|
|
859
970
|
type = string
|
|
@@ -883,6 +994,18 @@ variable "resource_suffix" {
|
|
|
883
994
|
description = "Globally unique suffix for Azure resource names."
|
|
884
995
|
}
|
|
885
996
|
|
|
997
|
+
variable "backend_image" {
|
|
998
|
+
type = string
|
|
999
|
+
default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
|
|
1000
|
+
description = "Backend image. Replace the bootstrap image with the generated backend image after pushing it to ACR."
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
variable "backend_target_port" {
|
|
1004
|
+
type = number
|
|
1005
|
+
default = 80
|
|
1006
|
+
description = "Backend ingress port. Set to 8000 when switching from the bootstrap image to the generated backend."
|
|
1007
|
+
}
|
|
1008
|
+
${frontendVariables}
|
|
886
1009
|
variable "postgres_admin_password" {
|
|
887
1010
|
type = string
|
|
888
1011
|
sensitive = true
|
|
@@ -898,6 +1021,18 @@ ${functionVariables}
|
|
|
898
1021
|
`;
|
|
899
1022
|
}
|
|
900
1023
|
function renderTofuMain(plan) {
|
|
1024
|
+
const functionPattern = hasFunctionWorker(plan) ? genAiPattern(plan) : undefined;
|
|
1025
|
+
const projectIdentityEnv = plan.projectType.id === 'genai' ? `
|
|
1026
|
+
env {
|
|
1027
|
+
name = "GENAI_PATTERN"
|
|
1028
|
+
value = "${genAiPattern(plan).id}"
|
|
1029
|
+
}
|
|
1030
|
+
` : `
|
|
1031
|
+
env {
|
|
1032
|
+
name = "API_STACK"
|
|
1033
|
+
value = "${plan.apiStack.id}"
|
|
1034
|
+
}
|
|
1035
|
+
`;
|
|
901
1036
|
const frontendContainer = plan.includeFrontend ? `
|
|
902
1037
|
resource "azurerm_container_app" "frontend" {
|
|
903
1038
|
name = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
|
|
@@ -905,10 +1040,20 @@ resource "azurerm_container_app" "frontend" {
|
|
|
905
1040
|
resource_group_name = azurerm_resource_group.main.name
|
|
906
1041
|
revision_mode = "Single"
|
|
907
1042
|
|
|
1043
|
+
identity {
|
|
1044
|
+
type = "UserAssigned"
|
|
1045
|
+
identity_ids = [azurerm_user_assigned_identity.app.id]
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
registry {
|
|
1049
|
+
server = azurerm_container_registry.main.login_server
|
|
1050
|
+
identity = azurerm_user_assigned_identity.app.id
|
|
1051
|
+
}
|
|
1052
|
+
|
|
908
1053
|
template {
|
|
909
1054
|
container {
|
|
910
1055
|
name = "frontend"
|
|
911
|
-
image =
|
|
1056
|
+
image = var.frontend_image
|
|
912
1057
|
cpu = 0.25
|
|
913
1058
|
memory = "0.5Gi"
|
|
914
1059
|
}
|
|
@@ -922,6 +1067,8 @@ resource "azurerm_container_app" "frontend" {
|
|
|
922
1067
|
latest_revision = true
|
|
923
1068
|
}
|
|
924
1069
|
}
|
|
1070
|
+
|
|
1071
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
925
1072
|
}
|
|
926
1073
|
` : '';
|
|
927
1074
|
const functionWorker = hasFunctionWorker(plan) ? `
|
|
@@ -955,7 +1102,7 @@ resource "azurerm_linux_function_app" "worker" {
|
|
|
955
1102
|
app_settings = {
|
|
956
1103
|
APP_ENV = var.environment
|
|
957
1104
|
APP_NAME = "${plan.safeProjectName}"
|
|
958
|
-
GENAI_PATTERN = "${
|
|
1105
|
+
GENAI_PATTERN = "${functionPattern?.id}"
|
|
959
1106
|
FUNCTIONS_WORKER_RUNTIME = "python"
|
|
960
1107
|
SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
|
|
961
1108
|
ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
|
|
@@ -999,6 +1146,12 @@ resource "azurerm_user_assigned_identity" "app" {
|
|
|
999
1146
|
location = azurerm_resource_group.main.location
|
|
1000
1147
|
}
|
|
1001
1148
|
|
|
1149
|
+
resource "azurerm_role_assignment" "acr_pull" {
|
|
1150
|
+
scope = azurerm_container_registry.main.id
|
|
1151
|
+
role_definition_name = "AcrPull"
|
|
1152
|
+
principal_id = azurerm_user_assigned_identity.app.principal_id
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1002
1155
|
resource "azurerm_container_app_environment" "main" {
|
|
1003
1156
|
name = "cae-\${local.name_prefix}"
|
|
1004
1157
|
resource_group_name = azurerm_resource_group.main.name
|
|
@@ -1016,23 +1169,85 @@ resource "azurerm_container_app" "backend" {
|
|
|
1016
1169
|
identity_ids = [azurerm_user_assigned_identity.app.id]
|
|
1017
1170
|
}
|
|
1018
1171
|
|
|
1172
|
+
registry {
|
|
1173
|
+
server = azurerm_container_registry.main.login_server
|
|
1174
|
+
identity = azurerm_user_assigned_identity.app.id
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
secret {
|
|
1178
|
+
name = "database-url"
|
|
1179
|
+
value = "postgresql://liftoffadmin:\${urlencode(var.postgres_admin_password)}@\${azurerm_postgresql_flexible_server.main.fqdn}:5432/postgres?sslmode=require"
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
secret {
|
|
1183
|
+
name = "redis-url"
|
|
1184
|
+
value = "rediss://:\${urlencode(azurerm_redis_cache.main.primary_access_key)}@\${azurerm_redis_cache.main.hostname}:\${azurerm_redis_cache.main.ssl_port}/0"
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1019
1187
|
template {
|
|
1020
1188
|
container {
|
|
1021
1189
|
name = "backend"
|
|
1022
|
-
image =
|
|
1190
|
+
image = var.backend_image
|
|
1023
1191
|
cpu = 0.5
|
|
1024
1192
|
memory = "1Gi"
|
|
1193
|
+
|
|
1194
|
+
env {
|
|
1195
|
+
name = "APP_ENV"
|
|
1196
|
+
value = var.environment
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
env {
|
|
1200
|
+
name = "APP_NAME"
|
|
1201
|
+
value = "${plan.safeProjectName}"
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
env {
|
|
1205
|
+
name = "PROJECT_TYPE"
|
|
1206
|
+
value = "${plan.projectType.id}"
|
|
1207
|
+
}
|
|
1208
|
+
${projectIdentityEnv}
|
|
1209
|
+
env {
|
|
1210
|
+
name = "CLOUD_PROVIDER"
|
|
1211
|
+
value = "azure"
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
env {
|
|
1215
|
+
name = "AZURE_REGION"
|
|
1216
|
+
value = var.location
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
env {
|
|
1220
|
+
name = "DATABASE_URL"
|
|
1221
|
+
secret_name = "database-url"
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
env {
|
|
1225
|
+
name = "REDIS_URL"
|
|
1226
|
+
secret_name = "redis-url"
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
env {
|
|
1230
|
+
name = "MESSAGING_TRANSPORT"
|
|
1231
|
+
value = "azure-service-bus"
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
env {
|
|
1235
|
+
name = "BLOB_ENDPOINT"
|
|
1236
|
+
value = azurerm_storage_account.main.primary_blob_endpoint
|
|
1237
|
+
}
|
|
1025
1238
|
}
|
|
1026
1239
|
}
|
|
1027
1240
|
|
|
1028
1241
|
ingress {
|
|
1029
1242
|
external_enabled = true
|
|
1030
|
-
target_port =
|
|
1243
|
+
target_port = var.backend_target_port
|
|
1031
1244
|
traffic_weight {
|
|
1032
1245
|
percentage = 100
|
|
1033
1246
|
latest_revision = true
|
|
1034
1247
|
}
|
|
1035
1248
|
}
|
|
1249
|
+
|
|
1250
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
1036
1251
|
}
|
|
1037
1252
|
${frontendContainer}
|
|
1038
1253
|
resource "azurerm_postgresql_flexible_server" "main" {
|
|
@@ -1046,6 +1261,14 @@ resource "azurerm_postgresql_flexible_server" "main" {
|
|
|
1046
1261
|
sku_name = "B_Standard_B1ms"
|
|
1047
1262
|
}
|
|
1048
1263
|
|
|
1264
|
+
resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
|
|
1265
|
+
count = var.enable_private_networking ? 0 : 1
|
|
1266
|
+
name = "AllowAzureServices"
|
|
1267
|
+
server_id = azurerm_postgresql_flexible_server.main.id
|
|
1268
|
+
start_ip_address = "0.0.0.0"
|
|
1269
|
+
end_ip_address = "0.0.0.0"
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1049
1272
|
resource "azurerm_redis_cache" "main" {
|
|
1050
1273
|
name = "redis-\${local.name_prefix}-\${var.resource_suffix}"
|
|
1051
1274
|
location = azurerm_resource_group.main.location
|
|
@@ -1119,6 +1342,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
|
|
|
1119
1342
|
` : ''}${functionOutputs}output "container_registry" {
|
|
1120
1343
|
value = azurerm_container_registry.main.login_server
|
|
1121
1344
|
}
|
|
1345
|
+
|
|
1346
|
+
output "container_registry_name" {
|
|
1347
|
+
value = azurerm_container_registry.main.name
|
|
1348
|
+
}
|
|
1122
1349
|
`;
|
|
1123
1350
|
}
|
|
1124
1351
|
function renderTofuLocalState() {
|
|
@@ -1149,30 +1376,98 @@ This project includes an Azure Functions worker under \`functions/${functionWork
|
|
|
1149
1376
|
|
|
1150
1377
|
Azure is the complete V1 provider for this Liftoff project.
|
|
1151
1378
|
|
|
1379
|
+
## Bootstrap Infrastructure
|
|
1380
|
+
|
|
1381
|
+
The first apply uses a public bootstrap image so Azure Container Apps can start before the new ACR contains application images.
|
|
1382
|
+
|
|
1152
1383
|
\`\`\`bash
|
|
1153
1384
|
tofu init
|
|
1154
1385
|
tofu plan -var-file=environments/${env}.tfvars
|
|
1155
1386
|
tofu apply -var-file=environments/${env}.tfvars
|
|
1156
|
-
|
|
1387
|
+
\`\`\`
|
|
1388
|
+
|
|
1389
|
+
Build the generated backend in ACR, then replace the bootstrap image:
|
|
1390
|
+
|
|
1391
|
+
\`\`\`bash
|
|
1392
|
+
ACR_NAME="$(tofu output -raw container_registry_name)"
|
|
1393
|
+
az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
|
|
1394
|
+
${plan.includeFrontend ? `az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest ../../../frontend
|
|
1395
|
+
` : ''}\`\`\`
|
|
1396
|
+
|
|
1397
|
+
Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
|
|
1398
|
+
|
|
1399
|
+
\`\`\`hcl
|
|
1400
|
+
backend_image = "<login-server>/${plan.safeProjectName}-backend:latest"
|
|
1401
|
+
backend_target_port = 8000
|
|
1402
|
+
${plan.includeFrontend ? `frontend_image = "<login-server>/${plan.safeProjectName}-frontend:latest"
|
|
1403
|
+
` : ''}\`\`\`
|
|
1404
|
+
|
|
1405
|
+
\`\`\`bash
|
|
1406
|
+
tofu apply -var-file=environments/${env}.tfvars
|
|
1157
1407
|
\`\`\`
|
|
1158
1408
|
|
|
1159
1409
|
Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
|
|
1410
|
+
The default PostgreSQL firewall permits Azure-hosted services. Replace it with private networking before production; set \`enable_private_networking=true\` only when the required VNet, delegated subnet, and private DNS resources are added.
|
|
1160
1411
|
${functionSection}
|
|
1161
1412
|
`;
|
|
1162
1413
|
}
|
|
1163
1414
|
function renderTofuTfvars(plan, environment) {
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
enable_private_networking
|
|
1171
|
-
|
|
1172
|
-
|
|
1415
|
+
const values = [
|
|
1416
|
+
['environment', JSON.stringify(environment)],
|
|
1417
|
+
['location', JSON.stringify(plan.region.slug)],
|
|
1418
|
+
['resource_suffix', JSON.stringify(`${plan.safeProjectName.replace(/-/g, '')}${environment}`)],
|
|
1419
|
+
['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
|
|
1420
|
+
['backend_target_port', '80'],
|
|
1421
|
+
['enable_private_networking', 'false']
|
|
1422
|
+
];
|
|
1423
|
+
if (plan.includeFrontend) {
|
|
1424
|
+
values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
|
|
1425
|
+
}
|
|
1426
|
+
if (hasFunctionWorker(plan)) {
|
|
1427
|
+
values.push(['function_worker_queue_name', JSON.stringify('events')], ['functions_python_version', JSON.stringify('3.12')]);
|
|
1428
|
+
}
|
|
1429
|
+
const width = Math.max(...values.map(([key]) => key.length));
|
|
1430
|
+
return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
|
|
1173
1431
|
}
|
|
1174
1432
|
function renderOpenSpecConfig(plan) {
|
|
1175
1433
|
const frontendRule = plan.includeFrontend ? '\n - Keep frontend code under frontend.' : '';
|
|
1434
|
+
if (plan.projectType.id === 'standard') {
|
|
1435
|
+
const backendRule = plan.apiStack.id === 'python-fastapi'
|
|
1436
|
+
? 'Keep backend API code under backend/apis.'
|
|
1437
|
+
: plan.apiStack.id === 'node-fastify'
|
|
1438
|
+
? 'Keep backend API code under backend/src.'
|
|
1439
|
+
: 'Keep the Go entrypoint under backend/cmd/api and reusable code under backend/internal.';
|
|
1440
|
+
return `schema: spec-driven
|
|
1441
|
+
|
|
1442
|
+
context: |
|
|
1443
|
+
Project generated by Mission Control Liftoff.
|
|
1444
|
+
Project type: Standard application.
|
|
1445
|
+
API stack: ${plan.apiStack.label}.
|
|
1446
|
+
Database tooling: ${plan.apiStack.databaseTooling}.
|
|
1447
|
+
API developer portal: Scalar.
|
|
1448
|
+
Infrastructure: OpenTofu.
|
|
1449
|
+
Primary cloud: Azure (${plan.region.slug}).
|
|
1450
|
+
Local development: Docker Compose.
|
|
1451
|
+
Database: PostgreSQL.
|
|
1452
|
+
Cache and local messaging: Redis.
|
|
1453
|
+
Environments: ${plan.environments.map((environment) => environment.id).join(', ')}.
|
|
1454
|
+
|
|
1455
|
+
rules:
|
|
1456
|
+
specs:
|
|
1457
|
+
- Requirements must describe observable product behavior.
|
|
1458
|
+
- Cloud behavior must identify environment differences for generated environments.
|
|
1459
|
+
design:
|
|
1460
|
+
- Use ${plan.apiStack.framework} for backend APIs.
|
|
1461
|
+
- Use ${plan.apiStack.databaseTooling} for database access and migrations.
|
|
1462
|
+
- Use OpenTofu for infrastructure changes.${frontendRule}
|
|
1463
|
+
- ${backendRule}
|
|
1464
|
+
- Keep database artifacts under database.
|
|
1465
|
+
tasks:
|
|
1466
|
+
- Include local Docker Compose verification.
|
|
1467
|
+
- Include OpenTofu validation for generated infrastructure.
|
|
1468
|
+
`;
|
|
1469
|
+
}
|
|
1470
|
+
const pattern = genAiPattern(plan);
|
|
1176
1471
|
const functionsContext = hasFunctionWorker(plan) ? `
|
|
1177
1472
|
Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
|
|
1178
1473
|
const functionsRule = hasFunctionWorker(plan) ? `
|
|
@@ -1182,7 +1477,7 @@ function renderOpenSpecConfig(plan) {
|
|
|
1182
1477
|
|
|
1183
1478
|
context: |
|
|
1184
1479
|
Project generated by Mission Control Liftoff.
|
|
1185
|
-
GenAI pattern: ${
|
|
1480
|
+
GenAI pattern: ${pattern.label}
|
|
1186
1481
|
Application framework: FastAPI + PydanticAI.
|
|
1187
1482
|
API developer portal: Scalar.
|
|
1188
1483
|
Infrastructure: OpenTofu.
|
|
@@ -1211,10 +1506,36 @@ ${functionsRule}
|
|
|
1211
1506
|
`;
|
|
1212
1507
|
}
|
|
1213
1508
|
function renderSeedProposal(plan) {
|
|
1509
|
+
if (plan.projectType.id === 'standard') {
|
|
1510
|
+
return `## Why
|
|
1511
|
+
|
|
1512
|
+
Bootstrap the generated ${plan.apiStack.label} standard application baseline created by Mission Control Liftoff.
|
|
1513
|
+
|
|
1514
|
+
## What Changes
|
|
1515
|
+
|
|
1516
|
+
- Establish the approved backend, infrastructure, local development, and governance baseline.
|
|
1517
|
+
- Capture follow-up product requirements through spec-driven changes.
|
|
1518
|
+
|
|
1519
|
+
## Capabilities
|
|
1520
|
+
|
|
1521
|
+
### New Capabilities
|
|
1522
|
+
|
|
1523
|
+
- \`${plan.apiStack.id}-application-baseline\`: Generated standard application baseline for this Liftoff project.
|
|
1524
|
+
|
|
1525
|
+
### Modified Capabilities
|
|
1526
|
+
|
|
1527
|
+
- None.
|
|
1528
|
+
|
|
1529
|
+
## Impact
|
|
1530
|
+
|
|
1531
|
+
- Generated ${plan.apiStack.label} backend, OpenTofu infrastructure, Docker Compose local development, and governance files.
|
|
1532
|
+
`;
|
|
1533
|
+
}
|
|
1534
|
+
const pattern = genAiPattern(plan);
|
|
1214
1535
|
const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
|
|
1215
1536
|
return `## Why
|
|
1216
1537
|
|
|
1217
|
-
Bootstrap the generated ${
|
|
1538
|
+
Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
|
|
1218
1539
|
|
|
1219
1540
|
## What Changes
|
|
1220
1541
|
|
|
@@ -1226,7 +1547,7 @@ ${functionsChange}
|
|
|
1226
1547
|
|
|
1227
1548
|
### New Capabilities
|
|
1228
1549
|
|
|
1229
|
-
- \`${
|
|
1550
|
+
- \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
|
|
1230
1551
|
|
|
1231
1552
|
### Modified Capabilities
|
|
1232
1553
|
|
|
@@ -1238,10 +1559,38 @@ ${functionsChange}
|
|
|
1238
1559
|
`;
|
|
1239
1560
|
}
|
|
1240
1561
|
function renderSeedDesign(plan) {
|
|
1562
|
+
if (plan.projectType.id === 'standard') {
|
|
1563
|
+
return `## Context
|
|
1564
|
+
|
|
1565
|
+
This standard project was generated with Liftoff using ${plan.apiStack.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
|
|
1566
|
+
|
|
1567
|
+
## Goals / Non-Goals
|
|
1568
|
+
|
|
1569
|
+
**Goals:**
|
|
1570
|
+
|
|
1571
|
+
- Keep the generated baseline aligned to the approved Mission Control stack.
|
|
1572
|
+
|
|
1573
|
+
**Non-Goals:**
|
|
1574
|
+
|
|
1575
|
+
- Define domain-specific product behavior in the bootstrap change.
|
|
1576
|
+
|
|
1577
|
+
## Decisions
|
|
1578
|
+
|
|
1579
|
+
- Use ${plan.apiStack.framework} for backend APIs.
|
|
1580
|
+
- Use ${plan.apiStack.databaseTooling} for PostgreSQL integration.
|
|
1581
|
+
- Use OpenTofu for Azure infrastructure.
|
|
1582
|
+
- Use Docker Compose for local development.
|
|
1583
|
+
|
|
1584
|
+
## Risks / Trade-offs
|
|
1585
|
+
|
|
1586
|
+
- The baseline contains placeholders that product-specific changes should replace.
|
|
1587
|
+
`;
|
|
1588
|
+
}
|
|
1589
|
+
const pattern = genAiPattern(plan);
|
|
1241
1590
|
const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
|
|
1242
1591
|
return `## Context
|
|
1243
1592
|
|
|
1244
|
-
This project was generated with Liftoff using ${
|
|
1593
|
+
This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
|
|
1245
1594
|
|
|
1246
1595
|
## Goals / Non-Goals
|
|
1247
1596
|
|
|
@@ -1274,6 +1623,28 @@ function renderSeedTasks() {
|
|
|
1274
1623
|
`;
|
|
1275
1624
|
}
|
|
1276
1625
|
function renderSpecKitConstitution(plan) {
|
|
1626
|
+
if (plan.projectType.id === 'standard') {
|
|
1627
|
+
const backendLayout = plan.apiStack.id === 'python-fastapi'
|
|
1628
|
+
? 'backend/apis'
|
|
1629
|
+
: plan.apiStack.id === 'node-fastify' ? 'backend/src' : 'backend/cmd/api and backend/internal';
|
|
1630
|
+
return `# Mission Control Liftoff Constitution
|
|
1631
|
+
|
|
1632
|
+
## Principle 1: Approved Application Stack
|
|
1633
|
+
Generated backend services MUST use ${plan.apiStack.framework}, ${plan.apiStack.databaseTooling}, and Scalar for API documentation.
|
|
1634
|
+
|
|
1635
|
+
## Principle 2: Standard Project Layout
|
|
1636
|
+
Backend APIs live under ${backendLayout}. Database artifacts live under database.${plan.includeFrontend ? ' Frontend code lives under frontend.' : ''}
|
|
1637
|
+
|
|
1638
|
+
## Principle 3: Infrastructure As Code
|
|
1639
|
+
Cloud infrastructure MUST be defined with OpenTofu. Azure is the supported V1 provider.
|
|
1640
|
+
|
|
1641
|
+
## Principle 4: Local Development Parity
|
|
1642
|
+
Projects MUST include Docker Compose for local development with PostgreSQL, Redis, local blob storage, and local messaging behavior.
|
|
1643
|
+
|
|
1644
|
+
## Principle 5: Observability And Operations
|
|
1645
|
+
Services MUST use structured logging and environment-specific configuration for ${plan.environments.map((environment) => environment.id).join(', ')}.
|
|
1646
|
+
`;
|
|
1647
|
+
}
|
|
1277
1648
|
const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
|
|
1278
1649
|
return `# Mission Control Liftoff Constitution
|
|
1279
1650
|
|
|
@@ -1335,7 +1706,7 @@ function renderFrontendPackage(plan) {
|
|
|
1335
1706
|
}, null, 2);
|
|
1336
1707
|
}
|
|
1337
1708
|
function renderFrontendIndex(plan) {
|
|
1338
|
-
return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${plan.projectName}</title>`;
|
|
1709
|
+
return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${escapeHtml(plan.projectName)}</title>`;
|
|
1339
1710
|
}
|
|
1340
1711
|
function renderFrontendMain() {
|
|
1341
1712
|
return `import { createApp } from 'vue';
|
|
@@ -1346,10 +1717,11 @@ createApp(App).mount('#app');
|
|
|
1346
1717
|
`;
|
|
1347
1718
|
}
|
|
1348
1719
|
function renderFrontendApp(plan) {
|
|
1720
|
+
const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
|
|
1349
1721
|
return `<script setup lang="ts">
|
|
1350
|
-
const title =
|
|
1351
|
-
const starter =
|
|
1352
|
-
const
|
|
1722
|
+
const title = ${scriptSourceString(plan.projectName)};
|
|
1723
|
+
const starter = ${scriptSourceString(plan.frontendStarter)};
|
|
1724
|
+
const descriptor = ${scriptSourceString(descriptor)};
|
|
1353
1725
|
</script>
|
|
1354
1726
|
|
|
1355
1727
|
<template>
|
|
@@ -1358,7 +1730,7 @@ const pattern = '${plan.pattern.label}';
|
|
|
1358
1730
|
<header>
|
|
1359
1731
|
<p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
|
|
1360
1732
|
<h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
|
|
1361
|
-
<p class="mt-2 text-slate-600">{{
|
|
1733
|
+
<p class="mt-2 text-slate-600">{{ descriptor }}</p>
|
|
1362
1734
|
</header>
|
|
1363
1735
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
|
1364
1736
|
<h2 class="text-xl font-semibold">{{ starter }}</h2>
|