fix: decouple workdir attachments from materialization

This commit is contained in:
2026-09-22 02:35:42 +09:00
parent 2cdc35f3e2
commit aa41250beb
9 changed files with 439 additions and 168 deletions
+12 -1
View File
@@ -345,6 +345,15 @@ pub struct WorkingDirectoryRequest {
pub materialization: Option<RepositoryMaterializationContext>, pub materialization: Option<RepositoryMaterializationContext>,
} }
/// Workspace-authoritative alias-to-Workdir mapping, independent of any
/// Runtime-local materialized binding.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LogicalWorkdirAttachment {
/// Stable Worker-local routing key. This is not a Workdir id or display name.
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryAttachmentClaim { pub struct WorkingDirectoryAttachmentClaim {
/// Stable Worker-local routing key. This is not a Workdir id or display name. /// Stable Worker-local routing key. This is not a Workdir id or display name.
@@ -356,7 +365,9 @@ pub struct WorkingDirectoryAttachmentClaim {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkerWorkdirAttachmentsRequest { pub struct WorkerWorkdirAttachmentsRequest {
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>, /// Exact current logical attachment set. Runtime-local bindings are not
/// inferred from these identities.
pub logical_workdir_attachments: Vec<LogicalWorkdirAttachment>,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+8
View File
@@ -198,6 +198,14 @@ pub struct RepositoryRefObservation {
pub observed_at_epoch_seconds: u64, pub observed_at_epoch_seconds: u64,
} }
/// Workspace-authoritative alias-to-Workdir mapping. This carries no claim that
/// the Worker Runtime materialized or can bind the referenced Workdir locally.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogicalWorkdirAttachment {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryAttachmentClaim { pub struct WorkingDirectoryAttachmentClaim {
pub alias: workdir::WorkdirAttachmentAlias, pub alias: workdir::WorkdirAttachmentAlias,
+1
View File
@@ -259,6 +259,7 @@ pub struct WorkerExecutionRestoreRequest {
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>, pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
pub previous_workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>, pub previous_workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
pub logical_workdir_attachments: Vec<crate::catalog::LogicalWorkdirAttachment>,
pub workdir_attachments: BTreeMap<workdir::WorkdirAttachmentAlias, WorkingDirectoryBinding>, pub workdir_attachments: BTreeMap<workdir::WorkdirAttachmentAlias, WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>, pub config_bundle: Option<ConfigBundle>,
} }
+47 -3
View File
@@ -1,7 +1,7 @@
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerRestoreIntent, WorkerStatus, ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, ProfileSelector,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, WorkerRestoreIntent, WorkerStatus, WorkingDirectoryAttachmentClaim,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRequest, WorkingDirectoryAttachmentRequest, WorkingDirectoryAttachmentStatus, WorkingDirectoryRequest,
}; };
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
@@ -444,6 +444,7 @@ pub(crate) struct PersistedWorkerRecord {
pub(crate) execution_state: PersistedWorkerExecutionState, pub(crate) execution_state: PersistedWorkerExecutionState,
pub(crate) workspace_id: Option<String>, pub(crate) workspace_id: Option<String>,
pub(crate) workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>, pub(crate) workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
pub(crate) logical_workdir_attachments: Vec<LogicalWorkdirAttachment>,
} }
fn runtime_io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError { fn runtime_io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError {
@@ -1688,6 +1689,8 @@ struct WorkerIdentityRecord {
workspace_id: Option<String>, workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>, workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
logical_workdir_attachments: Vec<LogicalWorkdirAttachment>,
/// Schema-v8 compatibility input. New records serialize only `workdir_attachments`. /// Schema-v8 compatibility input. New records serialize only `workdir_attachments`.
#[serde( #[serde(
default, default,
@@ -1787,6 +1790,7 @@ impl WorkerIdentityRecord {
status: worker.status, status: worker.status,
workspace_id: worker.workspace_id.clone(), workspace_id: worker.workspace_id.clone(),
workdir_attachments: worker.workdir_attachments.clone(), workdir_attachments: worker.workdir_attachments.clone(),
logical_workdir_attachments: worker.logical_workdir_attachments.clone(),
legacy_working_directory: None, legacy_working_directory: None,
} }
} }
@@ -1860,6 +1864,7 @@ impl WorkerIdentityRecord {
execution_state, execution_state,
workspace_id: self.workspace_id, workspace_id: self.workspace_id,
workdir_attachments, workdir_attachments,
logical_workdir_attachments: self.logical_workdir_attachments,
} }
} }
} }
@@ -2337,6 +2342,7 @@ mod tests {
})) }))
.unwrap(); .unwrap();
let persisted = identity.into_persisted(PersistedWorkerExecutionState::Unavailable); let persisted = identity.into_persisted(PersistedWorkerExecutionState::Unavailable);
assert!(persisted.logical_workdir_attachments.is_empty());
assert_eq!(persisted.workdir_attachments.len(), 1); assert_eq!(persisted.workdir_attachments.len(), 1);
assert_eq!(persisted.workdir_attachments[0].alias.as_str(), "workdir"); assert_eq!(persisted.workdir_attachments[0].alias.as_str(), "workdir");
assert_eq!( assert_eq!(
@@ -2369,6 +2375,44 @@ mod tests {
assert!(serialized["request"].get("working_directory").is_none()); assert!(serialized["request"].get("working_directory").is_none());
} }
#[test]
fn worker_identity_round_trips_logical_workdir_attachments() {
let worker_id = WorkerId::now_v7();
let identity: WorkerIdentityRecord = serde_json::from_value(serde_json::json!({
"schema_version": SCHEMA_VERSION,
"worker_ref": { "worker_id": worker_id },
"worker_id": worker_id,
"profile": { "kind": "builtin", "value": "builtin:coder" },
"display_name": null,
"profile_source": {
"id": "archive-1",
"digest": "sha256:archive",
"size_bytes": 0,
"source_graph": {
"source_count": 0,
"total_source_bytes": 0,
"entrypoints": {},
"import_count": 0
}
},
"config_bundle": null,
"status": "stopped",
"logical_workdir_attachments": [{
"alias": "checkout",
"working_directory_id": "remote-workdir"
}]
}))
.unwrap();
let persisted = identity.into_persisted(PersistedWorkerExecutionState::Unavailable);
assert_eq!(
persisted.logical_workdir_attachments,
[LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "remote-workdir".to_string(),
}]
);
}
#[test] #[test]
fn startup_recovers_staging_created_before_source_backup() { fn startup_recovers_staging_created_before_source_backup() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -428,7 +428,7 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
worker_id: String, worker_id: String,
value: runtime_api::WorkerWorkdirAttachmentsRequest, value: runtime_api::WorkerWorkdirAttachmentsRequest,
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> { ) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
let attachments = request(value.workdir_attachments)?; let attachments = request(value.logical_workdir_attachments)?;
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref()) let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)? .map_err(api_error)?
+186 -86
View File
@@ -1,10 +1,9 @@
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource, ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, ProfileSelector,
RepositoryRefObservation, RepositoryRefObservationRequest, WorkerDetail, WorkerLifecycleAck, ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkerRestoreIntent, WorkerStatus, WorkerSummary, WorkingDirectoryAttachmentClaim, WorkerDetail, WorkerLifecycleAck, WorkerRestoreIntent, WorkerStatus, WorkerSummary,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryRequest, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle, ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary, validate_config_bundle,
@@ -1061,6 +1060,7 @@ impl Runtime {
execution_bound: true, execution_bound: true,
restore_intent: WorkerRestoreIntent::Explicit, restore_intent: WorkerRestoreIntent::Explicit,
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
logical_workdir_attachments: Vec::new(),
execution_handle: None, execution_handle: None,
internal_workers: InternalWorkerActivityProjection::default(), internal_workers: InternalWorkerActivityProjection::default(),
}; };
@@ -1447,106 +1447,95 @@ impl Runtime {
&self, &self,
scope: &RuntimeWorkspaceScope, scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
attachments: Vec<WorkingDirectoryAttachmentClaim>, attachments: Vec<LogicalWorkdirAttachment>,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?; self.ensure_worker_in_workspace(scope, worker_ref)?;
self.replace_worker_workdir_attachments(worker_ref, attachments) self.replace_worker_workdir_attachments(worker_ref, attachments)
} }
/// Persist the exact current attachment set used by live mutation and future restore. /// Persist the Workspace-authoritative logical attachment set used by live
/// mutation and future restore. Runtime-local bindings remain a separate,
/// optional implementation detail and never authorize logical attachment.
pub fn replace_worker_workdir_attachments( pub fn replace_worker_workdir_attachments(
&self, &self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
mut attachments: Vec<WorkingDirectoryAttachmentClaim>, mut attachments: Vec<LogicalWorkdirAttachment>,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?; let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
let _operation_guard = operation_lock let _operation_guard = operation_lock
.lock() .lock()
.map_err(|_| RuntimeError::StatePoisoned)?; .map_err(|_| RuntimeError::StatePoisoned)?;
validate_logical_workdir_attachments(&attachments)?;
let backend = { attachments.sort_by(|left, right| left.alias.cmp(&right.alias));
let state = self.lock()?;
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_none() || !worker.status.is_active() {
return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker.worker_id,
message: "Workdir attachments can only be changed for a live Worker"
.to_string(),
});
}
let request = worker.request.as_ref().ok_or_else(|| {
RuntimeError::WorkerExecutionUnavailable {
worker_id: worker.worker_id,
message: "persisted Worker restore request is unavailable".to_string(),
}
})?;
for attachment in &mut attachments {
if attachment.relative_cwd.is_none()
&& let Some(previous) = request.workdir_attachments.iter().find(|previous| {
previous.alias == attachment.alias
&& previous.working_directory_id == attachment.working_directory_id
})
{
attachment.relative_cwd = previous.relative_cwd.clone();
}
}
let mut candidate = request.clone();
candidate.workdir_attachment_requests.clear();
candidate.workdir_attachments = attachments.clone();
validate_create_worker_request(&candidate)?;
state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "Workdir attachment mutation requires an execution backend"
.to_string(),
}
})?
};
let statuses = attachments
.iter()
.map(|attachment| {
backend
.working_directory(&attachment.working_directory_id)
.map(|working_directory| WorkingDirectoryAttachmentStatus {
alias: attachment.alias.clone(),
working_directory,
})
.map_err(RuntimeError::from)
})
.collect::<Result<Vec<_>, RuntimeError>>()?;
if let Some(inactive) = statuses.iter().find(|attachment| {
attachment.working_directory.summary.status
!= workdir::workspace::WorkingDirectoryStatusKind::Active
}) {
return Err(RuntimeError::InvalidRequest(format!(
"working directory {} is not active ({})",
inactive.working_directory.summary.working_directory_id,
inactive.working_directory.summary.status
)));
}
let mut state = self.lock()?; let mut state = self.lock()?;
for attachment in &attachments {
if let Some(owner_worker_id) =
state.worker_id_for_workdir(&attachment.working_directory_id)
&& owner_worker_id != worker_ref.worker_id
{
return Err(RuntimeError::InvalidRequest(format!(
"working directory {} is already assigned to worker {owner_worker_id}",
attachment.working_directory_id
)));
}
}
let previous = state.worker(worker_ref)?.clone(); let previous = state.worker(worker_ref)?.clone();
{ {
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
if !attachments.is_empty() && worker.workspace_id.is_none() {
return Err(RuntimeError::InvalidRequest(
"logical Workdir attachments require Workspace authority".to_string(),
));
}
if worker.request.is_none() {
return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker.worker_id,
message: "persisted Worker restore request is unavailable".to_string(),
});
}
let logical_by_alias = attachments
.iter()
.map(|attachment| {
(
attachment.alias.clone(),
attachment.working_directory_id.as_str(),
)
})
.collect::<BTreeMap<_, _>>();
for local in &worker.workdir_attachments {
let local_id = local
.working_directory
.summary
.working_directory_id
.as_str();
if let Some(logical_id) = logical_by_alias.get(&local.alias)
&& *logical_id != local_id
{
return Err(RuntimeError::InvalidRequest(format!(
"logical Workdir attachment alias `{}` conflicts with Runtime-local Workdir {}",
local.alias, local_id
)));
}
}
let retained_local = worker
.workdir_attachments
.iter()
.filter_map(|local| {
let local_id = local
.working_directory
.summary
.working_directory_id
.as_str();
(logical_by_alias.get(&local.alias).copied() == Some(local_id))
.then(|| (local.alias.clone(), local_id.to_string()))
})
.collect::<BTreeMap<_, _>>();
worker
.workdir_attachments
.retain(|local| retained_local.contains_key(&local.alias));
let request = worker let request = worker
.request .request
.as_mut() .as_mut()
.expect("restore request checked above"); .expect("restore request existence checked above");
request.workdir_attachment_requests.clear(); request
request.workdir_attachments = attachments; .workdir_attachment_requests
worker.workdir_attachments = statuses; .retain(|local| retained_local.contains_key(&local.alias));
request.workdir_attachments.retain(|local| {
retained_local
.get(&local.alias)
.is_some_and(|workdir_id| workdir_id == &local.working_directory_id)
});
worker.logical_workdir_attachments = attachments;
} }
if let Err(error) = state.persist_worker(&worker_ref.worker_id) { if let Err(error) = state.persist_worker(&worker_ref.worker_id) {
state.workers.insert(worker_ref.worker_id, previous); state.workers.insert(worker_ref.worker_id, previous);
@@ -1702,6 +1691,7 @@ impl Runtime {
workspace_scope, workspace_scope,
context: self.execution_context(worker_ref.clone()), context: self.execution_context(worker_ref.clone()),
previous_workdir_attachments: worker.workdir_attachments.clone(), previous_workdir_attachments: worker.workdir_attachments.clone(),
logical_workdir_attachments: worker.logical_workdir_attachments.clone(),
workdir_attachments: BTreeMap::new(), workdir_attachments: BTreeMap::new(),
config_bundle: None, config_bundle: None,
}; };
@@ -2225,6 +2215,8 @@ impl Runtime {
let _ = worker.apply_worker_state(snapshot); let _ = worker.apply_worker_state(snapshot);
} }
worker.restore_intent = restore_intent_for_status(worker.status); worker.restore_intent = restore_intent_for_status(worker.status);
worker.logical_workdir_attachments =
logical_workdir_attachments_from_statuses(&workdir_attachments);
worker.workdir_attachments = workdir_attachments; worker.workdir_attachments = workdir_attachments;
worker.detail() worker.detail()
}; };
@@ -2875,6 +2867,8 @@ impl Runtime {
candidate.execution_bound = true; candidate.execution_bound = true;
candidate.status = WorkerStatus::Idle; candidate.status = WorkerStatus::Idle;
candidate.restore_intent = WorkerRestoreIntent::Explicit; candidate.restore_intent = WorkerRestoreIntent::Explicit;
candidate.logical_workdir_attachments =
logical_workdir_attachments_from_statuses(&workdir_attachments);
candidate.workdir_attachments = workdir_attachments; candidate.workdir_attachments = workdir_attachments;
state.workers.insert(worker_ref.worker_id, candidate); state.workers.insert(worker_ref.worker_id, candidate);
state.publish_worker_upsert(worker_ref.worker_id)?; state.publish_worker_upsert(worker_ref.worker_id)?;
@@ -3218,6 +3212,11 @@ impl RuntimeState {
(None, false, false, restore_intent_for_status(worker.status)) (None, false, false, restore_intent_for_status(worker.status))
} }
}; };
let logical_workdir_attachments = if worker.logical_workdir_attachments.is_empty() {
logical_workdir_attachments_from_statuses(&worker.workdir_attachments)
} else {
worker.logical_workdir_attachments
};
workers.insert( workers.insert(
worker_id, worker_id,
WorkerRecord { WorkerRecord {
@@ -3236,6 +3235,7 @@ impl RuntimeState {
execution_bound, execution_bound,
restore_intent, restore_intent,
workdir_attachments: worker.workdir_attachments, workdir_attachments: worker.workdir_attachments,
logical_workdir_attachments,
execution_handle: None, execution_handle: None,
internal_workers: InternalWorkerActivityProjection::default(), internal_workers: InternalWorkerActivityProjection::default(),
}, },
@@ -4132,6 +4132,7 @@ struct WorkerRecord {
execution_bound: bool, execution_bound: bool,
restore_intent: WorkerRestoreIntent, restore_intent: WorkerRestoreIntent,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>, workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
logical_workdir_attachments: Vec<LogicalWorkdirAttachment>,
execution_handle: Option<WorkerExecutionHandle>, execution_handle: Option<WorkerExecutionHandle>,
internal_workers: InternalWorkerActivityProjection, internal_workers: InternalWorkerActivityProjection,
} }
@@ -4205,6 +4206,7 @@ impl WorkerRecord {
execution_state, execution_state,
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
workdir_attachments: self.workdir_attachments.clone(), workdir_attachments: self.workdir_attachments.clone(),
logical_workdir_attachments: self.logical_workdir_attachments.clone(),
} }
} }
} }
@@ -4279,6 +4281,50 @@ fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> {
.collect() .collect()
} }
fn logical_workdir_attachments_from_statuses(
attachments: &[crate::catalog::WorkingDirectoryAttachmentStatus],
) -> Vec<LogicalWorkdirAttachment> {
attachments
.iter()
.map(|attachment| LogicalWorkdirAttachment {
alias: attachment.alias.clone(),
working_directory_id: attachment
.working_directory
.summary
.working_directory_id
.clone(),
})
.collect()
}
fn validate_logical_workdir_attachments(
attachments: &[LogicalWorkdirAttachment],
) -> Result<(), RuntimeError> {
let mut aliases = std::collections::BTreeSet::new();
let mut workdir_ids = std::collections::BTreeSet::new();
for attachment in attachments {
let workdir_id = attachment.working_directory_id.trim();
if workdir_id.is_empty() || workdir_id.chars().any(char::is_control) {
return Err(RuntimeError::InvalidRequest(
"logical Workdir attachment id must not be empty or contain control characters"
.to_string(),
));
}
if !aliases.insert(attachment.alias.as_str()) {
return Err(RuntimeError::InvalidRequest(format!(
"duplicate logical Workdir attachment alias `{}`",
attachment.alias
)));
}
if !workdir_ids.insert(workdir_id) {
return Err(RuntimeError::InvalidRequest(format!(
"logical working directory `{workdir_id}` cannot be attached more than once"
)));
}
}
Ok(())
}
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> { fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
if request.create_fingerprint.trim().is_empty() { if request.create_fingerprint.trim().is_empty() {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(
@@ -4615,7 +4661,8 @@ mod tests {
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext, ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString, RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, WorkingDirectoryAttachmentStatus, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -6168,6 +6215,59 @@ mod tests {
); );
} }
#[test]
fn logical_workdir_attachments_do_not_require_local_materialization() {
let runtime = runtime_with_backend();
let workspace_scope = scope("workspace-a", "server-a");
let worker = runtime
.create_worker_scoped(
&workspace_scope,
scoped_task_request("logical attachment", "workspace-a"),
)
.unwrap();
let attachment = LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "workdir-on-another-runtime".to_string(),
};
let detail = runtime
.replace_worker_workdir_attachments_scoped(
&workspace_scope,
&worker.worker_ref,
vec![attachment.clone()],
)
.unwrap();
assert!(detail.workdir_attachments.is_empty());
{
let state = runtime.lock().unwrap();
assert_eq!(
state
.worker(&worker.worker_ref)
.unwrap()
.logical_workdir_attachments,
[attachment.clone()]
);
}
runtime.stop_worker(&worker.worker_ref, None).unwrap();
runtime
.replace_worker_workdir_attachments_scoped(
&workspace_scope,
&worker.worker_ref,
vec![attachment.clone()],
)
.unwrap();
runtime.restore_worker(&worker.worker_ref).unwrap();
let state = runtime.lock().unwrap();
assert_eq!(
state
.worker(&worker.worker_ref)
.unwrap()
.logical_workdir_attachments,
[attachment]
);
}
#[test] #[test]
fn workspace_owner_binding_rejects_other_backend_and_forgets_after_last_worker_delete() { fn workspace_owner_binding_rejects_other_backend_and_forgets_after_last_worker_delete() {
let runtime = runtime_with_backend(); let runtime = runtime_with_backend();
+73 -62
View File
@@ -115,6 +115,7 @@ use workdir::{
#[cfg(test)] #[cfg(test)]
use worker::WorkerController; use worker::WorkerController;
use worker::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession;
use worker::feature::builtin::{ use worker::feature::builtin::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider, CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider,
WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture, WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture,
@@ -796,6 +797,42 @@ fn runtime_local_workdir_router(
Ok(router) Ok(router)
} }
fn restored_workdir_router(
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
scope: manifest::SharedScope,
workspace_client: Arc<dyn WorkspaceClient>,
) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = runtime_local_workdir_router(local_attachments, scope)?;
for attachment in logical_attachments {
if let Some(local) = local_attachments.get(&attachment.alias) {
if local.working_directory.id != attachment.working_directory_id {
return Err(format!(
"logical Workdir attachment `{}` conflicts with Runtime-local Workdir {}",
attachment.alias, local.working_directory.id
));
}
continue;
}
router
.attach(
attachment.alias.clone(),
WorkspaceAttachedWorkdirSession::handle_for_workdir(
workspace_client.clone(),
attachment.alias.as_str(),
&attachment.working_directory_id,
),
)
.map_err(|error| {
format!(
"bind logical Workdir attachment `{}`: {error}",
attachment.alias
)
})?;
}
Ok(router)
}
fn bind_workspace_memory_settings( fn bind_workspace_memory_settings(
manifest: &mut manifest::WorkerManifest, manifest: &mut manifest::WorkerManifest,
request: &CreateWorkerRequest, request: &CreateWorkerRequest,
@@ -1259,10 +1296,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
}; };
validate_worker_memory_settings(worker.manifest(), &request.request)?; validate_worker_memory_settings(worker.manifest(), &request.request)?;
let flow_transition_enabled = worker.manifest().feature.flow.enabled; let flow_transition_enabled = worker.manifest().feature.flow.enabled;
worker.bind_workdir_sessions(runtime_local_workdir_router( let workdir_sessions = restored_workdir_router(
&request.workdir_attachments, &request.workdir_attachments,
&request.logical_workdir_attachments,
worker.scope().clone(), worker.scope().clone(),
)?); worker.workspace_client_handle(),
)?;
worker.bind_workdir_sessions(workdir_sessions);
if let (Some(runtime_id), Some(workspace_id)) = if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone()) (observation_runtime_id, observation_workspace_id.clone())
&& observation_enabled && observation_enabled
@@ -2897,9 +2937,10 @@ mod tests {
use crate::Runtime as EmbeddedRuntime; use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, MaterializerKind,
RepositorySelector, WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, ProfileSelector, RepositorySelector, WorkingDirectoryAttachmentClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, WorkingDirectoryAttachmentRequest, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
}; };
use crate::execution::WorkerExecutionContext; use crate::execution::WorkerExecutionContext;
use crate::identity::WorkerId; use crate::identity::WorkerId;
@@ -2915,6 +2956,31 @@ mod tests {
use manifest::{Scope, WorkerManifest}; use manifest::{Scope, WorkerManifest};
use session_store::{LogEntry, WorkerMetadataStore}; use session_store::{LogEntry, WorkerMetadataStore};
#[test]
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
let root = tempfile::tempdir().unwrap();
let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap());
let client = WorkerWorkspaceContext::local_filesystem(Some(
WorkspaceId::new("workspace-a").unwrap(),
))
.client_handle();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let router = restored_workdir_router(
&BTreeMap::new(),
&[LogicalWorkdirAttachment {
alias: alias.clone(),
working_directory_id: "remote-workdir".to_string(),
}],
scope,
client,
)
.unwrap();
let session = router.session(&alias).unwrap();
assert_eq!(session.workdir().id().as_str(), "remote-workdir");
}
#[test] #[test]
fn profile_factory_routes_workspace_requests_by_workspace_id() { fn profile_factory_routes_workspace_requests_by_workspace_id() {
let profiles = tempfile::tempdir().unwrap(); let profiles = tempfile::tempdir().unwrap();
@@ -3990,6 +4056,7 @@ mod tests {
)), )),
context: test_execution_context(worker_ref), context: test_execution_context(worker_ref),
previous_workdir_attachments: Vec::new(), previous_workdir_attachments: Vec::new(),
logical_workdir_attachments: Vec::new(),
workdir_attachments: BTreeMap::new(), workdir_attachments: BTreeMap::new(),
config_bundle: None, config_bundle: None,
}) })
@@ -4080,6 +4147,7 @@ mod tests {
workspace_scope: None, workspace_scope: None,
context: test_execution_context(worker_ref), context: test_execution_context(worker_ref),
previous_workdir_attachments: Vec::new(), previous_workdir_attachments: Vec::new(),
logical_workdir_attachments: Vec::new(),
workdir_attachments: BTreeMap::new(), workdir_attachments: BTreeMap::new(),
config_bundle: None, config_bundle: None,
}) })
@@ -4550,47 +4618,6 @@ mod tests {
&[("unavailable".to_string(), None, false)] &[("unavailable".to_string(), None, false)]
); );
let workdir_id = detail.workdir_attachments[0]
.working_directory
.summary
.working_directory_id
.clone();
let second = runtime.create_worker(create_request("second")).unwrap();
let conflict = runtime
.replace_worker_workdir_attachments(
&second.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("conflict").unwrap(),
working_directory_id: workdir_id.clone(),
relative_cwd: None,
}],
)
.unwrap_err();
assert!(matches!(
conflict,
crate::error::RuntimeError::InvalidRequest(_)
));
let inactive = runtime
.create_working_directory(working_directory_request(repo.path()))
.unwrap();
let inactive_id = inactive.summary.working_directory_id;
runtime.cleanup_working_directory(&inactive_id).unwrap();
let inactive_error = runtime
.replace_worker_workdir_attachments(
&second.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("inactive").unwrap(),
working_directory_id: inactive_id,
relative_cwd: None,
}],
)
.unwrap_err();
assert!(matches!(
inactive_error,
crate::error::RuntimeError::InvalidRequest(_)
| crate::error::RuntimeError::WorkingDirectory(_)
));
let detached = runtime let detached = runtime
.replace_worker_workdir_attachments(&detail.worker_ref, Vec::new()) .replace_worker_workdir_attachments(&detail.worker_ref, Vec::new())
.unwrap(); .unwrap();
@@ -4598,22 +4625,6 @@ mod tests {
runtime.stop_worker(&detail.worker_ref, None).unwrap(); runtime.stop_worker(&detail.worker_ref, None).unwrap();
let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap(); let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap();
assert!(restored_without_workdir.workdir_attachments.is_empty()); assert!(restored_without_workdir.workdir_attachments.is_empty());
let alias = workdir::WorkdirAttachmentAlias::new("restored").unwrap();
runtime
.replace_worker_workdir_attachments(
&detail.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: alias.clone(),
working_directory_id: workdir_id,
relative_cwd: None,
}],
)
.unwrap();
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let stopped = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(stopped.workdir_attachments.len(), 1);
assert_eq!(stopped.workdir_attachments[0].alias, alias);
} }
#[test] #[test]
+7 -7
View File
@@ -29,8 +29,8 @@ use workdir::{
}; };
use worker_runtime::RuntimeWorkspaceScope; use worker_runtime::RuntimeWorkspaceScope;
use worker_runtime::catalog::{ use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource, ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, ProfileSelector,
RepositoryRefObservation, RepositoryRefObservationRequest, ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus, WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest,
@@ -914,7 +914,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
fn replace_worker_workdir_attachments( fn replace_worker_workdir_attachments(
&self, &self,
worker_id: &str, worker_id: &str,
_attachments: Vec<WorkingDirectoryAttachmentClaim>, _attachments: Vec<LogicalWorkdirAttachment>,
) -> WorkerWorkspaceApiResult { ) -> WorkerWorkspaceApiResult {
WorkerWorkspaceApiResult { WorkerWorkspaceApiResult {
state: WorkerOperationState::Unsupported, state: WorkerOperationState::Unsupported,
@@ -1516,7 +1516,7 @@ impl RuntimeRegistry {
pub fn replace_worker_workdir_attachments( pub fn replace_worker_workdir_attachments(
&self, &self,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
attachments: Vec<WorkingDirectoryAttachmentClaim>, attachments: Vec<LogicalWorkdirAttachment>,
) -> Result<WorkerWorkspaceApiResult, RuntimeRegistryError> { ) -> Result<WorkerWorkspaceApiResult, RuntimeRegistryError> {
let runtime_id = worker.runtime_id.as_str(); let runtime_id = worker.runtime_id.as_str();
let worker_id = worker.worker_id.as_str(); let worker_id = worker.worker_id.as_str();
@@ -2469,7 +2469,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
fn replace_worker_workdir_attachments( fn replace_worker_workdir_attachments(
&self, &self,
worker_id: &str, worker_id: &str,
attachments: Vec<WorkingDirectoryAttachmentClaim>, attachments: Vec<LogicalWorkdirAttachment>,
) -> WorkerWorkspaceApiResult { ) -> WorkerWorkspaceApiResult {
let Some(worker_ref) = self.worker_ref(worker_id) else { let Some(worker_ref) = self.worker_ref(worker_id) else {
return WorkerWorkspaceApiResult { return WorkerWorkspaceApiResult {
@@ -4453,7 +4453,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
fn replace_worker_workdir_attachments( fn replace_worker_workdir_attachments(
&self, &self,
worker_id: &str, worker_id: &str,
attachments: Vec<WorkingDirectoryAttachmentClaim>, attachments: Vec<LogicalWorkdirAttachment>,
) -> WorkerWorkspaceApiResult { ) -> WorkerWorkspaceApiResult {
let workdir_attachments = match runtime_contract_convert(attachments) { let workdir_attachments = match runtime_contract_convert(attachments) {
Ok(attachments) => attachments, Ok(attachments) => attachments,
@@ -4466,7 +4466,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
}; };
let request = runtime_api::WorkerWorkdirAttachmentsRequest { let request = runtime_api::WorkerWorkdirAttachmentsRequest {
workdir_attachments, logical_workdir_attachments: workdir_attachments,
}; };
let worker_id = worker_id.to_string(); let worker_id = worker_id.to_string();
match self match self
+104 -8
View File
@@ -192,12 +192,12 @@ use crate::workspace_signing_identity::{
}; };
use crate::{Error, Result}; use crate::{Error, Result};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
ConfigBundleRef, ProfileSelector, RepositoryMaterializationContext, RepositoryRefObservation, ConfigBundleRef, LogicalWorkdirAttachment, ProfileSelector, RepositoryMaterializationContext,
RepositoryRefObservationRequest, RepositorySelector as RuntimeRepositorySelector, RepositoryRefObservation, RepositoryRefObservationRequest,
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString, RepositorySelector as RuntimeRepositorySelector, RepositorySshCredentialCandidate,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryAttachmentClaim,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkingDirectoryAttachmentRequest, WorkingDirectoryAttachmentStatus,
WorkspaceApiRef, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
}; };
use worker_runtime::config_bundle::ConfigBundle; use worker_runtime::config_bundle::ConfigBundle;
use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES; use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES;
@@ -3064,6 +3064,7 @@ impl WorkspaceApi {
diagnostics: binding.diagnostics, diagnostics: binding.diagnostics,
}); });
} }
sync_runtime_worker_workdir_attachments(self, worker)?;
Ok(self Ok(self
.runtime .runtime
.restore_worker(worker) .restore_worker(worker)
@@ -8601,11 +8602,10 @@ fn sync_runtime_worker_workdir_attachments(
.into_iter() .into_iter()
.filter(|link| link.unlinked_at.is_none()) .filter(|link| link.unlinked_at.is_none())
.map(|link| { .map(|link| {
Ok(WorkingDirectoryAttachmentClaim { Ok(LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new(link.alias) alias: workdir::WorkdirAttachmentAlias::new(link.alias)
.map_err(|error| Error::InvalidInput(error.to_string()))?, .map_err(|error| Error::InvalidInput(error.to_string()))?,
working_directory_id: link.workdir_id, working_directory_id: link.workdir_id,
relative_cwd: None,
}) })
}) })
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
@@ -8678,6 +8678,18 @@ async fn scoped_attach_current_worker_workdir(
} }
let session_lock = current_worker_session_lock(&api, &worker); let session_lock = current_worker_session_lock(&api, &worker);
let _session_guard = session_lock.lock().await; let _session_guard = session_lock.lock().await;
if !api
.store
.worker_workdir_link_history_exists(&api.config.workspace_id, &worker)?
{
let observed_worker = api
.runtime
.worker(&worker)
.map_err(|error| error.into_error())?;
if !observed_worker.workdir_attachments.is_empty() {
sync_worker_observation(&api, &observed_worker)?;
}
}
if let Some(existing) = api if let Some(existing) = api
.store .store
.list_worker_workdir_links(&api.config.workspace_id, &worker)? .list_worker_workdir_links(&api.config.workspace_id, &worker)?
@@ -21666,6 +21678,90 @@ mod tests {
assert!(matches!(error, Error::WorkerSourceIdentity(_))); assert!(matches!(error, Error::WorkerSourceIdentity(_)));
} }
#[tokio::test]
async fn cross_runtime_attachment_sync_does_not_require_worker_runtime_materialization() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let Json(created) = create_workspace_worker(
State(api.clone()),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: "Cross-runtime Worker".to_string(),
profile: Some("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachments: Vec::new(),
control_operation_id: None,
}),
)
.await
.unwrap();
let worker = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id);
let workdir_id = "cross-runtime-workdir";
api.store
.upsert_workdir_registry(&WorkdirRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
workdir_id: workdir_id.to_string(),
display_name: Some("Cross-runtime checkout".to_string()),
runtime_id: "arcadia".to_string(),
repository_id: test_repository_id(&api),
creation_selector: Some("HEAD".to_string()),
creation_ref: None,
creation_tree: None,
current_selector: Some("HEAD".to_string()),
current_ref: None,
current_tree: None,
observed_at_epoch_seconds: None,
materialization_status: "present".to_string(),
cleanliness: "clean".to_string(),
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
api.store
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: worker.clone(),
workdir_id: workdir_id.to_string(),
alias: "checkout".to_string(),
linked_at: TEST_CREATED_AT.to_string(),
unlinked_at: None,
})
.unwrap();
sync_runtime_worker_workdir_attachments(&api, &worker).unwrap();
assert!(
api.runtime
.worker(&worker)
.unwrap()
.workdir_attachments
.is_empty(),
"logical attachment must not fabricate a Runtime-local binding"
);
api.runtime
.stop_worker(
&worker,
WorkerLifecycleRequest {
reason: Some("exercise logical attachment reconciliation".to_string()),
ticket_assignment: None,
},
)
.unwrap();
sync_runtime_worker_workdir_attachments(&api, &worker).unwrap();
assert_eq!(
api.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
.unwrap()
.into_iter()
.filter(|link| link.unlinked_at.is_none())
.count(),
1
);
}
#[tokio::test] #[tokio::test]
async fn merge_request_completion_authority_requires_current_online_orchestrator() { async fn merge_request_completion_authority_requires_current_online_orchestrator() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();