use std::path::Path;
use serde::Serialize;
use serde_json::Value;
use crate::actions::doctor::CheckResult;
use crate::actions::doctor::CheckStatus;
use crate::client::GatewayApi;
use crate::client::adopt::ResourceMutationWire;
use crate::client::adopt::api_tokens_via_session;
use crate::client::adopt::build_security_put_body;
use crate::client::adopt::build_token_create_body;
use crate::client::adopt::create_api_token_via_session;
use crate::client::adopt::generate_api_key_via_session;
use crate::client::adopt::level_tree;
use crate::client::adopt::merge_level_into;
use crate::client::adopt::put_security_properties_via_session;
use crate::client::adopt::security_properties_via_session;
use crate::client::idp;
use crate::client::idp::GatewaySession;
use crate::client::idp::IdpLoginFlow;
use crate::config;
use crate::config::AuthRef;
use crate::config::Credential;
use crate::config::KeyringStore;
use crate::config::Secret;
use crate::error::CoreError;
#[derive(Debug, Clone)]
pub struct AdoptOptions {
pub username: String,
pub key_name: String,
pub level: Vec<String>,
pub project: Option<String>,
pub testing: bool,
pub checkout: Option<std::path::PathBuf>,
pub bake: Option<std::path::PathBuf>,
}
#[derive(Debug, Serialize)]
pub struct AdoptResult {
pub key_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stored: Option<String>,
pub steps: Vec<CheckResult>,
}
pub const DEFAULT_KEY_NAME: &str = "ign-cli";
pub const DEFAULT_LEVEL: &[&str] = &["Authenticated", "Roles", "Administrator"];
pub async fn adopt(
base_url: &str,
profile_name: &str,
config_path: &Path,
password: &Secret,
compose_credential: Option<&Credential>,
opts: &AdoptOptions,
) -> Result<AdoptResult, CoreError> {
let mut steps: Vec<CheckResult> = Vec::new();
let level_path: Vec<&str> = opts.level.iter().map(String::as_str).collect();
if level_path.is_empty() || level_path.iter().any(|segment| segment.trim().is_empty()) {
return Err(CoreError::InvalidInput {
reason: "--level must be a slash path of non-empty segments, e.g. \
Authenticated/Roles/Administrator"
.into(),
});
}
let flow = IdpLoginFlow::new(base_url)?;
let (flow, session) = idp::login(flow, &opts.username, password).await?;
steps.push(CheckResult {
name: "login".into(),
status: CheckStatus::Ok,
detail: format!("native OIDC session as {}", opts.username),
hint: None,
});
let tokens = api_tokens_via_session(&flow, &session).await?;
let existing = tokens.iter().find(|record| record.name == opts.key_name);
let minted: Option<String> = match existing {
Some(record) => {
if !record.enabled {
return Err(CoreError::Internal(format!(
"API key {:?} exists but is disabled — delete it in the gateway UI \
(Platform → Security → API Keys) and re-run adopt",
opts.key_name
)));
}
if record.config.profile.secure_channel_required {
return Err(CoreError::Internal(format!(
"API key {:?} exists but requires secure connections — it cannot \
authenticate over http; delete it in the gateway UI and re-run adopt",
opts.key_name
)));
}
let level_names: Vec<String> = record
.config
.profile
.security_levels
.iter()
.map(crate::client::adopt::level_path_string)
.collect();
let requested = opts.level.join("/");
if !level_names.iter().any(|granted| granted == &requested) {
return Err(CoreError::Internal(format!(
"API key {:?} exists at level {:?} but {:?} was requested — \
pass --level to match, or delete the key and re-run to mint \
the requested level",
opts.key_name, level_names, requested
)));
}
steps.push(CheckResult {
name: "key".into(),
status: CheckStatus::Skip,
detail: format!(
"API key {:?} already present (level {:?}, secure off) — nothing minted",
opts.key_name, level_names
),
hint: None,
});
None
}
None => {
let token = mint(&flow, &session, &opts.key_name, &level_path).await?;
steps.push(CheckResult {
name: "key".into(),
status: CheckStatus::Ok,
detail: format!(
"minted API key {:?} at {} (secure connections off)",
opts.key_name,
opts.level.join("/")
),
hint: None,
});
Some(token)
}
};
let singleton = security_properties_via_session(&flow, &session).await?;
let mut config = singleton.config.clone();
let level = level_tree(&level_path);
let changed_read = merge_level_into(&mut config, "readPermissions", &level);
let changed_write = merge_level_into(&mut config, "writePermissions", &level);
if changed_read || changed_write {
let body = build_security_put_body(&singleton, &config);
let answer = put_security_properties_via_session(&flow, &session, &body).await?;
if !answer.success {
return Err(CoreError::Internal(format!(
"security-properties write refused: {:?}",
answer.problem
)));
}
steps.push(CheckResult {
name: "permissions".into(),
status: CheckStatus::Ok,
detail: format!(
"wired {} into gateway read/write permissions ({}added)",
opts.level.join("/"),
if changed_read && changed_write {
"both "
} else {
""
}
),
hint: None,
});
} else {
steps.push(CheckResult {
name: "permissions".into(),
status: CheckStatus::Skip,
detail: format!(
"gateway permissions already admit {} — nothing written",
opts.level.join("/")
),
hint: None,
});
}
let probe_credential: Option<Credential> = minted
.as_ref()
.map(|token| Credential::Token(Secret::new(token.clone())))
.or_else(|| {
compose_credential
.cloned()
.filter(|credential| matches!(credential, Credential::Token(_)))
});
let token_for_persist: Option<String> = match probe_credential {
Some(credential) => {
let url: url::Url = base_url
.parse()
.map_err(|err| CoreError::Internal(format!("invalid gateway URL: {err}")))?;
let probe = crate::session::Session::for_url(url, Some(credential), true)?;
probe.api().gateway_info().await?;
let via = if minted.is_some() {
"the fresh name:key"
} else {
"the profile's resolved credential"
};
steps.push(CheckResult {
name: "probe".into(),
status: CheckStatus::Ok,
detail: format!("gateway-info answered 200 with {via} — the key works"),
hint: None,
});
minted
}
None => {
return Err(CoreError::SecretUnavailable {
profile: profile_name.to_string(),
});
}
};
let (stored, exposed): (Option<String>, Option<String>) = match &token_for_persist {
None => (None, None),
Some(token) => {
let keyring = KeyringStore;
match keyring.set(profile_name, &Secret::new(token.clone())) {
Ok(()) => {
rewrite_profile_auth(
config_path,
profile_name,
AuthRef::Keyring {
keyring: format!("profile:{profile_name}"),
},
)?;
(Some("keyring".into()), None)
}
Err(err) => {
tracing::debug!(error = %err, "keyring unavailable; env fallback");
let var = format!("IGNITION_TOKEN_{}", super_profile_env_suffix(profile_name));
rewrite_profile_auth(
config_path,
profile_name,
AuthRef::TokenEnv {
token_env: var.clone(),
},
)?;
(Some(format!("token_env:{var}")), Some(token.clone()))
}
}
}
};
match &stored {
Some(how) if token_for_persist.is_some() => steps.push(CheckResult {
name: "persist".into(),
status: CheckStatus::Ok,
detail: match exposed {
Some(_) => format!(
"profile now expects {} (no keyring on this host) — export it \
with the token from this run's output",
how
),
None => format!("credential stored in the OS keyring — profile {how:?} wired"),
},
hint: exposed
.as_ref()
.map(|_| "export the token now; it is never shown again".into()),
}),
_ => steps.push(CheckResult {
name: "persist".into(),
status: CheckStatus::Skip,
detail: "profile auth untouched (key pre-existed)".into(),
hint: None,
}),
}
let composing =
opts.project.is_some() || opts.checkout.is_some() || opts.bake.is_some() || opts.testing;
if composing {
let credential: Option<Credential> = match &token_for_persist {
Some(token) => Some(Credential::Token(Secret::new(token.clone()))),
None => compose_credential.cloned(),
};
let Some(credential) = credential else {
return Err(CoreError::SecretUnavailable {
profile: profile_name.to_string(),
});
};
let url: url::Url = base_url
.parse()
.map_err(|err| CoreError::Internal(format!("invalid gateway URL: {err}")))?;
let session = crate::session::Session::for_url(url, Some(credential), true)?;
let api = session.api();
if let Some(project) = &opts.project {
let deployed = crate::actions::webdev::webdev_deploy(
api,
project,
true,
false,
config_path,
profile_name,
opts.testing,
)
.await?;
steps.push(CheckResult {
name: "routes".into(),
status: CheckStatus::Ok,
detail: format!(
"deployed {} WebDev routes into {project:?} (scriptExec on, secret {}{})",
deployed.routes.len(),
if deployed.secret_rotated {
"generated"
} else {
"reused"
},
if opts.testing {
", testing bundle on"
} else {
""
}
),
hint: None,
});
if opts.testing {
let discover = |()| crate::client::webdev::testing_discover(api, project);
let discovered = match discover(()).await {
Ok(value) => value,
Err(_) => {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
discover(()).await?
}
};
let count = discovered.get("count").and_then(Value::as_i64).unwrap_or(0);
let modules = discovered
.get("discovered_modules")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if count < 1 || modules.is_empty() {
return Err(CoreError::Internal(format!(
"testing deploy is NOT green: ?discover=true listed {} modules \
(expected ≥1 — the testing.__tests__ sentinel ships with the \
bundle; zero means the discovery walk found nothing, the \
empty-suite trap)",
modules.len()
)));
}
let run = crate::client::webdev::testing_run(api, project, &serde_json::json!({}))
.await?;
let passed = run.get("passed").and_then(Value::as_i64).unwrap_or(0);
let failed = run.get("failed").and_then(Value::as_i64).unwrap_or(-1);
let errors = run.get("errors").and_then(Value::as_i64).unwrap_or(-1);
if failed != 0 || errors != 0 || passed < 1 {
return Err(CoreError::Internal(format!(
"testing smoke run is NOT green: passed={passed} failed={failed} \
errors={errors} (the testing.__tests__ sentinel must pass)"
)));
}
steps.push(CheckResult {
name: "testing".into(),
status: CheckStatus::Ok,
detail: format!(
"framework live: {} module(s) discovered, smoke run {passed} passed",
modules.len()
),
hint: None,
});
}
}
if let Some(dir) = &opts.checkout {
let page = api
.projects(&crate::client::query::ListQuery::default())
.await?;
let mut checked: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for record in page.items.iter().filter(|record| record.enabled) {
let target = dir.join(&record.name);
if target.exists() {
skipped.push(record.name.clone());
continue;
}
crate::actions::workspace::workspace_checkout(
api,
&record.name,
&target,
profile_name,
true,
)
.await?;
checked.push(record.name.clone());
}
let mut detail = if checked.is_empty() && skipped.is_empty() {
"no enabled projects on the gateway".to_string()
} else {
format!("{} into {}", checked.len(), dir.display())
};
if !skipped.is_empty() {
detail.push_str(&format!(
", {} skipped (already checked out)",
skipped.len()
));
}
steps.push(CheckResult {
name: "checkout".into(),
status: CheckStatus::Ok,
detail,
hint: None,
});
}
if let Some(file) = &opts.bake {
crate::actions::backup::backup_download(
api,
Some(file),
profile_name,
crate::client::backup::BackupType::Roaming,
)
.await?;
steps.push(CheckResult {
name: "bake".into(),
status: CheckStatus::Ok,
detail: format!("roaming gwbk at {}", file.display()),
hint: None,
});
}
}
Ok(AdoptResult {
key_name: opts.key_name.clone(),
token: exposed,
stored,
steps,
})
}
async fn mint(
flow: &IdpLoginFlow,
session: &GatewaySession,
key_name: &str,
level_path: &[&str],
) -> Result<String, CoreError> {
let generated = generate_api_key_via_session(flow, session).await?;
if generated.key.is_empty() || generated.hash.is_empty() {
return Err(CoreError::Internal(
"api-token generate answered an empty key or hash".into(),
));
}
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the unix epoch")
.as_millis() as i64;
let body = build_token_create_body(
key_name,
&level_tree(level_path),
&generated.hash,
timestamp_ms,
);
let answer: ResourceMutationWire = create_api_token_via_session(flow, session, &body).await?;
if !answer.success
|| !answer.changes.iter().any(|change| {
change.name == key_name && change.kind == crate::client::adopt::API_TOKEN_TYPE
})
{
return Err(CoreError::Internal(format!(
"api-token create refused: success={} problem={:?}",
answer.success, answer.problem
)));
}
Ok(format!("{key_name}:{}", generated.key))
}
fn rewrite_profile_auth(
config_path: &Path,
profile_name: &str,
auth: AuthRef,
) -> Result<(), CoreError> {
let mut config = config::load(config_path)?;
let profile = config.profiles.get_mut(profile_name).ok_or_else(|| {
CoreError::Internal(format!(
"profile {profile_name:?} vanished from the config mid-adopt"
))
})?;
profile.auth = auth;
config::save(config_path, &config)
}
fn super_profile_env_suffix(profile: &str) -> String {
config::secret::profile_env_suffix(profile)
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_KEY_NAME, DEFAULT_LEVEL};
#[test]
fn defaults_are_the_administrator_posture() {
assert_eq!(DEFAULT_KEY_NAME, "ign-cli");
assert_eq!(DEFAULT_LEVEL, &["Authenticated", "Roles", "Administrator"]);
}
}