@msn-control/liftoff 0.2.1 → 0.3.1
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 +165 -8
- package/dist/args.d.ts +19 -0
- package/dist/args.js +216 -39
- 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/cli.js +12 -7
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +172 -26
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +3 -0
- package/dist/file-system.js +306 -24
- 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 +199 -11
- package/dist/planner.js.map +1 -1
- package/dist/reconcile.d.ts +1 -0
- package/dist/reconcile.js +58 -12
- 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 +942 -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.d.ts +18 -0
- package/dist/templates.js +1363 -142
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +24 -2
- package/package.json +6 -4
package/dist/templates.js
CHANGED
|
@@ -1,20 +1,82 @@
|
|
|
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')}`;
|
|
6
|
+
const DEFAULT_FUNCTION_WORKER_QUEUE_NAME = 'events';
|
|
7
|
+
export const AZURE_NAME_LIMITS = {
|
|
8
|
+
resourceGroup: 90,
|
|
9
|
+
containerRegistry: 50,
|
|
10
|
+
identity: 128,
|
|
11
|
+
containerAppEnvironment: 60,
|
|
12
|
+
backendContainerApp: 32,
|
|
13
|
+
frontendContainerApp: 32,
|
|
14
|
+
functionServicePlan: 40,
|
|
15
|
+
functionApp: 60,
|
|
16
|
+
postgres: 63,
|
|
17
|
+
redis: 63,
|
|
18
|
+
storage: 24,
|
|
19
|
+
serviceBus: 50,
|
|
20
|
+
communication: 63,
|
|
21
|
+
keyVault: 24
|
|
22
|
+
};
|
|
23
|
+
const boundedToken = (value, length) => value.slice(0, length).replace(/-+$/g, '') || 'app';
|
|
24
|
+
export function buildAzureResourceNames(plan, environment, resourceSuffix) {
|
|
25
|
+
const workload = boundedToken(plan.safeProjectName, 12);
|
|
26
|
+
const compactWorkload = boundedToken(plan.safeProjectName.replace(/-/g, ''), 8);
|
|
27
|
+
return {
|
|
28
|
+
resourceGroup: `rg-${workload}-${environment}`,
|
|
29
|
+
containerRegistry: `acr${compactWorkload}${resourceSuffix}`,
|
|
30
|
+
identity: `id-${workload}-${environment}`,
|
|
31
|
+
containerAppEnvironment: `cae-${workload}-${environment}`,
|
|
32
|
+
backendContainerApp: `ca-${workload}-be-${environment}`,
|
|
33
|
+
frontendContainerApp: `ca-${workload}-fe-${environment}`,
|
|
34
|
+
functionServicePlan: `asp-${workload}-fn-${environment}`,
|
|
35
|
+
functionApp: `func-${workload}-${environment}-${resourceSuffix}`,
|
|
36
|
+
postgres: `psql-${workload}-${environment}-${resourceSuffix}`,
|
|
37
|
+
redis: `redis-${workload}-${environment}-${resourceSuffix}`,
|
|
38
|
+
storage: `st${compactWorkload}${resourceSuffix}`,
|
|
39
|
+
serviceBus: `sb-${workload}-${environment}-${resourceSuffix}`,
|
|
40
|
+
communication: `acs-${workload}-${environment}-${resourceSuffix}`,
|
|
41
|
+
keyVault: `kv-${compactWorkload}-${resourceSuffix}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function stableResourceSuffix(plan, environment) {
|
|
45
|
+
return createHash('sha256')
|
|
46
|
+
.update(`${plan.safeProjectName}:${environment}`, 'utf8')
|
|
47
|
+
.digest('hex')
|
|
48
|
+
.slice(0, 12);
|
|
49
|
+
}
|
|
4
50
|
const pyModule = (value) => value.replace(/-/g, '_');
|
|
5
51
|
const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
|
|
6
|
-
const
|
|
7
|
-
const
|
|
52
|
+
const sourceString = (value) => JSON.stringify(value);
|
|
53
|
+
const scriptSourceString = (value) => sourceString(value).replaceAll('<', '\\u003c');
|
|
54
|
+
const escapeHtml = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
55
|
+
const genAiPattern = (plan) => {
|
|
56
|
+
if (plan.projectType.id !== 'genai' || !plan.pattern) {
|
|
57
|
+
throw new Error('GenAI template rendering requires a GenAI pattern.');
|
|
58
|
+
}
|
|
59
|
+
return plan.pattern;
|
|
60
|
+
};
|
|
61
|
+
const hasFunctionWorker = (plan) => plan.projectType.id === 'genai' && plan.provider.id === 'azure' && genAiPattern(plan).worker;
|
|
62
|
+
const functionWorkerName = (plan) => `${genAiPattern(plan).id}-worker`;
|
|
8
63
|
export function buildArtifacts(plan) {
|
|
9
64
|
const artifacts = [];
|
|
10
65
|
const add = (logicalName, category, pathParts, content) => {
|
|
11
66
|
artifacts.push({ logicalName, category, pathParts, content: ensureTrailingNewline(content) });
|
|
12
67
|
};
|
|
13
68
|
addBaseArtifacts(add, plan);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
69
|
+
if (plan.projectType.id === 'genai') {
|
|
70
|
+
addGenAiExtensionArtifacts(add, plan, {
|
|
71
|
+
backend: addBackendArtifacts,
|
|
72
|
+
database: addDatabaseArtifacts,
|
|
73
|
+
pattern: addPatternArtifacts,
|
|
74
|
+
functions: addFunctionArtifacts
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
addStandardStackArtifacts(add, plan);
|
|
79
|
+
}
|
|
18
80
|
addEnvironmentArtifacts(add, plan);
|
|
19
81
|
addDockerArtifacts(add, plan);
|
|
20
82
|
addInfrastructureArtifacts(add, plan);
|
|
@@ -38,7 +100,9 @@ export function buildManifest(plan, artifacts) {
|
|
|
38
100
|
liftoffVersion,
|
|
39
101
|
project: {
|
|
40
102
|
name: plan.projectName,
|
|
41
|
-
|
|
103
|
+
projectType: plan.projectType.id,
|
|
104
|
+
apiStack: plan.apiStack.id,
|
|
105
|
+
...(plan.pattern ? { pattern: plan.pattern.id } : {}),
|
|
42
106
|
cloud: plan.provider.id,
|
|
43
107
|
region: plan.region.slug,
|
|
44
108
|
frontend: plan.includeFrontend,
|
|
@@ -60,7 +124,9 @@ function addBaseArtifacts(add, plan) {
|
|
|
60
124
|
add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
|
|
61
125
|
add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
|
|
62
126
|
projectName: plan.projectName,
|
|
63
|
-
|
|
127
|
+
projectType: plan.projectType.id,
|
|
128
|
+
apiStack: plan.apiStack.id,
|
|
129
|
+
...(plan.pattern ? { pattern: plan.pattern.id } : {}),
|
|
64
130
|
cloud: plan.provider.id,
|
|
65
131
|
region: plan.region.slug,
|
|
66
132
|
includeFrontend: plan.includeFrontend,
|
|
@@ -68,10 +134,10 @@ function addBaseArtifacts(add, plan) {
|
|
|
68
134
|
specWorkflow: plan.specWorkflow.id
|
|
69
135
|
}, null, 2));
|
|
70
136
|
add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
|
|
71
|
-
add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
|
|
137
|
+
add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
|
|
72
138
|
}
|
|
73
139
|
function addBackendArtifacts(add, plan) {
|
|
74
|
-
const routeModule = pyModule(plan.
|
|
140
|
+
const routeModule = pyModule(genAiPattern(plan).id);
|
|
75
141
|
add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
|
|
76
142
|
add('backend-package', 'backend', ['backend', '__init__.py'], '');
|
|
77
143
|
add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
|
|
@@ -89,6 +155,8 @@ function addBackendArtifacts(add, plan) {
|
|
|
89
155
|
add('backend-observability', 'backend', ['backend', 'observability', 'tracing.py'], renderTracing());
|
|
90
156
|
add('backend-observability-package', 'backend', ['backend', 'observability', '__init__.py'], '');
|
|
91
157
|
add('backend-test-health', 'backend-test', ['backend', 'tests', 'test_health.py'], renderBackendHealthTest());
|
|
158
|
+
add('backend-test-messaging', 'backend-test', ['backend', 'tests', 'test_messaging.py'], renderMessagingTest());
|
|
159
|
+
add('backend-test-tracing', 'backend-test', ['backend', 'tests', 'test_tracing.py'], renderTracingTest());
|
|
92
160
|
}
|
|
93
161
|
function addDatabaseArtifacts(add, plan) {
|
|
94
162
|
add('database-alembic-ini', 'database', ['database', 'alembic.ini'], renderAlembicIni());
|
|
@@ -97,20 +165,22 @@ function addDatabaseArtifacts(add, plan) {
|
|
|
97
165
|
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
|
|
98
166
|
}
|
|
99
167
|
function addPatternArtifacts(add, plan) {
|
|
100
|
-
const
|
|
168
|
+
const pattern = genAiPattern(plan);
|
|
169
|
+
const routeModule = pyModule(pattern.id);
|
|
101
170
|
add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
|
|
102
|
-
add('pattern-
|
|
171
|
+
add('pattern-agent-test', 'backend-test', ['backend', 'tests', `test_${routeModule}_orchestration.py`], renderPatternAgentTest(plan));
|
|
172
|
+
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
|
|
103
173
|
add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
|
|
104
174
|
add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
|
|
105
|
-
if (
|
|
175
|
+
if (pattern.id === 'rag') {
|
|
106
176
|
add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
|
|
107
177
|
add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
|
|
108
178
|
}
|
|
109
|
-
if (
|
|
179
|
+
if (pattern.worker) {
|
|
110
180
|
add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
|
|
111
181
|
add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
|
|
112
182
|
}
|
|
113
|
-
if (
|
|
183
|
+
if (pattern.id === 'fine-tuned') {
|
|
114
184
|
add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
|
|
115
185
|
}
|
|
116
186
|
}
|
|
@@ -132,7 +202,7 @@ function addFunctionArtifacts(add, plan) {
|
|
|
132
202
|
}
|
|
133
203
|
function addEnvironmentArtifacts(add, plan) {
|
|
134
204
|
for (const environment of plan.environments) {
|
|
135
|
-
add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], renderBackendEnv(plan, environment.id));
|
|
205
|
+
add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], plan.projectType.id === 'genai' ? renderBackendEnv(plan, environment.id) : renderStandardEnv(plan, environment.id));
|
|
136
206
|
if (hasFunctionWorker(plan)) {
|
|
137
207
|
add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
|
|
138
208
|
}
|
|
@@ -177,12 +247,135 @@ function addFrontendArtifacts(add, plan) {
|
|
|
177
247
|
add('frontend-index', 'frontend', ['frontend', 'index.html'], renderFrontendIndex(plan));
|
|
178
248
|
add('frontend-main', 'frontend', ['frontend', 'src', 'main.ts'], renderFrontendMain());
|
|
179
249
|
add('frontend-app', 'frontend', ['frontend', 'src', 'App.vue'], renderFrontendApp(plan));
|
|
250
|
+
add('frontend-env-example', 'frontend', ['frontend', '.env.example'], 'VITE_API_BASE_URL=http://localhost:8000');
|
|
180
251
|
add('frontend-styles', 'frontend', ['frontend', 'src', 'styles.css'], renderFrontendStyles());
|
|
181
252
|
add('frontend-vite-config', 'frontend', ['frontend', 'vite.config.ts'], renderFrontendViteConfig());
|
|
182
253
|
add('frontend-tailwind-config', 'frontend', ['frontend', 'tailwind.config.ts'], renderFrontendTailwindConfig());
|
|
183
254
|
add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
|
|
184
255
|
}
|
|
256
|
+
function renderDirectBuildAndTestGuide(plan) {
|
|
257
|
+
let backendCommands;
|
|
258
|
+
if (plan.projectType.id === 'genai' || plan.apiStack.id === 'python-fastapi') {
|
|
259
|
+
backendCommands = `python -m venv .venv
|
|
260
|
+
. .venv/bin/activate
|
|
261
|
+
python -m pip install -e "./backend[test]"
|
|
262
|
+
(cd backend && python -m pytest -q)`;
|
|
263
|
+
}
|
|
264
|
+
else if (plan.apiStack.id === 'node-fastify') {
|
|
265
|
+
backendCommands = `cd backend
|
|
266
|
+
npm install
|
|
267
|
+
npm run build
|
|
268
|
+
npm test`;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
backendCommands = `cd backend
|
|
272
|
+
go test ./...`;
|
|
273
|
+
}
|
|
274
|
+
const frontendCommands = plan.includeFrontend ? `
|
|
275
|
+
|
|
276
|
+
Build the frontend without a running backend:
|
|
277
|
+
|
|
278
|
+
\`\`\`bash
|
|
279
|
+
cp frontend/.env.example frontend/.env
|
|
280
|
+
cd frontend
|
|
281
|
+
npm install
|
|
282
|
+
npm run build
|
|
283
|
+
\`\`\`
|
|
284
|
+
` : '';
|
|
285
|
+
const functionCommands = hasFunctionWorker(plan) ? `
|
|
286
|
+
|
|
287
|
+
Run the Function worker unit tests from the same Python virtual environment:
|
|
288
|
+
|
|
289
|
+
\`\`\`bash
|
|
290
|
+
cd functions/${functionWorkerName(plan)}
|
|
291
|
+
python -m pip install -r requirements.txt
|
|
292
|
+
python -m pytest -q
|
|
293
|
+
\`\`\`
|
|
294
|
+
` : '';
|
|
295
|
+
return `## Direct Build And Test
|
|
296
|
+
|
|
297
|
+
\`\`\`bash
|
|
298
|
+
${backendCommands}
|
|
299
|
+
\`\`\`
|
|
300
|
+
|
|
301
|
+
On Windows, activate Python virtual environments with \`.venv\\Scripts\\activate\`.
|
|
302
|
+
${frontendCommands}${functionCommands}`;
|
|
303
|
+
}
|
|
304
|
+
function renderGeneratedConfigurationGuide(plan) {
|
|
305
|
+
const frontendConfiguration = plan.includeFrontend
|
|
306
|
+
? '\n- `frontend/.env` configures `VITE_API_BASE_URL`; the production build does not contact the backend.'
|
|
307
|
+
: '';
|
|
308
|
+
if (plan.projectType.id === 'standard') {
|
|
309
|
+
return `## Runtime Configuration
|
|
310
|
+
|
|
311
|
+
Copy \`.env.example\` to \`.env\` before running outside Docker Compose. The backend requires \`DATABASE_URL\` and \`REDIS_URL\`. \`CORS_ALLOWED_ORIGINS\` is a comma-separated allowlist and defaults to the local frontend at \`http://localhost:5173\`.${frontendConfiguration}
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
return `## Starter Integration Configuration
|
|
315
|
+
|
|
316
|
+
Copy \`.env.example\` to \`.env\`, then configure only the integrations you use:
|
|
317
|
+
|
|
318
|
+
- \`PYDANTIC_AI_MODEL\` is required when production orchestration is invoked. If it is absent, the agent raises an explicit configuration error rather than returning a placeholder answer.
|
|
319
|
+
- Redis Streams uses \`REDIS_URL\` and \`REDIS_STREAM_NAME\`.
|
|
320
|
+
- Azure Service Bus uses \`SERVICE_BUS_QUEUE_NAME\` plus either \`SERVICE_BUS_CONNECTION_STRING\` or \`SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE\`; set \`AZURE_CLIENT_ID\` when selecting a user-assigned managed identity.
|
|
321
|
+
- Langfuse requires both \`LANGFUSE_PUBLIC_KEY\` and \`LANGFUSE_SECRET_KEY\`, with optional \`LANGFUSE_HOST\`. Without both keys, tracing is explicitly disabled and no remote trace ID is reported.${frontendConfiguration}
|
|
322
|
+
- \`CORS_ALLOWED_ORIGINS\` is a comma-separated frontend-origin allowlist and defaults to \`http://localhost:5173\`.
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
function renderGeneratedUpdateGuide() {
|
|
326
|
+
return `## Safe Liftoff Updates
|
|
327
|
+
|
|
328
|
+
\`liftoff update\` is a read-only drift check; \`liftoff update --apply\` writes only preflighted changes. An occupied destination with different user bytes is reported and skipped, while an identical destination is adopted without rewriting it. Use \`--force\` only after reviewing each conflict.
|
|
329
|
+
|
|
330
|
+
Liftoff rejects malformed, traversal, absolute, drive-qualified, UNC, separator-containing, or symlink-escaping manifest paths before artifact access. If the manifest is unsafe or malformed, restore \`liftoff.manifest.json\` from version control or regenerate the project with a matching Liftoff version; do not hand-edit unsafe paths. Run \`liftoff <command> --help\` for command-specific syntax because unknown flags, subcommands, values, and extra arguments fail before any write.
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
185
333
|
function renderRootReadme(plan) {
|
|
334
|
+
if (plan.projectType.id === 'standard') {
|
|
335
|
+
return `# ${plan.projectName}
|
|
336
|
+
|
|
337
|
+
Generated by Mission Control Liftoff.
|
|
338
|
+
|
|
339
|
+
## Stack
|
|
340
|
+
|
|
341
|
+
- Project type: Standard application
|
|
342
|
+
- API: ${plan.apiStack.label}
|
|
343
|
+
- Database tooling: ${plan.apiStack.databaseTooling}
|
|
344
|
+
- API reference: Scalar with OpenAPI
|
|
345
|
+
- Cloud: ${plan.provider.label} (${plan.region.slug})
|
|
346
|
+
- Infrastructure: OpenTofu
|
|
347
|
+
- Database: PostgreSQL
|
|
348
|
+
- Cache and local messaging: Redis
|
|
349
|
+
- Local development: Docker Compose
|
|
350
|
+
${plan.includeFrontend ? '- Frontend: Vue 3 with Tailwind\n' : ''}
|
|
351
|
+
## Local Development
|
|
352
|
+
|
|
353
|
+
\`\`\`bash
|
|
354
|
+
docker compose up --build
|
|
355
|
+
\`\`\`
|
|
356
|
+
|
|
357
|
+
The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
|
|
358
|
+
|
|
359
|
+
${renderGeneratedConfigurationGuide(plan)}
|
|
360
|
+
${renderDirectBuildAndTestGuide(plan)}
|
|
361
|
+
${renderGeneratedUpdateGuide()}
|
|
362
|
+
## Infrastructure
|
|
363
|
+
|
|
364
|
+
\`\`\`bash
|
|
365
|
+
cd infrastructure/opentofu/azure
|
|
366
|
+
tofu init
|
|
367
|
+
tofu plan -var-file=environments/dev.tfvars
|
|
368
|
+
tofu apply -var-file=environments/dev.tfvars
|
|
369
|
+
\`\`\`
|
|
370
|
+
|
|
371
|
+
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.
|
|
372
|
+
|
|
373
|
+
## Spec-Driven Workflow
|
|
374
|
+
|
|
375
|
+
Selected workflow: ${plan.specWorkflow.label}.
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
const pattern = genAiPattern(plan);
|
|
186
379
|
const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
|
|
187
380
|
` : '';
|
|
188
381
|
const functionsSection = hasFunctionWorker(plan) ? `
|
|
@@ -197,10 +390,10 @@ Generated by Mission Control Liftoff.
|
|
|
197
390
|
## Stack
|
|
198
391
|
|
|
199
392
|
- Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
|
|
200
|
-
- Pattern: ${
|
|
393
|
+
- Pattern: ${pattern.label}
|
|
201
394
|
- Cloud: ${plan.provider.label} (${plan.region.slug})
|
|
202
395
|
- Infrastructure: OpenTofu
|
|
203
|
-
- Database: PostgreSQL with Alembic migrations${
|
|
396
|
+
- Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
|
|
204
397
|
- Cache and local messaging: Redis
|
|
205
398
|
- Observability: Langfuse
|
|
206
399
|
- Local development: Docker Compose
|
|
@@ -215,6 +408,9 @@ docker compose --profile observability up --build
|
|
|
215
408
|
|
|
216
409
|
The backend API is available on port 8000. Scalar is exposed at \`/scalar\`.
|
|
217
410
|
|
|
411
|
+
${renderGeneratedConfigurationGuide(plan)}
|
|
412
|
+
${renderDirectBuildAndTestGuide(plan)}
|
|
413
|
+
${renderGeneratedUpdateGuide()}
|
|
218
414
|
## Infrastructure
|
|
219
415
|
|
|
220
416
|
\`\`\`bash
|
|
@@ -224,6 +420,8 @@ tofu plan -var-file=environments/dev.tfvars
|
|
|
224
420
|
tofu apply -var-file=environments/dev.tfvars
|
|
225
421
|
\`\`\`
|
|
226
422
|
|
|
423
|
+
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.
|
|
424
|
+
|
|
227
425
|
## Spec-Driven Workflow
|
|
228
426
|
|
|
229
427
|
Selected workflow: ${plan.specWorkflow.label}.
|
|
@@ -244,16 +442,29 @@ migration/legacy/
|
|
|
244
442
|
`;
|
|
245
443
|
}
|
|
246
444
|
function renderEnvExample(plan) {
|
|
445
|
+
if (plan.projectType.id === 'standard') {
|
|
446
|
+
return renderStandardEnv(plan);
|
|
447
|
+
}
|
|
448
|
+
const pattern = genAiPattern(plan);
|
|
247
449
|
return `APP_ENV=dev
|
|
248
450
|
APP_NAME=${plan.safeProjectName}
|
|
249
|
-
GENAI_PATTERN=${
|
|
451
|
+
GENAI_PATTERN=${pattern.id}
|
|
250
452
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
251
453
|
AZURE_REGION=${plan.region.slug}
|
|
252
454
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
253
455
|
REDIS_URL=redis://redis:6379/0
|
|
456
|
+
REDIS_STREAM_NAME=liftoff-events
|
|
254
457
|
MESSAGING_TRANSPORT=redis-streams
|
|
458
|
+
SERVICE_BUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
|
|
459
|
+
SERVICE_BUS_CONNECTION_STRING=
|
|
460
|
+
SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE=
|
|
461
|
+
AZURE_CLIENT_ID=
|
|
255
462
|
BLOB_ENDPOINT=http://azurite:10000/devstoreaccount1
|
|
463
|
+
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
|
464
|
+
PYDANTIC_AI_MODEL=
|
|
256
465
|
LANGFUSE_HOST=http://langfuse:3000
|
|
466
|
+
LANGFUSE_PUBLIC_KEY=
|
|
467
|
+
LANGFUSE_SECRET_KEY=
|
|
257
468
|
`;
|
|
258
469
|
}
|
|
259
470
|
function renderBackendDockerfile() {
|
|
@@ -285,14 +496,16 @@ dependencies = [
|
|
|
285
496
|
"uvicorn[standard]>=0.30",
|
|
286
497
|
"pydantic>=2.7",
|
|
287
498
|
"pydantic-settings>=2.3",
|
|
288
|
-
"pydantic-ai
|
|
499
|
+
"pydantic-ai-slim[openai]==1.107.1",
|
|
289
500
|
"scalar-fastapi>=1.0",
|
|
290
501
|
"sqlalchemy[asyncio]>=2.0",
|
|
291
502
|
"asyncpg>=0.29",
|
|
503
|
+
"psycopg[binary]>=3.2",
|
|
292
504
|
"alembic>=1.13",
|
|
293
505
|
"redis>=5.0",
|
|
294
|
-
"langfuse
|
|
506
|
+
"langfuse==2.60.10",
|
|
295
507
|
"azure-servicebus>=7.12",
|
|
508
|
+
"azure-identity>=1.17",
|
|
296
509
|
"azure-storage-blob>=12.20",
|
|
297
510
|
"azure-communication-email>=1.0"
|
|
298
511
|
]
|
|
@@ -304,6 +517,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
|
|
|
304
517
|
requires = ["setuptools>=70"]
|
|
305
518
|
build-backend = "setuptools.build_meta"
|
|
306
519
|
|
|
520
|
+
[tool.setuptools]
|
|
521
|
+
packages = []
|
|
522
|
+
|
|
307
523
|
[tool.pytest.ini_options]
|
|
308
524
|
pythonpath = [".."]
|
|
309
525
|
testpaths = ["tests"]
|
|
@@ -311,6 +527,7 @@ testpaths = ["tests"]
|
|
|
311
527
|
}
|
|
312
528
|
function renderFastApiMain(plan, routeModule) {
|
|
313
529
|
return `from fastapi import FastAPI
|
|
530
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
314
531
|
|
|
315
532
|
try:
|
|
316
533
|
from scalar_fastapi import get_scalar_api_reference
|
|
@@ -323,6 +540,16 @@ from backend.config.settings import get_settings
|
|
|
323
540
|
|
|
324
541
|
settings = get_settings()
|
|
325
542
|
app = FastAPI(title=settings.app_name, version="0.1.0")
|
|
543
|
+
app.add_middleware(
|
|
544
|
+
CORSMiddleware,
|
|
545
|
+
allow_origins=[
|
|
546
|
+
origin.strip()
|
|
547
|
+
for origin in settings.cors_allowed_origins.split(",")
|
|
548
|
+
if origin.strip()
|
|
549
|
+
],
|
|
550
|
+
allow_methods=["*"],
|
|
551
|
+
allow_headers=["*"],
|
|
552
|
+
)
|
|
326
553
|
|
|
327
554
|
app.include_router(health.router)
|
|
328
555
|
app.include_router(${routeModule}.router)
|
|
@@ -352,16 +579,17 @@ def ready():
|
|
|
352
579
|
`;
|
|
353
580
|
}
|
|
354
581
|
function renderPatternRoutes(plan) {
|
|
355
|
-
const
|
|
582
|
+
const pattern = genAiPattern(plan);
|
|
583
|
+
const moduleName = pyModule(pattern.id);
|
|
356
584
|
const agentName = `${moduleName}_agent`;
|
|
357
|
-
const prefix =
|
|
358
|
-
if (
|
|
585
|
+
const prefix = pattern.routePrefix;
|
|
586
|
+
if (pattern.id === 'streaming') {
|
|
359
587
|
return `from fastapi import APIRouter
|
|
360
588
|
from fastapi.responses import StreamingResponse
|
|
361
589
|
|
|
362
590
|
from backend.orchestration.agents.${agentName} import stream_response
|
|
363
591
|
|
|
364
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
592
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
365
593
|
|
|
366
594
|
|
|
367
595
|
@router.get("")
|
|
@@ -369,7 +597,7 @@ def stream(prompt: str):
|
|
|
369
597
|
return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
|
|
370
598
|
`;
|
|
371
599
|
}
|
|
372
|
-
if (
|
|
600
|
+
if (pattern.id === 'rag') {
|
|
373
601
|
return `from fastapi import APIRouter
|
|
374
602
|
from pydantic import BaseModel
|
|
375
603
|
|
|
@@ -396,13 +624,13 @@ async def ingest(request: IngestionRequest):
|
|
|
396
624
|
return await enqueue_ingestion(request.source_uri)
|
|
397
625
|
`;
|
|
398
626
|
}
|
|
399
|
-
const bodyClass = `${titleCase(
|
|
627
|
+
const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
|
|
400
628
|
return `from fastapi import APIRouter
|
|
401
629
|
from pydantic import BaseModel
|
|
402
630
|
|
|
403
631
|
from backend.orchestration.agents.${agentName} import run_${moduleName}
|
|
404
632
|
|
|
405
|
-
router = APIRouter(prefix="${prefix}", tags=["${
|
|
633
|
+
router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
|
|
406
634
|
|
|
407
635
|
|
|
408
636
|
class ${bodyClass}(BaseModel):
|
|
@@ -428,6 +656,7 @@ async def get_current_user() -> CurrentUser:
|
|
|
428
656
|
`;
|
|
429
657
|
}
|
|
430
658
|
function renderSettings(plan) {
|
|
659
|
+
const pattern = genAiPattern(plan);
|
|
431
660
|
return `from functools import lru_cache
|
|
432
661
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
433
662
|
|
|
@@ -435,16 +664,25 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
435
664
|
class Settings(BaseSettings):
|
|
436
665
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
437
666
|
|
|
438
|
-
app_name: str =
|
|
667
|
+
app_name: str = ${sourceString(plan.projectName)}
|
|
439
668
|
app_env: str = "dev"
|
|
440
|
-
genai_pattern: str = "${
|
|
669
|
+
genai_pattern: str = "${pattern.id}"
|
|
441
670
|
cloud_provider: str = "${plan.provider.id}"
|
|
442
671
|
azure_region: str = "${plan.region.slug}"
|
|
443
672
|
database_url: str
|
|
444
673
|
redis_url: str
|
|
674
|
+
redis_stream_name: str = "liftoff-events"
|
|
445
675
|
messaging_transport: str = "redis-streams"
|
|
676
|
+
service_bus_queue_name: str = "${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}"
|
|
677
|
+
service_bus_connection_string: str | None = None
|
|
678
|
+
service_bus_fully_qualified_namespace: str | None = None
|
|
679
|
+
azure_client_id: str | None = None
|
|
446
680
|
blob_endpoint: str | None = None
|
|
681
|
+
cors_allowed_origins: str = "http://localhost:5173"
|
|
682
|
+
pydantic_ai_model: str | None = None
|
|
447
683
|
langfuse_host: str | None = None
|
|
684
|
+
langfuse_public_key: str | None = None
|
|
685
|
+
langfuse_secret_key: str | None = None
|
|
448
686
|
|
|
449
687
|
|
|
450
688
|
@lru_cache
|
|
@@ -453,18 +691,66 @@ def get_settings() -> Settings:
|
|
|
453
691
|
`;
|
|
454
692
|
}
|
|
455
693
|
function renderModelConfig(plan) {
|
|
456
|
-
|
|
694
|
+
const pattern = genAiPattern(plan);
|
|
695
|
+
return `import os
|
|
696
|
+
from dataclasses import dataclass
|
|
697
|
+
from typing import Protocol
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class ModelConfigurationError(RuntimeError):
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class AgentRunner(Protocol):
|
|
705
|
+
async def run(self, prompt: str) -> str:
|
|
706
|
+
...
|
|
457
707
|
|
|
458
708
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
709
|
+
@dataclass(frozen=True)
|
|
710
|
+
class ModelConfig:
|
|
711
|
+
model_name: str
|
|
712
|
+
pattern: str = "${pattern.id}"
|
|
713
|
+
|
|
714
|
+
@classmethod
|
|
715
|
+
def from_environment(cls) -> "ModelConfig":
|
|
716
|
+
model_name = os.getenv("PYDANTIC_AI_MODEL", "").strip()
|
|
717
|
+
if not model_name:
|
|
718
|
+
raise ModelConfigurationError(
|
|
719
|
+
"PYDANTIC_AI_MODEL is required before invoking production GenAI orchestration. "
|
|
720
|
+
"Use a PydanticAI model name such as 'openai:gpt-4.1-mini'."
|
|
721
|
+
)
|
|
722
|
+
return cls(model_name=model_name)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class PydanticAgentRunner:
|
|
726
|
+
def __init__(self, config: ModelConfig):
|
|
727
|
+
from pydantic_ai import Agent
|
|
728
|
+
|
|
729
|
+
self._agent = Agent(config.model_name)
|
|
730
|
+
|
|
731
|
+
async def run(self, prompt: str) -> str:
|
|
732
|
+
result = await self._agent.run(prompt)
|
|
733
|
+
output = getattr(result, "output", None)
|
|
734
|
+
if output is None:
|
|
735
|
+
output = getattr(result, "data", None)
|
|
736
|
+
if output is None:
|
|
737
|
+
raise RuntimeError("PydanticAI returned a result without output data.")
|
|
738
|
+
return str(output)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def build_agent_runner(config: ModelConfig | None = None) -> AgentRunner:
|
|
742
|
+
return PydanticAgentRunner(config or ModelConfig.from_environment())
|
|
464
743
|
`;
|
|
465
744
|
}
|
|
466
745
|
function renderMessagingBoundary() {
|
|
467
|
-
return `
|
|
746
|
+
return `import json
|
|
747
|
+
import os
|
|
748
|
+
from collections.abc import Callable
|
|
749
|
+
from typing import Any, Protocol
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class MessagingConfigurationError(RuntimeError):
|
|
753
|
+
pass
|
|
468
754
|
|
|
469
755
|
|
|
470
756
|
class MessagePublisher(Protocol):
|
|
@@ -472,32 +758,202 @@ class MessagePublisher(Protocol):
|
|
|
472
758
|
...
|
|
473
759
|
|
|
474
760
|
|
|
761
|
+
class RedisStreamClient(Protocol):
|
|
762
|
+
async def xadd(self, name: str, fields: dict[str, str]) -> Any:
|
|
763
|
+
...
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class ServiceBusSender(Protocol):
|
|
767
|
+
async def __aenter__(self) -> "ServiceBusSender":
|
|
768
|
+
...
|
|
769
|
+
|
|
770
|
+
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
|
771
|
+
...
|
|
772
|
+
|
|
773
|
+
async def send_messages(self, message: Any) -> None:
|
|
774
|
+
...
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class ServiceBusClient(Protocol):
|
|
778
|
+
def get_queue_sender(self, *, queue_name: str) -> ServiceBusSender:
|
|
779
|
+
...
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _serialize(topic: str, payload: dict) -> str:
|
|
783
|
+
return json.dumps({"topic": topic, "payload": payload}, separators=(",", ":"), sort_keys=True)
|
|
784
|
+
|
|
785
|
+
|
|
475
786
|
class RedisStreamPublisher:
|
|
787
|
+
def __init__(self, client: RedisStreamClient, stream_name: str):
|
|
788
|
+
self._client = client
|
|
789
|
+
self._stream_name = stream_name
|
|
790
|
+
|
|
476
791
|
async def publish(self, topic: str, payload: dict) -> None:
|
|
477
|
-
|
|
478
|
-
|
|
792
|
+
await self._client.xadd(
|
|
793
|
+
self._stream_name,
|
|
794
|
+
{"topic": topic, "payload": _serialize(topic, payload)},
|
|
795
|
+
)
|
|
479
796
|
|
|
480
797
|
|
|
481
798
|
class AzureServiceBusPublisher:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
799
|
+
def __init__(
|
|
800
|
+
self,
|
|
801
|
+
client: ServiceBusClient,
|
|
802
|
+
queue_name: str,
|
|
803
|
+
message_factory: Callable[[str], Any] | None = None,
|
|
804
|
+
):
|
|
805
|
+
self._client = client
|
|
806
|
+
self._queue_name = queue_name
|
|
807
|
+
self._message_factory = message_factory or self._default_message_factory
|
|
808
|
+
|
|
809
|
+
@staticmethod
|
|
810
|
+
def _default_message_factory(body: str) -> Any:
|
|
811
|
+
from azure.servicebus import ServiceBusMessage
|
|
812
|
+
|
|
813
|
+
return ServiceBusMessage(body)
|
|
485
814
|
|
|
815
|
+
async def publish(self, topic: str, payload: dict) -> None:
|
|
816
|
+
message = self._message_factory(_serialize(topic, payload))
|
|
817
|
+
async with self._client.get_queue_sender(queue_name=self._queue_name) as sender:
|
|
818
|
+
await sender.send_messages(message)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def build_message_publisher(
|
|
822
|
+
transport: str,
|
|
823
|
+
*,
|
|
824
|
+
redis_client: RedisStreamClient | None = None,
|
|
825
|
+
service_bus_client: ServiceBusClient | None = None,
|
|
826
|
+
message_factory: Callable[[str], Any] | None = None,
|
|
827
|
+
) -> MessagePublisher:
|
|
828
|
+
if transport == "redis-streams":
|
|
829
|
+
stream_name = os.getenv("REDIS_STREAM_NAME", "liftoff-events").strip()
|
|
830
|
+
if not stream_name:
|
|
831
|
+
raise MessagingConfigurationError("REDIS_STREAM_NAME must not be empty.")
|
|
832
|
+
if redis_client is None:
|
|
833
|
+
redis_url = os.getenv("REDIS_URL", "").strip()
|
|
834
|
+
if not redis_url:
|
|
835
|
+
raise MessagingConfigurationError("REDIS_URL is required for redis-streams messaging.")
|
|
836
|
+
from redis.asyncio import Redis
|
|
837
|
+
|
|
838
|
+
redis_client = Redis.from_url(redis_url, decode_responses=True)
|
|
839
|
+
return RedisStreamPublisher(redis_client, stream_name)
|
|
486
840
|
|
|
487
|
-
def build_message_publisher(transport: str) -> MessagePublisher:
|
|
488
841
|
if transport == "azure-service-bus":
|
|
489
|
-
|
|
490
|
-
|
|
842
|
+
queue_name = os.getenv("SERVICE_BUS_QUEUE_NAME", "").strip()
|
|
843
|
+
if not queue_name:
|
|
844
|
+
raise MessagingConfigurationError(
|
|
845
|
+
"SERVICE_BUS_QUEUE_NAME is required for azure-service-bus messaging."
|
|
846
|
+
)
|
|
847
|
+
if service_bus_client is None:
|
|
848
|
+
connection_string = os.getenv("SERVICE_BUS_CONNECTION_STRING", "").strip()
|
|
849
|
+
namespace = os.getenv("SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE", "").strip()
|
|
850
|
+
from azure.servicebus.aio import ServiceBusClient as AzureServiceBusClient
|
|
851
|
+
|
|
852
|
+
if connection_string:
|
|
853
|
+
service_bus_client = AzureServiceBusClient.from_connection_string(connection_string)
|
|
854
|
+
elif namespace:
|
|
855
|
+
from azure.identity.aio import DefaultAzureCredential
|
|
856
|
+
|
|
857
|
+
client_id = os.getenv("AZURE_CLIENT_ID", "").strip() or None
|
|
858
|
+
credential = DefaultAzureCredential(managed_identity_client_id=client_id)
|
|
859
|
+
service_bus_client = AzureServiceBusClient(namespace, credential)
|
|
860
|
+
else:
|
|
861
|
+
raise MessagingConfigurationError(
|
|
862
|
+
"Set SERVICE_BUS_CONNECTION_STRING or SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE "
|
|
863
|
+
"for azure-service-bus messaging."
|
|
864
|
+
)
|
|
865
|
+
return AzureServiceBusPublisher(service_bus_client, queue_name, message_factory)
|
|
866
|
+
|
|
867
|
+
raise MessagingConfigurationError(
|
|
868
|
+
f"Unsupported MESSAGING_TRANSPORT '{transport}'. "
|
|
869
|
+
"Expected 'redis-streams' or 'azure-service-bus'."
|
|
870
|
+
)
|
|
491
871
|
`;
|
|
492
872
|
}
|
|
493
873
|
function renderTracing() {
|
|
494
|
-
return `
|
|
874
|
+
return `import os
|
|
875
|
+
from contextlib import asynccontextmanager
|
|
876
|
+
from dataclasses import dataclass
|
|
877
|
+
from typing import Any, AsyncContextManager, Protocol
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
class TracingConfigurationError(RuntimeError):
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@dataclass
|
|
885
|
+
class TraceHandle:
|
|
886
|
+
enabled: bool
|
|
887
|
+
trace_id: str | None
|
|
888
|
+
output: Any = None
|
|
889
|
+
|
|
890
|
+
def set_output(self, output: Any) -> None:
|
|
891
|
+
self.output = output
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
class Tracer(Protocol):
|
|
895
|
+
def trace(self, name: str, input_data: Any = None) -> AsyncContextManager[TraceHandle]:
|
|
896
|
+
...
|
|
495
897
|
|
|
496
898
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
899
|
+
class DisabledTracer:
|
|
900
|
+
@asynccontextmanager
|
|
901
|
+
async def trace(self, name: str, input_data: Any = None):
|
|
902
|
+
del name, input_data
|
|
903
|
+
yield TraceHandle(enabled=False, trace_id=None)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
class LangfuseTracer:
|
|
907
|
+
def __init__(self, client: Any):
|
|
908
|
+
self._client = client
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@asynccontextmanager
|
|
912
|
+
async def trace(self, name: str, input_data: Any = None):
|
|
913
|
+
remote_trace = self._client.trace(name=name, input=input_data)
|
|
914
|
+
remote_id = getattr(remote_trace, "id", None)
|
|
915
|
+
handle = TraceHandle(
|
|
916
|
+
enabled=True,
|
|
917
|
+
trace_id=str(remote_id) if remote_id is not None else None,
|
|
918
|
+
)
|
|
919
|
+
try:
|
|
920
|
+
yield handle
|
|
921
|
+
except Exception as error:
|
|
922
|
+
remote_trace.update(level="ERROR", status_message=str(error))
|
|
923
|
+
raise
|
|
924
|
+
else:
|
|
925
|
+
remote_trace.update(output=handle.output)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def build_tracer(
|
|
929
|
+
*,
|
|
930
|
+
client: Any = None,
|
|
931
|
+
public_key: str | None = None,
|
|
932
|
+
secret_key: str | None = None,
|
|
933
|
+
host: str | None = None,
|
|
934
|
+
) -> Tracer:
|
|
935
|
+
if client is not None:
|
|
936
|
+
return LangfuseTracer(client)
|
|
937
|
+
|
|
938
|
+
resolved_public_key = public_key or os.getenv("LANGFUSE_PUBLIC_KEY", "").strip()
|
|
939
|
+
resolved_secret_key = secret_key or os.getenv("LANGFUSE_SECRET_KEY", "").strip()
|
|
940
|
+
if not resolved_public_key and not resolved_secret_key:
|
|
941
|
+
return DisabledTracer()
|
|
942
|
+
if not resolved_public_key or not resolved_secret_key:
|
|
943
|
+
raise TracingConfigurationError(
|
|
944
|
+
"LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be configured together."
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
from langfuse import Langfuse
|
|
948
|
+
|
|
949
|
+
resolved_host = host or os.getenv("LANGFUSE_HOST", "").strip() or None
|
|
950
|
+
kwargs = {
|
|
951
|
+
"public_key": resolved_public_key,
|
|
952
|
+
"secret_key": resolved_secret_key,
|
|
953
|
+
}
|
|
954
|
+
if resolved_host:
|
|
955
|
+
kwargs["host"] = resolved_host
|
|
956
|
+
return LangfuseTracer(Langfuse(**kwargs))
|
|
501
957
|
`;
|
|
502
958
|
}
|
|
503
959
|
function renderBackendHealthTest() {
|
|
@@ -511,29 +967,192 @@ def test_health():
|
|
|
511
967
|
response = client.get("/health")
|
|
512
968
|
assert response.status_code == 200
|
|
513
969
|
assert response.json()["status"] == "ok"
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def test_cors_preflight_for_local_frontend():
|
|
973
|
+
response = TestClient(app).options(
|
|
974
|
+
"/health",
|
|
975
|
+
headers={
|
|
976
|
+
"Origin": "http://localhost:5173",
|
|
977
|
+
"Access-Control-Request-Method": "GET",
|
|
978
|
+
},
|
|
979
|
+
)
|
|
980
|
+
assert response.status_code == 200
|
|
981
|
+
assert response.headers["access-control-allow-origin"] == "http://localhost:5173"
|
|
982
|
+
`;
|
|
983
|
+
}
|
|
984
|
+
function renderMessagingTest() {
|
|
985
|
+
return `import asyncio
|
|
986
|
+
import json
|
|
987
|
+
|
|
988
|
+
from backend.orchestration.tools.messaging import build_message_publisher
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
class FakeRedisClient:
|
|
992
|
+
def __init__(self):
|
|
993
|
+
self.calls = []
|
|
994
|
+
|
|
995
|
+
async def xadd(self, name, fields):
|
|
996
|
+
self.calls.append((name, fields))
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
class FakeSender:
|
|
1000
|
+
def __init__(self):
|
|
1001
|
+
self.messages = []
|
|
1002
|
+
|
|
1003
|
+
async def __aenter__(self):
|
|
1004
|
+
return self
|
|
1005
|
+
|
|
1006
|
+
async def __aexit__(self, exc_type, exc, traceback):
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
async def send_messages(self, message):
|
|
1010
|
+
self.messages.append(message)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class FakeServiceBusClient:
|
|
1014
|
+
def __init__(self, sender):
|
|
1015
|
+
self.sender = sender
|
|
1016
|
+
self.queue_names = []
|
|
1017
|
+
|
|
1018
|
+
def get_queue_sender(self, *, queue_name):
|
|
1019
|
+
self.queue_names.append(queue_name)
|
|
1020
|
+
return self.sender
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def test_redis_stream_publisher_uses_xadd(monkeypatch):
|
|
1024
|
+
monkeypatch.setenv("REDIS_STREAM_NAME", "orchestration-events")
|
|
1025
|
+
client = FakeRedisClient()
|
|
1026
|
+
publisher = build_message_publisher("redis-streams", redis_client=client)
|
|
1027
|
+
|
|
1028
|
+
asyncio.run(publisher.publish("rag.ingest", {"source_uri": "az://document"}))
|
|
1029
|
+
|
|
1030
|
+
stream_name, fields = client.calls[0]
|
|
1031
|
+
assert stream_name == "orchestration-events"
|
|
1032
|
+
assert fields["topic"] == "rag.ingest"
|
|
1033
|
+
assert json.loads(fields["payload"]) == {
|
|
1034
|
+
"payload": {"source_uri": "az://document"},
|
|
1035
|
+
"topic": "rag.ingest",
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def test_service_bus_publisher_uses_async_sender(monkeypatch):
|
|
1040
|
+
monkeypatch.setenv("SERVICE_BUS_QUEUE_NAME", "orchestration-jobs")
|
|
1041
|
+
sender = FakeSender()
|
|
1042
|
+
client = FakeServiceBusClient(sender)
|
|
1043
|
+
publisher = build_message_publisher(
|
|
1044
|
+
"azure-service-bus",
|
|
1045
|
+
service_bus_client=client,
|
|
1046
|
+
message_factory=lambda body: body,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
asyncio.run(publisher.publish("workflow.run", {"job_id": "job-1"}))
|
|
1050
|
+
|
|
1051
|
+
assert client.queue_names == ["orchestration-jobs"]
|
|
1052
|
+
assert json.loads(sender.messages[0]) == {
|
|
1053
|
+
"payload": {"job_id": "job-1"},
|
|
1054
|
+
"topic": "workflow.run",
|
|
1055
|
+
}
|
|
1056
|
+
`;
|
|
1057
|
+
}
|
|
1058
|
+
function renderTracingTest() {
|
|
1059
|
+
return `import asyncio
|
|
1060
|
+
|
|
1061
|
+
import pytest
|
|
1062
|
+
|
|
1063
|
+
from backend.observability.tracing import (
|
|
1064
|
+
TracingConfigurationError,
|
|
1065
|
+
build_tracer,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class FakeRemoteTrace:
|
|
1070
|
+
id = "trace-123"
|
|
1071
|
+
|
|
1072
|
+
def __init__(self):
|
|
1073
|
+
self.updates = []
|
|
1074
|
+
|
|
1075
|
+
def update(self, **values):
|
|
1076
|
+
self.updates.append(values)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
class FakeLangfuse:
|
|
1080
|
+
def __init__(self):
|
|
1081
|
+
self.calls = []
|
|
1082
|
+
self.remote_trace = FakeRemoteTrace()
|
|
1083
|
+
|
|
1084
|
+
def trace(self, **values):
|
|
1085
|
+
self.calls.append(values)
|
|
1086
|
+
return self.remote_trace
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def test_unconfigured_tracing_is_explicitly_disabled(monkeypatch):
|
|
1090
|
+
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
|
|
1091
|
+
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
|
|
1092
|
+
|
|
1093
|
+
async def scenario():
|
|
1094
|
+
async with build_tracer().trace("offline") as trace:
|
|
1095
|
+
assert trace.enabled is False
|
|
1096
|
+
assert trace.trace_id is None
|
|
1097
|
+
|
|
1098
|
+
asyncio.run(scenario())
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def test_configured_tracing_updates_langfuse_operation():
|
|
1102
|
+
client = FakeLangfuse()
|
|
1103
|
+
|
|
1104
|
+
async def scenario():
|
|
1105
|
+
async with build_tracer(client=client).trace(
|
|
1106
|
+
"agent.run",
|
|
1107
|
+
{"prompt": "hello"},
|
|
1108
|
+
) as trace:
|
|
1109
|
+
assert trace.enabled is True
|
|
1110
|
+
assert trace.trace_id == "trace-123"
|
|
1111
|
+
trace.set_output({"answer": "world"})
|
|
1112
|
+
|
|
1113
|
+
asyncio.run(scenario())
|
|
1114
|
+
assert client.calls == [{"name": "agent.run", "input": {"prompt": "hello"}}]
|
|
1115
|
+
assert client.remote_trace.updates == [{"output": {"answer": "world"}}]
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def test_partial_langfuse_configuration_fails(monkeypatch):
|
|
1119
|
+
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
|
|
1120
|
+
monkeypatch.delenv("LANGFUSE_SECRET_KEY", raising=False)
|
|
1121
|
+
with pytest.raises(TracingConfigurationError, match="configured together"):
|
|
1122
|
+
build_tracer()
|
|
514
1123
|
`;
|
|
515
1124
|
}
|
|
516
1125
|
function renderAlembicIni() {
|
|
517
1126
|
return `[alembic]
|
|
518
|
-
script_location = migrations
|
|
1127
|
+
script_location = %(here)s/migrations
|
|
519
1128
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
520
1129
|
`;
|
|
521
1130
|
}
|
|
522
1131
|
function renderAlembicEnv() {
|
|
523
|
-
return `
|
|
1132
|
+
return `import os
|
|
1133
|
+
|
|
1134
|
+
from alembic import context
|
|
1135
|
+
from sqlalchemy import create_engine
|
|
524
1136
|
|
|
525
1137
|
|
|
526
1138
|
def run_migrations_online():
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
1139
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
1140
|
+
if not database_url:
|
|
1141
|
+
raise RuntimeError("DATABASE_URL is required to run migrations")
|
|
1142
|
+
database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
1143
|
+
database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
1144
|
+
connectable = create_engine(database_url)
|
|
1145
|
+
with connectable.connect() as connection:
|
|
1146
|
+
context.configure(connection=connection, target_metadata=None)
|
|
1147
|
+
with context.begin_transaction():
|
|
1148
|
+
context.run_migrations()
|
|
530
1149
|
|
|
531
1150
|
|
|
532
1151
|
run_migrations_online()
|
|
533
1152
|
`;
|
|
534
1153
|
}
|
|
535
1154
|
function renderInitialMigration(plan) {
|
|
536
|
-
const vectorExtension = plan.
|
|
1155
|
+
const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
|
|
537
1156
|
return `from alembic import op
|
|
538
1157
|
import sqlalchemy as sa
|
|
539
1158
|
|
|
@@ -564,45 +1183,256 @@ function renderDatabaseSchema(plan) {
|
|
|
564
1183
|
payload JSONB NOT NULL,
|
|
565
1184
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
566
1185
|
);
|
|
567
|
-
${plan.
|
|
1186
|
+
${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
|
|
568
1187
|
}
|
|
569
1188
|
function renderPatternAgent(plan) {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
1189
|
+
const pattern = genAiPattern(plan);
|
|
1190
|
+
const moduleName = pyModule(pattern.id);
|
|
1191
|
+
if (pattern.id === 'rag') {
|
|
1192
|
+
return `import os
|
|
1193
|
+
|
|
1194
|
+
from backend.observability.tracing import Tracer, build_tracer
|
|
1195
|
+
from backend.orchestration.model_config import AgentRunner, build_agent_runner
|
|
1196
|
+
from backend.orchestration.tools.messaging import MessagePublisher, build_message_publisher
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
async def _run_agent(
|
|
1200
|
+
operation: str,
|
|
1201
|
+
prompt: str,
|
|
1202
|
+
runner: AgentRunner | None,
|
|
1203
|
+
tracer: Tracer | None,
|
|
1204
|
+
) -> str:
|
|
1205
|
+
selected_runner = runner or build_agent_runner()
|
|
1206
|
+
selected_tracer = tracer or build_tracer()
|
|
1207
|
+
async with selected_tracer.trace(operation, {"prompt": prompt}) as trace:
|
|
1208
|
+
output = await selected_runner.run(prompt)
|
|
1209
|
+
trace.set_output({"text": output})
|
|
1210
|
+
return output
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
async def answer_question(
|
|
1214
|
+
question: str,
|
|
1215
|
+
*,
|
|
1216
|
+
runner: AgentRunner | None = None,
|
|
1217
|
+
tracer: Tracer | None = None,
|
|
1218
|
+
) -> dict:
|
|
1219
|
+
answer = await _run_agent(
|
|
1220
|
+
"rag.query",
|
|
1221
|
+
f"Answer the question using retrieved evidence when available.\\nQuestion: {question}",
|
|
1222
|
+
runner,
|
|
1223
|
+
tracer,
|
|
1224
|
+
)
|
|
576
1225
|
return {
|
|
577
|
-
"answer":
|
|
1226
|
+
"answer": answer,
|
|
578
1227
|
"question": question,
|
|
579
1228
|
"citations": [],
|
|
580
1229
|
}
|
|
581
1230
|
|
|
582
1231
|
|
|
583
|
-
async def enqueue_ingestion(
|
|
584
|
-
|
|
585
|
-
|
|
1232
|
+
async def enqueue_ingestion(
|
|
1233
|
+
source_uri: str,
|
|
1234
|
+
*,
|
|
1235
|
+
publisher: MessagePublisher | None = None,
|
|
1236
|
+
) -> dict:
|
|
1237
|
+
selected_publisher = publisher or build_message_publisher(
|
|
1238
|
+
os.getenv("MESSAGING_TRANSPORT", "redis-streams")
|
|
1239
|
+
)
|
|
1240
|
+
await selected_publisher.publish("rag.ingest", {"source_uri": source_uri})
|
|
586
1241
|
return {"status": "queued", "source_uri": source_uri}
|
|
587
1242
|
`;
|
|
588
1243
|
}
|
|
589
|
-
if (
|
|
590
|
-
return `
|
|
591
|
-
|
|
592
|
-
|
|
1244
|
+
if (pattern.id === 'streaming') {
|
|
1245
|
+
return `import json
|
|
1246
|
+
|
|
1247
|
+
from backend.observability.tracing import Tracer, build_tracer
|
|
1248
|
+
from backend.orchestration.model_config import AgentRunner, build_agent_runner
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
async def stream_response(
|
|
1252
|
+
prompt: str,
|
|
1253
|
+
*,
|
|
1254
|
+
runner: AgentRunner | None = None,
|
|
1255
|
+
tracer: Tracer | None = None,
|
|
1256
|
+
):
|
|
1257
|
+
selected_runner = runner or build_agent_runner()
|
|
1258
|
+
selected_tracer = tracer or build_tracer()
|
|
1259
|
+
async with selected_tracer.trace("streaming.run", {"prompt": prompt}) as trace:
|
|
1260
|
+
output = await selected_runner.run(
|
|
1261
|
+
f"Respond concisely and safely to this streaming request:\\n{prompt}"
|
|
1262
|
+
)
|
|
1263
|
+
trace.set_output({"text": output})
|
|
1264
|
+
yield f"data: {json.dumps({'text': output})}\\n\\n"
|
|
593
1265
|
`;
|
|
594
1266
|
}
|
|
595
|
-
return `
|
|
1267
|
+
return `from backend.observability.tracing import Tracer, build_tracer
|
|
1268
|
+
from backend.orchestration.model_config import AgentRunner, build_agent_runner
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
async def run_${moduleName}(
|
|
1272
|
+
input_text: str,
|
|
1273
|
+
*,
|
|
1274
|
+
runner: AgentRunner | None = None,
|
|
1275
|
+
tracer: Tracer | None = None,
|
|
1276
|
+
) -> dict:
|
|
1277
|
+
selected_runner = runner or build_agent_runner()
|
|
1278
|
+
selected_tracer = tracer or build_tracer()
|
|
1279
|
+
prompt = (
|
|
1280
|
+
"Run the ${pattern.label} orchestration contract for this input:\\n"
|
|
1281
|
+
f"{input_text}"
|
|
1282
|
+
)
|
|
1283
|
+
async with selected_tracer.trace("${pattern.id}.run", {"input": input_text}) as trace:
|
|
1284
|
+
output = await selected_runner.run(prompt)
|
|
1285
|
+
trace.set_output({"result": output})
|
|
596
1286
|
return {
|
|
597
|
-
"result":
|
|
1287
|
+
"result": output,
|
|
598
1288
|
"input": input_text,
|
|
599
1289
|
}
|
|
600
1290
|
`;
|
|
601
1291
|
}
|
|
1292
|
+
function renderPatternAgentTest(plan) {
|
|
1293
|
+
const pattern = genAiPattern(plan);
|
|
1294
|
+
const moduleName = pyModule(pattern.id);
|
|
1295
|
+
const agentModule = `backend.orchestration.agents.${moduleName}_agent`;
|
|
1296
|
+
if (pattern.id === 'rag') {
|
|
1297
|
+
return `import asyncio
|
|
1298
|
+
|
|
1299
|
+
import pytest
|
|
1300
|
+
|
|
1301
|
+
from ${agentModule} import answer_question, enqueue_ingestion
|
|
1302
|
+
from backend.observability.tracing import DisabledTracer
|
|
1303
|
+
from backend.orchestration.model_config import ModelConfigurationError
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
class FakeRunner:
|
|
1307
|
+
async def run(self, prompt):
|
|
1308
|
+
assert "Question: What is Liftoff?" in prompt
|
|
1309
|
+
return "Liftoff is the generated orchestration starter."
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
class FakePublisher:
|
|
1313
|
+
def __init__(self):
|
|
1314
|
+
self.messages = []
|
|
1315
|
+
|
|
1316
|
+
async def publish(self, topic, payload):
|
|
1317
|
+
self.messages.append((topic, payload))
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def test_rag_query_uses_injected_runner_without_network():
|
|
1321
|
+
result = asyncio.run(
|
|
1322
|
+
answer_question(
|
|
1323
|
+
"What is Liftoff?",
|
|
1324
|
+
runner=FakeRunner(),
|
|
1325
|
+
tracer=DisabledTracer(),
|
|
1326
|
+
)
|
|
1327
|
+
)
|
|
1328
|
+
assert result == {
|
|
1329
|
+
"answer": "Liftoff is the generated orchestration starter.",
|
|
1330
|
+
"question": "What is Liftoff?",
|
|
1331
|
+
"citations": [],
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def test_rag_ingestion_uses_injected_publisher():
|
|
1336
|
+
publisher = FakePublisher()
|
|
1337
|
+
result = asyncio.run(
|
|
1338
|
+
enqueue_ingestion("az://documents/one.pdf", publisher=publisher)
|
|
1339
|
+
)
|
|
1340
|
+
assert result == {
|
|
1341
|
+
"status": "queued",
|
|
1342
|
+
"source_uri": "az://documents/one.pdf",
|
|
1343
|
+
}
|
|
1344
|
+
assert publisher.messages == [
|
|
1345
|
+
("rag.ingest", {"source_uri": "az://documents/one.pdf"})
|
|
1346
|
+
]
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def test_missing_model_configuration_is_explicit(monkeypatch):
|
|
1350
|
+
monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
|
|
1351
|
+
with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
|
|
1352
|
+
asyncio.run(answer_question("unconfigured"))
|
|
1353
|
+
`;
|
|
1354
|
+
}
|
|
1355
|
+
if (pattern.id === 'streaming') {
|
|
1356
|
+
return `import asyncio
|
|
1357
|
+
|
|
1358
|
+
import pytest
|
|
1359
|
+
|
|
1360
|
+
from ${agentModule} import stream_response
|
|
1361
|
+
from backend.observability.tracing import DisabledTracer
|
|
1362
|
+
from backend.orchestration.model_config import ModelConfigurationError
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
class FakeRunner:
|
|
1366
|
+
async def run(self, prompt):
|
|
1367
|
+
assert "stream this" in prompt
|
|
1368
|
+
return "offline streamed answer"
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def test_streaming_uses_injected_runner_without_network():
|
|
1372
|
+
async def collect():
|
|
1373
|
+
return [
|
|
1374
|
+
chunk
|
|
1375
|
+
async for chunk in stream_response(
|
|
1376
|
+
"stream this",
|
|
1377
|
+
runner=FakeRunner(),
|
|
1378
|
+
tracer=DisabledTracer(),
|
|
1379
|
+
)
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
chunks = asyncio.run(collect())
|
|
1383
|
+
assert chunks == ['data: {"text": "offline streamed answer"}\\n\\n']
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def test_missing_model_configuration_is_explicit(monkeypatch):
|
|
1387
|
+
monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
|
|
1388
|
+
|
|
1389
|
+
async def collect():
|
|
1390
|
+
return [chunk async for chunk in stream_response("unconfigured")]
|
|
1391
|
+
|
|
1392
|
+
with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
|
|
1393
|
+
asyncio.run(collect())
|
|
1394
|
+
`;
|
|
1395
|
+
}
|
|
1396
|
+
return `import asyncio
|
|
1397
|
+
|
|
1398
|
+
import pytest
|
|
1399
|
+
|
|
1400
|
+
from ${agentModule} import run_${moduleName}
|
|
1401
|
+
from backend.observability.tracing import DisabledTracer
|
|
1402
|
+
from backend.orchestration.model_config import ModelConfigurationError
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
class FakeRunner:
|
|
1406
|
+
async def run(self, prompt):
|
|
1407
|
+
assert "offline input" in prompt
|
|
1408
|
+
return "offline ${pattern.id} result"
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def test_${moduleName}_uses_injected_runner_without_network():
|
|
1412
|
+
result = asyncio.run(
|
|
1413
|
+
run_${moduleName}(
|
|
1414
|
+
"offline input",
|
|
1415
|
+
runner=FakeRunner(),
|
|
1416
|
+
tracer=DisabledTracer(),
|
|
1417
|
+
)
|
|
1418
|
+
)
|
|
1419
|
+
assert result == {
|
|
1420
|
+
"result": "offline ${pattern.id} result",
|
|
1421
|
+
"input": "offline input",
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def test_missing_model_configuration_is_explicit(monkeypatch):
|
|
1426
|
+
monkeypatch.delenv("PYDANTIC_AI_MODEL", raising=False)
|
|
1427
|
+
with pytest.raises(ModelConfigurationError, match="PYDANTIC_AI_MODEL is required"):
|
|
1428
|
+
asyncio.run(run_${moduleName}("unconfigured"))
|
|
1429
|
+
`;
|
|
1430
|
+
}
|
|
602
1431
|
function renderPromptTemplate(plan) {
|
|
603
|
-
|
|
1432
|
+
const pattern = genAiPattern(plan);
|
|
1433
|
+
return `# ${pattern.label} Prompt
|
|
604
1434
|
|
|
605
|
-
You are implementing a ${
|
|
1435
|
+
You are implementing a ${pattern.label} generated by Mission Control Liftoff.
|
|
606
1436
|
|
|
607
1437
|
Use PydanticAI orchestration and return outputs that match the API contract.
|
|
608
1438
|
`;
|
|
@@ -628,8 +1458,9 @@ class PgVectorStore:
|
|
|
628
1458
|
`;
|
|
629
1459
|
}
|
|
630
1460
|
function renderPatternWorker(plan) {
|
|
1461
|
+
const pattern = genAiPattern(plan);
|
|
631
1462
|
return `async def run_worker() -> None:
|
|
632
|
-
# Consume ${
|
|
1463
|
+
# Consume ${pattern.label} jobs from the configured messaging boundary.
|
|
633
1464
|
return None
|
|
634
1465
|
`;
|
|
635
1466
|
}
|
|
@@ -643,12 +1474,15 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
|
|
|
643
1474
|
}
|
|
644
1475
|
function renderFunctionWorkerReadme(plan) {
|
|
645
1476
|
const workerName = functionWorkerName(plan);
|
|
1477
|
+
const pattern = genAiPattern(plan);
|
|
646
1478
|
return `# ${workerName}
|
|
647
1479
|
|
|
648
|
-
Azure Functions worker scaffold for ${
|
|
1480
|
+
Azure Functions worker scaffold for ${pattern.label}.
|
|
649
1481
|
|
|
650
1482
|
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
1483
|
|
|
1484
|
+
Deployed triggers use \`ServiceBusConnection__fullyQualifiedNamespace\` and \`ServiceBusConnection__clientId\` to select the same user-assigned identity that OpenTofu grants the Service Bus Data Receiver role. \`SERVICEBUS_QUEUE_NAME\` is populated from \`function_worker_queue_name\`. Function host storage uses the complete \`AzureWebJobsStorage\` connection setting.
|
|
1485
|
+
|
|
652
1486
|
## Local Development
|
|
653
1487
|
|
|
654
1488
|
\`\`\`bash
|
|
@@ -656,6 +1490,7 @@ python -m venv .venv
|
|
|
656
1490
|
source .venv/bin/activate
|
|
657
1491
|
pip install -r requirements.txt
|
|
658
1492
|
cp local.settings.example.json local.settings.json
|
|
1493
|
+
python -m pytest -q
|
|
659
1494
|
func start
|
|
660
1495
|
\`\`\`
|
|
661
1496
|
|
|
@@ -672,14 +1507,15 @@ function renderFunctionHostJson() {
|
|
|
672
1507
|
}, null, 2);
|
|
673
1508
|
}
|
|
674
1509
|
function renderFunctionLocalSettings(plan) {
|
|
1510
|
+
const pattern = genAiPattern(plan);
|
|
675
1511
|
return JSON.stringify({
|
|
676
1512
|
IsEncrypted: false,
|
|
677
1513
|
Values: {
|
|
678
1514
|
AzureWebJobsStorage: 'UseDevelopmentStorage=true',
|
|
679
1515
|
FUNCTIONS_WORKER_RUNTIME: 'python',
|
|
680
|
-
SERVICEBUS_QUEUE_NAME:
|
|
1516
|
+
SERVICEBUS_QUEUE_NAME: DEFAULT_FUNCTION_WORKER_QUEUE_NAME,
|
|
681
1517
|
ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
|
|
682
|
-
GENAI_PATTERN:
|
|
1518
|
+
GENAI_PATTERN: pattern.id,
|
|
683
1519
|
SHARED_ORCHESTRATION_ROOT: '../../backend'
|
|
684
1520
|
}
|
|
685
1521
|
}, null, 2);
|
|
@@ -690,7 +1526,8 @@ pytest>=8.2
|
|
|
690
1526
|
`;
|
|
691
1527
|
}
|
|
692
1528
|
function renderFunctionApp(plan) {
|
|
693
|
-
const
|
|
1529
|
+
const pattern = genAiPattern(plan);
|
|
1530
|
+
const moduleName = pyModule(pattern.id);
|
|
694
1531
|
return `import json
|
|
695
1532
|
import logging
|
|
696
1533
|
|
|
@@ -717,7 +1554,7 @@ def decode_message_payload(body: str) -> dict:
|
|
|
717
1554
|
)
|
|
718
1555
|
def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
|
|
719
1556
|
payload = decode_message_payload(message.get_body().decode("utf-8"))
|
|
720
|
-
logging.info("Received ${
|
|
1557
|
+
logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
|
|
721
1558
|
# Keep this adapter thin; call backend.orchestration code from packaged shared modules.
|
|
722
1559
|
`;
|
|
723
1560
|
}
|
|
@@ -751,31 +1588,44 @@ local.settings.json
|
|
|
751
1588
|
`;
|
|
752
1589
|
}
|
|
753
1590
|
function renderBackendEnv(plan, environment) {
|
|
1591
|
+
const pattern = genAiPattern(plan);
|
|
754
1592
|
const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
|
|
755
1593
|
return `APP_ENV=${environment}
|
|
756
1594
|
APP_NAME=${plan.safeProjectName}
|
|
757
|
-
GENAI_PATTERN=${
|
|
1595
|
+
GENAI_PATTERN=${pattern.id}
|
|
758
1596
|
CLOUD_PROVIDER=${plan.provider.id}
|
|
759
1597
|
AZURE_REGION=${plan.region.slug}
|
|
760
1598
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
761
1599
|
REDIS_URL=redis://redis:6379/0
|
|
1600
|
+
REDIS_STREAM_NAME=liftoff-events
|
|
762
1601
|
MESSAGING_TRANSPORT=${transport}
|
|
1602
|
+
SERVICE_BUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
|
|
1603
|
+
SERVICE_BUS_CONNECTION_STRING=
|
|
1604
|
+
SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE=${environment === 'dev' ? '' : '<service-bus-namespace>.servicebus.windows.net'}
|
|
1605
|
+
AZURE_CLIENT_ID=
|
|
763
1606
|
BLOB_ENDPOINT=
|
|
764
|
-
|
|
1607
|
+
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
|
1608
|
+
PYDANTIC_AI_MODEL=
|
|
1609
|
+
LANGFUSE_HOST=${environment === 'dev' ? 'http://langfuse:3000' : ''}
|
|
1610
|
+
LANGFUSE_PUBLIC_KEY=
|
|
1611
|
+
LANGFUSE_SECRET_KEY=
|
|
765
1612
|
`;
|
|
766
1613
|
}
|
|
767
1614
|
function renderFunctionsEnv(plan, environment) {
|
|
1615
|
+
const pattern = genAiPattern(plan);
|
|
768
1616
|
return `APP_ENV=${environment}
|
|
769
1617
|
APP_NAME=${plan.safeProjectName}
|
|
770
|
-
GENAI_PATTERN=${
|
|
1618
|
+
GENAI_PATTERN=${pattern.id}
|
|
771
1619
|
FUNCTIONS_WORKER_RUNTIME=python
|
|
772
|
-
SERVICEBUS_QUEUE_NAME
|
|
1620
|
+
SERVICEBUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
|
|
773
1621
|
ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
|
|
774
|
-
|
|
1622
|
+
ServiceBusConnection__clientId=<managed-identity-client-id>
|
|
1623
|
+
AzureWebJobsStorage=<storage-connection-string>
|
|
775
1624
|
SHARED_ORCHESTRATION_ROOT=../../backend
|
|
776
1625
|
`;
|
|
777
1626
|
}
|
|
778
1627
|
function renderDockerCompose(plan) {
|
|
1628
|
+
const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
|
|
779
1629
|
const frontendService = plan.includeFrontend ? `
|
|
780
1630
|
frontend:
|
|
781
1631
|
build:
|
|
@@ -785,13 +1635,17 @@ function renderDockerCompose(plan) {
|
|
|
785
1635
|
depends_on:
|
|
786
1636
|
- backend
|
|
787
1637
|
` : '';
|
|
1638
|
+
const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
|
|
788
1639
|
return `services:
|
|
789
1640
|
backend:
|
|
790
1641
|
build:
|
|
791
1642
|
context: .
|
|
792
1643
|
dockerfile: Dockerfile
|
|
793
1644
|
env_file:
|
|
794
|
-
- ./environments/
|
|
1645
|
+
- ./environments/${localEnvironment.id}/backend.env
|
|
1646
|
+
environment:
|
|
1647
|
+
MESSAGING_TRANSPORT: redis-streams
|
|
1648
|
+
BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
|
|
795
1649
|
ports:
|
|
796
1650
|
- "8000:8000"
|
|
797
1651
|
depends_on:
|
|
@@ -801,7 +1655,7 @@ function renderDockerCompose(plan) {
|
|
|
801
1655
|
- mailpit
|
|
802
1656
|
${frontendService}
|
|
803
1657
|
postgres:
|
|
804
|
-
image:
|
|
1658
|
+
image: ${postgresImage}
|
|
805
1659
|
environment:
|
|
806
1660
|
POSTGRES_USER: postgres
|
|
807
1661
|
POSTGRES_PASSWORD: postgres
|
|
@@ -825,7 +1679,7 @@ ${frontendService}
|
|
|
825
1679
|
ports:
|
|
826
1680
|
- "8025:8025"
|
|
827
1681
|
|
|
828
|
-
langfuse:
|
|
1682
|
+
${plan.projectType.id === 'genai' ? ` langfuse:
|
|
829
1683
|
image: langfuse/langfuse:2
|
|
830
1684
|
profiles:
|
|
831
1685
|
- observability
|
|
@@ -835,6 +1689,7 @@ ${frontendService}
|
|
|
835
1689
|
SALT: local-development-placeholder
|
|
836
1690
|
ports:
|
|
837
1691
|
- "3000:3000"
|
|
1692
|
+
` : ''}
|
|
838
1693
|
`;
|
|
839
1694
|
}
|
|
840
1695
|
function renderTofuVersions() {
|
|
@@ -856,6 +1711,13 @@ function renderTofuProviders() {
|
|
|
856
1711
|
`;
|
|
857
1712
|
}
|
|
858
1713
|
function renderTofuVariables(plan) {
|
|
1714
|
+
const frontendVariables = plan.includeFrontend ? `
|
|
1715
|
+
variable "frontend_image" {
|
|
1716
|
+
type = string
|
|
1717
|
+
default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
|
|
1718
|
+
description = "Frontend image. Replace the bootstrap image with the generated frontend image after pushing it to ACR."
|
|
1719
|
+
}
|
|
1720
|
+
` : '';
|
|
859
1721
|
const functionVariables = hasFunctionWorker(plan) ? `
|
|
860
1722
|
variable "function_worker_queue_name" {
|
|
861
1723
|
type = string
|
|
@@ -882,9 +1744,26 @@ variable "location" {
|
|
|
882
1744
|
|
|
883
1745
|
variable "resource_suffix" {
|
|
884
1746
|
type = string
|
|
885
|
-
description = "
|
|
1747
|
+
description = "Twelve-character lowercase alphanumeric suffix for globally scoped Azure resource names."
|
|
1748
|
+
|
|
1749
|
+
validation {
|
|
1750
|
+
condition = can(regex("^[a-z0-9]{12}$", var.resource_suffix))
|
|
1751
|
+
error_message = "resource_suffix must contain exactly 12 lowercase letters or numbers."
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
variable "backend_image" {
|
|
1756
|
+
type = string
|
|
1757
|
+
default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
|
|
1758
|
+
description = "Backend image. Replace the bootstrap image with the generated backend image after pushing it to ACR."
|
|
886
1759
|
}
|
|
887
1760
|
|
|
1761
|
+
variable "backend_target_port" {
|
|
1762
|
+
type = number
|
|
1763
|
+
default = 80
|
|
1764
|
+
description = "Backend ingress port. Set to 8000 when switching from the bootstrap image to the generated backend."
|
|
1765
|
+
}
|
|
1766
|
+
${frontendVariables}
|
|
888
1767
|
variable "postgres_admin_password" {
|
|
889
1768
|
type = string
|
|
890
1769
|
sensitive = true
|
|
@@ -900,17 +1779,49 @@ ${functionVariables}
|
|
|
900
1779
|
`;
|
|
901
1780
|
}
|
|
902
1781
|
function renderTofuMain(plan) {
|
|
1782
|
+
const functionPattern = hasFunctionWorker(plan) ? genAiPattern(plan) : undefined;
|
|
1783
|
+
const names = buildAzureResourceNames(plan, '${var.environment}', '${var.resource_suffix}');
|
|
1784
|
+
const queueName = hasFunctionWorker(plan)
|
|
1785
|
+
? 'var.function_worker_queue_name'
|
|
1786
|
+
: JSON.stringify(DEFAULT_FUNCTION_WORKER_QUEUE_NAME);
|
|
1787
|
+
const projectIdentityEnv = plan.projectType.id === 'genai' ? `
|
|
1788
|
+
env {
|
|
1789
|
+
name = "GENAI_PATTERN"
|
|
1790
|
+
value = "${genAiPattern(plan).id}"
|
|
1791
|
+
}
|
|
1792
|
+
` : `
|
|
1793
|
+
env {
|
|
1794
|
+
name = "API_STACK"
|
|
1795
|
+
value = "${plan.apiStack.id}"
|
|
1796
|
+
}
|
|
1797
|
+
`;
|
|
1798
|
+
const frontendCorsEnvironment = plan.includeFrontend ? `
|
|
1799
|
+
env {
|
|
1800
|
+
name = "CORS_ALLOWED_ORIGINS"
|
|
1801
|
+
value = "https://\${azurerm_container_app.frontend.ingress[0].fqdn}"
|
|
1802
|
+
}
|
|
1803
|
+
` : '';
|
|
903
1804
|
const frontendContainer = plan.includeFrontend ? `
|
|
904
1805
|
resource "azurerm_container_app" "frontend" {
|
|
905
|
-
name = "
|
|
1806
|
+
name = "${names.frontendContainerApp}"
|
|
906
1807
|
container_app_environment_id = azurerm_container_app_environment.main.id
|
|
907
1808
|
resource_group_name = azurerm_resource_group.main.name
|
|
908
1809
|
revision_mode = "Single"
|
|
909
1810
|
|
|
1811
|
+
identity {
|
|
1812
|
+
type = "UserAssigned"
|
|
1813
|
+
identity_ids = [azurerm_user_assigned_identity.app.id]
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
registry {
|
|
1817
|
+
server = azurerm_container_registry.main.login_server
|
|
1818
|
+
identity = azurerm_user_assigned_identity.app.id
|
|
1819
|
+
}
|
|
1820
|
+
|
|
910
1821
|
template {
|
|
911
1822
|
container {
|
|
912
1823
|
name = "frontend"
|
|
913
|
-
image =
|
|
1824
|
+
image = var.frontend_image
|
|
914
1825
|
cpu = 0.25
|
|
915
1826
|
memory = "0.5Gi"
|
|
916
1827
|
}
|
|
@@ -924,11 +1835,13 @@ resource "azurerm_container_app" "frontend" {
|
|
|
924
1835
|
latest_revision = true
|
|
925
1836
|
}
|
|
926
1837
|
}
|
|
1838
|
+
|
|
1839
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
927
1840
|
}
|
|
928
1841
|
` : '';
|
|
929
1842
|
const functionWorker = hasFunctionWorker(plan) ? `
|
|
930
1843
|
resource "azurerm_service_plan" "functions" {
|
|
931
|
-
name = "
|
|
1844
|
+
name = "${names.functionServicePlan}"
|
|
932
1845
|
resource_group_name = azurerm_resource_group.main.name
|
|
933
1846
|
location = azurerm_resource_group.main.location
|
|
934
1847
|
os_type = "Linux"
|
|
@@ -936,7 +1849,7 @@ resource "azurerm_service_plan" "functions" {
|
|
|
936
1849
|
}
|
|
937
1850
|
|
|
938
1851
|
resource "azurerm_linux_function_app" "worker" {
|
|
939
|
-
name = "
|
|
1852
|
+
name = "${names.functionApp}"
|
|
940
1853
|
resource_group_name = azurerm_resource_group.main.name
|
|
941
1854
|
location = azurerm_resource_group.main.location
|
|
942
1855
|
service_plan_id = azurerm_service_plan.functions.id
|
|
@@ -955,14 +1868,14 @@ resource "azurerm_linux_function_app" "worker" {
|
|
|
955
1868
|
}
|
|
956
1869
|
|
|
957
1870
|
app_settings = {
|
|
958
|
-
APP_ENV
|
|
959
|
-
APP_NAME
|
|
960
|
-
GENAI_PATTERN
|
|
961
|
-
FUNCTIONS_WORKER_RUNTIME
|
|
962
|
-
SERVICEBUS_QUEUE_NAME
|
|
1871
|
+
APP_ENV = var.environment
|
|
1872
|
+
APP_NAME = "${plan.safeProjectName}"
|
|
1873
|
+
GENAI_PATTERN = "${functionPattern?.id}"
|
|
1874
|
+
FUNCTIONS_WORKER_RUNTIME = "python"
|
|
1875
|
+
SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
|
|
1876
|
+
ServiceBusConnection__clientId = azurerm_user_assigned_identity.app.client_id
|
|
963
1877
|
ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
|
|
964
|
-
|
|
965
|
-
SHARED_ORCHESTRATION_ROOT = "../../backend"
|
|
1878
|
+
SHARED_ORCHESTRATION_ROOT = "../../backend"
|
|
966
1879
|
}
|
|
967
1880
|
}
|
|
968
1881
|
|
|
@@ -978,17 +1891,13 @@ resource "azurerm_role_assignment" "function_storage_blob_contributor" {
|
|
|
978
1891
|
principal_id = azurerm_user_assigned_identity.app.principal_id
|
|
979
1892
|
}
|
|
980
1893
|
` : '';
|
|
981
|
-
return `
|
|
982
|
-
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
resource "azurerm_resource_group" "main" {
|
|
986
|
-
name = "rg-\${local.name_prefix}"
|
|
1894
|
+
return `resource "azurerm_resource_group" "main" {
|
|
1895
|
+
name = "${names.resourceGroup}"
|
|
987
1896
|
location = var.location
|
|
988
1897
|
}
|
|
989
1898
|
|
|
990
1899
|
resource "azurerm_container_registry" "main" {
|
|
991
|
-
name = "
|
|
1900
|
+
name = "${names.containerRegistry}"
|
|
992
1901
|
resource_group_name = azurerm_resource_group.main.name
|
|
993
1902
|
location = azurerm_resource_group.main.location
|
|
994
1903
|
sku = "Basic"
|
|
@@ -996,19 +1905,25 @@ resource "azurerm_container_registry" "main" {
|
|
|
996
1905
|
}
|
|
997
1906
|
|
|
998
1907
|
resource "azurerm_user_assigned_identity" "app" {
|
|
999
|
-
name = "
|
|
1908
|
+
name = "${names.identity}"
|
|
1000
1909
|
resource_group_name = azurerm_resource_group.main.name
|
|
1001
1910
|
location = azurerm_resource_group.main.location
|
|
1002
1911
|
}
|
|
1003
1912
|
|
|
1913
|
+
resource "azurerm_role_assignment" "acr_pull" {
|
|
1914
|
+
scope = azurerm_container_registry.main.id
|
|
1915
|
+
role_definition_name = "AcrPull"
|
|
1916
|
+
principal_id = azurerm_user_assigned_identity.app.principal_id
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1004
1919
|
resource "azurerm_container_app_environment" "main" {
|
|
1005
|
-
name = "
|
|
1920
|
+
name = "${names.containerAppEnvironment}"
|
|
1006
1921
|
resource_group_name = azurerm_resource_group.main.name
|
|
1007
1922
|
location = azurerm_resource_group.main.location
|
|
1008
1923
|
}
|
|
1009
1924
|
|
|
1010
1925
|
resource "azurerm_container_app" "backend" {
|
|
1011
|
-
name = "
|
|
1926
|
+
name = "${names.backendContainerApp}"
|
|
1012
1927
|
container_app_environment_id = azurerm_container_app_environment.main.id
|
|
1013
1928
|
resource_group_name = azurerm_resource_group.main.name
|
|
1014
1929
|
revision_mode = "Single"
|
|
@@ -1018,27 +1933,90 @@ resource "azurerm_container_app" "backend" {
|
|
|
1018
1933
|
identity_ids = [azurerm_user_assigned_identity.app.id]
|
|
1019
1934
|
}
|
|
1020
1935
|
|
|
1936
|
+
registry {
|
|
1937
|
+
server = azurerm_container_registry.main.login_server
|
|
1938
|
+
identity = azurerm_user_assigned_identity.app.id
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
secret {
|
|
1942
|
+
name = "database-url"
|
|
1943
|
+
value = "postgresql://liftoffadmin:\${urlencode(var.postgres_admin_password)}@\${azurerm_postgresql_flexible_server.main.fqdn}:5432/postgres?sslmode=require"
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
secret {
|
|
1947
|
+
name = "redis-url"
|
|
1948
|
+
value = "rediss://:\${urlencode(azurerm_redis_cache.main.primary_access_key)}@\${azurerm_redis_cache.main.hostname}:\${azurerm_redis_cache.main.ssl_port}/0"
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1021
1951
|
template {
|
|
1022
1952
|
container {
|
|
1023
1953
|
name = "backend"
|
|
1024
|
-
image =
|
|
1954
|
+
image = var.backend_image
|
|
1025
1955
|
cpu = 0.5
|
|
1026
1956
|
memory = "1Gi"
|
|
1957
|
+
|
|
1958
|
+
env {
|
|
1959
|
+
name = "APP_ENV"
|
|
1960
|
+
value = var.environment
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
env {
|
|
1964
|
+
name = "APP_NAME"
|
|
1965
|
+
value = "${plan.safeProjectName}"
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
env {
|
|
1969
|
+
name = "PROJECT_TYPE"
|
|
1970
|
+
value = "${plan.projectType.id}"
|
|
1971
|
+
}
|
|
1972
|
+
${projectIdentityEnv}
|
|
1973
|
+
env {
|
|
1974
|
+
name = "CLOUD_PROVIDER"
|
|
1975
|
+
value = "azure"
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
env {
|
|
1979
|
+
name = "AZURE_REGION"
|
|
1980
|
+
value = var.location
|
|
1981
|
+
}
|
|
1982
|
+
${frontendCorsEnvironment}
|
|
1983
|
+
|
|
1984
|
+
env {
|
|
1985
|
+
name = "DATABASE_URL"
|
|
1986
|
+
secret_name = "database-url"
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
env {
|
|
1990
|
+
name = "REDIS_URL"
|
|
1991
|
+
secret_name = "redis-url"
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
env {
|
|
1995
|
+
name = "MESSAGING_TRANSPORT"
|
|
1996
|
+
value = "azure-service-bus"
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
env {
|
|
2000
|
+
name = "BLOB_ENDPOINT"
|
|
2001
|
+
value = azurerm_storage_account.main.primary_blob_endpoint
|
|
2002
|
+
}
|
|
1027
2003
|
}
|
|
1028
2004
|
}
|
|
1029
2005
|
|
|
1030
2006
|
ingress {
|
|
1031
2007
|
external_enabled = true
|
|
1032
|
-
target_port =
|
|
2008
|
+
target_port = var.backend_target_port
|
|
1033
2009
|
traffic_weight {
|
|
1034
2010
|
percentage = 100
|
|
1035
2011
|
latest_revision = true
|
|
1036
2012
|
}
|
|
1037
2013
|
}
|
|
2014
|
+
|
|
2015
|
+
depends_on = [azurerm_role_assignment.acr_pull]
|
|
1038
2016
|
}
|
|
1039
2017
|
${frontendContainer}
|
|
1040
2018
|
resource "azurerm_postgresql_flexible_server" "main" {
|
|
1041
|
-
name = "
|
|
2019
|
+
name = "${names.postgres}"
|
|
1042
2020
|
resource_group_name = azurerm_resource_group.main.name
|
|
1043
2021
|
location = azurerm_resource_group.main.location
|
|
1044
2022
|
version = "16"
|
|
@@ -1048,8 +2026,16 @@ resource "azurerm_postgresql_flexible_server" "main" {
|
|
|
1048
2026
|
sku_name = "B_Standard_B1ms"
|
|
1049
2027
|
}
|
|
1050
2028
|
|
|
2029
|
+
resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
|
|
2030
|
+
count = var.enable_private_networking ? 0 : 1
|
|
2031
|
+
name = "AllowAzureServices"
|
|
2032
|
+
server_id = azurerm_postgresql_flexible_server.main.id
|
|
2033
|
+
start_ip_address = "0.0.0.0"
|
|
2034
|
+
end_ip_address = "0.0.0.0"
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1051
2037
|
resource "azurerm_redis_cache" "main" {
|
|
1052
|
-
name = "
|
|
2038
|
+
name = "${names.redis}"
|
|
1053
2039
|
location = azurerm_resource_group.main.location
|
|
1054
2040
|
resource_group_name = azurerm_resource_group.main.name
|
|
1055
2041
|
capacity = 0
|
|
@@ -1058,7 +2044,7 @@ resource "azurerm_redis_cache" "main" {
|
|
|
1058
2044
|
}
|
|
1059
2045
|
|
|
1060
2046
|
resource "azurerm_storage_account" "main" {
|
|
1061
|
-
name = "
|
|
2047
|
+
name = "${names.storage}"
|
|
1062
2048
|
resource_group_name = azurerm_resource_group.main.name
|
|
1063
2049
|
location = azurerm_resource_group.main.location
|
|
1064
2050
|
account_tier = "Standard"
|
|
@@ -1072,26 +2058,26 @@ resource "azurerm_storage_container" "documents" {
|
|
|
1072
2058
|
}
|
|
1073
2059
|
|
|
1074
2060
|
resource "azurerm_servicebus_namespace" "main" {
|
|
1075
|
-
name = "
|
|
2061
|
+
name = "${names.serviceBus}"
|
|
1076
2062
|
location = azurerm_resource_group.main.location
|
|
1077
2063
|
resource_group_name = azurerm_resource_group.main.name
|
|
1078
2064
|
sku = "Standard"
|
|
1079
2065
|
}
|
|
1080
2066
|
|
|
1081
2067
|
resource "azurerm_servicebus_queue" "events" {
|
|
1082
|
-
name =
|
|
2068
|
+
name = ${queueName}
|
|
1083
2069
|
namespace_id = azurerm_servicebus_namespace.main.id
|
|
1084
2070
|
}
|
|
1085
2071
|
${functionWorker}
|
|
1086
2072
|
|
|
1087
2073
|
resource "azurerm_communication_service" "main" {
|
|
1088
|
-
name = "
|
|
2074
|
+
name = "${names.communication}"
|
|
1089
2075
|
resource_group_name = azurerm_resource_group.main.name
|
|
1090
2076
|
data_location = "United States"
|
|
1091
2077
|
}
|
|
1092
2078
|
|
|
1093
2079
|
resource "azurerm_key_vault" "main" {
|
|
1094
|
-
name = "
|
|
2080
|
+
name = "${names.keyVault}"
|
|
1095
2081
|
location = azurerm_resource_group.main.location
|
|
1096
2082
|
resource_group_name = azurerm_resource_group.main.name
|
|
1097
2083
|
tenant_id = data.azurerm_client_config.current.tenant_id
|
|
@@ -1121,6 +2107,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
|
|
|
1121
2107
|
` : ''}${functionOutputs}output "container_registry" {
|
|
1122
2108
|
value = azurerm_container_registry.main.login_server
|
|
1123
2109
|
}
|
|
2110
|
+
|
|
2111
|
+
output "container_registry_name" {
|
|
2112
|
+
value = azurerm_container_registry.main.name
|
|
2113
|
+
}
|
|
1124
2114
|
`;
|
|
1125
2115
|
}
|
|
1126
2116
|
function renderTofuLocalState() {
|
|
@@ -1145,36 +2135,109 @@ function renderTofuReadme(plan) {
|
|
|
1145
2135
|
const functionSection = hasFunctionWorker(plan) ? `
|
|
1146
2136
|
## Azure Functions Worker
|
|
1147
2137
|
|
|
1148
|
-
This project includes an Azure Functions worker under \`functions/${functionWorkerName(plan)}\`. The OpenTofu configuration
|
|
2138
|
+
This project includes an Azure Functions worker under \`functions/${functionWorkerName(plan)}\`. The OpenTofu configuration attaches one user-assigned identity, grants its principal the Service Bus Data Receiver role, and selects it through \`ServiceBusConnection__clientId\` plus \`ServiceBusConnection__fullyQualifiedNamespace\`. \`function_worker_queue_name\` provisions the queue, configures \`SERVICEBUS_QUEUE_NAME\`, and drives the worker queue output. Function host storage uses the complete key-backed \`AzureWebJobsStorage\` connection setting.
|
|
1149
2139
|
` : '';
|
|
1150
2140
|
return `# Azure OpenTofu
|
|
1151
2141
|
|
|
1152
2142
|
Azure is the complete V1 provider for this Liftoff project.
|
|
1153
2143
|
|
|
2144
|
+
## Bootstrap Infrastructure
|
|
2145
|
+
|
|
2146
|
+
The first apply uses a public bootstrap image so Azure Container Apps can start before the new ACR contains application images.
|
|
2147
|
+
|
|
1154
2148
|
\`\`\`bash
|
|
1155
2149
|
tofu init
|
|
1156
2150
|
tofu plan -var-file=environments/${env}.tfvars
|
|
1157
2151
|
tofu apply -var-file=environments/${env}.tfvars
|
|
1158
|
-
|
|
2152
|
+
\`\`\`
|
|
2153
|
+
|
|
2154
|
+
Build the generated backend in ACR, then replace the bootstrap image:
|
|
2155
|
+
|
|
2156
|
+
\`\`\`bash
|
|
2157
|
+
ACR_NAME="$(tofu output -raw container_registry_name)"
|
|
2158
|
+
az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
|
|
2159
|
+
${plan.includeFrontend ? `BACKEND_URL="https://$(tofu output -raw backend_url)"
|
|
2160
|
+
az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest --build-arg VITE_API_BASE_URL="$BACKEND_URL" ../../../frontend
|
|
2161
|
+
` : ''}\`\`\`
|
|
2162
|
+
|
|
2163
|
+
Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
|
|
2164
|
+
|
|
2165
|
+
\`\`\`hcl
|
|
2166
|
+
backend_image = "<login-server>/${plan.safeProjectName}-backend:latest"
|
|
2167
|
+
backend_target_port = 8000
|
|
2168
|
+
${plan.includeFrontend ? `frontend_image = "<login-server>/${plan.safeProjectName}-frontend:latest"
|
|
2169
|
+
` : ''}\`\`\`
|
|
2170
|
+
|
|
2171
|
+
\`\`\`bash
|
|
2172
|
+
tofu apply -var-file=environments/${env}.tfvars
|
|
1159
2173
|
\`\`\`
|
|
1160
2174
|
|
|
1161
2175
|
Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
|
|
2176
|
+
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.
|
|
2177
|
+
|
|
2178
|
+
## Azure Name Suffixes
|
|
2179
|
+
|
|
2180
|
+
Each environment tfvars file contains a deterministic 12-character lowercase alphanumeric \`resource_suffix\` used by globally scoped Azure names. If Azure reports that a name is already taken, replace that environment's suffix with another unique value matching \`^[a-z0-9]{12}$\`; \`tofu validate\` rejects invalid overrides before deployment.
|
|
1162
2181
|
${functionSection}
|
|
1163
2182
|
`;
|
|
1164
2183
|
}
|
|
1165
2184
|
function renderTofuTfvars(plan, environment) {
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
enable_private_networking
|
|
1173
|
-
|
|
1174
|
-
|
|
2185
|
+
const values = [
|
|
2186
|
+
['environment', JSON.stringify(environment)],
|
|
2187
|
+
['location', JSON.stringify(plan.region.slug)],
|
|
2188
|
+
['resource_suffix', JSON.stringify(stableResourceSuffix(plan, environment))],
|
|
2189
|
+
['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
|
|
2190
|
+
['backend_target_port', '80'],
|
|
2191
|
+
['enable_private_networking', 'false']
|
|
2192
|
+
];
|
|
2193
|
+
if (plan.includeFrontend) {
|
|
2194
|
+
values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
|
|
2195
|
+
}
|
|
2196
|
+
if (hasFunctionWorker(plan)) {
|
|
2197
|
+
values.push(['function_worker_queue_name', JSON.stringify(DEFAULT_FUNCTION_WORKER_QUEUE_NAME)], ['functions_python_version', JSON.stringify('3.12')]);
|
|
2198
|
+
}
|
|
2199
|
+
const width = Math.max(...values.map(([key]) => key.length));
|
|
2200
|
+
return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
|
|
1175
2201
|
}
|
|
1176
2202
|
function renderOpenSpecConfig(plan) {
|
|
1177
2203
|
const frontendRule = plan.includeFrontend ? '\n - Keep frontend code under frontend.' : '';
|
|
2204
|
+
if (plan.projectType.id === 'standard') {
|
|
2205
|
+
const backendRule = plan.apiStack.id === 'python-fastapi'
|
|
2206
|
+
? 'Keep backend API code under backend/apis.'
|
|
2207
|
+
: plan.apiStack.id === 'node-fastify'
|
|
2208
|
+
? 'Keep backend API code under backend/src.'
|
|
2209
|
+
: 'Keep the Go entrypoint under backend/cmd/api and reusable code under backend/internal.';
|
|
2210
|
+
return `schema: spec-driven
|
|
2211
|
+
|
|
2212
|
+
context: |
|
|
2213
|
+
Project generated by Mission Control Liftoff.
|
|
2214
|
+
Project type: Standard application.
|
|
2215
|
+
API stack: ${plan.apiStack.label}.
|
|
2216
|
+
Database tooling: ${plan.apiStack.databaseTooling}.
|
|
2217
|
+
API developer portal: Scalar.
|
|
2218
|
+
Infrastructure: OpenTofu.
|
|
2219
|
+
Primary cloud: Azure (${plan.region.slug}).
|
|
2220
|
+
Local development: Docker Compose.
|
|
2221
|
+
Database: PostgreSQL.
|
|
2222
|
+
Cache and local messaging: Redis.
|
|
2223
|
+
Environments: ${plan.environments.map((environment) => environment.id).join(', ')}.
|
|
2224
|
+
|
|
2225
|
+
rules:
|
|
2226
|
+
specs:
|
|
2227
|
+
- Requirements must describe observable product behavior.
|
|
2228
|
+
- Cloud behavior must identify environment differences for generated environments.
|
|
2229
|
+
design:
|
|
2230
|
+
- Use ${plan.apiStack.framework} for backend APIs.
|
|
2231
|
+
- Use ${plan.apiStack.databaseTooling} for database access and migrations.
|
|
2232
|
+
- Use OpenTofu for infrastructure changes.${frontendRule}
|
|
2233
|
+
- ${backendRule}
|
|
2234
|
+
- Keep database artifacts under database.
|
|
2235
|
+
tasks:
|
|
2236
|
+
- Include local Docker Compose verification.
|
|
2237
|
+
- Include OpenTofu validation for generated infrastructure.
|
|
2238
|
+
`;
|
|
2239
|
+
}
|
|
2240
|
+
const pattern = genAiPattern(plan);
|
|
1178
2241
|
const functionsContext = hasFunctionWorker(plan) ? `
|
|
1179
2242
|
Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
|
|
1180
2243
|
const functionsRule = hasFunctionWorker(plan) ? `
|
|
@@ -1184,7 +2247,7 @@ function renderOpenSpecConfig(plan) {
|
|
|
1184
2247
|
|
|
1185
2248
|
context: |
|
|
1186
2249
|
Project generated by Mission Control Liftoff.
|
|
1187
|
-
GenAI pattern: ${
|
|
2250
|
+
GenAI pattern: ${pattern.label}
|
|
1188
2251
|
Application framework: FastAPI + PydanticAI.
|
|
1189
2252
|
API developer portal: Scalar.
|
|
1190
2253
|
Infrastructure: OpenTofu.
|
|
@@ -1213,10 +2276,36 @@ ${functionsRule}
|
|
|
1213
2276
|
`;
|
|
1214
2277
|
}
|
|
1215
2278
|
function renderSeedProposal(plan) {
|
|
2279
|
+
if (plan.projectType.id === 'standard') {
|
|
2280
|
+
return `## Why
|
|
2281
|
+
|
|
2282
|
+
Bootstrap the generated ${plan.apiStack.label} standard application baseline created by Mission Control Liftoff.
|
|
2283
|
+
|
|
2284
|
+
## What Changes
|
|
2285
|
+
|
|
2286
|
+
- Establish the approved backend, infrastructure, local development, and governance baseline.
|
|
2287
|
+
- Capture follow-up product requirements through spec-driven changes.
|
|
2288
|
+
|
|
2289
|
+
## Capabilities
|
|
2290
|
+
|
|
2291
|
+
### New Capabilities
|
|
2292
|
+
|
|
2293
|
+
- \`${plan.apiStack.id}-application-baseline\`: Generated standard application baseline for this Liftoff project.
|
|
2294
|
+
|
|
2295
|
+
### Modified Capabilities
|
|
2296
|
+
|
|
2297
|
+
- None.
|
|
2298
|
+
|
|
2299
|
+
## Impact
|
|
2300
|
+
|
|
2301
|
+
- Generated ${plan.apiStack.label} backend, OpenTofu infrastructure, Docker Compose local development, and governance files.
|
|
2302
|
+
`;
|
|
2303
|
+
}
|
|
2304
|
+
const pattern = genAiPattern(plan);
|
|
1216
2305
|
const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
|
|
1217
2306
|
return `## Why
|
|
1218
2307
|
|
|
1219
|
-
Bootstrap the generated ${
|
|
2308
|
+
Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
|
|
1220
2309
|
|
|
1221
2310
|
## What Changes
|
|
1222
2311
|
|
|
@@ -1228,7 +2317,7 @@ ${functionsChange}
|
|
|
1228
2317
|
|
|
1229
2318
|
### New Capabilities
|
|
1230
2319
|
|
|
1231
|
-
- \`${
|
|
2320
|
+
- \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
|
|
1232
2321
|
|
|
1233
2322
|
### Modified Capabilities
|
|
1234
2323
|
|
|
@@ -1240,10 +2329,38 @@ ${functionsChange}
|
|
|
1240
2329
|
`;
|
|
1241
2330
|
}
|
|
1242
2331
|
function renderSeedDesign(plan) {
|
|
2332
|
+
if (plan.projectType.id === 'standard') {
|
|
2333
|
+
return `## Context
|
|
2334
|
+
|
|
2335
|
+
This standard project was generated with Liftoff using ${plan.apiStack.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
|
|
2336
|
+
|
|
2337
|
+
## Goals / Non-Goals
|
|
2338
|
+
|
|
2339
|
+
**Goals:**
|
|
2340
|
+
|
|
2341
|
+
- Keep the generated baseline aligned to the approved Mission Control stack.
|
|
2342
|
+
|
|
2343
|
+
**Non-Goals:**
|
|
2344
|
+
|
|
2345
|
+
- Define domain-specific product behavior in the bootstrap change.
|
|
2346
|
+
|
|
2347
|
+
## Decisions
|
|
2348
|
+
|
|
2349
|
+
- Use ${plan.apiStack.framework} for backend APIs.
|
|
2350
|
+
- Use ${plan.apiStack.databaseTooling} for PostgreSQL integration.
|
|
2351
|
+
- Use OpenTofu for Azure infrastructure.
|
|
2352
|
+
- Use Docker Compose for local development.
|
|
2353
|
+
|
|
2354
|
+
## Risks / Trade-offs
|
|
2355
|
+
|
|
2356
|
+
- The baseline contains placeholders that product-specific changes should replace.
|
|
2357
|
+
`;
|
|
2358
|
+
}
|
|
2359
|
+
const pattern = genAiPattern(plan);
|
|
1243
2360
|
const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
|
|
1244
2361
|
return `## Context
|
|
1245
2362
|
|
|
1246
|
-
This project was generated with Liftoff using ${
|
|
2363
|
+
This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
|
|
1247
2364
|
|
|
1248
2365
|
## Goals / Non-Goals
|
|
1249
2366
|
|
|
@@ -1276,6 +2393,28 @@ function renderSeedTasks() {
|
|
|
1276
2393
|
`;
|
|
1277
2394
|
}
|
|
1278
2395
|
function renderSpecKitConstitution(plan) {
|
|
2396
|
+
if (plan.projectType.id === 'standard') {
|
|
2397
|
+
const backendLayout = plan.apiStack.id === 'python-fastapi'
|
|
2398
|
+
? 'backend/apis'
|
|
2399
|
+
: plan.apiStack.id === 'node-fastify' ? 'backend/src' : 'backend/cmd/api and backend/internal';
|
|
2400
|
+
return `# Mission Control Liftoff Constitution
|
|
2401
|
+
|
|
2402
|
+
## Principle 1: Approved Application Stack
|
|
2403
|
+
Generated backend services MUST use ${plan.apiStack.framework}, ${plan.apiStack.databaseTooling}, and Scalar for API documentation.
|
|
2404
|
+
|
|
2405
|
+
## Principle 2: Standard Project Layout
|
|
2406
|
+
Backend APIs live under ${backendLayout}. Database artifacts live under database.${plan.includeFrontend ? ' Frontend code lives under frontend.' : ''}
|
|
2407
|
+
|
|
2408
|
+
## Principle 3: Infrastructure As Code
|
|
2409
|
+
Cloud infrastructure MUST be defined with OpenTofu. Azure is the supported V1 provider.
|
|
2410
|
+
|
|
2411
|
+
## Principle 4: Local Development Parity
|
|
2412
|
+
Projects MUST include Docker Compose for local development with PostgreSQL, Redis, local blob storage, and local messaging behavior.
|
|
2413
|
+
|
|
2414
|
+
## Principle 5: Observability And Operations
|
|
2415
|
+
Services MUST use structured logging and environment-specific configuration for ${plan.environments.map((environment) => environment.id).join(', ')}.
|
|
2416
|
+
`;
|
|
2417
|
+
}
|
|
1279
2418
|
const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
|
|
1280
2419
|
return `# Mission Control Liftoff Constitution
|
|
1281
2420
|
|
|
@@ -1337,7 +2476,7 @@ function renderFrontendPackage(plan) {
|
|
|
1337
2476
|
}, null, 2);
|
|
1338
2477
|
}
|
|
1339
2478
|
function renderFrontendIndex(plan) {
|
|
1340
|
-
return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${plan.projectName}</title>`;
|
|
2479
|
+
return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${escapeHtml(plan.projectName)}</title>`;
|
|
1341
2480
|
}
|
|
1342
2481
|
function renderFrontendMain() {
|
|
1343
2482
|
return `import { createApp } from 'vue';
|
|
@@ -1348,10 +2487,72 @@ createApp(App).mount('#app');
|
|
|
1348
2487
|
`;
|
|
1349
2488
|
}
|
|
1350
2489
|
function renderFrontendApp(plan) {
|
|
2490
|
+
const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
|
|
2491
|
+
const apiContract = plan.projectType.id === 'standard'
|
|
2492
|
+
? { route: '/api', method: 'GET', bodyField: '', queryParameter: '', requiresInput: false }
|
|
2493
|
+
: genAiPattern(plan).id === 'rag'
|
|
2494
|
+
? { route: `${genAiPattern(plan).routePrefix}/query`, method: 'POST', bodyField: 'question', queryParameter: '', requiresInput: true }
|
|
2495
|
+
: genAiPattern(plan).id === 'streaming'
|
|
2496
|
+
? { route: genAiPattern(plan).routePrefix, method: 'GET', bodyField: '', queryParameter: 'prompt', requiresInput: true }
|
|
2497
|
+
: { route: `${genAiPattern(plan).routePrefix}/run`, method: 'POST', bodyField: 'input', queryParameter: '', requiresInput: true };
|
|
1351
2498
|
return `<script setup lang="ts">
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
const
|
|
2499
|
+
import { ref } from 'vue';
|
|
2500
|
+
|
|
2501
|
+
const title = ${scriptSourceString(plan.projectName)};
|
|
2502
|
+
const starter = ${scriptSourceString(plan.frontendStarter)};
|
|
2503
|
+
const descriptor = ${scriptSourceString(descriptor)};
|
|
2504
|
+
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000').replace(/\\/+$/, '');
|
|
2505
|
+
const route = ${scriptSourceString(apiContract.route)};
|
|
2506
|
+
const method = ${scriptSourceString(apiContract.method)};
|
|
2507
|
+
const bodyField = ${scriptSourceString(apiContract.bodyField)};
|
|
2508
|
+
const queryParameter = ${scriptSourceString(apiContract.queryParameter)};
|
|
2509
|
+
const requiresInput = ${apiContract.requiresInput};
|
|
2510
|
+
|
|
2511
|
+
const input = ref('');
|
|
2512
|
+
const loading = ref(false);
|
|
2513
|
+
const result = ref('');
|
|
2514
|
+
const errorMessage = ref('');
|
|
2515
|
+
|
|
2516
|
+
async function submit(): Promise<void> {
|
|
2517
|
+
const value = input.value.trim();
|
|
2518
|
+
if (requiresInput && !value) {
|
|
2519
|
+
errorMessage.value = 'Enter a value before running the starter.';
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
loading.value = true;
|
|
2524
|
+
result.value = '';
|
|
2525
|
+
errorMessage.value = '';
|
|
2526
|
+
try {
|
|
2527
|
+
const query = queryParameter
|
|
2528
|
+
? '?' + queryParameter + '=' + encodeURIComponent(value)
|
|
2529
|
+
: '';
|
|
2530
|
+
const request: RequestInit = { method };
|
|
2531
|
+
if (method === 'POST') {
|
|
2532
|
+
request.headers = { 'Content-Type': 'application/json' };
|
|
2533
|
+
request.body = JSON.stringify({ [bodyField]: value });
|
|
2534
|
+
}
|
|
2535
|
+
const response = await fetch(apiBaseUrl + route + query, request);
|
|
2536
|
+
const responseText = await response.text();
|
|
2537
|
+
if (!response.ok) {
|
|
2538
|
+
throw new Error(
|
|
2539
|
+
'Backend request failed (' + response.status + '): ' +
|
|
2540
|
+
(responseText || response.statusText)
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
if ((response.headers.get('content-type') || '').includes('application/json')) {
|
|
2544
|
+
result.value = JSON.stringify(JSON.parse(responseText), null, 2);
|
|
2545
|
+
} else {
|
|
2546
|
+
result.value = responseText;
|
|
2547
|
+
}
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
errorMessage.value = error instanceof Error
|
|
2550
|
+
? error.message
|
|
2551
|
+
: 'The backend request failed unexpectedly.';
|
|
2552
|
+
} finally {
|
|
2553
|
+
loading.value = false;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
1355
2556
|
</script>
|
|
1356
2557
|
|
|
1357
2558
|
<template>
|
|
@@ -1360,12 +2561,30 @@ const pattern = '${plan.pattern.label}';
|
|
|
1360
2561
|
<header>
|
|
1361
2562
|
<p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
|
|
1362
2563
|
<h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
|
|
1363
|
-
<p class="mt-2 text-slate-600">{{
|
|
2564
|
+
<p class="mt-2 text-slate-600">{{ descriptor }}</p>
|
|
1364
2565
|
</header>
|
|
1365
2566
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
|
1366
2567
|
<h2 class="text-xl font-semibold">{{ starter }}</h2>
|
|
1367
|
-
<
|
|
1368
|
-
<
|
|
2568
|
+
<p class="mt-2 text-sm text-slate-500">API: {{ apiBaseUrl }}{{ route }}</p>
|
|
2569
|
+
<textarea
|
|
2570
|
+
v-if="requiresInput"
|
|
2571
|
+
v-model="input"
|
|
2572
|
+
class="mt-4 min-h-40 w-full rounded-md border border-slate-300 p-3"
|
|
2573
|
+
:disabled="loading"
|
|
2574
|
+
placeholder="Enter input for the generated backend."
|
|
2575
|
+
/>
|
|
2576
|
+
<button
|
|
2577
|
+
class="mt-4 rounded-md bg-emerald-700 px-4 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
2578
|
+
:disabled="loading"
|
|
2579
|
+
type="button"
|
|
2580
|
+
@click="submit"
|
|
2581
|
+
>
|
|
2582
|
+
{{ loading ? 'Running...' : 'Run' }}
|
|
2583
|
+
</button>
|
|
2584
|
+
<p v-if="errorMessage" class="mt-4 rounded-md bg-red-50 p-3 text-red-800" role="alert">
|
|
2585
|
+
{{ errorMessage }}
|
|
2586
|
+
</p>
|
|
2587
|
+
<pre v-if="result" class="mt-4 overflow-auto rounded-md bg-slate-950 p-4 text-sm text-white" aria-live="polite">{{ result }}</pre>
|
|
1369
2588
|
</section>
|
|
1370
2589
|
</section>
|
|
1371
2590
|
</main>
|
|
@@ -1401,6 +2620,8 @@ WORKDIR /app
|
|
|
1401
2620
|
COPY package.json package-lock.json* ./
|
|
1402
2621
|
RUN npm install
|
|
1403
2622
|
COPY . .
|
|
2623
|
+
ARG VITE_API_BASE_URL=http://localhost:8000
|
|
2624
|
+
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
|
1404
2625
|
RUN npm run build
|
|
1405
2626
|
|
|
1406
2627
|
FROM nginx:1.27-alpine
|