use azure_core::auth::TokenCredential;
use azure_identity::AzureCliCredential;
use azure_mgmt_resources::{
models::ResourceGroup, package_resources_2021_04::Client as ResourcesClient,
};
use azure_mgmt_web::package_2024_04::{
models::{
app_service_plan, site, AppServicePlan, NameValuePair, Resource, Site, SiteConfig,
SkuDescription,
},
Client as WebSiteManagementClient,
};
use std::marker::PhantomData;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
use crate::errors::{DeployError, Result};
use crate::resilience::reliable_op;
pub struct Init;
pub struct Authenticated {
creds: Arc<dyn TokenCredential>,
}
pub struct InfraReady {
creds: Arc<dyn TokenCredential>,
}
pub struct ArtifactReady {
_creds: Arc<dyn TokenCredential>,
zip_path: PathBuf,
}
pub struct Live;
#[derive(Clone, Debug)]
pub struct SubscriptionId(pub String);
impl std::fmt::Display for SubscriptionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct AppName(String);
impl AppName {
pub fn resource_group(&self) -> ResourceGroupName {
ResourceGroupName(format!("{}-rg", self.0))
}
}
impl std::fmt::Display for AppName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct ResourceGroupName(String);
impl std::fmt::Display for ResourceGroupName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct Location(pub String);
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub sub_id: SubscriptionId,
pub app_name: AppName,
pub rg_name: ResourceGroupName,
pub location: Location,
pub binary_name: String,
}
pub struct Pipeline<State> {
config: Config,
state: State,
_marker: PhantomData<State>,
}
impl Pipeline<Init> {
pub fn new() -> Result<Self> {
let sub_id = std::env::var("AZURE_SUBSCRIPTION_ID")
.map_err(|_| DeployError::Config("Missing AZURE_SUBSCRIPTION_ID".into()))?;
let app_name = AppName(
std::env::var("APP_NAME").unwrap_or_else(|_| "rust-enterprise-api".to_string()),
);
which::which("az")
.map_err(|_| DeployError::Dependency("Azure CLI ('az') not found".into()))?;
which::which("cargo")
.map_err(|_| DeployError::Dependency("Rust Toolchain ('cargo') not found".into()))?;
let config = Config {
rg_name: app_name.resource_group(),
app_name,
sub_id: SubscriptionId(sub_id),
location: Location(std::env::var("AZURE_LOCATION").unwrap_or_else(|_| "eastus".into())),
binary_name: "server".to_string(),
};
info!(app = %config.app_name, location = %config.location, "Pipeline initialised");
Ok(Self {
config,
state: Init,
_marker: PhantomData,
})
}
pub async fn authenticate(self) -> Result<Pipeline<Authenticated>> {
info!("🔐 [1/4] Authenticating via Azure CLI...");
let creds = reliable_op("Auth Handshake", || async {
let credential = AzureCliCredential::new();
credential
.get_token(&["https://management.azure.com/.default"])
.await
.map_err(DeployError::Auth)?;
Ok(Arc::new(credential) as Arc<dyn TokenCredential>)
})
.await?;
Ok(Pipeline {
config: self.config,
state: Authenticated { creds },
_marker: PhantomData,
})
}
}
impl Pipeline<Authenticated> {
pub async fn ensure_infrastructure(self) -> Result<Pipeline<InfraReady>> {
info!("🏗️ [2/4] Converging Infrastructure...");
let cfg = self.config.clone();
let creds = self.state.creds.clone();
reliable_op("Azure Provisioning (Group)", || {
let cfg = cfg.clone();
let creds = creds.clone();
async move {
ensure_resource_group(&cfg, creds).await
}
})
.await?;
reliable_op("Azure Provisioning (Plan)", || {
let cfg = cfg.clone();
let creds = creds.clone();
async move {
let plan_id = ensure_app_service_plan(&cfg, creds).await?;
Ok(plan_id)
}
})
.await?;
let plan_id = format!(
"/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/serverfarms/{}-plan",
cfg.sub_id, cfg.rg_name, cfg.app_name
);
reliable_op("Azure Provisioning (App)", || {
let cfg = cfg.clone();
let creds = creds.clone();
let plan_id = plan_id.clone();
async move {
ensure_web_app(&cfg, creds, &plan_id).await
}
})
.await?;
Ok(Pipeline {
config: self.config,
state: InfraReady {
creds: self.state.creds,
},
_marker: PhantomData,
})
}
}
async fn ensure_resource_group(cfg: &Config, creds: Arc<dyn TokenCredential>) -> Result<()> {
let resources_client = ResourcesClient::new(
azure_core::Url::parse("https://management.azure.com").expect("Invalid Azure URL"),
creds,
vec!["https://management.azure.com/.default".to_string()],
azure_core::ClientOptions::default(),
);
let rg_client = resources_client.resource_groups_client();
if rg_client.get(cfg.rg_name.to_string(), cfg.sub_id.to_string()).await.is_err() {
let rg = ResourceGroup::new(cfg.location.to_string());
rg_client
.create_or_update(cfg.rg_name.to_string(), rg, cfg.sub_id.to_string())
.await
.map_err(|e| DeployError::Infra(format!("RG Error: {e}")))?;
}
Ok(())
}
async fn ensure_app_service_plan(cfg: &Config, creds: Arc<dyn TokenCredential>) -> Result<String> {
let web_client = WebSiteManagementClient::new(
azure_core::Url::parse("https://management.azure.com").expect("Invalid Azure URL"),
creds,
vec!["https://management.azure.com/.default".to_string()],
azure_core::ClientOptions::default(),
);
let plan_name = format!("{}-plan", cfg.app_name);
let resource = Resource::new(cfg.location.to_string());
let mut plan = AppServicePlan::new(resource);
plan.resource.kind = Some("linux".into());
plan.sku = Some(SkuDescription {
name: Some("B1".into()),
tier: Some("Basic".into()),
..Default::default()
});
plan.properties = Some(app_service_plan::Properties {
reserved: Some(true), ..Default::default()
});
let response = web_client
.app_service_plans_client()
.create_or_update(cfg.rg_name.to_string(), &plan_name, plan, cfg.sub_id.to_string())
.send()
.await
.map_err(|e| DeployError::Infra(format!("Plan Error: {e}")))?;
let status = response.into_raw_response().status();
if !status.is_success() {
return Err(DeployError::Infra(format!(
"HTTP {status} from App Service Plan API"
)));
}
Ok(format!(
"/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/serverfarms/{}",
cfg.sub_id, cfg.rg_name, plan_name
))
}
async fn ensure_web_app(
cfg: &Config,
creds: Arc<dyn TokenCredential>,
plan_id: &str,
) -> Result<()> {
let web_client = WebSiteManagementClient::new(
azure_core::Url::parse("https://management.azure.com").expect("Invalid Azure URL"),
creds,
vec!["https://management.azure.com/.default".to_string()],
azure_core::ClientOptions::default(),
);
let resource = Resource::new(cfg.location.to_string());
let mut site = Site::new(resource);
site.resource.kind = Some("app,linux".into());
let site_config = SiteConfig {
linux_fx_version: Some("DOTNETCORE|8.0".into()),
app_command_line: Some("sh startup.sh".into()),
app_settings: vec![
NameValuePair {
name: Some("WEBSITES_PORT".into()),
value: Some("8080".into()),
},
NameValuePair {
name: Some("WEBSITE_RUN_FROM_PACKAGE".into()),
value: Some("1".into()),
},
],
..Default::default()
};
site.properties = Some(site::Properties {
server_farm_id: Some(plan_id.to_string()),
site_config: Some(site_config),
https_only: Some(true),
..Default::default()
});
let response = web_client
.web_apps_client()
.create_or_update(cfg.rg_name.to_string(), cfg.app_name.to_string(), site, cfg.sub_id.to_string())
.send()
.await
.map_err(|e| DeployError::Infra(format!("WebApp Error: {e}")))?;
let status = response.into_raw_response().status();
if !status.is_success() {
return Err(DeployError::Infra(format!(
"HTTP {status} from Web App API"
)));
}
Ok(())
}
impl Pipeline<InfraReady> {
pub async fn build_and_package(self) -> Result<Pipeline<ArtifactReady>> {
info!("📦 [3/4] Building Release...");
let cfg = self.config.clone();
reliable_op("Cargo Build", || async {
let status = tokio::process::Command::new("cross")
.args([
"build",
"--release",
"--target",
"x86_64-unknown-linux-musl",
])
.status()
.await
.map_err(|e| DeployError::Io {
source: e,
context: "Spawning cross".into(),
})?;
if !status.success() {
return Err(DeployError::Build(
"Compilation failed (ensure target is valid)".into(),
));
}
Ok(())
})
.await?;
let zip_path = PathBuf::from("deploy.zip");
let zip_clone = zip_path.clone();
let bin_name = cfg.binary_name.clone();
let bin_path = PathBuf::from(format!(
"target/x86_64-unknown-linux-musl/release/{}",
cfg.binary_name
));
if !bin_path.exists() {
return Err(DeployError::Build(format!(
"Binary not found at {}",
bin_path.display()
)));
}
info!(" Compressing Artifact (Off-thread)...");
compress_artifact(zip_clone.clone(), bin_path, bin_name).await?;
Ok(Pipeline {
config: self.config,
state: ArtifactReady {
_creds: self.state.creds,
zip_path,
},
_marker: PhantomData,
})
}
}
async fn compress_artifact(zip_path: PathBuf, bin_path: PathBuf, bin_name: String) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> {
use std::io::Write;
let file = std::fs::File::create(&zip_path).map_err(|e| DeployError::Io {
source: e,
context: "Creating zip file".into(),
})?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
zip.start_file(&bin_name, options)?;
let data = std::fs::read(&bin_path).map_err(|e| DeployError::Io {
source: e,
context: "Reading binary".into(),
})?;
zip.write_all(&data).map_err(|e| DeployError::Io {
source: e,
context: "Writing to zip".into(),
})?;
zip.start_file("startup.sh", options)?;
let startup_script = std::fs::read("startup.sh").map_err(|e| DeployError::Io {
source: e,
context: "Reading startup.sh".into(),
})?;
zip.write_all(&startup_script)
.map_err(|e| DeployError::Io {
source: e,
context: "Writing startup.sh to zip".into(),
})?;
zip.finish()?;
Ok(())
})
.await?
}
impl Pipeline<ArtifactReady> {
pub async fn deploy_and_verify(self) -> Result<Pipeline<Live>> {
info!("🚀 [4/4] Deploying to Azure...");
let cfg = &self.config;
let zip_path = &self.state.zip_path;
reliable_op("CLI ZipDeploy", || async {
let path_str = zip_path
.to_str()
.ok_or(DeployError::PathEncoding(format!("{}", zip_path.display())))?;
let output = tokio::process::Command::new("az")
.args([
"webapp",
"deployment",
"source",
"config-zip",
"-g",
&cfg.rg_name.to_string(),
"-n",
&cfg.app_name.to_string(),
"--src",
path_str,
])
.output()
.await
.map_err(|e| DeployError::Io {
source: e,
context: "Spawning az CLI".into(),
})?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(DeployError::Cli(err.to_string()));
}
Ok(())
})
.await?;
info!("🏥 Verifying Health...");
let url = format!("https://{}.azurewebsites.net/health", cfg.app_name);
let client = reqwest::Client::new();
reliable_op("Health Check", || async {
let resp = client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(DeployError::Health(format!("Status: {}", resp.status())))
}
})
.await?;
Ok(Pipeline {
config: self.config.clone(),
state: Live,
_marker: PhantomData,
})
}
}