@feltdb/core 0.13.0 → 0.16.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/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +68 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +210 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +12 -4
- package/dist/cli/commands.js +214 -10
- package/dist/cli/publish-engine.js +116 -16
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +33 -8
- package/dist/create/build-identity.json +12 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/managed-account.js +93 -35
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +8 -511
- package/dist/create/server-source/Cargo.toml +2 -13
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1134 -96
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
- package/dist/db.d.ts +75 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +168 -29
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +36 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +50 -3
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +43 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +287 -59
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/managed-recovery.d.ts +6 -0
- package/dist/managed-recovery.d.ts.map +1 -1
- package/dist/managed-recovery.js +9 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-DtybUY0q.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DsgIUwaB.wasm +0 -0
- package/dist/studio-app/assets/index-C4OffsZl.js +37 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +86 -6
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +51 -3
- package/dist/workspace/workspace-connection.d.ts.map +1 -1
- package/dist/workspace/workspace-connection.js +14 -3
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
- package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
- package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
- package/dist/create/server-source/crates/feltdb/README.md +0 -75
- package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
- package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
- package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
- package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
- package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
- package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
- package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
- package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
- package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
- package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
- package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
- package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
- package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7274
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
- package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
- package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
- package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
- package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
- package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
- package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
- package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
- package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -5894
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
- package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
- package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
- package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
- package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
- package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1133
- package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
- package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
- package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
- package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
- package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -2886
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
- package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
- package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
- package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
- package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
- package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
- package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
|
@@ -88,9 +88,11 @@ use feltdb_server::{
|
|
|
88
88
|
cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
|
|
89
89
|
connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
|
|
90
90
|
content::ContentStore,
|
|
91
|
+
control_authorization::{self, PrincipalClass},
|
|
92
|
+
console_accounts::{self, ConsoleAccountStore},
|
|
91
93
|
control_plane,
|
|
92
94
|
identity::IdentityStore,
|
|
93
|
-
key_management::{create_key, list_keys, revoke_key},
|
|
95
|
+
key_management::{create_key, list_keys, revoke_key, rotate_key},
|
|
94
96
|
key_provider::provider_from_environment,
|
|
95
97
|
leases::LeaseStore,
|
|
96
98
|
metrics::Metrics,
|
|
@@ -200,11 +202,13 @@ struct HealthResponse<'a> {
|
|
|
200
202
|
public_contract: &'a str,
|
|
201
203
|
capability_fingerprint: String,
|
|
202
204
|
runtime: &'a str,
|
|
205
|
+
/// Replay outcome of the durable log at open (`clean` or
|
|
206
|
+
/// `recovered-incomplete-write`). Liveness only: it is not a probe of the
|
|
207
|
+
/// volume now and not a durability claim — see `/ready` and `/v1/status`.
|
|
203
208
|
storage: &'a str,
|
|
204
|
-
|
|
209
|
+
/// Configuration, not a probe: whether this process runs the autonomous
|
|
210
|
+
/// worker.
|
|
205
211
|
execution: &'a str,
|
|
206
|
-
workflows: &'a str,
|
|
207
|
-
agents: &'a str,
|
|
208
212
|
membership: &'a str,
|
|
209
213
|
}
|
|
210
214
|
|
|
@@ -1090,12 +1094,22 @@ async fn push_sync(
|
|
|
1090
1094
|
});
|
|
1091
1095
|
continue;
|
|
1092
1096
|
}
|
|
1093
|
-
|
|
1097
|
+
// The production contract, never one the client names: with nothing promoted there is
|
|
1098
|
+
// no contract to write under.
|
|
1099
|
+
let Some(target_revision) = state
|
|
1094
1100
|
.applications
|
|
1095
1101
|
.pointers(&operation.application_id)
|
|
1096
1102
|
.get("production")
|
|
1097
1103
|
.cloned()
|
|
1098
|
-
|
|
1104
|
+
else {
|
|
1105
|
+
rejections.push(SyncRejection {
|
|
1106
|
+
operation_id: operation.operation_id,
|
|
1107
|
+
code: "SYNC_SCHEMA_MISMATCH".into(),
|
|
1108
|
+
message: "application environment has no active contract".into(),
|
|
1109
|
+
retryable: false,
|
|
1110
|
+
});
|
|
1111
|
+
continue;
|
|
1112
|
+
};
|
|
1099
1113
|
let Some(revision) = state.applications.revision(
|
|
1100
1114
|
&operation.tenant_id,
|
|
1101
1115
|
&operation.application_id,
|
|
@@ -1224,6 +1238,24 @@ async fn push_sync(
|
|
|
1224
1238
|
.acquire()
|
|
1225
1239
|
.await
|
|
1226
1240
|
.expect("transaction admission is never closed");
|
|
1241
|
+
// A promotion between resolving the contract and taking the permit: refuse, write nothing.
|
|
1242
|
+
if ensure_revision_current(
|
|
1243
|
+
&state,
|
|
1244
|
+
&operation.application_id,
|
|
1245
|
+
"production",
|
|
1246
|
+
&revision.revision_id,
|
|
1247
|
+
true,
|
|
1248
|
+
)
|
|
1249
|
+
.is_err()
|
|
1250
|
+
{
|
|
1251
|
+
rejections.push(SyncRejection {
|
|
1252
|
+
operation_id: operation.operation_id,
|
|
1253
|
+
code: "SYNC_SCHEMA_MISMATCH".into(),
|
|
1254
|
+
message: "the production contract changed; nothing was written".into(),
|
|
1255
|
+
retryable: false,
|
|
1256
|
+
});
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1227
1259
|
match execute_transaction(&state.db, &schema, &transaction, None) {
|
|
1228
1260
|
Ok(committed) => {
|
|
1229
1261
|
let position = state
|
|
@@ -1466,6 +1498,10 @@ struct WorkloadFailRequest {
|
|
|
1466
1498
|
fence: WorkloadFenceRequest,
|
|
1467
1499
|
kind: String,
|
|
1468
1500
|
error: String,
|
|
1501
|
+
/// `none` (failed before any external effect) or `unknown` (an effect may
|
|
1502
|
+
/// have happened). Omitted: the policy decides by `kind`.
|
|
1503
|
+
#[serde(default)]
|
|
1504
|
+
effect: Option<String>,
|
|
1469
1505
|
}
|
|
1470
1506
|
#[derive(Deserialize)]
|
|
1471
1507
|
struct WorkloadContextRequest {
|
|
@@ -1482,6 +1518,16 @@ struct WorkloadControlRequest {
|
|
|
1482
1518
|
reason: Option<String>,
|
|
1483
1519
|
}
|
|
1484
1520
|
#[derive(Deserialize)]
|
|
1521
|
+
struct WorkloadDecisionRequest {
|
|
1522
|
+
/// A `workload:approve` grant whose subject is the authenticated caller.
|
|
1523
|
+
grant: feltdb::authorization::SignedGrant,
|
|
1524
|
+
/// The `input_hash` of the work the decider reviewed.
|
|
1525
|
+
input_hash: String,
|
|
1526
|
+
/// Required for a rejection; recorded with the decision.
|
|
1527
|
+
#[serde(default)]
|
|
1528
|
+
reason: Option<String>,
|
|
1529
|
+
}
|
|
1530
|
+
#[derive(Deserialize)]
|
|
1485
1531
|
struct WorkerHeartbeatRequest {
|
|
1486
1532
|
heartbeat: WorkerHeartbeat,
|
|
1487
1533
|
grant: feltdb::authorization::SignedGrant,
|
|
@@ -1496,6 +1542,45 @@ struct WorkerReconcileRequest {
|
|
|
1496
1542
|
reconciliation: WorkerReconciliation,
|
|
1497
1543
|
grant: feltdb::authorization::SignedGrant,
|
|
1498
1544
|
}
|
|
1545
|
+
/// Whether this node commits Durable Work.
|
|
1546
|
+
///
|
|
1547
|
+
/// Durable Work's guarantees rest on one serialization point for each
|
|
1548
|
+
/// workload: the guarded commit of one store. Peers replicate by causal
|
|
1549
|
+
/// order with last-writer-wins for concurrent writes, so two nodes that both
|
|
1550
|
+
/// committed work would each pass their own guard and silently diverge. A node
|
|
1551
|
+
/// without peers is therefore always the authority (the single-node default);
|
|
1552
|
+
/// in a peered cluster exactly one node, started with `--work-authority`,
|
|
1553
|
+
/// commits Durable Work, and every other node serves it read-only from its
|
|
1554
|
+
/// replicated store. A node that learns a peer is also the authority stops
|
|
1555
|
+
/// being one.
|
|
1556
|
+
fn is_work_authority(state: &AppState) -> bool {
|
|
1557
|
+
if state
|
|
1558
|
+
.work_authority_conflict
|
|
1559
|
+
.load(std::sync::atomic::Ordering::SeqCst)
|
|
1560
|
+
{
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
state.work_authority || state.cluster.get().peers.is_empty()
|
|
1564
|
+
}
|
|
1565
|
+
/// Refuse a Durable Work write on a node that is not the authority.
|
|
1566
|
+
fn require_work_authority(state: &AppState) -> Result<(), ApiError> {
|
|
1567
|
+
if is_work_authority(state) {
|
|
1568
|
+
return Ok(());
|
|
1569
|
+
}
|
|
1570
|
+
let conflict = state
|
|
1571
|
+
.work_authority_conflict
|
|
1572
|
+
.load(std::sync::atomic::Ordering::SeqCst);
|
|
1573
|
+
Err(ApiError::structured(
|
|
1574
|
+
StatusCode::MISDIRECTED_REQUEST,
|
|
1575
|
+
json!({"code": "WORKLOAD_NOT_AUTHORITY",
|
|
1576
|
+
"message": if conflict {
|
|
1577
|
+
"more than one node is configured as the Durable Work authority; Durable Work writes are refused until that is corrected"
|
|
1578
|
+
} else {
|
|
1579
|
+
"this node replicates Durable Work read-only; send the request to the node started with --work-authority"
|
|
1580
|
+
},
|
|
1581
|
+
"details": {"reason": if conflict { "authority_conflict" } else { "replica" }, "retryable": false}}),
|
|
1582
|
+
))
|
|
1583
|
+
}
|
|
1499
1584
|
fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
1500
1585
|
let status = match error.code.as_str() {
|
|
1501
1586
|
"WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
|
@@ -1505,8 +1590,18 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
|
1505
1590
|
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1506
1591
|
| "WORKLOAD_ATTEMPTS_EXHAUSTED"
|
|
1507
1592
|
| "WORKLOAD_RETRY_EXHAUSTED"
|
|
1593
|
+
| "WORKLOAD_RESULT_CONFLICT"
|
|
1594
|
+
| "WORKLOAD_TIMED_OUT"
|
|
1595
|
+
| "WORKLOAD_APPROVAL_PENDING"
|
|
1596
|
+
| "WORKLOAD_REJECTED"
|
|
1597
|
+
| "WORKLOAD_APPROVAL_DECIDED"
|
|
1598
|
+
| "WORKLOAD_APPROVAL_STALE"
|
|
1599
|
+
| "WORKLOAD_APPROVAL_NOT_ACTIONABLE"
|
|
1600
|
+
| "WORKLOAD_APPROVAL_NOT_REQUIRED"
|
|
1508
1601
|
| "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
|
|
1509
|
-
"WORKLOAD_UNAUTHORIZED"
|
|
1602
|
+
"WORKLOAD_UNAUTHORIZED"
|
|
1603
|
+
| "WORKLOAD_CAPABILITY_DENIED"
|
|
1604
|
+
| "WORKLOAD_APPROVER_NOT_PERMITTED" => StatusCode::FORBIDDEN,
|
|
1510
1605
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
1511
1606
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
1512
1607
|
};
|
|
@@ -1597,6 +1692,7 @@ async fn create_workload(
|
|
|
1597
1692
|
Extension(principal): Extension<Principal>,
|
|
1598
1693
|
Json(mut input): Json<CreateWorkload>,
|
|
1599
1694
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
|
|
1695
|
+
require_work_authority(&state)?;
|
|
1600
1696
|
let subject = grant_subject(&principal);
|
|
1601
1697
|
input.requested_by = subject.clone();
|
|
1602
1698
|
input.session_id = principal.session_id.clone();
|
|
@@ -1624,10 +1720,18 @@ async fn create_workload(
|
|
|
1624
1720
|
if error.code == "IDEMPOTENCY_KEY_REUSED" {
|
|
1625
1721
|
// AppPort's contract for a reused key (§13.1): CONFLICT, with
|
|
1626
1722
|
// the reason, not retryable.
|
|
1723
|
+
// `fields` names the execution-affecting request fields that
|
|
1724
|
+
// differ from the admitted workload.
|
|
1725
|
+
let fields = error
|
|
1726
|
+
.details
|
|
1727
|
+
.as_ref()
|
|
1728
|
+
.and_then(|details| details.get("fields"))
|
|
1729
|
+
.cloned()
|
|
1730
|
+
.unwrap_or_else(|| json!([]));
|
|
1627
1731
|
ApiError::structured(
|
|
1628
1732
|
StatusCode::CONFLICT,
|
|
1629
1733
|
json!({"code": "CONFLICT", "message": error.message,
|
|
1630
|
-
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
|
|
1734
|
+
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
|
|
1631
1735
|
)
|
|
1632
1736
|
} else {
|
|
1633
1737
|
workload_error(error)
|
|
@@ -1668,7 +1772,8 @@ async fn list_workloads(
|
|
|
1668
1772
|
"workload store unavailable".into(),
|
|
1669
1773
|
)
|
|
1670
1774
|
})?
|
|
1671
|
-
.
|
|
1775
|
+
.fresh_list(&tenant, &target.application_id)
|
|
1776
|
+
.map_err(workload_error)?;
|
|
1672
1777
|
Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
|
|
1673
1778
|
}
|
|
1674
1779
|
async fn get_workload(
|
|
@@ -1685,7 +1790,7 @@ async fn get_workload(
|
|
|
1685
1790
|
"workload store unavailable".into(),
|
|
1686
1791
|
)
|
|
1687
1792
|
})?
|
|
1688
|
-
.
|
|
1793
|
+
.fresh(&id)
|
|
1689
1794
|
.map_err(workload_error)?
|
|
1690
1795
|
.clone();
|
|
1691
1796
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -1715,11 +1820,12 @@ async fn claim_workload(
|
|
|
1715
1820
|
Path(id): Path<String>,
|
|
1716
1821
|
Json(input): Json<WorkloadClaimRequest>,
|
|
1717
1822
|
) -> Result<Json<Value>, ApiError> {
|
|
1823
|
+
require_work_authority(&state)?;
|
|
1718
1824
|
let workload = state
|
|
1719
1825
|
.workloads
|
|
1720
1826
|
.lock()
|
|
1721
1827
|
.unwrap()
|
|
1722
|
-
.
|
|
1828
|
+
.fresh(&id)
|
|
1723
1829
|
.map_err(workload_error)?
|
|
1724
1830
|
.clone();
|
|
1725
1831
|
let subject = grant_subject(&principal);
|
|
@@ -1773,7 +1879,7 @@ fn authorize_fence(
|
|
|
1773
1879
|
.workloads
|
|
1774
1880
|
.lock()
|
|
1775
1881
|
.unwrap()
|
|
1776
|
-
.
|
|
1882
|
+
.fresh(id)
|
|
1777
1883
|
.map_err(workload_error)?
|
|
1778
1884
|
.clone();
|
|
1779
1885
|
let subject = grant_subject(principal);
|
|
@@ -1800,6 +1906,7 @@ async fn start_workload(
|
|
|
1800
1906
|
Path(id): Path<String>,
|
|
1801
1907
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1802
1908
|
) -> Result<StatusCode, ApiError> {
|
|
1909
|
+
require_work_authority(&state)?;
|
|
1803
1910
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1804
1911
|
state
|
|
1805
1912
|
.workloads
|
|
@@ -1821,6 +1928,7 @@ async fn heartbeat_workload(
|
|
|
1821
1928
|
Path(id): Path<String>,
|
|
1822
1929
|
Json(input): Json<WorkloadHeartbeatRequest>,
|
|
1823
1930
|
) -> Result<Json<Value>, ApiError> {
|
|
1931
|
+
require_work_authority(&state)?;
|
|
1824
1932
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1825
1933
|
let claim = state
|
|
1826
1934
|
.workloads
|
|
@@ -1843,6 +1951,7 @@ async fn checkpoint_workload(
|
|
|
1843
1951
|
Path(id): Path<String>,
|
|
1844
1952
|
Json(input): Json<WorkloadCheckpointRequest>,
|
|
1845
1953
|
) -> Result<Json<Value>, ApiError> {
|
|
1954
|
+
require_work_authority(&state)?;
|
|
1846
1955
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1847
1956
|
let checkpoint = state
|
|
1848
1957
|
.workloads
|
|
@@ -1865,6 +1974,7 @@ async fn complete_workload(
|
|
|
1865
1974
|
Path(id): Path<String>,
|
|
1866
1975
|
Json(input): Json<WorkloadCompleteRequest>,
|
|
1867
1976
|
) -> Result<Json<Value>, ApiError> {
|
|
1977
|
+
require_work_authority(&state)?;
|
|
1868
1978
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1869
1979
|
let result = state
|
|
1870
1980
|
.workloads
|
|
@@ -1891,18 +2001,20 @@ async fn fail_workload(
|
|
|
1891
2001
|
Path(id): Path<String>,
|
|
1892
2002
|
Json(input): Json<WorkloadFailRequest>,
|
|
1893
2003
|
) -> Result<Json<Value>, ApiError> {
|
|
2004
|
+
require_work_authority(&state)?;
|
|
1894
2005
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1895
2006
|
let status = state
|
|
1896
2007
|
.workloads
|
|
1897
2008
|
.lock()
|
|
1898
2009
|
.unwrap()
|
|
1899
|
-
.
|
|
2010
|
+
.fail_with_effect(
|
|
1900
2011
|
&id,
|
|
1901
2012
|
&input.fence.claim_id,
|
|
1902
2013
|
&input.fence.worker_id,
|
|
1903
2014
|
input.fence.fencing_token,
|
|
1904
2015
|
&input.kind,
|
|
1905
2016
|
&input.error,
|
|
2017
|
+
input.effect.as_deref(),
|
|
1906
2018
|
unix_seconds_i64(),
|
|
1907
2019
|
)
|
|
1908
2020
|
.map_err(workload_error)?;
|
|
@@ -1915,6 +2027,7 @@ async fn confirm_workload_cancelled(
|
|
|
1915
2027
|
Path(id): Path<String>,
|
|
1916
2028
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1917
2029
|
) -> Result<StatusCode, ApiError> {
|
|
2030
|
+
require_work_authority(&state)?;
|
|
1918
2031
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1919
2032
|
state
|
|
1920
2033
|
.workloads
|
|
@@ -1936,6 +2049,7 @@ async fn workload_execution_context(
|
|
|
1936
2049
|
Path(id): Path<String>,
|
|
1937
2050
|
Json(input): Json<WorkloadContextRequest>,
|
|
1938
2051
|
) -> Result<Json<Value>, ApiError> {
|
|
2052
|
+
require_work_authority(&state)?;
|
|
1939
2053
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1940
2054
|
let context = state
|
|
1941
2055
|
.workloads
|
|
@@ -1958,11 +2072,12 @@ async fn cancel_workload(
|
|
|
1958
2072
|
Path(id): Path<String>,
|
|
1959
2073
|
Json(input): Json<WorkloadControlRequest>,
|
|
1960
2074
|
) -> Result<Json<Value>, ApiError> {
|
|
2075
|
+
require_work_authority(&state)?;
|
|
1961
2076
|
let workload = state
|
|
1962
2077
|
.workloads
|
|
1963
2078
|
.lock()
|
|
1964
2079
|
.unwrap()
|
|
1965
|
-
.
|
|
2080
|
+
.fresh(&id)
|
|
1966
2081
|
.map_err(workload_error)?
|
|
1967
2082
|
.clone();
|
|
1968
2083
|
workload_decision(
|
|
@@ -1988,11 +2103,12 @@ async fn retry_workload(
|
|
|
1988
2103
|
Path(id): Path<String>,
|
|
1989
2104
|
Json(input): Json<WorkloadControlRequest>,
|
|
1990
2105
|
) -> Result<StatusCode, ApiError> {
|
|
2106
|
+
require_work_authority(&state)?;
|
|
1991
2107
|
let workload = state
|
|
1992
2108
|
.workloads
|
|
1993
2109
|
.lock()
|
|
1994
2110
|
.unwrap()
|
|
1995
|
-
.
|
|
2111
|
+
.fresh(&id)
|
|
1996
2112
|
.map_err(workload_error)?
|
|
1997
2113
|
.clone();
|
|
1998
2114
|
workload_decision(
|
|
@@ -2012,6 +2128,99 @@ async fn retry_workload(
|
|
|
2012
2128
|
.map_err(workload_error)?;
|
|
2013
2129
|
Ok(StatusCode::NO_CONTENT)
|
|
2014
2130
|
}
|
|
2131
|
+
async fn approve_workload(
|
|
2132
|
+
State(state): State<AppState>,
|
|
2133
|
+
Extension(principal): Extension<Principal>,
|
|
2134
|
+
Path(id): Path<String>,
|
|
2135
|
+
Json(input): Json<WorkloadDecisionRequest>,
|
|
2136
|
+
) -> Result<(HeaderMap, Json<Value>), ApiError> {
|
|
2137
|
+
decide_workload(
|
|
2138
|
+
&state,
|
|
2139
|
+
&principal,
|
|
2140
|
+
&id,
|
|
2141
|
+
input,
|
|
2142
|
+
feltdb::workload::ApprovalOutcome::Approved,
|
|
2143
|
+
)
|
|
2144
|
+
}
|
|
2145
|
+
async fn reject_workload(
|
|
2146
|
+
State(state): State<AppState>,
|
|
2147
|
+
Extension(principal): Extension<Principal>,
|
|
2148
|
+
Path(id): Path<String>,
|
|
2149
|
+
Json(input): Json<WorkloadDecisionRequest>,
|
|
2150
|
+
) -> Result<(HeaderMap, Json<Value>), ApiError> {
|
|
2151
|
+
decide_workload(
|
|
2152
|
+
&state,
|
|
2153
|
+
&principal,
|
|
2154
|
+
&id,
|
|
2155
|
+
input,
|
|
2156
|
+
feltdb::workload::ApprovalOutcome::Rejected,
|
|
2157
|
+
)
|
|
2158
|
+
}
|
|
2159
|
+
/// Commit an approval decision. The caller must be the Durable Work
|
|
2160
|
+
/// authority's client, authenticate as the grant's subject, and hold
|
|
2161
|
+
/// `workload:approve` for this workload; the store then applies the
|
|
2162
|
+
/// workload's approval policy (permitted kinds and approvers, never the
|
|
2163
|
+
/// requester) and binds the decision to the reviewed input.
|
|
2164
|
+
fn decide_workload(
|
|
2165
|
+
state: &AppState,
|
|
2166
|
+
principal: &Principal,
|
|
2167
|
+
id: &str,
|
|
2168
|
+
input: WorkloadDecisionRequest,
|
|
2169
|
+
outcome: feltdb::workload::ApprovalOutcome,
|
|
2170
|
+
) -> Result<(HeaderMap, Json<Value>), ApiError> {
|
|
2171
|
+
require_work_authority(state)?;
|
|
2172
|
+
let workload = state
|
|
2173
|
+
.workloads
|
|
2174
|
+
.lock()
|
|
2175
|
+
.map_err(|_| {
|
|
2176
|
+
ApiError(
|
|
2177
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
2178
|
+
"workload store unavailable".into(),
|
|
2179
|
+
)
|
|
2180
|
+
})?
|
|
2181
|
+
.fresh(id)
|
|
2182
|
+
.map_err(workload_error)?
|
|
2183
|
+
.clone();
|
|
2184
|
+
let decider = grant_subject(principal);
|
|
2185
|
+
workload_decision(
|
|
2186
|
+
state,
|
|
2187
|
+
decider.clone(),
|
|
2188
|
+
&workload.tenant_id,
|
|
2189
|
+
&workload.application_id,
|
|
2190
|
+
format!("flow://{}/workloads/{id}", workload.application_id),
|
|
2191
|
+
"workload:approve",
|
|
2192
|
+
&input.grant,
|
|
2193
|
+
)?;
|
|
2194
|
+
let mut store = state.workloads.lock().map_err(|_| {
|
|
2195
|
+
ApiError(
|
|
2196
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
2197
|
+
"workload store unavailable".into(),
|
|
2198
|
+
)
|
|
2199
|
+
})?;
|
|
2200
|
+
let (decision, replayed) = store
|
|
2201
|
+
.decide(
|
|
2202
|
+
id,
|
|
2203
|
+
&decider,
|
|
2204
|
+
outcome,
|
|
2205
|
+
input.reason.as_deref(),
|
|
2206
|
+
&input.grant.grant.id,
|
|
2207
|
+
&input.input_hash,
|
|
2208
|
+
unix_seconds_i64(),
|
|
2209
|
+
)
|
|
2210
|
+
.map_err(workload_error)?;
|
|
2211
|
+
let state_now = store.get(id).map_err(workload_error)?.state;
|
|
2212
|
+
let mut headers = HeaderMap::new();
|
|
2213
|
+
if replayed {
|
|
2214
|
+
headers.insert(
|
|
2215
|
+
"idempotent-replay",
|
|
2216
|
+
axum::http::HeaderValue::from_static("true"),
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
Ok((
|
|
2220
|
+
headers,
|
|
2221
|
+
Json(json!({"decision": decision, "state": state_now})),
|
|
2222
|
+
))
|
|
2223
|
+
}
|
|
2015
2224
|
async fn workload_history(
|
|
2016
2225
|
State(state): State<AppState>,
|
|
2017
2226
|
Extension(principal): Extension<Principal>,
|
|
@@ -2021,7 +2230,7 @@ async fn workload_history(
|
|
|
2021
2230
|
.workloads
|
|
2022
2231
|
.lock()
|
|
2023
2232
|
.unwrap()
|
|
2024
|
-
.
|
|
2233
|
+
.fresh(&id)
|
|
2025
2234
|
.map_err(workload_error)?
|
|
2026
2235
|
.clone();
|
|
2027
2236
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -2038,7 +2247,7 @@ async fn workload_result(
|
|
|
2038
2247
|
.workloads
|
|
2039
2248
|
.lock()
|
|
2040
2249
|
.unwrap()
|
|
2041
|
-
.
|
|
2250
|
+
.fresh(&id)
|
|
2042
2251
|
.map_err(workload_error)?
|
|
2043
2252
|
.clone();
|
|
2044
2253
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -2223,7 +2432,8 @@ async fn reconcile_worker(
|
|
|
2223
2432
|
) -> Result<Json<Value>, ApiError> {
|
|
2224
2433
|
authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
|
|
2225
2434
|
input.reconciliation.worker_id = id;
|
|
2226
|
-
let workloads = state.workloads.lock().unwrap();
|
|
2435
|
+
let mut workloads = state.workloads.lock().unwrap();
|
|
2436
|
+
workloads.refresh().map_err(workload_error)?;
|
|
2227
2437
|
let result = state
|
|
2228
2438
|
.mesh
|
|
2229
2439
|
.lock()
|
|
@@ -2314,7 +2524,7 @@ async fn eligible_workers(
|
|
|
2314
2524
|
.workloads
|
|
2315
2525
|
.lock()
|
|
2316
2526
|
.unwrap()
|
|
2317
|
-
.
|
|
2527
|
+
.fresh(&id)
|
|
2318
2528
|
.map_err(workload_error)?
|
|
2319
2529
|
.clone();
|
|
2320
2530
|
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
@@ -4539,6 +4749,7 @@ async fn promote_environment(
|
|
|
4539
4749
|
Path((application_id, environment)): Path<(String, String)>,
|
|
4540
4750
|
Json(input): Json<MoveEnvironmentRequest>,
|
|
4541
4751
|
) -> Result<Json<Value>, ApiError> {
|
|
4752
|
+
let _drain = drain_state_commits(&state).await;
|
|
4542
4753
|
Ok(Json(move_environment(
|
|
4543
4754
|
&state,
|
|
4544
4755
|
&principal,
|
|
@@ -4557,6 +4768,7 @@ async fn rollback_environment(
|
|
|
4557
4768
|
Path((application_id, environment)): Path<(String, String)>,
|
|
4558
4769
|
Json(input): Json<RollbackEnvironmentRequest>,
|
|
4559
4770
|
) -> Result<Json<Value>, ApiError> {
|
|
4771
|
+
let _drain = drain_state_commits(&state).await;
|
|
4560
4772
|
Ok(Json(move_environment(
|
|
4561
4773
|
&state,
|
|
4562
4774
|
&principal,
|
|
@@ -4575,6 +4787,7 @@ async fn promote_application_revision(
|
|
|
4575
4787
|
Path((application_id, revision_id)): Path<(String, String)>,
|
|
4576
4788
|
Json(input): Json<PromoteRevisionRequest>,
|
|
4577
4789
|
) -> Result<Json<Value>, ApiError> {
|
|
4790
|
+
let _drain = drain_state_commits(&state).await;
|
|
4578
4791
|
Ok(Json(move_environment(
|
|
4579
4792
|
&state,
|
|
4580
4793
|
&principal,
|
|
@@ -4681,6 +4894,97 @@ fn runtime_inventory(
|
|
|
4681
4894
|
enforce_availability: true,
|
|
4682
4895
|
})
|
|
4683
4896
|
}
|
|
4897
|
+
/// The contract a state request runs under: the revision promoted to `environment`, and
|
|
4898
|
+
/// only that one.
|
|
4899
|
+
///
|
|
4900
|
+
/// A client names the revision it discovered. Any other revision (one a later promotion
|
|
4901
|
+
/// superseded, or one committed but never promoted) is refused with `REVISION_NOT_ACTIVE`,
|
|
4902
|
+
/// naming the active one, before anything is read or written. Accepting it would apply a
|
|
4903
|
+
/// contract that is not the environment's: fields a newer contract removed, policies it
|
|
4904
|
+
/// tightened, or a revision whose promotion was never approved. The client re-discovers and
|
|
4905
|
+
/// sends its next request under the active revision; nothing is retried under another contract.
|
|
4906
|
+
fn active_revision_contract(
|
|
4907
|
+
state: &AppState,
|
|
4908
|
+
actor: &str,
|
|
4909
|
+
application_id: &str,
|
|
4910
|
+
revision_id: &str,
|
|
4911
|
+
environment: &str,
|
|
4912
|
+
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4913
|
+
application_scope(state, actor, application_id, "application:revision:read")?;
|
|
4914
|
+
let active = state
|
|
4915
|
+
.applications
|
|
4916
|
+
.pointers(application_id)
|
|
4917
|
+
.get(environment)
|
|
4918
|
+
.cloned()
|
|
4919
|
+
.ok_or(ApiError(
|
|
4920
|
+
StatusCode::NOT_FOUND,
|
|
4921
|
+
"application environment has no active contract".into(),
|
|
4922
|
+
))?;
|
|
4923
|
+
if active != revision_id {
|
|
4924
|
+
return Err(revision_not_active(revision_id, &active, environment));
|
|
4925
|
+
}
|
|
4926
|
+
revision_contract(state, actor, application_id, revision_id, environment)
|
|
4927
|
+
}
|
|
4928
|
+
/// A revision runtime runs in an environment that has a promoted contract only as that
|
|
4929
|
+
/// contract. An environment nothing is promoted to (a preview) runs any committed revision.
|
|
4930
|
+
fn runtime_revision_contract(
|
|
4931
|
+
state: &AppState,
|
|
4932
|
+
actor: &str,
|
|
4933
|
+
application_id: &str,
|
|
4934
|
+
revision_id: &str,
|
|
4935
|
+
environment: &str,
|
|
4936
|
+
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4937
|
+
application_scope(state, actor, application_id, "application:revision:read")?;
|
|
4938
|
+
if let Some(active) = state.applications.pointers(application_id).get(environment) {
|
|
4939
|
+
if active != revision_id {
|
|
4940
|
+
return Err(revision_not_active(revision_id, active, environment));
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
revision_contract(state, actor, application_id, revision_id, environment)
|
|
4944
|
+
}
|
|
4945
|
+
/// Commits that write application state each hold one of these permits.
|
|
4946
|
+
const STATE_TRANSACTION_PERMITS: u32 = 2;
|
|
4947
|
+
/// Hold every commit permit while an environment's contract pointer moves: the move waits for
|
|
4948
|
+
/// commits in flight, and no commit starts until it is done. With `ensure_revision_current`
|
|
4949
|
+
/// checked under a permit, a commit and a promotion are ordered: a commit never lands under a
|
|
4950
|
+
/// contract a promotion has already replaced.
|
|
4951
|
+
async fn drain_state_commits(state: &AppState) -> tokio::sync::SemaphorePermit<'_> {
|
|
4952
|
+
state
|
|
4953
|
+
.state_transaction_admission
|
|
4954
|
+
.acquire_many(STATE_TRANSACTION_PERMITS)
|
|
4955
|
+
.await
|
|
4956
|
+
.expect("transaction admission is never closed")
|
|
4957
|
+
}
|
|
4958
|
+
/// Re-check, while holding a commit permit, that `revision_id` is still the environment's
|
|
4959
|
+
/// contract (`require_promoted`: an environment with nothing promoted refuses too).
|
|
4960
|
+
fn ensure_revision_current(
|
|
4961
|
+
state: &AppState,
|
|
4962
|
+
application_id: &str,
|
|
4963
|
+
environment: &str,
|
|
4964
|
+
revision_id: &str,
|
|
4965
|
+
require_promoted: bool,
|
|
4966
|
+
) -> Result<(), ApiError> {
|
|
4967
|
+
match state.applications.pointers(application_id).get(environment) {
|
|
4968
|
+
Some(active) if active != revision_id => Err(revision_not_active(revision_id, active, environment)),
|
|
4969
|
+
None if require_promoted => Err(ApiError(
|
|
4970
|
+
StatusCode::NOT_FOUND,
|
|
4971
|
+
"application environment has no active contract".into(),
|
|
4972
|
+
)),
|
|
4973
|
+
_ => Ok(()),
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
fn revision_not_active(requested: &str, active: &str, environment: &str) -> ApiError {
|
|
4977
|
+
ApiError::structured(
|
|
4978
|
+
StatusCode::CONFLICT,
|
|
4979
|
+
json!({
|
|
4980
|
+
"code": "REVISION_NOT_ACTIVE",
|
|
4981
|
+
"message": format!("revision {requested} is not the contract active in {environment}; {active} is. Nothing was read or written. Whether to send the request again under the active revision is the caller's decision."),
|
|
4982
|
+
"requested_revision_id": requested,
|
|
4983
|
+
"active_revision_id": active,
|
|
4984
|
+
"environment": environment,
|
|
4985
|
+
}),
|
|
4986
|
+
)
|
|
4987
|
+
}
|
|
4684
4988
|
fn revision_contract(
|
|
4685
4989
|
state: &AppState,
|
|
4686
4990
|
actor: &str,
|
|
@@ -6957,6 +7261,139 @@ async fn auth_sign_up(
|
|
|
6957
7261
|
.map_err(control_error)?;
|
|
6958
7262
|
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
6959
7263
|
}
|
|
7264
|
+
#[derive(Deserialize)]
|
|
7265
|
+
struct ConsolePasswordRequest {
|
|
7266
|
+
email: String,
|
|
7267
|
+
password: String,
|
|
7268
|
+
}
|
|
7269
|
+
#[derive(Deserialize)]
|
|
7270
|
+
struct ConsoleGithubRequest {
|
|
7271
|
+
github_id: Value,
|
|
7272
|
+
login: String,
|
|
7273
|
+
}
|
|
7274
|
+
|
|
7275
|
+
fn console_account_error(error: console_accounts::AccountError) -> ApiError {
|
|
7276
|
+
ApiError::structured(
|
|
7277
|
+
StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
|
7278
|
+
json!({ "code": error.code, "message": error.message }),
|
|
7279
|
+
)
|
|
7280
|
+
}
|
|
7281
|
+
|
|
7282
|
+
/// Authenticate a website call to `/v1/console/accounts/*` and parse its body.
|
|
7283
|
+
fn console_request<T: serde::de::DeserializeOwned>(
|
|
7284
|
+
state: &AppState,
|
|
7285
|
+
method: &Method,
|
|
7286
|
+
uri: &axum::http::Uri,
|
|
7287
|
+
headers: &HeaderMap,
|
|
7288
|
+
body: &[u8],
|
|
7289
|
+
) -> Result<T, ApiError> {
|
|
7290
|
+
let Some(secret) = state.console_accounts.secret() else {
|
|
7291
|
+
return Err(ApiError::structured(
|
|
7292
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
7293
|
+
json!({
|
|
7294
|
+
"code": "CONSOLE_ACCOUNTS_UNCONFIGURED",
|
|
7295
|
+
"message": "console accounts require FELTDB_SESSION_SECRET (at least 32 bytes) on the control plane",
|
|
7296
|
+
}),
|
|
7297
|
+
));
|
|
7298
|
+
};
|
|
7299
|
+
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
|
|
7300
|
+
let now = std::time::SystemTime::now()
|
|
7301
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
7302
|
+
.unwrap_or_default()
|
|
7303
|
+
.as_secs();
|
|
7304
|
+
if let Err(error) = console_accounts::verify_console_request(
|
|
7305
|
+
secret,
|
|
7306
|
+
method.as_str(),
|
|
7307
|
+
uri.path(),
|
|
7308
|
+
header(console_accounts::TIMESTAMP_HEADER),
|
|
7309
|
+
header(console_accounts::SIGNATURE_HEADER),
|
|
7310
|
+
body,
|
|
7311
|
+
now,
|
|
7312
|
+
) {
|
|
7313
|
+
state.metrics.authentication_failure();
|
|
7314
|
+
audit(state, "anonymous", "console.accounts", uri.path(), "denied", 401);
|
|
7315
|
+
return Err(console_account_error(error));
|
|
7316
|
+
}
|
|
7317
|
+
serde_json::from_slice(body)
|
|
7318
|
+
.map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid request body: {error}")))
|
|
7319
|
+
}
|
|
7320
|
+
|
|
7321
|
+
/// Argon2 work and the account file write run off the request worker.
|
|
7322
|
+
async fn console_blocking<F>(work: F) -> Result<Json<Value>, ApiError>
|
|
7323
|
+
where
|
|
7324
|
+
F: FnOnce() -> Result<console_accounts::ResolvedAccount, console_accounts::AccountError>
|
|
7325
|
+
+ Send
|
|
7326
|
+
+ 'static,
|
|
7327
|
+
{
|
|
7328
|
+
match tokio::task::spawn_blocking(work).await {
|
|
7329
|
+
Ok(Ok(account)) => Ok(Json(json!({
|
|
7330
|
+
"user_id": account.user_id,
|
|
7331
|
+
"identity": account.identity,
|
|
7332
|
+
"display_identity": account.display_identity,
|
|
7333
|
+
}))),
|
|
7334
|
+
Ok(Err(error)) => Err(console_account_error(error)),
|
|
7335
|
+
Err(_) => Err(ApiError(
|
|
7336
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
7337
|
+
"console account operation failed".into(),
|
|
7338
|
+
)),
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
|
|
7342
|
+
async fn console_password_signup(
|
|
7343
|
+
State(state): State<AppState>,
|
|
7344
|
+
method: Method,
|
|
7345
|
+
uri: axum::http::Uri,
|
|
7346
|
+
headers: HeaderMap,
|
|
7347
|
+
body: axum::body::Bytes,
|
|
7348
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
7349
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7350
|
+
let created = console_blocking(move || {
|
|
7351
|
+
state
|
|
7352
|
+
.console_accounts
|
|
7353
|
+
.password_signup(&state.tenancy, &input.email, &input.password)
|
|
7354
|
+
})
|
|
7355
|
+
.await?;
|
|
7356
|
+
Ok((StatusCode::CREATED, created))
|
|
7357
|
+
}
|
|
7358
|
+
|
|
7359
|
+
async fn console_password_signin(
|
|
7360
|
+
State(state): State<AppState>,
|
|
7361
|
+
method: Method,
|
|
7362
|
+
uri: axum::http::Uri,
|
|
7363
|
+
headers: HeaderMap,
|
|
7364
|
+
body: axum::body::Bytes,
|
|
7365
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7366
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7367
|
+
console_blocking(move || {
|
|
7368
|
+
state
|
|
7369
|
+
.console_accounts
|
|
7370
|
+
.password_signin(&input.email, &input.password)
|
|
7371
|
+
})
|
|
7372
|
+
.await
|
|
7373
|
+
}
|
|
7374
|
+
|
|
7375
|
+
async fn console_github_resolve(
|
|
7376
|
+
State(state): State<AppState>,
|
|
7377
|
+
method: Method,
|
|
7378
|
+
uri: axum::http::Uri,
|
|
7379
|
+
headers: HeaderMap,
|
|
7380
|
+
body: axum::body::Bytes,
|
|
7381
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7382
|
+
let input: ConsoleGithubRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7383
|
+
// GitHub user ids are numbers; accept the number or its decimal string.
|
|
7384
|
+
let github_id = match &input.github_id {
|
|
7385
|
+
Value::Number(number) if number.is_u64() => number.to_string(),
|
|
7386
|
+
Value::String(text) => text.clone(),
|
|
7387
|
+
_ => String::new(),
|
|
7388
|
+
};
|
|
7389
|
+
console_blocking(move || {
|
|
7390
|
+
state
|
|
7391
|
+
.console_accounts
|
|
7392
|
+
.github_resolve(&state.tenancy, &github_id, &input.login)
|
|
7393
|
+
})
|
|
7394
|
+
.await
|
|
7395
|
+
}
|
|
7396
|
+
|
|
6960
7397
|
async fn auth_sign_in(
|
|
6961
7398
|
State(state): State<AppState>,
|
|
6962
7399
|
Json(input): Json<SignInRequest>,
|
|
@@ -7095,7 +7532,7 @@ async fn run_revision_runtime(
|
|
|
7095
7532
|
&application_id,
|
|
7096
7533
|
"application:runtime:run",
|
|
7097
7534
|
)?;
|
|
7098
|
-
let contract =
|
|
7535
|
+
let contract = runtime_revision_contract(
|
|
7099
7536
|
&state,
|
|
7100
7537
|
&principal.key_id,
|
|
7101
7538
|
&application_id,
|
|
@@ -7160,7 +7597,7 @@ async fn run_runtime_action(
|
|
|
7160
7597
|
Json(input): Json<Value>,
|
|
7161
7598
|
) -> Result<Json<Value>, ApiError> {
|
|
7162
7599
|
application_scope(&state, &principal.key_id, &application_id, "state:write")?;
|
|
7163
|
-
let contract =
|
|
7600
|
+
let contract = runtime_revision_contract(
|
|
7164
7601
|
&state,
|
|
7165
7602
|
&principal.key_id,
|
|
7166
7603
|
&application_id,
|
|
@@ -7259,6 +7696,7 @@ async fn run_runtime_action(
|
|
|
7259
7696
|
.acquire()
|
|
7260
7697
|
.await
|
|
7261
7698
|
.expect("transaction admission is never closed");
|
|
7699
|
+
ensure_revision_current(&state, &application_id, &environment.environment, &contract.revision_id, false)?;
|
|
7262
7700
|
Ok(Json(json!(execute_transaction(
|
|
7263
7701
|
&state.db,
|
|
7264
7702
|
&contract.state.schema,
|
|
@@ -7290,7 +7728,7 @@ async fn run_runtime_query(
|
|
|
7290
7728
|
Query(environment): Query<RuntimeEnvironmentRequest>,
|
|
7291
7729
|
) -> Result<Json<Value>, ApiError> {
|
|
7292
7730
|
application_scope(&state, &principal.key_id, &application_id, "state:read")?;
|
|
7293
|
-
let contract =
|
|
7731
|
+
let contract = runtime_revision_contract(
|
|
7294
7732
|
&state,
|
|
7295
7733
|
&principal.key_id,
|
|
7296
7734
|
&application_id,
|
|
@@ -7392,7 +7830,7 @@ async fn run_runtime_workflow(
|
|
|
7392
7830
|
Json(input): Json<StartRun>,
|
|
7393
7831
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
7394
7832
|
application_scope(&state, &principal.key_id, &application_id, "workflows:run")?;
|
|
7395
|
-
let contract =
|
|
7833
|
+
let contract = runtime_revision_contract(
|
|
7396
7834
|
&state,
|
|
7397
7835
|
&principal.key_id,
|
|
7398
7836
|
&application_id,
|
|
@@ -7595,7 +8033,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
|
|
|
7595
8033
|
"PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
|
|
7596
8034
|
"UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
|
|
7597
8035
|
"SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
|
|
7598
|
-
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" =>
|
|
8036
|
+
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
|
|
8037
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
8038
|
+
}
|
|
7599
8039
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
7600
8040
|
};
|
|
7601
8041
|
ApiError(
|
|
@@ -7647,7 +8087,7 @@ async fn get_state_version(
|
|
|
7647
8087
|
&target.application_id,
|
|
7648
8088
|
"state:read",
|
|
7649
8089
|
)?;
|
|
7650
|
-
let contract =
|
|
8090
|
+
let contract = active_revision_contract(
|
|
7651
8091
|
&state,
|
|
7652
8092
|
&principal.key_id,
|
|
7653
8093
|
&target.application_id,
|
|
@@ -7689,7 +8129,7 @@ async fn execute_canonical_query(
|
|
|
7689
8129
|
"state:read",
|
|
7690
8130
|
)?;
|
|
7691
8131
|
require_bound_tenant(&headers, &tenant)?;
|
|
7692
|
-
let contract =
|
|
8132
|
+
let contract = active_revision_contract(
|
|
7693
8133
|
&state,
|
|
7694
8134
|
&principal.key_id,
|
|
7695
8135
|
&input.application_id,
|
|
@@ -7998,7 +8438,7 @@ async fn capture_canonical_read_basis(
|
|
|
7998
8438
|
"state:read",
|
|
7999
8439
|
)?;
|
|
8000
8440
|
require_bound_tenant(&headers, &tenant)?;
|
|
8001
|
-
let contract =
|
|
8441
|
+
let contract = active_revision_contract(
|
|
8002
8442
|
&state,
|
|
8003
8443
|
&principal.key_id,
|
|
8004
8444
|
&input.application_id,
|
|
@@ -8029,7 +8469,7 @@ async fn execute_canonical_transaction(
|
|
|
8029
8469
|
"state:write",
|
|
8030
8470
|
)?;
|
|
8031
8471
|
require_bound_tenant(&headers, &tenant)?;
|
|
8032
|
-
let contract =
|
|
8472
|
+
let contract = active_revision_contract(
|
|
8033
8473
|
&state,
|
|
8034
8474
|
&principal.key_id,
|
|
8035
8475
|
&input.application_id,
|
|
@@ -8118,6 +8558,7 @@ async fn execute_canonical_transaction(
|
|
|
8118
8558
|
.acquire()
|
|
8119
8559
|
.await
|
|
8120
8560
|
.expect("transaction admission is never closed");
|
|
8561
|
+
ensure_revision_current(&state, &input.application_id, &input.environment, &contract.revision_id, true)?;
|
|
8121
8562
|
let result = execute_transaction_with_collection_policies(
|
|
8122
8563
|
&state.db,
|
|
8123
8564
|
&contract.state.schema,
|
|
@@ -8232,6 +8673,10 @@ struct SyncPullResponse {
|
|
|
8232
8673
|
operations: Vec<Operation>,
|
|
8233
8674
|
versions: HashMap<String, u64>,
|
|
8234
8675
|
catchup: CatchupStatus,
|
|
8676
|
+
/// Whether the answering node is configured as the Durable Work authority
|
|
8677
|
+
/// (`--work-authority`). Absent from older peers.
|
|
8678
|
+
#[serde(default)]
|
|
8679
|
+
durable_work_authority: bool,
|
|
8235
8680
|
}
|
|
8236
8681
|
|
|
8237
8682
|
#[derive(Serialize, Deserialize)]
|
|
@@ -8480,6 +8925,7 @@ struct Config {
|
|
|
8480
8925
|
audit: PathBuf,
|
|
8481
8926
|
allowed_origins: Vec<axum::http::HeaderValue>,
|
|
8482
8927
|
worker_enabled: bool,
|
|
8928
|
+
work_authority: bool,
|
|
8483
8929
|
advertise_url: Option<String>,
|
|
8484
8930
|
maximum_clock_drift_ms: u64,
|
|
8485
8931
|
}
|
|
@@ -8528,6 +8974,7 @@ impl Config {
|
|
|
8528
8974
|
.transpose()?
|
|
8529
8975
|
.unwrap_or_default();
|
|
8530
8976
|
let mut worker_enabled = std::env::var("FELTDB_WORKER_ENABLED").as_deref() == Ok("true");
|
|
8977
|
+
let mut work_authority = std::env::var("FELTDB_WORK_AUTHORITY").as_deref() == Ok("true");
|
|
8531
8978
|
let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
|
|
8532
8979
|
.ok()
|
|
8533
8980
|
.map(|value| value.trim_end_matches('/').to_string());
|
|
@@ -8580,6 +9027,7 @@ impl Config {
|
|
|
8580
9027
|
.map_err(|_| "invalid --allow-origin".to_string())?,
|
|
8581
9028
|
),
|
|
8582
9029
|
"--worker" => worker_enabled = true,
|
|
9030
|
+
"--work-authority" => work_authority = true,
|
|
8583
9031
|
"--advertise-url" => {
|
|
8584
9032
|
advertise_url = Some(value()?.trim_end_matches('/').to_string())
|
|
8585
9033
|
}
|
|
@@ -8594,7 +9042,7 @@ impl Config {
|
|
|
8594
9042
|
}
|
|
8595
9043
|
}
|
|
8596
9044
|
"--help" | "-h" => {
|
|
8597
|
-
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker]");
|
|
9045
|
+
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker] [--work-authority]");
|
|
8598
9046
|
std::process::exit(0);
|
|
8599
9047
|
}
|
|
8600
9048
|
_ => return Err(format!("unknown argument: {argument}")),
|
|
@@ -8617,6 +9065,7 @@ impl Config {
|
|
|
8617
9065
|
audit,
|
|
8618
9066
|
allowed_origins,
|
|
8619
9067
|
worker_enabled,
|
|
9068
|
+
work_authority,
|
|
8620
9069
|
advertise_url,
|
|
8621
9070
|
maximum_clock_drift_ms,
|
|
8622
9071
|
})
|
|
@@ -8687,10 +9136,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
|
|
|
8687
9136
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
8688
9137
|
tracing_subscriber::fmt()
|
|
8689
9138
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
9139
|
+
// Every log line passes through credential redaction, whatever wrote it.
|
|
9140
|
+
.with_writer(feltdb_server::redaction::RedactingStdout)
|
|
8690
9141
|
.init();
|
|
9142
|
+
if matches!(
|
|
9143
|
+
std::env::args().nth(1).as_deref(),
|
|
9144
|
+
Some("version" | "--version" | "-V")
|
|
9145
|
+
) {
|
|
9146
|
+
// One line an operator script can print instead of a hard-coded banner.
|
|
9147
|
+
println!(
|
|
9148
|
+
"feltdb-server {} (release {}, commit {})",
|
|
9149
|
+
env!("CARGO_PKG_VERSION"),
|
|
9150
|
+
option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
9151
|
+
option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown")
|
|
9152
|
+
);
|
|
9153
|
+
return Ok(());
|
|
9154
|
+
}
|
|
8691
9155
|
if manage_keys()? {
|
|
8692
9156
|
return Ok(());
|
|
8693
9157
|
}
|
|
9158
|
+
if manage_console_accounts()? {
|
|
9159
|
+
return Ok(());
|
|
9160
|
+
}
|
|
8694
9161
|
if manage_authorization()? {
|
|
8695
9162
|
return Ok(());
|
|
8696
9163
|
}
|
|
@@ -8825,8 +9292,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8825
9292
|
peer_client,
|
|
8826
9293
|
peer_token,
|
|
8827
9294
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
8828
|
-
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(
|
|
9295
|
+
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(STATE_TRANSACTION_PERMITS as usize)),
|
|
8829
9296
|
worker_enabled: config.worker_enabled,
|
|
9297
|
+
work_authority: config.work_authority,
|
|
9298
|
+
work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
8830
9299
|
leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
|
|
8831
9300
|
content: content_store,
|
|
8832
9301
|
artifacts: artifact_store,
|
|
@@ -8848,6 +9317,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8848
9317
|
.into_bytes(),
|
|
8849
9318
|
config.data.with_extension("revoked-sessions.json"),
|
|
8850
9319
|
)?,
|
|
9320
|
+
console_accounts: ConsoleAccountStore::load(
|
|
9321
|
+
config.data.with_extension("console-accounts.json"),
|
|
9322
|
+
console_accounts::configured_secret(std::env::var("FELTDB_SESSION_SECRET").ok()),
|
|
9323
|
+
)?,
|
|
8851
9324
|
principals: principal_authority,
|
|
8852
9325
|
connections: ConnectionStore::load_with_provider(
|
|
8853
9326
|
config.data.with_extension("connections.json"),
|
|
@@ -8936,6 +9409,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8936
9409
|
.route("/api/tenants/{tenant_id}", get(get_tenant))
|
|
8937
9410
|
.route("/api/keys", get(list_keys).post(create_key))
|
|
8938
9411
|
.route("/api/keys/{id}", axum::routing::delete(revoke_key))
|
|
9412
|
+
.route("/api/keys/{id}/rotate", axum::routing::post(rotate_key))
|
|
9413
|
+
// Operator observability: authenticated, `operator:read`.
|
|
9414
|
+
.route("/metrics", get(network_metrics))
|
|
9415
|
+
.route("/v1/status", get(operator_status))
|
|
8939
9416
|
.route(
|
|
8940
9417
|
"/api/tenants/{tenant_id}/applications",
|
|
8941
9418
|
get(list_applications).post(create_application_control),
|
|
@@ -9293,6 +9770,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9293
9770
|
"/v1/workloads/{id}/retry",
|
|
9294
9771
|
axum::routing::post(retry_workload),
|
|
9295
9772
|
)
|
|
9773
|
+
.route(
|
|
9774
|
+
"/v1/workloads/{id}/approve",
|
|
9775
|
+
axum::routing::post(approve_workload),
|
|
9776
|
+
)
|
|
9777
|
+
.route(
|
|
9778
|
+
"/v1/workloads/{id}/reject",
|
|
9779
|
+
axum::routing::post(reject_workload),
|
|
9780
|
+
)
|
|
9296
9781
|
.route("/v1/workloads/{id}/history", get(workload_history))
|
|
9297
9782
|
.route("/v1/workloads/{id}/result", get(workload_result))
|
|
9298
9783
|
.route("/v1/workloads/{id}/eligible-workers", get(eligible_workers))
|
|
@@ -9596,23 +10081,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9596
10081
|
.route("/v1/openapi.json", get(application_openapi))
|
|
9597
10082
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
9598
10083
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
9599
|
-
|
|
10084
|
+
// Console accounts: callable only by the website, which signs each
|
|
10085
|
+
// request with FELTDB_SESSION_SECRET (see console_accounts.rs).
|
|
9600
10086
|
.route(
|
|
9601
|
-
"/
|
|
9602
|
-
axum::routing::post(
|
|
10087
|
+
"/v1/console/accounts/password/signup",
|
|
10088
|
+
axum::routing::post(console_password_signup),
|
|
9603
10089
|
)
|
|
9604
|
-
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
9605
10090
|
.route(
|
|
9606
|
-
"/
|
|
9607
|
-
axum::routing::post(
|
|
10091
|
+
"/v1/console/accounts/password/signin",
|
|
10092
|
+
axum::routing::post(console_password_signin),
|
|
9608
10093
|
)
|
|
9609
|
-
.route("/ready", get(readiness))
|
|
9610
|
-
.route("/runtime", get(runtime))
|
|
9611
|
-
.route("/metrics", get(network_metrics))
|
|
9612
10094
|
.route(
|
|
9613
|
-
"/
|
|
9614
|
-
axum::routing::
|
|
10095
|
+
"/v1/console/accounts/github/resolve",
|
|
10096
|
+
axum::routing::post(console_github_resolve),
|
|
9615
10097
|
)
|
|
10098
|
+
.route("/ready", get(readiness))
|
|
10099
|
+
.route("/runtime", get(runtime))
|
|
10100
|
+
.merge(service_secret_routes())
|
|
9616
10101
|
.merge(protected)
|
|
9617
10102
|
.merge(query_execution_diagnostics_router())
|
|
9618
10103
|
.layer(middleware::from_fn_with_state(state.clone(), admit_request))
|
|
@@ -9620,7 +10105,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9620
10105
|
.layer(middleware::from_fn(attribute_request))
|
|
9621
10106
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
9622
10107
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
9623
|
-
.layer(
|
|
10108
|
+
.layer(middleware::from_fn(redact_error_responses))
|
|
10109
|
+
// Spans carry the path, never the query string, where clients put tokens.
|
|
10110
|
+
.layer(TraceLayer::new_for_http().make_span_with(
|
|
10111
|
+
|request: &Request<axum::body::Body>| {
|
|
10112
|
+
tracing::debug_span!(
|
|
10113
|
+
"request",
|
|
10114
|
+
method = %request.method(),
|
|
10115
|
+
path = %request.uri().path(),
|
|
10116
|
+
)
|
|
10117
|
+
},
|
|
10118
|
+
))
|
|
9624
10119
|
.with_state(state);
|
|
9625
10120
|
if !config.allowed_origins.is_empty() {
|
|
9626
10121
|
app = app.layer(
|
|
@@ -9664,6 +10159,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9664
10159
|
Ok(())
|
|
9665
10160
|
}
|
|
9666
10161
|
|
|
10162
|
+
/// Error bodies can quote client input (axum's JSON rejections echo the
|
|
10163
|
+
/// offending value); a credential sent by mistake must not come back out, or
|
|
10164
|
+
/// land in whatever logs the response. Success bodies are untouched: a key's
|
|
10165
|
+
/// secret is returned exactly once, at creation or rotation.
|
|
10166
|
+
async fn redact_error_responses(request: Request<axum::body::Body>, next: Next) -> Response {
|
|
10167
|
+
let response = next.run(request).await;
|
|
10168
|
+
if response.status().as_u16() < 400 {
|
|
10169
|
+
return response;
|
|
10170
|
+
}
|
|
10171
|
+
let (parts, body) = response.into_parts();
|
|
10172
|
+
let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
|
|
10173
|
+
Ok(bytes) => bytes,
|
|
10174
|
+
Err(_) => return Response::from_parts(parts, axum::body::Body::empty()),
|
|
10175
|
+
};
|
|
10176
|
+
let body = match std::str::from_utf8(&bytes) {
|
|
10177
|
+
Ok(text) => match feltdb_server::redaction::redact(text) {
|
|
10178
|
+
std::borrow::Cow::Owned(redacted) => axum::body::Body::from(redacted),
|
|
10179
|
+
std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
|
|
10180
|
+
},
|
|
10181
|
+
Err(_) => axum::body::Body::from(bytes),
|
|
10182
|
+
};
|
|
10183
|
+
let mut parts = parts;
|
|
10184
|
+
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
|
|
10185
|
+
Response::from_parts(parts, body)
|
|
10186
|
+
}
|
|
10187
|
+
|
|
9667
10188
|
async fn count_request(
|
|
9668
10189
|
State(state): State<AppState>,
|
|
9669
10190
|
request: Request<axum::body::Body>,
|
|
@@ -9696,7 +10217,7 @@ async fn authenticate(
|
|
|
9696
10217
|
// an answer to PR36's question — which part of the request does is.
|
|
9697
10218
|
let authorization =
|
|
9698
10219
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
|
|
9699
|
-
let principal = if state.auth_enabled {
|
|
10220
|
+
let (principal, class) = if state.auth_enabled {
|
|
9700
10221
|
let token = request
|
|
9701
10222
|
.headers()
|
|
9702
10223
|
.get(AUTHORIZATION)
|
|
@@ -9759,8 +10280,8 @@ async fn authenticate(
|
|
|
9759
10280
|
})
|
|
9760
10281
|
.and_then(|token| state.sessions.verify(token));
|
|
9761
10282
|
match (machine, actor, human) {
|
|
9762
|
-
(Some(principal), _, _) => principal,
|
|
9763
|
-
(None, Some(session), _) => Principal {
|
|
10283
|
+
(Some(principal), _, _) => (principal, PrincipalClass::Machine),
|
|
10284
|
+
(None, Some(session), _) => (Principal {
|
|
9764
10285
|
key_id: session.subject_id,
|
|
9765
10286
|
scopes: vec![
|
|
9766
10287
|
"application:read".into(),
|
|
@@ -9775,24 +10296,55 @@ async fn authenticate(
|
|
|
9775
10296
|
subject_type: "human".into(),
|
|
9776
10297
|
identity_id: Some(session.identity_id),
|
|
9777
10298
|
session_id: Some(session.session_id),
|
|
9778
|
-
},
|
|
10299
|
+
}, PrincipalClass::EndUser),
|
|
9779
10300
|
(None, None, Some(session)) => {
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
10301
|
+
// A signed cookie is not enough: its user id must belong to a
|
|
10302
|
+
// console account (created through the verified account
|
|
10303
|
+
// routes, or bound/claimed for a pre-0.15.0 user), and its
|
|
10304
|
+
// identity must be that account's. Sessions the website
|
|
10305
|
+
// minted before accounts existed are refused here.
|
|
10306
|
+
let account = match state.console_accounts.account_for_user(&session.user_id) {
|
|
10307
|
+
Some(account) if account.display_identity == session.identity.trim().to_lowercase() => account,
|
|
10308
|
+
_ => {
|
|
10309
|
+
state.metrics.authentication_failure();
|
|
10310
|
+
audit(
|
|
10311
|
+
&state,
|
|
10312
|
+
&session.user_id,
|
|
10313
|
+
"authenticate",
|
|
10314
|
+
request.uri().path(),
|
|
10315
|
+
"denied",
|
|
10316
|
+
401,
|
|
10317
|
+
);
|
|
10318
|
+
return ApiError::structured(
|
|
10319
|
+
StatusCode::UNAUTHORIZED,
|
|
10320
|
+
json!({
|
|
10321
|
+
"code": "CONSOLE_ACCOUNT_REQUIRED",
|
|
10322
|
+
"message": "this console session is not bound to a console account; sign in again",
|
|
10323
|
+
}),
|
|
10324
|
+
)
|
|
10325
|
+
.into_response();
|
|
10326
|
+
}
|
|
10327
|
+
};
|
|
10328
|
+
if (!state.tenancy.platform_user_exists(&account.user_id)
|
|
10329
|
+
&& state
|
|
10330
|
+
.tenancy
|
|
10331
|
+
.register_platform_user(&account.user_id, &account.display_identity)
|
|
10332
|
+
.is_err())
|
|
10333
|
+
|| !state.tenancy.platform_user_active(&account.user_id)
|
|
9785
10334
|
{
|
|
9786
10335
|
return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
|
|
9787
10336
|
.into_response();
|
|
9788
10337
|
}
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
10338
|
+
(
|
|
10339
|
+
Principal {
|
|
10340
|
+
key_id: account.user_id,
|
|
10341
|
+
scopes: vec![],
|
|
10342
|
+
subject_type: "human".into(),
|
|
10343
|
+
identity_id: Some(account.display_identity),
|
|
10344
|
+
session_id: Some(session.id),
|
|
10345
|
+
},
|
|
10346
|
+
PrincipalClass::ConsoleUser,
|
|
10347
|
+
)
|
|
9796
10348
|
}
|
|
9797
10349
|
(None, None, None) => {
|
|
9798
10350
|
state.metrics.authentication_failure();
|
|
@@ -9812,17 +10364,43 @@ async fn authenticate(
|
|
|
9812
10364
|
}
|
|
9813
10365
|
}
|
|
9814
10366
|
} else {
|
|
9815
|
-
|
|
9816
|
-
|
|
9817
|
-
|
|
9818
|
-
|
|
9819
|
-
|
|
9820
|
-
|
|
9821
|
-
|
|
10367
|
+
(
|
|
10368
|
+
Principal {
|
|
10369
|
+
key_id: "development".to_string(),
|
|
10370
|
+
scopes: vec!["*".to_string()],
|
|
10371
|
+
subject_type: "service".into(),
|
|
10372
|
+
identity_id: None,
|
|
10373
|
+
session_id: None,
|
|
10374
|
+
},
|
|
10375
|
+
PrincipalClass::Development,
|
|
10376
|
+
)
|
|
9822
10377
|
};
|
|
9823
10378
|
|
|
9824
10379
|
let path = request.uri().path();
|
|
9825
|
-
|
|
10380
|
+
// The control plane (`/api/*`) is decided per route, before any handler:
|
|
10381
|
+
// machine keys need the route's capability, console users are held to
|
|
10382
|
+
// tenant/application membership by the handler, and application end-user
|
|
10383
|
+
// sessions are refused. There is no path family without a decision.
|
|
10384
|
+
let control = control_authorization::control_plane_requirement(request.method(), path);
|
|
10385
|
+
if let Some(requirement) = control {
|
|
10386
|
+
if let Err(denial) = control_authorization::decide(class, &principal.scopes, requirement) {
|
|
10387
|
+
state.metrics.authentication_failure();
|
|
10388
|
+
let action = match requirement {
|
|
10389
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10390
|
+
_ => "control-plane",
|
|
10391
|
+
};
|
|
10392
|
+
audit(&state, &principal.key_id, action, path, "denied", 403);
|
|
10393
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10394
|
+
}
|
|
10395
|
+
}
|
|
10396
|
+
let required = if let Some(requirement) = control {
|
|
10397
|
+
match requirement {
|
|
10398
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10399
|
+
_ => "session:self",
|
|
10400
|
+
}
|
|
10401
|
+
} else if path == "/metrics" || path == "/v1/status" {
|
|
10402
|
+
control_authorization::OPERATOR_READ
|
|
10403
|
+
} else if path == "/events" {
|
|
9826
10404
|
"events:read"
|
|
9827
10405
|
} else if path == "/v1/state/diff"
|
|
9828
10406
|
|| path == "/v1/state/conflicts"
|
|
@@ -9882,6 +10460,24 @@ async fn authenticate(
|
|
|
9882
10460
|
} else {
|
|
9883
10461
|
"state:write"
|
|
9884
10462
|
};
|
|
10463
|
+
// An end-user session belongs to one application (self-serve sign-up
|
|
10464
|
+
// registers it there). Its state scopes are meaningful only on the
|
|
10465
|
+
// application API, which checks that membership per request. The legacy
|
|
10466
|
+
// routes (`/collections`, `/transactions`, `/cas`, `/events`, ...) are
|
|
10467
|
+
// scoped to the whole server namespace, so a session there would read and
|
|
10468
|
+
// write every application's records.
|
|
10469
|
+
if class == PrincipalClass::EndUser && !path.starts_with("/v1/") {
|
|
10470
|
+
state.metrics.authentication_failure();
|
|
10471
|
+
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
10472
|
+
let denial = control_authorization::Denial::new(
|
|
10473
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10474
|
+
format!(
|
|
10475
|
+
"end-user sessions can only use the application API (/v1/...) of their own application; {path} is a namespace-wide route — use an application-scoped service key on the server side"
|
|
10476
|
+
),
|
|
10477
|
+
)
|
|
10478
|
+
.with_scope(required);
|
|
10479
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10480
|
+
}
|
|
9885
10481
|
let subject = if principal.subject_type == "human" {
|
|
9886
10482
|
Subject::User(principal.key_id.clone())
|
|
9887
10483
|
} else {
|
|
@@ -9899,16 +10495,26 @@ async fn authenticate(
|
|
|
9899
10495
|
let human_contract = principal.subject_type == "human"
|
|
9900
10496
|
&& path.starts_with("/v1/applications/")
|
|
9901
10497
|
&& path.split('/').any(|segment| segment == "contract");
|
|
10498
|
+
// Two non-control routes are decided by who is asking rather than by a
|
|
10499
|
+
// scope, and are listed here rather than exempted: a user's own contract
|
|
10500
|
+
// drafts (keyed by that user) and the authenticated diagnostics summary.
|
|
9902
10501
|
let authenticated_diagnostics = path == "/v1/diagnostics";
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
&& !authenticated_diagnostics
|
|
9906
|
-
&& !decision.allowed
|
|
9907
|
-
{
|
|
10502
|
+
let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
|
|
10503
|
+
if !decided_by_route && !decision.allowed {
|
|
9908
10504
|
state.metrics.authentication_failure();
|
|
9909
10505
|
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
9910
|
-
|
|
9911
|
-
.
|
|
10506
|
+
let denial = if class == PrincipalClass::EndUser
|
|
10507
|
+
&& (path == "/metrics" || path == "/v1/status" || path.starts_with("/admin/"))
|
|
10508
|
+
{
|
|
10509
|
+
control_authorization::Denial::new(
|
|
10510
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10511
|
+
format!("missing scope {required} — end-user sessions cannot use operator routes; use an operator key"),
|
|
10512
|
+
)
|
|
10513
|
+
.with_scope(required)
|
|
10514
|
+
} else {
|
|
10515
|
+
control_authorization::Denial::missing_scope(required)
|
|
10516
|
+
};
|
|
10517
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
9912
10518
|
}
|
|
9913
10519
|
if path == "/events" {
|
|
9914
10520
|
state.metrics.event_connection();
|
|
@@ -9927,6 +10533,7 @@ async fn authenticate(
|
|
|
9927
10533
|
|
|
9928
10534
|
request.extensions_mut().insert(principal.clone());
|
|
9929
10535
|
request.extensions_mut().insert(authenticated.clone());
|
|
10536
|
+
request.extensions_mut().insert(class);
|
|
9930
10537
|
|
|
9931
10538
|
let principal_id = request
|
|
9932
10539
|
.extensions()
|
|
@@ -10272,14 +10879,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
10272
10879
|
capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
|
|
10273
10880
|
runtime: "self-hosted",
|
|
10274
10881
|
storage: database.storage.label(),
|
|
10275
|
-
fabric
|
|
10882
|
+
// `fabric`, `workflows` and `agents` were constant "healthy" /
|
|
10883
|
+
// "available" here whatever their state (GAP-10). Nothing checks them,
|
|
10884
|
+
// so nothing reports them.
|
|
10276
10885
|
execution: if state.worker_enabled {
|
|
10277
10886
|
"autonomous"
|
|
10278
10887
|
} else {
|
|
10279
10888
|
"state-coordinated"
|
|
10280
10889
|
},
|
|
10281
|
-
workflows: "available",
|
|
10282
|
-
agents: "available",
|
|
10283
10890
|
membership: if clock {
|
|
10284
10891
|
membership
|
|
10285
10892
|
} else {
|
|
@@ -10417,10 +11024,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10417
11024
|
values("--namespace", "default"),
|
|
10418
11025
|
option("--worker"),
|
|
10419
11026
|
)?;
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
|
|
10423
|
-
)
|
|
11027
|
+
// `--secret-file <path>` writes the secret to an owner-only file
|
|
11028
|
+
// and prints only the id, so a bootstrap script never has to put a
|
|
11029
|
+
// credential on stdout (and from there into platform logs).
|
|
11030
|
+
match option("--secret-file") {
|
|
11031
|
+
Some(file) => {
|
|
11032
|
+
write_secret_file(std::path::Path::new(&file), &key.secret)?;
|
|
11033
|
+
println!("API key created (id: {}). Secret written to {file} (mode 0600).", key.id);
|
|
11034
|
+
}
|
|
11035
|
+
None => println!(
|
|
11036
|
+
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
11037
|
+
key.id, key.secret
|
|
11038
|
+
),
|
|
11039
|
+
}
|
|
10424
11040
|
}
|
|
10425
11041
|
"list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
|
|
10426
11042
|
"revoke" => {
|
|
@@ -10430,11 +11046,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10430
11046
|
}
|
|
10431
11047
|
println!("API key revoked: {id}");
|
|
10432
11048
|
}
|
|
10433
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
11049
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--secret-file path] [--id key-id]".into()),
|
|
10434
11050
|
}
|
|
10435
11051
|
Ok(true)
|
|
10436
11052
|
}
|
|
10437
11053
|
|
|
11054
|
+
/// `feltdb-server console-accounts <claim|list> --data <state.log> ...`
|
|
11055
|
+
///
|
|
11056
|
+
/// `claim --email <email> --password-file <file>` binds a console password
|
|
11057
|
+
/// account to the existing pre-0.15.0 platform user registered under that
|
|
11058
|
+
/// e-mail, so its owner keeps their tenants. Before 0.15.0 the website did not
|
|
11059
|
+
/// verify passwords, so such a user cannot be claimed by self-service sign-up;
|
|
11060
|
+
/// the operator verifies the person out of band and runs this. The password
|
|
11061
|
+
/// is read from a file (first line) so it never appears in the process list.
|
|
11062
|
+
fn manage_console_accounts() -> Result<bool, Box<dyn std::error::Error>> {
|
|
11063
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
11064
|
+
if arguments.get(1).map(String::as_str) != Some("console-accounts") {
|
|
11065
|
+
return Ok(false);
|
|
11066
|
+
}
|
|
11067
|
+
let usage = "usage: feltdb-server console-accounts <claim|list> --data <state.log path> [--email email --password-file path]";
|
|
11068
|
+
let option = |name: &str| {
|
|
11069
|
+
arguments
|
|
11070
|
+
.iter()
|
|
11071
|
+
.position(|argument| argument == name)
|
|
11072
|
+
.and_then(|index| arguments.get(index + 1))
|
|
11073
|
+
.cloned()
|
|
11074
|
+
};
|
|
11075
|
+
let data = PathBuf::from(
|
|
11076
|
+
option("--data").unwrap_or_else(|| "./data/feltdb.log".to_string()),
|
|
11077
|
+
);
|
|
11078
|
+
let accounts = ConsoleAccountStore::load(data.with_extension("console-accounts.json"), None)?;
|
|
11079
|
+
let tenancy = TenancyStore::load(data.with_extension("platform.json"))?;
|
|
11080
|
+
match arguments.get(2).map(String::as_str).unwrap_or("") {
|
|
11081
|
+
"claim" => {
|
|
11082
|
+
let email = option("--email").ok_or("console-accounts claim requires --email")?;
|
|
11083
|
+
let file = option("--password-file")
|
|
11084
|
+
.ok_or("console-accounts claim requires --password-file")?;
|
|
11085
|
+
let password = std::fs::read_to_string(&file)?
|
|
11086
|
+
.lines()
|
|
11087
|
+
.next()
|
|
11088
|
+
.unwrap_or_default()
|
|
11089
|
+
.to_string();
|
|
11090
|
+
let claimed = accounts
|
|
11091
|
+
.claim_password(&tenancy, &email, &password)
|
|
11092
|
+
.map_err(|error| format!("{}: {}", error.code, error.message))?;
|
|
11093
|
+
println!(
|
|
11094
|
+
"Console account claimed: {} is bound to existing platform user {}.",
|
|
11095
|
+
claimed.identity, claimed.user_id
|
|
11096
|
+
);
|
|
11097
|
+
}
|
|
11098
|
+
"list" => {
|
|
11099
|
+
let listed: Vec<Value> = accounts
|
|
11100
|
+
.list()
|
|
11101
|
+
.into_iter()
|
|
11102
|
+
.map(|account| {
|
|
11103
|
+
json!({
|
|
11104
|
+
"identity": account.identity,
|
|
11105
|
+
"user_id": account.user_id,
|
|
11106
|
+
"provider": account.provider,
|
|
11107
|
+
"display_identity": account.display_identity,
|
|
11108
|
+
"origin": account.origin,
|
|
11109
|
+
"created_at": account.created_at,
|
|
11110
|
+
})
|
|
11111
|
+
})
|
|
11112
|
+
.collect();
|
|
11113
|
+
println!("{}", serde_json::to_string_pretty(&listed)?);
|
|
11114
|
+
}
|
|
11115
|
+
_ => return Err(usage.into()),
|
|
11116
|
+
}
|
|
11117
|
+
Ok(true)
|
|
11118
|
+
}
|
|
11119
|
+
|
|
11120
|
+
/// Create `path` owner-only (0600) and write `secret` to it; refuses to
|
|
11121
|
+
/// overwrite, so a second bootstrap never silently replaces a delivered secret.
|
|
11122
|
+
fn write_secret_file(path: &std::path::Path, secret: &str) -> std::io::Result<()> {
|
|
11123
|
+
let mut options = OpenOptions::new();
|
|
11124
|
+
options.write(true).create_new(true);
|
|
11125
|
+
#[cfg(unix)]
|
|
11126
|
+
{
|
|
11127
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
11128
|
+
options.mode(0o600);
|
|
11129
|
+
}
|
|
11130
|
+
let mut file = options.open(path)?;
|
|
11131
|
+
file.write_all(secret.as_bytes())?;
|
|
11132
|
+
file.write_all(b"\n")?;
|
|
11133
|
+
file.sync_all()
|
|
11134
|
+
}
|
|
11135
|
+
|
|
10438
11136
|
fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10439
11137
|
let arguments: Vec<String> = std::env::args().collect();
|
|
10440
11138
|
let family = arguments.get(1).map(String::as_str).unwrap_or("");
|
|
@@ -10961,6 +11659,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10961
11659
|
Ok(true)
|
|
10962
11660
|
}
|
|
10963
11661
|
|
|
11662
|
+
/// Where an online backup bundle may be written.
|
|
11663
|
+
///
|
|
11664
|
+
/// With `FELTDB_BACKUP_DIR` set (the managed deployments set it), the request
|
|
11665
|
+
/// names a bundle, not a path: a single relative component placed inside that
|
|
11666
|
+
/// directory. Without it the server is self-hosted and the operator's path is
|
|
11667
|
+
/// used as given, as before.
|
|
11668
|
+
fn backup_output_path(
|
|
11669
|
+
requested: &str,
|
|
11670
|
+
backup_dir: Option<std::ffi::OsString>,
|
|
11671
|
+
) -> Result<PathBuf, ApiError> {
|
|
11672
|
+
let Some(root) = backup_dir.filter(|value| !value.is_empty()) else {
|
|
11673
|
+
return Ok(PathBuf::from(requested));
|
|
11674
|
+
};
|
|
11675
|
+
let name = std::path::Path::new(requested);
|
|
11676
|
+
let mut components = name.components();
|
|
11677
|
+
let single = matches!(
|
|
11678
|
+
(components.next(), components.next()),
|
|
11679
|
+
(Some(std::path::Component::Normal(_)), None)
|
|
11680
|
+
);
|
|
11681
|
+
if !single {
|
|
11682
|
+
return Err(ApiError(
|
|
11683
|
+
StatusCode::BAD_REQUEST,
|
|
11684
|
+
format!(
|
|
11685
|
+
"backup output must be a bundle name, not a path: this server writes bundles inside FELTDB_BACKUP_DIR ({}); send e.g. {{\"output\":\"backup-2026-10-28\"}}",
|
|
11686
|
+
std::path::Path::new(&root).display()
|
|
11687
|
+
),
|
|
11688
|
+
));
|
|
11689
|
+
}
|
|
11690
|
+
Ok(PathBuf::from(root).join(name))
|
|
11691
|
+
}
|
|
11692
|
+
|
|
10964
11693
|
async fn create_online_backup(
|
|
10965
11694
|
State(state): State<AppState>,
|
|
10966
11695
|
Json(request): Json<OnlineBackupRequest>,
|
|
@@ -10990,7 +11719,7 @@ async fn create_online_backup(
|
|
|
10990
11719
|
})
|
|
10991
11720
|
.cloned()
|
|
10992
11721
|
.collect();
|
|
10993
|
-
let output =
|
|
11722
|
+
let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
|
|
10994
11723
|
let source = feltdb_server::backup::BackupSource {
|
|
10995
11724
|
data: state
|
|
10996
11725
|
.backup_artifacts
|
|
@@ -11002,14 +11731,22 @@ async fn create_online_backup(
|
|
|
11002
11731
|
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
11003
11732
|
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
11004
11733
|
};
|
|
11734
|
+
let records = operator_record_path(&state, BACKUP_RECORDS);
|
|
11005
11735
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
11006
|
-
let
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11736
|
+
let manifest = {
|
|
11737
|
+
let _guard = guard;
|
|
11738
|
+
feltdb_server::backup::create_bundle_with_source(
|
|
11739
|
+
&output,
|
|
11740
|
+
&artifacts,
|
|
11741
|
+
&[("database.snapshot.json".to_string(), snapshot_bytes)],
|
|
11742
|
+
source,
|
|
11743
|
+
)?
|
|
11744
|
+
};
|
|
11745
|
+
// Verified after the barrier is released: restoring into scratch and
|
|
11746
|
+
// opening the result is what turns "a bundle exists" into "a bundle
|
|
11747
|
+
// restores", and the record says which of the two is true.
|
|
11748
|
+
record_online_backup(&records, &output, &manifest);
|
|
11749
|
+
Ok::<_, std::io::Error>(manifest)
|
|
11013
11750
|
})
|
|
11014
11751
|
.await
|
|
11015
11752
|
.map_err(|error| {
|
|
@@ -11033,6 +11770,236 @@ async fn create_online_backup(
|
|
|
11033
11770
|
/// finding was that losing security evidence was invisible, and a signal an
|
|
11034
11771
|
/// operator has to know to go and look for is not much better than no signal.
|
|
11035
11772
|
/// It rides with the metrics they already read.
|
|
11773
|
+
const BACKUP_RECORDS: &str = "backups.json";
|
|
11774
|
+
|
|
11775
|
+
/// Operator records live beside the durable log (`<data>.<name>`), on the
|
|
11776
|
+
/// same volume, like the readiness probe they are derived from.
|
|
11777
|
+
fn operator_record_path(state: &AppState, name: &str) -> PathBuf {
|
|
11778
|
+
state.readiness_probe.with_extension(name)
|
|
11779
|
+
}
|
|
11780
|
+
|
|
11781
|
+
fn read_records(path: &std::path::Path) -> Vec<Value> {
|
|
11782
|
+
std::fs::read(path)
|
|
11783
|
+
.ok()
|
|
11784
|
+
.and_then(|bytes| serde_json::from_slice::<Vec<Value>>(&bytes).ok())
|
|
11785
|
+
.unwrap_or_default()
|
|
11786
|
+
}
|
|
11787
|
+
|
|
11788
|
+
/// Append one record; bounded so the file cannot grow without limit.
|
|
11789
|
+
fn append_record(path: &std::path::Path, record: Value) -> std::io::Result<()> {
|
|
11790
|
+
let mut records = read_records(path);
|
|
11791
|
+
records.push(record);
|
|
11792
|
+
let excess = records.len().saturating_sub(100);
|
|
11793
|
+
records.drain(..excess);
|
|
11794
|
+
let temporary = path.with_extension("json.tmp");
|
|
11795
|
+
let mut file = std::fs::File::create(&temporary)?;
|
|
11796
|
+
file.write_all(&serde_json::to_vec_pretty(&records).map_err(std::io::Error::other)?)?;
|
|
11797
|
+
file.sync_all()?;
|
|
11798
|
+
std::fs::rename(&temporary, path)
|
|
11799
|
+
}
|
|
11800
|
+
|
|
11801
|
+
fn record_online_backup(
|
|
11802
|
+
records: &std::path::Path,
|
|
11803
|
+
output: &std::path::Path,
|
|
11804
|
+
manifest: &feltdb_server::backup::BackupManifest,
|
|
11805
|
+
) {
|
|
11806
|
+
let started = Instant::now();
|
|
11807
|
+
let restore = verify_restorable(output).map_err(|error| error.to_string());
|
|
11808
|
+
let record = json!({
|
|
11809
|
+
"kind": "online_backup",
|
|
11810
|
+
"bundle_id": feltdb_server::backup::backup_id(output).ok(),
|
|
11811
|
+
"output": output.display().to_string(),
|
|
11812
|
+
"created_ms": manifest.created_ms,
|
|
11813
|
+
"files": manifest.entries.len(),
|
|
11814
|
+
"verified": restore.is_ok(),
|
|
11815
|
+
"restore_verification": match &restore {
|
|
11816
|
+
Ok(detail) => json!({
|
|
11817
|
+
"method": "bundle verified, restored into a scratch directory on this host and opened",
|
|
11818
|
+
"verified_at_ms": unix_millis(),
|
|
11819
|
+
"duration_ms": started.elapsed().as_millis() as u64,
|
|
11820
|
+
"detail": detail,
|
|
11821
|
+
}),
|
|
11822
|
+
Err(error) => json!({ "method": "scratch restore", "error": error }),
|
|
11823
|
+
},
|
|
11824
|
+
});
|
|
11825
|
+
if let Err(error) = append_record(records, record) {
|
|
11826
|
+
tracing::error!(%error, "failed to record online backup");
|
|
11827
|
+
}
|
|
11828
|
+
}
|
|
11829
|
+
|
|
11830
|
+
fn unix_millis() -> u64 {
|
|
11831
|
+
std::time::SystemTime::now()
|
|
11832
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
11833
|
+
.unwrap_or_default()
|
|
11834
|
+
.as_millis() as u64
|
|
11835
|
+
}
|
|
11836
|
+
|
|
11837
|
+
/// The routes authenticated by a shared service secret rather than an API
|
|
11838
|
+
/// key. They are routed only when deliberately configured, so an authority
|
|
11839
|
+
/// that is not part of a managed Intent Engine deployment or a certification
|
|
11840
|
+
/// run has no such surface at all.
|
|
11841
|
+
fn service_secret_routes() -> Router<AppState> {
|
|
11842
|
+
let mut routes = Router::new();
|
|
11843
|
+
let intent_secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").unwrap_or_default();
|
|
11844
|
+
if intent_secret.len() >= 32 {
|
|
11845
|
+
routes = routes
|
|
11846
|
+
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
11847
|
+
.route(
|
|
11848
|
+
"/internal/intents",
|
|
11849
|
+
axum::routing::post(internal_create_intent),
|
|
11850
|
+
)
|
|
11851
|
+
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
11852
|
+
.route(
|
|
11853
|
+
"/internal/intent-events",
|
|
11854
|
+
axum::routing::post(internal_create_intent_event),
|
|
11855
|
+
);
|
|
11856
|
+
} else if !intent_secret.is_empty() {
|
|
11857
|
+
tracing::warn!("FELTDB_INTENT_SERVICE_SECRET is shorter than 32 bytes; /internal/intents* is not routed");
|
|
11858
|
+
}
|
|
11859
|
+
let certification = matches!(
|
|
11860
|
+
std::env::var("FELTDB_CERTIFICATION_MODE").as_deref(),
|
|
11861
|
+
Ok("1" | "true")
|
|
11862
|
+
);
|
|
11863
|
+
if certification {
|
|
11864
|
+
tracing::warn!("FELTDB_CERTIFICATION_MODE is set: certification fixture deletion is routed");
|
|
11865
|
+
routes = routes.route(
|
|
11866
|
+
"/api/certification/fixtures/{application_id}",
|
|
11867
|
+
axum::routing::delete(delete_certification_fixture),
|
|
11868
|
+
);
|
|
11869
|
+
}
|
|
11870
|
+
routes
|
|
11871
|
+
}
|
|
11872
|
+
|
|
11873
|
+
/// A real write → fsync → read-back probe of the storage volume.
|
|
11874
|
+
fn storage_probe(path: &std::path::Path) -> Value {
|
|
11875
|
+
let started = Instant::now();
|
|
11876
|
+
let token = format!("feltdb-status-probe-{}\n", uuid::Uuid::new_v4().simple());
|
|
11877
|
+
let mut wrote = false;
|
|
11878
|
+
let mut synced = false;
|
|
11879
|
+
let result = (|| -> std::io::Result<bool> {
|
|
11880
|
+
if let Some(parent) = path.parent() {
|
|
11881
|
+
std::fs::create_dir_all(parent)?;
|
|
11882
|
+
}
|
|
11883
|
+
let mut file = OpenOptions::new()
|
|
11884
|
+
.create(true)
|
|
11885
|
+
.write(true)
|
|
11886
|
+
.truncate(true)
|
|
11887
|
+
.open(path)?;
|
|
11888
|
+
file.write_all(token.as_bytes())?;
|
|
11889
|
+
wrote = true;
|
|
11890
|
+
file.sync_all()?;
|
|
11891
|
+
synced = true;
|
|
11892
|
+
Ok(std::fs::read(path)? == token.as_bytes())
|
|
11893
|
+
})();
|
|
11894
|
+
let read_back = matches!(result, Ok(true));
|
|
11895
|
+
json!({
|
|
11896
|
+
"ok": read_back,
|
|
11897
|
+
"write": wrote,
|
|
11898
|
+
"fsync": synced,
|
|
11899
|
+
"read_back": read_back,
|
|
11900
|
+
"checked_at_ms": unix_millis(),
|
|
11901
|
+
"latency_us": started.elapsed().as_micros() as u64,
|
|
11902
|
+
"error": result.err().map(|error| error.to_string()),
|
|
11903
|
+
})
|
|
11904
|
+
}
|
|
11905
|
+
|
|
11906
|
+
/// `GET /v1/status` — the operator's view, needs `operator:read`.
|
|
11907
|
+
///
|
|
11908
|
+
/// Every field is observed by this request or read from a record this server
|
|
11909
|
+
/// wrote when the event happened; nothing is asserted. Availability (this
|
|
11910
|
+
/// process answers and its volume accepts a synced write) is reported apart
|
|
11911
|
+
/// from verified durability (a backup of this state was restored and opened).
|
|
11912
|
+
async fn operator_status(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
11913
|
+
let probe_path = operator_record_path(&state, "status-probe");
|
|
11914
|
+
let backups_path = operator_record_path(&state, BACKUP_RECORDS);
|
|
11915
|
+
let (probe, backups) = tokio::task::spawn_blocking(move || {
|
|
11916
|
+
(storage_probe(&probe_path), read_records(&backups_path))
|
|
11917
|
+
})
|
|
11918
|
+
.await
|
|
11919
|
+
.map_err(|error| {
|
|
11920
|
+
tracing::error!(%error, "status worker failed");
|
|
11921
|
+
ApiError(StatusCode::SERVICE_UNAVAILABLE, "status check temporarily unavailable".into())
|
|
11922
|
+
})?;
|
|
11923
|
+
let database = state.db.health();
|
|
11924
|
+
let last_backup = backups
|
|
11925
|
+
.iter()
|
|
11926
|
+
.rev()
|
|
11927
|
+
.find(|record| record["kind"] == "online_backup")
|
|
11928
|
+
.map(|record| {
|
|
11929
|
+
json!({
|
|
11930
|
+
"bundle_id": record["bundle_id"],
|
|
11931
|
+
"created_ms": record["created_ms"],
|
|
11932
|
+
"output": record["output"],
|
|
11933
|
+
"verified": record["verified"],
|
|
11934
|
+
})
|
|
11935
|
+
});
|
|
11936
|
+
let last_verified_restore = backups
|
|
11937
|
+
.iter()
|
|
11938
|
+
.rev()
|
|
11939
|
+
.find(|record| record["verified"] == true)
|
|
11940
|
+
.map(|record| {
|
|
11941
|
+
json!({
|
|
11942
|
+
"bundle_id": record["bundle_id"],
|
|
11943
|
+
"verified_at_ms": record["restore_verification"]["verified_at_ms"],
|
|
11944
|
+
"method": record["restore_verification"]["method"],
|
|
11945
|
+
})
|
|
11946
|
+
});
|
|
11947
|
+
let clock = state.lease_clock.is_healthy();
|
|
11948
|
+
let membership_healthy = !matches!(
|
|
11949
|
+
state.cluster.proposal().map(|value| value.phase),
|
|
11950
|
+
Some(ProposalPhase::Preparing | ProposalPhase::Prepared | ProposalPhase::Aborted)
|
|
11951
|
+
);
|
|
11952
|
+
let available = probe["ok"] == true;
|
|
11953
|
+
Ok(Json(json!({
|
|
11954
|
+
"status": if available && overall_status(membership_healthy, clock, &database.storage) == "healthy" { "available" } else { "degraded" },
|
|
11955
|
+
"release": {
|
|
11956
|
+
"version": env!("CARGO_PKG_VERSION"),
|
|
11957
|
+
"release_id": option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
11958
|
+
"git_commit": option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
11959
|
+
"build_timestamp": option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
|
|
11960
|
+
"deployment_id": std::env::var("FELTDB_DEPLOYMENT_ID").or_else(|_| std::env::var("FLY_IMAGE_REF")).ok(),
|
|
11961
|
+
"promoted_revision": std::env::var("FELTDB_PROMOTED_REVISION").ok(),
|
|
11962
|
+
},
|
|
11963
|
+
"capabilities": {
|
|
11964
|
+
"public_contract": "feltdb.public.substrate@1.0",
|
|
11965
|
+
"capability_fingerprint": control_plane::public_contract_capability_fingerprint(),
|
|
11966
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
11967
|
+
"authentication": state.auth_enabled,
|
|
11968
|
+
"autonomous_workers": state.worker_enabled,
|
|
11969
|
+
"configured_peers": state.cluster.get().peers.len(),
|
|
11970
|
+
"durable_work_authority": is_work_authority(&state),
|
|
11971
|
+
},
|
|
11972
|
+
"availability": {
|
|
11973
|
+
"serving": true,
|
|
11974
|
+
"storage_writable": available,
|
|
11975
|
+
"clock": if clock { "healthy" } else { "unhealthy" },
|
|
11976
|
+
"membership": if membership_healthy { "healthy" } else { "recovering-or-attention-required" },
|
|
11977
|
+
},
|
|
11978
|
+
"storage": {
|
|
11979
|
+
"replay": database.storage.label(),
|
|
11980
|
+
"durable_format": database.durable_format.to_string(),
|
|
11981
|
+
"probe": probe,
|
|
11982
|
+
},
|
|
11983
|
+
"backups": {
|
|
11984
|
+
"last": last_backup,
|
|
11985
|
+
"recorded": backups.len(),
|
|
11986
|
+
},
|
|
11987
|
+
"restore": {
|
|
11988
|
+
"last_verified": last_verified_restore,
|
|
11989
|
+
"status": if last_verified_restore.is_some() { "verified" } else { "never_verified" },
|
|
11990
|
+
"off_host": "not observable by this server",
|
|
11991
|
+
},
|
|
11992
|
+
"durability": {
|
|
11993
|
+
"verified": last_verified_restore.is_some(),
|
|
11994
|
+
"basis": if last_verified_restore.is_some() {
|
|
11995
|
+
"a backup of this authority was restored into scratch on this host and opened; off-host recovery and power-loss durability are not proven by this endpoint"
|
|
11996
|
+
} else {
|
|
11997
|
+
"no backup of this authority has been restored and verified; availability is not durability"
|
|
11998
|
+
},
|
|
11999
|
+
},
|
|
12000
|
+
})))
|
|
12001
|
+
}
|
|
12002
|
+
|
|
11036
12003
|
async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
|
|
11037
12004
|
let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
|
|
11038
12005
|
if let Some(fields) = body.as_object_mut() {
|
|
@@ -11075,6 +12042,7 @@ async fn sync_pull(
|
|
|
11075
12042
|
operations,
|
|
11076
12043
|
versions: authority_position,
|
|
11077
12044
|
catchup,
|
|
12045
|
+
durable_work_authority: state.work_authority,
|
|
11078
12046
|
}))
|
|
11079
12047
|
}
|
|
11080
12048
|
|
|
@@ -11239,10 +12207,13 @@ fn start_workload_recovery(state: &AppState) {
|
|
|
11239
12207
|
let state = state.clone();
|
|
11240
12208
|
tokio::spawn(async move {
|
|
11241
12209
|
loop {
|
|
11242
|
-
if state.cluster.get().is_member {
|
|
12210
|
+
if state.cluster.get().is_member && is_work_authority(&state) {
|
|
11243
12211
|
let outcome = match (state.mesh.lock(), state.workloads.lock()) {
|
|
12212
|
+
// Deadlines first: work past one is timed out under its
|
|
12213
|
+
// own decision, not recovered as a lost worker.
|
|
11244
12214
|
(Ok(mut mesh), Ok(mut workloads)) => mesh
|
|
11245
|
-
.
|
|
12215
|
+
.enforce_deadlines(unix_seconds_i64(), &mut workloads)
|
|
12216
|
+
.and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
|
|
11246
12217
|
.map(|_| ())
|
|
11247
12218
|
.map_err(|error| error.message),
|
|
11248
12219
|
_ => Err("worker mesh or workload store unavailable".to_string()),
|
|
@@ -11325,8 +12296,12 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11325
12296
|
}
|
|
11326
12297
|
}
|
|
11327
12298
|
|
|
11328
|
-
|
|
11329
|
-
|
|
12299
|
+
// Triggers admit work and the pass claims it: Durable Work writes, so
|
|
12300
|
+
// only on the authority.
|
|
12301
|
+
if is_work_authority(state) {
|
|
12302
|
+
evaluate_state_triggers(state)?;
|
|
12303
|
+
execute_eligible_workloads(state, &worker)?;
|
|
12304
|
+
}
|
|
11330
12305
|
Ok(())
|
|
11331
12306
|
}
|
|
11332
12307
|
|
|
@@ -12141,6 +13116,17 @@ async fn sync_with_peer(
|
|
|
12141
13116
|
.json::<SyncPullResponse>()
|
|
12142
13117
|
.await?;
|
|
12143
13118
|
|
|
13119
|
+
// Two nodes configured as the Durable Work authority would each commit
|
|
13120
|
+
// work the other cannot see in time; both refuse until one is restarted
|
|
13121
|
+
// without `--work-authority`.
|
|
13122
|
+
if state.work_authority && pull.durable_work_authority {
|
|
13123
|
+
if !state
|
|
13124
|
+
.work_authority_conflict
|
|
13125
|
+
.swap(true, std::sync::atomic::Ordering::SeqCst)
|
|
13126
|
+
{
|
|
13127
|
+
tracing::error!(%peer, "two nodes are configured as the Durable Work authority; refusing Durable Work writes");
|
|
13128
|
+
}
|
|
13129
|
+
}
|
|
12144
13130
|
let mut incoming = pull.operations;
|
|
12145
13131
|
incoming.sort_by(|left, right| {
|
|
12146
13132
|
left.instance_id
|
|
@@ -14977,7 +15963,7 @@ async fn commit_transaction(
|
|
|
14977
15963
|
"committed": false,
|
|
14978
15964
|
"code": "PRECONDITION_FAILED",
|
|
14979
15965
|
"error": failure.to_string(),
|
|
14980
|
-
"failure":
|
|
15966
|
+
"failure": failure.public_json(&feltdb::ReadLayout::Direct),
|
|
14981
15967
|
}),
|
|
14982
15968
|
),
|
|
14983
15969
|
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
@@ -14994,7 +15980,29 @@ async fn commit_transaction(
|
|
|
14994
15980
|
}),
|
|
14995
15981
|
)
|
|
14996
15982
|
}
|
|
14997
|
-
|
|
15983
|
+
// The commit could not be made durable. It is neither a lost
|
|
15984
|
+
// race nor a success, and answering 409 here once let a caller
|
|
15985
|
+
// read a storage failure as "someone else won". The outcome is
|
|
15986
|
+
// to be treated as unknown and reconciled by retrying with the
|
|
15987
|
+
// same transaction id.
|
|
15988
|
+
storage @ (FlowError::Io(_) | FlowError::Serde(_)) => ApiError::structured(
|
|
15989
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
15990
|
+
json!({
|
|
15991
|
+
"committed": "unknown",
|
|
15992
|
+
"code": "DURABILITY_FAILURE",
|
|
15993
|
+
"message": storage.to_string(),
|
|
15994
|
+
"transaction_id": request.transaction_id,
|
|
15995
|
+
}),
|
|
15996
|
+
),
|
|
15997
|
+
other => ApiError::structured(
|
|
15998
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
15999
|
+
json!({
|
|
16000
|
+
"committed": "unknown",
|
|
16001
|
+
"code": "EXECUTION_FAILED",
|
|
16002
|
+
"message": other.to_string(),
|
|
16003
|
+
"transaction_id": request.transaction_id,
|
|
16004
|
+
}),
|
|
16005
|
+
),
|
|
14998
16006
|
}
|
|
14999
16007
|
})?;
|
|
15000
16008
|
let operation_ids = state
|
|
@@ -16437,7 +17445,8 @@ async fn control_plane_agent(
|
|
|
16437
17445
|
"workload store unavailable".into(),
|
|
16438
17446
|
)
|
|
16439
17447
|
})?
|
|
16440
|
-
.
|
|
17448
|
+
.fresh_list(&tenant, &application_id)
|
|
17449
|
+
.map_err(workload_error)?
|
|
16441
17450
|
.into_iter()
|
|
16442
17451
|
.filter(|workload| {
|
|
16443
17452
|
serde_json::to_value(&workload.requested_by)
|
|
@@ -17032,3 +18041,32 @@ mod authority_gate_tests {
|
|
|
17032
18041
|
}
|
|
17033
18042
|
}
|
|
17034
18043
|
}
|
|
18044
|
+
|
|
18045
|
+
#[cfg(test)]
|
|
18046
|
+
mod backup_output_tests {
|
|
18047
|
+
use super::backup_output_path;
|
|
18048
|
+
use std::path::PathBuf;
|
|
18049
|
+
|
|
18050
|
+
#[test]
|
|
18051
|
+
fn a_confined_server_writes_named_bundles_inside_its_backup_directory() {
|
|
18052
|
+
let root = Some("/data/backups".into());
|
|
18053
|
+
assert_eq!(
|
|
18054
|
+
backup_output_path("nightly-1", root.clone()).unwrap(),
|
|
18055
|
+
PathBuf::from("/data/backups/nightly-1")
|
|
18056
|
+
);
|
|
18057
|
+
for refused in ["/data/state.log", "../state.log", "a/b", "..", ".", "", "/"] {
|
|
18058
|
+
assert!(
|
|
18059
|
+
backup_output_path(refused, root.clone()).is_err(),
|
|
18060
|
+
"{refused:?} must be refused"
|
|
18061
|
+
);
|
|
18062
|
+
}
|
|
18063
|
+
}
|
|
18064
|
+
|
|
18065
|
+
#[test]
|
|
18066
|
+
fn a_self_hosted_server_uses_the_operator_path() {
|
|
18067
|
+
assert_eq!(
|
|
18068
|
+
backup_output_path("/srv/backups/x", None).unwrap(),
|
|
18069
|
+
PathBuf::from("/srv/backups/x")
|
|
18070
|
+
);
|
|
18071
|
+
}
|
|
18072
|
+
}
|