@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/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>=0.0.13",
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>=2.39",
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 `from pydantic import BaseModel, Field
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
- class ModelConfig(BaseModel):
538
- provider: str = Field(default="azure-openai")
539
- deployment_name: str = Field(default="gpt-4.1")
540
- embedding_deployment_name: str = Field(default="text-embedding-3-large")
541
- pattern: str = Field(default="${pattern.id}")
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 `from typing import Protocol
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
- # Implement with redis.asyncio in application code.
556
- return None
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
- async def publish(self, topic: str, payload: dict) -> None:
561
- # Implement with azure-servicebus in cloud configuration.
562
- return None
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
- return AzureServiceBusPublisher()
568
- return RedisStreamPublisher()
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 `from contextlib import asynccontextmanager
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
- @asynccontextmanager
576
- async def trace_llm_operation(name: str):
577
- # Wire Langfuse callbacks here when model credentials are configured.
578
- yield {"trace": name}
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 `from backend.orchestration.tools.messaging import build_message_publisher
662
-
663
-
664
- async def answer_question(question: str) -> dict:
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": "Replace this placeholder with PydanticAI retrieval orchestration.",
1226
+ "answer": answer,
667
1227
  "question": question,
668
1228
  "citations": [],
669
1229
  }
670
1230
 
671
1231
 
672
- async def enqueue_ingestion(source_uri: str) -> dict:
673
- publisher = build_message_publisher("redis-streams")
674
- await publisher.publish("rag.ingest", {"source_uri": source_uri})
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 `async def stream_response(prompt: str):
680
- yield f"data: Starting response for {prompt}\\n\\n"
681
- yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
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 `async def run_${moduleName}(input_text: str) -> dict:
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": "Replace this placeholder with ${pattern.label} PydanticAI orchestration.",
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: 'events',
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
- LANGFUSE_HOST=
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=events
1620
+ SERVICEBUS_QUEUE_NAME=${DEFAULT_FUNCTION_WORKER_QUEUE_NAME}
869
1621
  ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
870
- AzureWebJobsStorage__accountName=<storage-account-name>
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 = "Globally unique suffix for Azure resource names."
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 = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
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 = "asp-\${local.name_prefix}-functions"
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 = "func-${plan.safeProjectName}-\${var.environment}-\${var.resource_suffix}"
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 = var.environment
1104
- APP_NAME = "${plan.safeProjectName}"
1105
- GENAI_PATTERN = "${functionPattern?.id}"
1106
- FUNCTIONS_WORKER_RUNTIME = "python"
1107
- SERVICEBUS_QUEUE_NAME = var.function_worker_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
- AzureWebJobsStorage__accountName = azurerm_storage_account.main.name
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 `locals {
1127
- name_prefix = "${plan.safeProjectName}-\${var.environment}"
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 = "acr\${replace(var.resource_suffix, "-", "")}"
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 = "id-\${local.name_prefix}"
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 = "cae-\${local.name_prefix}"
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 = "ca-${plan.safeProjectName}-backend-\${var.environment}"
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 = "psql-\${local.name_prefix}-\${var.resource_suffix}"
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 = "redis-\${local.name_prefix}-\${var.resource_suffix}"
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 = "st\${replace(var.resource_suffix, "-", "")}"
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 = "sb-\${local.name_prefix}-\${var.resource_suffix}"
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 = "events"
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 = "acs-\${local.name_prefix}"
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 = "kv-\${local.name_prefix}-\${var.resource_suffix}"
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 provisions Function app hosting, app settings, managed identity access to Service Bus, and worker outputs for the selected environments.
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 ? `az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest ../../../frontend
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(`${plan.safeProjectName.replace(/-/g, '')}${environment}`)],
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('events')], ['functions_python_version', JSON.stringify('3.12')]);
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
- <textarea class="mt-4 min-h-40 w-full rounded-md border border-slate-300 p-3" placeholder="Start with your application-specific input." />
1738
- <button class="mt-4 rounded-md bg-emerald-700 px-4 py-2 font-semibold text-white">Run</button>
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