use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::client::GatewayApi;
use crate::client::webdev::{self as seam, RouteProbe};
use crate::config;
use crate::error::{CoreError, TAG_DELETE_DROP_REFUSAL_PREFIX};
use crate::webdev as bundle;
pub(crate) const SCRIPT_EXEC_ROUTE: &str = "scriptExec";
pub(crate) const SECRET_HEADER: &str = "X-Ignition-CLI-Secret";
pub(crate) const TAG_DELETE_ROUTE: &str = crate::error::TAG_DELETE_ROUTE_NAME;
#[derive(Debug, Clone)]
pub struct TagDeleteRequest {
pub repos: Vec<PathBuf>,
pub user_source: String,
pub role: String,
pub rotate_secret: bool,
pub caller: Option<(config::Secret, config::Secret)>,
}
#[derive(Debug, Serialize)]
pub struct WebdevDeployResult {
pub project: String,
pub routes: Vec<String>,
pub script_exec: bool,
pub secret_rotated: bool,
pub import: serde_json::Value,
pub tag_delete: bool,
pub tag_delete_secret_rotated: bool,
pub tag_delete_providers: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RouteStatusRow {
pub route: String,
pub status: RouteStatus,
pub deployed_version: Option<String>,
pub expected_version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteStatus {
Present,
Absent,
Unlicensed,
AuthGated,
SecretMismatch,
VersionMismatch,
}
#[derive(Debug, Serialize)]
pub struct WebdevStatusResult {
pub project: String,
pub routes: Vec<RouteStatusRow>,
pub ok: bool,
}
pub async fn webdev_deploy(
api: &dyn GatewayApi,
project: &str,
with_script_exec: bool,
rotate_secret: bool,
config_path: &Path,
profile_name: &str,
with_testing: bool,
) -> Result<WebdevDeployResult, CoreError> {
webdev_deploy_with(
api,
project,
with_script_exec,
rotate_secret,
config_path,
profile_name,
with_testing,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn webdev_deploy_with(
api: &dyn GatewayApi,
project: &str,
with_script_exec: bool,
rotate_secret: bool,
config_path: &Path,
profile_name: &str,
with_testing: bool,
tag_delete: Option<TagDeleteRequest>,
) -> Result<WebdevDeployResult, CoreError> {
let mut config = config::load(config_path)?;
let profile = config.profiles.get(profile_name);
let existing = profile.and_then(|profile| profile.webdev_secret.clone());
let existing_tag_delete = profile.and_then(|profile| profile.webdev_tag_delete_secret.clone());
let (pack_secret, secret_rotated) = if rotate_secret || (with_script_exec && existing.is_none())
{
(Some(generate_secret()?), true)
} else if with_script_exec {
(existing, false)
} else {
(None, false)
};
let mut warnings = Vec::new();
let (install, tag_delete_secret_rotated) = match &tag_delete {
None => (None, false),
Some(request) => {
let transport = api.gateway_transport().or_else(|| {
config
.profiles
.get(profile_name)
.map(|profile| (profile.url.clone(), profile.ssl_verify))
});
if let Some((url, ssl_verify)) = &transport
&& let Some(refusal) =
crate::deploy::tag_delete::transport_refusal(url, *ssl_verify)
{
return Err(CoreError::TagDeleteNotConfigured {
profile: profile_name.to_string(),
missing: vec![refusal],
});
}
let mut providers: Vec<String> = Vec::new();
for repo in &request.repos {
for provider in crate::deploy::projects::load_project_file(repo)?.tag_providers {
if !providers.contains(&provider) {
providers.push(provider);
}
}
}
let (secret, rotated) = match existing_tag_delete.clone() {
Some(secret) if !request.rotate_secret => (secret, false),
_ => (generate_secret()?, true),
};
let install = seam::TagDeleteInstall {
secret,
user_source: request.user_source.clone(),
role: request.role.clone(),
providers,
};
seam::validate_tag_delete_install(&install)?;
if let Some(stored) = &existing_tag_delete {
match read_live_allowlist(
profile_name,
transport.as_ref(),
project,
request.caller.clone(),
stored,
)
.await
{
LiveAllowlist::Read(live) => {
refuse_dropped_providers(project, &live, &install.providers)?;
}
LiveAllowlist::Absent => {}
LiveAllowlist::Unreadable(reason) => warnings.push(format!(
"could not read the live tagDelete allowlist on project {project} \
({reason}); the install replaced it with [{}], so check that no \
other repo's providers were dropped",
install.providers.join(", ")
)),
}
}
(Some(install), rotated)
}
};
let tag_delete_removed =
tag_delete.is_none() && project == seam::DEFAULT_PROJECT && existing_tag_delete.is_some();
if tag_delete_removed {
warnings.push(format!(
"tagDelete route removed from project {project}: this deploy did not include \
--with-tag-delete, so the overwrite import dropped it; the stored tagDelete \
secret was removed from profile {profile_name:?}, and tag-owning runs refuse \
tag_delete_not_configured until `ign webdev deploy --with-tag-delete` reinstalls it"
));
}
if secret_rotated || tag_delete_secret_rotated || tag_delete_removed {
let entry = config.profiles.get_mut(profile_name).ok_or_else(|| {
CoreError::Internal(format!(
"profile {profile_name:?} vanished from the config between dispatch and deploy"
))
})?;
if secret_rotated {
entry.webdev_secret = pack_secret.clone();
}
if tag_delete_secret_rotated {
entry.webdev_tag_delete_secret = install.as_ref().map(|install| install.secret.clone());
}
if tag_delete_removed {
entry.webdev_tag_delete_secret = None;
}
config::save(config_path, &config)?; }
let zip = seam::build_deploy_zip_with(
project,
with_script_exec,
pack_secret.as_deref(),
with_testing,
install.as_ref(),
)?;
let mut routes = seam::always_on_routes();
if with_script_exec {
routes.push(SCRIPT_EXEC_ROUTE.to_string());
}
if install.is_some() {
routes.push(TAG_DELETE_ROUTE.to_string());
}
if with_testing {
routes.extend(
crate::webdev::testing::TESTING_ROUTES
.iter()
.map(|route| format!("testing/{route}")),
);
}
let import = api.project_import(project, zip, true).await?;
Ok(WebdevDeployResult {
project: project.to_string(),
routes,
script_exec: with_script_exec,
secret_rotated,
import: import.response,
tag_delete: install.is_some(),
tag_delete_secret_rotated,
tag_delete_providers: install.map(|install| install.providers).unwrap_or_default(),
warnings,
})
}
enum LiveAllowlist {
Read(Vec<String>),
Absent,
Unreadable(String),
}
async fn read_live_allowlist(
profile_name: &str,
transport: Option<&(url::Url, bool)>,
project: &str,
caller: Option<(config::Secret, config::Secret)>,
stored_secret: &str,
) -> LiveAllowlist {
use crate::deploy::tag_delete::{TagDeleteClient, TagDeleteRoute};
let Some((url, ssl_verify)) = transport else {
return LiveAllowlist::Unreadable("no gateway URL is known for the profile".to_string());
};
let Some((user, password)) = caller else {
let (user, password) = config::secret::tag_delete_env_names(profile_name);
return LiveAllowlist::Unreadable(format!("{user} and {password} are not both set"));
};
let client = match TagDeleteClient::new(
profile_name,
url,
*ssl_verify,
project,
user,
password,
config::Secret::new(stored_secret),
) {
Ok(client) => client,
Err(err) => return LiveAllowlist::Unreadable(err.code().to_string()),
};
match client.handshake().await {
Ok(handshake) => LiveAllowlist::Read(handshake.providers),
Err(CoreError::RoutesNotDeployed { .. }) => LiveAllowlist::Absent,
Err(err) => LiveAllowlist::Unreadable(format!("the handshake answered {}", err.code())),
}
}
fn refuse_dropped_providers(
project: &str,
live: &[String],
new: &[String],
) -> Result<(), CoreError> {
let dropped: Vec<&str> = live
.iter()
.filter(|provider| !new.contains(provider))
.map(String::as_str)
.collect();
if dropped.is_empty() {
return Ok(());
}
Err(CoreError::InvalidInput {
reason: format!(
"{TAG_DELETE_DROP_REFUSAL_PREFIX} {} from the live tagDelete allowlist on project \
{project} (allowed now: {}; this install: {}); nothing was stored or imported. \
Re-run naming every repo whose providers the route must keep (repeat \
--tag-delete-repo), or replace the allowlist deliberately: run `ign webdev \
deploy` without --with-tag-delete first (it removes the route and its stored \
secret), then install",
dropped.join(", "),
live.join(", "),
new.join(", ")
),
})
}
pub async fn webdev_status(
api: &dyn GatewayApi,
project: &str,
secret: Option<&str>,
) -> Result<WebdevStatusResult, CoreError> {
let mut routes = Vec::new();
let mut ok = true;
for route in seam::always_on_routes() {
let probe = api.webdev_route_probe(project, &route, &[]).await?;
let row = classify_probe(&route, probe);
ok &= row.status == RouteStatus::Present;
routes.push(row);
}
if let Some(secret) = secret {
let probe = api
.webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
.await?;
routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
}
Ok(WebdevStatusResult {
project: project.to_string(),
routes,
ok,
})
}
pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
const ROUTE: &str = "tags";
let endpoint = seam::route_url(project, ROUTE);
match api.webdev_route_probe(project, ROUTE, &[]).await? {
RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
Ok(())
}
RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
route: ROUTE.to_string(),
deployed: route_version,
expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
endpoint: Some(endpoint),
}),
RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
project: project.to_string(),
route: ROUTE.to_string(),
endpoint: Some(endpoint),
}),
RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
endpoint: Some(endpoint),
}),
RouteProbe::AuthGated => Err(CoreError::Auth {
status: 401,
endpoint: Some(endpoint),
}),
RouteProbe::Denied {
code,
message,
traceback,
} => {
let mut full = message;
if let Some(traceback) = traceback {
full.push_str("\nroute traceback: ");
full.push_str(&traceback);
}
Err(CoreError::WebdevRouteError {
code,
message: full,
endpoint: Some(endpoint),
})
}
}
}
fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
let expected = bundle::ROUTE_BUNDLE_VERSION;
match probe {
RouteProbe::Present { route_version } => {
let status = if route_version == expected {
RouteStatus::Present
} else {
RouteStatus::VersionMismatch
};
RouteStatusRow {
route: route.to_string(),
status,
deployed_version: Some(route_version),
expected_version: Some(expected.to_string()),
}
}
RouteProbe::Denied { code, .. } => {
let status = if code == "secret_required" || code == "secret_mismatch" {
RouteStatus::SecretMismatch
} else {
RouteStatus::VersionMismatch
};
absent_row(route, status)
}
RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
}
}
fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
RouteStatusRow {
route: route.to_string(),
status,
deployed_version: None,
expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
}
}
fn generate_secret() -> Result<String, CoreError> {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes)
.map_err(|err| CoreError::Internal(format!("cannot generate the deploy secret: {err}")))?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::{
RouteProbe, RouteStatus, TagDeleteRequest, WebdevDeployResult, generate_secret,
webdev_deploy, webdev_deploy_with, webdev_precondition, webdev_status,
};
use crate::client::GatewayApi;
use crate::client::projects::ImportOutcome;
use crate::error::CoreError;
use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
use std::path::PathBuf;
struct WebdevRig {
probe: fn(&str) -> Result<RouteProbe, CoreError>,
transport: Option<(url::Url, bool)>,
import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
}
fn present(version: &str) -> Result<RouteProbe, CoreError> {
Ok(RouteProbe::Present {
route_version: version.to_string(),
})
}
fn ok_import() -> ImportOutcome {
ImportOutcome {
response: serde_json::json!({"success": true}),
}
}
#[async_trait::async_trait]
impl GatewayApi for WebdevRig {
fn gateway_transport(&self) -> Option<(url::Url, bool)> {
self.transport.clone()
}
async fn thread_dump(
&self,
) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
unreachable!("not part of this action")
}
async fn thread_deadlocks(
&self,
) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
unreachable!("not part of this action")
}
async fn uninstall_module(
&self,
_gateway_module_id: &str,
_module_id: &str,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn bundle_generate(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_status(
&self,
) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn bundle_download(
&self,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_list(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn tag_provider_find(
&self,
_name: &str,
) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_create(
&self,
_body: &[crate::client::tags::TagProviderCreate],
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn tag_provider_delete(
&self,
_name: &str,
_signature: &str,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_probe(
&self,
_project: &str,
route: &str,
_extra_headers: &[(&str, &str)],
) -> Result<RouteProbe, CoreError> {
(self.probe)(route)
}
async fn project_import(
&self,
_name: &str,
zip: Vec<u8>,
overwrite: bool,
) -> Result<ImportOutcome, CoreError> {
(self.import)(zip, overwrite)
}
async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
unreachable!("not part of this action")
}
async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
unreachable!("not part of this action")
}
async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
unreachable!("not part of this action")
}
async fn modules(
&self,
_quarantined: bool,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
{
unreachable!("not part of this action")
}
async fn metrics_current(
&self,
) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_historic(
&self,
) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
unreachable!("not part of this action")
}
async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
unreachable!("not part of this action")
}
async fn designers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn perspective_sessions(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn vision_clients(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn terminate_perspective_session(
&self,
_id: &str,
_message: Option<&str>,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn database_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn opc_connections(
&self,
) -> Result<
crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn logs(
&self,
_filter: &crate::client::logs::LogQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
{
unreachable!("not part of this action")
}
async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
unreachable!("not part of this action")
}
async fn loggers(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
{
unreachable!("not part of this action")
}
async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn reset_logger_levels(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn restart(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn scan_projects(&self) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn security_properties(
&self,
) -> Result<crate::client::restart::SecurityProperties, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
unreachable!("not part of this action")
}
async fn webdev_route_call(
&self,
_project: &str,
_route: &str,
_body: &serde_json::Value,
_extra_headers: &[(&str, &str)],
) -> Result<serde_json::Value, CoreError> {
unreachable!("not part of this action")
}
async fn projects(
&self,
_query: &crate::client::query::ListQuery,
) -> Result<
crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
CoreError,
> {
unreachable!("not part of this action")
}
async fn project_find(
&self,
_name: &str,
) -> Result<crate::client::projects::ProjectRecord, CoreError> {
unreachable!("not part of this action")
}
async fn project_create(
&self,
_body: &crate::client::projects::ProjectCreate,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_modify(
&self,
_name: &str,
_body: &crate::client::projects::ProjectModify,
) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn project_export_to_file(
&self,
_name: &str,
_out: &std::path::Path,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
unreachable!("not part of this action")
}
async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
unreachable!("not part of this action")
}
async fn backup_download(
&self,
_out: &std::path::Path,
_backup_type: crate::client::backup::BackupType,
) -> Result<crate::client::projects::ExportMeta, CoreError> {
unreachable!("not part of this action")
}
async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_history(
&self,
_limit: Option<u32>,
_search: Option<&str>,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_definitions(
&self,
) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
{
unreachable!("not part of this action")
}
async fn eam_task_find(
&self,
_name: &str,
) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
unreachable!("not part of this action")
}
async fn eam_tasks_scheduled(
&self,
_running: bool,
) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_modify(
&self,
_definition: &serde_json::Value,
) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
unreachable!("not part of this action")
}
async fn eam_task_delete(
&self,
_name: &str,
_signature: &str,
_confirm: bool,
) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
unreachable!("not part of this action")
}
async fn api_call(
&self,
_call: &crate::client::apicall::ApiCallRequest,
) -> Result<crate::client::apicall::ApiCallData, CoreError> {
unreachable!("not part of this action")
}
async fn license_status(
&self,
) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn redundancy_status(
&self,
) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
unreachable!("not part of this action")
}
async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
unreachable!("not part of this action")
}
}
fn temp_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
)
.expect("write config");
(dir, path)
}
fn stored_secret(path: &std::path::Path) -> Option<String> {
crate::config::load(path)
.expect("config reloads")
.profiles
.get("dev")
.and_then(|profile| profile.webdev_secret.clone())
}
fn importing_rig() -> WebdevRig {
WebdevRig {
probe: |_| unreachable!("deploy never probes"),
transport: None,
import: Box::new(|_zip, _overwrite| Ok(ok_import())),
}
}
#[tokio::test]
async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
let (dir, config) = temp_config();
let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = std::sync::Arc::clone(&seen_zip);
let rig = WebdevRig {
probe: |_| unreachable!("deploy never probes"),
transport: None,
import: Box::new(move |zip, overwrite| {
assert!(overwrite, "deploy ALWAYS overwrite-imports");
*recorder.lock().expect("zip lock") = zip;
Ok(ok_import())
}),
};
let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
.await
.expect("plain deploy");
assert_eq!(
result.routes,
vec!["tags", "tagConfig", "alarms", "tagHistory"]
);
assert!(!result.script_exec);
assert!(!result.secret_rotated);
assert_eq!(result.import["success"], true);
let names = member_names(&seen_zip.lock().expect("zip lock"));
assert!(names.iter().all(|name| !name.contains("scriptExec")));
assert_eq!(stored_secret(&config), None);
let _ = dir; }
#[tokio::test]
async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
let (dir, config) = temp_config();
let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = std::sync::Arc::clone(&seen_zip);
let rig = WebdevRig {
probe: |_| unreachable!("deploy never probes"),
transport: None,
import: Box::new(move |zip, _| {
*recorder.lock().expect("zip lock") = zip;
Ok(ok_import())
}),
};
let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
.await
.expect("scriptExec deploy");
assert_eq!(
result.routes,
vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
);
assert!(result.script_exec && result.secret_rotated);
let secret = stored_secret(&config).expect("secret persisted");
assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
assert!(
secret.chars().all(|c| c.is_ascii_hexdigit()),
"hex alphabet: {secret}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&config)
.expect("config stat")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
}
let do_post = member(
&seen_zip.lock().expect("zip lock"),
"com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
);
assert!(String::from_utf8_lossy(&do_post).contains(&secret));
let serialized = serde_json::to_string(&result).expect("result serializes");
assert!(!serialized.contains(&secret), "redaction: {serialized}");
let envelope_check: WebdevDeployResult = result;
let again = serde_json::to_string(&envelope_check).expect("serializes");
assert!(!again.contains(&secret));
let _ = dir;
}
#[tokio::test]
async fn rotate_regenerates_and_plain_redeploy_reuses() {
let (dir, config) = temp_config();
let mut seeded = crate::config::load(&config).expect("load");
seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
crate::config::save(&config, &seeded).expect("seed save");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = std::sync::Arc::clone(&seen);
let rig = WebdevRig {
probe: |_| unreachable!(),
transport: None,
import: Box::new(move |zip, _| {
*recorder.lock().expect("lock") = zip;
Ok(ok_import())
}),
};
let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
.await
.expect("reuse deploy");
assert!(!result.secret_rotated);
assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
let seen = seen.lock().expect("lock").clone();
let do_post = member(
&seen,
"com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
);
assert!(
String::from_utf8_lossy(&do_post).contains("aa11"),
"the STORED secret rode the zip's scriptExec member"
);
let result = webdev_deploy(
&importing_rig(),
"ign-cli",
true,
true,
&config,
"dev",
false,
)
.await
.expect("rotate deploy");
assert!(result.secret_rotated);
let rotated = stored_secret(&config).expect("rotated secret stored");
assert_eq!(rotated.len(), 64);
assert_ne!(rotated, "aa11");
let _ = dir;
}
#[tokio::test]
async fn status_maps_the_full_probe_matrix() {
let healthy = WebdevRig {
probe: |_| present(BUNDLE_VERSION),
transport: None,
import: Box::new(|_, _| unreachable!("status never imports")),
};
let result = webdev_status(&healthy, "ign-cli", None)
.await
.expect("status sweep");
assert_eq!(result.routes.len(), 4);
assert!(
result
.routes
.iter()
.all(|row| row.status == RouteStatus::Present)
);
assert!(result.ok);
let degraded = WebdevRig {
transport: None,
probe: |route| {
Ok(match route {
"tags" => RouteProbe::Absent,
"tagConfig" => present("9.9.9").expect("fixture"),
"alarms" => present(BUNDLE_VERSION).expect("fixture"),
"tagHistory" => RouteProbe::Unlicensed,
"scriptExec" => RouteProbe::Denied {
code: "secret_mismatch".into(),
message: "mismatch".into(),
traceback: None,
},
_ => RouteProbe::AuthGated,
})
},
import: Box::new(|_, _| unreachable!()),
};
let result = webdev_status(°raded, "ign-cli", Some("stored-secret"))
.await
.expect("degraded sweep still completes");
let by_route = |name: &str| {
result
.routes
.iter()
.find(|row| row.route == name)
.unwrap_or_else(|| panic!("{name} row"))
};
assert_eq!(by_route("tags").status, RouteStatus::Absent);
assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
assert_eq!(
by_route("tagConfig").deployed_version.as_deref(),
Some("9.9.9")
);
assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
assert!(!result.ok);
let gated_exec = WebdevRig {
transport: None,
probe: |route| {
if route == "scriptExec" {
Ok(RouteProbe::AuthGated)
} else {
present(BUNDLE_VERSION)
}
},
import: Box::new(|_, _| unreachable!()),
};
let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
.await
.expect("sweep");
assert!(result.ok, "scriptExec never gates ok");
assert_eq!(result.routes.len(), 5);
}
#[tokio::test]
async fn precondition_refuses_the_undeployed_and_mismatched() {
let undeployed = WebdevRig {
probe: |_| Ok(RouteProbe::Absent),
transport: None,
import: Box::new(|_, _| unreachable!()),
};
let err = webdev_precondition(&undeployed, "ign-cli")
.await
.expect_err("absent refuses");
assert_eq!(err.code(), "routes_not_deployed");
assert_eq!(err.exit_code(), 6);
assert!(
err.hint().unwrap().contains("ign webdev deploy"),
"hint names the fix"
);
let older = WebdevRig {
probe: |_| present("0.9.0"),
transport: None,
import: Box::new(|_, _| unreachable!()),
};
let err = webdev_precondition(&older, "ign-cli")
.await
.expect_err("older refuses");
assert_eq!(err.code(), "route_version_mismatch");
assert!(
err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
"both versions named: {err}"
);
let matching = WebdevRig {
probe: |_| present(BUNDLE_VERSION),
transport: None,
import: Box::new(|_, _| unreachable!()),
};
webdev_precondition(&matching, "ign-cli")
.await
.expect("matching handshake passes");
}
const TAG_DELETE_DO_POST: &str =
"com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py";
const TAG_DELETE_CONFIG: &str =
"com.inductiveautomation.webdev/resources/cli/tagDelete/config.json";
fn temp_repo(providers: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("deploy")).expect("deploy dir");
std::fs::write(
dir.path().join("deploy/project.yaml"),
format!(
"schema_version: 1\nproject: Demo\nenvironments: [dev]\ntag_providers: {providers}\n"
),
)
.expect("project.yaml");
dir
}
fn tag_delete_request(repo: &std::path::Path, rotate_secret: bool) -> TagDeleteRequest {
TagDeleteRequest {
repos: vec![repo.to_path_buf()],
user_source: "default".into(),
role: "FakeDeployRole".into(),
rotate_secret,
caller: None,
}
}
fn stored_tag_delete_secret(path: &std::path::Path) -> Option<String> {
crate::config::load(path)
.expect("config reloads")
.profiles
.get("dev")
.and_then(|profile| profile.webdev_tag_delete_secret.clone())
}
fn no_gateway_rig() -> WebdevRig {
WebdevRig {
probe: |_| unreachable!("deploy never probes"),
transport: None,
import: Box::new(|_, _| panic!("no gateway request may happen")),
}
}
#[allow(clippy::type_complexity)]
fn recording_rig(
config: &std::path::Path,
) -> (
WebdevRig,
std::sync::Arc<std::sync::Mutex<(Vec<u8>, Option<String>)>>,
) {
let seen = std::sync::Arc::new(std::sync::Mutex::new((Vec::new(), None)));
let recorder = std::sync::Arc::clone(&seen);
let config = config.to_path_buf();
let rig = WebdevRig {
probe: |_| unreachable!("deploy never probes"),
transport: None,
import: Box::new(move |zip, overwrite| {
assert!(overwrite);
*recorder.lock().expect("lock") = (zip, stored_tag_delete_secret(&config));
Ok(ok_import())
}),
};
(rig, seen)
}
#[tokio::test]
async fn tag_delete_secret_generated_persisted_reused_and_rotated() {
let (dir, config) = temp_config();
let repo = temp_repo("[P26Del]");
let (rig, seen) = recording_rig(&config);
let result = webdev_deploy_with(
&rig,
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("tag delete deploy");
assert!(result.tag_delete && result.tag_delete_secret_rotated);
assert!(!result.script_exec && !result.secret_rotated);
assert_eq!(result.tag_delete_providers, vec!["P26Del".to_string()]);
assert_eq!(
result.routes,
vec!["tags", "tagConfig", "alarms", "tagHistory", "tagDelete"]
);
let secret = stored_tag_delete_secret(&config).expect("secret persisted");
assert_eq!(secret.len(), 64);
assert!(
secret
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
);
assert_eq!(
stored_secret(&config),
None,
"the scriptExec secret is untouched"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&config)
.expect("stat")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
let (zip, at_upload) = seen.lock().expect("lock").clone();
assert_eq!(
at_upload.as_deref(),
Some(secret.as_str()),
"persisted before upload"
);
assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
let packed_config: serde_json::Value =
serde_json::from_slice(&member(&zip, TAG_DELETE_CONFIG)).expect("config.json");
assert_eq!(packed_config["doPost"]["required-roles"], "FakeDeployRole");
assert_eq!(packed_config["doPost"]["user-source"], "default");
let serialized = serde_json::to_string(&result).expect("serializes");
assert!(!serialized.contains(&secret), "redaction: {serialized}");
assert!(serialized.contains("\"tag_delete\":true"));
assert!(serialized.contains("\"tag_delete_secret_rotated\":true"));
assert!(serialized.contains("\"tag_delete_providers\":[\"P26Del\"]"));
assert!(!format!("{result:?}").contains(&secret));
let (rig, seen) = recording_rig(&config);
let result = webdev_deploy_with(
&rig,
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("reuse deploy");
assert!(result.tag_delete && !result.tag_delete_secret_rotated);
assert_eq!(
stored_tag_delete_secret(&config).as_deref(),
Some(secret.as_str())
);
let (zip, _) = seen.lock().expect("lock").clone();
assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), true)),
)
.await
.expect("rotate deploy");
assert!(result.tag_delete_secret_rotated);
let rotated = stored_tag_delete_secret(&config).expect("rotated");
assert_eq!(rotated.len(), 64);
assert_ne!(rotated, secret);
let _ = dir;
}
#[tokio::test]
async fn script_exec_and_tag_delete_secrets_are_independent() {
let (dir, config) = temp_config();
let repo = temp_repo("[P26Del, Other]");
let (rig, seen) = recording_rig(&config);
let result = webdev_deploy_with(
&rig,
"ign-cli",
true,
false,
&config,
"dev",
true,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("composed deploy");
assert!(result.script_exec && result.tag_delete);
assert_eq!(
result.tag_delete_providers,
vec!["P26Del".to_string(), "Other".to_string()]
);
assert!(result.routes.contains(&"scriptExec".to_string()));
assert!(result.routes.contains(&"tagDelete".to_string()));
assert!(
result
.routes
.iter()
.any(|route| route.starts_with("testing/"))
);
let names = member_names(&seen.lock().expect("lock").0);
assert!(names.iter().any(|name| name.contains("cli/scriptExec/")));
assert!(names.iter().any(|name| name.contains("cli/tagDelete/")));
let exec_secret = stored_secret(&config).expect("scriptExec secret");
let delete_secret = stored_tag_delete_secret(&config).expect("tagDelete secret");
assert_ne!(exec_secret, delete_secret);
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
true,
true,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("rotate scriptExec");
assert!(result.secret_rotated && !result.tag_delete_secret_rotated);
assert_ne!(
stored_secret(&config).as_deref(),
Some(exec_secret.as_str())
);
assert_eq!(
stored_tag_delete_secret(&config).as_deref(),
Some(delete_secret.as_str())
);
let exec_now = stored_secret(&config).expect("scriptExec secret");
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
true,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), true)),
)
.await
.expect("rotate tag delete");
assert!(!result.secret_rotated && result.tag_delete_secret_rotated);
assert_eq!(stored_secret(&config).as_deref(), Some(exec_now.as_str()));
assert_ne!(
stored_tag_delete_secret(&config).as_deref(),
Some(delete_secret.as_str())
);
let serialized = serde_json::to_string(&result).expect("serializes");
for secret in [
exec_now,
stored_tag_delete_secret(&config).expect("tagDelete secret"),
] {
assert!(!serialized.contains(&secret));
assert!(!format!("{result:?}").contains(&secret));
}
let _ = dir;
}
#[tokio::test]
async fn tag_delete_refusals_happen_before_any_gateway_request() {
let (dir, config) = temp_config();
let mut seeded = crate::config::load(&config).expect("load");
seeded.profiles.get_mut("dev").expect("dev").webdev_secret =
Some("fakeexecsecretvalue".into());
crate::config::save(&config, &seeded).expect("seed");
for (providers, code) in [
("[]", "invalid_input"),
("[System]", "invalid_input"),
("[P1, system]", "invalid_input"),
] {
let repo = temp_repo(providers);
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
true,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect_err("refused");
assert_eq!(err.code(), code, "{providers}: {err}");
assert!(!err.to_string().contains("fakeexecsecretvalue"));
}
let repo = temp_repo("[P26Del]");
let mut bad_role = tag_delete_request(repo.path(), false);
bad_role.role = "x'y".into();
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(bad_role),
)
.await
.expect_err("bad role");
assert_eq!(err.code(), "invalid_input");
assert!(err.to_string().contains("role"), "{err}");
let empty_repo = tempfile::tempdir().expect("tempdir");
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(empty_repo.path(), false)),
)
.await
.expect_err("no project.yaml");
assert_eq!(err.code(), "deploy_tree_invalid");
assert_eq!(stored_tag_delete_secret(&config), None, "nothing stored");
let _ = dir;
}
#[tokio::test]
async fn tag_delete_install_refuses_an_unsafe_transport() {
let repo = temp_repo("[P26Del]");
for (url, ssl_verify, why) in [
("http://gateway.example.com:8088/", true, "plain http"),
(
"https://gateway.example.com:8043/",
false,
"ssl_verify false",
),
] {
let (dir, config) = temp_config();
let mut seeded = crate::config::load(&config).expect("load");
let profile = seeded.profiles.get_mut("dev").expect("dev");
profile.url = url::Url::parse(url).expect("url");
profile.ssl_verify = ssl_verify;
crate::config::save(&config, &seeded).expect("seed");
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect_err("refused");
assert_eq!(err.code(), "tag_delete_not_configured", "{url}: {err}");
assert!(err.to_string().contains(why), "{url}: {err}");
assert_eq!(stored_tag_delete_secret(&config), None, "{url}");
let _ = dir;
}
}
#[test]
fn tag_delete_transport_check_reads_the_clients_url() {
let _lock = crate::config::ENV_LOCK.lock().expect("env lock");
tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime")
.block_on(transport_check_reads_the_clients_url());
}
async fn transport_check_reads_the_clients_url() {
let repo = temp_repo("[P26Del]");
let loopback = url::Url::parse("http://localhost:9088/").expect("url");
let remote = url::Url::parse("http://gateway.example.com:8088/").expect("url");
let (dir, config) = temp_config();
unsafe { std::env::set_var("IGNITION_URL", remote.as_str()) };
let mut rig = importing_rig();
rig.transport = Some((loopback, true));
let result = webdev_deploy_with(
&rig,
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await;
unsafe { std::env::remove_var("IGNITION_URL") };
let result = result.expect("the client's loopback URL is safe");
assert!(result.tag_delete);
let _ = dir;
let (dir, config) = temp_config();
let mut rig = no_gateway_rig();
rig.transport = Some((remote, true));
let err = webdev_deploy_with(
&rig,
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect_err("the client's remote http URL is refused");
assert_eq!(err.code(), "tag_delete_not_configured", "{err}");
assert!(err.to_string().contains("plain http"), "{err}");
assert_eq!(stored_tag_delete_secret(&config), None);
let _ = dir;
}
#[cfg(unix)]
#[tokio::test]
async fn tag_delete_broken_config_store_refuses_before_any_gateway_request() {
use std::os::unix::fs::PermissionsExt;
let (dir, config) = temp_config();
let repo = temp_repo("[P26Del]");
std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o400))
.expect("read-only");
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect_err("unwritable store refuses");
assert_eq!(err.code(), "config_invalid", "{err}");
std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).expect("restore");
std::fs::write(&config, "not = [valid").expect("break");
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect_err("unparsable store refuses");
assert_ne!(err.code(), "internal");
let _ = dir;
}
#[tokio::test]
async fn deploy_without_tag_delete_packs_no_tag_delete_member() {
let (dir, config) = temp_config();
let (rig, seen) = recording_rig(&config);
let result = webdev_deploy_with(&rig, "ign-cli", true, false, &config, "dev", false, None)
.await
.expect("deploy");
assert!(!result.tag_delete && !result.tag_delete_secret_rotated);
assert!(result.tag_delete_providers.is_empty());
assert!(!result.routes.contains(&"tagDelete".to_string()));
let names = member_names(&seen.lock().expect("lock").0);
assert!(names.iter().all(|name| !name.contains("tagDelete")));
assert_eq!(stored_tag_delete_secret(&config), None);
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
let _ = dir;
}
#[tokio::test]
async fn deploy_without_tag_delete_removes_the_stored_secret_and_warns() {
let (dir, config) = temp_config();
let mut seeded = crate::config::load(&config).expect("load");
seeded
.profiles
.get_mut("dev")
.expect("dev")
.webdev_tag_delete_secret = Some("a".repeat(64));
crate::config::save(&config, &seeded).expect("seed");
let result = webdev_deploy(
&importing_rig(),
"Other",
false,
false,
&config,
"dev",
false,
)
.await
.expect("deploy");
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert!(stored_tag_delete_secret(&config).is_some());
let (rig, seen) = recording_rig(&config);
let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
.await
.expect("the deploy is not refused");
assert!(!result.tag_delete);
assert_eq!(stored_tag_delete_secret(&config), None, "secret removed");
assert_eq!(seen.lock().expect("lock").1, None, "removed before upload");
assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
let warning = &result.warnings[0];
assert!(warning.contains("tagDelete"), "{warning}");
assert!(warning.contains("removed"), "{warning}");
assert!(warning.contains("--with-tag-delete"), "{warning}");
assert!(!warning.contains(&"a".repeat(64)), "{warning}");
assert!(stored_secret(&config).is_some(), "scriptExec kept");
let profile = crate::config::load(&config)
.expect("reload")
.profiles
.remove("dev")
.expect("dev");
match crate::deploy::tag_delete::tag_delete_config("dev", &profile) {
crate::deploy::tag_delete::TagDeleteConfig::NotConfigured { missing } => {
assert!(
missing
.iter()
.any(|part| part == "webdev_tag_delete_secret"),
"{missing:?}"
);
}
other => panic!("expected NotConfigured, got {other:?}"),
}
let result = webdev_deploy(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
)
.await
.expect("deploy");
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
let _ = dir;
}
#[tokio::test]
async fn deploy_with_tag_delete_keeps_the_secret_and_does_not_warn() {
let (dir, config) = temp_config();
let repo = temp_repo("[P26Del]");
for _ in 0..2 {
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("deploy");
assert!(
!result.warnings.iter().any(|w| w.contains("removed")),
"{:?}",
result.warnings
);
assert!(stored_tag_delete_secret(&config).is_some());
}
let _ = dir;
}
fn config_with_route_secret(url: &str, secret: &str) -> (tempfile::TempDir, PathBuf) {
let (dir, config) = temp_config();
let mut seeded = crate::config::load(&config).expect("load");
let profile = seeded.profiles.get_mut("dev").expect("dev");
profile.url = url::Url::parse(url).expect("url");
profile.webdev_tag_delete_secret = Some(secret.to_string());
crate::config::save(&config, &seeded).expect("seed");
(dir, config)
}
async fn mount_live_route(server: &wiremock::MockServer, providers: Option<&[&str]>) {
let response = match providers {
Some(providers) => wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"ok": true, "data": {
"routeVersion": BUNDLE_VERSION, "minCli": "1.0.0",
"providers": providers, "maxPaths": 500, "maxPathChars": 1024,
}}),
),
None => wiremock::ResponseTemplate::new(405),
};
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/system/webdev/ign-cli/cli/tagDelete",
))
.and(wiremock::matchers::header(
"X-Ignition-CLI-Secret",
"c".repeat(64).as_str(),
))
.respond_with(response)
.mount(server)
.await;
}
fn caller() -> Option<(crate::config::Secret, crate::config::Secret)> {
Some((
crate::config::Secret::new("deployer"),
crate::config::Secret::new("Pw-fake-Z9"),
))
}
#[tokio::test]
async fn tag_delete_install_refuses_to_drop_live_providers() {
let server = wiremock::MockServer::start().await;
mount_live_route(&server, Some(&["PA", "PB"])).await;
let secret = "c".repeat(64);
let (dir, config) = config_with_route_secret(&server.uri(), &secret);
let repo_a = temp_repo("[PA]");
let repo_b = temp_repo("[PB]");
let mut request = tag_delete_request(repo_b.path(), false);
request.caller = caller();
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(request.clone()),
)
.await
.expect_err("dropping PA is refused");
assert_eq!(err.code(), "invalid_input", "{err}");
let text = err.to_string();
assert!(text.contains("PA"), "{text}");
assert!(text.contains("--tag-delete-repo"), "{text}");
assert!(text.contains("without --with-tag-delete"), "{text}");
assert!(!text.contains(&secret), "{text}");
let hint = err.hint().unwrap_or_default();
assert!(hint.contains("--tag-delete-repo"), "{hint}");
assert_eq!(
stored_tag_delete_secret(&config).as_deref(),
Some(secret.as_str()),
"nothing stored"
);
let mut rotate = request.clone();
rotate.rotate_secret = true;
let err = webdev_deploy_with(
&no_gateway_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(rotate),
)
.await
.expect_err("a rotate is refused too");
assert_eq!(err.code(), "invalid_input", "{err}");
request.repos = vec![repo_a.path().to_path_buf(), repo_b.path().to_path_buf()];
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(request),
)
.await
.expect("the union keeps every live provider");
assert_eq!(result.tag_delete_providers, vec!["PA", "PB"]);
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
let _ = dir;
}
#[tokio::test]
async fn tag_delete_install_warns_when_the_live_allowlist_is_unreadable() {
let secret = "c".repeat(64);
let repo = temp_repo("[PB]");
let server = wiremock::MockServer::start().await;
mount_live_route(&server, Some(&["PA"])).await;
let (dir, config) = config_with_route_secret(&server.uri(), &secret);
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(tag_delete_request(repo.path(), false)),
)
.await
.expect("installs");
assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
assert!(
result.warnings[0].contains("could not read the live tagDelete allowlist"),
"{:?}",
result.warnings
);
assert!(!result.warnings[0].contains(&secret));
let _ = dir;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let (dir, config) = config_with_route_secret(&server.uri(), &secret);
let mut request = tag_delete_request(repo.path(), false);
request.caller = caller();
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(request.clone()),
)
.await
.expect("installs");
assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
assert!(result.warnings[0].contains("could not read the live tagDelete allowlist"));
let _ = dir;
let server = wiremock::MockServer::start().await;
mount_live_route(&server, None).await;
let (dir, config) = config_with_route_secret(&server.uri(), &secret);
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(request.clone()),
)
.await
.expect("installs");
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
let _ = dir;
let (dir, config) = temp_config();
let result = webdev_deploy_with(
&importing_rig(),
"ign-cli",
false,
false,
&config,
"dev",
false,
Some(request),
)
.await
.expect("installs");
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
let _ = dir;
}
#[test]
fn generated_secrets_are_hex_and_unique() {
let a = generate_secret().expect("secret");
let b = generate_secret().expect("secret");
assert_eq!(a.len(), 64);
assert_ne!(a, b);
}
fn member_names(zip_bytes: &[u8]) -> Vec<String> {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
(0..archive.len())
.map(|index| archive.by_index(index).expect("member").name().to_string())
.collect()
}
fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
let mut file = archive.by_name(name).expect("member present");
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
bytes
}
}