use crate::config::{
AiProvider, AzureConfig, Config, DeploymentConfig, DeploymentRole, DeploymentTarget,
};
const IMAGE_BASE: &str = "ghcr.io/mklab-se/quelch";
const QUELCH_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, thiserror::Error)]
pub enum BicepError {
#[error("template render: {0}")]
Template(String),
#[error("deployment not found: {0}")]
DeploymentNotFound(String),
#[error("config: {0}")]
Config(#[from] crate::config::ConfigError),
}
pub fn generate(config: &Config, deployment_name: &str) -> Result<String, BicepError> {
let deployment = config
.deployments
.iter()
.find(|d| d.name == deployment_name)
.ok_or_else(|| BicepError::DeploymentNotFound(deployment_name.to_string()))?;
Ok(render_bicep(config, deployment))
}
fn render_bicep(config: &Config, deployment: &DeploymentConfig) -> String {
let prefix = config.azure.naming.prefix.as_deref().unwrap_or("quelch");
let env = config.azure.naming.environment.as_deref().unwrap_or("prod");
let existing = render_existing_resources(config, prefix, env);
let cosmos_internals = render_cosmos_internals(config);
let per_deployment = match deployment.target {
DeploymentTarget::Azure => render_per_deployment(config, deployment, prefix, env),
DeploymentTarget::Onprem => {
"// target: onprem — no Container App or managed identity emitted.\n".to_string()
}
};
format!(
"// .quelch/azure/{name}.bicep — generated by Quelch {version}\n\
// DO NOT EDIT MANUALLY — re-run `quelch azure plan` to regenerate.\n\
//\n\
// Quelch does not provision Cosmos, AI Search, Key Vault, Container\n\
// Apps environment, App Insights, or the AI provider — those are\n\
// referenced via `existing`. See docs/getting-started.md for the\n\
// prerequisites list.\n\
\n\
{params}\n\
{existing}\n\
{cosmos_internals}\n\
{per_deployment}",
name = deployment.name,
version = QUELCH_VERSION,
params = render_parameters(),
)
}
fn render_parameters() -> String {
"\
@description('Resource naming prefix')
param prefix string = 'quelch'
@description('Environment name (e.g. prod, staging)')
param environment string = 'prod'
@description('Azure region for all resources')
param location string = resourceGroup().location
"
.to_string()
}
fn render_existing_resources(config: &Config, prefix: &str, env: &str) -> String {
let cosmos_name = config
.cosmos
.account
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-cosmos"));
let search_name = config
.search
.service
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-search"));
let kv_name = config
.azure
.resources
.key_vault
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-kv"));
let cae_name = config
.azure
.resources
.container_apps_env
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-cae"));
let appi_name = config
.azure
.resources
.application_insights
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-appi"));
let ai_account_name = ai_account_name_from_endpoint(&config.ai.endpoint);
let ai_resource_block = match config.ai.provider {
AiProvider::AzureOpenai | AiProvider::Foundry => format!(
"resource aiAccount 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = {{\n\
\x20\x20name: '{ai_account_name}'\n\
}}\n"
),
};
format!(
"// ─────────────────────────────────────────────────────────────────\n\
// References to existing resources (provisioned by the operator)\n\
// ─────────────────────────────────────────────────────────────────\n\
\n\
resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' existing = {{\n\
\x20\x20name: '{cosmos_name}'\n\
}}\n\
\n\
resource search 'Microsoft.Search/searchServices@2024-03-01-preview' existing = {{\n\
\x20\x20name: '{search_name}'\n\
}}\n\
\n\
resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' existing = {{\n\
\x20\x20name: '{kv_name}'\n\
}}\n\
\n\
resource cae 'Microsoft.App/managedEnvironments@2024-03-01' existing = {{\n\
\x20\x20name: '{cae_name}'\n\
}}\n\
\n\
resource appi 'Microsoft.Insights/components@2020-02-02' existing = {{\n\
\x20\x20name: '{appi_name}'\n\
}}\n\
\n\
{ai_resource_block}"
)
}
fn ai_account_name_from_endpoint(endpoint: &str) -> String {
endpoint
.strip_prefix("https://")
.or_else(|| endpoint.strip_prefix("http://"))
.unwrap_or(endpoint)
.split('.')
.next()
.unwrap_or("ai-account")
.to_string()
}
fn render_cosmos_internals(config: &Config) -> String {
let database_block = cosmos_database_block(&config.cosmos.database);
let container_blocks = cosmos_container_blocks(config);
format!(
"// ─────────────────────────────────────────────────────────────────\n\
// Cosmos database + containers (managed by Quelch)\n\
// ─────────────────────────────────────────────────────────────────\n\
\n\
{database_block}\n\
{container_blocks}"
)
}
fn cosmos_database_block(database: &str) -> String {
format!(
"\
resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2024-05-15' = {{
parent: cosmos
name: '{database}'
properties: {{
resource: {{
id: '{database}'
}}
options: {{}}
}}
}}
"
)
}
fn cosmos_container_blocks(config: &Config) -> String {
let c = &config.cosmos.containers;
let meta = &config.cosmos.meta_container;
let containers = [
(c.jira_issues.as_str(), "/id"),
(c.confluence_pages.as_str(), "/id"),
(c.jira_sprints.as_str(), "/id"),
(c.jira_fix_versions.as_str(), "/id"),
(c.jira_projects.as_str(), "/id"),
(c.confluence_spaces.as_str(), "/id"),
(meta.as_str(), "/id"),
];
let mut out = String::new();
for (i, (name, partition_key)) in containers.iter().enumerate() {
let resource_ident = sanitize_resource_ident(name);
out.push_str(&cosmos_container_block(
&resource_ident,
name,
partition_key,
));
if i < containers.len() - 1 {
out.push('\n');
}
}
out
}
fn cosmos_container_block(
resource_ident: &str,
container_name: &str,
partition_key: &str,
) -> String {
format!(
"\
resource {resource_ident} 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-05-15' = {{
parent: cosmosDb
name: '{container_name}'
properties: {{
resource: {{
id: '{container_name}'
partitionKey: {{
paths: [
'{partition_key}'
]
kind: 'Hash'
}}
}}
options: {{}}
}}
}}
"
)
}
fn render_per_deployment(
config: &Config,
deployment: &DeploymentConfig,
prefix: &str,
env: &str,
) -> String {
let identity = managed_identity_block(&deployment.name, prefix, env);
let role_assignments = if config.azure.skip_role_assignments {
"// Role assignments skipped (azure.skip_role_assignments = true).\n".to_string()
} else {
role_assignment_blocks()
};
let app = container_app_block(config, deployment, prefix, env);
format!(
"// ─────────────────────────────────────────────────────────────────\n\
// Per-deployment: {name}\n\
// ─────────────────────────────────────────────────────────────────\n\
\n\
{identity}\n\
{role_assignments}\n\
{app}",
name = deployment.name,
)
}
fn managed_identity_block(deployment_name: &str, prefix: &str, env: &str) -> String {
format!(
"\
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {{
name: '{prefix}-{env}-{deployment_name}-id'
location: location
}}
"
)
}
fn role_assignment_blocks() -> String {
"\
// Cosmos DB Built-in Data Contributor — read/write to Cosmos containers.
var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002'
resource cosmosRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-05-15' = {
parent: cosmos
name: guid(cosmos.id, identity.id, cosmosDataContributorRoleId)
properties: {
roleDefinitionId: '${cosmos.id}/sqlRoleDefinitions/${cosmosDataContributorRoleId}'
principalId: identity.properties.principalId
scope: cosmos.id
}
}
// Search Index Data Contributor — push documents to and query AI Search.
var searchIndexDataContributorId = '8ebe5a00-799e-43f5-93ac-243d3dce84a7'
resource searchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(search.id, identity.id, searchIndexDataContributorId)
scope: search
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', searchIndexDataContributorId)
principalId: identity.properties.principalId
principalType: 'ServicePrincipal'
}
}
// Key Vault Secrets User — read secrets (MCP API key, source PATs).
var kvSecretsUserId = '4633458b-17de-408a-b874-0445c86b69e6'
resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(kv.id, identity.id, kvSecretsUserId)
scope: kv
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvSecretsUserId)
principalId: identity.properties.principalId
principalType: 'ServicePrincipal'
}
}
// Cognitive Services User — call embedding + chat models on the AI provider.
var cogServicesUserId = 'a97b65f3-24c7-4388-baec-2e87135dc908'
resource aiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(aiAccount.id, identity.id, cogServicesUserId)
scope: aiAccount
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cogServicesUserId)
principalId: identity.properties.principalId
principalType: 'ServicePrincipal'
}
}
"
.to_string()
}
fn container_app_block(
config: &Config,
deployment: &DeploymentConfig,
prefix: &str,
env: &str,
) -> String {
let name = &deployment.name;
let image = format!("{IMAGE_BASE}:{QUELCH_VERSION}");
let args = match deployment.role {
DeploymentRole::Ingest => format!("['ingest', '--deployment', '{name}']"),
DeploymentRole::Mcp => format!("['mcp', '--deployment', '{name}']"),
};
let spec = deployment
.azure
.as_ref()
.map(|a| a.container_app.clone())
.unwrap_or_default();
let cpu = spec.cpu.unwrap_or(0.5);
let memory = spec.memory.clone().unwrap_or_else(|| "1.0Gi".to_string());
let min_replicas = spec.min_replicas.unwrap_or(1);
let max_replicas = spec.max_replicas.unwrap_or(1);
let secrets_block = render_secrets_block(config, deployment);
let env_vars_block = render_env_vars_block(config, deployment);
format!(
"\
resource app 'Microsoft.App/containerApps@2024-03-01' = {{
name: '{prefix}-{env}-{name}'
location: location
identity: {{
type: 'UserAssigned'
userAssignedIdentities: {{
'${{identity.id}}': {{}}
}}
}}
properties: {{
managedEnvironmentId: cae.id
configuration: {{
secrets: [
{secrets_block}\
]
}}
template: {{
containers: [
{{
image: '{image}'
name: 'quelch'
command: [
'quelch'
]
args: {args}
resources: {{
cpu: json('{cpu}')
memory: '{memory}'
}}
env: [
{env_vars_block}\
]
}}
]
scale: {{
minReplicas: {min_replicas}
maxReplicas: {max_replicas}
}}
}}
}}
}}
"
)
}
fn render_secrets_block(config: &Config, deployment: &DeploymentConfig) -> String {
let kv_name = kv_name(&config.azure);
let kv_prefix = format!("https://{kv_name}.vault.azure.net/secrets");
let indent = " ";
let mut entries: Vec<String> = Vec::new();
match deployment.role {
DeploymentRole::Mcp => {
entries.push(format!(
"{indent}// TODO: populate quelch-mcp-api-key at deploy time.\n\
{indent}{{ name: 'mcp-api-key', keyVaultUrl: '{kv_prefix}/quelch-mcp-api-key', identity: identity.id }}"
));
}
DeploymentRole::Ingest => {
for ds in deployment.sources.iter().flatten() {
if let Some(source) = config.sources.iter().find(|s| s.name() == ds.source) {
let kv_key = match source {
crate::config::SourceConfig::Jira(j) => {
format!("{}-pat", j.name.to_lowercase().replace(' ', "-"))
}
crate::config::SourceConfig::Confluence(c) => {
format!("{}-pat", c.name.to_lowercase().replace(' ', "-"))
}
};
entries.push(format!(
"{indent}// TODO: populate {kv_key} at deploy time.\n\
{indent}{{ name: '{kv_key}', keyVaultUrl: '{kv_prefix}/{kv_key}', identity: identity.id }}"
));
}
}
}
}
if entries.is_empty() {
return String::new();
}
entries.join("\n") + "\n"
}
fn render_env_vars_block(_config: &Config, deployment: &DeploymentConfig) -> String {
let mut vars = vec![
" { name: 'COSMOS_ENDPOINT', value: cosmos.properties.documentEndpoint }".to_string(),
" { name: 'AZURE_SEARCH_ENDPOINT', value: 'https://${search.name}.search.windows.net' }".to_string(),
" { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appi.properties.ConnectionString }".to_string(),
];
match deployment.role {
DeploymentRole::Mcp => {
vars.push(
" { name: 'QUELCH_MCP_API_KEY', secretRef: 'mcp-api-key' }".to_string(),
);
}
DeploymentRole::Ingest => {
if let Some(sources) = &deployment.sources {
for ds in sources {
let secret_ref = format!("{}-pat", ds.source.to_lowercase().replace(' ', "-"));
let env_name =
format!("{}_PAT", ds.source.to_uppercase().replace(['-', ' '], "_"));
vars.push(format!(
" {{ name: '{env_name}', secretRef: '{secret_ref}' }}"
));
}
}
}
}
vars.join("\n") + "\n"
}
fn kv_name(azure: &AzureConfig) -> String {
let prefix = azure.naming.prefix.as_deref().unwrap_or("quelch");
let env = azure.naming.environment.as_deref().unwrap_or("prod");
azure
.resources
.key_vault
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-kv"))
}
fn sanitize_resource_ident(name: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for ch in name.chars() {
if ch == '-' || ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
if let Some(first) = result.chars().next().filter(|c| c.is_ascii_uppercase()) {
result = first.to_ascii_lowercase().to_string() + &result[1..];
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::*;
fn minimal_config() -> Config {
serde_yaml::from_str(
r#"
azure:
subscription_id: "sub-test"
resource_group: "rg-test"
region: "swedencentral"
naming:
prefix: "quelch"
environment: "prod"
ai:
provider: azure_openai
endpoint: "https://test.openai.azure.com"
embedding:
deployment: "text-embedding-3-large"
dimensions: 3072
chat:
deployment: "gpt-5-mini"
model_name: "gpt-5-mini"
sources:
- type: jira
name: jira-cloud
url: "https://example.atlassian.net"
auth:
email: "user@example.com"
api_token: "tok"
projects: ["DO"]
deployments:
- name: ingest
role: ingest
target: azure
sources:
- source: jira-cloud
- name: mcp
role: mcp
target: azure
expose: ["jira_issues"]
auth:
mode: "api_key"
"#,
)
.unwrap()
}
fn two_deployment_config() -> Config {
serde_yaml::from_str(
r#"
azure:
subscription_id: "sub-test"
resource_group: "rg-test"
region: "swedencentral"
naming:
prefix: "quelch"
environment: "prod"
cosmos:
account: "quelch-prod-cosmos"
database: "quelch"
search:
service: "quelch-prod-search"
sku: "standard"
ai:
provider: azure_openai
endpoint: "https://test.openai.azure.com"
embedding:
deployment: "text-embedding-3-large"
dimensions: 3072
chat:
deployment: "gpt-5-mini"
model_name: "gpt-5-mini"
sources:
- type: jira
name: jira-cloud
url: "https://example.atlassian.net"
auth:
email: "user@example.com"
api_token: "tok"
projects: ["DO"]
- type: confluence
name: confluence-cloud
url: "https://example.atlassian.net/wiki"
auth:
email: "user@example.com"
api_token: "tok"
spaces: ["ENG"]
deployments:
- name: ingest
role: ingest
target: azure
azure:
container_app:
cpu: 0.5
memory: "1.0Gi"
min_replicas: 1
max_replicas: 1
sources:
- source: jira-cloud
- source: confluence-cloud
- name: mcp
role: mcp
target: azure
azure:
container_app:
cpu: 1.0
memory: "2.0Gi"
min_replicas: 0
max_replicas: 5
expose: ["jira_issues"]
auth:
mode: "api_key"
"#,
)
.unwrap()
}
fn onprem_config() -> Config {
serde_yaml::from_str(
r#"
azure:
subscription_id: "sub-test"
resource_group: "rg-test"
region: "swedencentral"
ai:
provider: azure_openai
endpoint: "https://test.openai.azure.com"
embedding:
deployment: "text-embedding-3-large"
dimensions: 3072
chat:
deployment: "gpt-5-mini"
model_name: "gpt-5-mini"
sources:
- type: jira
name: jira-dc
url: "https://jira.internal.example"
auth:
pat: "my-pat"
projects: ["INT"]
deployments:
- name: ingest-onprem
role: ingest
target: onprem
sources:
- source: jira-dc
"#,
)
.unwrap()
}
#[test]
fn generates_minimal_bicep_for_ingest_deployment() {
let cfg = minimal_config();
let bicep = generate(&cfg, "ingest").unwrap();
insta::assert_snapshot!(bicep);
}
#[test]
fn generates_minimal_bicep_for_mcp_deployment() {
let cfg = minimal_config();
let bicep = generate(&cfg, "mcp").unwrap();
insta::assert_snapshot!(bicep);
}
#[test]
fn generates_bicep_for_two_deployments() {
let cfg = two_deployment_config();
let ingest_bicep = generate(&cfg, "ingest").unwrap();
let mcp_bicep = generate(&cfg, "mcp").unwrap();
insta::assert_snapshot!("two_deployments_ingest", ingest_bicep);
insta::assert_snapshot!("two_deployments_mcp", mcp_bicep);
}
#[test]
fn generates_onprem_has_no_container_app() {
let cfg = onprem_config();
let bicep = generate(&cfg, "ingest-onprem").unwrap();
assert!(
!bicep.contains("Microsoft.App/containerApps"),
"onprem deployment should not contain a Container App resource"
);
assert!(
!bicep.contains("Microsoft.ManagedIdentity"),
"onprem deployment should not contain a managed identity"
);
insta::assert_snapshot!(bicep);
}
#[test]
fn error_when_deployment_not_found() {
let cfg = minimal_config();
let err = generate(&cfg, "does-not-exist").unwrap_err();
assert!(matches!(err, BicepError::DeploymentNotFound(_)));
assert!(err.to_string().contains("does-not-exist"));
}
#[test]
fn sanitize_resource_ident_works() {
assert_eq!(sanitize_resource_ident("jira-issues"), "jiraIssues");
assert_eq!(sanitize_resource_ident("quelch-meta"), "quelchMeta");
assert_eq!(
sanitize_resource_ident("confluence-spaces"),
"confluenceSpaces"
);
}
#[test]
fn ingest_bicep_contains_cosmos_endpoint_env_var() {
let cfg = minimal_config();
let bicep = generate(&cfg, "ingest").unwrap();
assert!(
bicep.contains("COSMOS_ENDPOINT"),
"ingest bicep must reference COSMOS_ENDPOINT env var"
);
}
#[test]
fn mcp_bicep_contains_mcp_api_key_secret() {
let cfg = minimal_config();
let bicep = generate(&cfg, "mcp").unwrap();
assert!(
bicep.contains("mcp-api-key"),
"mcp bicep must reference mcp-api-key secret"
);
}
#[test]
fn skip_role_assignments_omits_role_blocks() {
let yaml = r#"
azure:
subscription_id: "sub-test"
resource_group: "rg-test"
region: "swedencentral"
skip_role_assignments: true
ai:
provider: azure_openai
endpoint: "https://test.openai.azure.com"
embedding:
deployment: "text-embedding-3-large"
dimensions: 3072
chat:
deployment: "gpt-5-mini"
model_name: "gpt-5-mini"
sources:
- type: jira
name: jira-cloud
url: "https://example.atlassian.net"
auth:
email: "user@example.com"
api_token: "tok"
projects: ["DO"]
deployments:
- name: ingest
role: ingest
target: azure
sources:
- source: jira-cloud
"#;
let cfg: Config = serde_yaml::from_str(yaml).unwrap();
let bicep = generate(&cfg, "ingest").unwrap();
assert!(
!bicep.contains("Microsoft.Authorization/roleAssignments"),
"skip_role_assignments should suppress role assignment resources"
);
}
}