aegis-admin-tool 0.4.4

Deploy and administer an Aegis installation with local GCP credentials.
use std::time::{Duration, Instant};

use aegis_tool::ui;
use anyhow::{Context, Result, ensure};
use serde_json::Value;

use crate::gcloud::Gcloud;

const OPERATOR_WAIT: Duration = Duration::from_secs(15 * 60);

pub(super) fn prepare(project: Option<String>, interactive: bool) -> Result<Gcloud> {
    if let Some(project) = &project {
        Gcloud::new(project.clone())?;
    }
    Gcloud::global_json(&["version"]).map_err(|error| {
        if error.chain().any(|cause| {
            cause
                .downcast_ref::<std::io::Error>()
                .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
        }) {
            error.context(
                "Install the Google Cloud CLI: https://cloud.google.com/sdk/docs/install\n\
                 Open a new terminal so `gcloud` is on PATH, then rerun `aegis-admin setup`",
            )
        } else {
            error
        }
    })?;
    let mut account = active_account()?;
    if account.is_none() {
        ui::stage("Sign in to Google Cloud: run `gcloud auth login` in another terminal.");
        wait_for(
            "Google Cloud sign-in",
            "No active gcloud account",
            interactive,
            |_| {
                account = active_account()?;
                Ok(account.is_some())
            },
        )?;
    }
    let account = account.context("gcloud sign-in completed without an active account")?;
    ui::detail(&format!("GCP account: {account}"));
    Gcloud::global_json(&["auth", "print-access-token"]).context(
        "Could not use the active GCP credential. If sign-in has expired, run `gcloud auth login`, then rerun setup",
    )?;
    let project = match project {
        Some(project) => project,
        None => match Gcloud::active_project()? {
            Some(project) => project,
            None => {
                let project = select_project(&account, interactive)?;
                Gcloud::new(project.clone())?;
                Gcloud::global_json(&["config", "set", "project", &project])?;
                ui::detail(&format!("Saved GCP project: {project}"));
                project
            }
        },
    };
    Gcloud::new(project)
}

pub(super) fn check_project(cloud: &Gcloud, interactive: bool) -> Result<()> {
    ui::stage("Preparing GCP management APIs");
    let activation = cloud.json(&[
        "services",
        "enable",
        "serviceusage.googleapis.com",
        "cloudresourcemanager.googleapis.com",
        "cloudbilling.googleapis.com",
        "iamcredentials.googleapis.com",
    ]);
    if activation.as_ref().is_err_and(capulus::error_is_cancelled) {
        ui::warn(
            "GCP API activation was interrupted and may be partially applied. No Aegis resources were created in this run; rerun setup to verify and continue.",
        );
    }
    activation.with_context(|| format!(
        "Use a GCP account with provisioning access to project {}. Review access at https://console.cloud.google.com/iam-admin/iam?project={}. API activation may be partially applied; no Aegis resources were created in this run",
        cloud.project, cloud.project,
    ))?;
    let billing = check_billing(cloud, interactive);
    let retained = "GCP management APIs remain enabled; no Aegis resources were created in this run. Any billing changes are retained. Rerun `aegis-admin setup` to continue";
    if billing.as_ref().is_err_and(capulus::error_is_cancelled) {
        ui::warn(retained);
    }
    billing.context(retained)
}

fn active_account() -> Result<Option<String>> {
    let accounts = Gcloud::global_json(&["auth", "list", "--filter=status:ACTIVE"])?;
    let accounts = accounts
        .as_array()
        .context("gcloud returned an invalid account list")?;
    accounts
        .first()
        .map(|account| {
            account["account"]
                .as_str()
                .filter(|value| !value.is_empty())
                .map(str::to_owned)
                .context("gcloud returned an invalid active account")
        })
        .transpose()
}

fn select_project(account: &str, interactive: bool) -> Result<String> {
    require_interactive(
        interactive,
        "Choose a GCP project with `--project PROJECT_ID`, or run `gcloud config set project PROJECT_ID`, then rerun setup",
    )?;
    let projects = Gcloud::global_json(&["projects", "list", "--sort-by=projectId"])?;
    let projects = projects
        .as_array()
        .context("gcloud returned an invalid project list")?;
    let ids = projects
        .iter()
        .map(|project| {
            project["projectId"]
                .as_str()
                .context("gcloud returned a project without its ID")
        })
        .collect::<Result<Vec<_>>>()?;
    let mut choices = ids.clone();
    choices.push("Create a project / enter a project ID");
    let selected = ui::suspend(|| {
        dialoguer::Select::new()
            .with_prompt("GCP project")
            .items(&choices)
            .default(0)
            .max_length(10)
            .interact()
    })?;
    if let Some(project) = ids.get(selected) {
        return Ok((*project).into());
    }
    ui::stage(&format!(
        "Create a project at https://console.cloud.google.com/projectcreate using {account}, or enter an existing project ID."
    ));
    ui::suspend(|| {
        dialoguer::Input::<String>::new()
            .with_prompt("GCP project ID")
            .validate_with(|value: &String| Gcloud::new(value.clone()).map(|_| ()))
            .interact_text()
            .map_err(Into::into)
    })
}

fn check_billing(cloud: &Gcloud, interactive: bool) -> Result<()> {
    let args = ["billing", "projects", "describe", &cloud.project];
    if billing_enabled(&cloud.ready_json(&args)?)? {
        return Ok(());
    }
    let url = format!(
        "https://console.cloud.google.com/billing/linkedaccount?project={}",
        cloud.project
    );
    ui::stage(&format!(
        "Enable billing for {}: {url}\nLink a billing account in the Cloud Console. GCP charges this account for the API and hub VMs.",
        cloud.project,
    ));
    require_interactive(
        interactive,
        "Enable project billing at the URL above, then rerun `aegis-admin setup`",
    )?;
    ui::maybe_open_browser(&url);
    wait_for(
        "Project billing",
        "Billing is not enabled",
        interactive,
        |remaining| {
            billing_enabled(&serde_json::from_str(&cloud.run(
                &args,
                None,
                remaining.min(Duration::from_secs(30)),
            )?)?)
        },
    )
}

fn billing_enabled(value: &Value) -> Result<bool> {
    value["billingEnabled"]
        .as_bool()
        .context("gcloud returned no billing status")
}

fn require_interactive(interactive: bool, message: &str) -> Result<()> {
    ensure!(interactive, "{message}");
    ui::require_interactive(message)
}

fn wait_for(
    label: &str,
    state: &str,
    interactive: bool,
    mut check: impl FnMut(Duration) -> Result<bool>,
) -> Result<()> {
    require_interactive(
        interactive,
        "Complete the step above, then rerun `aegis-admin setup` in a terminal",
    )?;
    let task = ui::task(ui::TaskOptions {
        label: format!("Waiting for {label}"),
        deadline: Some(OPERATOR_WAIT),
        ..Default::default()
    })?;
    task.set_phase(state);
    let deadline = Instant::now() + OPERATOR_WAIT;
    loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        ensure!(
            !remaining.is_zero(),
            "{label} was not completed within 15 minutes; finish the step above and rerun setup"
        );
        ui::check_cancelled()?;
        if check(remaining)? {
            task.finish_and_clear();
            return Ok(());
        }
        ui::sleep(Duration::from_secs(3).min(deadline.saturating_duration_since(Instant::now())))?;
    }
}