@msn-control/liftoff 0.2.1 → 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 +34 -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/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 +445 -75
- 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,7 +56,9 @@ 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,
|
|
@@ -60,7 +80,9 @@ function addBaseArtifacts(add, plan) {
|
|
|
60
80
|
add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
|
|
61
81
|
add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
|
|
62
82
|
projectName: plan.projectName,
|
|
63
|
-
|
|
83
|
+
projectType: plan.projectType.id,
|
|
84
|
+
apiStack: plan.apiStack.id,
|
|
85
|
+
...(plan.pattern ? { pattern: plan.pattern.id } : {}),
|
|
64
86
|
cloud: plan.provider.id,
|
|
65
87
|
region: plan.region.slug,
|
|
66
88
|
includeFrontend: plan.includeFrontend,
|
|
@@ -68,10 +90,10 @@ function addBaseArtifacts(add, plan) {
|
|
|
68
90
|
specWorkflow: plan.specWorkflow.id
|
|
69
91
|
}, null, 2));
|
|
70
92
|
add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
|
|
71
|
-
add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
|
|
93
|
+
add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
|
|
72
94
|
}
|
|
73
95
|
function addBackendArtifacts(add, plan) {
|
|
74
|
-
const routeModule = pyModule(plan.
|
|
96
|
+
const routeModule = pyModule(genAiPattern(plan).id);
|
|
75
97
|
add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
|
|
76
98
|
add('backend-package', 'backend', ['backend', '__init__.py'], '');
|
|
77
99
|
add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
|
|
@@ -97,20 +119,21 @@ function addDatabaseArtifacts(add, plan) {
|
|
|
97
119
|
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
|
|
98
120
|
}
|
|
99
121
|
function addPatternArtifacts(add, plan) {
|
|
100
|
-
const
|
|
122
|
+
const pattern = genAiPattern(plan);
|
|
123
|
+
const routeModule = pyModule(pattern.id);
|
|
101
124
|
add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
|
|
102
|
-
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${
|
|
125
|
+
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
|
|
103
126
|
add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
|
|
104
127
|
add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
|
|
105
|
-
if (
|
|
128
|
+
if (pattern.id === 'rag') {
|
|
106
129
|
add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
|
|
107
130
|
add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
|
|
108
131
|
}
|
|
109
|
-
if (
|
|
132
|
+
if (pattern.worker) {
|
|
110
133
|
add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
|
|
111
134
|
add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
|
|
112
135
|
}
|
|
113
|
-
if (
|
|
136
|
+
if (pattern.id === 'fine-tuned') {
|
|
114
137
|
add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
|
|
115
138
|
}
|
|
116
139
|
}
|
|
@@ -132,7 +155,7 @@ function addFunctionArtifacts(add, plan) {
|
|
|
132
155
|
}
|
|
133
156
|
function addEnvironmentArtifacts(add, plan) {
|
|
134
157
|
for (const environment of plan.environments) {
|
|
135
|
-
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));
|
|
136
159
|
if (hasFunctionWorker(plan)) {
|
|
137
160
|
add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
|
|
138
161
|
}
|
|
@@ -183,6 +206,48 @@ function addFrontendArtifacts(add, plan) {
|
|
|
183
206
|
add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
|
|
184
207
|
}
|
|
185
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);
|
|
186
251
|
const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
|
|
187
252
|
` : '';
|
|
188
253
|
const functionsSection = hasFunctionWorker(plan) ? `
|
|
@@ -197,10 +262,10 @@ Generated by Mission Control Liftoff.
|
|
|
197
262
|
## Stack
|
|
198
263
|
|
|
199
264
|
- Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
|
|
200
|
-
- Pattern: ${
|
|
265
|
+
- Pattern: ${pattern.label}
|
|
201
266
|
- Cloud: ${plan.provider.label} (${plan.region.slug})
|
|
202
267
|
- Infrastructure: OpenTofu
|
|
203
|
-
- Database: PostgreSQL with Alembic migrations${
|
|
268
|
+
- Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
|
|
204
269
|
- Cache and local messaging: Redis
|
|
205
270
|
- Observability: Langfuse
|
|
206
271
|
- Local development: Docker Compose
|
|
@@ -224,6 +289,8 @@ tofu plan -var-file=environments/dev.tfvars
|
|
|
224
289
|
tofu apply -var-file=environments/dev.tfvars
|
|
225
290
|
\`\`\`
|
|
226
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
|
+
|
|
227
294
|
## Spec-Driven Workflow
|
|
228
295
|
|
|
229
296
|
Selected workflow: ${plan.specWorkflow.label}.
|
|
@@ -244,9 +311,13 @@ migration/legacy/
|
|
|
244
311
|
`;
|
|
245
312
|
}
|
|
246
313
|
function renderEnvExample(plan) {
|
|
314
|
+
if (plan.projectType.id === 'standard') {
|
|
315
|
+
return renderStandardEnv(plan);
|
|
316
|
+
}
|
|
317
|
+
const pattern = genAiPattern(plan);
|
|
247
318
|
return `APP_ENV=dev
|
|
248
319
|
APP_NAME=${plan.safeProjectName}
|
|
249
|
-
GENAI_PATTERN=${
|
|
320
|
+
GENAI_PATTERN=${pattern.id}
|
|
250
321
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
251
322
|
AZURE_REGION=${plan.region.slug}
|
|
252
323
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
@@ -289,6 +360,7 @@ dependencies = [
|
|
|
289
360
|
"scalar-fastapi>=1.0",
|
|
290
361
|
"sqlalchemy[asyncio]>=2.0",
|
|
291
362
|
"asyncpg>=0.29",
|
|
363
|
+
"psycopg[binary]>=3.2",
|
|
292
364
|
"alembic>=1.13",
|
|
293
365
|
"redis>=5.0",
|
|
294
366
|
"langfuse>=2.39",
|
|
@@ -304,6 +376,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
|
|
|
304
376
|
requires = ["setuptools>=70"]
|
|
305
377
|
build-backend = "setuptools.build_meta"
|
|
306
378
|
|
|
379
|
+
[tool.setuptools]
|
|
380
|
+
packages = []
|
|
381
|
+
|
|
307
382
|
[tool.pytest.ini_options]
|
|
308
383
|
pythonpath = [".."]
|
|
309
384
|
testpaths = ["tests"]
|
|
@@ -352,16 +427,17 @@ def ready():
|
|
|
352
427
|
`;
|
|
353
428
|
}
|
|
354
429
|
function renderPatternRoutes(plan) {
|
|
355
|
-
const
|
|
430
|
+
const pattern = genAiPattern(plan);
|
|
431
|
+
const moduleName = pyModule(pattern.id);
|
|
356
432
|
const agentName = `${moduleName}_agent`;
|
|
357
|
-
const prefix =
|
|
358
|
-
if (
|
|
433
|
+
const prefix = pattern.routePrefix;
|
|
434
|
+
if (pattern.id === 'streaming') {
|
|
359
435
|
return `from fastapi import APIRouter
|
|
360
436
|
from fastapi.responses import StreamingResponse
|
|
361
437
|
|
|
362
438
|
from backend.orchestration.agents.${agentName} import stream_response
|
|
363
439
|
|
|
364
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
440
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
365
441
|
|
|
366
442
|
|
|
367
443
|
@router.get("")
|
|
@@ -369,7 +445,7 @@ def stream(prompt: str):
|
|
|
369
445
|
return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
|
|
370
446
|
`;
|
|
371
447
|
}
|
|
372
|
-
if (
|
|
448
|
+
if (pattern.id === 'rag') {
|
|
373
449
|
return `from fastapi import APIRouter
|
|
374
450
|
from pydantic import BaseModel
|
|
375
451
|
|
|
@@ -396,13 +472,13 @@ async def ingest(request: IngestionRequest):
|
|
|
396
472
|
return await enqueue_ingestion(request.source_uri)
|
|
397
473
|
`;
|
|
398
474
|
}
|
|
399
|
-
const bodyClass = `${titleCase(
|
|
475
|
+
const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
|
|
400
476
|
return `from fastapi import APIRouter
|
|
401
477
|
from pydantic import BaseModel
|
|
402
478
|
|
|
403
479
|
from backend.orchestration.agents.${agentName} import run_${moduleName}
|
|
404
480
|
|
|
405
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
481
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
406
482
|
|
|
407
483
|
|
|
408
484
|
class ${bodyClass}(BaseModel):
|
|
@@ -428,6 +504,7 @@ async def get_current_user() -> CurrentUser:
|
|
|
428
504
|
`;
|
|
429
505
|
}
|
|
430
506
|
function renderSettings(plan) {
|
|
507
|
+
const pattern = genAiPattern(plan);
|
|
431
508
|
return `from functools import lru_cache
|
|
432
509
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
433
510
|
|
|
@@ -435,9 +512,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
435
512
|
class Settings(BaseSettings):
|
|
436
513
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
437
514
|
|
|
438
|
-
app_name: str =
|
|
515
|
+
app_name: str = ${sourceString(plan.projectName)}
|
|
439
516
|
app_env: str = "dev"
|
|
440
|
-
genai_pattern: str = "${
|
|
517
|
+
genai_pattern: str = "${pattern.id}"
|
|
441
518
|
cloud_provider: str = "${plan.provider.id}"
|
|
442
519
|
azure_region: str = "${plan.region.slug}"
|
|
443
520
|
database_url: str
|
|
@@ -453,6 +530,7 @@ def get_settings() -> Settings:
|
|
|
453
530
|
`;
|
|
454
531
|
}
|
|
455
532
|
function renderModelConfig(plan) {
|
|
533
|
+
const pattern = genAiPattern(plan);
|
|
456
534
|
return `from pydantic import BaseModel, Field
|
|
457
535
|
|
|
458
536
|
|
|
@@ -460,7 +538,7 @@ class ModelConfig(BaseModel):
|
|
|
460
538
|
provider: str = Field(default="azure-openai")
|
|
461
539
|
deployment_name: str = Field(default="gpt-4.1")
|
|
462
540
|
embedding_deployment_name: str = Field(default="text-embedding-3-large")
|
|
463
|
-
pattern: str = Field(default="${
|
|
541
|
+
pattern: str = Field(default="${pattern.id}")
|
|
464
542
|
`;
|
|
465
543
|
}
|
|
466
544
|
function renderMessagingBoundary() {
|
|
@@ -515,25 +593,35 @@ def test_health():
|
|
|
515
593
|
}
|
|
516
594
|
function renderAlembicIni() {
|
|
517
595
|
return `[alembic]
|
|
518
|
-
script_location = migrations
|
|
596
|
+
script_location = %(here)s/migrations
|
|
519
597
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
520
598
|
`;
|
|
521
599
|
}
|
|
522
600
|
function renderAlembicEnv() {
|
|
523
|
-
return `
|
|
601
|
+
return `import os
|
|
602
|
+
|
|
603
|
+
from alembic import context
|
|
604
|
+
from sqlalchemy import create_engine
|
|
524
605
|
|
|
525
606
|
|
|
526
607
|
def run_migrations_online():
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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()
|
|
530
618
|
|
|
531
619
|
|
|
532
620
|
run_migrations_online()
|
|
533
621
|
`;
|
|
534
622
|
}
|
|
535
623
|
function renderInitialMigration(plan) {
|
|
536
|
-
const vectorExtension = plan.
|
|
624
|
+
const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
|
|
537
625
|
return `from alembic import op
|
|
538
626
|
import sqlalchemy as sa
|
|
539
627
|
|
|
@@ -564,11 +652,12 @@ function renderDatabaseSchema(plan) {
|
|
|
564
652
|
payload JSONB NOT NULL,
|
|
565
653
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
566
654
|
);
|
|
567
|
-
${plan.
|
|
655
|
+
${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
|
|
568
656
|
}
|
|
569
657
|
function renderPatternAgent(plan) {
|
|
570
|
-
const
|
|
571
|
-
|
|
658
|
+
const pattern = genAiPattern(plan);
|
|
659
|
+
const moduleName = pyModule(pattern.id);
|
|
660
|
+
if (pattern.id === 'rag') {
|
|
572
661
|
return `from backend.orchestration.tools.messaging import build_message_publisher
|
|
573
662
|
|
|
574
663
|
|
|
@@ -586,7 +675,7 @@ async def enqueue_ingestion(source_uri: str) -> dict:
|
|
|
586
675
|
return {"status": "queued", "source_uri": source_uri}
|
|
587
676
|
`;
|
|
588
677
|
}
|
|
589
|
-
if (
|
|
678
|
+
if (pattern.id === 'streaming') {
|
|
590
679
|
return `async def stream_response(prompt: str):
|
|
591
680
|
yield f"data: Starting response for {prompt}\\n\\n"
|
|
592
681
|
yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
|
|
@@ -594,15 +683,16 @@ async def enqueue_ingestion(source_uri: str) -> dict:
|
|
|
594
683
|
}
|
|
595
684
|
return `async def run_${moduleName}(input_text: str) -> dict:
|
|
596
685
|
return {
|
|
597
|
-
"result": "Replace this placeholder with ${
|
|
686
|
+
"result": "Replace this placeholder with ${pattern.label} PydanticAI orchestration.",
|
|
598
687
|
"input": input_text,
|
|
599
688
|
}
|
|
600
689
|
`;
|
|
601
690
|
}
|
|
602
691
|
function renderPromptTemplate(plan) {
|
|
603
|
-
|
|
692
|
+
const pattern = genAiPattern(plan);
|
|
693
|
+
return `# ${pattern.label} Prompt
|
|
604
694
|
|
|
605
|
-
You are implementing a ${
|
|
695
|
+
You are implementing a ${pattern.label} generated by Mission Control Liftoff.
|
|
606
696
|
|
|
607
697
|
Use PydanticAI orchestration and return outputs that match the API contract.
|
|
608
698
|
`;
|
|
@@ -628,8 +718,9 @@ class PgVectorStore:
|
|
|
628
718
|
`;
|
|
629
719
|
}
|
|
630
720
|
function renderPatternWorker(plan) {
|
|
721
|
+
const pattern = genAiPattern(plan);
|
|
631
722
|
return `async def run_worker() -> None:
|
|
632
|
-
# Consume ${
|
|
723
|
+
# Consume ${pattern.label} jobs from the configured messaging boundary.
|
|
633
724
|
return None
|
|
634
725
|
`;
|
|
635
726
|
}
|
|
@@ -643,9 +734,10 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
|
|
|
643
734
|
}
|
|
644
735
|
function renderFunctionWorkerReadme(plan) {
|
|
645
736
|
const workerName = functionWorkerName(plan);
|
|
737
|
+
const pattern = genAiPattern(plan);
|
|
646
738
|
return `# ${workerName}
|
|
647
739
|
|
|
648
|
-
Azure Functions worker scaffold for ${
|
|
740
|
+
Azure Functions worker scaffold for ${pattern.label}.
|
|
649
741
|
|
|
650
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.
|
|
651
743
|
|
|
@@ -672,6 +764,7 @@ function renderFunctionHostJson() {
|
|
|
672
764
|
}, null, 2);
|
|
673
765
|
}
|
|
674
766
|
function renderFunctionLocalSettings(plan) {
|
|
767
|
+
const pattern = genAiPattern(plan);
|
|
675
768
|
return JSON.stringify({
|
|
676
769
|
IsEncrypted: false,
|
|
677
770
|
Values: {
|
|
@@ -679,7 +772,7 @@ function renderFunctionLocalSettings(plan) {
|
|
|
679
772
|
FUNCTIONS_WORKER_RUNTIME: 'python',
|
|
680
773
|
SERVICEBUS_QUEUE_NAME: 'events',
|
|
681
774
|
ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
|
|
682
|
-
GENAI_PATTERN:
|
|
775
|
+
GENAI_PATTERN: pattern.id,
|
|
683
776
|
SHARED_ORCHESTRATION_ROOT: '../../backend'
|
|
684
777
|
}
|
|
685
778
|
}, null, 2);
|
|
@@ -690,7 +783,8 @@ pytest>=8.2
|
|
|
690
783
|
`;
|
|
691
784
|
}
|
|
692
785
|
function renderFunctionApp(plan) {
|
|
693
|
-
const
|
|
786
|
+
const pattern = genAiPattern(plan);
|
|
787
|
+
const moduleName = pyModule(pattern.id);
|
|
694
788
|
return `import json
|
|
695
789
|
import logging
|
|
696
790
|
|
|
@@ -717,7 +811,7 @@ def decode_message_payload(body: str) -> dict:
|
|
|
717
811
|
)
|
|
718
812
|
def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
|
|
719
813
|
payload = decode_message_payload(message.get_body().decode("utf-8"))
|
|
720
|
-
logging.info("Received ${
|
|
814
|
+
logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
|
|
721
815
|
# Keep this adapter thin; call backend.orchestration code from packaged shared modules.
|
|
722
816
|
`;
|
|
723
817
|
}
|
|
@@ -751,10 +845,11 @@ local.settings.json
|
|
|
751
845
|
`;
|
|
752
846
|
}
|
|
753
847
|
function renderBackendEnv(plan, environment) {
|
|
848
|
+
const pattern = genAiPattern(plan);
|
|
754
849
|
const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
|
|
755
850
|
return `APP_ENV=${environment}
|
|
756
851
|
APP_NAME=${plan.safeProjectName}
|
|
757
|
-
GENAI_PATTERN=${
|
|
852
|
+
GENAI_PATTERN=${pattern.id}
|
|
758
853
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
759
854
|
AZURE_REGION=${plan.region.slug}
|
|
760
855
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
@@ -765,9 +860,10 @@ LANGFUSE_HOST=
|
|
|
765
860
|
`;
|
|
766
861
|
}
|
|
767
862
|
function renderFunctionsEnv(plan, environment) {
|
|
863
|
+
const pattern = genAiPattern(plan);
|
|
768
864
|
return `APP_ENV=${environment}
|
|
769
865
|
APP_NAME=${plan.safeProjectName}
|
|
770
|
-
GENAI_PATTERN=${
|
|
866
|
+
GENAI_PATTERN=${pattern.id}
|
|
771
867
|
FUNCTIONS_WORKER_RUNTIME=python
|
|
772
868
|
SERVICEBUS_QUEUE_NAME=events
|
|
773
869
|
ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
|
|
@@ -776,6 +872,7 @@ SHARED_ORCHESTRATION_ROOT=../../backend
|
|
|
776
872
|
`;
|
|
777
873
|
}
|
|
778
874
|
function renderDockerCompose(plan) {
|
|
875
|
+
const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
|
|
779
876
|
const frontendService = plan.includeFrontend ? `
|
|
780
877
|
frontend:
|
|
781
878
|
build:
|
|
@@ -785,13 +882,17 @@ function renderDockerCompose(plan) {
|
|
|
785
882
|
depends_on:
|
|
786
883
|
- backend
|
|
787
884
|
` : '';
|
|
885
|
+
const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
|
|
788
886
|
return `services:
|
|
789
887
|
backend:
|
|
790
888
|
build:
|
|
791
889
|
context: .
|
|
792
890
|
dockerfile: Dockerfile
|
|
793
891
|
env_file:
|
|
794
|
-
- ./environments/
|
|
892
|
+
- ./environments/${localEnvironment.id}/backend.env
|
|
893
|
+
environment:
|
|
894
|
+
MESSAGING_TRANSPORT: redis-streams
|
|
895
|
+
BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
|
|
795
896
|
ports:
|
|
796
897
|
- "8000:8000"
|
|
797
898
|
depends_on:
|
|
@@ -801,7 +902,7 @@ function renderDockerCompose(plan) {
|
|
|
801
902
|
- mailpit
|
|
802
903
|
${frontendService}
|
|
803
904
|
postgres:
|
|
804
|
-
image:
|
|
905
|
+
image: ${postgresImage}
|
|
805
906
|
environment:
|
|
806
907
|
POSTGRES_USER: postgres
|
|
807
908
|
POSTGRES_PASSWORD: postgres
|
|
@@ -825,7 +926,7 @@ ${frontendService}
|
|
|
825
926
|
ports:
|
|
826
927
|
- "8025:8025"
|
|
827
928
|
|
|
828
|
-
langfuse:
|
|
929
|
+
${plan.projectType.id === 'genai' ? ` langfuse:
|
|
829
930
|
image: langfuse/langfuse:2
|
|
830
931
|
profiles:
|
|
831
932
|
- observability
|
|
@@ -835,6 +936,7 @@ ${frontendService}
|
|
|
835
936
|
SALT: local-development-placeholder
|
|
836
937
|
ports:
|
|
837
938
|
- "3000:3000"
|
|
939
|
+
` : ''}
|
|
838
940
|
`;
|
|
839
941
|
}
|
|
840
942
|
function renderTofuVersions() {
|
|
@@ -856,6 +958,13 @@ function renderTofuProviders() {
|
|
|
856
958
|
`;
|
|
857
959
|
}
|
|
858
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
|
+
` : '';
|
|
859
968
|
const functionVariables = hasFunctionWorker(plan) ? `
|
|
860
969
|
variable "function_worker_queue_name" {
|
|
861
970
|
type = string
|
|
@@ -885,6 +994,18 @@ variable "resource_suffix" {
|
|
|
885
994
|
description = "Globally unique suffix for Azure resource names."
|
|
886
995
|
}
|
|
887
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}
|
|
888
1009
|
variable "postgres_admin_password" {
|
|
889
1010
|
type = string
|
|
890
1011
|
sensitive = true
|
|
@@ -900,6 +1021,18 @@ ${functionVariables}
|
|
|
900
1021
|
`;
|
|
901
1022
|
}
|
|
902
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
|
+
`;
|
|
903
1036
|
const frontendContainer = plan.includeFrontend ? `
|
|
904
1037
|
resource "azurerm_container_app" "frontend" {
|
|
905
1038
|
name = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
|
|
@@ -907,10 +1040,20 @@ resource "azurerm_container_app" "frontend" {
|
|
|
907
1040
|
resource_group_name = azurerm_resource_group.main.name
|
|
908
1041
|
revision_mode = "Single"
|
|
909
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
|
+
|
|
910
1053
|
template {
|
|
911
1054
|
container {
|
|
912
1055
|
name = "frontend"
|
|
913
|
-
image =
|
|
1056
|
+
image = var.frontend_image
|
|
914
1057
|
cpu = 0.25
|
|
915
1058
|
memory = "0.5Gi"
|
|
916
1059
|
}
|
|
@@ -924,6 +1067,8 @@ resource "azurerm_container_app" "frontend" {
|
|
|
924
1067
|
latest_revision = true
|
|
925
1068
|
}
|
|
926
1069
|
}
|
|
1070
|
+
|
|
1071
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
927
1072
|
}
|
|
928
1073
|
` : '';
|
|
929
1074
|
const functionWorker = hasFunctionWorker(plan) ? `
|
|
@@ -957,7 +1102,7 @@ resource "azurerm_linux_function_app" "worker" {
|
|
|
957
1102
|
app_settings = {
|
|
958
1103
|
APP_ENV = var.environment
|
|
959
1104
|
APP_NAME = "${plan.safeProjectName}"
|
|
960
|
-
GENAI_PATTERN = "${
|
|
1105
|
+
GENAI_PATTERN = "${functionPattern?.id}"
|
|
961
1106
|
FUNCTIONS_WORKER_RUNTIME = "python"
|
|
962
1107
|
SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
|
|
963
1108
|
ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
|
|
@@ -1001,6 +1146,12 @@ resource "azurerm_user_assigned_identity" "app" {
|
|
|
1001
1146
|
location = azurerm_resource_group.main.location
|
|
1002
1147
|
}
|
|
1003
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
|
+
|
|
1004
1155
|
resource "azurerm_container_app_environment" "main" {
|
|
1005
1156
|
name = "cae-\${local.name_prefix}"
|
|
1006
1157
|
resource_group_name = azurerm_resource_group.main.name
|
|
@@ -1018,23 +1169,85 @@ resource "azurerm_container_app" "backend" {
|
|
|
1018
1169
|
identity_ids = [azurerm_user_assigned_identity.app.id]
|
|
1019
1170
|
}
|
|
1020
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
|
+
|
|
1021
1187
|
template {
|
|
1022
1188
|
container {
|
|
1023
1189
|
name = "backend"
|
|
1024
|
-
image =
|
|
1190
|
+
image = var.backend_image
|
|
1025
1191
|
cpu = 0.5
|
|
1026
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
|
+
}
|
|
1027
1238
|
}
|
|
1028
1239
|
}
|
|
1029
1240
|
|
|
1030
1241
|
ingress {
|
|
1031
1242
|
external_enabled = true
|
|
1032
|
-
target_port =
|
|
1243
|
+
target_port = var.backend_target_port
|
|
1033
1244
|
traffic_weight {
|
|
1034
1245
|
percentage = 100
|
|
1035
1246
|
latest_revision = true
|
|
1036
1247
|
}
|
|
1037
1248
|
}
|
|
1249
|
+
|
|
1250
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
1038
1251
|
}
|
|
1039
1252
|
${frontendContainer}
|
|
1040
1253
|
resource "azurerm_postgresql_flexible_server" "main" {
|
|
@@ -1048,6 +1261,14 @@ resource "azurerm_postgresql_flexible_server" "main" {
|
|
|
1048
1261
|
sku_name = "B_Standard_B1ms"
|
|
1049
1262
|
}
|
|
1050
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
|
+
|
|
1051
1272
|
resource "azurerm_redis_cache" "main" {
|
|
1052
1273
|
name = "redis-\${local.name_prefix}-\${var.resource_suffix}"
|
|
1053
1274
|
location = azurerm_resource_group.main.location
|
|
@@ -1121,6 +1342,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
|
|
|
1121
1342
|
` : ''}${functionOutputs}output "container_registry" {
|
|
1122
1343
|
value = azurerm_container_registry.main.login_server
|
|
1123
1344
|
}
|
|
1345
|
+
|
|
1346
|
+
output "container_registry_name" {
|
|
1347
|
+
value = azurerm_container_registry.main.name
|
|
1348
|
+
}
|
|
1124
1349
|
`;
|
|
1125
1350
|
}
|
|
1126
1351
|
function renderTofuLocalState() {
|
|
@@ -1151,30 +1376,98 @@ This project includes an Azure Functions worker under \`functions/${functionWork
|
|
|
1151
1376
|
|
|
1152
1377
|
Azure is the complete V1 provider for this Liftoff project.
|
|
1153
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
|
+
|
|
1154
1383
|
\`\`\`bash
|
|
1155
1384
|
tofu init
|
|
1156
1385
|
tofu plan -var-file=environments/${env}.tfvars
|
|
1157
1386
|
tofu apply -var-file=environments/${env}.tfvars
|
|
1158
|
-
|
|
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
|
|
1159
1407
|
\`\`\`
|
|
1160
1408
|
|
|
1161
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.
|
|
1162
1411
|
${functionSection}
|
|
1163
1412
|
`;
|
|
1164
1413
|
}
|
|
1165
1414
|
function renderTofuTfvars(plan, environment) {
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
enable_private_networking
|
|
1173
|
-
|
|
1174
|
-
|
|
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');
|
|
1175
1431
|
}
|
|
1176
1432
|
function renderOpenSpecConfig(plan) {
|
|
1177
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);
|
|
1178
1471
|
const functionsContext = hasFunctionWorker(plan) ? `
|
|
1179
1472
|
Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
|
|
1180
1473
|
const functionsRule = hasFunctionWorker(plan) ? `
|
|
@@ -1184,7 +1477,7 @@ function renderOpenSpecConfig(plan) {
|
|
|
1184
1477
|
|
|
1185
1478
|
context: |
|
|
1186
1479
|
Project generated by Mission Control Liftoff.
|
|
1187
|
-
GenAI pattern: ${
|
|
1480
|
+
GenAI pattern: ${pattern.label}
|
|
1188
1481
|
Application framework: FastAPI + PydanticAI.
|
|
1189
1482
|
API developer portal: Scalar.
|
|
1190
1483
|
Infrastructure: OpenTofu.
|
|
@@ -1213,10 +1506,36 @@ ${functionsRule}
|
|
|
1213
1506
|
`;
|
|
1214
1507
|
}
|
|
1215
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);
|
|
1216
1535
|
const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
|
|
1217
1536
|
return `## Why
|
|
1218
1537
|
|
|
1219
|
-
Bootstrap the generated ${
|
|
1538
|
+
Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
|
|
1220
1539
|
|
|
1221
1540
|
## What Changes
|
|
1222
1541
|
|
|
@@ -1228,7 +1547,7 @@ ${functionsChange}
|
|
|
1228
1547
|
|
|
1229
1548
|
### New Capabilities
|
|
1230
1549
|
|
|
1231
|
-
- \`${
|
|
1550
|
+
- \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
|
|
1232
1551
|
|
|
1233
1552
|
### Modified Capabilities
|
|
1234
1553
|
|
|
@@ -1240,10 +1559,38 @@ ${functionsChange}
|
|
|
1240
1559
|
`;
|
|
1241
1560
|
}
|
|
1242
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);
|
|
1243
1590
|
const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
|
|
1244
1591
|
return `## Context
|
|
1245
1592
|
|
|
1246
|
-
This project was generated with Liftoff using ${
|
|
1593
|
+
This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
|
|
1247
1594
|
|
|
1248
1595
|
## Goals / Non-Goals
|
|
1249
1596
|
|
|
@@ -1276,6 +1623,28 @@ function renderSeedTasks() {
|
|
|
1276
1623
|
`;
|
|
1277
1624
|
}
|
|
1278
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
|
+
}
|
|
1279
1648
|
const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
|
|
1280
1649
|
return `# Mission Control Liftoff Constitution
|
|
1281
1650
|
|
|
@@ -1337,7 +1706,7 @@ function renderFrontendPackage(plan) {
|
|
|
1337
1706
|
}, null, 2);
|
|
1338
1707
|
}
|
|
1339
1708
|
function renderFrontendIndex(plan) {
|
|
1340
|
-
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>`;
|
|
1341
1710
|
}
|
|
1342
1711
|
function renderFrontendMain() {
|
|
1343
1712
|
return `import { createApp } from 'vue';
|
|
@@ -1348,10 +1717,11 @@ createApp(App).mount('#app');
|
|
|
1348
1717
|
`;
|
|
1349
1718
|
}
|
|
1350
1719
|
function renderFrontendApp(plan) {
|
|
1720
|
+
const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
|
|
1351
1721
|
return `<script setup lang="ts">
|
|
1352
|
-
const title =
|
|
1353
|
-
const starter =
|
|
1354
|
-
const
|
|
1722
|
+
const title = ${scriptSourceString(plan.projectName)};
|
|
1723
|
+
const starter = ${scriptSourceString(plan.frontendStarter)};
|
|
1724
|
+
const descriptor = ${scriptSourceString(descriptor)};
|
|
1355
1725
|
</script>
|
|
1356
1726
|
|
|
1357
1727
|
<template>
|
|
@@ -1360,7 +1730,7 @@ const pattern = '${plan.pattern.label}';
|
|
|
1360
1730
|
<header>
|
|
1361
1731
|
<p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
|
|
1362
1732
|
<h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
|
|
1363
|
-
<p class="mt-2 text-slate-600">{{
|
|
1733
|
+
<p class="mt-2 text-slate-600">{{ descriptor }}</p>
|
|
1364
1734
|
</header>
|
|
1365
1735
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
|
1366
1736
|
<h2 class="text-xl font-semibold">{{ starter }}</h2>
|