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