feat: integrate runtime Git cache materialization

This commit is contained in:
2026-08-27 08:30:38 +09:00
21 changed files with 5243 additions and 407 deletions
+1
View File
@@ -48,6 +48,7 @@ tracing.workspace = true
ts-rs = { version = "12.0.1", optional = true }
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
webauthn-rs = { workspace = true }
[dev-dependencies]
+48 -8
View File
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectorySummary, WorkspaceApiRef,
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)]
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
};
@@ -818,6 +819,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
fn authorize_working_directory_repository_access(
&self,
_request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
Err(Error::InvalidInput(
"Runtime does not support working directory Repository access authorization"
.to_string(),
))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
RuntimeList::new(Vec::new(), Vec::new())
}
@@ -1391,6 +1402,23 @@ impl RuntimeRegistry {
Ok(runtime.create_working_directory(request))
}
pub fn authorize_working_directory_repository_access(
&self,
runtime_id: &str,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
let runtime = self.runtime(runtime_id)?;
runtime
.authorize_working_directory_repository_access(request)
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "working_directory_repository_access_failed".to_string(),
message: error.to_string(),
})
}
pub fn list_working_directories(
&self,
runtime_id: &str,
@@ -3179,6 +3207,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
"/v1/working-directories/repository-access",
&request,
)
.map(|_| ())
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
@@ -4386,7 +4426,7 @@ mod tests {
let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none());
let response = broker
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest {
.fetch_resource(worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.to_string(),
worker_id: None,
@@ -11,7 +11,7 @@ use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey};
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
use workspace_api::{
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
@@ -59,7 +59,6 @@ impl WorkspaceConfigSchemaProvider for RepositoryAccessConfigSchemaProvider {
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct VirtualWorkspaceConfig {
#[serde(default)]
repository_access: BTreeMap<String, VirtualRepositoryAccess>,
@@ -219,6 +218,16 @@ fn project_repository_access_evaluation(
})
}
#[derive(Clone)]
pub struct LeasedRepositorySshAccess {
pub credential_id: String,
pub credential_revision: u64,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub private_key: zeroize::Zeroizing<String>,
pub known_hosts_entry: String,
}
#[derive(Clone)]
pub struct RepositorySecretService {
store: Arc<SqliteWorkspaceStore>,
@@ -829,6 +838,184 @@ impl RepositorySecretService {
})
}
pub fn lease_ssh_materialization_access(
&self,
workspace_id: &str,
binding: &RepositorySshAccessBinding,
) -> Result<LeasedRepositorySshAccess> {
let credential = self
.get_credential(workspace_id, &binding.credential_id, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH credential `{}`",
binding.credential_id
))
})?;
if credential.status != "active" {
return Err(Error::InvalidInput(format!(
"Repository SSH credential `{}` is not active",
binding.credential_id
)));
}
let host_trust = self
.get_host_trust(workspace_id, &binding.host_trust_id, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH host trust `{}`",
binding.host_trust_id
))
})?;
self.lease_ssh_materialization_access_revision(
workspace_id,
&binding.credential_id,
credential.current_revision,
&binding.host_trust_id,
host_trust.current_revision,
)
}
pub fn lease_ssh_materialization_access_revision(
&self,
workspace_id: &str,
credential_id: &str,
credential_revision: u64,
host_trust_id: &str,
host_trust_revision: u64,
) -> Result<LeasedRepositorySshAccess> {
let (private_key, passphrase, hostname, port, host_key) = self.store.with_conn(|conn| {
let private_key = read_sealed_secret(
conn,
workspace_id,
credential_id,
credential_revision,
"private_key",
)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Repository SSH credential `{credential_id}` revision {credential_revision} is unavailable"
))
})?;
let passphrase = read_sealed_secret(
conn,
workspace_id,
credential_id,
credential_revision,
"passphrase",
)?;
let (hostname, port, host_key) = conn
.query_row(
r#"SELECT h.hostname, h.port, v.host_key
FROM repository_ssh_host_trusts h
JOIN repository_ssh_host_trust_revisions v
ON v.workspace_id = h.workspace_id
AND v.host_trust_id = h.host_trust_id
WHERE h.workspace_id = ?1 AND h.host_trust_id = ?2
AND v.revision = ?3"#,
params![workspace_id, host_trust_id, host_trust_revision as i64],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? as u16,
row.get::<_, String>(2)?,
))
},
)
.optional()?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Repository SSH host trust `{host_trust_id}` revision {host_trust_revision} is unavailable"
))
})?;
Ok((private_key, passphrase, hostname, port, host_key))
})?;
let private_key = self.unseal(
workspace_id,
credential_id,
credential_revision,
"private_key",
private_key,
)?;
let passphrase = passphrase
.map(|secret| {
self.unseal(
workspace_id,
credential_id,
credential_revision,
"passphrase",
secret,
)
})
.transpose()?;
let private_key =
zeroize::Zeroizing::new(String::from_utf8(private_key).map_err(|_| {
Error::Store("Repository SSH private key plaintext is invalid".to_string())
})?);
let passphrase = passphrase
.map(|value| {
String::from_utf8(value)
.map(zeroize::Zeroizing::new)
.map_err(|_| {
Error::Store("Repository SSH passphrase plaintext is invalid".to_string())
})
})
.transpose()?;
let key = PrivateKey::from_openssh(private_key.as_str()).map_err(|_| {
Error::Store("Repository SSH private key plaintext is invalid".to_string())
})?;
let key = if key.is_encrypted() {
key.decrypt(passphrase.as_deref().ok_or_else(|| {
Error::Store("Repository SSH passphrase revision is unavailable".to_string())
})?)
.map_err(|_| Error::Store("Repository SSH private key decryption failed".to_string()))?
} else {
key
};
let private_key = key
.to_openssh(LineEnding::LF)
.map_err(|_| Error::Store("Repository SSH private key encoding failed".to_string()))?;
let host = if port == 22 {
hostname
} else {
format!("[{hostname}]:{port}")
};
Ok(LeasedRepositorySshAccess {
credential_id: credential_id.to_string(),
credential_revision,
host_trust_id: host_trust_id.to_string(),
host_trust_revision,
private_key,
known_hosts_entry: format!("{host} {host_key}\n"),
})
}
fn unseal(
&self,
workspace_id: &str,
credential_id: &str,
revision: u64,
purpose: &str,
secret: SealedSecret,
) -> Result<Vec<u8>> {
let master_key = self.master_key.as_ref().ok_or_else(|| {
Error::Store("Repository secret encryption authority is unavailable".to_string())
})?;
let unbound = UnboundKey::new(&AES_256_GCM, master_key.as_slice())
.map_err(|_| Error::Store("Repository secret encryption key is invalid".to_string()))?;
let key = LessSafeKey::new(unbound);
let mut plaintext = secret.ciphertext;
let aad = secret_aad(workspace_id, credential_id, revision, purpose);
let plaintext_len = key
.open_in_place(
Nonce::assume_unique_for_key(secret.nonce),
Aad::from(aad.as_bytes()),
&mut plaintext,
)
.map_err(|_| Error::Store("Repository secret decryption failed".to_string()))?
.len();
plaintext.truncate(plaintext_len);
Ok(plaintext)
}
fn seal(
&self,
workspace_id: &str,
@@ -968,6 +1155,45 @@ fn insert_secret(
Ok(())
}
fn read_sealed_secret(
conn: &rusqlite::Connection,
workspace_id: &str,
credential_id: &str,
revision: u64,
purpose: &str,
) -> Result<Option<SealedSecret>> {
let row = conn
.query_row(
r#"SELECT encryption_algorithm, nonce, ciphertext
FROM server_secret_versions
WHERE workspace_id = ?1 AND secret_id = ?2
AND revision = ?3 AND purpose = ?4"#,
params![workspace_id, credential_id, revision, purpose],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Vec<u8>>(1)?,
row.get::<_, Vec<u8>>(2)?,
))
},
)
.optional()?;
let Some((algorithm, nonce, ciphertext)) = row else {
return Ok(None);
};
if algorithm != "aes-256-gcm-v1" || nonce.len() != NONCE_BYTES {
return Err(Error::RegistryInconsistency(
"Repository secret envelope is invalid".to_string(),
));
}
let mut nonce_bytes = [0u8; NONCE_BYTES];
nonce_bytes.copy_from_slice(&nonce);
Ok(Some(SealedSecret {
nonce: nonce_bytes,
ciphertext,
}))
}
fn replay_credential_operation(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
@@ -1691,6 +1917,29 @@ mod tests {
projection.bindings[0].access,
RepositoryAccessMode::ReadOnly
);
let lease = service
.lease_ssh_materialization_access("workspace-a", &projection.bindings[0])
.unwrap();
assert_eq!(lease.credential_revision, 1);
assert_eq!(lease.host_trust_revision, 1);
assert!(lease.private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
assert!(
lease
.known_hosts_entry
.starts_with("example.test ssh-ed25519 ")
);
let exact = service
.lease_ssh_materialization_access_revision(
"workspace-a",
"deploy",
lease.credential_revision,
"example",
lease.host_trust_revision,
)
.unwrap();
assert_eq!(exact.credential_revision, lease.credential_revision);
assert_eq!(exact.host_trust_revision, lease.host_trust_revision);
assert_eq!(exact.known_hosts_entry, lease.known_hosts_entry);
let unknown = config_state(
r#"{
+199 -33
View File
@@ -9,7 +9,8 @@ use worker_runtime::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind,
BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy,
DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret, ResourceRedactionPolicy,
};
#[derive(Clone, Default)]
@@ -29,7 +30,34 @@ struct StoredResource {
runtime_id: Option<String>,
worker: Option<RuntimeWorkerRef>,
handle: BackendResourceHandle,
archive: ProfileSourceArchive,
bytes: Vec<u8>,
archive: Option<ProfileSourceArchive>,
one_shot: bool,
}
impl StoredResource {
fn byte_len(&self) -> usize {
self.archive
.as_ref()
.map(|archive| archive.content.len())
.unwrap_or_else(|| self.bytes.len())
}
fn take_bytes(&mut self) -> Vec<u8> {
self.archive
.as_mut()
.map(|archive| std::mem::take(&mut archive.content))
.unwrap_or_else(|| std::mem::take(&mut self.bytes))
}
}
impl Drop for StoredResource {
fn drop(&mut self) {
self.bytes.fill(0);
if let Some(archive) = self.archive.as_mut() {
archive.content.fill(0);
}
}
}
impl BackendResourceBroker {
@@ -73,7 +101,9 @@ impl BackendResourceBroker {
runtime_id,
worker,
handle: handle.clone(),
archive,
bytes: Vec::new(),
archive: Some(archive),
one_shot: false,
};
if let Ok(mut resources) = self.resources.lock() {
resources.insert(nonce, stored);
@@ -81,6 +111,84 @@ impl BackendResourceBroker {
handle
}
pub fn issue_repository_ssh_access_handle(
&self,
workspace_id: impl Into<String>,
runtime_id: &str,
resource_id: impl Into<String>,
revision: impl Into<String>,
expires_at_unix_seconds: i64,
secret: RepositorySshAccessSecret,
) -> Result<BackendResourceHandle, BackendResourceError> {
let bytes =
serde_json::to_vec(&secret).map_err(|error| BackendResourceError::InvalidResponse {
message: error.to_string(),
})?;
if bytes.len() as u64 > DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES {
return Err(BackendResourceError::Oversized {
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
actual_bytes: bytes.len() as u64,
});
}
let workspace_id = workspace_id.into();
let resource_id = resource_id.into();
let revision = revision.into();
let nonce = Uuid::now_v7().to_string();
let handle = BackendResourceHandle {
kind: BackendResourceKind::RepositorySshAccess,
workspace_id,
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some(runtime_id.to_string()),
worker_id: None,
resource_id,
digest: format!("opaque:{nonce}"),
operation: BackendResourceOperation::FetchOnce,
expires_at_unix_seconds,
nonce: nonce.clone(),
revision,
generation: None,
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: format!("repository-ssh-access-{nonce}"),
profile_source_graph: None,
};
let stored = StoredResource {
runtime_id: Some(runtime_id.to_string()),
worker: None,
handle: handle.clone(),
bytes,
archive: None,
one_shot: true,
};
let resource_key = nonce.clone();
self.resources
.lock()
.map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(),
})?
.insert(resource_key.clone(), stored);
if expires_at_unix_seconds != i64::MAX {
let resources = self.resources.clone();
std::thread::spawn(move || {
let now = Utc::now().timestamp();
if expires_at_unix_seconds > now {
std::thread::sleep(std::time::Duration::from_secs(
(expires_at_unix_seconds - now) as u64,
));
}
if let Ok(mut resources) = resources.lock()
&& resources
.get(&resource_key)
.is_some_and(|stored| stored.handle.nonce == resource_key)
{
resources.remove(&resource_key);
}
});
}
Ok(handle)
}
pub fn profile_source_archive(
&self,
digest: &str,
@@ -90,20 +198,21 @@ impl BackendResourceBroker {
.ok()?
.values()
.find(|resource| resource.handle.digest == digest)
.map(|resource| resource.archive.clone())
.and_then(|resource| resource.archive.clone())
}
pub fn fetch_profile_source_archive(
pub fn fetch_resource(
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
verify_handle_shape(&request.handle)?;
let stored = self
let mut resources = self
.resources
.lock()
.map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(),
})?
})?;
let mut stored = resources
.get(&request.handle.nonce)
.cloned()
.ok_or(BackendResourceError::MissingResource)?;
@@ -111,7 +220,7 @@ impl BackendResourceBroker {
if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() {
return Err(BackendResourceError::Expired);
}
let actual_bytes = stored.archive.content.len() as u64;
let actual_bytes = stored.byte_len() as u64;
if actual_bytes > stored.handle.max_bytes {
return Err(BackendResourceError::Oversized {
max_bytes: stored.handle.max_bytes,
@@ -139,12 +248,15 @@ impl BackendResourceBroker {
});
}
}
if stored.one_shot {
resources.remove(&request.handle.nonce);
}
Ok(BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive,
resource_id: stored.archive.reference.id,
digest: stored.archive.reference.digest,
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
bytes: stored.archive.content,
kind: stored.handle.kind.clone(),
resource_id: stored.handle.resource_id.clone(),
digest: stored.handle.digest.clone(),
content_type: stored.handle.content_type.clone(),
bytes: stored.take_bytes(),
audit_correlation_id: request.audit_correlation_id,
})
}
@@ -156,24 +268,38 @@ impl BackendResourceClient for BackendResourceBroker {
&self,
request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> {
self.fetch_profile_source_archive(request)
self.fetch_resource(request)
}
}
fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive {
return Err(BackendResourceError::UnsupportedKind);
}
if handle.operation != BackendResourceOperation::FetchArchive {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_archive".to_string(),
});
}
if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
match handle.kind {
BackendResourceKind::ProfileSourceArchive => {
if handle.operation != BackendResourceOperation::FetchArchive {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_archive".to_string(),
});
}
if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
}
}
BackendResourceKind::RepositorySshAccess => {
if handle.operation != BackendResourceOperation::FetchOnce {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_once".to_string(),
});
}
if handle.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
}
}
}
Ok(())
}
@@ -249,7 +375,7 @@ mod tests {
archive(),
);
let response = broker
.fetch_profile_source_archive(BackendResourceFetchRequest {
.fetch_resource(BackendResourceFetchRequest {
handle: handle.clone(),
runtime_id: runtime_id.to_string(),
worker_id: None,
@@ -260,6 +386,46 @@ mod tests {
assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE);
}
#[test]
fn repository_ssh_access_resource_is_runtime_bound_and_one_shot() {
let broker = BackendResourceBroker::default();
let handle = broker
.issue_repository_ssh_access_handle(
"workspace-test",
"runtime-test",
"repository-access-test",
"1",
i64::MAX,
RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
},
)
.unwrap();
let unauthorized = broker
.fetch_resource(request(handle.clone(), "runtime-other", None))
.unwrap_err();
assert!(matches!(
unauthorized,
BackendResourceError::Unauthorized { .. }
));
let response = broker
.fetch_resource(request(handle.clone(), "runtime-test", None))
.unwrap();
assert_eq!(response.kind, BackendResourceKind::RepositorySshAccess);
assert_eq!(response.content_type, REPOSITORY_SSH_ACCESS_CONTENT_TYPE);
let debug = format!("{response:?}");
assert!(!debug.contains("private-key-bytes"));
assert!(debug.contains("REDACTED"));
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
assert_eq!(secret.private_key, "private-key-bytes");
assert!(matches!(
broker.fetch_resource(request(handle, "runtime-test", None)),
Err(BackendResourceError::MissingResource)
));
}
#[test]
fn broker_rejects_runtime_mismatch() {
let broker = BackendResourceBroker::default();
@@ -270,7 +436,7 @@ mod tests {
archive(),
);
let err = broker
.fetch_profile_source_archive(request(handle, "runtime-b", None))
.fetch_resource(request(handle, "runtime-b", None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
@@ -287,7 +453,7 @@ mod tests {
archive(),
);
let err = broker
.fetch_profile_source_archive(request(handle, runtime_id, Some(&worker_b.worker_id)))
.fetch_resource(request(handle, runtime_id, Some(&worker_b.worker_id)))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
@@ -312,7 +478,7 @@ mod tests {
let mut extended = handle;
extended.expires_at_unix_seconds = 4_102_444_800;
let err = broker
.fetch_profile_source_archive(request(extended, &runtime_id, None))
.fetch_resource(request(extended, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Expired));
}
@@ -328,7 +494,7 @@ mod tests {
);
handle.scope_id = Some("tampered-scope".to_string());
let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None))
.fetch_resource(request(handle, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
}
@@ -345,7 +511,7 @@ mod tests {
);
handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024;
let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None))
.fetch_resource(request(handle, &runtime_id, None))
.unwrap_err();
assert!(matches!(err, BackendResourceError::Oversized { .. }));
}
File diff suppressed because it is too large Load Diff
+161 -32
View File
@@ -257,6 +257,11 @@ const MIGRATIONS: &[Migration] = &[
name: "create Workspace Repository SSH secret authority",
apply: create_repository_ssh_secret_authority,
},
Migration {
version: 47,
name: "bind Workdir create repository access evidence",
apply: bind_workdir_create_repository_access_evidence,
},
];
struct Migration {
@@ -590,6 +595,16 @@ pub struct WorkdirCreateOperationRecord {
pub resolved_runtime_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
pub source_kind: Option<String>,
pub source_uri: Option<String>,
pub source_revision: Option<u64>,
pub source_fingerprint: Option<String>,
pub credential_id: Option<String>,
pub credential_revision: Option<u64>,
pub host_trust_id: Option<String>,
pub host_trust_revision: Option<u64>,
pub repository_access_mode: Option<String>,
pub cache_generation: u64,
pub working_directory_id: String,
pub state: String,
pub failure: Option<String>,
@@ -605,8 +620,11 @@ pub struct WorkdirRegistryRecord {
pub repository_id: String,
pub creation_selector: Option<String>,
pub creation_ref: Option<String>,
pub creation_tree: Option<String>,
pub current_selector: Option<String>,
pub current_ref: Option<String>,
pub current_tree: Option<String>,
pub observed_at_epoch_seconds: Option<u64>,
pub materialization_status: String,
pub cleanliness: String,
pub created_at: String,
@@ -4639,16 +4657,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
conn.execute(
r#"INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id,
creation_selector, creation_ref, current_selector, current_ref,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
materialization_status, cleanliness, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
runtime_id = excluded.runtime_id,
repository_id = excluded.repository_id,
creation_selector = excluded.creation_selector,
creation_ref = excluded.creation_ref,
creation_tree = excluded.creation_tree,
current_selector = excluded.current_selector,
current_ref = excluded.current_ref,
current_tree = excluded.current_tree,
observed_at_epoch_seconds = excluded.observed_at_epoch_seconds,
materialization_status = excluded.materialization_status,
cleanliness = excluded.cleanliness,
updated_at = excluded.updated_at"#,
@@ -4659,8 +4681,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.repository_id,
record.creation_selector,
record.creation_ref,
record.creation_tree,
record.current_selector,
record.current_ref,
record.current_tree,
record.observed_at_epoch_seconds.map(|value| value as i64),
record.materialization_status,
record.cleanliness,
record.created_at,
@@ -5880,7 +5905,8 @@ fn require_expected_ticket_assignment(
fn workdir_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, workdir_id, runtime_id, repository_id, \
creation_selector, creation_ref, current_selector, current_ref, \
creation_selector, creation_ref, creation_tree, \
current_selector, current_ref, current_tree, observed_at_epoch_seconds, \
materialization_status, cleanliness, created_at, updated_at \
FROM workdir_registry {where_clause}"
)
@@ -5896,12 +5922,15 @@ fn read_workdir_registry_record(
repository_id: row.get(3)?,
creation_selector: row.get(4)?,
creation_ref: row.get(5)?,
current_selector: row.get(6)?,
current_ref: row.get(7)?,
materialization_status: row.get(8)?,
cleanliness: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
creation_tree: row.get(6)?,
current_selector: row.get(7)?,
current_ref: row.get(8)?,
current_tree: row.get(9)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(10)?.map(|value| value as u64),
materialization_status: row.get(11)?,
cleanliness: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
})
}
@@ -6839,6 +6868,28 @@ fn create_repository_ssh_secret_authority(conn: &Connection) -> Result<()> {
Ok(())
}
fn bind_workdir_create_repository_access_evidence(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
ALTER TABLE workdir_registry ADD COLUMN creation_tree TEXT;
ALTER TABLE workdir_registry ADD COLUMN current_tree TEXT;
ALTER TABLE workdir_registry ADD COLUMN observed_at_epoch_seconds INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN source_kind TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN source_uri TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN source_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN source_fingerprint TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN credential_id TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN credential_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN host_trust_id TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN host_trust_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN repository_access_mode TEXT;
ALTER TABLE workdir_create_operations
ADD COLUMN cache_generation INTEGER NOT NULL DEFAULT 0;
"#,
)?;
Ok(())
}
fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
@@ -9724,7 +9775,7 @@ mod tests {
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let remote = conn
.query_row(
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
@@ -9802,7 +9853,7 @@ mod tests {
let before = std::fs::read(&path).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 36);
assert_eq!(plan.target_schema_version, 46);
assert_eq!(plan.target_schema_version, 47);
assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
@@ -9816,7 +9867,7 @@ mod tests {
store
.with_conn(|conn| {
assert!(table_exists(conn, "worker_diagnostics_archives")?);
assert_eq!(current_schema_version(conn)?, 46);
assert_eq!(current_schema_version(conn)?, 47);
Ok(())
})
.unwrap();
@@ -9952,7 +10003,7 @@ mod tests {
),
]
);
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let foreign_key_error: Option<String> = conn
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
.optional()
@@ -10081,7 +10132,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let controller_worker_id: String = conn
.query_row(
@@ -10199,7 +10250,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
}
@@ -10217,7 +10268,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let settings = conn
.query_row(
"SELECT settings_revision, language FROM workspace_memory_settings \
@@ -10258,7 +10309,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -10325,7 +10376,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let repositories_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -10508,7 +10559,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46);
assert_eq!(store.schema_version().await.unwrap(), 47);
assert!(
!store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -10525,7 +10576,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 46);
assert_eq!(reopened.schema_version().await.unwrap(), 47);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@@ -11290,7 +11341,7 @@ INSERT INTO worker_registry (
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
migrated
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 46);
assert_eq!(current_schema_version(conn)?, 47);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
1,
@@ -11647,13 +11698,16 @@ INSERT INTO worker_registry (
DROP TABLE repository_ssh_host_trust_revisions;
DROP TABLE repository_ssh_host_trusts;
DROP TABLE workdir_create_operations;
DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46);",
ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46, 47);",
)
.unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 44);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "workdir_create_operations").unwrap());
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
@@ -11685,13 +11739,26 @@ INSERT INTO worker_registry (
DROP TABLE repository_ssh_credentials;
DROP TABLE repository_ssh_host_trust_revisions;
DROP TABLE repository_ssh_host_trusts;
DELETE FROM __yoi_schema_migrations WHERE version = 46;",
ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
ALTER TABLE workdir_create_operations DROP COLUMN source_kind;
ALTER TABLE workdir_create_operations DROP COLUMN source_uri;
ALTER TABLE workdir_create_operations DROP COLUMN source_revision;
ALTER TABLE workdir_create_operations DROP COLUMN source_fingerprint;
ALTER TABLE workdir_create_operations DROP COLUMN credential_id;
ALTER TABLE workdir_create_operations DROP COLUMN credential_revision;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_id;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_revision;
ALTER TABLE workdir_create_operations DROP COLUMN repository_access_mode;
ALTER TABLE workdir_create_operations DROP COLUMN cache_generation;
DELETE FROM __yoi_schema_migrations WHERE version IN (46, 47);",
)
.unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 45);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
for table in [
"repository_ssh_credentials",
"repository_ssh_credential_revisions",
@@ -11710,19 +11777,72 @@ INSERT INTO worker_registry (
assert!(foreign_key_error.is_none());
}
#[test]
fn schema_v47_binds_workdir_create_repository_access_evidence() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
conn.execute_batch(
"ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
ALTER TABLE workdir_create_operations DROP COLUMN source_kind;
ALTER TABLE workdir_create_operations DROP COLUMN source_uri;
ALTER TABLE workdir_create_operations DROP COLUMN source_revision;
ALTER TABLE workdir_create_operations DROP COLUMN source_fingerprint;
ALTER TABLE workdir_create_operations DROP COLUMN credential_id;
ALTER TABLE workdir_create_operations DROP COLUMN credential_revision;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_id;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_revision;
ALTER TABLE workdir_create_operations DROP COLUMN repository_access_mode;
ALTER TABLE workdir_create_operations DROP COLUMN cache_generation;
DELETE FROM __yoi_schema_migrations WHERE version = 47;",
)
.unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
"source_kind",
"source_uri",
"source_revision",
"source_fingerprint",
"credential_id",
"credential_revision",
"host_trust_id",
"host_trust_revision",
"repository_access_mode",
"cache_generation",
] {
assert!(
columns.iter().any(|column| column == required),
"missing column {required}"
);
}
let workdir_columns = table_columns(&conn, "workdir_registry").unwrap();
for required in ["creation_tree", "current_tree", "observed_at_epoch_seconds"] {
assert!(
workdir_columns.iter().any(|column| column == required),
"missing column {required}"
);
}
}
#[test]
fn server_refuses_a_database_from_a_newer_schema_generation() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (47, 'future')",
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (48, 'future')",
[],
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 47 is newer"), "{error}");
assert!(error.contains("schema version 48 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}");
}
@@ -11943,7 +12063,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
apply_migrations(&mut conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let workspace_id: Option<String> = conn
.query_row(
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
@@ -12488,6 +12608,9 @@ WHERE workspace_id = 'workspace-a'
"updated_at",
"current_selector",
"current_ref",
"creation_tree",
"current_tree",
"observed_at_epoch_seconds",
],
);
assert_columns(
@@ -12566,7 +12689,7 @@ WHERE workspace_id = 'workspace-a'
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46);
assert_eq!(store.schema_version().await.unwrap(), 47);
store
.with_conn(|conn| {
@@ -12755,7 +12878,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46);
assert_eq!(store.schema_version().await.unwrap(), 47);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@@ -12833,7 +12956,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46);
assert_eq!(store.schema_version().await.unwrap(), 47);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@@ -12979,8 +13102,11 @@ CREATE TABLE ticket_assignment_operations (
repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()),
creation_ref: Some("abcdef".to_string()),
creation_tree: Some("tree-creation".to_string()),
current_selector: None,
current_ref: Some("abcdef".to_string()),
current_tree: Some("tree-current".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materialization_status: "not_found".to_string(),
cleanliness: "clean".to_string(),
created_at: "2".to_string(),
@@ -12994,8 +13120,11 @@ CREATE TABLE ticket_assignment_operations (
repository_id: "repo".to_string(),
creation_selector: Some("feature".to_string()),
creation_ref: Some("123456".to_string()),
creation_tree: None,
current_selector: Some("feature".to_string()),
current_ref: Some("123456".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materialization_status: "present".to_string(),
cleanliness: "unknown".to_string(),
created_at: "3".to_string(),
@@ -13240,7 +13369,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46);
assert_eq!(store.schema_version().await.unwrap(), 47);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),
@@ -4,13 +4,31 @@ use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
use crate::{Error, Result, SqliteWorkspaceStore};
pub fn selector_for_retry(
explicit_selector: Option<&str>,
persisted_selector: Option<&str>,
current_default_selector: Option<&str>,
) -> Option<String> {
explicit_selector
.or(persisted_selector)
.or(current_default_selector)
.map(str::to_string)
}
pub fn request_fingerprint(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
repository_source_fingerprint: &str,
repository_source_revision: u64,
) -> String {
let mut hasher = Sha256::new();
for value in [Some(repository_id), selector, requested_runtime_id] {
for value in [
Some(repository_id),
selector,
requested_runtime_id,
Some(repository_source_fingerprint),
] {
match value {
Some(value) => {
hasher.update([1]);
@@ -20,6 +38,7 @@ pub fn request_fingerprint(
None => hasher.update([0]),
}
}
hasher.update(repository_source_revision.to_be_bytes());
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
@@ -40,9 +59,10 @@ impl SqliteWorkspaceStore {
r#"INSERT OR IGNORE INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure,
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, working_directory_id, state, failure,
created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#,
params![
record.workspace_id,
record.operation_id,
@@ -53,6 +73,10 @@ impl SqliteWorkspaceStore {
record.resolved_runtime_id,
record.config_revision as i64,
record.config_projection_digest,
record.source_kind,
record.source_uri,
record.source_revision.map(|revision| revision as i64),
record.source_fingerprint,
record.working_directory_id,
record.state,
record.failure,
@@ -79,6 +103,81 @@ impl SqliteWorkspaceStore {
})
}
pub fn bind_workdir_create_repository_access(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
credential_id: &str,
credential_revision: u64,
host_trust_id: &str,
host_trust_revision: u64,
repository_access_mode: &str,
cache_generation: u64,
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
))
})?;
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if let Some(existing) = operation.credential_id.as_deref() {
if existing != credential_id
|| operation.credential_revision != Some(credential_revision)
|| operation.host_trust_id.as_deref() != Some(host_trust_id)
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.cache_generation != cache_generation
{
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed"
)));
}
return Ok(operation);
}
conn.execute(
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9,
updated_at = ?10
WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![
workspace_id,
operation_id,
request_fingerprint,
credential_id,
i64::try_from(credential_revision).map_err(|_| Error::InvalidInput(
"credential revision is out of range".to_string()
))?,
host_trust_id,
i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput(
"host-trust revision is out of range".to_string()
))?,
repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now,
],
)?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})
})
}
pub fn finish_workdir_create_operation(
&self,
workspace_id: &str,
@@ -133,7 +232,10 @@ fn read_workdir_create_operation(
conn.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure,
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode,
cache_generation, working_directory_id, state, failure,
created_at, updated_at
FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#,
@@ -149,11 +251,21 @@ fn read_workdir_create_operation(
resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?,
working_directory_id: row.get(9)?,
state: row.get(10)?,
failure: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
source_kind: row.get(9)?,
source_uri: row.get(10)?,
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
source_fingerprint: row.get(12)?,
credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64,
working_directory_id: row.get(19)?,
state: row.get(20)?,
failure: row.get(21)?,
created_at: row.get(22)?,
updated_at: row.get(23)?,
})
},
)
@@ -166,6 +278,22 @@ mod tests {
use super::*;
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
#[test]
fn retry_selector_keeps_persisted_default_but_honors_explicit_input() {
assert_eq!(
selector_for_retry(None, Some("develop"), Some("main")),
Some("develop".to_string())
);
assert_eq!(
selector_for_retry(Some("release"), Some("develop"), Some("main")),
Some("release".to_string())
);
assert_eq!(
selector_for_retry(None, None, Some("main")),
Some("main".to_string())
);
}
#[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
@@ -201,13 +329,29 @@ mod tests {
let record = WorkdirCreateOperationRecord {
workspace_id: "workspace".to_string(),
operation_id: "call-1".to_string(),
request_fingerprint: request_fingerprint("main", Some("develop"), None),
request_fingerprint: request_fingerprint(
"main",
Some("develop"),
None,
"sha256:test",
1,
),
repository_id: "main".to_string(),
selector: Some("develop".to_string()),
requested_runtime_id: None,
resolved_runtime_id: "arcadia".to_string(),
config_revision: 7,
config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("local_path".to_string()),
source_uri: Some("/tmp/repo".to_string()),
source_revision: Some(1),
source_fingerprint: Some("sha256:source".to_string()),
credential_id: None,
credential_revision: None,
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
cache_generation: 0,
working_directory_id: "wd-1".to_string(),
state: "pending".to_string(),
failure: None,
@@ -218,23 +362,59 @@ mod tests {
store.reserve_workdir_create_operation(&record).unwrap(),
record
);
let bound = store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
3,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2);
assert!(
store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
4,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:02Z",
)
.is_err()
);
let mut changed_resolution = record.clone();
changed_resolution.resolved_runtime_id = "other".to_string();
changed_resolution.config_revision = 8;
assert_eq!(
store
.reserve_workdir_create_operation(&changed_resolution)
.unwrap(),
record
);
changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string());
changed_resolution.source_revision = Some(9);
let replayed = store
.reserve_workdir_create_operation(&changed_resolution)
.unwrap();
assert_eq!(replayed, bound);
assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
.unwrap(),
Some(record.clone())
Some(bound.clone())
);
let mut changed_input = record.clone();
changed_input.request_fingerprint = request_fingerprint("main", Some("main"), None);
changed_input.request_fingerprint =
request_fingerprint("main", Some("main"), None, "sha256:test", 1);
assert!(
store
.reserve_workdir_create_operation(&changed_input)