fix: persist Workdir credential candidate snapshots
This commit is contained in:
@@ -628,6 +628,42 @@ CREATE TABLE workdir_create_operations (
|
|||||||
PRIMARY KEY (workspace_id, operation_id),
|
PRIMARY KEY (workspace_id, operation_id),
|
||||||
UNIQUE (workspace_id, working_directory_id)
|
UNIQUE (workspace_id, working_directory_id)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workdir_create_credential_candidates (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
|
||||||
|
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
|
||||||
|
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
UNIQUE (workspace_id, operation_id, role),
|
||||||
|
UNIQUE (workspace_id, operation_id, credential_id),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id)
|
||||||
|
REFERENCES workdir_create_operations(workspace_id, operation_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workdir_create_credential_candidates_revision
|
||||||
|
ON workdir_create_credential_candidates(
|
||||||
|
workspace_id, credential_id, credential_revision
|
||||||
|
);
|
||||||
|
CREATE TABLE workdir_create_credential_revision_retentions (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
credential_revision INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id, ordinal)
|
||||||
|
REFERENCES workdir_create_credential_candidates(
|
||||||
|
workspace_id, operation_id, ordinal
|
||||||
|
)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, credential_id, credential_revision)
|
||||||
|
REFERENCES repository_ssh_credential_revisions(
|
||||||
|
workspace_id, credential_id, revision
|
||||||
|
)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
CREATE TABLE "workdir_registry" (
|
CREATE TABLE "workdir_registry" (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
workdir_id TEXT NOT NULL,
|
workdir_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -749,6 +749,20 @@ impl RepositorySecretService {
|
|||||||
"credential `{credential_id}` revision changed"
|
"credential `{credential_id}` revision changed"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let retained_by_workdir_create: bool = tx.query_row(
|
||||||
|
r#"SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM workdir_create_credential_revision_retentions
|
||||||
|
WHERE workspace_id = ?1 AND credential_id = ?2
|
||||||
|
)"#,
|
||||||
|
params![workspace_id, credential_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if retained_by_workdir_create {
|
||||||
|
return Err(Error::RepositoryConflict(format!(
|
||||||
|
"credential `{credential_id}` is retained by a retryable Workdir create operation"
|
||||||
|
)));
|
||||||
|
}
|
||||||
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
|
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
|
||||||
let deleted = tx.execute(
|
let deleted = tx.execute(
|
||||||
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
|
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
|
||||||
@@ -2405,6 +2419,114 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retryable_workdir_create_retains_candidate_revision_until_success() {
|
||||||
|
let (_dir, store, service) = test_service();
|
||||||
|
let (private_key, _) = test_private_key(13);
|
||||||
|
service
|
||||||
|
.create_credential(
|
||||||
|
"workspace-a",
|
||||||
|
CreateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "create-retained".to_string(),
|
||||||
|
credential_id: "retained-deploy".to_string(),
|
||||||
|
name: "Retained deploy".to_string(),
|
||||||
|
private_key,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let operation = crate::store::WorkdirCreateOperationRecord {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
operation_id: "create-workdir-retained".to_string(),
|
||||||
|
request_fingerprint: "sha256:request".to_string(),
|
||||||
|
repository_id: "repo-a".to_string(),
|
||||||
|
selector: Some("develop".to_string()),
|
||||||
|
requested_runtime_id: Some("runtime-a".to_string()),
|
||||||
|
resolved_runtime_id: "runtime-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
source_kind: Some("ssh".to_string()),
|
||||||
|
source_uri: Some("ssh://git@example.test/org/main.git".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,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
|
working_directory_id: "workdir-retained".to_string(),
|
||||||
|
state: "pending".to_string(),
|
||||||
|
failure: None,
|
||||||
|
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
store.reserve_workdir_create_operation(&operation).unwrap();
|
||||||
|
let candidates = vec![crate::store::WorkdirCreateCredentialCandidate {
|
||||||
|
role: crate::store::WorkdirCreateCredentialCandidateRole::Primary,
|
||||||
|
credential_id: "retained-deploy".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
}];
|
||||||
|
store
|
||||||
|
.bind_workdir_create_repository_access(
|
||||||
|
"workspace-a",
|
||||||
|
"create-workdir-retained",
|
||||||
|
"sha256:request",
|
||||||
|
"retained-deploy",
|
||||||
|
1,
|
||||||
|
"host-a",
|
||||||
|
1,
|
||||||
|
"read_only",
|
||||||
|
&candidates,
|
||||||
|
"2026-08-24T00:00:01Z",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let projection = RepositoryAccessProjection {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
projection_digest: "sha256:empty".to_string(),
|
||||||
|
bindings: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let retained = service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
"retained-deploy",
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-retained".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&projection,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(retained, Error::RepositoryConflict(_)));
|
||||||
|
|
||||||
|
store
|
||||||
|
.finish_workdir_create_operation(
|
||||||
|
"workspace-a",
|
||||||
|
"create-workdir-retained",
|
||||||
|
"sha256:request",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
"2026-08-24T00:00:02Z",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
"retained-deploy",
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-released".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&projection,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn referenced_resources_cannot_be_deleted() {
|
fn referenced_resources_cannot_be_deleted() {
|
||||||
let (_dir, _store, service) = test_service();
|
let (_dir, _store, service) = test_service();
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ use crate::store::{
|
|||||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||||
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
|
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome,
|
||||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate,
|
||||||
|
WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||||
WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode,
|
WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode,
|
||||||
WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation,
|
WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation,
|
||||||
@@ -10917,6 +10918,7 @@ async fn create_workspace_working_directory(
|
|||||||
host_trust_id: None,
|
host_trust_id: None,
|
||||||
host_trust_revision: None,
|
host_trust_revision: None,
|
||||||
repository_access_mode: None,
|
repository_access_mode: None,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
working_directory_id: next_backend_workdir_id(&request.repository_key),
|
working_directory_id: next_backend_workdir_id(&request.repository_key),
|
||||||
state: "pending".to_string(),
|
state: "pending".to_string(),
|
||||||
failure: None,
|
failure: None,
|
||||||
@@ -17570,10 +17572,10 @@ fn authorize_repository_materialization_operation(
|
|||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let context = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
|
let context = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
|
||||||
if let (
|
if let (
|
||||||
Some(credential_id),
|
Some(_credential_id),
|
||||||
Some(credential_revision),
|
Some(_credential_revision),
|
||||||
Some(host_trust_id),
|
Some(_host_trust_id),
|
||||||
Some(host_trust_revision),
|
Some(_host_trust_revision),
|
||||||
Some(access_mode),
|
Some(access_mode),
|
||||||
) = (
|
) = (
|
||||||
operation.credential_id.as_deref(),
|
operation.credential_id.as_deref(),
|
||||||
@@ -17582,16 +17584,7 @@ fn authorize_repository_materialization_operation(
|
|||||||
operation.host_trust_revision,
|
operation.host_trust_revision,
|
||||||
operation.repository_access_mode.as_deref(),
|
operation.repository_access_mode.as_deref(),
|
||||||
) {
|
) {
|
||||||
let lease = api
|
let leases = repository_ssh_lease_candidates_from_operation(api, operation, request)?;
|
||||||
.repository_secrets
|
|
||||||
.lease_ssh_materialization_access_revision(
|
|
||||||
&api.config.workspace_id,
|
|
||||||
credential_id,
|
|
||||||
credential_revision,
|
|
||||||
host_trust_id,
|
|
||||||
host_trust_revision,
|
|
||||||
)?;
|
|
||||||
let leases = repository_ssh_lease_candidates(api, lease)?;
|
|
||||||
let primary_lease = leases.first().ok_or_else(|| {
|
let primary_lease = leases.first().ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"working_directory_repository_access_invalid",
|
"working_directory_repository_access_invalid",
|
||||||
@@ -17712,6 +17705,20 @@ fn authorize_repository_materialization_operation(
|
|||||||
"Repository SSH access has no credential candidates",
|
"Repository SSH access has no credential candidates",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let credential_candidates = ssh
|
||||||
|
.credential_candidates
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, candidate)| WorkdirCreateCredentialCandidate {
|
||||||
|
role: if index == 0 {
|
||||||
|
WorkdirCreateCredentialCandidateRole::Primary
|
||||||
|
} else {
|
||||||
|
WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
|
||||||
|
},
|
||||||
|
credential_id: candidate.credential_id.clone(),
|
||||||
|
credential_revision: candidate.credential_revision,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
api.config_store.bind_workdir_create_repository_access(
|
api.config_store.bind_workdir_create_repository_access(
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&operation.operation_id,
|
&operation.operation_id,
|
||||||
@@ -17724,6 +17731,7 @@ fn authorize_repository_materialization_operation(
|
|||||||
workspace_api::RepositoryAccessMode::ReadOnly => "read_only",
|
workspace_api::RepositoryAccessMode::ReadOnly => "read_only",
|
||||||
workspace_api::RepositoryAccessMode::ReadWrite => "read_write",
|
workspace_api::RepositoryAccessMode::ReadWrite => "read_write",
|
||||||
},
|
},
|
||||||
|
&credential_candidates,
|
||||||
&now_registry_timestamp(),
|
&now_registry_timestamp(),
|
||||||
)?;
|
)?;
|
||||||
context
|
context
|
||||||
@@ -17760,6 +17768,112 @@ fn authorize_worker_spawn_workdir_materialization(
|
|||||||
authorize_repository_materialization(api, runtime_id, operation_id, &projection, request)
|
authorize_repository_materialization(api, runtime_id, operation_id, &projection, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_ssh_lease_candidates_from_operation(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
operation: &WorkdirCreateOperationRecord,
|
||||||
|
request: &WorkingDirectoryRequest,
|
||||||
|
) -> ApiResult<Vec<crate::repository_access::LeasedRepositorySshAccess>> {
|
||||||
|
if operation.repository_id != request.repository.id
|
||||||
|
|| operation.source_kind.as_deref() != Some(request.repository.source.kind.as_str())
|
||||||
|
|| operation.source_uri.as_deref() != Some(request.repository.source.uri.as_str())
|
||||||
|
|| operation.source_revision != Some(request.repository.source_revision)
|
||||||
|
|| operation.source_fingerprint.as_deref()
|
||||||
|
!= Some(request.repository.source_fingerprint.as_str())
|
||||||
|
{
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_mismatch",
|
||||||
|
"persisted Workdir Repository access snapshot does not match the create request",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let host_trust_id = operation.host_trust_id.as_deref().ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir Repository access snapshot is incomplete",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let host_trust_revision = operation.host_trust_revision.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir Repository access snapshot is incomplete",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if operation.credential_candidates.is_empty() {
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir credential candidate snapshot is unavailable",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let primary_credential_id = operation.credential_id.as_deref().ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir primary credential evidence is unavailable",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let primary_credential_revision = operation.credential_revision.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir primary credential evidence is unavailable",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
crate::workdir_create_operations::validate_workdir_create_credential_candidates(
|
||||||
|
primary_credential_id,
|
||||||
|
primary_credential_revision,
|
||||||
|
&operation.credential_candidates,
|
||||||
|
)
|
||||||
|
.map_err(|_error| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_invalid",
|
||||||
|
"persisted Workdir credential candidate snapshot is invalid",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _access_mode = operation
|
||||||
|
.repository_access_mode
|
||||||
|
.as_deref()
|
||||||
|
.map(|access_mode| match access_mode {
|
||||||
|
"read_only" => Ok(workspace_api::RepositoryAccessMode::ReadOnly),
|
||||||
|
"read_write" => Ok(workspace_api::RepositoryAccessMode::ReadWrite),
|
||||||
|
_other => Err(settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_invalid",
|
||||||
|
"persisted Repository access mode is invalid",
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_missing",
|
||||||
|
"persisted Workdir Repository access mode is unavailable",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut leases = Vec::with_capacity(operation.credential_candidates.len());
|
||||||
|
for candidate in &operation.credential_candidates {
|
||||||
|
let lease = api
|
||||||
|
.repository_secrets
|
||||||
|
.lease_ssh_materialization_access_revision(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
&candidate.credential_id,
|
||||||
|
candidate.credential_revision,
|
||||||
|
host_trust_id,
|
||||||
|
host_trust_revision,
|
||||||
|
)
|
||||||
|
.map_err(|_| {
|
||||||
|
settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_unavailable",
|
||||||
|
"persisted Workdir credential or host-trust revision is unavailable",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if lease.host_trust_id != host_trust_id || lease.host_trust_revision != host_trust_revision
|
||||||
|
{
|
||||||
|
return Err(settings_bad_request(
|
||||||
|
"working_directory_repository_access_snapshot_mismatch",
|
||||||
|
"persisted Workdir host-trust snapshot does not match the credential candidate",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
leases.push(lease);
|
||||||
|
}
|
||||||
|
Ok(leases)
|
||||||
|
}
|
||||||
|
|
||||||
fn authorize_repository_materialization(
|
fn authorize_repository_materialization(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
@@ -19619,6 +19733,89 @@ mod tests {
|
|||||||
candidates[1].known_hosts_entry
|
candidates[1].known_hosts_entry
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let source = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||||
|
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||||
|
};
|
||||||
|
let source_fingerprint = repository_source_fingerprint(&source);
|
||||||
|
let request = WorkingDirectoryRequest {
|
||||||
|
repository: worker_runtime::catalog::WorkingDirectoryRepository {
|
||||||
|
id: "repository-a".to_string(),
|
||||||
|
provider: "git".to_string(),
|
||||||
|
source: source.clone(),
|
||||||
|
source_revision: 1,
|
||||||
|
source_fingerprint: source_fingerprint.clone(),
|
||||||
|
selector: None,
|
||||||
|
},
|
||||||
|
materializer: Default::default(),
|
||||||
|
backend_workdir_id: Some("workdir-a".to_string()),
|
||||||
|
materialization: None,
|
||||||
|
};
|
||||||
|
let operation = WorkdirCreateOperationRecord {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
operation_id: "retry-workdir-a".to_string(),
|
||||||
|
request_fingerprint: "sha256:request".to_string(),
|
||||||
|
repository_id: "repository-a".to_string(),
|
||||||
|
selector: None,
|
||||||
|
requested_runtime_id: Some("runtime-1".to_string()),
|
||||||
|
resolved_runtime_id: "runtime-1".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
source_kind: Some(source.kind.as_str().to_string()),
|
||||||
|
source_uri: Some(source.uri.clone()),
|
||||||
|
source_revision: Some(1),
|
||||||
|
source_fingerprint: Some(source_fingerprint),
|
||||||
|
credential_id: Some(candidates[0].credential_id.clone()),
|
||||||
|
credential_revision: Some(candidates[0].credential_revision),
|
||||||
|
host_trust_id: Some(candidates[0].host_trust_id.clone()),
|
||||||
|
host_trust_revision: Some(candidates[0].host_trust_revision),
|
||||||
|
repository_access_mode: Some("read_only".to_string()),
|
||||||
|
credential_candidates: candidates
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, candidate)| WorkdirCreateCredentialCandidate {
|
||||||
|
role: if index == 0 {
|
||||||
|
WorkdirCreateCredentialCandidateRole::Primary
|
||||||
|
} else {
|
||||||
|
WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
|
||||||
|
},
|
||||||
|
credential_id: candidate.credential_id.clone(),
|
||||||
|
credential_revision: candidate.credential_revision,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
working_directory_id: "workdir-a".to_string(),
|
||||||
|
state: "failed".to_string(),
|
||||||
|
failure: Some("runtime unavailable".to_string()),
|
||||||
|
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-08-24T00:00:01Z".to_string(),
|
||||||
|
};
|
||||||
|
let retry_candidates =
|
||||||
|
repository_ssh_lease_candidates_from_operation(&api, &operation, &request).unwrap();
|
||||||
|
assert_eq!(retry_candidates.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
retry_candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| (
|
||||||
|
candidate.credential_id.as_str(),
|
||||||
|
candidate.credential_revision
|
||||||
|
))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
operation
|
||||||
|
.credential_candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| (
|
||||||
|
candidate.credential_id.as_str(),
|
||||||
|
candidate.credential_revision
|
||||||
|
))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
let mut missing_snapshot = operation.clone();
|
||||||
|
missing_snapshot.credential_candidates.clear();
|
||||||
|
assert!(
|
||||||
|
repository_ssh_lease_candidates_from_operation(&api, &missing_snapshot, &request)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
let default_binding = workspace_api::RepositorySshAccessBinding {
|
let default_binding = workspace_api::RepositorySshAccessBinding {
|
||||||
credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -28175,6 +28372,7 @@ mod tests {
|
|||||||
host_trust_id: None,
|
host_trust_id: None,
|
||||||
host_trust_revision: None,
|
host_trust_revision: None,
|
||||||
repository_access_mode: None,
|
repository_access_mode: None,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
working_directory_id: "workdir-provider-rejection".to_string(),
|
working_directory_id: "workdir-provider-rejection".to_string(),
|
||||||
state: "pending".to_string(),
|
state: "pending".to_string(),
|
||||||
failure: None,
|
failure: None,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 58;
|
const LATEST_SCHEMA_VERSION: i64 = 59;
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||||
@@ -32,6 +32,8 @@ const LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME: &str =
|
|||||||
"convert legacy Server-issued Runtime bindings to Workspace identity";
|
"convert legacy Server-issued Runtime bindings to Workspace identity";
|
||||||
const REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME: &str =
|
const REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME: &str =
|
||||||
"remove obsolete Workdir Repository cache generation";
|
"remove obsolete Workdir Repository cache generation";
|
||||||
|
const WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME: &str =
|
||||||
|
"Workdir create credential candidate snapshots";
|
||||||
|
|
||||||
const MIGRATIONS: &[Migration] = &[
|
const MIGRATIONS: &[Migration] = &[
|
||||||
Migration {
|
Migration {
|
||||||
@@ -74,6 +76,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME,
|
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME,
|
||||||
apply: migrate_workdir_cache_generation_v57_to_v58,
|
apply: migrate_workdir_cache_generation_v57_to_v58,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 59,
|
||||||
|
name: WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME,
|
||||||
|
apply: migrate_workdir_credential_candidate_snapshots_v58_to_v59,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -553,6 +560,39 @@ pub struct TicketWorkerAssignmentUpdate {
|
|||||||
pub previous: Option<TicketCoderAssignmentRecord>,
|
pub previous: Option<TicketCoderAssignmentRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkdirCreateCredentialCandidateRole {
|
||||||
|
Primary,
|
||||||
|
WorkspaceDefaultFallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkdirCreateCredentialCandidateRole {
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Primary => "primary",
|
||||||
|
Self::WorkspaceDefaultFallback => "workspace_default_fallback",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(value: &str) -> Result<Self> {
|
||||||
|
match value {
|
||||||
|
"primary" => Ok(Self::Primary),
|
||||||
|
"workspace_default_fallback" => Ok(Self::WorkspaceDefaultFallback),
|
||||||
|
other => Err(Error::Store(format!(
|
||||||
|
"invalid Workdir create credential candidate role `{other}`"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WorkdirCreateCredentialCandidate {
|
||||||
|
pub role: WorkdirCreateCredentialCandidateRole,
|
||||||
|
pub credential_id: String,
|
||||||
|
pub credential_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkdirCreateOperationRecord {
|
pub struct WorkdirCreateOperationRecord {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -573,6 +613,8 @@ pub struct WorkdirCreateOperationRecord {
|
|||||||
pub host_trust_id: Option<String>,
|
pub host_trust_id: Option<String>,
|
||||||
pub host_trust_revision: Option<u64>,
|
pub host_trust_revision: Option<u64>,
|
||||||
pub repository_access_mode: Option<String>,
|
pub repository_access_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub credential_candidates: Vec<WorkdirCreateCredentialCandidate>,
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub failure: Option<String>,
|
pub failure: Option<String>,
|
||||||
@@ -9504,6 +9546,56 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) -> Result<()> {
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
|
tx.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE workdir_create_credential_candidates (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
|
||||||
|
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
|
||||||
|
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
UNIQUE (workspace_id, operation_id, role),
|
||||||
|
UNIQUE (workspace_id, operation_id, credential_id),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id)
|
||||||
|
REFERENCES workdir_create_operations(workspace_id, operation_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workdir_create_credential_candidates_revision
|
||||||
|
ON workdir_create_credential_candidates(
|
||||||
|
workspace_id, credential_id, credential_revision
|
||||||
|
);
|
||||||
|
CREATE TABLE workdir_create_credential_revision_retentions (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
credential_revision INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id, ordinal)
|
||||||
|
REFERENCES workdir_create_credential_candidates(
|
||||||
|
workspace_id, operation_id, ordinal
|
||||||
|
)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, credential_id, credential_revision)
|
||||||
|
REFERENCES repository_ssh_credential_revisions(
|
||||||
|
workspace_id, credential_id, revision
|
||||||
|
)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
|
params![59_i64, WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -9572,6 +9664,8 @@ mod tests {
|
|||||||
create_latest_workspace_schema(&conn).unwrap();
|
create_latest_workspace_schema(&conn).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
|
DROP TABLE workdir_create_credential_revision_retentions;
|
||||||
|
DROP TABLE workdir_create_credential_candidates;
|
||||||
DROP INDEX workspace_signing_identity_audit_workspace_idx;
|
DROP INDEX workspace_signing_identity_audit_workspace_idx;
|
||||||
DROP TABLE workspace_signing_identity_audit;
|
DROP TABLE workspace_signing_identity_audit;
|
||||||
DROP TABLE workspace_signing_identity_provisioning_operations;
|
DROP TABLE workspace_signing_identity_provisioning_operations;
|
||||||
@@ -9713,6 +9807,10 @@ mod tests {
|
|||||||
version: 58,
|
version: 58,
|
||||||
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
|
name: REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 59,
|
||||||
|
name: WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME.to_string(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -9751,6 +9849,10 @@ mod tests {
|
|||||||
58,
|
58,
|
||||||
REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
|
REMOVE_WORKDIR_CACHE_GENERATION_MIGRATION_NAME.to_string(),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
59,
|
||||||
|
WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME.to_string(),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -9821,7 +9923,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|migration| migration.version)
|
.map(|migration| migration.version)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![52, 53, 54, 55, 56, 57, 58]
|
vec![52, 53, 54, 55, 56, 57, 58, 59]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
let conn = Connection::open(&path).unwrap();
|
let conn = Connection::open(&path).unwrap();
|
||||||
@@ -9829,7 +9931,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 9);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -10617,6 +10719,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v59_adds_workdir_create_credential_candidate_snapshots() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("server.db");
|
||||||
|
prepare_schema_v50(&path, Some("workspace-a"));
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
configure_sqlite(&conn).unwrap();
|
||||||
|
for migration in MIGRATIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|migration| migration.version <= 58)
|
||||||
|
{
|
||||||
|
(migration.apply)(&conn).unwrap();
|
||||||
|
}
|
||||||
|
migrate_workdir_credential_candidate_snapshots_v58_to_v59(&conn).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 59);
|
||||||
|
assert!(table_exists(&conn, "workdir_create_credential_candidates").unwrap());
|
||||||
|
assert!(table_exists(&conn, "workdir_create_credential_revision_retentions").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
|
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::store::WorkdirCreateOperationRecord;
|
use crate::store::{
|
||||||
|
WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole,
|
||||||
|
WorkdirCreateOperationRecord,
|
||||||
|
};
|
||||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||||
|
|
||||||
|
const MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES: usize = 2;
|
||||||
|
const MAX_CREDENTIAL_ID_BYTES: usize = 128;
|
||||||
|
|
||||||
pub fn selector_for_retry(
|
pub fn selector_for_retry(
|
||||||
explicit_selector: Option<&str>,
|
explicit_selector: Option<&str>,
|
||||||
persisted_selector: Option<&str>,
|
persisted_selector: Option<&str>,
|
||||||
@@ -172,10 +178,17 @@ impl SqliteWorkspaceStore {
|
|||||||
host_trust_id: &str,
|
host_trust_id: &str,
|
||||||
host_trust_revision: u64,
|
host_trust_revision: u64,
|
||||||
repository_access_mode: &str,
|
repository_access_mode: &str,
|
||||||
|
credential_candidates: &[WorkdirCreateCredentialCandidate],
|
||||||
now: &str,
|
now: &str,
|
||||||
) -> Result<WorkdirCreateOperationRecord> {
|
) -> Result<WorkdirCreateOperationRecord> {
|
||||||
|
validate_workdir_create_credential_candidates(
|
||||||
|
credential_id,
|
||||||
|
credential_revision,
|
||||||
|
credential_candidates,
|
||||||
|
)?;
|
||||||
self.with_conn_mut(|conn| {
|
self.with_conn_mut(|conn| {
|
||||||
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)?
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::RegistryInconsistency(format!(
|
Error::RegistryInconsistency(format!(
|
||||||
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
|
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
|
||||||
@@ -193,6 +206,7 @@ impl SqliteWorkspaceStore {
|
|||||||
|| operation.host_trust_revision != Some(host_trust_revision)
|
|| operation.host_trust_revision != Some(host_trust_revision)
|
||||||
|| operation.repository_access_mode.as_deref()
|
|| operation.repository_access_mode.as_deref()
|
||||||
!= Some(repository_access_mode)
|
!= Some(repository_access_mode)
|
||||||
|
|| operation.credential_candidates != credential_candidates
|
||||||
{
|
{
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Workdir create operation `{operation_id}` Repository access evidence changed"
|
"Workdir create operation `{operation_id}` Repository access evidence changed"
|
||||||
@@ -200,7 +214,7 @@ impl SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
return Ok(operation);
|
return Ok(operation);
|
||||||
}
|
}
|
||||||
conn.execute(
|
let updated = tx.execute(
|
||||||
r#"UPDATE workdir_create_operations
|
r#"UPDATE workdir_create_operations
|
||||||
SET credential_id = ?4, credential_revision = ?5,
|
SET credential_id = ?4, credential_revision = ?5,
|
||||||
host_trust_id = ?6, host_trust_revision = ?7,
|
host_trust_id = ?6, host_trust_revision = ?7,
|
||||||
@@ -223,11 +237,60 @@ impl SqliteWorkspaceStore {
|
|||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
|
if updated != 1 {
|
||||||
|
return Err(Error::RegistryInconsistency(format!(
|
||||||
|
"Workdir create operation `{operation_id}` changed before Repository access binding"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (ordinal, candidate) in credential_candidates.iter().enumerate() {
|
||||||
|
tx.execute(
|
||||||
|
r#"INSERT INTO workdir_create_credential_candidates (
|
||||||
|
workspace_id, operation_id, ordinal, role,
|
||||||
|
credential_id, credential_revision
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
operation_id,
|
||||||
|
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
|
||||||
|
"credential candidate ordinal is out of range".to_string()
|
||||||
|
))?,
|
||||||
|
candidate.role.as_str(),
|
||||||
|
candidate.credential_id,
|
||||||
|
i64::try_from(candidate.credential_revision).map_err(|_| {
|
||||||
|
Error::InvalidInput(
|
||||||
|
"credential candidate revision is out of range".to_string(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
r#"INSERT INTO workdir_create_credential_revision_retentions (
|
||||||
|
workspace_id, operation_id, ordinal,
|
||||||
|
credential_id, credential_revision
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
operation_id,
|
||||||
|
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
|
||||||
|
"credential candidate ordinal is out of range".to_string()
|
||||||
|
))?,
|
||||||
|
candidate.credential_id,
|
||||||
|
i64::try_from(candidate.credential_revision).map_err(|_| {
|
||||||
|
Error::InvalidInput(
|
||||||
|
"credential candidate revision is out of range".to_string(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
Error::RegistryInconsistency(format!(
|
Error::RegistryInconsistency(format!(
|
||||||
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
|
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
|
||||||
))
|
))
|
||||||
})
|
})?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(bound)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +304,8 @@ impl SqliteWorkspaceStore {
|
|||||||
updated_at: &str,
|
updated_at: &str,
|
||||||
) -> Result<WorkdirCreateOperationRecord> {
|
) -> Result<WorkdirCreateOperationRecord> {
|
||||||
self.with_conn_mut(|conn| {
|
self.with_conn_mut(|conn| {
|
||||||
let changed = conn.execute(
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let changed = tx.execute(
|
||||||
r#"UPDATE workdir_create_operations
|
r#"UPDATE workdir_create_operations
|
||||||
SET state = ?1, failure = ?2, updated_at = ?3
|
SET state = ?1, failure = ?2, updated_at = ?3
|
||||||
WHERE workspace_id = ?4 AND operation_id = ?5
|
WHERE workspace_id = ?4 AND operation_id = ?5
|
||||||
@@ -260,11 +324,21 @@ impl SqliteWorkspaceStore {
|
|||||||
"Workdir create operation `{operation_id}` could not be finalized"
|
"Workdir create operation `{operation_id}` could not be finalized"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
|
if succeeded {
|
||||||
|
tx.execute(
|
||||||
|
r#"DELETE FROM workdir_create_credential_revision_retentions
|
||||||
|
WHERE workspace_id = ?1 AND operation_id = ?2"#,
|
||||||
|
params![workspace_id, operation_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let finished = read_workdir_create_operation(&tx, workspace_id, operation_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
Error::RegistryInconsistency(format!(
|
Error::RegistryInconsistency(format!(
|
||||||
"Workdir create operation `{operation_id}` disappeared"
|
"Workdir create operation `{operation_id}` disappeared"
|
||||||
))
|
))
|
||||||
})
|
})?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(finished)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +356,8 @@ fn read_workdir_create_operation(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
operation_id: &str,
|
operation_id: &str,
|
||||||
) -> Result<Option<WorkdirCreateOperationRecord>> {
|
) -> Result<Option<WorkdirCreateOperationRecord>> {
|
||||||
conn.query_row(
|
let mut operation = conn
|
||||||
|
.query_row(
|
||||||
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
|
||||||
requested_runtime_id, resolved_runtime_id, config_revision,
|
requested_runtime_id, resolved_runtime_id, config_revision,
|
||||||
config_projection_digest, source_kind, source_uri, source_revision,
|
config_projection_digest, source_kind, source_uri, source_revision,
|
||||||
@@ -313,6 +388,7 @@ fn read_workdir_create_operation(
|
|||||||
host_trust_id: row.get(15)?,
|
host_trust_id: row.get(15)?,
|
||||||
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
|
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
|
||||||
repository_access_mode: row.get(17)?,
|
repository_access_mode: row.get(17)?,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
working_directory_id: row.get(18)?,
|
working_directory_id: row.get(18)?,
|
||||||
state: row.get(19)?,
|
state: row.get(19)?,
|
||||||
failure: row.get(20)?,
|
failure: row.get(20)?,
|
||||||
@@ -321,8 +397,116 @@ fn read_workdir_create_operation(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()?;
|
||||||
.map_err(Error::from)
|
|
||||||
|
if let Some(operation) = operation.as_mut() {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
r#"SELECT ordinal, role, credential_id, credential_revision
|
||||||
|
FROM workdir_create_credential_candidates
|
||||||
|
WHERE workspace_id = ?1 AND operation_id = ?2
|
||||||
|
ORDER BY ordinal ASC"#,
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(params![workspace_id, operation_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, i64>(3)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for (expected_ordinal, row) in rows.enumerate() {
|
||||||
|
let (ordinal, role, credential_id, credential_revision) = row?;
|
||||||
|
if ordinal
|
||||||
|
!= i64::try_from(expected_ordinal).map_err(|_| {
|
||||||
|
Error::Store(
|
||||||
|
"Workdir create credential candidate ordinal is out of range".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
{
|
||||||
|
return Err(Error::Store(
|
||||||
|
"Workdir create credential candidate ordinals are not contiguous".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
operation
|
||||||
|
.credential_candidates
|
||||||
|
.push(WorkdirCreateCredentialCandidate {
|
||||||
|
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
|
||||||
|
credential_id,
|
||||||
|
credential_revision: u64::try_from(credential_revision).map_err(|_| {
|
||||||
|
Error::Store(format!(
|
||||||
|
"invalid Workdir create credential candidate revision `{credential_revision}`"
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !operation.credential_candidates.is_empty() {
|
||||||
|
validate_workdir_create_credential_candidates(
|
||||||
|
operation.credential_id.as_deref().unwrap_or_default(),
|
||||||
|
operation.credential_revision.unwrap_or_default(),
|
||||||
|
&operation.credential_candidates,
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::Store(format!(
|
||||||
|
"invalid persisted Workdir create credential snapshot: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_workdir_create_credential_candidates(
|
||||||
|
credential_id: &str,
|
||||||
|
credential_revision: u64,
|
||||||
|
candidates: &[WorkdirCreateCredentialCandidate],
|
||||||
|
) -> Result<()> {
|
||||||
|
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"Workdir create credential candidates must contain 1..={MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES} entries"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if credential_id.is_empty() || credential_id.len() > MAX_CREDENTIAL_ID_BYTES {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create credential id is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if credential_revision == 0 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create credential revision must be greater than zero".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let primary = &candidates[0];
|
||||||
|
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|
||||||
|
|| primary.credential_id != credential_id
|
||||||
|
|| primary.credential_revision != credential_revision
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if candidates.len() == 2
|
||||||
|
&& candidates[1].role != WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create fallback credential role is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if candidates.iter().any(|candidate| {
|
||||||
|
candidate.credential_id.is_empty()
|
||||||
|
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|
||||||
|
|| candidate.credential_revision == 0
|
||||||
|
}) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create credential candidate identity is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if candidates.len() == 2 && candidates[0].credential_id == candidates[1].credential_id {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"Workdir create credential candidates contain a duplicate credential id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -347,7 +531,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
|
fn retry_keeps_resolved_config_and_credential_candidates_after_fallback_moves() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
|
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
|
||||||
workspace_id: "workspace".to_string(),
|
workspace_id: "workspace".to_string(),
|
||||||
@@ -403,6 +587,7 @@ mod tests {
|
|||||||
host_trust_id: None,
|
host_trust_id: None,
|
||||||
host_trust_revision: None,
|
host_trust_revision: None,
|
||||||
repository_access_mode: None,
|
repository_access_mode: None,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
working_directory_id: "wd-1".to_string(),
|
working_directory_id: "wd-1".to_string(),
|
||||||
state: "pending".to_string(),
|
state: "pending".to_string(),
|
||||||
failure: None,
|
failure: None,
|
||||||
@@ -413,6 +598,44 @@ mod tests {
|
|||||||
store.reserve_workdir_create_operation(&record).unwrap(),
|
store.reserve_workdir_create_operation(&record).unwrap(),
|
||||||
record
|
record
|
||||||
);
|
);
|
||||||
|
store
|
||||||
|
.with_conn_mut(|conn| {
|
||||||
|
for (credential_id, revision) in
|
||||||
|
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO repository_ssh_credentials (
|
||||||
|
workspace_id, credential_id, name,
|
||||||
|
public_key_algorithm, public_key_fingerprint,
|
||||||
|
current_revision, status, created_at
|
||||||
|
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
|
||||||
|
'active', '2026-08-24T00:00:00Z')"#,
|
||||||
|
params![credential_id, revision],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO repository_ssh_credential_revisions (
|
||||||
|
workspace_id, credential_id, revision,
|
||||||
|
public_key_algorithm, public_key_fingerprint, created_at
|
||||||
|
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
|
||||||
|
'2026-08-24T00:00:00Z')"#,
|
||||||
|
params![credential_id, revision],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let candidates = vec![
|
||||||
|
WorkdirCreateCredentialCandidate {
|
||||||
|
role: WorkdirCreateCredentialCandidateRole::Primary,
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 3,
|
||||||
|
},
|
||||||
|
WorkdirCreateCredentialCandidate {
|
||||||
|
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
|
||||||
|
credential_id: "workspace-default-ssh".to_string(),
|
||||||
|
credential_revision: 7,
|
||||||
|
},
|
||||||
|
];
|
||||||
let bound = store
|
let bound = store
|
||||||
.bind_workdir_create_repository_access(
|
.bind_workdir_create_repository_access(
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -423,12 +646,22 @@ mod tests {
|
|||||||
"trust-1",
|
"trust-1",
|
||||||
5,
|
5,
|
||||||
"read_only",
|
"read_only",
|
||||||
|
&candidates,
|
||||||
"2026-08-24T00:00:01Z",
|
"2026-08-24T00:00:01Z",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
|
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
|
||||||
assert_eq!(bound.credential_revision, Some(3));
|
assert_eq!(bound.credential_revision, Some(3));
|
||||||
assert_eq!(bound.host_trust_revision, Some(5));
|
assert_eq!(bound.host_trust_revision, Some(5));
|
||||||
|
assert_eq!(bound.credential_candidates, candidates);
|
||||||
|
let serialized = serde_json::to_string(&bound).unwrap();
|
||||||
|
assert!(serialized.contains("workspace_default_fallback"));
|
||||||
|
assert!(!serialized.contains("private_key"));
|
||||||
|
assert!(!serialized.contains("known_hosts"));
|
||||||
|
// A concurrent Workspace-default rotation must not replace the fallback
|
||||||
|
// revision already bound to this operation.
|
||||||
|
let mut changed_candidates = candidates.clone();
|
||||||
|
changed_candidates[1].credential_revision = 8;
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.bind_workdir_create_repository_access(
|
.bind_workdir_create_repository_access(
|
||||||
@@ -436,10 +669,11 @@ mod tests {
|
|||||||
"call-1",
|
"call-1",
|
||||||
&record.request_fingerprint,
|
&record.request_fingerprint,
|
||||||
"credential-1",
|
"credential-1",
|
||||||
4,
|
3,
|
||||||
"trust-1",
|
"trust-1",
|
||||||
5,
|
5,
|
||||||
"read_only",
|
"read_only",
|
||||||
|
&changed_candidates,
|
||||||
"2026-08-24T00:00:02Z",
|
"2026-08-24T00:00:02Z",
|
||||||
)
|
)
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -475,6 +709,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(retry.state, "pending");
|
assert_eq!(retry.state, "pending");
|
||||||
assert_eq!(retry.failure, None);
|
assert_eq!(retry.failure, None);
|
||||||
|
assert_eq!(retry.credential_candidates, candidates);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.load_workdir_create_operation("workspace", "call-1")
|
.load_workdir_create_operation("workspace", "call-1")
|
||||||
|
|||||||
@@ -1098,6 +1098,7 @@ mod tests {
|
|||||||
host_trust_id: None,
|
host_trust_id: None,
|
||||||
host_trust_revision: None,
|
host_trust_revision: None,
|
||||||
repository_access_mode: None,
|
repository_access_mode: None,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
working_directory_id: "workdir-a".to_string(),
|
working_directory_id: "workdir-a".to_string(),
|
||||||
state: "pending".to_string(),
|
state: "pending".to_string(),
|
||||||
failure: None,
|
failure: None,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"typed_ticket_relations",
|
"typed_ticket_relations",
|
||||||
"typed_ticket_risk_flags",
|
"typed_ticket_risk_flags",
|
||||||
"typed_tickets",
|
"typed_tickets",
|
||||||
|
"workdir_create_credential_candidates",
|
||||||
|
"workdir_create_credential_revision_retentions",
|
||||||
"workdir_create_operations",
|
"workdir_create_operations",
|
||||||
"workdir_registry",
|
"workdir_registry",
|
||||||
"workdir_removal_operations",
|
"workdir_removal_operations",
|
||||||
|
|||||||
Reference in New Issue
Block a user