@msn-control/liftoff 0.3.0 → 0.3.2
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/LICENSE +674 -0
- package/README.md +48 -14
- package/dist/args.d.ts +19 -0
- package/dist/args.js +216 -39
- package/dist/args.js.map +1 -1
- package/dist/cli.js +12 -7
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +43 -8
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +3 -0
- package/dist/file-system.js +288 -40
- package/dist/file-system.js.map +1 -1
- package/dist/planner.js +113 -3
- 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/standard-templates.js +147 -2
- package/dist/standard-templates.js.map +1 -1
- package/dist/templates.d.ts +18 -0
- package/dist/templates.js +924 -73
- package/dist/templates.js.map +1 -1
- package/package.json +6 -6
package/dist/templates.js
CHANGED
|
@@ -3,6 +3,50 @@ import { addGenAiExtensionArtifacts } from './genai-templates.js';
|
|
|
3
3
|
import { addStandardStackArtifacts, renderStandardDockerfile, renderStandardEnv } from './standard-templates.js';
|
|
4
4
|
import { liftoffVersion } from './version.js';
|
|
5
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
|
+
}
|
|
6
50
|
const pyModule = (value) => value.replace(/-/g, '_');
|
|
7
51
|
const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
|
|
8
52
|
const sourceString = (value) => JSON.stringify(value);
|
|
@@ -111,6 +155,8 @@ function addBackendArtifacts(add, plan) {
|
|
|
111
155
|
add('backend-observability', 'backend', ['backend', 'observability', 'tracing.py'], renderTracing());
|
|
112
156
|
add('backend-observability-package', 'backend', ['backend', 'observability', '__init__.py'], '');
|
|
113
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());
|
|
114
160
|
}
|
|
115
161
|
function addDatabaseArtifacts(add, plan) {
|
|
116
162
|
add('database-alembic-ini', 'database', ['database', 'alembic.ini'], renderAlembicIni());
|
|
@@ -122,6 +168,7 @@ function addPatternArtifacts(add, plan) {
|
|
|
122
168
|
const pattern = genAiPattern(plan);
|
|
123
169
|
const routeModule = pyModule(pattern.id);
|
|
124
170
|
add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
|
|
171
|
+
add('pattern-agent-test', 'backend-test', ['backend', 'tests', `test_${routeModule}_orchestration.py`], renderPatternAgentTest(plan));
|
|
125
172
|
add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
|
|
126
173
|
add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
|
|
127
174
|
add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
|
|
@@ -200,11 +247,89 @@ function addFrontendArtifacts(add, plan) {
|
|
|
200
247
|
add('frontend-index', 'frontend', ['frontend', 'index.html'], renderFrontendIndex(plan));
|
|
201
248
|
add('frontend-main', 'frontend', ['frontend', 'src', 'main.ts'], renderFrontendMain());
|
|
202
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');
|
|
203
251
|
add('frontend-styles', 'frontend', ['frontend', 'src', 'styles.css'], renderFrontendStyles());
|
|
204
252
|
add('frontend-vite-config', 'frontend', ['frontend', 'vite.config.ts'], renderFrontendViteConfig());
|
|
205
253
|
add('frontend-tailwind-config', 'frontend', ['frontend', 'tailwind.config.ts'], renderFrontendTailwindConfig());
|
|
206
254
|
add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
|
|
207
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
|
+
}
|
|
208
333
|
function renderRootReadme(plan) {
|
|
209
334
|
if (plan.projectType.id === 'standard') {
|
|
210
335
|
return `# ${plan.projectName}
|
|
@@ -231,6 +356,9 @@ docker compose up --build
|
|
|
231
356
|
|
|
232
357
|
The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
|
|
233
358
|
|
|
359
|
+
${renderGeneratedConfigurationGuide(plan)}
|
|
360
|
+
${renderDirectBuildAndTestGuide(plan)}
|
|
361
|
+
${renderGeneratedUpdateGuide()}
|
|
234
362
|
## Infrastructure
|
|
235
363
|
|
|
236
364
|
\`\`\`bash
|
|
@@ -280,6 +408,9 @@ docker compose --profile observability up --build
|
|
|
280
408
|
|
|
281
409
|
The backend API is available on port 8000. Scalar is exposed at \`/scalar\`.
|
|
282
410
|
|
|
411
|
+
${renderGeneratedConfigurationGuide(plan)}
|
|
412
|
+
${renderDirectBuildAndTestGuide(plan)}
|
|
413
|
+
${renderGeneratedUpdateGuide()}
|
|
283
414
|
## Infrastructure
|
|
284
415
|
|
|
285
416
|
\`\`\`bash
|
|
@@ -322,9 +453,18 @@ CLOUD_PROVIDER=${plan.provider.id}
|
|
|
322
453
|
AZURE_REGION=${plan.region.slug}
|
|
323
454
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
324
455
|
REDIS_URL=redis://redis:6379/0
|
|
456
|
+
REDIS_STREAM_NAME=liftoff-events
|
|
325
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=
|
|
326
462
|
BLOB_ENDPOINT=http://azurite:10000/devstoreaccount1
|
|
463
|
+
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
|
464
|
+
PYDANTIC_AI_MODEL=
|
|
327
465
|
LANGFUSE_HOST=http://langfuse:3000
|
|
466
|
+
LANGFUSE_PUBLIC_KEY=
|
|
467
|
+
LANGFUSE_SECRET_KEY=
|
|
328
468
|
`;
|
|
329
469
|
}
|
|
330
470
|
function renderBackendDockerfile() {
|
|
@@ -356,15 +496,16 @@ dependencies = [
|
|
|
356
496
|
"uvicorn[standard]>=0.30",
|
|
357
497
|
"pydantic>=2.7",
|
|
358
498
|
"pydantic-settings>=2.3",
|
|
359
|
-
"pydantic-ai
|
|
499
|
+
"pydantic-ai-slim[openai]==1.107.1",
|
|
360
500
|
"scalar-fastapi>=1.0",
|
|
361
501
|
"sqlalchemy[asyncio]>=2.0",
|
|
362
502
|
"asyncpg>=0.29",
|
|
363
503
|
"psycopg[binary]>=3.2",
|
|
364
504
|
"alembic>=1.13",
|
|
365
505
|
"redis>=5.0",
|
|
366
|
-
"langfuse
|
|
506
|
+
"langfuse==2.60.10",
|
|
367
507
|
"azure-servicebus>=7.12",
|
|
508
|
+
"azure-identity>=1.17",
|
|
368
509
|
"azure-storage-blob>=12.20",
|
|
369
510
|
"azure-communication-email>=1.0"
|
|
370
511
|
]
|
|
@@ -386,6 +527,7 @@ testpaths = ["tests"]
|
|
|
386
527
|
}
|
|
387
528
|
function renderFastApiMain(plan, routeModule) {
|
|
388
529
|
return `from fastapi import FastAPI
|
|
530
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
389
531
|
|
|
390
532
|
try:
|
|
391
533
|
from scalar_fastapi import get_scalar_api_reference
|
|
@@ -398,6 +540,16 @@ from backend.config.settings import get_settings
|
|
|
398
540
|
|
|
399
541
|
settings = get_settings()
|
|
400
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
|
+
)
|
|
401
553
|
|
|
402
554
|
app.include_router(health.router)
|
|
403
555
|
app.include_router(${routeModule}.router)
|
|
@@ -519,9 +671,18 @@ class Settings(BaseSettings):
|
|
|
519
671
|
azure_region: str = "${plan.region.slug}"
|
|
520
672
|
database_url: str
|
|
521
673
|
redis_url: str
|
|
674
|
+
redis_stream_name: str = "liftoff-events"
|
|
522
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
|
|
523
680
|
blob_endpoint: str | None = None
|
|
681
|
+
cors_allowed_origins: str = "http://localhost:5173"
|
|
682
|
+
pydantic_ai_model: str | None = None
|
|
524
683
|
langfuse_host: str | None = None
|
|
684
|
+
langfuse_public_key: str | None = None
|
|
685
|
+
langfuse_secret_key: str | None = None
|
|
525
686
|
|
|
526
687
|
|
|
527
688
|
@lru_cache
|
|
@@ -531,18 +692,65 @@ def get_settings() -> Settings:
|
|
|
531
692
|
}
|
|
532
693
|
function renderModelConfig(plan) {
|
|
533
694
|
const pattern = genAiPattern(plan);
|
|
534
|
-
return `
|
|
695
|
+
return `import os
|
|
696
|
+
from dataclasses import dataclass
|
|
697
|
+
from typing import Protocol
|
|
698
|
+
|
|
535
699
|
|
|
700
|
+
class ModelConfigurationError(RuntimeError):
|
|
701
|
+
pass
|
|
536
702
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
703
|
+
|
|
704
|
+
class AgentRunner(Protocol):
|
|
705
|
+
async def run(self, prompt: str) -> str:
|
|
706
|
+
...
|
|
707
|
+
|
|
708
|
+
|
|
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())
|
|
542
743
|
`;
|
|
543
744
|
}
|
|
544
745
|
function renderMessagingBoundary() {
|
|
545
|
-
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
|
|
546
754
|
|
|
547
755
|
|
|
548
756
|
class MessagePublisher(Protocol):
|
|
@@ -550,32 +758,202 @@ class MessagePublisher(Protocol):
|
|
|
550
758
|
...
|
|
551
759
|
|
|
552
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
|
+
|
|
553
786
|
class RedisStreamPublisher:
|
|
787
|
+
def __init__(self, client: RedisStreamClient, stream_name: str):
|
|
788
|
+
self._client = client
|
|
789
|
+
self._stream_name = stream_name
|
|
790
|
+
|
|
554
791
|
async def publish(self, topic: str, payload: dict) -> None:
|
|
555
|
-
|
|
556
|
-
|
|
792
|
+
await self._client.xadd(
|
|
793
|
+
self._stream_name,
|
|
794
|
+
{"topic": topic, "payload": _serialize(topic, payload)},
|
|
795
|
+
)
|
|
557
796
|
|
|
558
797
|
|
|
559
798
|
class AzureServiceBusPublisher:
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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)
|
|
563
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)
|
|
564
840
|
|
|
565
|
-
def build_message_publisher(transport: str) -> MessagePublisher:
|
|
566
841
|
if transport == "azure-service-bus":
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
)
|
|
569
871
|
`;
|
|
570
872
|
}
|
|
571
873
|
function renderTracing() {
|
|
572
|
-
return `
|
|
874
|
+
return `import os
|
|
875
|
+
from contextlib import asynccontextmanager
|
|
876
|
+
from dataclasses import dataclass
|
|
877
|
+
from typing import Any, AsyncContextManager, Protocol
|
|
878
|
+
|
|
573
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
|
+
...
|
|
574
897
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
898
|
+
|
|
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))
|
|
579
957
|
`;
|
|
580
958
|
}
|
|
581
959
|
function renderBackendHealthTest() {
|
|
@@ -589,6 +967,159 @@ def test_health():
|
|
|
589
967
|
response = client.get("/health")
|
|
590
968
|
assert response.status_code == 200
|
|
591
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()
|
|
592
1123
|
`;
|
|
593
1124
|
}
|
|
594
1125
|
function renderAlembicIni() {
|
|
@@ -658,36 +1189,245 @@ function renderPatternAgent(plan) {
|
|
|
658
1189
|
const pattern = genAiPattern(plan);
|
|
659
1190
|
const moduleName = pyModule(pattern.id);
|
|
660
1191
|
if (pattern.id === 'rag') {
|
|
661
|
-
return `
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
+
)
|
|
665
1225
|
return {
|
|
666
|
-
"answer":
|
|
1226
|
+
"answer": answer,
|
|
667
1227
|
"question": question,
|
|
668
1228
|
"citations": [],
|
|
669
1229
|
}
|
|
670
1230
|
|
|
671
1231
|
|
|
672
|
-
async def enqueue_ingestion(
|
|
673
|
-
|
|
674
|
-
|
|
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})
|
|
675
1241
|
return {"status": "queued", "source_uri": source_uri}
|
|
676
1242
|
`;
|
|
677
1243
|
}
|
|
678
1244
|
if (pattern.id === 'streaming') {
|
|
679
|
-
return `
|
|
680
|
-
|
|
681
|
-
|
|
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"
|
|
682
1265
|
`;
|
|
683
1266
|
}
|
|
684
|
-
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})
|
|
685
1286
|
return {
|
|
686
|
-
"result":
|
|
1287
|
+
"result": output,
|
|
687
1288
|
"input": input_text,
|
|
688
1289
|
}
|
|
689
1290
|
`;
|
|
690
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
|
+
}
|
|
691
1431
|
function renderPromptTemplate(plan) {
|
|
692
1432
|
const pattern = genAiPattern(plan);
|
|
693
1433
|
return `# ${pattern.label} Prompt
|
|
@@ -741,6 +1481,8 @@ Azure Functions worker scaffold for ${pattern.label}.
|
|
|
741
1481
|
|
|
742
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.
|
|
743
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
|
+
|
|
744
1486
|
## Local Development
|
|
745
1487
|
|
|
746
1488
|
\`\`\`bash
|
|
@@ -748,6 +1490,7 @@ python -m venv .venv
|
|
|
748
1490
|
source .venv/bin/activate
|
|
749
1491
|
pip install -r requirements.txt
|
|
750
1492
|
cp local.settings.example.json local.settings.json
|
|
1493
|
+
python -m pytest -q
|
|
751
1494
|
func start
|
|
752
1495
|
\`\`\`
|
|
753
1496
|
|
|
@@ -770,7 +1513,7 @@ function renderFunctionLocalSettings(plan) {
|
|
|
770
1513
|
Values: {
|
|
771
1514
|
AzureWebJobsStorage: 'UseDevelopmentStorage=true',
|
|
772
1515
|
FUNCTIONS_WORKER_RUNTIME: 'python',
|
|
773
|
-
SERVICEBUS_QUEUE_NAME:
|
|
1516
|
+
SERVICEBUS_QUEUE_NAME: DEFAULT_FUNCTION_WORKER_QUEUE_NAME,
|
|
774
1517
|
ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
|
|
775
1518
|
GENAI_PATTERN: pattern.id,
|
|
776
1519
|
SHARED_ORCHESTRATION_ROOT: '../../backend'
|
|
@@ -854,9 +1597,18 @@ CLOUD_PROVIDER=${plan.provider.id}
|
|
|
854
1597
|
AZURE_REGION=${plan.region.slug}
|
|
855
1598
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
|
|
856
1599
|
REDIS_URL=redis://redis:6379/0
|
|
1600
|
+
REDIS_STREAM_NAME=liftoff-events
|
|
857
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=
|
|
858
1606
|
BLOB_ENDPOINT=
|
|
859
|
-
|
|
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=
|
|
860
1612
|
`;
|
|
861
1613
|
}
|
|
862
1614
|
function renderFunctionsEnv(plan, environment) {
|
|
@@ -865,9 +1617,10 @@ function renderFunctionsEnv(plan, environment) {
|
|
|
865
1617
|
APP_NAME=${plan.safeProjectName}
|
|
866
1618
|
GENAI_PATTERN=${pattern.id}
|
|
867
1619
|
FUNCTIONS_WORKER_RUNTIME=python
|
|
868
|
-
SERVICEBUS_QUEUE_NAME
|
|
1620
|
+
SERVICEBUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
|
|
869
1621
|
ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
|
|
870
|
-
|
|
1622
|
+
ServiceBusConnection__clientId=<managed-identity-client-id>
|
|
1623
|
+
AzureWebJobsStorage=<storage-connection-string>
|
|
871
1624
|
SHARED_ORCHESTRATION_ROOT=../../backend
|
|
872
1625
|
`;
|
|
873
1626
|
}
|
|
@@ -991,7 +1744,12 @@ variable "location" {
|
|
|
991
1744
|
|
|
992
1745
|
variable "resource_suffix" {
|
|
993
1746
|
type = string
|
|
994
|
-
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
|
+
}
|
|
995
1753
|
}
|
|
996
1754
|
|
|
997
1755
|
variable "backend_image" {
|
|
@@ -1022,6 +1780,10 @@ ${functionVariables}
|
|
|
1022
1780
|
}
|
|
1023
1781
|
function renderTofuMain(plan) {
|
|
1024
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);
|
|
1025
1787
|
const projectIdentityEnv = plan.projectType.id === 'genai' ? `
|
|
1026
1788
|
env {
|
|
1027
1789
|
name = "GENAI_PATTERN"
|
|
@@ -1033,9 +1795,15 @@ function renderTofuMain(plan) {
|
|
|
1033
1795
|
value = "${plan.apiStack.id}"
|
|
1034
1796
|
}
|
|
1035
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
|
+
` : '';
|
|
1036
1804
|
const frontendContainer = plan.includeFrontend ? `
|
|
1037
1805
|
resource "azurerm_container_app" "frontend" {
|
|
1038
|
-
name = "
|
|
1806
|
+
name = "${names.frontendContainerApp}"
|
|
1039
1807
|
container_app_environment_id = azurerm_container_app_environment.main.id
|
|
1040
1808
|
resource_group_name = azurerm_resource_group.main.name
|
|
1041
1809
|
revision_mode = "Single"
|
|
@@ -1073,7 +1841,7 @@ resource "azurerm_container_app" "frontend" {
|
|
|
1073
1841
|
` : '';
|
|
1074
1842
|
const functionWorker = hasFunctionWorker(plan) ? `
|
|
1075
1843
|
resource "azurerm_service_plan" "functions" {
|
|
1076
|
-
name = "
|
|
1844
|
+
name = "${names.functionServicePlan}"
|
|
1077
1845
|
resource_group_name = azurerm_resource_group.main.name
|
|
1078
1846
|
location = azurerm_resource_group.main.location
|
|
1079
1847
|
os_type = "Linux"
|
|
@@ -1081,7 +1849,7 @@ resource "azurerm_service_plan" "functions" {
|
|
|
1081
1849
|
}
|
|
1082
1850
|
|
|
1083
1851
|
resource "azurerm_linux_function_app" "worker" {
|
|
1084
|
-
name = "
|
|
1852
|
+
name = "${names.functionApp}"
|
|
1085
1853
|
resource_group_name = azurerm_resource_group.main.name
|
|
1086
1854
|
location = azurerm_resource_group.main.location
|
|
1087
1855
|
service_plan_id = azurerm_service_plan.functions.id
|
|
@@ -1100,14 +1868,14 @@ resource "azurerm_linux_function_app" "worker" {
|
|
|
1100
1868
|
}
|
|
1101
1869
|
|
|
1102
1870
|
app_settings = {
|
|
1103
|
-
APP_ENV
|
|
1104
|
-
APP_NAME
|
|
1105
|
-
GENAI_PATTERN
|
|
1106
|
-
FUNCTIONS_WORKER_RUNTIME
|
|
1107
|
-
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
|
|
1108
1877
|
ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
|
|
1109
|
-
|
|
1110
|
-
SHARED_ORCHESTRATION_ROOT = "../../backend"
|
|
1878
|
+
SHARED_ORCHESTRATION_ROOT = "../../backend"
|
|
1111
1879
|
}
|
|
1112
1880
|
}
|
|
1113
1881
|
|
|
@@ -1123,17 +1891,13 @@ resource "azurerm_role_assignment" "function_storage_blob_contributor" {
|
|
|
1123
1891
|
principal_id = azurerm_user_assigned_identity.app.principal_id
|
|
1124
1892
|
}
|
|
1125
1893
|
` : '';
|
|
1126
|
-
return `
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
resource "azurerm_resource_group" "main" {
|
|
1131
|
-
name = "rg-\${local.name_prefix}"
|
|
1894
|
+
return `resource "azurerm_resource_group" "main" {
|
|
1895
|
+
name = "${names.resourceGroup}"
|
|
1132
1896
|
location = var.location
|
|
1133
1897
|
}
|
|
1134
1898
|
|
|
1135
1899
|
resource "azurerm_container_registry" "main" {
|
|
1136
|
-
name = "
|
|
1900
|
+
name = "${names.containerRegistry}"
|
|
1137
1901
|
resource_group_name = azurerm_resource_group.main.name
|
|
1138
1902
|
location = azurerm_resource_group.main.location
|
|
1139
1903
|
sku = "Basic"
|
|
@@ -1141,7 +1905,7 @@ resource "azurerm_container_registry" "main" {
|
|
|
1141
1905
|
}
|
|
1142
1906
|
|
|
1143
1907
|
resource "azurerm_user_assigned_identity" "app" {
|
|
1144
|
-
name = "
|
|
1908
|
+
name = "${names.identity}"
|
|
1145
1909
|
resource_group_name = azurerm_resource_group.main.name
|
|
1146
1910
|
location = azurerm_resource_group.main.location
|
|
1147
1911
|
}
|
|
@@ -1153,13 +1917,13 @@ resource "azurerm_role_assignment" "acr_pull" {
|
|
|
1153
1917
|
}
|
|
1154
1918
|
|
|
1155
1919
|
resource "azurerm_container_app_environment" "main" {
|
|
1156
|
-
name = "
|
|
1920
|
+
name = "${names.containerAppEnvironment}"
|
|
1157
1921
|
resource_group_name = azurerm_resource_group.main.name
|
|
1158
1922
|
location = azurerm_resource_group.main.location
|
|
1159
1923
|
}
|
|
1160
1924
|
|
|
1161
1925
|
resource "azurerm_container_app" "backend" {
|
|
1162
|
-
name = "
|
|
1926
|
+
name = "${names.backendContainerApp}"
|
|
1163
1927
|
container_app_environment_id = azurerm_container_app_environment.main.id
|
|
1164
1928
|
resource_group_name = azurerm_resource_group.main.name
|
|
1165
1929
|
revision_mode = "Single"
|
|
@@ -1215,6 +1979,7 @@ ${projectIdentityEnv}
|
|
|
1215
1979
|
name = "AZURE_REGION"
|
|
1216
1980
|
value = var.location
|
|
1217
1981
|
}
|
|
1982
|
+
${frontendCorsEnvironment}
|
|
1218
1983
|
|
|
1219
1984
|
env {
|
|
1220
1985
|
name = "DATABASE_URL"
|
|
@@ -1251,7 +2016,7 @@ ${projectIdentityEnv}
|
|
|
1251
2016
|
}
|
|
1252
2017
|
${frontendContainer}
|
|
1253
2018
|
resource "azurerm_postgresql_flexible_server" "main" {
|
|
1254
|
-
name = "
|
|
2019
|
+
name = "${names.postgres}"
|
|
1255
2020
|
resource_group_name = azurerm_resource_group.main.name
|
|
1256
2021
|
location = azurerm_resource_group.main.location
|
|
1257
2022
|
version = "16"
|
|
@@ -1270,7 +2035,7 @@ resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
|
|
|
1270
2035
|
}
|
|
1271
2036
|
|
|
1272
2037
|
resource "azurerm_redis_cache" "main" {
|
|
1273
|
-
name = "
|
|
2038
|
+
name = "${names.redis}"
|
|
1274
2039
|
location = azurerm_resource_group.main.location
|
|
1275
2040
|
resource_group_name = azurerm_resource_group.main.name
|
|
1276
2041
|
capacity = 0
|
|
@@ -1279,7 +2044,7 @@ resource "azurerm_redis_cache" "main" {
|
|
|
1279
2044
|
}
|
|
1280
2045
|
|
|
1281
2046
|
resource "azurerm_storage_account" "main" {
|
|
1282
|
-
name = "
|
|
2047
|
+
name = "${names.storage}"
|
|
1283
2048
|
resource_group_name = azurerm_resource_group.main.name
|
|
1284
2049
|
location = azurerm_resource_group.main.location
|
|
1285
2050
|
account_tier = "Standard"
|
|
@@ -1293,26 +2058,26 @@ resource "azurerm_storage_container" "documents" {
|
|
|
1293
2058
|
}
|
|
1294
2059
|
|
|
1295
2060
|
resource "azurerm_servicebus_namespace" "main" {
|
|
1296
|
-
name = "
|
|
2061
|
+
name = "${names.serviceBus}"
|
|
1297
2062
|
location = azurerm_resource_group.main.location
|
|
1298
2063
|
resource_group_name = azurerm_resource_group.main.name
|
|
1299
2064
|
sku = "Standard"
|
|
1300
2065
|
}
|
|
1301
2066
|
|
|
1302
2067
|
resource "azurerm_servicebus_queue" "events" {
|
|
1303
|
-
name =
|
|
2068
|
+
name = ${queueName}
|
|
1304
2069
|
namespace_id = azurerm_servicebus_namespace.main.id
|
|
1305
2070
|
}
|
|
1306
2071
|
${functionWorker}
|
|
1307
2072
|
|
|
1308
2073
|
resource "azurerm_communication_service" "main" {
|
|
1309
|
-
name = "
|
|
2074
|
+
name = "${names.communication}"
|
|
1310
2075
|
resource_group_name = azurerm_resource_group.main.name
|
|
1311
2076
|
data_location = "United States"
|
|
1312
2077
|
}
|
|
1313
2078
|
|
|
1314
2079
|
resource "azurerm_key_vault" "main" {
|
|
1315
|
-
name = "
|
|
2080
|
+
name = "${names.keyVault}"
|
|
1316
2081
|
location = azurerm_resource_group.main.location
|
|
1317
2082
|
resource_group_name = azurerm_resource_group.main.name
|
|
1318
2083
|
tenant_id = data.azurerm_client_config.current.tenant_id
|
|
@@ -1370,7 +2135,7 @@ function renderTofuReadme(plan) {
|
|
|
1370
2135
|
const functionSection = hasFunctionWorker(plan) ? `
|
|
1371
2136
|
## Azure Functions Worker
|
|
1372
2137
|
|
|
1373
|
-
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.
|
|
1374
2139
|
` : '';
|
|
1375
2140
|
return `# Azure OpenTofu
|
|
1376
2141
|
|
|
@@ -1391,7 +2156,8 @@ Build the generated backend in ACR, then replace the bootstrap image:
|
|
|
1391
2156
|
\`\`\`bash
|
|
1392
2157
|
ACR_NAME="$(tofu output -raw container_registry_name)"
|
|
1393
2158
|
az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
|
|
1394
|
-
${plan.includeFrontend ? `
|
|
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
|
|
1395
2161
|
` : ''}\`\`\`
|
|
1396
2162
|
|
|
1397
2163
|
Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
|
|
@@ -1408,6 +2174,10 @@ tofu apply -var-file=environments/${env}.tfvars
|
|
|
1408
2174
|
|
|
1409
2175
|
Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
|
|
1410
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.
|
|
1411
2181
|
${functionSection}
|
|
1412
2182
|
`;
|
|
1413
2183
|
}
|
|
@@ -1415,7 +2185,7 @@ function renderTofuTfvars(plan, environment) {
|
|
|
1415
2185
|
const values = [
|
|
1416
2186
|
['environment', JSON.stringify(environment)],
|
|
1417
2187
|
['location', JSON.stringify(plan.region.slug)],
|
|
1418
|
-
['resource_suffix', JSON.stringify(
|
|
2188
|
+
['resource_suffix', JSON.stringify(stableResourceSuffix(plan, environment))],
|
|
1419
2189
|
['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
|
|
1420
2190
|
['backend_target_port', '80'],
|
|
1421
2191
|
['enable_private_networking', 'false']
|
|
@@ -1424,7 +2194,7 @@ function renderTofuTfvars(plan, environment) {
|
|
|
1424
2194
|
values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
|
|
1425
2195
|
}
|
|
1426
2196
|
if (hasFunctionWorker(plan)) {
|
|
1427
|
-
values.push(['function_worker_queue_name', JSON.stringify(
|
|
2197
|
+
values.push(['function_worker_queue_name', JSON.stringify(DEFAULT_FUNCTION_WORKER_QUEUE_NAME)], ['functions_python_version', JSON.stringify('3.12')]);
|
|
1428
2198
|
}
|
|
1429
2199
|
const width = Math.max(...values.map(([key]) => key.length));
|
|
1430
2200
|
return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
|
|
@@ -1718,10 +2488,71 @@ createApp(App).mount('#app');
|
|
|
1718
2488
|
}
|
|
1719
2489
|
function renderFrontendApp(plan) {
|
|
1720
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 };
|
|
1721
2498
|
return `<script setup lang="ts">
|
|
2499
|
+
import { ref } from 'vue';
|
|
2500
|
+
|
|
1722
2501
|
const title = ${scriptSourceString(plan.projectName)};
|
|
1723
2502
|
const starter = ${scriptSourceString(plan.frontendStarter)};
|
|
1724
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
|
+
}
|
|
1725
2556
|
</script>
|
|
1726
2557
|
|
|
1727
2558
|
<template>
|
|
@@ -1734,8 +2565,26 @@ const descriptor = ${scriptSourceString(descriptor)};
|
|
|
1734
2565
|
</header>
|
|
1735
2566
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
|
1736
2567
|
<h2 class="text-xl font-semibold">{{ starter }}</h2>
|
|
1737
|
-
<
|
|
1738
|
-
<
|
|
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>
|
|
1739
2588
|
</section>
|
|
1740
2589
|
</section>
|
|
1741
2590
|
</main>
|
|
@@ -1771,6 +2620,8 @@ WORKDIR /app
|
|
|
1771
2620
|
COPY package.json package-lock.json* ./
|
|
1772
2621
|
RUN npm install
|
|
1773
2622
|
COPY . .
|
|
2623
|
+
ARG VITE_API_BASE_URL=http://localhost:8000
|
|
2624
|
+
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
|
1774
2625
|
RUN npm run build
|
|
1775
2626
|
|
|
1776
2627
|
FROM nginx:1.27-alpine
|