use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Args, Subcommand};
use serde::{de::DeserializeOwned, Serialize};
use sylphx_apps_api_types::{
CreateDeploymentRequest, CreateReleaseMember, CreateReleaseRequest, DeliveryPolicyPatch,
DeliveryRequestErrorDetail, GetDeliveryConfigurationRequest, GetDeliveryPolicyRequest,
GetDeploymentRequest, GetReleaseRequest, ListDeploymentsRequest, ListReleaseDecisionsRequest,
ListReleaseReceiptsRequest, ListReleasesRequest, Operation, OperationMode, OperationState,
PageRequest, PatchDeliveryPolicyRequest, PromoteReleaseRequest,
};
use sylphx_apps_sdk_core::SdkError;
use sylphx_apps_sdk_management::ManagementClient;
use crate::{context, ux};
#[derive(Args, Debug, Clone)]
pub(crate) struct ProjectScope {
#[arg(long = "project")]
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
}
impl ProjectScope {
async fn resolve(&self, client: &ManagementClient) -> anyhow::Result<(String, Option<String>)> {
let locator = context::resolve_project_id(self.project_id.as_deref())?;
let org = context::resolve_org_id(self.org_id.as_deref())?;
let project = client.get_project(&locator, org.as_deref()).await
.map_err(delivery_error)?;
anyhow::ensure!(!project.id.trim().is_empty(), "Project lookup returned no exact identity");
Ok((project.id, org))
}
}
#[derive(Args, Debug, Clone)]
pub(crate) struct EnvironmentScope {
#[command(flatten)]
project: ProjectScope,
#[arg(long)]
environment: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct PageArgs {
#[arg(long)]
page_token: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
page_size: Option<u32>,
}
impl PageArgs {
fn request(&self) -> Option<PageRequest> {
(self.page_token.is_some() || self.page_size.is_some()).then(|| PageRequest {
page_token: self.page_token.clone().unwrap_or_default(),
page_size: self.page_size.unwrap_or_default(),
..Default::default()
})
}
}
#[derive(Subcommand, Debug)]
pub(crate) enum DeploymentCommands {
List {
#[command(flatten)]
scope: ProjectScope,
#[arg(long)]
service: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Get {
deployment_id: String,
#[command(flatten)]
scope: ProjectScope,
},
Create {
#[command(flatten)]
scope: ProjectScope,
#[arg(long)]
service: String,
#[arg(long)]
completed_build: String,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
expected_artifact_digest: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum ReleasesCommands {
List {
#[command(flatten)]
scope: EnvironmentScope,
#[command(flatten)]
page: PageArgs,
},
Get {
release_id: String,
#[command(flatten)]
scope: EnvironmentScope,
},
Create {
#[command(flatten)]
scope: EnvironmentScope,
#[arg(long)]
policy_revision: String,
#[arg(long)]
previous_release: Option<String>,
#[arg(long, value_name = "FILE")]
members: PathBuf,
#[arg(long)]
idempotency_key: String,
},
Promote {
release_id: String,
#[command(flatten)]
scope: EnvironmentScope,
#[arg(long, value_parser = clap::value_parser!(i64).range(0..))]
expected_selection_generation: i64,
#[arg(long)]
expected_policy_revision: String,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
plan: bool,
},
Receipts {
release_id: String,
#[command(flatten)]
scope: EnvironmentScope,
#[command(flatten)]
page: PageArgs,
},
Decisions {
release_id: String,
#[command(flatten)]
scope: EnvironmentScope,
#[command(flatten)]
page: PageArgs,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum DeliveryCommands {
Configuration {
#[command(subcommand)]
command: ConfigurationCommands,
},
Policy {
#[command(subcommand)]
command: PolicyCommands,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum ConfigurationCommands {
Get {
#[command(flatten)]
scope: EnvironmentScope,
#[arg(long)]
service: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum PolicyCommands {
Get {
#[command(flatten)]
scope: EnvironmentScope,
},
Patch {
#[command(flatten)]
scope: EnvironmentScope,
#[arg(long)]
expected_policy_revision: String,
#[arg(long, value_parser = clap::value_parser!(i64).range(0..))]
expected_policy_version: i64,
#[arg(long, value_name = "FILE")]
patch: PathBuf,
#[arg(long)]
idempotency_key: String,
},
}
fn read_typed<T: DeserializeOwned>(path: &Path) -> anyhow::Result<T> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let mut deserializer = serde_json::Deserializer::from_slice(&bytes);
let mut ignored = Vec::new();
let value = serde_ignored::deserialize(&mut deserializer, |field| {
ignored.push(field.to_string());
})
.with_context(|| format!("decode delivery JSON {}", path.display()))?;
deserializer
.end()
.context("unexpected data after delivery JSON")?;
if !ignored.is_empty() {
anyhow::bail!(
"unknown delivery fields in {}: {}",
path.display(),
ignored.join(", ")
);
}
Ok(value)
}
fn emit<T: Serialize>(json: bool, value: &T) -> anyhow::Result<()> {
let rendered = serde_json::to_string_pretty(value)?;
ux::print_out(json, value, || println!("{rendered}"));
Ok(())
}
fn delivery_error(error: SdkError) -> anyhow::Error {
if let SdkError::Api {
status,
body: Some(body),
..
} = &error
{
if let Ok(detail) = serde_json::from_value::<DeliveryRequestErrorDetail>(body.clone()) {
if let Ok(rendered) = serde_json::to_string_pretty(&detail) {
return anyhow::anyhow!("delivery HTTP {status}: {rendered}");
}
}
}
ux::map_sdk_err(error)
}
fn emit_operation<T: Serialize>(
json: bool,
value: &T,
operation: Option<&Operation>,
) -> anyhow::Result<()> {
let operation = operation.context("delivery response omitted its Operation")?;
if operation.name.is_empty() {
anyhow::bail!("delivery response omitted its Operation identity");
}
emit(json, value)?;
if matches!(
operation.state.as_known(),
Some(OperationState::Failed | OperationState::Rejected | OperationState::Cancelled)
) {
anyhow::bail!(
"operation {} ended in {:?}",
operation.name,
operation.state
);
}
Ok(())
}
pub(crate) async fn deployments(
json: bool,
client: &ManagementClient,
command: &DeploymentCommands,
) -> anyhow::Result<()> {
match command {
DeploymentCommands::List {
scope,
service,
page,
} => {
let (project_id, org) = scope.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.list_deployments(ListDeploymentsRequest {
project_id,
service_id: service.clone(),
page: page.request().into(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
DeploymentCommands::Get {
scope,
deployment_id,
} => {
let (project_id, org) = scope.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.get_deployment(GetDeploymentRequest {
project_id,
deployment_id: deployment_id.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
DeploymentCommands::Create {
scope,
service,
completed_build,
idempotency_key,
expected_artifact_digest,
} => {
let (project_id, org) = scope.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.create_deployment(CreateDeploymentRequest {
project_id,
service_id: service.clone(),
completed_build_id: completed_build.clone(),
idempotency_key: idempotency_key.clone(),
expected_artifact_digest: expected_artifact_digest.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit_operation(json, &result, result.operation.as_option())
}
}
}
pub(crate) async fn releases(
json: bool,
client: &ManagementClient,
command: &ReleasesCommands,
) -> anyhow::Result<()> {
match command {
ReleasesCommands::List { scope, page } => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.list_releases(ListReleasesRequest {
project_id,
environment_id: scope.environment.clone(),
page: page.request().into(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
ReleasesCommands::Get { scope, release_id } => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.get_release(GetReleaseRequest {
project_id,
environment_id: scope.environment.clone(),
release_id: release_id.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
ReleasesCommands::Create {
scope,
policy_revision,
previous_release,
members,
idempotency_key,
} => {
let members: Vec<CreateReleaseMember> = read_typed(members)?;
if members.is_empty() {
anyhow::bail!("a Release must contain at least one member");
}
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.create_release(CreateReleaseRequest {
project_id,
environment_id: scope.environment.clone(),
policy_revision: policy_revision.clone(),
previous_release_id: previous_release.clone(),
members,
idempotency_key: idempotency_key.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit_operation(json, &result, result.operation.as_option())
}
ReleasesCommands::Promote {
scope,
release_id,
expected_selection_generation,
expected_policy_revision,
idempotency_key,
plan,
} => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.promote_release(PromoteReleaseRequest {
project_id,
environment_id: scope.environment.clone(),
release_id: release_id.clone(),
expected_selection_generation: *expected_selection_generation,
expected_policy_revision: expected_policy_revision.clone(),
idempotency_key: idempotency_key.clone(),
mode: if *plan {
OperationMode::Plan
} else {
OperationMode::Apply
}
.into(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit_operation(json, &result, result.operation.as_option())
}
ReleasesCommands::Receipts {
scope,
release_id,
page,
} => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.list_release_receipts(ListReleaseReceiptsRequest {
project_id,
environment_id: scope.environment.clone(),
release_id: release_id.clone(),
page: page.request().into(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
ReleasesCommands::Decisions {
scope,
release_id,
page,
} => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.list_release_decisions(ListReleaseDecisionsRequest {
project_id,
environment_id: scope.environment.clone(),
release_id: release_id.clone(),
page: page.request().into(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
}
}
pub(crate) async fn run(
json: bool,
client: &ManagementClient,
command: &DeliveryCommands,
) -> anyhow::Result<()> {
let command = match command {
DeliveryCommands::Configuration {
command: ConfigurationCommands::Get { scope, service },
} => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.get_delivery_configuration(GetDeliveryConfigurationRequest {
project_id,
environment_id: scope.environment.clone(),
service_id: service.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
return emit(json, &result);
}
DeliveryCommands::Policy { command } => command,
};
match command {
PolicyCommands::Get { scope } => {
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.get_delivery_policy(GetDeliveryPolicyRequest {
project_id,
environment_id: scope.environment.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit(json, &result)
}
PolicyCommands::Patch {
scope,
expected_policy_revision,
expected_policy_version,
patch,
idempotency_key,
} => {
let patch: DeliveryPolicyPatch = read_typed(patch)?;
let (project_id, org) = scope.project.resolve(client).await?;
let result = client
.delivery()
.with_organization(org.as_deref())
.patch_delivery_policy(PatchDeliveryPolicyRequest {
project_id,
environment_id: scope.environment.clone(),
expected_policy_revision: expected_policy_revision.clone(),
expected_policy_version: *expected_policy_version,
patch: patch.into(),
idempotency_key: idempotency_key.clone(),
..Default::default()
})
.await
.map_err(delivery_error)?;
emit_operation(json, &result, result.operation.as_option())
}
}
}