1use std::path::{Path, PathBuf};
32
33use serde::Serialize;
34
35use crate::client::GatewayApi;
36use crate::client::webdev::{self as seam, RouteProbe};
37use crate::config;
38use crate::error::{CoreError, TAG_DELETE_DROP_REFUSAL_PREFIX};
39use crate::webdev as bundle;
40
41pub(crate) const SCRIPT_EXEC_ROUTE: &str = "scriptExec";
46
47pub(crate) const SECRET_HEADER: &str = "X-Ignition-CLI-Secret";
51
52pub(crate) const TAG_DELETE_ROUTE: &str = crate::error::TAG_DELETE_ROUTE_NAME;
54
55#[derive(Debug, Clone)]
59pub struct TagDeleteRequest {
60 pub repos: Vec<PathBuf>,
63 pub user_source: String,
65 pub role: String,
67 pub rotate_secret: bool,
69 pub caller: Option<(config::Secret, config::Secret)>,
74}
75
76#[derive(Debug, Serialize)]
80pub struct WebdevDeployResult {
81 pub project: String,
83 pub routes: Vec<String>,
86 pub script_exec: bool,
88 pub secret_rotated: bool,
91 pub import: serde_json::Value,
94 pub tag_delete: bool,
96 pub tag_delete_secret_rotated: bool,
99 pub tag_delete_providers: Vec<String>,
102 pub warnings: Vec<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct RouteStatusRow {
110 pub route: String,
112 pub status: RouteStatus,
114 pub deployed_version: Option<String>,
116 pub expected_version: Option<String>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum RouteStatus {
125 Present,
127 Absent,
129 Unlicensed,
131 AuthGated,
133 SecretMismatch,
136 VersionMismatch,
138}
139
140#[derive(Debug, Serialize)]
142pub struct WebdevStatusResult {
143 pub project: String,
145 pub routes: Vec<RouteStatusRow>,
148 pub ok: bool,
152}
153
154pub async fn webdev_deploy(
165 api: &dyn GatewayApi,
166 project: &str,
167 with_script_exec: bool,
168 rotate_secret: bool,
169 config_path: &Path,
170 profile_name: &str,
171 with_testing: bool,
172) -> Result<WebdevDeployResult, CoreError> {
173 webdev_deploy_with(
174 api,
175 project,
176 with_script_exec,
177 rotate_secret,
178 config_path,
179 profile_name,
180 with_testing,
181 None,
182 )
183 .await
184}
185
186#[allow(clippy::too_many_arguments)]
194pub async fn webdev_deploy_with(
195 api: &dyn GatewayApi,
196 project: &str,
197 with_script_exec: bool,
198 rotate_secret: bool,
199 config_path: &Path,
200 profile_name: &str,
201 with_testing: bool,
202 tag_delete: Option<TagDeleteRequest>,
203) -> Result<WebdevDeployResult, CoreError> {
204 let mut config = config::load(config_path)?;
207 let profile = config.profiles.get(profile_name);
208 let existing = profile.and_then(|profile| profile.webdev_secret.clone());
209 let existing_tag_delete = profile.and_then(|profile| profile.webdev_tag_delete_secret.clone());
210
211 let (pack_secret, secret_rotated) = if rotate_secret || (with_script_exec && existing.is_none())
212 {
213 (Some(generate_secret()?), true)
214 } else if with_script_exec {
215 (existing, false)
216 } else {
217 (None, false)
218 };
219
220 let mut warnings = Vec::new();
221
222 let (install, tag_delete_secret_rotated) = match &tag_delete {
225 None => (None, false),
226 Some(request) => {
227 let transport = api.gateway_transport().or_else(|| {
234 config
235 .profiles
236 .get(profile_name)
237 .map(|profile| (profile.url.clone(), profile.ssl_verify))
238 });
239 if let Some((url, ssl_verify)) = &transport
240 && let Some(refusal) =
241 crate::deploy::tag_delete::transport_refusal(url, *ssl_verify)
242 {
243 return Err(CoreError::TagDeleteNotConfigured {
244 profile: profile_name.to_string(),
245 missing: vec![refusal],
246 });
247 }
248 let mut providers: Vec<String> = Vec::new();
249 for repo in &request.repos {
250 for provider in crate::deploy::projects::load_project_file(repo)?.tag_providers {
251 if !providers.contains(&provider) {
252 providers.push(provider);
253 }
254 }
255 }
256 let (secret, rotated) = match existing_tag_delete.clone() {
257 Some(secret) if !request.rotate_secret => (secret, false),
258 _ => (generate_secret()?, true),
259 };
260 let install = seam::TagDeleteInstall {
261 secret,
262 user_source: request.user_source.clone(),
263 role: request.role.clone(),
264 providers,
265 };
266 seam::validate_tag_delete_install(&install)?;
267 if let Some(stored) = &existing_tag_delete {
272 match read_live_allowlist(
273 profile_name,
274 transport.as_ref(),
275 project,
276 request.caller.clone(),
277 stored,
278 )
279 .await
280 {
281 LiveAllowlist::Read(live) => {
282 refuse_dropped_providers(project, &live, &install.providers)?;
283 }
284 LiveAllowlist::Absent => {}
285 LiveAllowlist::Unreadable(reason) => warnings.push(format!(
286 "could not read the live tagDelete allowlist on project {project} \
287 ({reason}); the install replaced it with [{}], so check that no \
288 other repo's providers were dropped",
289 install.providers.join(", ")
290 )),
291 }
292 }
293 (Some(install), rotated)
294 }
295 };
296
297 let tag_delete_removed =
305 tag_delete.is_none() && project == seam::DEFAULT_PROJECT && existing_tag_delete.is_some();
306 if tag_delete_removed {
307 warnings.push(format!(
308 "tagDelete route removed from project {project}: this deploy did not include \
309 --with-tag-delete, so the overwrite import dropped it; the stored tagDelete \
310 secret was removed from profile {profile_name:?}, and tag-owning runs refuse \
311 tag_delete_not_configured until `ign webdev deploy --with-tag-delete` reinstalls it"
312 ));
313 }
314
315 if secret_rotated || tag_delete_secret_rotated || tag_delete_removed {
316 let entry = config.profiles.get_mut(profile_name).ok_or_else(|| {
317 CoreError::Internal(format!(
318 "profile {profile_name:?} vanished from the config between dispatch and deploy"
319 ))
320 })?;
321 if secret_rotated {
322 entry.webdev_secret = pack_secret.clone();
323 }
324 if tag_delete_secret_rotated {
325 entry.webdev_tag_delete_secret = install.as_ref().map(|install| install.secret.clone());
326 }
327 if tag_delete_removed {
328 entry.webdev_tag_delete_secret = None;
329 }
330 config::save(config_path, &config)?; }
332
333 let zip = seam::build_deploy_zip_with(
338 project,
339 with_script_exec,
340 pack_secret.as_deref(),
341 with_testing,
342 install.as_ref(),
343 )?;
344 let mut routes = seam::always_on_routes();
345 if with_script_exec {
346 routes.push(SCRIPT_EXEC_ROUTE.to_string());
347 }
348 if install.is_some() {
349 routes.push(TAG_DELETE_ROUTE.to_string());
350 }
351 if with_testing {
352 routes.extend(
353 crate::webdev::testing::TESTING_ROUTES
354 .iter()
355 .map(|route| format!("testing/{route}")),
356 );
357 }
358 let import = api.project_import(project, zip, true).await?;
359
360 Ok(WebdevDeployResult {
361 project: project.to_string(),
362 routes,
363 script_exec: with_script_exec,
364 secret_rotated,
365 import: import.response,
366 tag_delete: install.is_some(),
367 tag_delete_secret_rotated,
368 tag_delete_providers: install.map(|install| install.providers).unwrap_or_default(),
369 warnings,
370 })
371}
372
373enum LiveAllowlist {
376 Read(Vec<String>),
378 Absent,
380 Unreadable(String),
382}
383
384async fn read_live_allowlist(
387 profile_name: &str,
388 transport: Option<&(url::Url, bool)>,
389 project: &str,
390 caller: Option<(config::Secret, config::Secret)>,
391 stored_secret: &str,
392) -> LiveAllowlist {
393 use crate::deploy::tag_delete::{TagDeleteClient, TagDeleteRoute};
394 let Some((url, ssl_verify)) = transport else {
395 return LiveAllowlist::Unreadable("no gateway URL is known for the profile".to_string());
396 };
397 let Some((user, password)) = caller else {
398 let (user, password) = config::secret::tag_delete_env_names(profile_name);
399 return LiveAllowlist::Unreadable(format!("{user} and {password} are not both set"));
400 };
401 let client = match TagDeleteClient::new(
402 profile_name,
403 url,
404 *ssl_verify,
405 project,
406 user,
407 password,
408 config::Secret::new(stored_secret),
409 ) {
410 Ok(client) => client,
411 Err(err) => return LiveAllowlist::Unreadable(err.code().to_string()),
412 };
413 match client.handshake().await {
414 Ok(handshake) => LiveAllowlist::Read(handshake.providers),
415 Err(CoreError::RoutesNotDeployed { .. }) => LiveAllowlist::Absent,
416 Err(err) => LiveAllowlist::Unreadable(format!("the handshake answered {}", err.code())),
417 }
418}
419
420fn refuse_dropped_providers(
422 project: &str,
423 live: &[String],
424 new: &[String],
425) -> Result<(), CoreError> {
426 let dropped: Vec<&str> = live
427 .iter()
428 .filter(|provider| !new.contains(provider))
429 .map(String::as_str)
430 .collect();
431 if dropped.is_empty() {
432 return Ok(());
433 }
434 Err(CoreError::InvalidInput {
435 reason: format!(
436 "{TAG_DELETE_DROP_REFUSAL_PREFIX} {} from the live tagDelete allowlist on project \
437 {project} (allowed now: {}; this install: {}); nothing was stored or imported. \
438 Re-run naming every repo whose providers the route must keep (repeat \
439 --tag-delete-repo), or replace the allowlist deliberately: run `ign webdev \
440 deploy` without --with-tag-delete first (it removes the route and its stored \
441 secret), then install",
442 dropped.join(", "),
443 live.join(", "),
444 new.join(", ")
445 ),
446 })
447}
448
449pub async fn webdev_status(
454 api: &dyn GatewayApi,
455 project: &str,
456 secret: Option<&str>,
457) -> Result<WebdevStatusResult, CoreError> {
458 let mut routes = Vec::new();
459 let mut ok = true;
460 for route in seam::always_on_routes() {
461 let probe = api.webdev_route_probe(project, &route, &[]).await?;
462 let row = classify_probe(&route, probe);
463 ok &= row.status == RouteStatus::Present;
464 routes.push(row);
465 }
466 if let Some(secret) = secret {
467 let probe = api
468 .webdev_route_probe(project, SCRIPT_EXEC_ROUTE, &[(SECRET_HEADER, secret)])
469 .await?;
470 routes.push(classify_probe(SCRIPT_EXEC_ROUTE, probe));
472 }
473 Ok(WebdevStatusResult {
474 project: project.to_string(),
475 routes,
476 ok,
477 })
478}
479
480pub async fn webdev_precondition(api: &dyn GatewayApi, project: &str) -> Result<(), CoreError> {
487 const ROUTE: &str = "tags";
488 let endpoint = seam::route_url(project, ROUTE);
489 match api.webdev_route_probe(project, ROUTE, &[]).await? {
490 RouteProbe::Present { route_version } if route_version == bundle::ROUTE_BUNDLE_VERSION => {
491 Ok(())
492 }
493 RouteProbe::Present { route_version } => Err(CoreError::RouteVersionMismatch {
494 route: ROUTE.to_string(),
495 deployed: route_version,
496 expected: bundle::ROUTE_BUNDLE_VERSION.to_string(),
497 endpoint: Some(endpoint),
498 }),
499 RouteProbe::Absent => Err(CoreError::RoutesNotDeployed {
500 project: project.to_string(),
501 route: ROUTE.to_string(),
502 endpoint: Some(endpoint),
503 }),
504 RouteProbe::Unlicensed => Err(CoreError::WebdevUnlicensed {
505 endpoint: Some(endpoint),
506 }),
507 RouteProbe::AuthGated => Err(CoreError::Auth {
508 status: 401,
509 endpoint: Some(endpoint),
510 }),
511 RouteProbe::Denied {
512 code,
513 message,
514 traceback,
515 } => {
516 let mut full = message;
517 if let Some(traceback) = traceback {
518 full.push_str("\nroute traceback: ");
519 full.push_str(&traceback);
520 }
521 Err(CoreError::WebdevRouteError {
522 code,
523 message: full,
524 endpoint: Some(endpoint),
525 })
526 }
527 }
528}
529
530fn classify_probe(route: &str, probe: RouteProbe) -> RouteStatusRow {
533 let expected = bundle::ROUTE_BUNDLE_VERSION;
534 match probe {
535 RouteProbe::Present { route_version } => {
536 let status = if route_version == expected {
537 RouteStatus::Present
538 } else {
539 RouteStatus::VersionMismatch
540 };
541 RouteStatusRow {
542 route: route.to_string(),
543 status,
544 deployed_version: Some(route_version),
545 expected_version: Some(expected.to_string()),
546 }
547 }
548 RouteProbe::Denied { code, .. } => {
549 let status = if code == "secret_required" || code == "secret_mismatch" {
555 RouteStatus::SecretMismatch
556 } else {
557 RouteStatus::VersionMismatch
558 };
559 absent_row(route, status)
560 }
561 RouteProbe::Absent => absent_row(route, RouteStatus::Absent),
562 RouteProbe::Unlicensed => absent_row(route, RouteStatus::Unlicensed),
563 RouteProbe::AuthGated => absent_row(route, RouteStatus::AuthGated),
564 }
565}
566
567fn absent_row(route: &str, status: RouteStatus) -> RouteStatusRow {
569 RouteStatusRow {
570 route: route.to_string(),
571 status,
572 deployed_version: None,
573 expected_version: Some(bundle::ROUTE_BUNDLE_VERSION.to_string()),
574 }
575}
576
577fn generate_secret() -> Result<String, CoreError> {
583 let mut bytes = [0u8; 32];
584 getrandom::getrandom(&mut bytes)
585 .map_err(|err| CoreError::Internal(format!("cannot generate the deploy secret: {err}")))?;
586 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
587}
588
589#[cfg(test)]
590mod tests {
591 use super::{
592 RouteProbe, RouteStatus, TagDeleteRequest, WebdevDeployResult, generate_secret,
593 webdev_deploy, webdev_deploy_with, webdev_precondition, webdev_status,
594 };
595 use crate::client::GatewayApi;
596 use crate::client::projects::ImportOutcome;
597 use crate::error::CoreError;
598 use crate::webdev::ROUTE_BUNDLE_VERSION as BUNDLE_VERSION;
599 use std::path::PathBuf;
600
601 struct WebdevRig {
607 probe: fn(&str) -> Result<RouteProbe, CoreError>,
608 transport: Option<(url::Url, bool)>,
611 import: Box<dyn Fn(Vec<u8>, bool) -> Result<ImportOutcome, CoreError> + Send + Sync>,
612 }
613
614 fn present(version: &str) -> Result<RouteProbe, CoreError> {
615 Ok(RouteProbe::Present {
616 route_version: version.to_string(),
617 })
618 }
619
620 fn ok_import() -> ImportOutcome {
621 ImportOutcome {
622 response: serde_json::json!({"success": true}),
623 }
624 }
625
626 #[async_trait::async_trait]
627 impl GatewayApi for WebdevRig {
628 fn gateway_transport(&self) -> Option<(url::Url, bool)> {
629 self.transport.clone()
630 }
631 async fn thread_dump(
632 &self,
633 ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
634 unreachable!("not part of this action")
635 }
636 async fn thread_deadlocks(
637 &self,
638 ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
639 unreachable!("not part of this action")
640 }
641 async fn uninstall_module(
642 &self,
643 _gateway_module_id: &str,
644 _module_id: &str,
645 ) -> Result<(), CoreError> {
646 unreachable!("not part of this action")
647 }
648 async fn bundle_generate(
649 &self,
650 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
651 unreachable!("not part of this action")
652 }
653 async fn bundle_status(
654 &self,
655 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
656 unreachable!("not part of this action")
657 }
658 async fn bundle_download(
659 &self,
660 _out: &std::path::Path,
661 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
662 unreachable!("not part of this action")
663 }
664 async fn tag_provider_list(
665 &self,
666 _query: &crate::client::query::ListQuery,
667 ) -> Result<
668 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
669 CoreError,
670 > {
671 unreachable!("not part of this action")
672 }
673 async fn tag_provider_find(
674 &self,
675 _name: &str,
676 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
677 unreachable!("not part of this action")
678 }
679 async fn tag_provider_create(
680 &self,
681 _body: &[crate::client::tags::TagProviderCreate],
682 ) -> Result<(), CoreError> {
683 unreachable!("not part of this action")
684 }
685 async fn tag_provider_delete(
686 &self,
687 _name: &str,
688 _signature: &str,
689 ) -> Result<(), CoreError> {
690 unreachable!("not part of this action")
691 }
692 async fn webdev_route_probe(
693 &self,
694 _project: &str,
695 route: &str,
696 _extra_headers: &[(&str, &str)],
697 ) -> Result<RouteProbe, CoreError> {
698 (self.probe)(route)
699 }
700 async fn project_import(
701 &self,
702 _name: &str,
703 zip: Vec<u8>,
704 overwrite: bool,
705 ) -> Result<ImportOutcome, CoreError> {
706 (self.import)(zip, overwrite)
707 }
708 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
709 unreachable!("not part of this action")
710 }
711 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
712 unreachable!("not part of this action")
713 }
714 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
715 unreachable!("not part of this action")
716 }
717 async fn modules(
718 &self,
719 _quarantined: bool,
720 _query: &crate::client::query::ListQuery,
721 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
722 {
723 unreachable!("not part of this action")
724 }
725 async fn metrics_current(
726 &self,
727 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
728 unreachable!("not part of this action")
729 }
730 async fn metrics_historic(
731 &self,
732 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
733 unreachable!("not part of this action")
734 }
735 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
736 unreachable!("not part of this action")
737 }
738 async fn designers(
739 &self,
740 _query: &crate::client::query::ListQuery,
741 ) -> Result<
742 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
743 CoreError,
744 > {
745 unreachable!("not part of this action")
746 }
747 async fn perspective_sessions(
748 &self,
749 _query: &crate::client::query::ListQuery,
750 ) -> Result<
751 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
752 CoreError,
753 > {
754 unreachable!("not part of this action")
755 }
756 async fn vision_clients(
757 &self,
758 _query: &crate::client::query::ListQuery,
759 ) -> Result<
760 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
761 CoreError,
762 > {
763 unreachable!("not part of this action")
764 }
765 async fn terminate_perspective_session(
766 &self,
767 _id: &str,
768 _message: Option<&str>,
769 ) -> Result<(), CoreError> {
770 unreachable!("not part of this action")
771 }
772 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
773 unreachable!("not part of this action")
774 }
775 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
776 unreachable!("not part of this action")
777 }
778 async fn database_connections(
779 &self,
780 ) -> Result<
781 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
782 CoreError,
783 > {
784 unreachable!("not part of this action")
785 }
786 async fn opc_connections(
787 &self,
788 ) -> Result<
789 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
790 CoreError,
791 > {
792 unreachable!("not part of this action")
793 }
794 async fn logs(
795 &self,
796 _filter: &crate::client::logs::LogQuery,
797 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
798 {
799 unreachable!("not part of this action")
800 }
801 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
802 unreachable!("not part of this action")
803 }
804 async fn loggers(
805 &self,
806 _query: &crate::client::query::ListQuery,
807 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
808 {
809 unreachable!("not part of this action")
810 }
811 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
812 unreachable!("not part of this action")
813 }
814 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
815 unreachable!("not part of this action")
816 }
817 async fn restart(&self) -> Result<(), CoreError> {
818 unreachable!("not part of this action")
819 }
820 async fn scan_projects(&self) -> Result<(), CoreError> {
821 unreachable!("not part of this action")
822 }
823 async fn security_properties(
824 &self,
825 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
826 unreachable!("not part of this action")
827 }
828 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
829 unreachable!("not part of this action")
830 }
831 async fn webdev_route_call(
832 &self,
833 _project: &str,
834 _route: &str,
835 _body: &serde_json::Value,
836 _extra_headers: &[(&str, &str)],
837 ) -> Result<serde_json::Value, CoreError> {
838 unreachable!("not part of this action")
839 }
840 async fn projects(
841 &self,
842 _query: &crate::client::query::ListQuery,
843 ) -> Result<
844 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
845 CoreError,
846 > {
847 unreachable!("not part of this action")
848 }
849 async fn project_find(
850 &self,
851 _name: &str,
852 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
853 unreachable!("not part of this action")
854 }
855 async fn project_create(
856 &self,
857 _body: &crate::client::projects::ProjectCreate,
858 ) -> Result<(), CoreError> {
859 unreachable!("not part of this action")
860 }
861 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
862 unreachable!("not part of this action")
863 }
864 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
865 unreachable!("not part of this action")
866 }
867 async fn project_modify(
868 &self,
869 _name: &str,
870 _body: &crate::client::projects::ProjectModify,
871 ) -> Result<(), CoreError> {
872 unreachable!("not part of this action")
873 }
874 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
875 unreachable!("not part of this action")
876 }
877 async fn project_export_to_file(
878 &self,
879 _name: &str,
880 _out: &std::path::Path,
881 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
882 unreachable!("not part of this action")
883 }
884 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
885 unreachable!("not part of this action")
886 }
887 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
888 unreachable!("not part of this action")
889 }
890 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
891 unreachable!("not part of this action")
892 }
893 async fn backup_download(
894 &self,
895 _out: &std::path::Path,
896 _backup_type: crate::client::backup::BackupType,
897 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
898 unreachable!("not part of this action")
899 }
900 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
901 unreachable!("not part of this action")
902 }
903 async fn eam_task_history(
904 &self,
905 _limit: Option<u32>,
906 _search: Option<&str>,
907 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
908 {
909 unreachable!("not part of this action")
910 }
911 async fn eam_task_definitions(
912 &self,
913 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
914 {
915 unreachable!("not part of this action")
916 }
917 async fn eam_task_find(
918 &self,
919 _name: &str,
920 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
921 unreachable!("not part of this action")
922 }
923 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
924 unreachable!("not part of this action")
925 }
926 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
927 unreachable!("not part of this action")
928 }
929 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
930 unreachable!("not part of this action")
931 }
932 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
933 unreachable!("not part of this action")
934 }
935 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
936 unreachable!("not part of this action")
937 }
938 async fn eam_tasks_scheduled(
939 &self,
940 _running: bool,
941 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
942 unreachable!("not part of this action")
943 }
944 async fn eam_task_modify(
945 &self,
946 _definition: &serde_json::Value,
947 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
948 unreachable!("not part of this action")
949 }
950 async fn eam_task_delete(
951 &self,
952 _name: &str,
953 _signature: &str,
954 _confirm: bool,
955 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
956 unreachable!("not part of this action")
957 }
958 async fn api_call(
959 &self,
960 _call: &crate::client::apicall::ApiCallRequest,
961 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
962 unreachable!("not part of this action")
963 }
964 async fn license_status(
965 &self,
966 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
967 unreachable!("not part of this action")
968 }
969 async fn redundancy_status(
970 &self,
971 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
972 unreachable!("not part of this action")
973 }
974 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
975 unreachable!("not part of this action")
976 }
977 }
978
979 fn temp_config() -> (tempfile::TempDir, PathBuf) {
982 let dir = tempfile::tempdir().expect("tempdir");
983 let path = dir.path().join("config.toml");
984 std::fs::write(
985 &path,
986 "active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n",
987 )
988 .expect("write config");
989 (dir, path)
990 }
991
992 fn stored_secret(path: &std::path::Path) -> Option<String> {
994 crate::config::load(path)
995 .expect("config reloads")
996 .profiles
997 .get("dev")
998 .and_then(|profile| profile.webdev_secret.clone())
999 }
1000
1001 fn importing_rig() -> WebdevRig {
1003 WebdevRig {
1004 probe: |_| unreachable!("deploy never probes"),
1005 transport: None,
1006 import: Box::new(|_zip, _overwrite| Ok(ok_import())),
1007 }
1008 }
1009
1010 #[tokio::test]
1013 async fn deploy_without_script_exec_ships_only_the_always_on_bundle() {
1014 let (dir, config) = temp_config();
1015 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1016 let recorder = std::sync::Arc::clone(&seen_zip);
1017 let rig = WebdevRig {
1018 probe: |_| unreachable!("deploy never probes"),
1019 transport: None,
1020 import: Box::new(move |zip, overwrite| {
1021 assert!(overwrite, "deploy ALWAYS overwrite-imports");
1022 *recorder.lock().expect("zip lock") = zip;
1023 Ok(ok_import())
1024 }),
1025 };
1026 let result = webdev_deploy(&rig, "ign-cli", false, false, &config, "dev", false)
1027 .await
1028 .expect("plain deploy");
1029 assert_eq!(
1030 result.routes,
1031 vec!["tags", "tagConfig", "alarms", "tagHistory"]
1032 );
1033 assert!(!result.script_exec);
1034 assert!(!result.secret_rotated);
1035 assert_eq!(result.import["success"], true);
1036
1037 let names = member_names(&seen_zip.lock().expect("zip lock"));
1040 assert!(names.iter().all(|name| !name.contains("scriptExec")));
1041 assert_eq!(stored_secret(&config), None);
1042 let _ = dir; }
1044
1045 #[tokio::test]
1049 async fn deploy_with_script_exec_generates_and_redacts_the_secret() {
1050 let (dir, config) = temp_config();
1051 let seen_zip = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1052 let recorder = std::sync::Arc::clone(&seen_zip);
1053 let rig = WebdevRig {
1054 probe: |_| unreachable!("deploy never probes"),
1055 transport: None,
1056 import: Box::new(move |zip, _| {
1057 *recorder.lock().expect("zip lock") = zip;
1058 Ok(ok_import())
1059 }),
1060 };
1061 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1062 .await
1063 .expect("scriptExec deploy");
1064 assert_eq!(
1065 result.routes,
1066 vec!["tags", "tagConfig", "alarms", "tagHistory", "scriptExec"]
1067 );
1068 assert!(result.script_exec && result.secret_rotated);
1069
1070 let secret = stored_secret(&config).expect("secret persisted");
1071 assert_eq!(secret.len(), 64, "32 bytes hex-encoded");
1072 assert!(
1073 secret.chars().all(|c| c.is_ascii_hexdigit()),
1074 "hex alphabet: {secret}"
1075 );
1076 #[cfg(unix)]
1077 {
1078 use std::os::unix::fs::PermissionsExt;
1079 let mode = std::fs::metadata(&config)
1080 .expect("config stat")
1081 .permissions()
1082 .mode();
1083 assert_eq!(mode & 0o777, 0o600, "the save path re-asserts 0600");
1084 }
1085
1086 let do_post = member(
1089 &seen_zip.lock().expect("zip lock"),
1090 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
1091 );
1092 assert!(String::from_utf8_lossy(&do_post).contains(&secret));
1093 let serialized = serde_json::to_string(&result).expect("result serializes");
1094 assert!(!serialized.contains(&secret), "redaction: {serialized}");
1095 let envelope_check: WebdevDeployResult = result;
1096 let again = serde_json::to_string(&envelope_check).expect("serializes");
1097 assert!(!again.contains(&secret));
1098 let _ = dir;
1099 }
1100
1101 #[tokio::test]
1104 async fn rotate_regenerates_and_plain_redeploy_reuses() {
1105 let (dir, config) = temp_config();
1106 let mut seeded = crate::config::load(&config).expect("load");
1108 seeded.profiles.get_mut("dev").unwrap().webdev_secret = Some("aa11".into());
1109 crate::config::save(&config, &seeded).expect("seed save");
1110
1111 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1113 let recorder = std::sync::Arc::clone(&seen);
1114 let rig = WebdevRig {
1115 probe: |_| unreachable!(),
1116 transport: None,
1117 import: Box::new(move |zip, _| {
1118 *recorder.lock().expect("lock") = zip;
1119 Ok(ok_import())
1120 }),
1121 };
1122 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1123 .await
1124 .expect("reuse deploy");
1125 assert!(!result.secret_rotated);
1126 assert_eq!(stored_secret(&config).as_deref(), Some("aa11"));
1127 let seen = seen.lock().expect("lock").clone();
1128 let do_post = member(
1129 &seen,
1130 "com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py",
1131 );
1132 assert!(
1133 String::from_utf8_lossy(&do_post).contains("aa11"),
1134 "the STORED secret rode the zip's scriptExec member"
1135 );
1136
1137 let result = webdev_deploy(
1139 &importing_rig(),
1140 "ign-cli",
1141 true,
1142 true,
1143 &config,
1144 "dev",
1145 false,
1146 )
1147 .await
1148 .expect("rotate deploy");
1149 assert!(result.secret_rotated);
1150 let rotated = stored_secret(&config).expect("rotated secret stored");
1151 assert_eq!(rotated.len(), 64);
1152 assert_ne!(rotated, "aa11");
1153 let _ = dir;
1154 }
1155
1156 #[tokio::test]
1160 async fn status_maps_the_full_probe_matrix() {
1161 let healthy = WebdevRig {
1163 probe: |_| present(BUNDLE_VERSION),
1164 transport: None,
1165 import: Box::new(|_, _| unreachable!("status never imports")),
1166 };
1167 let result = webdev_status(&healthy, "ign-cli", None)
1168 .await
1169 .expect("status sweep");
1170 assert_eq!(result.routes.len(), 4);
1171 assert!(
1172 result
1173 .routes
1174 .iter()
1175 .all(|row| row.status == RouteStatus::Present)
1176 );
1177 assert!(result.ok);
1178
1179 let degraded = WebdevRig {
1182 transport: None,
1183 probe: |route| {
1184 Ok(match route {
1185 "tags" => RouteProbe::Absent,
1186 "tagConfig" => present("9.9.9").expect("fixture"),
1187 "alarms" => present(BUNDLE_VERSION).expect("fixture"),
1188 "tagHistory" => RouteProbe::Unlicensed,
1189 "scriptExec" => RouteProbe::Denied {
1190 code: "secret_mismatch".into(),
1191 message: "mismatch".into(),
1192 traceback: None,
1193 },
1194 _ => RouteProbe::AuthGated,
1195 })
1196 },
1197 import: Box::new(|_, _| unreachable!()),
1198 };
1199 let result = webdev_status(°raded, "ign-cli", Some("stored-secret"))
1200 .await
1201 .expect("degraded sweep still completes");
1202 let by_route = |name: &str| {
1203 result
1204 .routes
1205 .iter()
1206 .find(|row| row.route == name)
1207 .unwrap_or_else(|| panic!("{name} row"))
1208 };
1209 assert_eq!(by_route("tags").status, RouteStatus::Absent);
1210 assert_eq!(by_route("tagConfig").status, RouteStatus::VersionMismatch);
1211 assert_eq!(
1212 by_route("tagConfig").deployed_version.as_deref(),
1213 Some("9.9.9")
1214 );
1215 assert_eq!(by_route("tagHistory").status, RouteStatus::Unlicensed);
1216 assert_eq!(by_route("scriptExec").status, RouteStatus::SecretMismatch);
1217 assert!(!result.ok);
1218 let gated_exec = WebdevRig {
1221 transport: None,
1222 probe: |route| {
1223 if route == "scriptExec" {
1224 Ok(RouteProbe::AuthGated)
1225 } else {
1226 present(BUNDLE_VERSION)
1227 }
1228 },
1229 import: Box::new(|_, _| unreachable!()),
1230 };
1231 let result = webdev_status(&gated_exec, "ign-cli", Some("s"))
1232 .await
1233 .expect("sweep");
1234 assert!(result.ok, "scriptExec never gates ok");
1235 assert_eq!(result.routes.len(), 5);
1236 }
1237
1238 #[tokio::test]
1242 async fn precondition_refuses_the_undeployed_and_mismatched() {
1243 let undeployed = WebdevRig {
1244 probe: |_| Ok(RouteProbe::Absent),
1245 transport: None,
1246 import: Box::new(|_, _| unreachable!()),
1247 };
1248 let err = webdev_precondition(&undeployed, "ign-cli")
1249 .await
1250 .expect_err("absent refuses");
1251 assert_eq!(err.code(), "routes_not_deployed");
1252 assert_eq!(err.exit_code(), 6);
1253 assert!(
1254 err.hint().unwrap().contains("ign webdev deploy"),
1255 "hint names the fix"
1256 );
1257
1258 let older = WebdevRig {
1259 probe: |_| present("0.9.0"),
1260 transport: None,
1261 import: Box::new(|_, _| unreachable!()),
1262 };
1263 let err = webdev_precondition(&older, "ign-cli")
1264 .await
1265 .expect_err("older refuses");
1266 assert_eq!(err.code(), "route_version_mismatch");
1267 assert!(
1268 err.to_string().contains("0.9.0") && err.to_string().contains(BUNDLE_VERSION),
1269 "both versions named: {err}"
1270 );
1271
1272 let matching = WebdevRig {
1273 probe: |_| present(BUNDLE_VERSION),
1274 transport: None,
1275 import: Box::new(|_, _| unreachable!()),
1276 };
1277 webdev_precondition(&matching, "ign-cli")
1278 .await
1279 .expect("matching handshake passes");
1280 }
1281
1282 const TAG_DELETE_DO_POST: &str =
1283 "com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py";
1284 const TAG_DELETE_CONFIG: &str =
1285 "com.inductiveautomation.webdev/resources/cli/tagDelete/config.json";
1286
1287 fn temp_repo(providers: &str) -> tempfile::TempDir {
1290 let dir = tempfile::tempdir().expect("tempdir");
1291 std::fs::create_dir_all(dir.path().join("deploy")).expect("deploy dir");
1292 std::fs::write(
1293 dir.path().join("deploy/project.yaml"),
1294 format!(
1295 "schema_version: 1\nproject: Demo\nenvironments: [dev]\ntag_providers: {providers}\n"
1296 ),
1297 )
1298 .expect("project.yaml");
1299 dir
1300 }
1301
1302 fn tag_delete_request(repo: &std::path::Path, rotate_secret: bool) -> TagDeleteRequest {
1303 TagDeleteRequest {
1304 repos: vec![repo.to_path_buf()],
1305 user_source: "default".into(),
1306 role: "FakeDeployRole".into(),
1307 rotate_secret,
1308 caller: None,
1309 }
1310 }
1311
1312 fn stored_tag_delete_secret(path: &std::path::Path) -> Option<String> {
1313 crate::config::load(path)
1314 .expect("config reloads")
1315 .profiles
1316 .get("dev")
1317 .and_then(|profile| profile.webdev_tag_delete_secret.clone())
1318 }
1319
1320 fn no_gateway_rig() -> WebdevRig {
1323 WebdevRig {
1324 probe: |_| unreachable!("deploy never probes"),
1325 transport: None,
1326 import: Box::new(|_, _| panic!("no gateway request may happen")),
1327 }
1328 }
1329
1330 #[allow(clippy::type_complexity)]
1333 fn recording_rig(
1334 config: &std::path::Path,
1335 ) -> (
1336 WebdevRig,
1337 std::sync::Arc<std::sync::Mutex<(Vec<u8>, Option<String>)>>,
1338 ) {
1339 let seen = std::sync::Arc::new(std::sync::Mutex::new((Vec::new(), None)));
1340 let recorder = std::sync::Arc::clone(&seen);
1341 let config = config.to_path_buf();
1342 let rig = WebdevRig {
1343 probe: |_| unreachable!("deploy never probes"),
1344 transport: None,
1345 import: Box::new(move |zip, overwrite| {
1346 assert!(overwrite);
1347 *recorder.lock().expect("lock") = (zip, stored_tag_delete_secret(&config));
1348 Ok(ok_import())
1349 }),
1350 };
1351 (rig, seen)
1352 }
1353
1354 #[tokio::test]
1358 async fn tag_delete_secret_generated_persisted_reused_and_rotated() {
1359 let (dir, config) = temp_config();
1360 let repo = temp_repo("[P26Del]");
1361 let (rig, seen) = recording_rig(&config);
1362 let result = webdev_deploy_with(
1363 &rig,
1364 "ign-cli",
1365 false,
1366 false,
1367 &config,
1368 "dev",
1369 false,
1370 Some(tag_delete_request(repo.path(), false)),
1371 )
1372 .await
1373 .expect("tag delete deploy");
1374 assert!(result.tag_delete && result.tag_delete_secret_rotated);
1375 assert!(!result.script_exec && !result.secret_rotated);
1376 assert_eq!(result.tag_delete_providers, vec!["P26Del".to_string()]);
1377 assert_eq!(
1378 result.routes,
1379 vec!["tags", "tagConfig", "alarms", "tagHistory", "tagDelete"]
1380 );
1381
1382 let secret = stored_tag_delete_secret(&config).expect("secret persisted");
1383 assert_eq!(secret.len(), 64);
1384 assert!(
1385 secret
1386 .bytes()
1387 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1388 );
1389 assert_eq!(
1390 stored_secret(&config),
1391 None,
1392 "the scriptExec secret is untouched"
1393 );
1394 #[cfg(unix)]
1395 {
1396 use std::os::unix::fs::PermissionsExt;
1397 let mode = std::fs::metadata(&config)
1398 .expect("stat")
1399 .permissions()
1400 .mode();
1401 assert_eq!(mode & 0o777, 0o600);
1402 }
1403 let (zip, at_upload) = seen.lock().expect("lock").clone();
1404 assert_eq!(
1405 at_upload.as_deref(),
1406 Some(secret.as_str()),
1407 "persisted before upload"
1408 );
1409 assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
1410 let packed_config: serde_json::Value =
1411 serde_json::from_slice(&member(&zip, TAG_DELETE_CONFIG)).expect("config.json");
1412 assert_eq!(packed_config["doPost"]["required-roles"], "FakeDeployRole");
1413 assert_eq!(packed_config["doPost"]["user-source"], "default");
1414
1415 let serialized = serde_json::to_string(&result).expect("serializes");
1416 assert!(!serialized.contains(&secret), "redaction: {serialized}");
1417 assert!(serialized.contains("\"tag_delete\":true"));
1418 assert!(serialized.contains("\"tag_delete_secret_rotated\":true"));
1419 assert!(serialized.contains("\"tag_delete_providers\":[\"P26Del\"]"));
1420 assert!(!format!("{result:?}").contains(&secret));
1421
1422 let (rig, seen) = recording_rig(&config);
1424 let result = webdev_deploy_with(
1425 &rig,
1426 "ign-cli",
1427 false,
1428 false,
1429 &config,
1430 "dev",
1431 false,
1432 Some(tag_delete_request(repo.path(), false)),
1433 )
1434 .await
1435 .expect("reuse deploy");
1436 assert!(result.tag_delete && !result.tag_delete_secret_rotated);
1437 assert_eq!(
1438 stored_tag_delete_secret(&config).as_deref(),
1439 Some(secret.as_str())
1440 );
1441 let (zip, _) = seen.lock().expect("lock").clone();
1442 assert!(String::from_utf8_lossy(&member(&zip, TAG_DELETE_DO_POST)).contains(&secret));
1443
1444 let result = webdev_deploy_with(
1446 &importing_rig(),
1447 "ign-cli",
1448 false,
1449 false,
1450 &config,
1451 "dev",
1452 false,
1453 Some(tag_delete_request(repo.path(), true)),
1454 )
1455 .await
1456 .expect("rotate deploy");
1457 assert!(result.tag_delete_secret_rotated);
1458 let rotated = stored_tag_delete_secret(&config).expect("rotated");
1459 assert_eq!(rotated.len(), 64);
1460 assert_ne!(rotated, secret);
1461 let _ = dir;
1462 }
1463
1464 #[tokio::test]
1467 async fn script_exec_and_tag_delete_secrets_are_independent() {
1468 let (dir, config) = temp_config();
1469 let repo = temp_repo("[P26Del, Other]");
1470 let (rig, seen) = recording_rig(&config);
1471 let result = webdev_deploy_with(
1472 &rig,
1473 "ign-cli",
1474 true,
1475 false,
1476 &config,
1477 "dev",
1478 true,
1479 Some(tag_delete_request(repo.path(), false)),
1480 )
1481 .await
1482 .expect("composed deploy");
1483 assert!(result.script_exec && result.tag_delete);
1484 assert_eq!(
1485 result.tag_delete_providers,
1486 vec!["P26Del".to_string(), "Other".to_string()]
1487 );
1488 assert!(result.routes.contains(&"scriptExec".to_string()));
1489 assert!(result.routes.contains(&"tagDelete".to_string()));
1490 assert!(
1491 result
1492 .routes
1493 .iter()
1494 .any(|route| route.starts_with("testing/"))
1495 );
1496 let names = member_names(&seen.lock().expect("lock").0);
1497 assert!(names.iter().any(|name| name.contains("cli/scriptExec/")));
1498 assert!(names.iter().any(|name| name.contains("cli/tagDelete/")));
1499
1500 let exec_secret = stored_secret(&config).expect("scriptExec secret");
1501 let delete_secret = stored_tag_delete_secret(&config).expect("tagDelete secret");
1502 assert_ne!(exec_secret, delete_secret);
1503
1504 let result = webdev_deploy_with(
1506 &importing_rig(),
1507 "ign-cli",
1508 true,
1509 true,
1510 &config,
1511 "dev",
1512 false,
1513 Some(tag_delete_request(repo.path(), false)),
1514 )
1515 .await
1516 .expect("rotate scriptExec");
1517 assert!(result.secret_rotated && !result.tag_delete_secret_rotated);
1518 assert_ne!(
1519 stored_secret(&config).as_deref(),
1520 Some(exec_secret.as_str())
1521 );
1522 assert_eq!(
1523 stored_tag_delete_secret(&config).as_deref(),
1524 Some(delete_secret.as_str())
1525 );
1526
1527 let exec_now = stored_secret(&config).expect("scriptExec secret");
1529 let result = webdev_deploy_with(
1530 &importing_rig(),
1531 "ign-cli",
1532 true,
1533 false,
1534 &config,
1535 "dev",
1536 false,
1537 Some(tag_delete_request(repo.path(), true)),
1538 )
1539 .await
1540 .expect("rotate tag delete");
1541 assert!(!result.secret_rotated && result.tag_delete_secret_rotated);
1542 assert_eq!(stored_secret(&config).as_deref(), Some(exec_now.as_str()));
1543 assert_ne!(
1544 stored_tag_delete_secret(&config).as_deref(),
1545 Some(delete_secret.as_str())
1546 );
1547 let serialized = serde_json::to_string(&result).expect("serializes");
1548 for secret in [
1549 exec_now,
1550 stored_tag_delete_secret(&config).expect("tagDelete secret"),
1551 ] {
1552 assert!(!serialized.contains(&secret));
1553 assert!(!format!("{result:?}").contains(&secret));
1554 }
1555 let _ = dir;
1556 }
1557
1558 #[tokio::test]
1563 async fn tag_delete_refusals_happen_before_any_gateway_request() {
1564 let (dir, config) = temp_config();
1565 let mut seeded = crate::config::load(&config).expect("load");
1566 seeded.profiles.get_mut("dev").expect("dev").webdev_secret =
1567 Some("fakeexecsecretvalue".into());
1568 crate::config::save(&config, &seeded).expect("seed");
1569
1570 for (providers, code) in [
1571 ("[]", "invalid_input"),
1572 ("[System]", "invalid_input"),
1573 ("[P1, system]", "invalid_input"),
1574 ] {
1575 let repo = temp_repo(providers);
1576 let err = webdev_deploy_with(
1577 &no_gateway_rig(),
1578 "ign-cli",
1579 true,
1580 false,
1581 &config,
1582 "dev",
1583 false,
1584 Some(tag_delete_request(repo.path(), false)),
1585 )
1586 .await
1587 .expect_err("refused");
1588 assert_eq!(err.code(), code, "{providers}: {err}");
1589 assert!(!err.to_string().contains("fakeexecsecretvalue"));
1590 }
1591
1592 let repo = temp_repo("[P26Del]");
1593 let mut bad_role = tag_delete_request(repo.path(), false);
1594 bad_role.role = "x'y".into();
1595 let err = webdev_deploy_with(
1596 &no_gateway_rig(),
1597 "ign-cli",
1598 false,
1599 false,
1600 &config,
1601 "dev",
1602 false,
1603 Some(bad_role),
1604 )
1605 .await
1606 .expect_err("bad role");
1607 assert_eq!(err.code(), "invalid_input");
1608 assert!(err.to_string().contains("role"), "{err}");
1609
1610 let empty_repo = tempfile::tempdir().expect("tempdir");
1611 let err = webdev_deploy_with(
1612 &no_gateway_rig(),
1613 "ign-cli",
1614 false,
1615 false,
1616 &config,
1617 "dev",
1618 false,
1619 Some(tag_delete_request(empty_repo.path(), false)),
1620 )
1621 .await
1622 .expect_err("no project.yaml");
1623 assert_eq!(err.code(), "deploy_tree_invalid");
1624
1625 assert_eq!(stored_tag_delete_secret(&config), None, "nothing stored");
1626 let _ = dir;
1627 }
1628
1629 #[tokio::test]
1635 async fn tag_delete_install_refuses_an_unsafe_transport() {
1636 let repo = temp_repo("[P26Del]");
1639 for (url, ssl_verify, why) in [
1640 ("http://gateway.example.com:8088/", true, "plain http"),
1641 (
1642 "https://gateway.example.com:8043/",
1643 false,
1644 "ssl_verify false",
1645 ),
1646 ] {
1647 let (dir, config) = temp_config();
1648 let mut seeded = crate::config::load(&config).expect("load");
1649 let profile = seeded.profiles.get_mut("dev").expect("dev");
1650 profile.url = url::Url::parse(url).expect("url");
1651 profile.ssl_verify = ssl_verify;
1652 crate::config::save(&config, &seeded).expect("seed");
1653 let err = webdev_deploy_with(
1654 &no_gateway_rig(),
1655 "ign-cli",
1656 false,
1657 false,
1658 &config,
1659 "dev",
1660 false,
1661 Some(tag_delete_request(repo.path(), false)),
1662 )
1663 .await
1664 .expect_err("refused");
1665 assert_eq!(err.code(), "tag_delete_not_configured", "{url}: {err}");
1666 assert!(err.to_string().contains(why), "{url}: {err}");
1667 assert_eq!(stored_tag_delete_secret(&config), None, "{url}");
1668 let _ = dir;
1669 }
1670 }
1671
1672 #[test]
1677 fn tag_delete_transport_check_reads_the_clients_url() {
1678 let _lock = crate::config::ENV_LOCK.lock().expect("env lock");
1679 tokio::runtime::Builder::new_current_thread()
1680 .build()
1681 .expect("runtime")
1682 .block_on(transport_check_reads_the_clients_url());
1683 }
1684
1685 async fn transport_check_reads_the_clients_url() {
1686 let repo = temp_repo("[P26Del]");
1687 let loopback = url::Url::parse("http://localhost:9088/").expect("url");
1688 let remote = url::Url::parse("http://gateway.example.com:8088/").expect("url");
1689
1690 let (dir, config) = temp_config();
1692 unsafe { std::env::set_var("IGNITION_URL", remote.as_str()) };
1694 let mut rig = importing_rig();
1695 rig.transport = Some((loopback, true));
1696 let result = webdev_deploy_with(
1697 &rig,
1698 "ign-cli",
1699 false,
1700 false,
1701 &config,
1702 "dev",
1703 false,
1704 Some(tag_delete_request(repo.path(), false)),
1705 )
1706 .await;
1707 unsafe { std::env::remove_var("IGNITION_URL") };
1709 let result = result.expect("the client's loopback URL is safe");
1710 assert!(result.tag_delete);
1711 let _ = dir;
1712
1713 let (dir, config) = temp_config();
1715 let mut rig = no_gateway_rig();
1716 rig.transport = Some((remote, true));
1717 let err = webdev_deploy_with(
1718 &rig,
1719 "ign-cli",
1720 false,
1721 false,
1722 &config,
1723 "dev",
1724 false,
1725 Some(tag_delete_request(repo.path(), false)),
1726 )
1727 .await
1728 .expect_err("the client's remote http URL is refused");
1729 assert_eq!(err.code(), "tag_delete_not_configured", "{err}");
1730 assert!(err.to_string().contains("plain http"), "{err}");
1731 assert_eq!(stored_tag_delete_secret(&config), None);
1732 let _ = dir;
1733 }
1734
1735 #[cfg(unix)]
1738 #[tokio::test]
1739 async fn tag_delete_broken_config_store_refuses_before_any_gateway_request() {
1740 use std::os::unix::fs::PermissionsExt;
1741 let (dir, config) = temp_config();
1742 let repo = temp_repo("[P26Del]");
1743 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o400))
1744 .expect("read-only");
1745 let err = webdev_deploy_with(
1746 &no_gateway_rig(),
1747 "ign-cli",
1748 false,
1749 false,
1750 &config,
1751 "dev",
1752 false,
1753 Some(tag_delete_request(repo.path(), false)),
1754 )
1755 .await
1756 .expect_err("unwritable store refuses");
1757 assert_eq!(err.code(), "config_invalid", "{err}");
1758 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).expect("restore");
1759
1760 std::fs::write(&config, "not = [valid").expect("break");
1761 let err = webdev_deploy_with(
1762 &no_gateway_rig(),
1763 "ign-cli",
1764 false,
1765 false,
1766 &config,
1767 "dev",
1768 false,
1769 Some(tag_delete_request(repo.path(), false)),
1770 )
1771 .await
1772 .expect_err("unparsable store refuses");
1773 assert_ne!(err.code(), "internal");
1774 let _ = dir;
1775 }
1776
1777 #[tokio::test]
1780 async fn deploy_without_tag_delete_packs_no_tag_delete_member() {
1781 let (dir, config) = temp_config();
1782 let (rig, seen) = recording_rig(&config);
1783 let result = webdev_deploy_with(&rig, "ign-cli", true, false, &config, "dev", false, None)
1784 .await
1785 .expect("deploy");
1786 assert!(!result.tag_delete && !result.tag_delete_secret_rotated);
1787 assert!(result.tag_delete_providers.is_empty());
1788 assert!(!result.routes.contains(&"tagDelete".to_string()));
1789 let names = member_names(&seen.lock().expect("lock").0);
1790 assert!(names.iter().all(|name| !name.contains("tagDelete")));
1791 assert_eq!(stored_tag_delete_secret(&config), None);
1792 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1793 let _ = dir;
1794 }
1795
1796 #[tokio::test]
1802 async fn deploy_without_tag_delete_removes_the_stored_secret_and_warns() {
1803 let (dir, config) = temp_config();
1804 let mut seeded = crate::config::load(&config).expect("load");
1805 seeded
1806 .profiles
1807 .get_mut("dev")
1808 .expect("dev")
1809 .webdev_tag_delete_secret = Some("a".repeat(64));
1810 crate::config::save(&config, &seeded).expect("seed");
1811
1812 let result = webdev_deploy(
1815 &importing_rig(),
1816 "Other",
1817 false,
1818 false,
1819 &config,
1820 "dev",
1821 false,
1822 )
1823 .await
1824 .expect("deploy");
1825 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1826 assert!(stored_tag_delete_secret(&config).is_some());
1827
1828 let (rig, seen) = recording_rig(&config);
1830 let result = webdev_deploy(&rig, "ign-cli", true, false, &config, "dev", false)
1831 .await
1832 .expect("the deploy is not refused");
1833 assert!(!result.tag_delete);
1834 assert_eq!(stored_tag_delete_secret(&config), None, "secret removed");
1835 assert_eq!(seen.lock().expect("lock").1, None, "removed before upload");
1836 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
1837 let warning = &result.warnings[0];
1838 assert!(warning.contains("tagDelete"), "{warning}");
1839 assert!(warning.contains("removed"), "{warning}");
1840 assert!(warning.contains("--with-tag-delete"), "{warning}");
1841 assert!(!warning.contains(&"a".repeat(64)), "{warning}");
1842 assert!(stored_secret(&config).is_some(), "scriptExec kept");
1843
1844 let profile = crate::config::load(&config)
1845 .expect("reload")
1846 .profiles
1847 .remove("dev")
1848 .expect("dev");
1849 match crate::deploy::tag_delete::tag_delete_config("dev", &profile) {
1850 crate::deploy::tag_delete::TagDeleteConfig::NotConfigured { missing } => {
1851 assert!(
1852 missing
1853 .iter()
1854 .any(|part| part == "webdev_tag_delete_secret"),
1855 "{missing:?}"
1856 );
1857 }
1858 other => panic!("expected NotConfigured, got {other:?}"),
1859 }
1860
1861 let result = webdev_deploy(
1863 &importing_rig(),
1864 "ign-cli",
1865 false,
1866 false,
1867 &config,
1868 "dev",
1869 false,
1870 )
1871 .await
1872 .expect("deploy");
1873 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
1874 let _ = dir;
1875 }
1876
1877 #[tokio::test]
1879 async fn deploy_with_tag_delete_keeps_the_secret_and_does_not_warn() {
1880 let (dir, config) = temp_config();
1881 let repo = temp_repo("[P26Del]");
1882 for _ in 0..2 {
1883 let result = webdev_deploy_with(
1884 &importing_rig(),
1885 "ign-cli",
1886 false,
1887 false,
1888 &config,
1889 "dev",
1890 false,
1891 Some(tag_delete_request(repo.path(), false)),
1892 )
1893 .await
1894 .expect("deploy");
1895 assert!(
1896 !result.warnings.iter().any(|w| w.contains("removed")),
1897 "{:?}",
1898 result.warnings
1899 );
1900 assert!(stored_tag_delete_secret(&config).is_some());
1901 }
1902 let _ = dir;
1903 }
1904
1905 fn config_with_route_secret(url: &str, secret: &str) -> (tempfile::TempDir, PathBuf) {
1908 let (dir, config) = temp_config();
1909 let mut seeded = crate::config::load(&config).expect("load");
1910 let profile = seeded.profiles.get_mut("dev").expect("dev");
1911 profile.url = url::Url::parse(url).expect("url");
1912 profile.webdev_tag_delete_secret = Some(secret.to_string());
1913 crate::config::save(&config, &seeded).expect("seed");
1914 (dir, config)
1915 }
1916
1917 async fn mount_live_route(server: &wiremock::MockServer, providers: Option<&[&str]>) {
1920 let response = match providers {
1921 Some(providers) => wiremock::ResponseTemplate::new(200).set_body_json(
1922 serde_json::json!({"ok": true, "data": {
1923 "routeVersion": BUNDLE_VERSION, "minCli": "1.0.0",
1924 "providers": providers, "maxPaths": 500, "maxPathChars": 1024,
1925 }}),
1926 ),
1927 None => wiremock::ResponseTemplate::new(405),
1928 };
1929 wiremock::Mock::given(wiremock::matchers::method("POST"))
1930 .and(wiremock::matchers::path(
1931 "/system/webdev/ign-cli/cli/tagDelete",
1932 ))
1933 .and(wiremock::matchers::header(
1934 "X-Ignition-CLI-Secret",
1935 "c".repeat(64).as_str(),
1936 ))
1937 .respond_with(response)
1938 .mount(server)
1939 .await;
1940 }
1941
1942 fn caller() -> Option<(crate::config::Secret, crate::config::Secret)> {
1943 Some((
1944 crate::config::Secret::new("deployer"),
1945 crate::config::Secret::new("Pw-fake-Z9"),
1946 ))
1947 }
1948
1949 #[tokio::test]
1956 async fn tag_delete_install_refuses_to_drop_live_providers() {
1957 let server = wiremock::MockServer::start().await;
1958 mount_live_route(&server, Some(&["PA", "PB"])).await;
1959 let secret = "c".repeat(64);
1960 let (dir, config) = config_with_route_secret(&server.uri(), &secret);
1961 let repo_a = temp_repo("[PA]");
1962 let repo_b = temp_repo("[PB]");
1963
1964 let mut request = tag_delete_request(repo_b.path(), false);
1965 request.caller = caller();
1966 let err = webdev_deploy_with(
1967 &no_gateway_rig(),
1968 "ign-cli",
1969 false,
1970 false,
1971 &config,
1972 "dev",
1973 false,
1974 Some(request.clone()),
1975 )
1976 .await
1977 .expect_err("dropping PA is refused");
1978 assert_eq!(err.code(), "invalid_input", "{err}");
1979 let text = err.to_string();
1980 assert!(text.contains("PA"), "{text}");
1981 assert!(text.contains("--tag-delete-repo"), "{text}");
1982 assert!(text.contains("without --with-tag-delete"), "{text}");
1983 assert!(!text.contains(&secret), "{text}");
1984 let hint = err.hint().unwrap_or_default();
1985 assert!(hint.contains("--tag-delete-repo"), "{hint}");
1986 assert_eq!(
1987 stored_tag_delete_secret(&config).as_deref(),
1988 Some(secret.as_str()),
1989 "nothing stored"
1990 );
1991
1992 let mut rotate = request.clone();
1994 rotate.rotate_secret = true;
1995 let err = webdev_deploy_with(
1996 &no_gateway_rig(),
1997 "ign-cli",
1998 false,
1999 false,
2000 &config,
2001 "dev",
2002 false,
2003 Some(rotate),
2004 )
2005 .await
2006 .expect_err("a rotate is refused too");
2007 assert_eq!(err.code(), "invalid_input", "{err}");
2008
2009 request.repos = vec![repo_a.path().to_path_buf(), repo_b.path().to_path_buf()];
2011 let result = webdev_deploy_with(
2012 &importing_rig(),
2013 "ign-cli",
2014 false,
2015 false,
2016 &config,
2017 "dev",
2018 false,
2019 Some(request),
2020 )
2021 .await
2022 .expect("the union keeps every live provider");
2023 assert_eq!(result.tag_delete_providers, vec!["PA", "PB"]);
2024 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2025 let _ = dir;
2026 }
2027
2028 #[tokio::test]
2032 async fn tag_delete_install_warns_when_the_live_allowlist_is_unreadable() {
2033 let secret = "c".repeat(64);
2034 let repo = temp_repo("[PB]");
2035
2036 let server = wiremock::MockServer::start().await;
2038 mount_live_route(&server, Some(&["PA"])).await;
2039 let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2040 let result = webdev_deploy_with(
2041 &importing_rig(),
2042 "ign-cli",
2043 false,
2044 false,
2045 &config,
2046 "dev",
2047 false,
2048 Some(tag_delete_request(repo.path(), false)),
2049 )
2050 .await
2051 .expect("installs");
2052 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2053 assert!(
2054 result.warnings[0].contains("could not read the live tagDelete allowlist"),
2055 "{:?}",
2056 result.warnings
2057 );
2058 assert!(!result.warnings[0].contains(&secret));
2059 let _ = dir;
2060
2061 let server = wiremock::MockServer::start().await;
2063 wiremock::Mock::given(wiremock::matchers::method("POST"))
2064 .respond_with(wiremock::ResponseTemplate::new(500))
2065 .mount(&server)
2066 .await;
2067 let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2068 let mut request = tag_delete_request(repo.path(), false);
2069 request.caller = caller();
2070 let result = webdev_deploy_with(
2071 &importing_rig(),
2072 "ign-cli",
2073 false,
2074 false,
2075 &config,
2076 "dev",
2077 false,
2078 Some(request.clone()),
2079 )
2080 .await
2081 .expect("installs");
2082 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2083 assert!(result.warnings[0].contains("could not read the live tagDelete allowlist"));
2084 let _ = dir;
2085
2086 let server = wiremock::MockServer::start().await;
2088 mount_live_route(&server, None).await;
2089 let (dir, config) = config_with_route_secret(&server.uri(), &secret);
2090 let result = webdev_deploy_with(
2091 &importing_rig(),
2092 "ign-cli",
2093 false,
2094 false,
2095 &config,
2096 "dev",
2097 false,
2098 Some(request.clone()),
2099 )
2100 .await
2101 .expect("installs");
2102 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2103 let _ = dir;
2104
2105 let (dir, config) = temp_config();
2107 let result = webdev_deploy_with(
2108 &importing_rig(),
2109 "ign-cli",
2110 false,
2111 false,
2112 &config,
2113 "dev",
2114 false,
2115 Some(request),
2116 )
2117 .await
2118 .expect("installs");
2119 assert!(result.warnings.is_empty(), "{:?}", result.warnings);
2120 let _ = dir;
2121 }
2122
2123 #[test]
2126 fn generated_secrets_are_hex_and_unique() {
2127 let a = generate_secret().expect("secret");
2128 let b = generate_secret().expect("secret");
2129 assert_eq!(a.len(), 64);
2130 assert_ne!(a, b);
2131 }
2132
2133 fn member_names(zip_bytes: &[u8]) -> Vec<String> {
2134 let mut archive =
2135 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
2136 (0..archive.len())
2137 .map(|index| archive.by_index(index).expect("member").name().to_string())
2138 .collect()
2139 }
2140
2141 fn member(zip_bytes: &[u8], name: &str) -> Vec<u8> {
2142 let mut archive =
2143 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).expect("built zip is readable");
2144 let mut file = archive.by_name(name).expect("member present");
2145 let mut bytes = Vec::new();
2146 std::io::Read::read_to_end(&mut file, &mut bytes).expect("member reads");
2147 bytes
2148 }
2149}