mod certificates;
mod connection;
mod deployment;
mod gcloud;
mod hub;
mod identity;
mod store;
use std::{future::Future, path::PathBuf, time::Duration};
use aegis_dto::{NamespaceId, NamespaceRole};
use anyhow::{Context, Result, ensure};
use clap::{Args, Parser, Subcommand, ValueEnum};
use phylax_core::OAuthAuthorizationCodeGrantRequest;
use phylax_gcp::identity::UserRecord;
use aegis_tool::{
client::{self, login},
ui,
};
use connection::Connection;
use deployment::Deployment;
#[derive(Debug, Parser)]
#[command(
name = "aegis-admin",
version,
about = "Deploy and administer Aegis with local GCP credentials."
)]
struct AdminCli {
#[command(flatten)]
ui: aegis_tool::ui::UiArgs,
#[command(subcommand)]
command: AdminCommand,
}
fn main() -> capulus::CliTermination {
let args = AdminCli::parse();
if let Err(error) = ui::init(args.ui.options()) {
return capulus::CliTermination::without_ui(Err(error));
}
let result = run(args.command);
let result = ui::check_cancelled().and(result);
capulus::CliTermination::with_ui(ui::current(), result)
}
#[derive(Debug, Subcommand)]
enum AdminCommand {
Connect {
#[command(flatten)]
project: ProjectArgs,
#[arg(long)]
database: String,
namespace: NamespaceId,
},
Setup(deployment::SetupArgs),
Configure(deployment::ConfigureArgs),
Deploy {
#[arg(long)]
project: Option<String>,
#[arg(long)]
image: String,
},
Doctor(ProjectArgs),
Authorize {
#[command(flatten)]
project: ProjectArgs,
#[arg(long)]
target_namespace: Option<NamespaceId>,
#[arg(long, value_enum, default_value = "member")]
role: Role,
#[arg(long)]
remote_auth: bool,
},
User {
#[command(flatten)]
project: ProjectArgs,
#[command(subcommand)]
command: UserCommand,
},
NamespaceTemplate,
NamespaceCreate {
#[command(flatten)]
project: ProjectArgs,
name: NamespaceId,
#[arg(long)]
config: PathBuf,
#[arg(long)]
tls_dns_suffix: String,
},
}
#[derive(Debug, Args)]
struct ProjectArgs {
#[arg(long)]
project: Option<String>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum Role {
Member,
Admin,
}
impl From<Role> for NamespaceRole {
fn from(value: Role) -> Self {
match value {
Role::Member => Self::Member,
Role::Admin => Self::Admin,
}
}
}
#[derive(Debug, Subcommand)]
enum UserCommand {
List,
Enable {
user: String,
},
Disable {
user: String,
},
Grant {
user: String,
namespace: NamespaceId,
#[arg(value_enum)]
role: Role,
},
Revoke {
user: String,
namespace: NamespaceId,
},
Members {
namespace: NamespaceId,
},
}
fn run(command: AdminCommand) -> Result<i32> {
match command {
AdminCommand::Connect {
project,
database,
namespace,
} => Connection::attach(project.project, database, namespace)?,
AdminCommand::Setup(args) => deployment::setup(args)?,
AdminCommand::Configure(args) => deployment::configure_external(args)?,
AdminCommand::Deploy { project, image } => {
let mut deployment = Deployment::load(project)?;
let _lock = aegis_tool::client::deployment_lock(&deployment.config.project)?;
deployment.deploy(&image)?;
}
AdminCommand::Doctor(args) => Deployment::load(args.project)?.doctor()?,
AdminCommand::Authorize {
project,
target_namespace,
role,
remote_auth,
} => {
let connection = Connection::load(project.project)?;
authorize(
&connection,
target_namespace.as_ref().unwrap_or(&connection.namespace),
role.into(),
remote_auth,
)?;
}
AdminCommand::User { project, command } => {
let connection = Connection::load(project.project)?;
let admin = connection.open()?;
let value = database("Updating account access", async {
Ok(match command {
UserCommand::List => serde_json::to_value(admin.users().await?)?,
UserCommand::Members { namespace } => admin.members(namespace).await?,
UserCommand::Enable { user } => {
admin.set_user_disabled(&user, false).await?;
serde_json::json!({"user":user,"disabled":false})
}
UserCommand::Disable { user } => {
admin.set_user_disabled(&user, true).await?;
serde_json::json!({"user":user,"disabled":true})
}
UserCommand::Grant {
user,
namespace,
role,
} => {
admin
.set_membership(namespace, &user, Some(role.into()))
.await?;
serde_json::json!({"user":user})
}
UserCommand::Revoke { user, namespace } => {
admin.set_membership(namespace, &user, None).await?;
serde_json::json!({"user":user})
}
})
})?;
println!("{}", serde_json::to_string_pretty(&value)?);
}
AdminCommand::NamespaceTemplate => println!(
"{}",
serde_json::to_string_pretty(&store::namespace_template())?
),
AdminCommand::NamespaceCreate {
project,
name,
config,
tls_dns_suffix,
} => {
let namespace = store::NamespaceOptions {
namespace: name,
configuration: serde_json::from_slice(&std::fs::read(config)?)?,
tls_dns_suffix,
}
.validate()?;
let connection = Connection::load(project.project)?;
let admin = connection.open()?;
database("Creating namespace", admin.create_namespace(namespace))?;
ui::success(
"Namespace and certificate authorities created; deploy a new API revision to load it",
);
}
}
Ok(0)
}
fn database<T>(label: &str, operation: impl Future<Output = Result<T>>) -> Result<T> {
let timeout = Duration::from_secs(120);
let task = ui::task(ui::TaskOptions {
label: label.into(),
deadline: Some(timeout),
..Default::default()
})?;
static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
if RUNTIME.get().is_none() {
let _ = RUNTIME.set(tokio::runtime::Runtime::new()?);
}
let runtime = RUNTIME.get().expect("runtime initialized");
let result = runtime.block_on(async {
tokio::select! {
result = tokio::time::timeout(timeout, operation) => result.context("Firestore operation timed out")?,
result = async {
loop {
if let Err(error) = ui::check_cancelled() { break Err(error); }
tokio::time::sleep(Duration::from_millis(100)).await;
}
} => result,
}
});
task.finish_and_clear();
result
}
fn authorize(
connection: &Connection,
namespace: &NamespaceId,
role: NamespaceRole,
remote: bool,
) -> Result<()> {
let endpoint = aegis_dto::namespace::ApiEndpoint::parse(&connection.endpoint)
.map_err(anyhow::Error::msg)?
.with_namespace(namespace.clone())
.base_url();
let proof = login::browser_proof(&endpoint, remote, Duration::from_secs(600))?;
let admin = connection.open()?;
let user_id = database("Authorizing verified account", async {
let identities = identity::store(&admin.db)?;
let login = identity::load_login(&admin.db).await?;
let settings = identities.load_config().await?;
let verified = identities
.auth_store(&settings, login.login_session_ttl_seconds)?
.verify_identity_proof(OAuthAuthorizationCodeGrantRequest {
code: &proof.code,
client_id: "aegis-tool",
redirect_uri: proof.callback.as_str(),
code_verifier: &proof.verifier,
now_unix: client::now_unix(),
})
.await?
.context("Browser identity proof expired or failed verification; sign in again")?;
let user = match identities
.authorize(&login.issuer_url, &verified.provider_sub)
.await?
{
Some(user) => {
ensure!(
!user.disabled,
"account {} is disabled; enable it explicitly before authorizing it",
user.id
);
user
}
None => {
let user = UserRecord {
id: uuid::Uuid::new_v4().to_string(),
email: verified.principal,
disabled: false,
session_version: 1,
};
admin.add_user(user.clone(), verified.provider_sub).await?;
user
}
};
admin
.set_membership(namespace.clone(), &user.id, Some(role))
.await
.context("Account is retained; namespace membership was not confirmed")?;
Ok(user.id)
})?;
proof.finish(&endpoint).with_context(|| format!(
"Account {user_id} and namespace membership are committed; run `aegis --api-base {endpoint} manage login` to retry sign-in"
))?;
client::UserContext { api_base: endpoint }.persist()?;
Ok(())
}