railwayapp 5.61.0

Interact with Railway via CLI
use std::collections::BTreeMap;

use anyhow::{Result, bail};
use clap::Parser;
use is_terminal::IsTerminal;
use serde_json::json;

use crate::{
    client::GQLClient,
    iac::ownership::{self, Preview},
    util::prompt::prompt_confirm_with_default,
};

const GUIDANCE: &str = "Update your authoring configuration and re-plan before applying it. Applying an old named-partial configuration can reclaim released ownership. Ordinary named-partial apply can restore ownership later. Whole-project planning requires all named ownership to be cleared; then remove the named partial export and create a fresh whole-project plan.";

#[derive(Parser)]
#[clap(
    after_help = "Uses the linked environment and does not require an authoring file. Release and transfer require environment ADMIN access and only change ownership metadata: resources are not changed, deleted, or redeployed.\n\nExamples:\n  railway config partials list\n  railway config partials transfer legacy-ops operations --resource service.api --resource service.admin --dry-run\n  railway config partials release operations --dry-run\n\nUpdate the authoring configuration and re-plan after changing ownership. Applying an old named-partial configuration can reclaim released ownership."
)]
pub(super) struct Args {
    #[clap(subcommand)]
    command: Command,
}

#[derive(Parser)]
enum Command {
    /// List partials and their owned resource addresses in the linked environment
    List(ListArgs),
    /// Release selected resources, or the entire partial when --resource is omitted
    Release(ReleaseArgs),
    /// Transfer selected resources, or the entire source when --resource is omitted
    Transfer(TransferArgs),
}

#[derive(Parser)]
struct ListArgs {
    /// Output the complete address-to-owner map as JSON
    #[clap(long)]
    json: bool,
}

#[derive(Parser)]
#[clap(after_help = GUIDANCE)]
struct ReleaseArgs {
    /// Named partial to release; use `railway config partials list` to discover names
    #[clap(value_parser = ownership::named_partial)]
    partial: String,
    #[clap(flatten)]
    options: ChangeArgs,
}

#[derive(Parser)]
#[clap(after_help = GUIDANCE)]
struct TransferArgs {
    /// Named partial that currently owns the resources
    #[clap(value_parser = ownership::named_partial)]
    from_partial: String,
    /// New or existing named partial to receive ownership
    #[clap(value_parser = ownership::named_partial)]
    to_partial: String,
    #[clap(flatten)]
    options: ChangeArgs,
}

#[derive(Parser)]
struct ChangeArgs {
    /// Exact owned address (for example service.api); repeat to select several. Omit for all.
    #[clap(long = "resource", value_name = "ADDRESS", action = clap::ArgAction::Append)]
    resources: Option<Vec<String>>,
    /// Preview exact affected addresses without changing ownership
    #[clap(long, conflicts_with = "yes")]
    dry_run: bool,
    /// Confirm the ownership change and proceed non-interactively
    #[clap(long)]
    yes: bool,
    /// Output JSON and proceed without prompting, as with `railway config apply --json`
    #[clap(long)]
    json: bool,
    /// Require the configEtag from `list`, or baseConfigEtag from a previous --dry-run
    #[clap(long, value_name = "ETAG")]
    base_config_etag: Option<String>,
}

pub(super) async fn command(args: Args) -> Result<()> {
    match args.command {
        Command::List(args) => list(args).await,
        Command::Release(args) => change(args.partial, None, args.options).await,
        Command::Transfer(args) => {
            change(args.from_partial, Some(args.to_partial), args.options).await
        }
    }
}

fn interactive(json: bool) -> bool {
    !json && std::io::stdout().is_terminal() && std::io::stdin().is_terminal()
}

async fn list(args: ListArgs) -> Result<()> {
    let (configs, linked, _, _) =
        super::runner::ensure_config_context_with_prompt(interactive(args.json)).await?;
    let client = GQLClient::new_authorized(&configs)?;
    let snapshot =
        ownership::fetch_snapshot(&client, &configs.get_backboard(), linked.environment_id()?)
            .await?;
    let owners = snapshot.iac_partials.unwrap_or_default();
    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "ok": true,
                "environmentId": snapshot.id,
                "environmentName": snapshot.name,
                "configEtag": snapshot.config_etag,
                "iacPartials": owners,
                "wholeProjectAvailable": ownership::whole_project_available(&owners),
            }))?
        );
    } else {
        println!("IaC ownership in {} ({})", snapshot.name, snapshot.id);
        let mut partials: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
        for (address, owner) in &owners {
            partials.entry(owner).or_default().push(address);
        }
        if partials.is_empty() {
            println!("No named partial ownership.");
        }
        for (partial, addresses) in partials {
            println!("\n{partial}");
            for address in addresses {
                println!("  {address}");
            }
        }
        println!(
            "\n{}",
            availability(ownership::whole_project_available(&owners))
        );
    }
    Ok(())
}

async fn change(from: String, to: Option<String>, args: ChangeArgs) -> Result<()> {
    if to.as_ref() == Some(&from) {
        bail!("The source and destination partials must differ.");
    }
    let interactive = interactive(args.json);
    if !args.dry_run && !args.yes && !args.json && !interactive {
        bail!("Review with --dry-run, then use --yes to change ownership non-interactively.");
    }
    let (configs, linked, _, _) =
        super::runner::ensure_config_context_with_prompt(interactive && !args.yes).await?;
    let client = GQLClient::new_authorized(&configs)?;
    let endpoint = configs.get_backboard();
    let snapshot = ownership::fetch_snapshot(&client, &endpoint, linked.environment_id()?).await?;
    let mut preview = ownership::preview(
        snapshot,
        &from,
        to.as_deref(),
        args.resources.as_deref(),
        args.base_config_etag.as_deref(),
    )?;
    if !args.json {
        print_preview(&preview);
    }
    if !args.dry_run {
        if !args.yes && !args.json && !prompt_confirm_with_default("Change this ownership?", false)?
        {
            bail!("No ownership changed.");
        }
        preview.result = ownership::apply(&client, &endpoint, &preview).await?;
    }
    if args.json {
        let mut output = serde_json::to_value(&preview)?;
        output["ok"] = json!(true);
        output["dryRun"] = json!(args.dry_run);
        output["wholeProjectAvailable"] = json!(ownership::whole_project_available(
            &preview.result.iac_partials
        ));
        output["guidance"] = json!(GUIDANCE);
        println!("{}", serde_json::to_string_pretty(&output)?);
    } else if args.dry_run {
        println!("\nDry run: no ownership changed.");
    } else {
        println!(
            "\nOwnership changed for {} resource(s).",
            preview.result.affected_resources.len()
        );
        println!(
            "{}",
            availability(ownership::whole_project_available(
                &preview.result.iac_partials
            ))
        );
    }
    Ok(())
}

fn print_preview(preview: &Preview) {
    let action = if let Some(to) = &preview.to_partial {
        format!(
            "Transfer ownership from {:?} to {to:?}",
            preview.from_partial
        )
    } else {
        format!("Release ownership from {:?}", preview.from_partial)
    };
    println!(
        "{action} in {} ({})",
        preview.environment_name, preview.environment_id
    );
    for address in &preview.result.affected_resources {
        println!("  {address}");
    }
    println!(
        "\nOnly ownership metadata changes. Resources are not changed, deleted, or redeployed."
    );
    println!("Base config etag: {}", preview.base_config_etag);
    println!(
        "After this change: {}",
        availability(ownership::whole_project_available(
            &preview.result.iac_partials
        ))
    );
    println!("\n{GUIDANCE}");
}

fn availability(available: bool) -> &'static str {
    if available {
        "No named ownership remains; whole-project planning is available."
    } else {
        "Named ownership remains; whole-project planning requires all named ownership to be cleared."
    }
}