1use crate::error::{EngineError, Result};
90use crate::types::{ProvisionedPreview, RemoteWorkspaceConfig};
91use crate::workspace_contract::PreviewSpec;
92use crate::workspace_provider::{
93 PreviewPlaceholder, ProgressSink, ProvisionSpec, ReadinessOutcome, TeardownMode,
94 WorkspaceHandle, WorkspaceProvider, WorkspaceProviderKind,
95};
96use std::sync::Arc;
97use std::time::Duration;
98
99pub const ADAPTER_VERSION: &str = "coder-v1";
104
105const WORKSPACE_NAME_PREFIX: &str = "kranz-remote-";
109
110const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
112const READY_TIMEOUT: Duration = Duration::from_secs(300);
114const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
116
117pub(crate) const PROVIDER_REASON_PREFIX: &str = "workspace provider:";
122
123pub(crate) fn provider_block_reason(detail: &str) -> String {
129 crate::scrub::scrub(&format!(
130 "{PROVIDER_REASON_PREFIX} {detail} (owner: provider — fix the substrate, then unblock \
131 and re-run)"
132 ))
133}
134
135#[derive(Debug, Clone, PartialEq)]
139pub struct SubstrateWorkspaceSpec {
140 pub template: String,
142 pub name: String,
144 pub env_names: Vec<String>,
146 pub idle_after_hours: Option<f64>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SubstrateUrl {
158 pub name: String,
159 pub url: String,
160 pub auth: Option<bool>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct SubstrateWorkspace {
169 pub id: String,
170 pub urls: Vec<SubstrateUrl>,
171 pub takeover: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum SubstrateStatus {
177 Ready,
178 Pending,
179 Failed { reason: String },
180}
181
182#[async_trait::async_trait]
187pub trait SubstrateClient: Send + Sync {
188 async fn create_workspace(&self, spec: &SubstrateWorkspaceSpec) -> Result<SubstrateWorkspace>;
191 async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus>;
193 async fn delete_workspace(&self, id: &str) -> Result<()>;
195 async fn stop_workspace(&self, id: &str) -> Result<()>;
197}
198
199#[derive(Debug, Clone, PartialEq)]
203pub struct RemoteConfig {
204 pub base_url: String,
205 pub template: String,
206 pub token_env: String,
208 pub idle_after_hours: Option<f64>,
213}
214
215impl RemoteConfig {
216 pub fn require(config: Option<&RemoteWorkspaceConfig>) -> Result<RemoteConfig> {
220 fn missing(key: &str) -> EngineError {
221 EngineError::Config(format!(
222 "workspace.provider \"remote\" needs {key} configured (owner: operator — set the \
223 mission config key); refusing rather than silently falling back to local"
224 ))
225 }
226 let Some(config) = config else {
227 return Err(missing("workspace.remote.baseUrl"));
228 };
229 Ok(RemoteConfig {
230 base_url: config
231 .base_url
232 .clone()
233 .ok_or_else(|| missing("workspace.remote.baseUrl"))?,
234 template: config
235 .template
236 .clone()
237 .ok_or_else(|| missing("workspace.remote.template"))?,
238 token_env: config
239 .token_env
240 .clone()
241 .ok_or_else(|| missing("workspace.remote.tokenEnv"))?,
242 idle_after_hours: config.idle_after_hours,
245 })
246 }
247}
248
249type ClientHook = Arc<dyn Fn(&RemoteConfig) -> Result<Arc<dyn SubstrateClient>> + Send + Sync>;
253
254fn coder_client_from_env(config: &RemoteConfig) -> Result<Arc<dyn SubstrateClient>> {
260 let token = std::env::var(&config.token_env)
261 .ok()
262 .filter(|value| !value.is_empty())
263 .ok_or_else(|| {
264 EngineError::Config(format!(
265 "workspace.provider \"remote\": the substrate token env var {} (from \
266 workspace.remote.tokenEnv) is not set (owner: operator — export it before \
267 `kranz work`); refusing rather than silently falling back to local",
268 config.token_env
269 ))
270 })?;
271 Ok(Arc::new(CoderHttpClient::new(&config.base_url, &token)?))
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum PollOutcome {
278 Ready,
279 Failed { reason: String },
280 TimedOut { waited_secs: u64 },
281}
282
283#[derive(Debug, Clone)]
287pub struct RemoteWorkspace {
288 pub name: String,
290 pub id: String,
293 pub takeover: Option<String>,
295 pub previews: Vec<ProvisionedPreview>,
298 pub injected_env_names: Vec<String>,
300 pub poll: PollOutcome,
302}
303
304pub struct RemoteWorkspaceProvider {
307 config: RemoteConfig,
308 client_hook: ClientHook,
309 ready_timeout: Duration,
310 poll_interval: Duration,
311}
312
313impl RemoteWorkspaceProvider {
314 pub(crate) fn from_config(config: Option<&RemoteWorkspaceConfig>) -> Result<Self> {
318 Ok(Self {
319 config: RemoteConfig::require(config)?,
320 client_hook: Arc::new(coder_client_from_env),
321 ready_timeout: READY_TIMEOUT,
322 poll_interval: READY_POLL_INTERVAL,
323 })
324 }
325
326 #[cfg(test)]
330 pub(crate) fn with_client(config: RemoteConfig, client: Arc<dyn SubstrateClient>) -> Self {
331 Self {
332 config,
333 client_hook: Arc::new(move |_config| Ok(Arc::clone(&client))),
334 ready_timeout: Duration::from_millis(120),
335 poll_interval: Duration::from_millis(5),
336 }
337 }
338
339 async fn poll_until_ready(&self, client: &dyn SubstrateClient, id: &str) -> PollOutcome {
344 let deadline = std::time::Instant::now() + self.ready_timeout;
345 loop {
346 match client.workspace_status(id).await {
347 Ok(SubstrateStatus::Ready) => return PollOutcome::Ready,
348 Ok(SubstrateStatus::Failed { reason }) => return PollOutcome::Failed { reason },
349 Ok(SubstrateStatus::Pending) => {}
350 Err(e) => {
351 return PollOutcome::Failed {
352 reason: format!("status poll failed: {e}"),
353 };
354 }
355 }
356 if std::time::Instant::now() >= deadline {
357 return PollOutcome::TimedOut {
358 waited_secs: self.ready_timeout.as_secs(),
359 };
360 }
361 tokio::time::sleep(self.poll_interval).await;
362 }
363 }
364}
365
366fn workspace_name(mission_id: &str) -> String {
371 let sanitized: String = mission_id
372 .chars()
373 .map(|c| {
374 if c.is_ascii_alphanumeric() {
375 c.to_ascii_lowercase()
376 } else if c == '-' || c == '_' {
377 c
378 } else {
379 '-'
380 }
381 })
382 .collect();
383 if sanitized.is_empty() {
384 format!("{WORKSPACE_NAME_PREFIX}mission")
385 } else {
386 format!("{WORKSPACE_NAME_PREFIX}{sanitized}")
387 }
388}
389
390fn preview_placeholders(
395 previews: &[PreviewSpec],
396 urls: &[SubstrateUrl],
397) -> Vec<PreviewPlaceholder> {
398 previews
399 .iter()
400 .map(|preview| {
401 let url_template = urls
402 .iter()
403 .find(|url| url.name == preview.name)
404 .map(|url| url.url.clone())
405 .unwrap_or_else(|| preview.url_template.clone());
406 PreviewPlaceholder {
407 name: preview.name.clone(),
408 url_template,
409 }
410 })
411 .collect()
412}
413
414fn map_previews(previews: &[PreviewSpec], urls: &[SubstrateUrl]) -> Vec<ProvisionedPreview> {
419 previews
420 .iter()
421 .filter_map(|preview| {
422 urls.iter()
423 .find(|url| url.name == preview.name)
424 .map(|url| ProvisionedPreview {
425 name: preview.name.clone(),
426 url: url.url.clone(),
427 auth: url.auth,
428 })
429 })
430 .collect()
431}
432
433fn provision_detail(
439 name: &str,
440 id: &str,
441 env_names: &[String],
442 idle_after_hours: Option<f64>,
443) -> String {
444 let injected = if env_names.is_empty() {
445 "none".to_string()
446 } else {
447 env_names.join(",")
448 };
449 let idle = match idle_after_hours {
450 Some(hours) => format!("; idle policy: hibernate after {hours}h (substrate-owned)"),
451 None => String::new(),
452 };
453 if id.is_empty() {
454 format!("substrate workspace {name}: create failed (injected env names: {injected}){idle}")
455 } else {
456 format!("substrate workspace {name} (id {id}); injected env names: {injected}{idle}")
457 }
458}
459
460#[async_trait::async_trait]
461impl WorkspaceProvider for RemoteWorkspaceProvider {
462 fn kind(&self) -> WorkspaceProviderKind {
463 WorkspaceProviderKind::Remote
464 }
465
466 async fn provision(&self, spec: &ProvisionSpec) -> Result<WorkspaceHandle> {
467 if !spec.repo_root.is_dir() {
468 return Err(EngineError::InvalidState(format!(
469 "remote provision: execution cwd {} does not exist",
470 spec.repo_root.display()
471 )));
472 }
473 let env = crate::runner::contract_env(spec.base_sha.as_deref());
474 let Some(contract) = &spec.contract else {
475 return Ok(WorkspaceHandle {
480 cwd: spec.repo_root.clone(),
481 env,
482 previews: Vec::new(),
483 contract: None,
484 detail: None,
485 container: None,
486 remote: None,
487 gate_env: spec.gate_env.clone(),
488 });
489 };
490
491 let client = (self.client_hook)(&self.config)?;
494 let name = workspace_name(&spec.mission_id);
495 let env_names = contract.secrets.clone();
496
497 let (id, urls, takeover, poll) = match client
501 .create_workspace(&SubstrateWorkspaceSpec {
502 template: self.config.template.clone(),
503 name: name.clone(),
504 env_names: env_names.clone(),
505 idle_after_hours: self.config.idle_after_hours,
506 })
507 .await
508 {
509 Ok(created) => {
510 let poll = self.poll_until_ready(&*client, &created.id).await;
511 (created.id, created.urls, created.takeover, poll)
512 }
513 Err(e) => (
514 String::new(),
515 Vec::new(),
516 None,
517 PollOutcome::Failed {
518 reason: format!("create_workspace failed: {e}"),
519 },
520 ),
521 };
522
523 Ok(WorkspaceHandle {
524 cwd: spec.repo_root.clone(),
525 env,
526 previews: preview_placeholders(&contract.previews, &urls),
527 contract: Some(contract.clone()),
528 detail: Some(provision_detail(
529 &name,
530 &id,
531 &env_names,
532 self.config.idle_after_hours,
533 )),
534 container: None,
535 remote: Some(RemoteWorkspace {
536 name,
537 id,
538 takeover,
539 previews: map_previews(&contract.previews, &urls),
540 injected_env_names: env_names,
541 poll,
542 }),
543 gate_env: spec.gate_env.clone(),
544 })
545 }
546
547 async fn readiness(
548 &self,
549 handle: &WorkspaceHandle,
550 progress: &mut ProgressSink<'_>,
551 ) -> Result<ReadinessOutcome> {
552 let Some(remote) = &handle.remote else {
553 return Ok(ReadinessOutcome::Ready);
556 };
557 match &remote.poll {
558 PollOutcome::Ready => {
559 progress(
565 "workspace remote: substrate reports ready (substrate-reported readiness \
566 only — contract bootstrap/readiness commands do not execute on the remote \
567 substrate in v1)",
568 None,
569 )?;
570 Ok(ReadinessOutcome::Ready)
571 }
572 PollOutcome::Failed { reason } => Ok(ReadinessOutcome::ProviderFailed {
573 detail: format!(
574 "substrate workspace {} failed to provision: {reason}",
575 remote.name
576 ),
577 }),
578 PollOutcome::TimedOut { waited_secs } => Ok(ReadinessOutcome::ProviderFailed {
579 detail: format!(
580 "substrate workspace {} did not become ready within {waited_secs}s",
581 remote.name
582 ),
583 }),
584 }
585 }
586
587 async fn teardown(&self, handle: WorkspaceHandle, mode: TeardownMode) -> Result<()> {
588 let Some(remote) = &handle.remote else {
589 return Ok(()); };
591 if remote.id.is_empty() {
592 return Ok(()); }
594 match mode {
595 TeardownMode::Keep => Ok(()),
598 TeardownMode::Hibernate => {
599 let client = (self.client_hook)(&self.config)?;
600 client.stop_workspace(&remote.id).await.map_err(|e| {
601 EngineError::InvalidState(format!(
602 "remote teardown (hibernate): stop_workspace failed (owner: provider): {}",
603 crate::scrub::scrub(&e.to_string())
604 ))
605 })
606 }
607 TeardownMode::Destroy => {
608 let client = (self.client_hook)(&self.config)?;
609 client.delete_workspace(&remote.id).await.map_err(|e| {
610 EngineError::InvalidState(format!(
611 "remote teardown (destroy): delete_workspace failed (owner: provider): {}",
612 crate::scrub::scrub(&e.to_string())
613 ))
614 })
615 }
616 }
617 }
618}
619
620pub struct CoderHttpClient {
642 base_url: String,
643 token: String,
644 client: reqwest::Client,
645}
646
647impl CoderHttpClient {
648 pub fn new(base_url: &str, token: &str) -> Result<Self> {
651 let parsed = reqwest::Url::parse(base_url).map_err(|e| {
652 EngineError::Config(format!(
653 "workspace.remote.baseUrl {base_url:?} is not a valid URL: {e} (owner: operator)"
654 ))
655 })?;
656 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
657 return Err(EngineError::Config(format!(
658 "workspace.remote.baseUrl {base_url:?} needs an http(s) URL with a host (owner: \
659 operator)"
660 )));
661 }
662 let client = reqwest::Client::builder()
663 .timeout(REQUEST_TIMEOUT)
664 .build()
665 .map_err(|e| {
666 EngineError::Config(format!("could not build the substrate HTTP client: {e}"))
667 })?;
668 Ok(Self {
669 base_url: base_url.trim_end_matches('/').to_string(),
670 token: token.to_string(),
671 client,
672 })
673 }
674
675 async fn send(
678 &self,
679 request: reqwest::RequestBuilder,
680 op: &'static str,
681 ) -> Result<reqwest::Response> {
682 let response = request
683 .header("Coder-Session-Token", &self.token)
684 .send()
685 .await
686 .map_err(|e| {
687 EngineError::InvalidState(format!(
688 "coder substrate {op}: request failed: {}",
689 crate::scrub::scrub(&e.to_string())
690 ))
691 })?;
692 let status = response.status();
693 if !status.is_success() {
694 let body = response.text().await.unwrap_or_default();
695 let tail = crate::command_exec::last_chars_local(&body, 500);
696 return Err(EngineError::InvalidState(format!(
697 "coder substrate {op}: HTTP {status}: {}",
698 crate::scrub::scrub(tail.trim())
699 )));
700 }
701 Ok(response)
702 }
703
704 async fn json(response: reqwest::Response, op: &'static str) -> Result<serde_json::Value> {
706 response.json().await.map_err(|e| {
707 EngineError::InvalidState(format!("coder substrate {op}: response was not JSON: {e}"))
708 })
709 }
710
711 async fn transition(&self, id: &str, transition: &str, op: &'static str) -> Result<()> {
713 let url = format!("{}/api/v2/workspaces/{id}/builds", self.base_url);
714 self.send(
715 self.client
716 .post(&url)
717 .json(&serde_json::json!({ "transition": transition })),
718 op,
719 )
720 .await?;
721 Ok(())
722 }
723}
724
725fn parse_substrate_url(value: &serde_json::Value) -> Option<SubstrateUrl> {
727 Some(SubstrateUrl {
728 name: value.get("name")?.as_str()?.to_string(),
729 url: value.get("url")?.as_str()?.to_string(),
730 auth: value.get("auth").and_then(serde_json::Value::as_bool),
731 })
732}
733
734fn parse_status(value: &serde_json::Value) -> SubstrateStatus {
738 let status = value
739 .get("status")
740 .and_then(|v| v.as_str())
741 .or_else(|| {
742 value
743 .get("latest_build")
744 .and_then(|build| build.get("status"))
745 .and_then(|v| v.as_str())
746 })
747 .unwrap_or("");
748 match status {
749 "running" => SubstrateStatus::Ready,
750 "failed" => SubstrateStatus::Failed {
751 reason: "substrate reported workspace status \"failed\"".to_string(),
752 },
753 _ => SubstrateStatus::Pending,
754 }
755}
756
757#[async_trait::async_trait]
758impl SubstrateClient for CoderHttpClient {
759 async fn create_workspace(&self, spec: &SubstrateWorkspaceSpec) -> Result<SubstrateWorkspace> {
760 let url = format!("{}/api/v2/users/me/workspaces", self.base_url);
761 let mut body = serde_json::json!({
762 "template_id": spec.template,
763 "name": spec.name,
764 "env_names": spec.env_names,
767 });
768 if let Some(hours) = spec.idle_after_hours {
769 body["idle_after_hours"] = serde_json::json!(hours);
774 }
775 let response = self
776 .send(self.client.post(&url).json(&body), "create_workspace")
777 .await?;
778 let value = Self::json(response, "create_workspace").await?;
779 let id = value
780 .get("id")
781 .and_then(|v| v.as_str())
782 .filter(|id| !id.is_empty())
783 .ok_or_else(|| {
784 EngineError::InvalidState(
785 "coder substrate create_workspace: response carried no workspace id"
786 .to_string(),
787 )
788 })?;
789 let urls = value
790 .get("urls")
791 .and_then(|v| v.as_array())
792 .map(|entries| entries.iter().filter_map(parse_substrate_url).collect())
793 .unwrap_or_default();
794 let takeover = value
795 .get("takeover")
796 .and_then(|v| v.as_str())
797 .map(str::to_string);
798 Ok(SubstrateWorkspace {
799 id: id.to_string(),
800 urls,
801 takeover,
802 })
803 }
804
805 async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus> {
806 let url = format!("{}/api/v2/workspaces/{id}", self.base_url);
807 let response = self.send(self.client.get(&url), "workspace_status").await?;
808 let value = Self::json(response, "workspace_status").await?;
809 Ok(parse_status(&value))
810 }
811
812 async fn delete_workspace(&self, id: &str) -> Result<()> {
813 self.transition(id, "delete", "delete_workspace").await
814 }
815
816 async fn stop_workspace(&self, id: &str) -> Result<()> {
817 self.transition(id, "stop", "stop_workspace").await
818 }
819}
820
821#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::workspace_contract::{parse_workspace_contract, WorkspaceContract};
827 use std::collections::VecDeque;
828 use std::sync::Mutex;
829
830 fn config() -> RemoteConfig {
831 RemoteConfig {
832 base_url: "https://coder.internal.example.com".to_string(),
833 template: "tmpl-baked-ami".to_string(),
834 token_env: "CODER_SESSION_TOKEN".to_string(),
835 idle_after_hours: None,
836 }
837 }
838
839 fn spec(root: &std::path::Path, contract: Option<WorkspaceContract>) -> ProvisionSpec {
840 let runtime_dir = root.join(".kranz").join("missions").join("m-test");
841 ProvisionSpec {
842 mission_id: "m-test".to_string(),
843 repo_root: root.to_path_buf(),
844 gate_env: crate::workspace_provider::GateEnvPolicy::for_mission(&runtime_dir, &[]),
845 runtime_dir,
846 base_sha: Some("deadbeefcafe".to_string()),
847 contract,
848 }
849 }
850
851 fn contract(json: &[u8]) -> WorkspaceContract {
852 parse_workspace_contract(json).expect("valid contract")
853 }
854
855 fn remote_contract() -> WorkspaceContract {
858 contract(
859 br#"{
860 "schemaVersion": 1,
861 "bootstrap": ["echo never-run-remotely"],
862 "readiness": ["true"],
863 "previews": [
864 { "name": "app", "urlTemplate": "http://localhost:{port}/" },
865 { "name": "db", "urlTemplate": "postgres://localhost:{port}/" }
866 ],
867 "secrets": ["DATABASE_URL", "STRIPE_API_KEY"]
868 }"#,
869 )
870 }
871
872 #[derive(Default)]
873 struct Progress(Vec<String>);
874
875 impl Progress {
876 fn sink(&mut self) -> impl FnMut(&str, Option<String>) -> Result<()> + Send + use<'_> {
877 |summary, _detail| {
878 self.0.push(summary.to_string());
879 Ok(())
880 }
881 }
882 }
883
884 struct FakeSubstrateClient {
889 created: Mutex<Vec<SubstrateWorkspaceSpec>>,
890 ops: Mutex<Vec<String>>,
891 statuses: Mutex<VecDeque<SubstrateStatus>>,
892 fail_create: Mutex<Option<String>>,
893 workspace: SubstrateWorkspace,
894 }
895
896 impl FakeSubstrateClient {
897 fn new(statuses: Vec<SubstrateStatus>) -> Arc<Self> {
898 Arc::new(Self {
899 created: Mutex::new(Vec::new()),
900 ops: Mutex::new(Vec::new()),
901 statuses: Mutex::new(statuses.into()),
902 fail_create: Mutex::new(None),
903 workspace: SubstrateWorkspace {
904 id: "ws-abc123".to_string(),
905 urls: vec![SubstrateUrl {
906 name: "app".to_string(),
907 url: "https://app--m-test.coder.internal.example.com".to_string(),
908 auth: Some(true),
909 }],
910 takeover: Some(
911 "ssh://coder.internal.example.com/kranz-remote-m-test".to_string(),
912 ),
913 },
914 })
915 }
916
917 fn failing_create(message: &str) -> Arc<Self> {
918 let client = Self::new(vec![]);
919 client
920 .fail_create
921 .lock()
922 .unwrap()
923 .replace(message.to_string());
924 client
925 }
926
927 fn ops(&self) -> Vec<String> {
928 self.ops.lock().unwrap().clone()
929 }
930 }
931
932 #[async_trait::async_trait]
933 impl SubstrateClient for FakeSubstrateClient {
934 async fn create_workspace(
935 &self,
936 spec: &SubstrateWorkspaceSpec,
937 ) -> Result<SubstrateWorkspace> {
938 self.created.lock().unwrap().push(spec.clone());
939 if let Some(message) = self.fail_create.lock().unwrap().as_ref() {
940 return Err(EngineError::InvalidState(message.clone()));
941 }
942 Ok(self.workspace.clone())
943 }
944
945 async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus> {
946 self.ops.lock().unwrap().push(format!("status:{id}"));
947 let mut statuses = self.statuses.lock().unwrap();
948 if statuses.len() > 1 {
949 Ok(statuses.pop_front().expect("len > 1"))
950 } else {
951 Ok(statuses.front().cloned().unwrap_or(SubstrateStatus::Ready))
952 }
953 }
954
955 async fn delete_workspace(&self, id: &str) -> Result<()> {
956 self.ops.lock().unwrap().push(format!("delete:{id}"));
957 Ok(())
958 }
959
960 async fn stop_workspace(&self, id: &str) -> Result<()> {
961 self.ops.lock().unwrap().push(format!("stop:{id}"));
962 Ok(())
963 }
964 }
965
966 fn provider_with(client: Arc<FakeSubstrateClient>) -> RemoteWorkspaceProvider {
967 RemoteWorkspaceProvider::with_client(config(), client)
968 }
969
970 async fn provisioned(
971 provider: &RemoteWorkspaceProvider,
972 root: &std::path::Path,
973 ) -> WorkspaceHandle {
974 provider
975 .provision(&spec(root, Some(remote_contract())))
976 .await
977 .expect("provision")
978 }
979
980 #[tokio::test]
984 async fn remote_workspace_provision_creates_with_template_name_and_secret_names() {
985 let dir = tempfile::tempdir().expect("tempdir");
986 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
987 let provider = provider_with(Arc::clone(&client));
988 let handle = provisioned(&provider, dir.path()).await;
989
990 let created = client.created.lock().unwrap().clone();
991 assert_eq!(created.len(), 1, "exactly one create_workspace call");
992 assert_eq!(created[0].template, "tmpl-baked-ami");
993 assert_eq!(created[0].name, "kranz-remote-m-test");
994 assert_eq!(
995 created[0].env_names,
996 vec!["DATABASE_URL".to_string(), "STRIPE_API_KEY".to_string()],
997 "the contract's secret NAMES — and only names — cross to the substrate"
998 );
999
1000 assert_eq!(
1001 handle.cwd,
1002 dir.path(),
1003 "sessions stay in the local worktree in v1"
1004 );
1005 assert_eq!(
1006 handle.env.get("KRANZ_BASE_SHA").map(String::as_str),
1007 Some("deadbeefcafe")
1008 );
1009 let remote = handle.remote.as_ref().expect("remote state");
1010 assert_eq!(remote.id, "ws-abc123");
1011 assert_eq!(remote.injected_env_names, created[0].env_names);
1012 assert!(
1013 handle.detail.as_deref().unwrap().contains("ws-abc123")
1014 && handle
1015 .detail
1016 .as_deref()
1017 .unwrap()
1018 .contains("DATABASE_URL,STRIPE_API_KEY"),
1019 "the provisioned detail records the workspace and injected names: {:?}",
1020 handle.detail
1021 );
1022 }
1023
1024 #[tokio::test]
1028 async fn remote_workspace_ready_poll_lands_previews_and_takeover() {
1029 let dir = tempfile::tempdir().expect("tempdir");
1030 let client =
1031 FakeSubstrateClient::new(vec![SubstrateStatus::Pending, SubstrateStatus::Ready]);
1032 let provider = provider_with(Arc::clone(&client));
1033 let handle = provisioned(&provider, dir.path()).await;
1034
1035 assert!(
1036 client
1037 .ops()
1038 .iter()
1039 .filter(|op| op.starts_with("status:"))
1040 .count()
1041 >= 2,
1042 "the poll looped past the pending status: {:?}",
1043 client.ops()
1044 );
1045 let remote = handle.remote.as_ref().expect("remote state");
1046 assert_eq!(remote.poll, PollOutcome::Ready);
1047 assert_eq!(
1048 remote.takeover.as_deref(),
1049 Some("ssh://coder.internal.example.com/kranz-remote-m-test")
1050 );
1051 assert_eq!(
1052 remote.previews,
1053 vec![ProvisionedPreview {
1054 name: "app".to_string(),
1055 url: "https://app--m-test.coder.internal.example.com".to_string(),
1056 auth: Some(true),
1057 }],
1058 "only the substrate-reported URL is recorded — db stays unfabricated"
1059 );
1060 assert_eq!(
1062 handle.previews,
1063 vec![
1064 PreviewPlaceholder {
1065 name: "app".to_string(),
1066 url_template: "https://app--m-test.coder.internal.example.com".to_string(),
1067 },
1068 PreviewPlaceholder {
1069 name: "db".to_string(),
1070 url_template: "postgres://localhost:{port}/".to_string(),
1071 },
1072 ]
1073 );
1074
1075 let mut progress = Progress::default();
1077 let outcome = provider
1078 .readiness(&handle, &mut progress.sink())
1079 .await
1080 .expect("readiness");
1081 assert!(matches!(outcome, ReadinessOutcome::Ready), "{outcome:?}");
1082 assert_eq!(progress.0.len(), 1);
1083 assert!(
1084 progress.0[0].starts_with("workspace remote: substrate reports ready")
1085 && progress.0[0].contains("substrate-reported readiness only"),
1086 "honest readiness wording, no gate-outcome prefix: {}",
1087 progress.0[0]
1088 );
1089 }
1090
1091 #[tokio::test]
1095 async fn remote_workspace_failed_status_is_a_provider_owned_block() {
1096 let dir = tempfile::tempdir().expect("tempdir");
1097 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Failed {
1098 reason: "template build exited 1".to_string(),
1099 }]);
1100 let provider = provider_with(Arc::clone(&client));
1101 let handle = provisioned(&provider, dir.path()).await;
1102
1103 let mut progress = Progress::default();
1104 let outcome = provider
1105 .readiness(&handle, &mut progress.sink())
1106 .await
1107 .expect("readiness");
1108 let ReadinessOutcome::ProviderFailed { detail } = outcome else {
1109 panic!("a failed substrate status must be ProviderFailed, got {outcome:?}");
1110 };
1111 assert!(detail.contains("kranz-remote-m-test"), "{detail}");
1112 assert!(detail.contains("template build exited 1"), "{detail}");
1113
1114 let reason = provider_block_reason(&detail);
1115 assert!(reason.starts_with("workspace provider:"), "{reason}");
1116 assert!(reason.contains("owner: provider"), "{reason}");
1117 assert!(!reason.contains("repo-setup"), "{reason}");
1118 assert!(
1119 !reason.starts_with(crate::workspace_gate::GATE_REASON_PREFIX),
1120 "not gate-owned ⇒ the pass path never auto-lifts it: {reason}"
1121 );
1122 assert!(
1123 progress.0.is_empty(),
1124 "a failed workspace reports no ready line"
1125 );
1126 }
1127
1128 #[tokio::test]
1131 async fn remote_workspace_poll_timeout_is_provider_owned_and_bounded() {
1132 let dir = tempfile::tempdir().expect("tempdir");
1133 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Pending]);
1134 let provider = provider_with(Arc::clone(&client));
1135 let handle =
1136 tokio::time::timeout(Duration::from_secs(10), provisioned(&provider, dir.path()))
1137 .await
1138 .expect("the provision poll must terminate inside its bound");
1139
1140 assert!(matches!(
1141 handle.remote.as_ref().expect("remote").poll,
1142 PollOutcome::TimedOut { .. }
1143 ));
1144 let mut progress = Progress::default();
1145 let outcome = provider
1146 .readiness(&handle, &mut progress.sink())
1147 .await
1148 .expect("readiness");
1149 let ReadinessOutcome::ProviderFailed { detail } = outcome else {
1150 panic!("a poll timeout must be ProviderFailed, got {outcome:?}");
1151 };
1152 assert!(detail.contains("did not become ready within"), "{detail}");
1153 }
1154
1155 #[tokio::test]
1159 async fn remote_workspace_create_failure_is_recorded_and_teardown_noops() {
1160 let dir = tempfile::tempdir().expect("tempdir");
1161 let client = FakeSubstrateClient::failing_create("HTTP 401: bad token");
1162 let provider = provider_with(Arc::clone(&client));
1163 let handle = provisioned(&provider, dir.path()).await;
1164
1165 let remote = handle.remote.as_ref().expect("remote state");
1166 assert!(remote.id.is_empty(), "no id when create failed");
1167 assert!(
1168 matches!(&remote.poll, PollOutcome::Failed { reason } if reason.contains("HTTP 401")),
1169 "{:?}",
1170 remote.poll
1171 );
1172 let mut progress = Progress::default();
1173 let outcome = provider
1174 .readiness(&handle, &mut progress.sink())
1175 .await
1176 .expect("readiness");
1177 assert!(
1178 matches!(outcome, ReadinessOutcome::ProviderFailed { .. }),
1179 "{outcome:?}"
1180 );
1181
1182 for mode in [
1183 TeardownMode::Keep,
1184 TeardownMode::Hibernate,
1185 TeardownMode::Destroy,
1186 ] {
1187 provider
1188 .teardown(handle.clone(), mode)
1189 .await
1190 .expect("teardown with no workspace no-ops");
1191 }
1192 assert!(
1193 !client
1194 .ops()
1195 .iter()
1196 .any(|op| op.starts_with("stop:") || op.starts_with("delete:")),
1197 "nothing exists ⇒ no substrate teardown calls: {:?}",
1198 client.ops()
1199 );
1200 }
1201
1202 #[tokio::test]
1205 async fn remote_workspace_teardown_modes_map_to_delete_stop_keep() {
1206 let dir = tempfile::tempdir().expect("tempdir");
1207
1208 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1209 let provider = provider_with(Arc::clone(&client));
1210 let handle = provisioned(&provider, dir.path()).await;
1211 provider
1212 .teardown(handle, TeardownMode::Keep)
1213 .await
1214 .expect("keep");
1215 assert!(
1216 !client
1217 .ops()
1218 .iter()
1219 .any(|op| op.starts_with("stop:") || op.starts_with("delete:")),
1220 "Keep leaves the workspace running — no teardown call: {:?}",
1221 client.ops()
1222 );
1223
1224 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1225 let provider = provider_with(Arc::clone(&client));
1226 let handle = provisioned(&provider, dir.path()).await;
1227 provider
1228 .teardown(handle, TeardownMode::Hibernate)
1229 .await
1230 .expect("hibernate");
1231 assert!(
1232 client.ops().contains(&"stop:ws-abc123".to_string())
1233 && !client.ops().iter().any(|op| op.starts_with("delete:")),
1234 "Hibernate is stop_workspace: {:?}",
1235 client.ops()
1236 );
1237
1238 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1239 let provider = provider_with(Arc::clone(&client));
1240 let handle = provisioned(&provider, dir.path()).await;
1241 provider
1242 .teardown(handle, TeardownMode::Destroy)
1243 .await
1244 .expect("destroy");
1245 assert!(
1246 client.ops().contains(&"delete:ws-abc123".to_string())
1247 && !client.ops().iter().any(|op| op.starts_with("stop:")),
1248 "Destroy is delete_workspace: {:?}",
1249 client.ops()
1250 );
1251 }
1252
1253 #[tokio::test]
1258 async fn idle_after_hours_passes_through_to_create_and_the_provisioned_detail() {
1259 let validated = RemoteConfig::require(Some(&RemoteWorkspaceConfig {
1262 base_url: Some("https://coder.internal.example.com".to_string()),
1263 template: Some("tmpl-baked-ami".to_string()),
1264 token_env: Some("CODER_SESSION_TOKEN".to_string()),
1265 idle_after_hours: Some(24.0),
1266 }))
1267 .expect("complete remote config validates with an idle policy");
1268 assert_eq!(validated.idle_after_hours, Some(24.0));
1269 let without = RemoteConfig::require(Some(&RemoteWorkspaceConfig {
1270 base_url: Some("https://coder.internal.example.com".to_string()),
1271 template: Some("tmpl-baked-ami".to_string()),
1272 token_env: Some("CODER_SESSION_TOKEN".to_string()),
1273 idle_after_hours: None,
1274 }))
1275 .expect("complete remote config validates without an idle policy");
1276 assert_eq!(without.idle_after_hours, None);
1277
1278 let dir = tempfile::tempdir().expect("tempdir");
1279 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1280 let provider = RemoteWorkspaceProvider::with_client(
1281 validated,
1282 Arc::clone(&client) as Arc<dyn SubstrateClient>,
1283 );
1284 let handle = provisioned(&provider, dir.path()).await;
1285
1286 let created = client.created.lock().unwrap().clone();
1287 assert_eq!(created.len(), 1);
1288 assert_eq!(
1289 created[0].idle_after_hours,
1290 Some(24.0),
1291 "the idle policy VALUE crosses to the substrate verbatim"
1292 );
1293 assert!(
1294 handle
1295 .detail
1296 .as_deref()
1297 .unwrap()
1298 .contains("idle policy: hibernate after 24h (substrate-owned)"),
1299 "the provisioned event records the substrate-owned policy: {:?}",
1300 handle.detail
1301 );
1302 }
1303
1304 #[tokio::test]
1307 async fn remote_workspace_contract_less_provision_never_touches_the_substrate() {
1308 let dir = tempfile::tempdir().expect("tempdir");
1309 let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1310 let provider = provider_with(Arc::clone(&client));
1311 let handle = provider
1312 .provision(&spec(dir.path(), None))
1313 .await
1314 .expect("contract-less provision");
1315 assert!(handle.remote.is_none() && handle.contract.is_none());
1316 assert!(
1317 client.created.lock().unwrap().is_empty() && client.ops().is_empty(),
1318 "no contract ⇒ no substrate workspace (D-H)"
1319 );
1320 let mut progress = Progress::default();
1321 let outcome = provider
1322 .readiness(&handle, &mut progress.sink())
1323 .await
1324 .expect("readiness");
1325 assert!(matches!(outcome, ReadinessOutcome::Ready));
1326 assert!(progress.0.is_empty(), "contract-less ⇒ silent");
1327 }
1328
1329 #[tokio::test]
1333 async fn remote_workspace_missing_creds_fail_closed_naming_the_env_var() {
1334 let dir = tempfile::tempdir().expect("tempdir");
1335 let var = format!("KRANZ_TEST_REMOTE_TOKEN_UNSET_{}", std::process::id());
1336 std::env::remove_var(&var); let mut cfg = config();
1338 cfg.token_env = var.clone();
1339 let provider = RemoteWorkspaceProvider {
1340 config: cfg,
1341 client_hook: Arc::new(coder_client_from_env),
1342 ready_timeout: Duration::from_millis(50),
1343 poll_interval: Duration::from_millis(5),
1344 };
1345 let err = provider
1346 .provision(&spec(dir.path(), Some(remote_contract())))
1347 .await
1348 .expect_err("missing creds must fail closed at provision");
1349 let msg = err.to_string();
1350 assert!(msg.contains(&var), "names the env var NAME: {msg}");
1351 assert!(msg.contains("workspace.remote.tokenEnv"), "{msg}");
1352 assert!(msg.contains("owner: operator"), "{msg}");
1353 assert!(
1354 msg.contains("refusing rather than silently falling back"),
1355 "{msg}"
1356 );
1357 }
1358
1359 #[test]
1360 fn remote_workspace_name_is_mission_owned_and_coder_shaped() {
1361 assert_eq!(workspace_name("m-test"), "kranz-remote-m-test");
1362 assert_eq!(workspace_name("M.Test X"), "kranz-remote-m-test-x");
1363 assert_eq!(
1364 workspace_name("..."),
1365 "kranz-remote----",
1366 "every non-alnum sanitizes to '-'"
1367 );
1368 assert_eq!(
1369 workspace_name(""),
1370 "kranz-remote-mission",
1371 "the prefix guarantees a valid leading character"
1372 );
1373 }
1374
1375 #[test]
1376 fn coder_http_client_rejects_a_bad_base_url() {
1377 let err = CoderHttpClient::new("not a url", "tok")
1378 .err()
1379 .expect("invalid URL fails closed");
1380 assert!(
1381 err.to_string().contains("workspace.remote.baseUrl"),
1382 "{err}"
1383 );
1384 let err = CoderHttpClient::new("file:///etc/passwd", "tok")
1385 .err()
1386 .expect("non-http(s) schemes fail closed");
1387 assert!(err.to_string().contains("http(s)"), "{err}");
1388 }
1389
1390 #[test]
1391 fn coder_http_status_mapping_is_conservative() {
1392 assert_eq!(
1393 parse_status(&serde_json::json!({"status": "running"})),
1394 SubstrateStatus::Ready
1395 );
1396 assert_eq!(
1397 parse_status(&serde_json::json!({"latest_build": {"status": "running"}})),
1398 SubstrateStatus::Ready,
1399 "stock Coder nests the state under latest_build"
1400 );
1401 assert!(matches!(
1402 parse_status(&serde_json::json!({"latest_build": {"status": "failed"}})),
1403 SubstrateStatus::Failed { .. }
1404 ));
1405 for unknown in [
1406 serde_json::json!({"latest_build": {"status": "starting"}}),
1407 serde_json::json!({"status": "stopping"}),
1408 serde_json::json!({}),
1409 ] {
1410 assert_eq!(
1411 parse_status(&unknown),
1412 SubstrateStatus::Pending,
1413 "unknown/absent states keep polling, never guess ready: {unknown}"
1414 );
1415 }
1416 }
1417
1418 fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1420 haystack
1421 .windows(needle.len())
1422 .position(|window| window == needle)
1423 }
1424
1425 async fn serve_once(
1431 listener: tokio::net::TcpListener,
1432 body: &str,
1433 recorded: Arc<Mutex<Vec<String>>>,
1434 ) {
1435 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1436 let (mut socket, _) = listener.accept().await.expect("accept");
1437 let mut buf = Vec::new();
1438 let mut chunk = [0u8; 4096];
1439 loop {
1440 let n = socket.read(&mut chunk).await.expect("read request");
1441 assert!(n > 0, "connection closed before the full request arrived");
1442 buf.extend_from_slice(&chunk[..n]);
1443 if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
1444 let headers = String::from_utf8_lossy(&buf[..pos]).to_string();
1445 let content_length = headers
1446 .lines()
1447 .find_map(|line| {
1448 line.to_ascii_lowercase()
1449 .strip_prefix("content-length:")
1450 .and_then(|value| value.trim().parse::<usize>().ok())
1451 })
1452 .unwrap_or(0);
1453 if buf.len() >= pos + 4 + content_length {
1454 break;
1455 }
1456 }
1457 }
1458 recorded
1459 .lock()
1460 .unwrap()
1461 .push(String::from_utf8_lossy(&buf).to_string());
1462 let response = format!(
1463 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
1464 body.len()
1465 );
1466 socket
1467 .write_all(response.as_bytes())
1468 .await
1469 .expect("write response");
1470 }
1471
1472 #[tokio::test]
1476 async fn coder_http_client_maps_the_coder_shaped_wire_over_loopback() {
1477 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1478 .await
1479 .expect("bind loopback");
1480 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1481 let recorded = Arc::new(Mutex::new(Vec::new()));
1482 let create_body = serde_json::json!({
1483 "id": "ws-loop1",
1484 "urls": [{"name": "app", "url": "https://app.example.com", "auth": true}],
1485 "takeover": "https://coder.example.com/@me/ws-loop1",
1486 })
1487 .to_string();
1488
1489 let client = CoderHttpClient::new(&base_url, "test-session-token").expect("client");
1490 let create_spec = SubstrateWorkspaceSpec {
1491 template: "tmpl-1".to_string(),
1492 name: "kranz-remote-m-1".to_string(),
1493 env_names: vec!["DATABASE_URL".to_string()],
1494 idle_after_hours: None,
1495 };
1496 let ((), workspace) = tokio::join!(
1497 serve_once(listener, &create_body, Arc::clone(&recorded)),
1498 tokio::time::timeout(
1499 Duration::from_secs(10),
1500 client.create_workspace(&create_spec)
1501 )
1502 );
1503 let workspace = workspace.expect("bounded").expect("create_workspace");
1504
1505 assert_eq!(workspace.id, "ws-loop1");
1506 assert_eq!(
1507 workspace.urls,
1508 vec![SubstrateUrl {
1509 name: "app".to_string(),
1510 url: "https://app.example.com".to_string(),
1511 auth: Some(true),
1512 }]
1513 );
1514 assert_eq!(
1515 workspace.takeover.as_deref(),
1516 Some("https://coder.example.com/@me/ws-loop1")
1517 );
1518
1519 let requests = recorded.lock().unwrap().clone();
1520 assert_eq!(requests.len(), 1);
1521 let request = &requests[0];
1522 assert!(
1523 request.starts_with("POST /api/v2/users/me/workspaces "),
1524 "the create verb+path: {}",
1525 request.lines().next().unwrap_or("")
1526 );
1527 assert!(
1528 request.contains("coder-session-token: test-session-token"),
1529 "Coder's auth header carries the token (and the token appears NOWHERE else): {request}"
1530 );
1531 let body = request.split("\r\n\r\n").nth(1).expect("a JSON body");
1532 let body: serde_json::Value = serde_json::from_str(body).expect("body is JSON");
1533 assert_eq!(body["template_id"], "tmpl-1");
1534 assert_eq!(body["name"], "kranz-remote-m-1");
1535 assert_eq!(
1536 body["env_names"],
1537 serde_json::json!(["DATABASE_URL"]),
1538 "secret NAMES on the wire — never values"
1539 );
1540 }
1541
1542 #[tokio::test]
1547 async fn coder_http_create_body_carries_idle_after_hours_only_when_configured() {
1548 for (idle_after_hours, expected) in
1549 [(Some(24.0), Some(serde_json::json!(24.0))), (None, None)]
1550 {
1551 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1552 .await
1553 .expect("bind loopback");
1554 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1555 let recorded = Arc::new(Mutex::new(Vec::new()));
1556 let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1557 let spec = SubstrateWorkspaceSpec {
1558 template: "tmpl-1".to_string(),
1559 name: "kranz-remote-m-1".to_string(),
1560 env_names: vec![],
1561 idle_after_hours,
1562 };
1563 let ((), created) = tokio::join!(
1564 serve_once(listener, r#"{"id":"ws-1"}"#, Arc::clone(&recorded)),
1565 tokio::time::timeout(Duration::from_secs(10), client.create_workspace(&spec))
1566 );
1567 created.expect("bounded").expect("create_workspace");
1568 let request = recorded.lock().unwrap()[0].clone();
1569 let body = request.split("\r\n\r\n").nth(1).expect("a JSON body");
1570 let body: serde_json::Value = serde_json::from_str(body).expect("body is JSON");
1571 assert_eq!(
1572 body.get("idle_after_hours").cloned(),
1573 expected,
1574 "idle_after_hours rides the wire only when configured: {body}"
1575 );
1576 }
1577 }
1578
1579 #[tokio::test]
1582 async fn coder_http_client_status_and_transitions_over_loopback() {
1583 let recorded = Arc::new(Mutex::new(Vec::new()));
1584
1585 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1587 .await
1588 .expect("bind loopback");
1589 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1590 let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1591 let ((), status) = tokio::join!(
1592 serve_once(
1593 listener,
1594 r#"{"latest_build":{"status":"running"}}"#,
1595 Arc::clone(&recorded)
1596 ),
1597 tokio::time::timeout(Duration::from_secs(10), client.workspace_status("ws-9"))
1598 );
1599 let status = status.expect("bounded").expect("status");
1600 assert_eq!(status, SubstrateStatus::Ready);
1601 assert!(
1602 recorded.lock().unwrap()[0].starts_with("GET /api/v2/workspaces/ws-9 "),
1603 "status path: {:?}",
1604 recorded.lock().unwrap()[0].lines().next()
1605 );
1606
1607 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1609 .await
1610 .expect("bind loopback");
1611 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1612 let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1613 let ((), stopped) = tokio::join!(
1614 serve_once(listener, "{}", Arc::clone(&recorded)),
1615 tokio::time::timeout(Duration::from_secs(10), client.stop_workspace("ws-9"))
1616 );
1617 stopped.expect("bounded").expect("stop");
1618 let stop_request = recorded.lock().unwrap().last().unwrap().clone();
1619 assert!(
1620 stop_request.starts_with("POST /api/v2/workspaces/ws-9/builds "),
1621 "{stop_request}"
1622 );
1623 assert!(
1624 stop_request.contains(r#""transition":"stop""#),
1625 "the stop transition body: {stop_request}"
1626 );
1627
1628 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1629 .await
1630 .expect("bind loopback");
1631 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1632 let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1633 let ((), deleted) = tokio::join!(
1634 serve_once(listener, "{}", Arc::clone(&recorded)),
1635 tokio::time::timeout(Duration::from_secs(10), client.delete_workspace("ws-9"))
1636 );
1637 deleted.expect("bounded").expect("delete");
1638 let delete_request = recorded.lock().unwrap().last().unwrap().clone();
1639 assert!(
1640 delete_request.contains(r#""transition":"delete""#),
1641 "the delete transition body: {delete_request}"
1642 );
1643 }
1644}