use anyhow::{Result, anyhow, bail};
use colored::Colorize;
use rigg_core::binding::{BindingCache, EnvBindings};
use rigg_core::identity::{Edge, EdgeKind, Scope, graph_for_docs, operator_edges};
use rigg_core::resources::ResourceKind;
use rigg_core::service::ServiceDomain;
use rigg_core::workspace::{ResolvedEnv, Workspace};
use crate::cli::CiCommands;
use crate::commands::auth_engine;
use crate::commands::{CommandError, GlobalContext, load_workspace, resolve_env};
use crate::say;
const VALIDATE_YML: &str = include_str!("ci_templates/rigg-validate.yml");
const DEPLOY_YML: &str = include_str!("ci_templates/rigg-deploy.yml");
const DRIFT_YML: &str = include_str!("ci_templates/rigg-drift.yml");
pub async fn run(ctx: &GlobalContext, cmd: CiCommands) -> Result<()> {
match cmd {
CiCommands::Init { provider, force } => init(ctx, &provider, force).await,
}
}
pub struct RoleLine {
pub role: String,
pub role_id: String,
pub scope: String,
pub grant: bool,
}
impl RoleLine {
pub fn display(&self) -> String {
format!("{} # {}", self.role_id, self.role)
}
}
fn render_scope(scope: &Scope, env: &str) -> String {
match scope {
Scope::Resolved(id) => id.clone(),
Scope::Unresolved { .. } => {
format!(
"{} — resolve with `rigg env bind {env} --learn`",
scope.describe()
)
}
}
}
fn kinds_for(
env: &ResolvedEnv,
docs: &[(ResourceKind, String, serde_json::Value)],
) -> Vec<ResourceKind> {
if !docs.is_empty() {
let mut kinds: Vec<ResourceKind> = docs.iter().map(|(k, ..)| *k).collect();
kinds.sort_by_key(|k| k.directory_name());
kinds.dedup();
return kinds;
}
ResourceKind::all()
.iter()
.copied()
.filter(|k| match k.domain() {
ServiceDomain::Search => env.env.search.is_some(),
ServiceDomain::Foundry => env.env.foundry.is_some(),
})
.collect()
}
pub fn role_lines(ws: &Workspace, env: &ResolvedEnv) -> Vec<RoleLine> {
let cache = BindingCache::load(ws, &env.name);
let bindings = EnvBindings::of_env(&env.name, &env.env, Some(&cache));
let docs = auth_engine::env_documents(ws, &env.name);
let kinds = kinds_for(env, &docs);
let graph = graph_for_docs(&bindings, &docs);
let project_id = env.env.foundry.as_ref().and_then(|f| {
bindings
.get("foundry")
.and_then(|e| e.resolved.as_ref().and_then(|r| r.arm_id.clone()))
.map(|account| format!("{account}/projects/{}", f.project))
});
let grants: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Rbac)
.collect();
let (operator, _) = operator_edges(&bindings, &kinds, false, &grants, project_id.as_deref());
let mut lines: Vec<RoleLine> = operator
.iter()
.map(|e| RoleLine {
role: e.role.name.to_string(),
role_id: e.role.id.to_string(),
scope: render_scope(&e.scope, &env.name),
grant: false,
})
.collect();
for edge in grants {
let line = RoleLine {
role: edge.role.name.to_string(),
role_id: edge.role.id.to_string(),
scope: render_scope(&edge.scope, &env.name),
grant: true,
};
if !lines
.iter()
.any(|l| l.role == line.role && l.scope == line.scope && l.grant)
{
lines.push(line);
}
}
lines
}
async fn init(ctx: &GlobalContext, provider: &str, force: bool) -> Result<()> {
if provider != "github" {
return Err(anyhow!(CommandError::Usage(format!(
"unsupported CI provider '{provider}' (supported: github; Azure DevOps templates are documented in docs/)"
))));
}
let ws = load_workspace()?;
let resolved = resolve_env(&ws, ctx).ok();
let env = resolved
.as_ref()
.map(|e| e.name.clone())
.or_else(|| ws.default_env_name().map(str::to_string))
.unwrap_or_else(|| "dev".to_string());
let dir = ws.root.join(".github").join("workflows");
std::fs::create_dir_all(&dir)?;
let files = [
("rigg-validate.yml", VALIDATE_YML),
("rigg-deploy.yml", DEPLOY_YML),
("rigg-drift.yml", DRIFT_YML),
];
for (name, template) in files {
let path = dir.join(name);
if path.exists() && !force {
bail!(
"{} already exists — pass --force to overwrite",
path.display()
);
}
std::fs::write(&path, template.replace("{{RIGG_ENV}}", &env))?;
say!(ctx, " created {}", path.display());
}
say!(ctx);
say!(
ctx,
"{} GitHub workflows created for environment '{env}'. To finish setup:",
"✓".green().bold()
);
say!(
ctx,
" 1. Create an Entra app registration with federated credentials for this repo"
);
say!(
ctx,
" (workload identity federation — no client secrets):"
);
say!(ctx, " az ad app create --display-name rigg-deploy");
say!(
ctx,
" az ad app federated-credential create ... (subject: repo:<owner>/<repo>:ref:refs/heads/main)"
);
match resolved.as_ref() {
Some(env_ref) => {
let lines = role_lines(&ws, env_ref);
let (held, granted): (Vec<&RoleLine>, Vec<&RoleLine>) =
lines.iter().partition(|l| !l.grant);
say!(
ctx,
" 2. Grant it what '{env}' actually requires — from this workspace's files:"
);
if held.is_empty() {
say!(
ctx,
" (no resources yet — re-run after your first push)"
);
}
for line in &held {
say!(ctx, " {}", line.display().bold());
say!(ctx, " {}", line.scope.dimmed());
}
if !granted.is_empty() {
say!(
ctx,
" …and, so a push can grant the service identities their own roles,"
);
say!(
ctx,
" Microsoft.Authorization/roleAssignments/write at each scope below"
);
say!(ctx, " (with the role rigg would grant there):");
for line in &granted {
say!(ctx, " {}", line.display().bold());
say!(ctx, " {}", line.scope.dimmed());
}
say!(
ctx,
" (or pre-grant them yourself once: rigg auth doctor -e {env} --fix,"
);
say!(
ctx,
" and add --skip-auth-preflight to the deploy job's push)"
);
}
say!(
ctx,
" az role assignment create --assignee <AZURE_CLIENT_ID> --role <role-guid> --scope \"<scope>\""
);
say!(
ctx,
" Verify: rigg auth doctor -e {env} --principal <the CI identity's object id>"
);
}
None => {
say!(
ctx,
" 2. Grant it the roles this workspace requires — run `rigg auth doctor -e {env}`"
);
say!(
ctx,
" once the environment exists to see them with their exact scopes."
);
}
}
say!(
ctx,
" 3. Add repository variables: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID."
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_are_valid_yaml_with_placeholder() {
for (name, t) in [
("validate", VALIDATE_YML),
("deploy", DEPLOY_YML),
("drift", DRIFT_YML),
] {
assert!(t.contains("{{RIGG_ENV}}"), "{name} missing env placeholder");
let replaced = t.replace("{{RIGG_ENV}}", "prod");
let parsed: std::result::Result<serde_yaml::Value, _> = serde_yaml::from_str(&replaced);
assert!(parsed.is_ok(), "{name} is not valid YAML: {parsed:?}");
}
}
}