use crate::config::{
Config, CosmosConfig, DeploymentConfig, DeploymentRole, DeploymentTarget, SearchConfig,
};
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 shared = render_shared_infrastructure(config, prefix, env);
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\
{params}\n\
{shared}\n\
{per_deployment}",
name = deployment.name,
version = QUELCH_VERSION,
params = render_parameters(),
shared = shared,
per_deployment = per_deployment,
)
}
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
@description('Azure OpenAI endpoint (pre-existing resource, not managed by this template)')
param openAiEndpoint string
"
.to_string()
}
fn render_shared_infrastructure(config: &Config, prefix: &str, env: &str) -> String {
let cosmos = cosmos_account_block(&config.cosmos, prefix, env);
let cosmos_db = cosmos_database_block(prefix, env, &config.cosmos.database);
let containers = cosmos_container_blocks(config, prefix, env);
let search = ai_search_block(&config.search, prefix, env);
let kv = key_vault_block(prefix, env);
let cae = container_apps_env_block(prefix, env);
format!(
"// ─────────────────────────────────────────────────────────────────\n\
// Shared infrastructure\n\
// ─────────────────────────────────────────────────────────────────\n\
\n\
{cosmos}\n\
{cosmos_db}\n\
{containers}\n\
{search}\n\
{kv}\n\
{cae}"
)
}
fn cosmos_account_block(cosmos: &CosmosConfig, prefix: &str, env: &str) -> String {
let account_name = cosmos
.account
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-cosmos"));
format!(
"\
resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = {{
name: '{account_name}'
location: location
kind: 'GlobalDocumentDB'
properties: {{
databaseAccountOfferType: 'Standard'
locations: [
{{
locationName: location
failoverPriority: 0
isZoneRedundant: false
}}
]
consistencyPolicy: {{
defaultConsistencyLevel: 'Session'
}}
capabilities: []
enableFreeTier: false
enableAutomaticFailover: false
}}
}}
"
)
}
fn cosmos_database_block(_prefix: &str, _env: &str, 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, _prefix: &str, _env: &str) -> String {
let c = &config.cosmos.containers;
let meta = &config.cosmos.meta_container;
let _database = &config.cosmos.database;
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 ai_search_block(search: &SearchConfig, prefix: &str, env: &str) -> String {
let service_name = search
.service
.clone()
.unwrap_or_else(|| format!("{prefix}-{env}-search"));
let sku = &search.sku;
format!(
"\
resource search 'Microsoft.Search/searchServices@2024-03-01-preview' = {{
name: '{service_name}'
location: location
sku: {{
name: '{sku}'
}}
properties: {{
replicaCount: 1
partitionCount: 1
hostingMode: 'default'
publicNetworkAccess: 'enabled'
networkRuleSet: {{
ipRules: []
}}
encryptionWithCmk: {{
enforcement: 'Unspecified'
}}
disableLocalAuth: false
authOptions: {{
apiKeyOnly: {{}}
}}
semanticSearch: 'free'
}}
}}
"
)
}
fn key_vault_block(prefix: &str, env: &str) -> String {
format!(
"\
resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {{
name: '{prefix}-{env}-kv'
location: location
properties: {{
sku: {{
family: 'A'
name: 'standard'
}}
tenantId: subscription().tenantId
enabledForDeployment: false
enabledForDiskEncryption: false
enabledForTemplateDeployment: true
enableSoftDelete: true
softDeleteRetentionInDays: 90
enableRbacAuthorization: true
}}
}}
"
)
}
fn container_apps_env_block(prefix: &str, env: &str) -> String {
format!(
"\
resource cae 'Microsoft.App/managedEnvironments@2024-03-01' = {{
name: '{prefix}-{env}-cae'
location: location
properties: {{
zoneRedundant: false
workloadProfiles: [
{{
workloadProfileType: 'Consumption'
name: 'Consumption'
}}
]
}}
}}
"
)
}
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(&deployment.name, prefix, env)
};
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,
identity = identity,
role_assignments = role_assignments,
app = app,
)
}
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(_deployment_name: &str, _prefix: &str, _env: &str) -> String {
"\
// Cosmos DB Built-in Data Contributor — allows 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 — allows the worker to push documents to 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 — allows the Container App to read secrets from Key Vault.
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'
}
}
"
.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_prefix = format!(
"https://{prefix}-{env}-kv.vault.azure.net/secrets",
prefix = config.azure.naming.prefix.as_deref().unwrap_or("quelch"),
env = config.azure.naming.environment.as_deref().unwrap_or("prod"),
);
let indent = " ";
let mut entries: Vec<String> = vec![format!(
"{indent}// TODO: populate cosmos-key at deploy time from operator env or Key Vault.\n\
{indent}{{ name: 'cosmos-key', keyVaultUrl: '{kv_prefix}/cosmos-key', identity: identity.id }}"
)];
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 }}"
));
}
}
}
}
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: 'COSMOS_KEY', secretRef: 'cosmos-key' }".to_string(),
" { name: 'AZURE_SEARCH_ENDPOINT', value: 'https://${search.name}.search.windows.net' }".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 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"
openai:
endpoint: "https://test.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
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"
openai:
endpoint: "https://test.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
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"
openai:
endpoint: "https://test.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
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
openai:
endpoint: "https://test.openai.azure.com"
embedding_deployment: "text-embedding-3-large"
embedding_dimensions: 3072
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"
);
}
}