@feltdb/core 0.8.8 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authority-scope.d.ts +16 -0
- package/dist/authority-scope.d.ts.map +1 -0
- package/dist/authority-scope.js +39 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +343 -1
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +15 -2
- package/dist/create/server-source/crates/feltdb-server/src/control_plane.rs +845 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1381 -5
- package/dist/create/server-source/crates/feltdb-server/src/principals.rs +37 -0
- package/dist/db.d.ts +16 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +76 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/error-codes.d.ts +7 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +20 -4
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +5 -1
- package/dist/http-db.d.ts +3 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +10 -3
- package/dist/index-core.d.ts +1 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +1 -0
- package/dist/operation-admission.d.ts +7 -0
- package/dist/operation-admission.d.ts.map +1 -1
- package/dist/operation-admission.js +0 -7
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-Duq2xQWt.js → components-CehkcX_k.js} +2355 -2355
- package/dist/studio/control-plane/Agents.d.ts +6 -0
- package/dist/studio/control-plane/Agents.d.ts.map +1 -0
- package/dist/studio/control-plane/Data.d.ts +10 -0
- package/dist/studio/control-plane/Data.d.ts.map +1 -0
- package/dist/studio/control-plane/Instance.d.ts +7 -0
- package/dist/studio/control-plane/Instance.d.ts.map +1 -0
- package/dist/studio/control-plane/Operations.d.ts +6 -0
- package/dist/studio/control-plane/Operations.d.ts.map +1 -0
- package/dist/studio/control-plane/Overview.d.ts +4 -0
- package/dist/studio/control-plane/Overview.d.ts.map +1 -0
- package/dist/studio/control-plane/Schema.d.ts +4 -0
- package/dist/studio/control-plane/Schema.d.ts.map +1 -0
- package/dist/studio/control-plane/State.d.ts +4 -0
- package/dist/studio/control-plane/State.d.ts.map +1 -0
- package/dist/studio/control-plane/context.d.ts +42 -0
- package/dist/studio/control-plane/context.d.ts.map +1 -0
- package/dist/studio/control-plane/index.d.ts +18 -0
- package/dist/studio/control-plane/index.d.ts.map +1 -0
- package/dist/studio/control-plane/index.js +2 -0
- package/dist/studio/control-plane/primitives.d.ts +70 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -0
- package/dist/studio/control-plane-D6TT2fCQ.js +3064 -0
- package/dist/studio/index.d.ts +2 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +326 -323
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +442 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -0
- package/dist/studio/utils/control-plane-api.js +204 -0
- package/dist/studio/utils/index.d.ts +1 -0
- package/dist/studio/utils/index.d.ts.map +1 -1
- package/dist/studio/utils/index.js +13 -12
- package/dist/studio-app/assets/{feltdb_wasm-C1VhI-U5.js → feltdb_wasm-DksXDnLa.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-Yy4QRHJT.wasm +0 -0
- package/dist/studio-app/assets/{index-Cd_NPw14.css → index-BrsF-kWL.css} +1 -1
- package/dist/studio-app/assets/index-CZ55nLgp.js +30 -0
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/index-ByHX4xDq.js +0 -29
|
@@ -62,7 +62,7 @@ use feltdb::{
|
|
|
62
62
|
},
|
|
63
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
64
64
|
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
-
PeerAdvertisement, PeerId, RecordPrecondition, StateTriggerStore, StoredRow,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StateRevision, StateTriggerStore, StoredRow,
|
|
66
66
|
};
|
|
67
67
|
use feltdb_server::{
|
|
68
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -82,13 +82,14 @@ use feltdb_server::{
|
|
|
82
82
|
cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
|
|
83
83
|
connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
|
|
84
84
|
content::ContentStore,
|
|
85
|
+
control_plane,
|
|
85
86
|
identity::IdentityStore,
|
|
86
87
|
key_management::{create_key, list_keys, revoke_key},
|
|
87
88
|
key_provider::provider_from_environment,
|
|
88
89
|
leases::LeaseStore,
|
|
89
90
|
metrics::Metrics,
|
|
90
91
|
portable_bundle::{BundleArtifact, BundleStore, ExportOptions, ImportMode, PortableBundle},
|
|
91
|
-
principals::{PrincipalKind, PrincipalStore},
|
|
92
|
+
principals::{PrincipalKind, PrincipalStatus, PrincipalStore},
|
|
92
93
|
providers::{ProviderManifest, ProviderStatus, ProviderStore},
|
|
93
94
|
releases::{
|
|
94
95
|
provider_for, AuthorizedDeploymentContext, DeploymentPlan, DeploymentProfile,
|
|
@@ -7512,17 +7513,29 @@ async fn get_state_version(
|
|
|
7512
7513
|
json!({"application_id":target.application_id,"revision_id":contract.revision_id,"schema_version":contract.state.schema.schema_version,"state_version":state.db.sequence()?,"causal_cursor":state.db.operation_versions()?,"state_namespace":contract.environment.state_namespace}),
|
|
7513
7514
|
))
|
|
7514
7515
|
}
|
|
7516
|
+
fn require_bound_tenant(headers: &HeaderMap, tenant: &str) -> Result<(), ApiError> {
|
|
7517
|
+
let Some(bound) = headers.get("feltdb-tenant-id") else { return Ok(()); };
|
|
7518
|
+
if bound.to_str().ok() == Some(tenant) { return Ok(()); }
|
|
7519
|
+
Err(ApiError(StatusCode::FORBIDDEN, json!({
|
|
7520
|
+
"code":"AUTHORITY_SCOPE_MISMATCH",
|
|
7521
|
+
"message":"request tenant does not match the authenticated application authority",
|
|
7522
|
+
"http_status":403
|
|
7523
|
+
}).to_string()))
|
|
7524
|
+
}
|
|
7525
|
+
|
|
7515
7526
|
async fn execute_canonical_query(
|
|
7516
7527
|
State(state): State<AppState>,
|
|
7517
7528
|
Extension(principal): Extension<Principal>,
|
|
7529
|
+
headers: HeaderMap,
|
|
7518
7530
|
Json(input): Json<CanonicalQueryRequest>,
|
|
7519
7531
|
) -> Result<Json<Value>, ApiError> {
|
|
7520
|
-
let
|
|
7532
|
+
let tenant = application_scope(
|
|
7521
7533
|
&state,
|
|
7522
7534
|
&principal.key_id,
|
|
7523
7535
|
&input.application_id,
|
|
7524
7536
|
"state:read",
|
|
7525
7537
|
)?;
|
|
7538
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
7526
7539
|
let contract = revision_contract(
|
|
7527
7540
|
&state,
|
|
7528
7541
|
&principal.key_id,
|
|
@@ -7717,6 +7730,7 @@ async fn execute_revision_recovery(
|
|
|
7717
7730
|
async fn execute_canonical_transaction(
|
|
7718
7731
|
State(state): State<AppState>,
|
|
7719
7732
|
Extension(principal): Extension<Principal>,
|
|
7733
|
+
headers: HeaderMap,
|
|
7720
7734
|
Json(mut input): Json<CanonicalTransactionRequest>,
|
|
7721
7735
|
) -> Result<Json<Value>, ApiError> {
|
|
7722
7736
|
let tenant = application_scope(
|
|
@@ -7725,6 +7739,7 @@ async fn execute_canonical_transaction(
|
|
|
7725
7739
|
&input.application_id,
|
|
7726
7740
|
"state:write",
|
|
7727
7741
|
)?;
|
|
7742
|
+
require_bound_tenant(&headers, &tenant)?;
|
|
7728
7743
|
let contract = revision_contract(
|
|
7729
7744
|
&state,
|
|
7730
7745
|
&principal.key_id,
|
|
@@ -7732,6 +7747,13 @@ async fn execute_canonical_transaction(
|
|
|
7732
7747
|
&input.revision_id,
|
|
7733
7748
|
&input.environment,
|
|
7734
7749
|
)?;
|
|
7750
|
+
if !input.transaction.tenant_id.is_empty() && input.transaction.tenant_id != tenant {
|
|
7751
|
+
return Err(ApiError(StatusCode::FORBIDDEN, json!({
|
|
7752
|
+
"code":"AUTHORITY_SCOPE_MISMATCH",
|
|
7753
|
+
"message":"transaction tenant does not match the bound authority",
|
|
7754
|
+
"http_status":403
|
|
7755
|
+
}).to_string()));
|
|
7756
|
+
}
|
|
7735
7757
|
input.transaction.tenant_id = tenant.clone();
|
|
7736
7758
|
input.transaction.application_id = input.application_id.clone();
|
|
7737
7759
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
@@ -8675,6 +8697,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8675
8697
|
)
|
|
8676
8698
|
.route("/v1/schema", get(get_state_schema))
|
|
8677
8699
|
.route("/v1/application", get(discover_application))
|
|
8700
|
+
.route(
|
|
8701
|
+
"/v1/control-plane/capabilities",
|
|
8702
|
+
get(control_plane_capabilities),
|
|
8703
|
+
)
|
|
8704
|
+
.route("/v1/control-plane/overview", get(control_plane_overview))
|
|
8705
|
+
.route("/v1/control-plane/actions", get(control_plane_actions))
|
|
8706
|
+
.route("/v1/control-plane/indexes", get(control_plane_indexes))
|
|
8707
|
+
.route(
|
|
8708
|
+
"/v1/control-plane/indexes/rebuild",
|
|
8709
|
+
axum::routing::post(control_plane_rebuild_index),
|
|
8710
|
+
)
|
|
8711
|
+
.route(
|
|
8712
|
+
"/v1/control-plane/operations",
|
|
8713
|
+
get(control_plane_operations),
|
|
8714
|
+
)
|
|
8715
|
+
.route(
|
|
8716
|
+
"/v1/control-plane/replication",
|
|
8717
|
+
get(control_plane_replication),
|
|
8718
|
+
)
|
|
8719
|
+
.route(
|
|
8720
|
+
"/v1/control-plane/applications",
|
|
8721
|
+
get(control_plane_applications),
|
|
8722
|
+
)
|
|
8723
|
+
.route("/v1/control-plane/developer", get(control_plane_developer))
|
|
8724
|
+
.route(
|
|
8725
|
+
"/v1/control-plane/transactions",
|
|
8726
|
+
get(control_plane_transactions),
|
|
8727
|
+
)
|
|
8728
|
+
.route(
|
|
8729
|
+
"/v1/control-plane/transactions/{transaction_id}",
|
|
8730
|
+
get(control_plane_transaction),
|
|
8731
|
+
)
|
|
8732
|
+
.route(
|
|
8733
|
+
"/v1/control-plane/provenance/{collection}/{id}",
|
|
8734
|
+
get(control_plane_record_provenance),
|
|
8735
|
+
)
|
|
8736
|
+
.route("/v1/control-plane/agents", get(control_plane_agents))
|
|
8737
|
+
.route("/v1/control-plane/agents/{agent_id}", get(control_plane_agent))
|
|
8738
|
+
.route("/v1/state/resources", get(state_resources))
|
|
8739
|
+
.route(
|
|
8740
|
+
"/v1/state/resources/{resource}/history",
|
|
8741
|
+
get(state_resource_history),
|
|
8742
|
+
)
|
|
8743
|
+
.route("/v1/state/revisions/{id}", get(state_revision))
|
|
8744
|
+
.route("/v1/state/diff", axum::routing::post(state_diff))
|
|
8745
|
+
.route("/v1/state/conflicts", axum::routing::post(state_conflicts))
|
|
8746
|
+
.route(
|
|
8747
|
+
"/v1/state/reconciliation/plan",
|
|
8748
|
+
axum::routing::post(state_reconciliation_plan),
|
|
8749
|
+
)
|
|
8750
|
+
.route(
|
|
8751
|
+
"/v1/state/reconciliation/execute",
|
|
8752
|
+
axum::routing::post(state_reconciliation_execute),
|
|
8753
|
+
)
|
|
8678
8754
|
.route("/v1/application/schema", get(inspect_application_schema))
|
|
8679
8755
|
.route("/v1/data/{collection}", get(inspect_collection))
|
|
8680
8756
|
.route("/v1/data/{collection}/{id}", get(inspect_record))
|
|
@@ -9329,6 +9405,16 @@ async fn authenticate(
|
|
|
9329
9405
|
let path = request.uri().path();
|
|
9330
9406
|
let required = if path == "/events" {
|
|
9331
9407
|
"events:read"
|
|
9408
|
+
} else if path == "/v1/state/diff"
|
|
9409
|
+
|| path == "/v1/state/conflicts"
|
|
9410
|
+
|| path == "/v1/state/reconciliation/plan"
|
|
9411
|
+
{
|
|
9412
|
+
// Describing state is reading it. These are POSTs because they take
|
|
9413
|
+
// three revision identities and a plan, not because they change
|
|
9414
|
+
// anything: the diff, the classification and the plan preview are all
|
|
9415
|
+
// pure. Letting the method decide the capability would make inspecting
|
|
9416
|
+
// a conflict require permission to resolve one.
|
|
9417
|
+
"state:read"
|
|
9332
9418
|
} else if path == "/sync/pull" || path == "/sync/snapshot" {
|
|
9333
9419
|
"sync:read"
|
|
9334
9420
|
} else if path == "/sync/push" {
|
|
@@ -13554,6 +13640,1285 @@ fn record_response(row: StoredRow) -> RecordResponse {
|
|
|
13554
13640
|
}
|
|
13555
13641
|
}
|
|
13556
13642
|
|
|
13643
|
+
// ============================================================================
|
|
13644
|
+
// The human control plane
|
|
13645
|
+
// ============================================================================
|
|
13646
|
+
//
|
|
13647
|
+
// Studio is FeltDB's human surface. It is not a second implementation of
|
|
13648
|
+
// FeltDB, and these routes are what make that possible: every one of them is a
|
|
13649
|
+
// public, authorized FeltDB capability that an application or an agent may call
|
|
13650
|
+
// on exactly the same terms. Nothing here reads a state file, reaches into
|
|
13651
|
+
// storage, or offers a mutation path Studio alone can use.
|
|
13652
|
+
//
|
|
13653
|
+
// The rule this section exists to hold: **if Studio can perform it, the
|
|
13654
|
+
// operation is a FeltDB capability**. When it is not one, the capability
|
|
13655
|
+
// declaration says so, and the interface renders it unavailable rather than
|
|
13656
|
+
// drawing a control that cannot work.
|
|
13657
|
+
|
|
13658
|
+
/// The state store, opened through the canonical facade.
|
|
13659
|
+
///
|
|
13660
|
+
/// Deliberately the facade rather than `StateStore::with_feltdb`: the state
|
|
13661
|
+
/// subsystem names one way in, and a second entry point here would be the first
|
|
13662
|
+
/// step toward the control plane growing its own copy of the state model.
|
|
13663
|
+
fn control_plane_state_store(state: &AppState) -> Result<feltdb::StateStore, ApiError> {
|
|
13664
|
+
feltdb::FeltDBStateSystem::create_store(&Arc::new(state.db.clone()))
|
|
13665
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error))
|
|
13666
|
+
}
|
|
13667
|
+
|
|
13668
|
+
fn control_plane_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
|
|
13669
|
+
ApiError::structured(status, json!({"code": code, "message": message.into()}))
|
|
13670
|
+
}
|
|
13671
|
+
|
|
13672
|
+
/// Reads a revision, or says precisely which one was missing.
|
|
13673
|
+
fn control_plane_revision(
|
|
13674
|
+
store: &feltdb::StateStore,
|
|
13675
|
+
label: &str,
|
|
13676
|
+
id: &str,
|
|
13677
|
+
) -> Result<StateRevision, ApiError> {
|
|
13678
|
+
store
|
|
13679
|
+
.get(&feltdb::StateId::from_hex(id.to_string()))
|
|
13680
|
+
.ok_or_else(|| {
|
|
13681
|
+
control_plane_error(
|
|
13682
|
+
StatusCode::NOT_FOUND,
|
|
13683
|
+
"revision_not_found",
|
|
13684
|
+
format!("the {label} revision {id} is not held by this instance"),
|
|
13685
|
+
)
|
|
13686
|
+
})
|
|
13687
|
+
}
|
|
13688
|
+
|
|
13689
|
+
/// A revision's content as JSON, redacted the same way inspected records are.
|
|
13690
|
+
///
|
|
13691
|
+
/// Revision content is record content. It reaches a human through the same
|
|
13692
|
+
/// redaction the data browser applies, because a secret does not stop being a
|
|
13693
|
+
/// secret by being historical.
|
|
13694
|
+
fn control_plane_revision_content(revision: &StateRevision) -> Value {
|
|
13695
|
+
let mut content =
|
|
13696
|
+
serde_json::from_str::<Value>(&revision.content).unwrap_or(Value::String(String::new()));
|
|
13697
|
+
redact_inspection_value(&mut content);
|
|
13698
|
+
content
|
|
13699
|
+
}
|
|
13700
|
+
|
|
13701
|
+
/// What the whole control plane declares it can do.
|
|
13702
|
+
async fn control_plane_capabilities() -> Json<Value> {
|
|
13703
|
+
Json(control_plane::capabilities())
|
|
13704
|
+
}
|
|
13705
|
+
|
|
13706
|
+
#[derive(Deserialize)]
|
|
13707
|
+
struct ControlPlaneScope {
|
|
13708
|
+
#[serde(default)]
|
|
13709
|
+
application_id: Option<String>,
|
|
13710
|
+
#[serde(default = "production_environment")]
|
|
13711
|
+
environment: String,
|
|
13712
|
+
}
|
|
13713
|
+
|
|
13714
|
+
/// Instance, authority, runtime, storage and activity in one document.
|
|
13715
|
+
///
|
|
13716
|
+
/// One request rather than eight, because an overview whose panels arrive
|
|
13717
|
+
/// independently shows eight moments and calls them one instant.
|
|
13718
|
+
async fn control_plane_overview(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
13719
|
+
let db = state.db.clone();
|
|
13720
|
+
let (state_version, authority_revision, instance_id, cardinalities, log) =
|
|
13721
|
+
tokio::task::spawn_blocking(move || {
|
|
13722
|
+
Ok::<_, FlowError>((
|
|
13723
|
+
db.sequence()?,
|
|
13724
|
+
db.current_revision()?,
|
|
13725
|
+
db.instance_id()?,
|
|
13726
|
+
db.list_cardinalities()?,
|
|
13727
|
+
db.log_recovery(),
|
|
13728
|
+
))
|
|
13729
|
+
})
|
|
13730
|
+
.await
|
|
13731
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
13732
|
+
let namespace = state.namespace.to_string();
|
|
13733
|
+
let prefix = format!("{namespace}:");
|
|
13734
|
+
let collections = cardinalities
|
|
13735
|
+
.iter()
|
|
13736
|
+
.filter(|(capability, _)| !capability.starts_with('_') && *capability != "state")
|
|
13737
|
+
.map(|(capability, count)| {
|
|
13738
|
+
json!({
|
|
13739
|
+
"capability": capability,
|
|
13740
|
+
"collection": capability.strip_prefix(&prefix).unwrap_or(capability),
|
|
13741
|
+
"records": count,
|
|
13742
|
+
})
|
|
13743
|
+
})
|
|
13744
|
+
.collect::<Vec<_>>();
|
|
13745
|
+
let records: u64 = collections
|
|
13746
|
+
.iter()
|
|
13747
|
+
.filter_map(|entry| entry["records"].as_u64())
|
|
13748
|
+
.sum();
|
|
13749
|
+
let revisions = cardinalities
|
|
13750
|
+
.iter()
|
|
13751
|
+
.find(|(capability, _)| capability == "state")
|
|
13752
|
+
.map(|(_, count)| *count)
|
|
13753
|
+
.unwrap_or(0);
|
|
13754
|
+
Ok(Json(json!({
|
|
13755
|
+
"instance": {
|
|
13756
|
+
"instance_id": instance_id,
|
|
13757
|
+
"namespace": namespace,
|
|
13758
|
+
"engine_version": env!("CARGO_PKG_VERSION"),
|
|
13759
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
13760
|
+
"uptime_seconds": state.started_at.elapsed().as_secs(),
|
|
13761
|
+
"authentication_enabled": state.auth_enabled,
|
|
13762
|
+
},
|
|
13763
|
+
"authority": {
|
|
13764
|
+
"authority_revision": authority_revision,
|
|
13765
|
+
"state_version": state_version,
|
|
13766
|
+
"lease_clock_healthy": state.lease_clock.is_healthy(),
|
|
13767
|
+
"advertise_url": state.advertise_url.as_ref().map(|value| value.to_string()),
|
|
13768
|
+
},
|
|
13769
|
+
"storage": {
|
|
13770
|
+
"records": records,
|
|
13771
|
+
"state_revisions": revisions,
|
|
13772
|
+
"collections": collections,
|
|
13773
|
+
// Reported as the two distinct facts the type carries: a clean
|
|
13774
|
+
// open, and an open that discarded an incomplete final append.
|
|
13775
|
+
// Flattening them to a boolean would hide the second, which is the
|
|
13776
|
+
// one an operator needs to see.
|
|
13777
|
+
"log_recovery": match log {
|
|
13778
|
+
feltdb::LogRecovery::Clean => json!({"status": "clean"}),
|
|
13779
|
+
feltdb::LogRecovery::RecoveredTornTail { byte_offset, discarded_bytes } => json!({
|
|
13780
|
+
"status": "recovered-torn-tail",
|
|
13781
|
+
"byte_offset": byte_offset,
|
|
13782
|
+
"discarded_bytes": discarded_bytes,
|
|
13783
|
+
}),
|
|
13784
|
+
},
|
|
13785
|
+
"audit": serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
|
|
13786
|
+
},
|
|
13787
|
+
"runtime": {
|
|
13788
|
+
"worker_enabled": state.worker_enabled,
|
|
13789
|
+
"request_deadline_ms": state.request_deadline.as_millis() as u64,
|
|
13790
|
+
"admission_permits_available": state.admission.available_permits(),
|
|
13791
|
+
},
|
|
13792
|
+
"activity": serde_json::to_value(state.metrics.snapshot()).unwrap_or(Value::Null),
|
|
13793
|
+
})))
|
|
13794
|
+
}
|
|
13795
|
+
|
|
13796
|
+
/// The write operations this application declares, per collection.
|
|
13797
|
+
///
|
|
13798
|
+
/// Studio binds its create, update and delete controls to this. A collection
|
|
13799
|
+
/// that declares no action for an operation gets no control for it — which is
|
|
13800
|
+
/// the same answer any other caller gets, because there is no other way in.
|
|
13801
|
+
async fn control_plane_actions(
|
|
13802
|
+
State(state): State<AppState>,
|
|
13803
|
+
Extension(principal): Extension<Principal>,
|
|
13804
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
13805
|
+
) -> Result<Json<Value>, ApiError> {
|
|
13806
|
+
let application_id = scope.application_id.clone().ok_or_else(|| {
|
|
13807
|
+
control_plane_error(
|
|
13808
|
+
StatusCode::BAD_REQUEST,
|
|
13809
|
+
"application_required",
|
|
13810
|
+
"application_id is required",
|
|
13811
|
+
)
|
|
13812
|
+
})?;
|
|
13813
|
+
let contract =
|
|
13814
|
+
active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
|
|
13815
|
+
let collections = contract
|
|
13816
|
+
.state
|
|
13817
|
+
.collections
|
|
13818
|
+
.iter()
|
|
13819
|
+
.map(|collection| {
|
|
13820
|
+
let actions = contract
|
|
13821
|
+
.actions
|
|
13822
|
+
.definitions
|
|
13823
|
+
.iter()
|
|
13824
|
+
.filter(|action| action.collection == collection.name)
|
|
13825
|
+
.map(|action| {
|
|
13826
|
+
json!({
|
|
13827
|
+
"name": action.name,
|
|
13828
|
+
"operation": match action.operation {
|
|
13829
|
+
ActionOperation::Create => "create",
|
|
13830
|
+
ActionOperation::Update => "update",
|
|
13831
|
+
ActionOperation::Delete => "delete",
|
|
13832
|
+
},
|
|
13833
|
+
"input_fields": action.input_fields,
|
|
13834
|
+
"required_capabilities": action.required_capabilities,
|
|
13835
|
+
"audit": action.audit,
|
|
13836
|
+
})
|
|
13837
|
+
})
|
|
13838
|
+
.collect::<Vec<_>>();
|
|
13839
|
+
json!({"collection": collection.name, "actions": actions})
|
|
13840
|
+
})
|
|
13841
|
+
.collect::<Vec<_>>();
|
|
13842
|
+
Ok(Json(json!({
|
|
13843
|
+
"application": {"application_id": application_id, "environment": scope.environment},
|
|
13844
|
+
"revision_id": contract.revision_id,
|
|
13845
|
+
"route": "/api/applications/{application_id}/revisions/{revision_id}/actions/{action}",
|
|
13846
|
+
"collections": collections,
|
|
13847
|
+
})))
|
|
13848
|
+
}
|
|
13849
|
+
|
|
13850
|
+
/// Declared indexes, and whether the running instance actually has them.
|
|
13851
|
+
///
|
|
13852
|
+
/// The two are separate questions and are answered separately. A contract can
|
|
13853
|
+
/// declare an index the runtime has not built, and the runtime can hold an
|
|
13854
|
+
/// equality index no contract declares; showing only one number would hide
|
|
13855
|
+
/// exactly the case an operator opened this page to find.
|
|
13856
|
+
async fn control_plane_indexes(
|
|
13857
|
+
State(state): State<AppState>,
|
|
13858
|
+
Extension(principal): Extension<Principal>,
|
|
13859
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
13860
|
+
) -> Result<Json<Value>, ApiError> {
|
|
13861
|
+
let application_id = scope.application_id.clone().ok_or_else(|| {
|
|
13862
|
+
control_plane_error(
|
|
13863
|
+
StatusCode::BAD_REQUEST,
|
|
13864
|
+
"application_required",
|
|
13865
|
+
"application_id is required",
|
|
13866
|
+
)
|
|
13867
|
+
})?;
|
|
13868
|
+
let contract =
|
|
13869
|
+
active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
|
|
13870
|
+
let namespace = contract.environment.state_namespace.clone();
|
|
13871
|
+
let runtime = state.db.equality_indexes()?;
|
|
13872
|
+
let backed = |collection: &str, field: &str| {
|
|
13873
|
+
let capability = format!("{namespace}:{collection}");
|
|
13874
|
+
runtime
|
|
13875
|
+
.iter()
|
|
13876
|
+
.any(|(indexed, indexed_field)| *indexed == capability && indexed_field == field)
|
|
13877
|
+
};
|
|
13878
|
+
let indexes = contract
|
|
13879
|
+
.state
|
|
13880
|
+
.indexes
|
|
13881
|
+
.iter()
|
|
13882
|
+
.map(|index| {
|
|
13883
|
+
let fields = index
|
|
13884
|
+
.fields
|
|
13885
|
+
.iter()
|
|
13886
|
+
.map(|field| json!({"field": field, "backed": backed(&index.collection, field)}))
|
|
13887
|
+
.collect::<Vec<_>>();
|
|
13888
|
+
let all_backed = fields.iter().all(|field| field["backed"] == json!(true));
|
|
13889
|
+
json!({
|
|
13890
|
+
"name": index.name,
|
|
13891
|
+
"collection": index.collection,
|
|
13892
|
+
"fields": fields,
|
|
13893
|
+
"unique": index.unique,
|
|
13894
|
+
"sparse": index.sparse,
|
|
13895
|
+
"version": index.version,
|
|
13896
|
+
"state": if all_backed { "backed" } else { "declared" },
|
|
13897
|
+
})
|
|
13898
|
+
})
|
|
13899
|
+
.collect::<Vec<_>>();
|
|
13900
|
+
// Which equality filters the server can serve from an index. A field that
|
|
13901
|
+
// is not here is still filterable; it is filterable by scanning, and saying
|
|
13902
|
+
// so is the difference between a usable browser and a surprising one.
|
|
13903
|
+
let access_paths = contract
|
|
13904
|
+
.state
|
|
13905
|
+
.collections
|
|
13906
|
+
.iter()
|
|
13907
|
+
.map(|collection| {
|
|
13908
|
+
json!({
|
|
13909
|
+
"collection": collection.name,
|
|
13910
|
+
"fields": collection.fields.iter().map(|field| json!({
|
|
13911
|
+
"field": field.name,
|
|
13912
|
+
"access_method": if backed(&collection.name, &field.name) { "index" } else { "scan" },
|
|
13913
|
+
})).collect::<Vec<_>>(),
|
|
13914
|
+
})
|
|
13915
|
+
})
|
|
13916
|
+
.collect::<Vec<_>>();
|
|
13917
|
+
Ok(Json(json!({
|
|
13918
|
+
"application": {"application_id": application_id, "environment": scope.environment},
|
|
13919
|
+
"state_namespace": namespace,
|
|
13920
|
+
"declared": indexes,
|
|
13921
|
+
"runtime_equality_indexes": runtime.iter().map(|(capability, field)| json!({
|
|
13922
|
+
"capability": capability, "field": field,
|
|
13923
|
+
})).collect::<Vec<_>>(),
|
|
13924
|
+
"access_paths": access_paths,
|
|
13925
|
+
})))
|
|
13926
|
+
}
|
|
13927
|
+
|
|
13928
|
+
#[derive(Deserialize)]
|
|
13929
|
+
struct IndexRebuildRequest {
|
|
13930
|
+
application_id: String,
|
|
13931
|
+
#[serde(default = "production_environment")]
|
|
13932
|
+
environment: String,
|
|
13933
|
+
collection: String,
|
|
13934
|
+
field: String,
|
|
13935
|
+
}
|
|
13936
|
+
|
|
13937
|
+
/// Rebuilds one declared equality index from authoritative state.
|
|
13938
|
+
///
|
|
13939
|
+
/// Restricted to a field the application's contract declares an index on.
|
|
13940
|
+
/// Rebuilding is a maintenance operation on a declared index, not a way to
|
|
13941
|
+
/// create indexes the contract never asked for.
|
|
13942
|
+
async fn control_plane_rebuild_index(
|
|
13943
|
+
State(state): State<AppState>,
|
|
13944
|
+
Extension(principal): Extension<Principal>,
|
|
13945
|
+
Json(request): Json<IndexRebuildRequest>,
|
|
13946
|
+
) -> Result<Json<Value>, ApiError> {
|
|
13947
|
+
let contract = active_inspection_contract(
|
|
13948
|
+
&state,
|
|
13949
|
+
&principal,
|
|
13950
|
+
&request.application_id,
|
|
13951
|
+
&request.environment,
|
|
13952
|
+
)?;
|
|
13953
|
+
let declared = contract.state.indexes.iter().any(|index| {
|
|
13954
|
+
index.collection == request.collection && index.fields.iter().any(|f| *f == request.field)
|
|
13955
|
+
});
|
|
13956
|
+
if !declared {
|
|
13957
|
+
return Err(control_plane_error(
|
|
13958
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
13959
|
+
"index_not_declared",
|
|
13960
|
+
format!(
|
|
13961
|
+
"the contract declares no index on {}.{}",
|
|
13962
|
+
request.collection, request.field
|
|
13963
|
+
),
|
|
13964
|
+
));
|
|
13965
|
+
}
|
|
13966
|
+
let capability = format!(
|
|
13967
|
+
"{}:{}",
|
|
13968
|
+
contract.environment.state_namespace, request.collection
|
|
13969
|
+
);
|
|
13970
|
+
let created = state.db.create_equality_index(&capability, &request.field)?;
|
|
13971
|
+
Ok(Json(json!({
|
|
13972
|
+
"collection": request.collection,
|
|
13973
|
+
"field": request.field,
|
|
13974
|
+
"capability": capability,
|
|
13975
|
+
"created": created,
|
|
13976
|
+
"state": "backed",
|
|
13977
|
+
})))
|
|
13978
|
+
}
|
|
13979
|
+
|
|
13980
|
+
#[derive(Deserialize)]
|
|
13981
|
+
struct OperationsQuery {
|
|
13982
|
+
#[serde(default)]
|
|
13983
|
+
collection: Option<String>,
|
|
13984
|
+
#[serde(default)]
|
|
13985
|
+
kind: Option<String>,
|
|
13986
|
+
#[serde(default)]
|
|
13987
|
+
limit: Option<usize>,
|
|
13988
|
+
}
|
|
13989
|
+
|
|
13990
|
+
const CONTROL_PLANE_OPERATION_LIMIT: usize = 200;
|
|
13991
|
+
|
|
13992
|
+
/// Recent writes, newest first, without the values they wrote.
|
|
13993
|
+
///
|
|
13994
|
+
/// The change log is what the instance retains for replication, so this is a
|
|
13995
|
+
/// window on recent activity rather than an operation archive; the response
|
|
13996
|
+
/// says so rather than letting an empty tail read as a quiet system.
|
|
13997
|
+
async fn control_plane_operations(
|
|
13998
|
+
State(state): State<AppState>,
|
|
13999
|
+
Query(query): Query<OperationsQuery>,
|
|
14000
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14001
|
+
let limit = query
|
|
14002
|
+
.limit
|
|
14003
|
+
.unwrap_or(50)
|
|
14004
|
+
.clamp(1, CONTROL_PLANE_OPERATION_LIMIT);
|
|
14005
|
+
let db = state.db.clone();
|
|
14006
|
+
let (mut operations, retained_from) = tokio::task::spawn_blocking(move || {
|
|
14007
|
+
Ok::<_, FlowError>((
|
|
14008
|
+
db.operations_since(&HashMap::new())?,
|
|
14009
|
+
db.retained_operation_floors()?,
|
|
14010
|
+
))
|
|
14011
|
+
})
|
|
14012
|
+
.await
|
|
14013
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14014
|
+
operations.sort_by(|left, right| right.op_id.cmp(&left.op_id));
|
|
14015
|
+
let matched = operations
|
|
14016
|
+
.iter()
|
|
14017
|
+
.filter(|operation| {
|
|
14018
|
+
query
|
|
14019
|
+
.collection
|
|
14020
|
+
.as_deref()
|
|
14021
|
+
.is_none_or(|collection| control_plane::collection_of(&operation.capability) == collection)
|
|
14022
|
+
})
|
|
14023
|
+
.filter(|operation| {
|
|
14024
|
+
query.kind.as_deref().is_none_or(|kind| {
|
|
14025
|
+
matches!(
|
|
14026
|
+
(kind, &operation.op_type),
|
|
14027
|
+
("insert", feltdb::OperationType::Insert)
|
|
14028
|
+
| ("update", feltdb::OperationType::Update)
|
|
14029
|
+
| ("delete", feltdb::OperationType::Delete)
|
|
14030
|
+
)
|
|
14031
|
+
})
|
|
14032
|
+
})
|
|
14033
|
+
.take(limit)
|
|
14034
|
+
.map(control_plane::operation_summary_to_json)
|
|
14035
|
+
.collect::<Vec<_>>();
|
|
14036
|
+
Ok(Json(json!({
|
|
14037
|
+
"operations": matched,
|
|
14038
|
+
"retained": {
|
|
14039
|
+
"operations_held": operations.len(),
|
|
14040
|
+
"floors": retained_from,
|
|
14041
|
+
"note": "The change log is bounded by retention and compaction. Operations older than the floors above are no longer held by this instance.",
|
|
14042
|
+
},
|
|
14043
|
+
})))
|
|
14044
|
+
}
|
|
14045
|
+
|
|
14046
|
+
/// Peers, causal position, and what replication still owes.
|
|
14047
|
+
async fn control_plane_replication(
|
|
14048
|
+
State(state): State<AppState>,
|
|
14049
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14050
|
+
let db = state.db.clone();
|
|
14051
|
+
let namespace = state.namespace.to_string();
|
|
14052
|
+
let scan_namespace = namespace.clone();
|
|
14053
|
+
let (peers, positions, floors, instance_id) = tokio::task::spawn_blocking(move || {
|
|
14054
|
+
Ok::<_, FlowError>((
|
|
14055
|
+
db.peers_for_namespace(&scan_namespace)?,
|
|
14056
|
+
db.operation_versions()?,
|
|
14057
|
+
db.retained_operation_floors()?,
|
|
14058
|
+
db.instance_id()?,
|
|
14059
|
+
))
|
|
14060
|
+
})
|
|
14061
|
+
.await
|
|
14062
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14063
|
+
let metrics = state.metrics.snapshot();
|
|
14064
|
+
let membership = state.cluster.get();
|
|
14065
|
+
Ok(Json(json!({
|
|
14066
|
+
"local": {"instance_id": instance_id, "namespace": namespace},
|
|
14067
|
+
"membership": membership,
|
|
14068
|
+
"peers": peers,
|
|
14069
|
+
"causal_position": positions,
|
|
14070
|
+
"retained_operation_floors": floors,
|
|
14071
|
+
"health": {
|
|
14072
|
+
"peer_sync_successes": metrics.peer_sync_successes,
|
|
14073
|
+
"peer_sync_failures": metrics.peer_sync_failures,
|
|
14074
|
+
"operations_sent": metrics.sync_operations_sent,
|
|
14075
|
+
"operations_received": metrics.sync_operations_received,
|
|
14076
|
+
"operations_compacted": metrics.operations_compacted,
|
|
14077
|
+
},
|
|
14078
|
+
})))
|
|
14079
|
+
}
|
|
14080
|
+
|
|
14081
|
+
/// Applications this instance runs, and whether their runtimes are up.
|
|
14082
|
+
///
|
|
14083
|
+
/// Scoped by the caller's own tenant and application memberships, exactly as
|
|
14084
|
+
/// `GET /api/tenants/{id}/applications` is. Studio sees the applications its
|
|
14085
|
+
/// operator may see, not the applications the instance happens to hold.
|
|
14086
|
+
async fn control_plane_applications(
|
|
14087
|
+
State(state): State<AppState>,
|
|
14088
|
+
Extension(principal): Extension<Principal>,
|
|
14089
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14090
|
+
let mut applications = Vec::new();
|
|
14091
|
+
for tenant in state.tenancy.tenants_for(&principal.key_id) {
|
|
14092
|
+
for application in state
|
|
14093
|
+
.tenancy
|
|
14094
|
+
.applications_for(&principal.key_id, &tenant.id)
|
|
14095
|
+
{
|
|
14096
|
+
let pointers = state.applications.pointers(&application.id);
|
|
14097
|
+
let instances = state.runtimes.list(&tenant.id, &application.id);
|
|
14098
|
+
let environments = pointers
|
|
14099
|
+
.iter()
|
|
14100
|
+
.map(|(environment, revision_id)| {
|
|
14101
|
+
let instance = instances.iter().find(|instance| {
|
|
14102
|
+
instance.environment == *environment
|
|
14103
|
+
&& instance.revision_id == *revision_id
|
|
14104
|
+
});
|
|
14105
|
+
json!({
|
|
14106
|
+
"environment": environment,
|
|
14107
|
+
"revision_id": revision_id,
|
|
14108
|
+
"runtime": instance.map(|instance| json!({
|
|
14109
|
+
"runtime_id": instance.runtime_id,
|
|
14110
|
+
"status": instance.status,
|
|
14111
|
+
"state_namespace": instance.state_namespace,
|
|
14112
|
+
"contract_hash": instance.contract_hash,
|
|
14113
|
+
"started_at": instance.started_at,
|
|
14114
|
+
"started_by": instance.started_by,
|
|
14115
|
+
})),
|
|
14116
|
+
})
|
|
14117
|
+
})
|
|
14118
|
+
.collect::<Vec<_>>();
|
|
14119
|
+
applications.push(json!({
|
|
14120
|
+
"application_id": application.id,
|
|
14121
|
+
"application_name": application.name,
|
|
14122
|
+
"tenant_id": tenant.id,
|
|
14123
|
+
"tenant_name": tenant.name,
|
|
14124
|
+
"namespace": application.namespace,
|
|
14125
|
+
"status": application.status,
|
|
14126
|
+
"environments": environments,
|
|
14127
|
+
}));
|
|
14128
|
+
}
|
|
14129
|
+
}
|
|
14130
|
+
Ok(Json(json!({"applications": applications})))
|
|
14131
|
+
}
|
|
14132
|
+
|
|
14133
|
+
/// What a developer needs to talk to this instance from code.
|
|
14134
|
+
async fn control_plane_developer(
|
|
14135
|
+
State(state): State<AppState>,
|
|
14136
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
14137
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14138
|
+
let endpoint = state
|
|
14139
|
+
.advertise_url
|
|
14140
|
+
.as_ref()
|
|
14141
|
+
.map(|value| value.to_string())
|
|
14142
|
+
.unwrap_or_default();
|
|
14143
|
+
let application = scope.application_id.clone().unwrap_or_default();
|
|
14144
|
+
Ok(Json(json!({
|
|
14145
|
+
"endpoint": endpoint,
|
|
14146
|
+
"namespace": state.namespace.to_string(),
|
|
14147
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
14148
|
+
"authentication": if state.auth_enabled { "bearer-api-key" } else { "disabled" },
|
|
14149
|
+
"openapi": "/v1/openapi.json",
|
|
14150
|
+
"sdk": {
|
|
14151
|
+
"package": "@feltdb/core",
|
|
14152
|
+
"install": "npm install @feltdb/core",
|
|
14153
|
+
"snippet": "import { createFeltDB } from '@feltdb/core';\nconst db = createFeltDB({ namespace: 'NAMESPACE', remoteUrl: 'ENDPOINT', token: 'API_KEY' });",
|
|
14154
|
+
},
|
|
14155
|
+
"cli": {
|
|
14156
|
+
"package": "@feltdb/core",
|
|
14157
|
+
"snippet": "npx feltdb --url ENDPOINT --token API_KEY collections list",
|
|
14158
|
+
},
|
|
14159
|
+
"environment": {
|
|
14160
|
+
"FELTDB_URL": endpoint,
|
|
14161
|
+
"FELTDB_NAMESPACE": state.namespace.to_string(),
|
|
14162
|
+
"FELTDB_APPLICATION_ID": application,
|
|
14163
|
+
},
|
|
14164
|
+
})))
|
|
14165
|
+
}
|
|
14166
|
+
|
|
14167
|
+
// ---------------------------------------------------------------------------
|
|
14168
|
+
// Attribution: who caused a change
|
|
14169
|
+
// ---------------------------------------------------------------------------
|
|
14170
|
+
//
|
|
14171
|
+
// The invariant these routes exist to hold: **there is no invisible mutation**.
|
|
14172
|
+
// Whatever wrote a record — a person, an application, an agent, replication —
|
|
14173
|
+
// the same question is answerable about it, through the same durable model.
|
|
14174
|
+
|
|
14175
|
+
#[derive(Deserialize)]
|
|
14176
|
+
struct TransactionsQuery {
|
|
14177
|
+
#[serde(default)]
|
|
14178
|
+
subject: Option<String>,
|
|
14179
|
+
#[serde(default)]
|
|
14180
|
+
collection: Option<String>,
|
|
14181
|
+
#[serde(default)]
|
|
14182
|
+
limit: Option<usize>,
|
|
14183
|
+
}
|
|
14184
|
+
|
|
14185
|
+
const CONTROL_PLANE_TRANSACTION_LIMIT: usize = 200;
|
|
14186
|
+
|
|
14187
|
+
/// Recent commits and who made them.
|
|
14188
|
+
///
|
|
14189
|
+
/// The window is bounded and says so. An operator reading this must be able to
|
|
14190
|
+
/// tell "nothing happened" from "it happened before what this instance still
|
|
14191
|
+
/// holds in memory", so the window's size and occupancy are part of the answer.
|
|
14192
|
+
async fn control_plane_transactions(
|
|
14193
|
+
State(state): State<AppState>,
|
|
14194
|
+
Query(query): Query<TransactionsQuery>,
|
|
14195
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14196
|
+
let limit = query
|
|
14197
|
+
.limit
|
|
14198
|
+
.unwrap_or(50)
|
|
14199
|
+
.clamp(1, CONTROL_PLANE_TRANSACTION_LIMIT);
|
|
14200
|
+
let db = state.db.clone();
|
|
14201
|
+
let (attributions, window) = tokio::task::spawn_blocking(move || {
|
|
14202
|
+
Ok::<_, FlowError>((
|
|
14203
|
+
db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)?,
|
|
14204
|
+
db.transaction_attribution_window()?,
|
|
14205
|
+
))
|
|
14206
|
+
})
|
|
14207
|
+
.await
|
|
14208
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14209
|
+
let matched = attributions
|
|
14210
|
+
.into_iter()
|
|
14211
|
+
.filter(|attribution| {
|
|
14212
|
+
query
|
|
14213
|
+
.subject
|
|
14214
|
+
.as_deref()
|
|
14215
|
+
.is_none_or(|subject| attribution.subject.as_deref() == Some(subject))
|
|
14216
|
+
})
|
|
14217
|
+
.filter(|attribution| {
|
|
14218
|
+
query.collection.as_deref().is_none_or(|collection| {
|
|
14219
|
+
attribution.keys.iter().any(|key| {
|
|
14220
|
+
key.rsplit_once(':')
|
|
14221
|
+
.map(|(capability, _)| control_plane::collection_of(capability))
|
|
14222
|
+
== Some(collection)
|
|
14223
|
+
})
|
|
14224
|
+
})
|
|
14225
|
+
})
|
|
14226
|
+
.take(limit)
|
|
14227
|
+
.map(|attribution| serde_json::to_value(&attribution).unwrap_or(Value::Null))
|
|
14228
|
+
.collect::<Vec<_>>();
|
|
14229
|
+
Ok(Json(json!({
|
|
14230
|
+
"transactions": matched,
|
|
14231
|
+
"window": {
|
|
14232
|
+
"held": window.0,
|
|
14233
|
+
"capacity": window.1,
|
|
14234
|
+
"note": "Attribution is answered from a bounded in-memory window over recent commits. Commits older than the window are in the durable transaction log, which this route does not read.",
|
|
14235
|
+
},
|
|
14236
|
+
})))
|
|
14237
|
+
}
|
|
14238
|
+
|
|
14239
|
+
/// One transaction: who committed it, what it touched, and whether it applied.
|
|
14240
|
+
///
|
|
14241
|
+
/// Two different facts, deliberately both reported. *Applied* comes from the
|
|
14242
|
+
/// database's own idempotency record and is true for as long as the database
|
|
14243
|
+
/// holds the transaction id — it is what makes a retried commit a no-op rather
|
|
14244
|
+
/// than a second write. *Attribution* comes from the bounded recent-commit
|
|
14245
|
+
/// window. A transaction that applied and has no attribution is one that
|
|
14246
|
+
/// committed longer ago than the window keeps, which is a different thing from
|
|
14247
|
+
/// one that never committed, and collapsing the two would make a replayed
|
|
14248
|
+
/// transaction id read as new.
|
|
14249
|
+
async fn control_plane_transaction(
|
|
14250
|
+
State(state): State<AppState>,
|
|
14251
|
+
Path(transaction_id): Path<String>,
|
|
14252
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14253
|
+
let db = state.db.clone();
|
|
14254
|
+
let id = transaction_id.clone();
|
|
14255
|
+
let (applied, attribution) = tokio::task::spawn_blocking(move || {
|
|
14256
|
+
Ok::<_, FlowError>((
|
|
14257
|
+
db.has_applied_transaction(&id)?,
|
|
14258
|
+
db.transaction_attribution(&id)?,
|
|
14259
|
+
))
|
|
14260
|
+
})
|
|
14261
|
+
.await
|
|
14262
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14263
|
+
if !applied && attribution.is_none() {
|
|
14264
|
+
return Err(control_plane_error(
|
|
14265
|
+
StatusCode::NOT_FOUND,
|
|
14266
|
+
"transaction_not_found",
|
|
14267
|
+
"this instance has not applied a transaction with that id",
|
|
14268
|
+
));
|
|
14269
|
+
}
|
|
14270
|
+
Ok(Json(json!({
|
|
14271
|
+
"transaction_id": transaction_id,
|
|
14272
|
+
"applied": applied,
|
|
14273
|
+
"idempotent_replay": if applied {
|
|
14274
|
+
"A commit repeating this id with the same payload is a no-op; with a different payload it is refused."
|
|
14275
|
+
} else {
|
|
14276
|
+
"This instance holds no idempotency record for this id."
|
|
14277
|
+
},
|
|
14278
|
+
"attribution": attribution,
|
|
14279
|
+
"attribution_available": attribution.is_some(),
|
|
14280
|
+
})))
|
|
14281
|
+
}
|
|
14282
|
+
|
|
14283
|
+
/// Everything this instance can say about how one record came to be as it is.
|
|
14284
|
+
///
|
|
14285
|
+
/// Four sources, joined and labelled rather than merged: the row's own
|
|
14286
|
+
/// operation provenance, the transactions that named it, the state revisions of
|
|
14287
|
+
/// its resource, and — when the record belongs to an application — the agent or
|
|
14288
|
+
/// principal each transaction names. They answer different parts of the same
|
|
14289
|
+
/// question and disagree in informative ways, so none of them is collapsed into
|
|
14290
|
+
/// the others.
|
|
14291
|
+
async fn control_plane_record_provenance(
|
|
14292
|
+
State(state): State<AppState>,
|
|
14293
|
+
Extension(principal): Extension<Principal>,
|
|
14294
|
+
Path((collection, id)): Path<(String, String)>,
|
|
14295
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
14296
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14297
|
+
let capability = match scope.application_id.as_deref() {
|
|
14298
|
+
Some(application_id) => {
|
|
14299
|
+
let contract =
|
|
14300
|
+
active_inspection_contract(&state, &principal, application_id, &scope.environment)?;
|
|
14301
|
+
inspectable_collection(&contract, &collection)?;
|
|
14302
|
+
format!("{}:{}", contract.environment.state_namespace, collection)
|
|
14303
|
+
}
|
|
14304
|
+
// Without an application the record is a direct-authority record, whose
|
|
14305
|
+
// capability is the collection itself.
|
|
14306
|
+
None => {
|
|
14307
|
+
validate_segment(&collection)?;
|
|
14308
|
+
collection.clone()
|
|
14309
|
+
}
|
|
14310
|
+
};
|
|
14311
|
+
validate_segment(&id)?;
|
|
14312
|
+
// The two write paths key their rows differently, and the revision resource
|
|
14313
|
+
// is the row key in both cases. An application transaction stores
|
|
14314
|
+
// `{state_namespace}:{collection}` / `{id}`; a direct-authority write stores
|
|
14315
|
+
// `{collection}` / `{collection}:{id}`. Getting this wrong reads as "no such
|
|
14316
|
+
// record" rather than as a bug, so it is derived once, here.
|
|
14317
|
+
let row_key = if capability == collection {
|
|
14318
|
+
record_key(&collection, &id)?
|
|
14319
|
+
} else {
|
|
14320
|
+
id.clone()
|
|
14321
|
+
};
|
|
14322
|
+
let resource = row_key.clone();
|
|
14323
|
+
let db = state.db.clone();
|
|
14324
|
+
let lookup_capability = capability.clone();
|
|
14325
|
+
let lookup_id = row_key.clone();
|
|
14326
|
+
let lookup_resource = resource.clone();
|
|
14327
|
+
let (row, transactions, revisions) = tokio::task::spawn_blocking(move || {
|
|
14328
|
+
let store = feltdb::FeltDBStateSystem::create_store(&Arc::new(db.clone()))
|
|
14329
|
+
.map_err(FlowError::CapabilityError)?;
|
|
14330
|
+
Ok::<_, FlowError>((
|
|
14331
|
+
db.get_collection_record(&lookup_capability, &lookup_id)?,
|
|
14332
|
+
db.transactions_touching(&lookup_capability, &lookup_id, 20)?,
|
|
14333
|
+
store.history_of(&lookup_resource),
|
|
14334
|
+
))
|
|
14335
|
+
})
|
|
14336
|
+
.await
|
|
14337
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14338
|
+
let row = row.ok_or_else(|| {
|
|
14339
|
+
control_plane_error(
|
|
14340
|
+
StatusCode::NOT_FOUND,
|
|
14341
|
+
"record_not_found",
|
|
14342
|
+
"no record with that identity",
|
|
14343
|
+
)
|
|
14344
|
+
})?;
|
|
14345
|
+
Ok(Json(json!({
|
|
14346
|
+
"record": {"collection": collection, "id": id, "capability": capability, "key": row_key, "resource": resource},
|
|
14347
|
+
// What the storage row itself records: the operation that last wrote it.
|
|
14348
|
+
"operation": row.operation.as_ref().map(|operation| json!({
|
|
14349
|
+
"type": match operation.op_type {
|
|
14350
|
+
feltdb::OperationType::Insert => "insert",
|
|
14351
|
+
feltdb::OperationType::Update => "update",
|
|
14352
|
+
feltdb::OperationType::Delete => "delete",
|
|
14353
|
+
},
|
|
14354
|
+
"instance_id": operation.instance_id,
|
|
14355
|
+
"sequence": operation.sequence,
|
|
14356
|
+
"vector_clock": operation.vector_clock,
|
|
14357
|
+
"state_id": operation.revision.as_ref().map(|revision| revision.id.clone()),
|
|
14358
|
+
})),
|
|
14359
|
+
"content_hash": row.content_hash,
|
|
14360
|
+
"updated_ms": row.unix_ms,
|
|
14361
|
+
// Who asked for the changes: the actor each transaction authenticated as.
|
|
14362
|
+
"transactions": transactions,
|
|
14363
|
+
// What the state model recorded: the resource's revision chain, if the
|
|
14364
|
+
// write path that produced it mints revisions.
|
|
14365
|
+
"revisions": revisions.iter().map(control_plane::revision_summary_to_json).collect::<Vec<_>>(),
|
|
14366
|
+
"attribution": {
|
|
14367
|
+
"actor": transactions.first().and_then(|attribution| attribution.subject.clone()),
|
|
14368
|
+
"authority": row.operation.as_ref().map(|operation| operation.instance_id.clone()),
|
|
14369
|
+
"source": if transactions.first().is_some() { "transaction" } else if row.operation.is_some() { "authority" } else { "unknown" },
|
|
14370
|
+
},
|
|
14371
|
+
})))
|
|
14372
|
+
}
|
|
14373
|
+
|
|
14374
|
+
// ---------------------------------------------------------------------------
|
|
14375
|
+
// Agents
|
|
14376
|
+
// ---------------------------------------------------------------------------
|
|
14377
|
+
//
|
|
14378
|
+
// An agent is a principal. It is not a separate identity system, it does not
|
|
14379
|
+
// get a private write path, and its changes are the same operations,
|
|
14380
|
+
// transactions and revisions every other writer produces. What the control
|
|
14381
|
+
// plane adds is the human's view of it: what it may do, what it has done, and
|
|
14382
|
+
// what it is doing now.
|
|
14383
|
+
|
|
14384
|
+
/// What an agent principal may reach in an application's contract.
|
|
14385
|
+
///
|
|
14386
|
+
/// Computed from the two things that actually decide it: the capabilities the
|
|
14387
|
+
/// principal holds, and the actions and policies the contract declares. Nothing
|
|
14388
|
+
/// is inferred from the agent's name or history.
|
|
14389
|
+
fn agent_access(
|
|
14390
|
+
contract: &ApplicationRuntimeContract,
|
|
14391
|
+
capabilities: &[String],
|
|
14392
|
+
) -> Vec<Value> {
|
|
14393
|
+
let holds = |required: &[String]| required.iter().all(|value| capabilities.contains(value));
|
|
14394
|
+
contract
|
|
14395
|
+
.state
|
|
14396
|
+
.collections
|
|
14397
|
+
.iter()
|
|
14398
|
+
.map(|collection| {
|
|
14399
|
+
let policy = matching_state_policy(contract, &collection.name);
|
|
14400
|
+
let actions = contract
|
|
14401
|
+
.actions
|
|
14402
|
+
.definitions
|
|
14403
|
+
.iter()
|
|
14404
|
+
.filter(|action| action.collection == collection.name)
|
|
14405
|
+
.map(|action| {
|
|
14406
|
+
json!({
|
|
14407
|
+
"name": action.name,
|
|
14408
|
+
"operation": match action.operation {
|
|
14409
|
+
ActionOperation::Create => "create",
|
|
14410
|
+
ActionOperation::Update => "update",
|
|
14411
|
+
ActionOperation::Delete => "delete",
|
|
14412
|
+
},
|
|
14413
|
+
"required_capabilities": action.required_capabilities,
|
|
14414
|
+
"permitted": holds(&action.required_capabilities),
|
|
14415
|
+
})
|
|
14416
|
+
})
|
|
14417
|
+
.collect::<Vec<_>>();
|
|
14418
|
+
json!({
|
|
14419
|
+
"collection": collection.name,
|
|
14420
|
+
"read": {
|
|
14421
|
+
"policy": policy.and_then(|value| value.read.clone()),
|
|
14422
|
+
"capabilities": policy.map(|value| value.capabilities.clone()).unwrap_or_default(),
|
|
14423
|
+
},
|
|
14424
|
+
"write": {
|
|
14425
|
+
"policy": policy.and_then(|value| value.write.clone()),
|
|
14426
|
+
"actions": actions,
|
|
14427
|
+
},
|
|
14428
|
+
})
|
|
14429
|
+
})
|
|
14430
|
+
.collect()
|
|
14431
|
+
}
|
|
14432
|
+
|
|
14433
|
+
fn principal_kind_name(kind: &PrincipalKind) -> &'static str {
|
|
14434
|
+
match kind {
|
|
14435
|
+
PrincipalKind::Agent => "agent",
|
|
14436
|
+
PrincipalKind::Workload => "workload",
|
|
14437
|
+
PrincipalKind::Service => "service",
|
|
14438
|
+
PrincipalKind::Worker => "worker",
|
|
14439
|
+
PrincipalKind::Connection => "connection",
|
|
14440
|
+
}
|
|
14441
|
+
}
|
|
14442
|
+
|
|
14443
|
+
/// Agents registered against an application, with what each has been doing.
|
|
14444
|
+
async fn control_plane_agents(
|
|
14445
|
+
State(state): State<AppState>,
|
|
14446
|
+
Extension(principal): Extension<Principal>,
|
|
14447
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
14448
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14449
|
+
let application_id = scope.application_id.clone().ok_or_else(|| {
|
|
14450
|
+
control_plane_error(
|
|
14451
|
+
StatusCode::BAD_REQUEST,
|
|
14452
|
+
"application_required",
|
|
14453
|
+
"application_id is required",
|
|
14454
|
+
)
|
|
14455
|
+
})?;
|
|
14456
|
+
let principals = state
|
|
14457
|
+
.principals
|
|
14458
|
+
.list(&principal.key_id, &state.tenancy, &application_id)
|
|
14459
|
+
.map_err(control_error)?;
|
|
14460
|
+
let db = state.db.clone();
|
|
14461
|
+
let attributions = tokio::task::spawn_blocking(move || {
|
|
14462
|
+
db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)
|
|
14463
|
+
})
|
|
14464
|
+
.await
|
|
14465
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14466
|
+
let agents = principals
|
|
14467
|
+
.iter()
|
|
14468
|
+
.filter(|record| record.kind == PrincipalKind::Agent)
|
|
14469
|
+
.map(|agent| {
|
|
14470
|
+
let sessions = principals
|
|
14471
|
+
.iter()
|
|
14472
|
+
.filter(|record| record.parent_id.as_deref() == Some(agent.id.as_str()))
|
|
14473
|
+
.collect::<Vec<_>>();
|
|
14474
|
+
let commits = attributions
|
|
14475
|
+
.iter()
|
|
14476
|
+
.filter(|attribution| attribution.subject.as_deref() == Some(agent.id.as_str()))
|
|
14477
|
+
.collect::<Vec<_>>();
|
|
14478
|
+
json!({
|
|
14479
|
+
"agent_id": agent.id,
|
|
14480
|
+
"name": agent.name,
|
|
14481
|
+
"status": agent.status,
|
|
14482
|
+
"capabilities": agent.capabilities,
|
|
14483
|
+
"created_by": agent.created_by,
|
|
14484
|
+
"created_at": agent.created_at,
|
|
14485
|
+
"version": agent.version,
|
|
14486
|
+
"application_id": agent.application_id,
|
|
14487
|
+
"sessions": {
|
|
14488
|
+
"total": sessions.len(),
|
|
14489
|
+
"running": sessions
|
|
14490
|
+
.iter()
|
|
14491
|
+
.filter(|record| record.status == PrincipalStatus::Running)
|
|
14492
|
+
.count(),
|
|
14493
|
+
},
|
|
14494
|
+
"activity": {
|
|
14495
|
+
"recent_transactions": commits.len(),
|
|
14496
|
+
"records_changed": commits.iter().map(|attribution| attribution.operations).sum::<usize>(),
|
|
14497
|
+
"last_activity_ms": commits.first().map(|attribution| attribution.unix_ms as u64),
|
|
14498
|
+
},
|
|
14499
|
+
})
|
|
14500
|
+
})
|
|
14501
|
+
.collect::<Vec<_>>();
|
|
14502
|
+
Ok(Json(json!({
|
|
14503
|
+
"application": {"application_id": application_id, "environment": scope.environment},
|
|
14504
|
+
"agents": agents,
|
|
14505
|
+
})))
|
|
14506
|
+
}
|
|
14507
|
+
|
|
14508
|
+
/// One agent: identity, access, what it has done, what it is doing now.
|
|
14509
|
+
async fn control_plane_agent(
|
|
14510
|
+
State(state): State<AppState>,
|
|
14511
|
+
Extension(principal): Extension<Principal>,
|
|
14512
|
+
Path(agent_id): Path<String>,
|
|
14513
|
+
Query(scope): Query<ControlPlaneScope>,
|
|
14514
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14515
|
+
let application_id = scope.application_id.clone().ok_or_else(|| {
|
|
14516
|
+
control_plane_error(
|
|
14517
|
+
StatusCode::BAD_REQUEST,
|
|
14518
|
+
"application_required",
|
|
14519
|
+
"application_id is required",
|
|
14520
|
+
)
|
|
14521
|
+
})?;
|
|
14522
|
+
let principals = state
|
|
14523
|
+
.principals
|
|
14524
|
+
.list(&principal.key_id, &state.tenancy, &application_id)
|
|
14525
|
+
.map_err(control_error)?;
|
|
14526
|
+
let agent = principals
|
|
14527
|
+
.iter()
|
|
14528
|
+
.find(|record| record.id == agent_id && record.kind == PrincipalKind::Agent)
|
|
14529
|
+
.ok_or_else(|| {
|
|
14530
|
+
control_plane_error(
|
|
14531
|
+
StatusCode::NOT_FOUND,
|
|
14532
|
+
"agent_not_found",
|
|
14533
|
+
"no agent principal with that identity in this application",
|
|
14534
|
+
)
|
|
14535
|
+
})?;
|
|
14536
|
+
let contract =
|
|
14537
|
+
active_inspection_contract(&state, &principal, &application_id, &scope.environment)?;
|
|
14538
|
+
let db = state.db.clone();
|
|
14539
|
+
let attributions = tokio::task::spawn_blocking(move || {
|
|
14540
|
+
db.recent_transactions(CONTROL_PLANE_TRANSACTION_LIMIT)
|
|
14541
|
+
})
|
|
14542
|
+
.await
|
|
14543
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))??;
|
|
14544
|
+
let commits = attributions
|
|
14545
|
+
.iter()
|
|
14546
|
+
.filter(|attribution| attribution.subject.as_deref() == Some(agent.id.as_str()))
|
|
14547
|
+
.collect::<Vec<_>>();
|
|
14548
|
+
let sessions = principals
|
|
14549
|
+
.iter()
|
|
14550
|
+
.filter(|record| record.parent_id.as_deref() == Some(agent.id.as_str()))
|
|
14551
|
+
.map(|session| {
|
|
14552
|
+
json!({
|
|
14553
|
+
"session_id": session.id,
|
|
14554
|
+
"kind": principal_kind_name(&session.kind),
|
|
14555
|
+
"name": session.name,
|
|
14556
|
+
"status": session.status,
|
|
14557
|
+
"capabilities": session.capabilities,
|
|
14558
|
+
"worker_id": session.worker_id,
|
|
14559
|
+
"started_at": session.started_at,
|
|
14560
|
+
"completed_at": session.completed_at,
|
|
14561
|
+
"created_by": session.created_by,
|
|
14562
|
+
})
|
|
14563
|
+
})
|
|
14564
|
+
.collect::<Vec<_>>();
|
|
14565
|
+
let tenant = state
|
|
14566
|
+
.tenancy
|
|
14567
|
+
.application_tenant(&application_id)
|
|
14568
|
+
.unwrap_or_default();
|
|
14569
|
+
let work = state
|
|
14570
|
+
.workloads
|
|
14571
|
+
.lock()
|
|
14572
|
+
.map_err(|_| {
|
|
14573
|
+
ApiError(
|
|
14574
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
14575
|
+
"workload store unavailable".into(),
|
|
14576
|
+
)
|
|
14577
|
+
})?
|
|
14578
|
+
.list(&tenant, &application_id)
|
|
14579
|
+
.into_iter()
|
|
14580
|
+
.filter(|workload| {
|
|
14581
|
+
serde_json::to_value(&workload.requested_by)
|
|
14582
|
+
.map(|value| value.to_string().contains(&agent.id))
|
|
14583
|
+
.unwrap_or(false)
|
|
14584
|
+
})
|
|
14585
|
+
.map(|workload| {
|
|
14586
|
+
json!({
|
|
14587
|
+
"workload_id": workload.workload_id,
|
|
14588
|
+
"definition_id": workload.definition_id,
|
|
14589
|
+
"state": workload.state,
|
|
14590
|
+
"trigger": workload.trigger,
|
|
14591
|
+
"attempts": workload.attempt_count,
|
|
14592
|
+
"created_at": workload.created_at,
|
|
14593
|
+
"updated_at": workload.updated_at,
|
|
14594
|
+
"next_attempt_at": workload.next_attempt_at,
|
|
14595
|
+
"last_failure": workload.last_failure,
|
|
14596
|
+
"capabilities": workload.capability_snapshot,
|
|
14597
|
+
})
|
|
14598
|
+
})
|
|
14599
|
+
.collect::<Vec<_>>();
|
|
14600
|
+
let decisions = state
|
|
14601
|
+
.principals
|
|
14602
|
+
.audit_for(
|
|
14603
|
+
&principal.key_id,
|
|
14604
|
+
&state.tenancy,
|
|
14605
|
+
&application_id,
|
|
14606
|
+
Some(&agent.id),
|
|
14607
|
+
50,
|
|
14608
|
+
)
|
|
14609
|
+
.map_err(control_error)?;
|
|
14610
|
+
Ok(Json(json!({
|
|
14611
|
+
"identity": {
|
|
14612
|
+
"agent_id": agent.id,
|
|
14613
|
+
"name": agent.name,
|
|
14614
|
+
"kind": principal_kind_name(&agent.kind),
|
|
14615
|
+
"status": agent.status,
|
|
14616
|
+
"application_id": agent.application_id,
|
|
14617
|
+
"environment": scope.environment,
|
|
14618
|
+
"application_revision": contract.revision_id,
|
|
14619
|
+
"created_by": agent.created_by,
|
|
14620
|
+
"created_at": agent.created_at,
|
|
14621
|
+
"version": agent.version,
|
|
14622
|
+
},
|
|
14623
|
+
"access": {
|
|
14624
|
+
"capabilities": agent.capabilities,
|
|
14625
|
+
"collections": agent_access(&contract, &agent.capabilities),
|
|
14626
|
+
},
|
|
14627
|
+
// Every change this agent made, as the same durable facts every other
|
|
14628
|
+
// writer produces: a transaction, the records it named, and the state
|
|
14629
|
+
// revisions those records carry.
|
|
14630
|
+
"did": {
|
|
14631
|
+
"transactions": commits,
|
|
14632
|
+
"records_changed": commits.iter().map(|attribution| attribution.operations).sum::<usize>(),
|
|
14633
|
+
"last_activity_ms": commits.first().map(|attribution| attribution.unix_ms as u64),
|
|
14634
|
+
},
|
|
14635
|
+
"doing": {
|
|
14636
|
+
"sessions": sessions,
|
|
14637
|
+
"work": work,
|
|
14638
|
+
},
|
|
14639
|
+
"authorization_decisions": decisions,
|
|
14640
|
+
})))
|
|
14641
|
+
}
|
|
14642
|
+
|
|
14643
|
+
// ---------------------------------------------------------------------------
|
|
14644
|
+
// State: history, diff, conflict, reconciliation
|
|
14645
|
+
// ---------------------------------------------------------------------------
|
|
14646
|
+
|
|
14647
|
+
#[derive(Deserialize)]
|
|
14648
|
+
struct StateResourceQuery {
|
|
14649
|
+
#[serde(default)]
|
|
14650
|
+
prefix: Option<String>,
|
|
14651
|
+
#[serde(default)]
|
|
14652
|
+
limit: Option<usize>,
|
|
14653
|
+
}
|
|
14654
|
+
|
|
14655
|
+
/// Every resource with history, and the newest revision of each.
|
|
14656
|
+
async fn state_resources(
|
|
14657
|
+
State(state): State<AppState>,
|
|
14658
|
+
Query(query): Query<StateResourceQuery>,
|
|
14659
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14660
|
+
let store = control_plane_state_store(&state)?;
|
|
14661
|
+
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
|
14662
|
+
let prefix = query.prefix.unwrap_or_default();
|
|
14663
|
+
let names = tokio::task::spawn_blocking(move || {
|
|
14664
|
+
let names: Vec<String> = store
|
|
14665
|
+
.resources()
|
|
14666
|
+
.into_iter()
|
|
14667
|
+
.filter(|resource| resource.starts_with(&prefix))
|
|
14668
|
+
.take(limit)
|
|
14669
|
+
.collect();
|
|
14670
|
+
names
|
|
14671
|
+
.into_iter()
|
|
14672
|
+
.map(|resource| {
|
|
14673
|
+
let history = store.history_of(&resource);
|
|
14674
|
+
let head = store.head_of(&resource);
|
|
14675
|
+
json!({
|
|
14676
|
+
"resource": resource,
|
|
14677
|
+
"revisions": history.len(),
|
|
14678
|
+
"head": head.as_ref().map(control_plane::revision_summary_to_json),
|
|
14679
|
+
})
|
|
14680
|
+
})
|
|
14681
|
+
.collect::<Vec<_>>()
|
|
14682
|
+
})
|
|
14683
|
+
.await
|
|
14684
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
14685
|
+
Ok(Json(json!({"resources": names})))
|
|
14686
|
+
}
|
|
14687
|
+
|
|
14688
|
+
/// One resource's history, oldest first, with its retention horizon.
|
|
14689
|
+
///
|
|
14690
|
+
/// The horizon is reported because it is the difference between "this is where
|
|
14691
|
+
/// the resource began" and "this is as far back as we still keep".
|
|
14692
|
+
async fn state_resource_history(
|
|
14693
|
+
State(state): State<AppState>,
|
|
14694
|
+
Path(resource): Path<String>,
|
|
14695
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14696
|
+
let store = control_plane_state_store(&state)?;
|
|
14697
|
+
let document = tokio::task::spawn_blocking(move || {
|
|
14698
|
+
let history = store.history_of(&resource);
|
|
14699
|
+
json!({
|
|
14700
|
+
"resource": resource,
|
|
14701
|
+
"retention": {
|
|
14702
|
+
"keep_last": store.retention_policy(&resource).keep_last,
|
|
14703
|
+
"horizon": store.retention_horizon(&resource),
|
|
14704
|
+
},
|
|
14705
|
+
"revisions": history.iter().map(control_plane::revision_summary_to_json).collect::<Vec<_>>(),
|
|
14706
|
+
})
|
|
14707
|
+
})
|
|
14708
|
+
.await
|
|
14709
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
14710
|
+
Ok(Json(document))
|
|
14711
|
+
}
|
|
14712
|
+
|
|
14713
|
+
/// One revision, with its content and what its parent link found.
|
|
14714
|
+
async fn state_revision(
|
|
14715
|
+
State(state): State<AppState>,
|
|
14716
|
+
Path(id): Path<String>,
|
|
14717
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14718
|
+
let store = control_plane_state_store(&state)?;
|
|
14719
|
+
let revision = control_plane_revision(&store, "requested", &id)?;
|
|
14720
|
+
let parent = match store.parent_of(&revision.id) {
|
|
14721
|
+
None | Some(feltdb::state_model::ParentLookup::Root) => json!({"status": "root"}),
|
|
14722
|
+
Some(feltdb::state_model::ParentLookup::Revision(parent)) => json!({
|
|
14723
|
+
"status": "retained",
|
|
14724
|
+
"revision": control_plane::revision_summary_to_json(&parent),
|
|
14725
|
+
}),
|
|
14726
|
+
Some(feltdb::state_model::ParentLookup::Expired(id)) => {
|
|
14727
|
+
json!({"status": "expired", "state_id": id.as_hex()})
|
|
14728
|
+
}
|
|
14729
|
+
Some(feltdb::state_model::ParentLookup::Missing(id)) => {
|
|
14730
|
+
json!({"status": "missing", "state_id": id.as_hex()})
|
|
14731
|
+
}
|
|
14732
|
+
};
|
|
14733
|
+
let mut document =
|
|
14734
|
+
control_plane::revision_to_json(&revision, control_plane_revision_content(&revision));
|
|
14735
|
+
if let Some(object) = document.as_object_mut() {
|
|
14736
|
+
object.insert("parent".into(), parent);
|
|
14737
|
+
}
|
|
14738
|
+
Ok(Json(document))
|
|
14739
|
+
}
|
|
14740
|
+
|
|
14741
|
+
#[derive(Deserialize)]
|
|
14742
|
+
struct StateDiffRequest {
|
|
14743
|
+
from: String,
|
|
14744
|
+
to: String,
|
|
14745
|
+
}
|
|
14746
|
+
|
|
14747
|
+
/// The semantic difference between two revisions.
|
|
14748
|
+
async fn state_diff(
|
|
14749
|
+
State(state): State<AppState>,
|
|
14750
|
+
Json(request): Json<StateDiffRequest>,
|
|
14751
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14752
|
+
let store = control_plane_state_store(&state)?;
|
|
14753
|
+
let from = control_plane_revision(&store, "from", &request.from)?;
|
|
14754
|
+
let to = control_plane_revision(&store, "to", &request.to)?;
|
|
14755
|
+
let diff = feltdb::SemanticDiff::compute(
|
|
14756
|
+
&serde_json::from_str(&from.content).unwrap_or(Value::Null),
|
|
14757
|
+
&serde_json::from_str(&to.content).unwrap_or(Value::Null),
|
|
14758
|
+
);
|
|
14759
|
+
let mut document = control_plane::diff_to_json(&diff);
|
|
14760
|
+
if let Some(object) = document.as_object_mut() {
|
|
14761
|
+
object.insert(
|
|
14762
|
+
"from".into(),
|
|
14763
|
+
control_plane::revision_summary_to_json(&from),
|
|
14764
|
+
);
|
|
14765
|
+
object.insert("to".into(), control_plane::revision_summary_to_json(&to));
|
|
14766
|
+
object.insert(
|
|
14767
|
+
"relationship".into(),
|
|
14768
|
+
json!(match (from.resource == to.resource, to.parent_id.as_ref() == Some(&from.id)) {
|
|
14769
|
+
(true, true) => "parent",
|
|
14770
|
+
(true, false) => "same-resource",
|
|
14771
|
+
(false, _) => "cross-resource",
|
|
14772
|
+
}),
|
|
14773
|
+
);
|
|
14774
|
+
}
|
|
14775
|
+
Ok(Json(document))
|
|
14776
|
+
}
|
|
14777
|
+
|
|
14778
|
+
#[derive(Deserialize)]
|
|
14779
|
+
struct StateConflictRequest {
|
|
14780
|
+
base: String,
|
|
14781
|
+
left: String,
|
|
14782
|
+
right: String,
|
|
14783
|
+
}
|
|
14784
|
+
|
|
14785
|
+
/// How two branches of one resource interact, path by path.
|
|
14786
|
+
async fn state_conflicts(
|
|
14787
|
+
State(state): State<AppState>,
|
|
14788
|
+
Json(request): Json<StateConflictRequest>,
|
|
14789
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14790
|
+
let store = control_plane_state_store(&state)?;
|
|
14791
|
+
let base = control_plane_revision(&store, "base", &request.base)?;
|
|
14792
|
+
let left = control_plane_revision(&store, "left", &request.left)?;
|
|
14793
|
+
let right = control_plane_revision(&store, "right", &request.right)?;
|
|
14794
|
+
let classification = feltdb::ConflictClassification::classify(&base, &left, &right);
|
|
14795
|
+
let mut document = control_plane::classification_to_json(&classification);
|
|
14796
|
+
if let Some(object) = document.as_object_mut() {
|
|
14797
|
+
object.insert(
|
|
14798
|
+
"revisions".into(),
|
|
14799
|
+
json!({
|
|
14800
|
+
"base": control_plane::revision_summary_to_json(&base),
|
|
14801
|
+
"left": control_plane::revision_summary_to_json(&left),
|
|
14802
|
+
"right": control_plane::revision_summary_to_json(&right),
|
|
14803
|
+
}),
|
|
14804
|
+
);
|
|
14805
|
+
}
|
|
14806
|
+
Ok(Json(document))
|
|
14807
|
+
}
|
|
14808
|
+
|
|
14809
|
+
/// Loads the three revisions a reconciliation plan names.
|
|
14810
|
+
fn control_plane_reconciliation_inputs(
|
|
14811
|
+
store: &feltdb::StateStore,
|
|
14812
|
+
request: &control_plane::ReconciliationPlanRequest,
|
|
14813
|
+
) -> Result<(StateRevision, StateRevision, StateRevision, feltdb::ReconciliationPlan), ApiError> {
|
|
14814
|
+
let plan = request.to_plan().map_err(|message| {
|
|
14815
|
+
control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_plan", message)
|
|
14816
|
+
})?;
|
|
14817
|
+
Ok((
|
|
14818
|
+
control_plane_revision(store, "base", &request.base)?,
|
|
14819
|
+
control_plane_revision(store, "left", &request.left)?,
|
|
14820
|
+
control_plane_revision(store, "right", &request.right)?,
|
|
14821
|
+
plan,
|
|
14822
|
+
))
|
|
14823
|
+
}
|
|
14824
|
+
|
|
14825
|
+
/// What this plan would produce, without producing it.
|
|
14826
|
+
///
|
|
14827
|
+
/// Pure: `apply_reconciliation_plan` reads no clock and writes nothing, so a
|
|
14828
|
+
/// human can see the resulting state before deciding to commit it.
|
|
14829
|
+
async fn state_reconciliation_plan(
|
|
14830
|
+
State(state): State<AppState>,
|
|
14831
|
+
Json(request): Json<control_plane::ReconciliationPlanRequest>,
|
|
14832
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14833
|
+
let store = control_plane_state_store(&state)?;
|
|
14834
|
+
let (base, left, right, plan) = control_plane_reconciliation_inputs(&store, &request)?;
|
|
14835
|
+
let classification = feltdb::ConflictClassification::classify(&base, &left, &right);
|
|
14836
|
+
let preview = feltdb::apply_reconciliation_plan(&base, &left, &right, &plan).map_err(
|
|
14837
|
+
|message| {
|
|
14838
|
+
control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "plan_refused", message)
|
|
14839
|
+
},
|
|
14840
|
+
)?;
|
|
14841
|
+
let mut content = serde_json::from_str::<Value>(&preview).unwrap_or(Value::Null);
|
|
14842
|
+
redact_inspection_value(&mut content);
|
|
14843
|
+
Ok(Json(json!({
|
|
14844
|
+
"plan": control_plane::plan_to_json(&plan),
|
|
14845
|
+
"classification": control_plane::classification_to_json(&classification),
|
|
14846
|
+
"preview": {
|
|
14847
|
+
"content": content,
|
|
14848
|
+
"content_id": feltdb::StateId::compute(&preview).as_hex(),
|
|
14849
|
+
"parent": control_plane::revision_summary_to_json(plan.selected_parent(&left, &right)),
|
|
14850
|
+
},
|
|
14851
|
+
"unresolved": classification
|
|
14852
|
+
.path_conflicts
|
|
14853
|
+
.iter()
|
|
14854
|
+
.filter(|conflict| matches!(conflict.classification, feltdb::ConflictClass::Conflict))
|
|
14855
|
+
.filter(|conflict| !plan.path_overrides.contains_key(&conflict.path))
|
|
14856
|
+
.map(|conflict| control_plane::path_to_json(&conflict.path))
|
|
14857
|
+
.collect::<Vec<_>>(),
|
|
14858
|
+
})))
|
|
14859
|
+
}
|
|
14860
|
+
|
|
14861
|
+
/// Applies a plan and commits the revision it produces.
|
|
14862
|
+
///
|
|
14863
|
+
/// The result is committed to the resource's history like any other revision:
|
|
14864
|
+
/// reconciliation produces history, it does not rewrite it.
|
|
14865
|
+
async fn state_reconciliation_execute(
|
|
14866
|
+
State(state): State<AppState>,
|
|
14867
|
+
Extension(principal): Extension<Principal>,
|
|
14868
|
+
Json(request): Json<control_plane::ReconciliationPlanRequest>,
|
|
14869
|
+
) -> Result<Json<Value>, ApiError> {
|
|
14870
|
+
let store = control_plane_state_store(&state)?;
|
|
14871
|
+
let (base, left, right, plan) = control_plane_reconciliation_inputs(&store, &request)?;
|
|
14872
|
+
let authority = format!("studio:{}", principal.key_id);
|
|
14873
|
+
let outcome = feltdb::reconcile(&base, &left, &right, &plan, authority).map_err(|message| {
|
|
14874
|
+
control_plane_error(StatusCode::UNPROCESSABLE_ENTITY, "plan_refused", message)
|
|
14875
|
+
})?;
|
|
14876
|
+
// `reconcile` produces the revision; the store is what makes it durable.
|
|
14877
|
+
// Committing its content through the store's own `commit` rather than
|
|
14878
|
+
// inserting the value directly keeps one minting path, so the revision a
|
|
14879
|
+
// human sees in history was created the way every other revision was.
|
|
14880
|
+
let parent = plan.selected_parent(&left, &right).clone();
|
|
14881
|
+
let committed = store
|
|
14882
|
+
.commit(
|
|
14883
|
+
outcome.materialized_state.content.clone(),
|
|
14884
|
+
&parent,
|
|
14885
|
+
outcome.materialized_state.authority.clone(),
|
|
14886
|
+
)
|
|
14887
|
+
.map_err(|error| {
|
|
14888
|
+
control_plane_error(
|
|
14889
|
+
StatusCode::CONFLICT,
|
|
14890
|
+
"revision_refused",
|
|
14891
|
+
error.to_string(),
|
|
14892
|
+
)
|
|
14893
|
+
})?;
|
|
14894
|
+
// The two must agree. They are computed by the same constructor from the
|
|
14895
|
+
// same content, parent and authority, so a disagreement means the store and
|
|
14896
|
+
// `reconcile` have drifted apart — which would put a revision in history
|
|
14897
|
+
// that is not the one the plan was previewed as producing.
|
|
14898
|
+
if committed.id != outcome.materialized_state.id {
|
|
14899
|
+
return Err(control_plane_error(
|
|
14900
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
14901
|
+
"revision_identity_mismatch",
|
|
14902
|
+
"the committed revision is not the revision the plan produced",
|
|
14903
|
+
));
|
|
14904
|
+
}
|
|
14905
|
+
audit(
|
|
14906
|
+
&state,
|
|
14907
|
+
&principal.key_id,
|
|
14908
|
+
"state:reconcile",
|
|
14909
|
+
&committed.resource,
|
|
14910
|
+
"allowed",
|
|
14911
|
+
200,
|
|
14912
|
+
);
|
|
14913
|
+
Ok(Json(json!({
|
|
14914
|
+
"plan": control_plane::plan_to_json(&plan),
|
|
14915
|
+
"revision": control_plane::revision_to_json(
|
|
14916
|
+
&committed,
|
|
14917
|
+
control_plane_revision_content(&committed),
|
|
14918
|
+
),
|
|
14919
|
+
})))
|
|
14920
|
+
}
|
|
14921
|
+
|
|
13557
14922
|
fn record_key(collection: &str, id: &str) -> Result<String, ApiError> {
|
|
13558
14923
|
validate_segment(collection)?;
|
|
13559
14924
|
validate_segment(id)?;
|
|
@@ -13658,8 +15023,8 @@ mod worker_identity_tests {
|
|
|
13658
15023
|
|
|
13659
15024
|
#[cfg(test)]
|
|
13660
15025
|
mod authority_gate_tests {
|
|
13661
|
-
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|
|
13662
|
-
use axum::http::StatusCode;
|
|
15026
|
+
use super::{api_error_body, authorize_transaction_collections, require_bound_tenant, state_authorization};
|
|
15027
|
+
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
|
13663
15028
|
use feltdb::{
|
|
13664
15029
|
application::{
|
|
13665
15030
|
manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
|
|
@@ -13767,6 +15132,17 @@ mod authority_gate_tests {
|
|
|
13767
15132
|
assert!(!allowed);
|
|
13768
15133
|
}
|
|
13769
15134
|
|
|
15135
|
+
#[test]
|
|
15136
|
+
fn bound_tenant_header_fails_closed_on_mismatch() {
|
|
15137
|
+
let mut headers = HeaderMap::new();
|
|
15138
|
+
headers.insert("feltdb-tenant-id", HeaderValue::from_static("tenant-a"));
|
|
15139
|
+
assert!(require_bound_tenant(&headers, "tenant-a").is_ok());
|
|
15140
|
+
let error = require_bound_tenant(&headers, "tenant-b").unwrap_err();
|
|
15141
|
+
assert_eq!(error.0, StatusCode::FORBIDDEN);
|
|
15142
|
+
let body: serde_json::Value = serde_json::from_str(&error.1).unwrap();
|
|
15143
|
+
assert_eq!(body.get("code").and_then(serde_json::Value::as_str), Some("AUTHORITY_SCOPE_MISMATCH"));
|
|
15144
|
+
}
|
|
15145
|
+
|
|
13770
15146
|
#[test]
|
|
13771
15147
|
fn every_forbidden_error_uses_the_canonical_wire_contract() {
|
|
13772
15148
|
for internal_message in [
|