Public dry run dryrun-1714a9eedd5b
Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
This commit is contained in:
commit
20c72e6066
102 changed files with 32054 additions and 0 deletions
709
crates/disasmer-coordinator/src/lib.rs
Normal file
709
crates/disasmer-coordinator/src/lib.rs
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use disasmer_core::{
|
||||
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
|
||||
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod postgres_store;
|
||||
pub mod service;
|
||||
pub use postgres_store::{
|
||||
PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES,
|
||||
};
|
||||
pub use service::{
|
||||
CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
|
||||
SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCompletionEvent,
|
||||
TaskTerminalState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TenantRecord {
|
||||
pub id: TenantId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserRecord {
|
||||
pub id: UserId,
|
||||
pub tenant: TenantId,
|
||||
pub credential_kind: CredentialKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectRecord {
|
||||
pub id: ProjectId,
|
||||
pub tenant: TenantId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeIdentityRecord {
|
||||
pub id: NodeId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub public_key: String,
|
||||
pub enrollment_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CredentialRecord {
|
||||
pub subject: String,
|
||||
pub tenant: TenantId,
|
||||
pub project: Option<ProjectId>,
|
||||
pub kind: CredentialKind,
|
||||
pub public_key_fingerprint: Option<Digest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceProviderConfigRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub provider: SourceProviderKind,
|
||||
pub manifest_digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServicePolicyRecord {
|
||||
pub tenant: TenantId,
|
||||
pub name: String,
|
||||
pub digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectPermissionRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub user: UserId,
|
||||
pub can_debug: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct DurableState {
|
||||
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
||||
pub users: BTreeMap<UserId, UserRecord>,
|
||||
pub projects: BTreeMap<ProjectId, ProjectRecord>,
|
||||
pub node_identities: BTreeMap<NodeId, NodeIdentityRecord>,
|
||||
pub credentials: BTreeMap<String, CredentialRecord>,
|
||||
pub source_provider_configs:
|
||||
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
||||
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
||||
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
||||
}
|
||||
|
||||
pub trait DurableStore {
|
||||
fn load(&self) -> DurableState;
|
||||
fn save(&mut self, state: DurableState);
|
||||
}
|
||||
|
||||
pub trait FallibleDurableStore {
|
||||
type Error;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error>;
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InMemoryDurableStore {
|
||||
state: DurableState,
|
||||
}
|
||||
|
||||
impl DurableStore for InMemoryDurableStore {
|
||||
fn load(&self) -> DurableState {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
fn save(&mut self, state: DurableState) {
|
||||
self.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActiveProcess {
|
||||
pub id: ProcessId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub connected_nodes: BTreeSet<NodeId>,
|
||||
pub coordinator_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Coordinator {
|
||||
durable: DurableState,
|
||||
active_processes: BTreeMap<ProcessId, ActiveProcess>,
|
||||
coordinator_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CoordinatorError {
|
||||
#[error("node identity is not enrolled")]
|
||||
UnknownNode,
|
||||
#[error("node enrollment failed: {0:?}")]
|
||||
Enrollment(EnrollmentError),
|
||||
#[error("stale virtual process state from coordinator epoch {stale_epoch}; current epoch is {current_epoch}")]
|
||||
StaleProcessEpoch {
|
||||
stale_epoch: u64,
|
||||
current_epoch: u64,
|
||||
},
|
||||
#[error("unauthorized coordinator action: {0}")]
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
pub fn boot(store: &impl DurableStore, coordinator_epoch: u64) -> Self {
|
||||
Self {
|
||||
durable: store.load(),
|
||||
active_processes: BTreeMap::new(),
|
||||
coordinator_epoch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_boot<S: FallibleDurableStore>(
|
||||
store: &mut S,
|
||||
coordinator_epoch: u64,
|
||||
) -> Result<Self, S::Error> {
|
||||
Ok(Self {
|
||||
durable: store.load_state()?,
|
||||
active_processes: BTreeMap::new(),
|
||||
coordinator_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn persist(&self, store: &mut impl DurableStore) {
|
||||
store.save(self.durable.clone());
|
||||
}
|
||||
|
||||
pub fn try_persist<S: FallibleDurableStore>(&self, store: &mut S) -> Result<(), S::Error> {
|
||||
store.save_state(&self.durable)
|
||||
}
|
||||
|
||||
pub fn coordinator_epoch(&self) -> u64 {
|
||||
self.coordinator_epoch
|
||||
}
|
||||
|
||||
pub fn upsert_tenant(&mut self, id: TenantId) {
|
||||
self.durable.tenants.insert(id.clone(), TenantRecord { id });
|
||||
}
|
||||
|
||||
pub fn upsert_user(&mut self, tenant: TenantId, id: UserId, credential_kind: CredentialKind) {
|
||||
self.durable.users.insert(
|
||||
id.clone(),
|
||||
UserRecord {
|
||||
id,
|
||||
tenant,
|
||||
credential_kind,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn upsert_project(&mut self, tenant: TenantId, id: ProjectId, name: impl Into<String>) {
|
||||
self.durable.projects.insert(
|
||||
id.clone(),
|
||||
ProjectRecord {
|
||||
id,
|
||||
tenant,
|
||||
name: name.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn enroll_node(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
node: NodeId,
|
||||
public_key: impl Into<String>,
|
||||
enrollment_scope: impl Into<String>,
|
||||
) {
|
||||
self.durable.node_identities.insert(
|
||||
node.clone(),
|
||||
NodeIdentityRecord {
|
||||
id: node,
|
||||
tenant,
|
||||
project,
|
||||
public_key: public_key.into(),
|
||||
enrollment_scope: enrollment_scope.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn create_node_enrollment_grant(
|
||||
&self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
grant_id: impl Into<String>,
|
||||
scope: impl Into<String>,
|
||||
expires_at_epoch_seconds: u64,
|
||||
) -> EnrollmentGrant {
|
||||
EnrollmentGrant {
|
||||
tenant,
|
||||
project,
|
||||
grant_id: grant_id.into(),
|
||||
scope: scope.into(),
|
||||
expires_at_epoch_seconds,
|
||||
consumed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exchange_node_enrollment_grant(
|
||||
&mut self,
|
||||
grant: &mut EnrollmentGrant,
|
||||
node: NodeId,
|
||||
public_key: &str,
|
||||
requested_scope: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<NodeCredential, CoordinatorError> {
|
||||
let credential = grant
|
||||
.exchange_for_node_identity(
|
||||
node.clone(),
|
||||
public_key,
|
||||
requested_scope,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
.map_err(CoordinatorError::Enrollment)?;
|
||||
self.enroll_node(
|
||||
credential.tenant.clone(),
|
||||
credential.project.clone(),
|
||||
node.clone(),
|
||||
public_key,
|
||||
credential.scope.clone(),
|
||||
);
|
||||
self.durable.credentials.insert(
|
||||
format!("node:{node}"),
|
||||
CredentialRecord {
|
||||
subject: format!("node:{node}"),
|
||||
tenant: credential.tenant.clone(),
|
||||
project: Some(credential.project.clone()),
|
||||
kind: credential.credential_kind.clone(),
|
||||
public_key_fingerprint: Some(credential.public_key_fingerprint.clone()),
|
||||
},
|
||||
);
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
pub fn upsert_source_provider_config(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
provider: SourceProviderKind,
|
||||
manifest_digest: Digest,
|
||||
) {
|
||||
let provider_key = format!("{provider:?}");
|
||||
self.durable.source_provider_configs.insert(
|
||||
(tenant.clone(), project.clone(), provider_key),
|
||||
SourceProviderConfigRecord {
|
||||
tenant,
|
||||
project,
|
||||
provider,
|
||||
manifest_digest,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn upsert_service_policy_record(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
name: impl Into<String>,
|
||||
digest: Digest,
|
||||
) {
|
||||
let name = name.into();
|
||||
self.durable.service_policy_records.insert(
|
||||
(tenant.clone(), name.clone()),
|
||||
ServicePolicyRecord {
|
||||
tenant,
|
||||
name,
|
||||
digest,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) {
|
||||
self.durable.project_permissions.insert(
|
||||
(tenant.clone(), project.clone(), user.clone()),
|
||||
ProjectPermissionRecord {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
can_debug: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn start_process(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
id: ProcessId,
|
||||
) -> ActiveProcess {
|
||||
let process = ActiveProcess {
|
||||
id: id.clone(),
|
||||
tenant,
|
||||
project,
|
||||
connected_nodes: BTreeSet::new(),
|
||||
coordinator_epoch: self.coordinator_epoch,
|
||||
};
|
||||
self.active_processes.insert(id, process.clone());
|
||||
process
|
||||
}
|
||||
|
||||
pub fn authorize_node_for_process(
|
||||
&self,
|
||||
node: &NodeId,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> Result<(), CoordinatorError> {
|
||||
let identity = self
|
||||
.durable
|
||||
.node_identities
|
||||
.get(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if &identity.tenant != tenant || &identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node identity is outside the requested tenant/project scope".to_owned(),
|
||||
));
|
||||
}
|
||||
let Some(active) = self.active_processes.get(process) else {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"virtual process is not active in coordinator memory".to_owned(),
|
||||
));
|
||||
};
|
||||
if &active.tenant != tenant || &active.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node cannot claim tasks or publish artifacts outside its process scope".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reconnect_node(
|
||||
&mut self,
|
||||
node: &NodeId,
|
||||
process: Option<(&ProcessId, u64)>,
|
||||
) -> Result<(), CoordinatorError> {
|
||||
if !self.durable.node_identities.contains_key(node) {
|
||||
return Err(CoordinatorError::UnknownNode);
|
||||
}
|
||||
|
||||
if let Some((process_id, stale_epoch)) = process {
|
||||
if stale_epoch != self.coordinator_epoch {
|
||||
return Err(CoordinatorError::StaleProcessEpoch {
|
||||
stale_epoch,
|
||||
current_epoch: self.coordinator_epoch,
|
||||
});
|
||||
}
|
||||
if let Some(active) = self.active_processes.get_mut(process_id) {
|
||||
active.connected_nodes.insert(node.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
|
||||
self.durable
|
||||
.projects
|
||||
.values()
|
||||
.filter(|project| project.tenant == context.tenant)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn authorize_debug_attach(
|
||||
&self,
|
||||
context: &AuthContext,
|
||||
process: &ProcessId,
|
||||
) -> Authorization {
|
||||
let Some(active) = self.active_processes.get(process) else {
|
||||
return Authorization::deny("virtual process is not active");
|
||||
};
|
||||
if active.tenant != context.tenant || active.project != context.project {
|
||||
return Authorization::deny("tenant or project mismatch");
|
||||
}
|
||||
let Actor::User(user) = &context.actor else {
|
||||
return Authorization::deny("debug attach requires a user identity");
|
||||
};
|
||||
let permission = self.durable.project_permissions.get(&(
|
||||
active.tenant.clone(),
|
||||
active.project.clone(),
|
||||
user.clone(),
|
||||
));
|
||||
if !permission.is_some_and(|permission| permission.can_debug) {
|
||||
return Authorization::deny("debug attach requires explicit project permission");
|
||||
}
|
||||
Authorization::allow("debug attach authorized for project")
|
||||
}
|
||||
|
||||
pub fn project(&self, id: &ProjectId) -> Option<&ProjectRecord> {
|
||||
self.durable.projects.get(id)
|
||||
}
|
||||
|
||||
pub fn active_process(&self, id: &ProcessId) -> Option<&ActiveProcess> {
|
||||
self.active_processes.get(id)
|
||||
}
|
||||
|
||||
pub fn active_process_count(&self) -> usize {
|
||||
self.active_processes.len()
|
||||
}
|
||||
|
||||
pub fn node_identity(&self, id: &NodeId) -> Option<&NodeIdentityRecord> {
|
||||
self.durable.node_identities.get(id)
|
||||
}
|
||||
|
||||
pub fn source_provider_config(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
provider: &str,
|
||||
) -> Option<&SourceProviderConfigRecord> {
|
||||
self.durable.source_provider_configs.get(&(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
provider.to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn service_policy_record(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
name: &str,
|
||||
) -> Option<&ServicePolicyRecord> {
|
||||
self.durable
|
||||
.service_policy_records
|
||||
.get(&(tenant.clone(), name.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coordinator_restart_preserves_project_but_not_live_processes() {
|
||||
let mut store = InMemoryDurableStore::default();
|
||||
let mut first = Coordinator::boot(&store, 1);
|
||||
first.upsert_tenant(TenantId::from("tenant"));
|
||||
first.upsert_user(
|
||||
TenantId::from("tenant"),
|
||||
UserId::from("user"),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||
first.upsert_source_provider_config(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
SourceProviderKind::Git,
|
||||
Digest::sha256("git-manifest"),
|
||||
);
|
||||
first.upsert_service_policy_record(
|
||||
TenantId::from("tenant"),
|
||||
"community tier",
|
||||
Digest::sha256("policy"),
|
||||
);
|
||||
let mut grant = first.create_node_enrollment_grant(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
"grant",
|
||||
"node:attach",
|
||||
100,
|
||||
);
|
||||
first
|
||||
.exchange_node_enrollment_grant(
|
||||
&mut grant,
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
99,
|
||||
)
|
||||
.unwrap();
|
||||
first.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
);
|
||||
first.persist(&mut store);
|
||||
|
||||
let mut restarted = Coordinator::boot(&store, 2);
|
||||
|
||||
assert!(restarted
|
||||
.durable
|
||||
.tenants
|
||||
.contains_key(&TenantId::from("tenant")));
|
||||
assert!(restarted.durable.users.contains_key(&UserId::from("user")));
|
||||
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||
assert_eq!(
|
||||
restarted
|
||||
.durable
|
||||
.credentials
|
||||
.get("node:node")
|
||||
.map(|credential| &credential.kind),
|
||||
Some(&CredentialKind::NodeCredential)
|
||||
);
|
||||
assert!(restarted
|
||||
.source_provider_config(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("project"),
|
||||
"Git"
|
||||
)
|
||||
.is_some());
|
||||
assert!(restarted
|
||||
.service_policy_record(&TenantId::from("tenant"), "community tier")
|
||||
.is_some());
|
||||
assert_eq!(restarted.active_process_count(), 0);
|
||||
|
||||
let process = ProcessId::from("process-rerun");
|
||||
let rerun = restarted.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
process.clone(),
|
||||
);
|
||||
assert_eq!(rerun.coordinator_epoch, 2);
|
||||
restarted
|
||||
.reconnect_node(&NodeId::from("node"), Some((&process, 2)))
|
||||
.unwrap();
|
||||
assert!(restarted
|
||||
.active_process(&process)
|
||||
.unwrap()
|
||||
.connected_nodes
|
||||
.contains(&NodeId::from("node")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_reconnect_rejects_stale_process_epoch_after_restart() {
|
||||
let mut store = InMemoryDurableStore::default();
|
||||
let mut first = Coordinator::boot(&store, 1);
|
||||
first.upsert_tenant(TenantId::from("tenant"));
|
||||
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||
first.enroll_node(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node",
|
||||
);
|
||||
first.persist(&mut store);
|
||||
|
||||
let mut restarted = Coordinator::boot(&store, 2);
|
||||
restarted
|
||||
.reconnect_node(&NodeId::from("node"), None)
|
||||
.unwrap();
|
||||
|
||||
let error = restarted
|
||||
.reconnect_node(
|
||||
&NodeId::from("node"),
|
||||
Some((&ProcessId::from("process"), 1)),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, CoordinatorError::StaleProcessEpoch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_enrollment_grant_becomes_persistent_node_identity() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
let mut grant = coordinator.create_node_enrollment_grant(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
"grant",
|
||||
"node:attach",
|
||||
100,
|
||||
);
|
||||
|
||||
let credential = coordinator
|
||||
.exchange_node_enrollment_grant(
|
||||
&mut grant,
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
99,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
||||
assert!(coordinator.node_identity(&NodeId::from("node")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_listing_is_filtered_by_tenant() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.upsert_project(
|
||||
TenantId::from("tenant-a"),
|
||||
ProjectId::from("project-a"),
|
||||
"a",
|
||||
);
|
||||
coordinator.upsert_project(
|
||||
TenantId::from("tenant-b"),
|
||||
ProjectId::from("project-b"),
|
||||
"b",
|
||||
);
|
||||
|
||||
let projects = coordinator.list_projects(&AuthContext {
|
||||
tenant: TenantId::from("tenant-a"),
|
||||
project: ProjectId::from("project-a"),
|
||||
actor: Actor::User(UserId::from("user-a")),
|
||||
});
|
||||
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_cannot_claim_process_outside_authorized_scope() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.enroll_node(
|
||||
TenantId::from("tenant-a"),
|
||||
ProjectId::from("project-a"),
|
||||
NodeId::from("node-a"),
|
||||
"public-key",
|
||||
"node",
|
||||
);
|
||||
coordinator.start_process(
|
||||
TenantId::from("tenant-b"),
|
||||
ProjectId::from("project-b"),
|
||||
ProcessId::from("process-b"),
|
||||
);
|
||||
|
||||
let error = coordinator
|
||||
.authorize_node_for_process(
|
||||
&NodeId::from("node-a"),
|
||||
&TenantId::from("tenant-b"),
|
||||
&ProjectId::from("project-b"),
|
||||
&ProcessId::from("process-b"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, CoordinatorError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_attach_requires_explicit_project_permission() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
);
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let denied = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||
coordinator.grant_project_debug(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
UserId::from("user"),
|
||||
);
|
||||
let allowed = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||
|
||||
assert!(!denied.allowed);
|
||||
assert!(denied.reason.contains("explicit project permission"));
|
||||
assert!(allowed.allowed);
|
||||
}
|
||||
}
|
||||
22
crates/disasmer-coordinator/src/main.rs
Normal file
22
crates/disasmer-coordinator/src/main.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use std::io::Write;
|
||||
|
||||
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut listen = "127.0.0.1:0".to_owned();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--listen" {
|
||||
listen = args.next().ok_or("--listen requires an address")?;
|
||||
}
|
||||
}
|
||||
|
||||
let (listener, addr) = bind_listener(&listen)?;
|
||||
println!("{}", json!({ "listen": addr.to_string() }));
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let service = CoordinatorService::new(1);
|
||||
service.serve_tcp(listener)?;
|
||||
Ok(())
|
||||
}
|
||||
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
use postgres::{Client, NoTls};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord,
|
||||
ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord,
|
||||
TenantRecord, UserRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostgresTable {
|
||||
pub name: &'static str,
|
||||
pub durable_record: &'static str,
|
||||
pub restart_surviving: bool,
|
||||
}
|
||||
|
||||
pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[
|
||||
PostgresTable {
|
||||
name: "disasmer_tenants",
|
||||
durable_record: "tenants",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_users",
|
||||
durable_record: "users",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_projects",
|
||||
durable_record: "projects",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_node_identities",
|
||||
durable_record: "node identities",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_credentials",
|
||||
durable_record: "credentials",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_source_provider_configs",
|
||||
durable_record: "source-provider configuration",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_service_policy_records",
|
||||
durable_record: "durable service policy records",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_project_permissions",
|
||||
durable_record: "explicit project permissions",
|
||||
restart_surviving: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PostgresStoreError {
|
||||
#[error("postgres durable store error: {0}")]
|
||||
Postgres(#[from] postgres::Error),
|
||||
#[error("durable state serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub struct PostgresDurableStore {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl PostgresDurableStore {
|
||||
pub fn connect(connection_string: &str) -> Result<Self, PostgresStoreError> {
|
||||
let mut store = Self {
|
||||
client: Client::connect(connection_string, NoTls)?,
|
||||
};
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn from_client(client: Client) -> Result<Self, PostgresStoreError> {
|
||||
let mut store = Self { client };
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn schema_sql() -> &'static str {
|
||||
POSTGRES_SCHEMA_SQL
|
||||
}
|
||||
|
||||
pub fn durable_tables() -> &'static [PostgresTable] {
|
||||
POSTGRES_DURABLE_TABLES
|
||||
}
|
||||
|
||||
pub fn migrate(&mut self) -> Result<(), PostgresStoreError> {
|
||||
self.client.batch_execute(Self::schema_sql())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_records<T: DeserializeOwned>(
|
||||
&mut self,
|
||||
sql: &str,
|
||||
) -> Result<Vec<T>, PostgresStoreError> {
|
||||
self.client
|
||||
.query(sql, &[])?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let value: serde_json::Value = row.get("record");
|
||||
Ok(serde_json::from_value(value)?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_value(record: &impl Serialize) -> Result<serde_json::Value, PostgresStoreError> {
|
||||
Ok(serde_json::to_value(record)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl FallibleDurableStore for PostgresDurableStore {
|
||||
type Error = PostgresStoreError;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||
let mut state = DurableState::default();
|
||||
|
||||
for record in self.query_records::<TenantRecord>(
|
||||
"SELECT record FROM disasmer_tenants ORDER BY tenant_id",
|
||||
)? {
|
||||
state.tenants.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in
|
||||
self.query_records::<UserRecord>("SELECT record FROM disasmer_users ORDER BY user_id")?
|
||||
{
|
||||
state.users.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<ProjectRecord>(
|
||||
"SELECT record FROM disasmer_projects ORDER BY project_id",
|
||||
)? {
|
||||
state.projects.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<NodeIdentityRecord>(
|
||||
"SELECT record FROM disasmer_node_identities ORDER BY node_id",
|
||||
)? {
|
||||
state.node_identities.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<CredentialRecord>(
|
||||
"SELECT record FROM disasmer_credentials ORDER BY subject",
|
||||
)? {
|
||||
state.credentials.insert(record.subject.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<SourceProviderConfigRecord>(
|
||||
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
|
||||
)? {
|
||||
let provider_key = format!("{:?}", record.provider);
|
||||
state.source_provider_configs.insert(
|
||||
(record.tenant.clone(), record.project.clone(), provider_key),
|
||||
record,
|
||||
);
|
||||
}
|
||||
for record in self.query_records::<ServicePolicyRecord>(
|
||||
"SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name",
|
||||
)? {
|
||||
state
|
||||
.service_policy_records
|
||||
.insert((record.tenant.clone(), record.name.clone()), record);
|
||||
}
|
||||
for record in self.query_records::<ProjectPermissionRecord>(
|
||||
"SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id",
|
||||
)? {
|
||||
state.project_permissions.insert(
|
||||
(
|
||||
record.tenant.clone(),
|
||||
record.project.clone(),
|
||||
record.user.clone(),
|
||||
),
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||
let mut tx = self.client.transaction()?;
|
||||
tx.batch_execute(
|
||||
"
|
||||
DELETE FROM disasmer_project_permissions;
|
||||
DELETE FROM disasmer_service_policy_records;
|
||||
DELETE FROM disasmer_source_provider_configs;
|
||||
DELETE FROM disasmer_credentials;
|
||||
DELETE FROM disasmer_node_identities;
|
||||
DELETE FROM disasmer_projects;
|
||||
DELETE FROM disasmer_users;
|
||||
DELETE FROM disasmer_tenants;
|
||||
",
|
||||
)?;
|
||||
|
||||
for record in state.tenants.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)",
|
||||
&[&record.id.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.users.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.projects.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.node_identities.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.id.as_str(),
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for record in state.credentials.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
let project_id = record.project.as_ref().map(|project| project.as_str());
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
|
||||
)?;
|
||||
}
|
||||
for ((_, _, provider_key), record) in &state.source_provider_configs {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&provider_key.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for record in state.service_policy_records.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)",
|
||||
&[&record.tenant.as_str(), &record.name.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.project_permissions.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&record.user.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const POSTGRES_SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS disasmer_tenants (
|
||||
tenant_id TEXT PRIMARY KEY,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_node_identities (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_credentials (
|
||||
subject TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
provider_key TEXT NOT NULL,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, project_id, provider_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_service_policy_records (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_project_permissions (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, project_id, user_id)
|
||||
);
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use disasmer_core::{
|
||||
CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore};
|
||||
|
||||
#[test]
|
||||
fn postgres_schema_contains_only_restart_surviving_durable_tables() {
|
||||
let names = PostgresDurableStore::durable_tables()
|
||||
.iter()
|
||||
.map(|table| table.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names.len(), 8);
|
||||
assert!(names.contains(&"disasmer_tenants"));
|
||||
assert!(names.contains(&"disasmer_users"));
|
||||
assert!(names.contains(&"disasmer_projects"));
|
||||
assert!(names.contains(&"disasmer_node_identities"));
|
||||
assert!(names.contains(&"disasmer_credentials"));
|
||||
assert!(names.contains(&"disasmer_source_provider_configs"));
|
||||
assert!(names.contains(&"disasmer_service_policy_records"));
|
||||
assert!(names.contains(&"disasmer_project_permissions"));
|
||||
assert!(PostgresDurableStore::durable_tables()
|
||||
.iter()
|
||||
.all(|table| table.restart_surviving));
|
||||
|
||||
for runtime_only in [
|
||||
"active_process",
|
||||
"virtual_thread",
|
||||
"scheduler_state",
|
||||
"debug_epoch",
|
||||
"vfs_manifest",
|
||||
"transient_artifact_location",
|
||||
] {
|
||||
assert!(
|
||||
!PostgresDurableStore::schema_sql().contains(runtime_only),
|
||||
"{runtime_only} must remain outside Postgres durable state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() {
|
||||
#[derive(Default)]
|
||||
struct FallibleMemoryStore {
|
||||
inner: InMemoryDurableStore,
|
||||
}
|
||||
|
||||
impl FallibleDurableStore for FallibleMemoryStore {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||
Ok(self.inner.load())
|
||||
}
|
||||
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||
self.inner.save(state.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut store = FallibleMemoryStore::default();
|
||||
let mut first = Coordinator::try_boot(&mut store, 1).unwrap();
|
||||
first.upsert_tenant(TenantId::from("tenant"));
|
||||
first.upsert_user(
|
||||
TenantId::from("tenant"),
|
||||
UserId::from("user"),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||
first.enroll_node(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
);
|
||||
first.upsert_source_provider_config(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
SourceProviderKind::Git,
|
||||
Digest::sha256("git-manifest"),
|
||||
);
|
||||
first.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
disasmer_core::ProcessId::from("process"),
|
||||
);
|
||||
first.try_persist(&mut store).unwrap();
|
||||
|
||||
let restarted = Coordinator::try_boot(&mut store, 2).unwrap();
|
||||
|
||||
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||
assert_eq!(restarted.active_process_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_round_trip_runs_when_dsn_is_configured() {
|
||||
let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut store = PostgresDurableStore::connect(&dsn).unwrap();
|
||||
let mut state = DurableState::default();
|
||||
state.tenants.insert(
|
||||
TenantId::from("tenant"),
|
||||
TenantRecord {
|
||||
id: TenantId::from("tenant"),
|
||||
},
|
||||
);
|
||||
state.projects.insert(
|
||||
ProjectId::from("project"),
|
||||
ProjectRecord {
|
||||
id: ProjectId::from("project"),
|
||||
tenant: TenantId::from("tenant"),
|
||||
name: "demo".to_owned(),
|
||||
},
|
||||
);
|
||||
store.save_state(&state).unwrap();
|
||||
let loaded = store.load_state().unwrap();
|
||||
|
||||
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
|
||||
}
|
||||
}
|
||||
3591
crates/disasmer-coordinator/src/service.rs
Normal file
3591
crates/disasmer-coordinator/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue