@feltdb/core 0.4.12 → 0.4.13
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/cli/commands.js +5 -2
- package/dist/cli/index.js +1 -1
- package/dist/create/cli.js +16 -1
- package/dist/create/create.js +30 -16
- package/dist/create/docker-compose-generator.js +48 -9
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +2238 -0
- package/dist/create/server-source/Cargo.toml +7 -0
- package/dist/create/server-source/crates/feltdb/Cargo.lock +175 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +79 -0
- package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +370 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +589 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +259 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +446 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +272 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +378 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +414 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +486 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +273 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +587 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +513 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +395 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +469 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +409 -0
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +627 -0
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +383 -0
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +298 -0
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +1698 -0
- package/dist/create/server-source/crates/feltdb/src/acquisition.rs +286 -0
- package/dist/create/server-source/crates/feltdb/src/admission.rs +192 -0
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +477 -0
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +566 -0
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +475 -0
- package/dist/create/server-source/crates/feltdb/src/application.rs +2244 -0
- package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +1070 -0
- package/dist/create/server-source/crates/feltdb/src/authorization.rs +1030 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +266 -0
- package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +8 -0
- package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +482 -0
- package/dist/create/server-source/crates/feltdb/src/capability.rs +903 -0
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +261 -0
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +78 -0
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +2214 -0
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +194 -0
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +427 -0
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +453 -0
- package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +465 -0
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +618 -0
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +380 -0
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +362 -0
- package/dist/create/server-source/crates/feltdb/src/cron.rs +294 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +474 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +1003 -0
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +536 -0
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +364 -0
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +235 -0
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +207 -0
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +316 -0
- package/dist/create/server-source/crates/feltdb/src/execution.rs +426 -0
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +219 -0
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +779 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +2838 -0
- package/dist/create/server-source/crates/feltdb/src/materialization.rs +184 -0
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +267 -0
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +311 -0
- package/dist/create/server-source/crates/feltdb/src/observability.rs +366 -0
- package/dist/create/server-source/crates/feltdb/src/operation.rs +251 -0
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +438 -0
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +344 -0
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +477 -0
- package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +166 -0
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +261 -0
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +560 -0
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +3226 -0
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +201 -0
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +263 -0
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +484 -0
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +216 -0
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +281 -0
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +323 -0
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +573 -0
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +404 -0
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +418 -0
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +381 -0
- package/dist/create/server-source/crates/feltdb/src/phase_1c_three_node.rs +523 -0
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +334 -0
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +306 -0
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +355 -0
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +395 -0
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +346 -0
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +430 -0
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +400 -0
- package/dist/create/server-source/crates/feltdb/src/provenance.rs +207 -0
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +217 -0
- package/dist/create/server-source/crates/feltdb/src/references.rs +404 -0
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +401 -0
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +160 -0
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +132 -0
- package/dist/create/server-source/crates/feltdb/src/routing.rs +409 -0
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +523 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +3118 -0
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +291 -0
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +376 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +267 -0
- package/dist/create/server-source/crates/feltdb/src/submission.rs +477 -0
- package/dist/create/server-source/crates/feltdb/src/sync.rs +483 -0
- package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +822 -0
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +366 -0
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +473 -0
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +949 -0
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +646 -0
- package/dist/create/server-source/crates/feltdb/src/trigger.rs +390 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +928 -0
- package/dist/create/server-source/crates/feltdb/src/workflow.rs +713 -0
- package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +510 -0
- package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +354 -0
- package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +594 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +1310 -0
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +23 -0
- package/dist/create/server-source/crates/feltdb-server/README.md +79 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +73 -0
- package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +425 -0
- package/dist/create/server-source/crates/feltdb-server/src/artifacts.rs +449 -0
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +59 -0
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +308 -0
- package/dist/create/server-source/crates/feltdb-server/src/authorization.rs +228 -0
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +416 -0
- package/dist/create/server-source/crates/feltdb-server/src/causal.rs +218 -0
- package/dist/create/server-source/crates/feltdb-server/src/certification.rs +223 -0
- package/dist/create/server-source/crates/feltdb-server/src/clock.rs +132 -0
- package/dist/create/server-source/crates/feltdb-server/src/cluster.rs +165 -0
- package/dist/create/server-source/crates/feltdb-server/src/connections.rs +1044 -0
- package/dist/create/server-source/crates/feltdb-server/src/content.rs +111 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +250 -0
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +78 -0
- package/dist/create/server-source/crates/feltdb-server/src/key_provider.rs +247 -0
- package/dist/create/server-source/crates/feltdb-server/src/leases.rs +123 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +25 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +8715 -0
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +97 -0
- package/dist/create/server-source/crates/feltdb-server/src/portable_bundle.rs +350 -0
- package/dist/create/server-source/crates/feltdb-server/src/principals.rs +510 -0
- package/dist/create/server-source/crates/feltdb-server/src/providers.rs +269 -0
- package/dist/create/server-source/crates/feltdb-server/src/releases.rs +2563 -0
- package/dist/create/server-source/crates/feltdb-server/src/sessions.rs +156 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +1097 -0
- package/dist/create/server-source/crates/feltdb-server/src/versions.rs +264 -0
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +31 -0
- package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +1086 -0
- package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
- package/dist/flowspec.d.ts +2 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +28 -4
- package/dist/state-contract.d.ts +7 -1
- package/dist/state-contract.d.ts.map +1 -1
- package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-Q0Nx6gLE.js → components-bHTARBin.js} +156 -46
- package/dist/studio/index.js +31 -28
- package/dist/studio-app/assets/{index-BHOmLZF-.js → index-BqKquLuU.js} +8 -8
- package/dist/studio-app/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,2244 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
use sha2::{Digest, Sha256};
|
|
4
|
+
use std::{
|
|
5
|
+
collections::{BTreeMap, BTreeSet},
|
|
6
|
+
fs,
|
|
7
|
+
path::PathBuf,
|
|
8
|
+
sync::{Arc, RwLock},
|
|
9
|
+
time::{SystemTime, UNIX_EPOCH},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
|
|
13
|
+
fn default_state_schema_version() -> u64 {
|
|
14
|
+
1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
18
|
+
pub struct ApplicationMetadata {
|
|
19
|
+
pub name: String,
|
|
20
|
+
#[serde(default)]
|
|
21
|
+
pub description: String,
|
|
22
|
+
#[serde(default)]
|
|
23
|
+
pub labels: BTreeMap<String, String>,
|
|
24
|
+
}
|
|
25
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
26
|
+
pub struct FieldDefinition {
|
|
27
|
+
pub name: String,
|
|
28
|
+
#[serde(rename = "type")]
|
|
29
|
+
pub field_type: String,
|
|
30
|
+
#[serde(default)]
|
|
31
|
+
pub required: bool,
|
|
32
|
+
#[serde(default)]
|
|
33
|
+
pub nullable: bool,
|
|
34
|
+
#[serde(default)]
|
|
35
|
+
pub default: Option<Value>,
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
pub enum_values: Vec<String>,
|
|
38
|
+
#[serde(default)]
|
|
39
|
+
pub reference: Option<String>,
|
|
40
|
+
#[serde(default)]
|
|
41
|
+
pub computed: Option<String>,
|
|
42
|
+
}
|
|
43
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
44
|
+
pub struct CollectionDefinition {
|
|
45
|
+
pub name: String,
|
|
46
|
+
#[serde(default)]
|
|
47
|
+
pub fields: Vec<FieldDefinition>,
|
|
48
|
+
}
|
|
49
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
50
|
+
pub struct IndexDefinition {
|
|
51
|
+
pub name: String,
|
|
52
|
+
pub collection: String,
|
|
53
|
+
pub fields: Vec<String>,
|
|
54
|
+
#[serde(default)]
|
|
55
|
+
pub unique: bool,
|
|
56
|
+
#[serde(default)]
|
|
57
|
+
pub sparse: bool,
|
|
58
|
+
#[serde(default = "default_index_version")]
|
|
59
|
+
pub version: u32,
|
|
60
|
+
}
|
|
61
|
+
fn default_index_version() -> u32 {
|
|
62
|
+
1
|
|
63
|
+
}
|
|
64
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
65
|
+
pub struct ReferenceDefinition {
|
|
66
|
+
pub name: String,
|
|
67
|
+
pub from_collection: String,
|
|
68
|
+
pub from_field: String,
|
|
69
|
+
pub to_collection: String,
|
|
70
|
+
pub to_field: String,
|
|
71
|
+
}
|
|
72
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
73
|
+
pub struct QueryDefinition {
|
|
74
|
+
pub name: String,
|
|
75
|
+
pub collection: String,
|
|
76
|
+
#[serde(default)]
|
|
77
|
+
pub filter: Value,
|
|
78
|
+
#[serde(default)]
|
|
79
|
+
pub fields: Vec<String>,
|
|
80
|
+
}
|
|
81
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
82
|
+
#[serde(rename_all = "snake_case")]
|
|
83
|
+
pub enum ActionOperation {
|
|
84
|
+
Create,
|
|
85
|
+
Update,
|
|
86
|
+
Delete,
|
|
87
|
+
}
|
|
88
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
89
|
+
pub struct ActionDefinition {
|
|
90
|
+
pub name: String,
|
|
91
|
+
pub operation: ActionOperation,
|
|
92
|
+
pub collection: String,
|
|
93
|
+
#[serde(default)]
|
|
94
|
+
pub input_fields: Vec<String>,
|
|
95
|
+
#[serde(default)]
|
|
96
|
+
pub required_capabilities: Vec<String>,
|
|
97
|
+
#[serde(default)]
|
|
98
|
+
pub audit: bool,
|
|
99
|
+
}
|
|
100
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
101
|
+
pub struct WorkflowStepDefinition {
|
|
102
|
+
pub name: String,
|
|
103
|
+
#[serde(default)]
|
|
104
|
+
pub capability: Option<String>,
|
|
105
|
+
}
|
|
106
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
107
|
+
pub struct WorkflowDefinition {
|
|
108
|
+
pub name: String,
|
|
109
|
+
#[serde(default)]
|
|
110
|
+
pub steps: Vec<WorkflowStepDefinition>,
|
|
111
|
+
}
|
|
112
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
113
|
+
pub struct TriggerDefinition {
|
|
114
|
+
pub name: String,
|
|
115
|
+
pub event: String,
|
|
116
|
+
pub workflow: String,
|
|
117
|
+
}
|
|
118
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
119
|
+
pub struct ScheduleDefinition {
|
|
120
|
+
pub name: String,
|
|
121
|
+
pub expression: String,
|
|
122
|
+
pub workflow: String,
|
|
123
|
+
}
|
|
124
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
125
|
+
pub struct AgentDefinition {
|
|
126
|
+
pub name: String,
|
|
127
|
+
#[serde(default)]
|
|
128
|
+
pub capabilities: Vec<String>,
|
|
129
|
+
}
|
|
130
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
131
|
+
pub struct CapabilityBinding {
|
|
132
|
+
pub name: String,
|
|
133
|
+
#[serde(default)]
|
|
134
|
+
pub provider: Option<String>,
|
|
135
|
+
}
|
|
136
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
137
|
+
pub struct SecretReference {
|
|
138
|
+
pub binding: String,
|
|
139
|
+
pub secret_id: String,
|
|
140
|
+
}
|
|
141
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
142
|
+
pub struct ConnectionBinding {
|
|
143
|
+
pub name: String,
|
|
144
|
+
pub connection_id: String,
|
|
145
|
+
#[serde(default)]
|
|
146
|
+
pub configuration: Value,
|
|
147
|
+
#[serde(default)]
|
|
148
|
+
pub secrets: Vec<SecretReference>,
|
|
149
|
+
}
|
|
150
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
151
|
+
pub struct PolicyDefinition {
|
|
152
|
+
pub name: String,
|
|
153
|
+
pub resource: String,
|
|
154
|
+
#[serde(default)]
|
|
155
|
+
pub capabilities: Vec<String>,
|
|
156
|
+
}
|
|
157
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
158
|
+
pub struct DataBinding {
|
|
159
|
+
pub name: String,
|
|
160
|
+
#[serde(default)]
|
|
161
|
+
pub collection: Option<String>,
|
|
162
|
+
#[serde(default)]
|
|
163
|
+
pub query: Option<String>,
|
|
164
|
+
#[serde(default)]
|
|
165
|
+
pub fields: Vec<String>,
|
|
166
|
+
}
|
|
167
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
168
|
+
pub struct FormDefinition {
|
|
169
|
+
pub name: String,
|
|
170
|
+
pub collection: String,
|
|
171
|
+
#[serde(default)]
|
|
172
|
+
pub fields: Vec<String>,
|
|
173
|
+
}
|
|
174
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
175
|
+
pub struct DashboardDefinition {
|
|
176
|
+
pub name: String,
|
|
177
|
+
#[serde(default)]
|
|
178
|
+
pub bindings: Vec<String>,
|
|
179
|
+
}
|
|
180
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
181
|
+
pub struct RouteDefinition {
|
|
182
|
+
pub path: String,
|
|
183
|
+
pub view: String,
|
|
184
|
+
}
|
|
185
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
186
|
+
pub struct UiApplicationModel {
|
|
187
|
+
#[serde(default)]
|
|
188
|
+
pub routes: Vec<RouteDefinition>,
|
|
189
|
+
#[serde(default)]
|
|
190
|
+
pub bindings: Vec<DataBinding>,
|
|
191
|
+
#[serde(default)]
|
|
192
|
+
pub forms: Vec<FormDefinition>,
|
|
193
|
+
#[serde(default)]
|
|
194
|
+
pub dashboards: Vec<DashboardDefinition>,
|
|
195
|
+
}
|
|
196
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
197
|
+
pub struct EnvironmentDefinition {
|
|
198
|
+
pub name: String,
|
|
199
|
+
#[serde(default)]
|
|
200
|
+
pub configuration: BTreeMap<String, String>,
|
|
201
|
+
}
|
|
202
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
203
|
+
pub struct ArtifactReference {
|
|
204
|
+
pub name: String,
|
|
205
|
+
pub uri: String,
|
|
206
|
+
pub media_type: String,
|
|
207
|
+
}
|
|
208
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
209
|
+
pub struct DeploymentIntent {
|
|
210
|
+
#[serde(default)]
|
|
211
|
+
pub environments: Vec<String>,
|
|
212
|
+
#[serde(default)]
|
|
213
|
+
pub placement: Option<String>,
|
|
214
|
+
}
|
|
215
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
216
|
+
pub struct RuntimeRequirement {
|
|
217
|
+
#[serde(default = "runtime_contract_version")]
|
|
218
|
+
pub contract: u32,
|
|
219
|
+
#[serde(default = "minimum_engine_version")]
|
|
220
|
+
pub minimum_engine: String,
|
|
221
|
+
#[serde(default)]
|
|
222
|
+
pub maximum_engine_exclusive: Option<String>,
|
|
223
|
+
}
|
|
224
|
+
impl Default for RuntimeRequirement {
|
|
225
|
+
fn default() -> Self {
|
|
226
|
+
Self {
|
|
227
|
+
contract: runtime_contract_version(),
|
|
228
|
+
minimum_engine: minimum_engine_version(),
|
|
229
|
+
maximum_engine_exclusive: Some("2.0.0".into()),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
fn runtime_contract_version() -> u32 {
|
|
234
|
+
1
|
|
235
|
+
}
|
|
236
|
+
fn minimum_engine_version() -> String {
|
|
237
|
+
"0.2.0".into()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
241
|
+
pub struct ApplicationManifest {
|
|
242
|
+
pub application_id: String,
|
|
243
|
+
pub tenant_id: String,
|
|
244
|
+
pub schema: u32,
|
|
245
|
+
#[serde(default = "default_state_schema_version")]
|
|
246
|
+
pub state_schema_version: u64,
|
|
247
|
+
pub metadata: ApplicationMetadata,
|
|
248
|
+
#[serde(default)]
|
|
249
|
+
pub collections: Vec<CollectionDefinition>,
|
|
250
|
+
#[serde(default)]
|
|
251
|
+
pub indexes: Vec<IndexDefinition>,
|
|
252
|
+
#[serde(default)]
|
|
253
|
+
pub references: Vec<ReferenceDefinition>,
|
|
254
|
+
#[serde(default)]
|
|
255
|
+
pub queries: Vec<QueryDefinition>,
|
|
256
|
+
#[serde(default)]
|
|
257
|
+
pub actions: Vec<ActionDefinition>,
|
|
258
|
+
#[serde(default)]
|
|
259
|
+
pub workflows: Vec<WorkflowDefinition>,
|
|
260
|
+
#[serde(default)]
|
|
261
|
+
pub triggers: Vec<TriggerDefinition>,
|
|
262
|
+
#[serde(default)]
|
|
263
|
+
pub schedules: Vec<ScheduleDefinition>,
|
|
264
|
+
#[serde(default)]
|
|
265
|
+
pub agents: Vec<AgentDefinition>,
|
|
266
|
+
#[serde(default)]
|
|
267
|
+
pub capabilities: Vec<CapabilityBinding>,
|
|
268
|
+
#[serde(default)]
|
|
269
|
+
pub connections: Vec<ConnectionBinding>,
|
|
270
|
+
#[serde(default)]
|
|
271
|
+
pub policies: Vec<PolicyDefinition>,
|
|
272
|
+
#[serde(default)]
|
|
273
|
+
pub ui: UiApplicationModel,
|
|
274
|
+
#[serde(default)]
|
|
275
|
+
pub environments: Vec<EnvironmentDefinition>,
|
|
276
|
+
#[serde(default)]
|
|
277
|
+
pub artifacts: Vec<ArtifactReference>,
|
|
278
|
+
#[serde(default)]
|
|
279
|
+
pub deployment: DeploymentIntent,
|
|
280
|
+
#[serde(default)]
|
|
281
|
+
pub runtime: RuntimeRequirement,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
impl ApplicationManifest {
|
|
285
|
+
pub fn empty(
|
|
286
|
+
tenant_id: impl Into<String>,
|
|
287
|
+
application_id: impl Into<String>,
|
|
288
|
+
name: impl Into<String>,
|
|
289
|
+
) -> Self {
|
|
290
|
+
Self {
|
|
291
|
+
application_id: application_id.into(),
|
|
292
|
+
tenant_id: tenant_id.into(),
|
|
293
|
+
schema: MANIFEST_SCHEMA_VERSION,
|
|
294
|
+
state_schema_version: 1,
|
|
295
|
+
metadata: ApplicationMetadata {
|
|
296
|
+
name: name.into(),
|
|
297
|
+
..Default::default()
|
|
298
|
+
},
|
|
299
|
+
collections: vec![],
|
|
300
|
+
indexes: vec![],
|
|
301
|
+
references: vec![],
|
|
302
|
+
queries: vec![],
|
|
303
|
+
actions: vec![],
|
|
304
|
+
workflows: vec![],
|
|
305
|
+
triggers: vec![],
|
|
306
|
+
schedules: vec![],
|
|
307
|
+
agents: vec![],
|
|
308
|
+
capabilities: vec![],
|
|
309
|
+
connections: vec![],
|
|
310
|
+
policies: vec![],
|
|
311
|
+
ui: UiApplicationModel::default(),
|
|
312
|
+
environments: vec![
|
|
313
|
+
EnvironmentDefinition {
|
|
314
|
+
name: "development".into(),
|
|
315
|
+
configuration: BTreeMap::new(),
|
|
316
|
+
},
|
|
317
|
+
EnvironmentDefinition {
|
|
318
|
+
name: "preview".into(),
|
|
319
|
+
configuration: BTreeMap::new(),
|
|
320
|
+
},
|
|
321
|
+
EnvironmentDefinition {
|
|
322
|
+
name: "staging".into(),
|
|
323
|
+
configuration: BTreeMap::new(),
|
|
324
|
+
},
|
|
325
|
+
EnvironmentDefinition {
|
|
326
|
+
name: "production".into(),
|
|
327
|
+
configuration: BTreeMap::new(),
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
artifacts: vec![],
|
|
331
|
+
deployment: DeploymentIntent {
|
|
332
|
+
environments: vec![
|
|
333
|
+
"development".into(),
|
|
334
|
+
"preview".into(),
|
|
335
|
+
"staging".into(),
|
|
336
|
+
"production".into(),
|
|
337
|
+
],
|
|
338
|
+
placement: None,
|
|
339
|
+
},
|
|
340
|
+
runtime: RuntimeRequirement::default(),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
346
|
+
#[serde(rename_all = "snake_case")]
|
|
347
|
+
pub enum Compatibility {
|
|
348
|
+
Compatible,
|
|
349
|
+
RequiresMigration,
|
|
350
|
+
Breaking,
|
|
351
|
+
Invalid,
|
|
352
|
+
}
|
|
353
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
354
|
+
pub struct ValidationIssue {
|
|
355
|
+
pub category: String,
|
|
356
|
+
pub path: String,
|
|
357
|
+
pub message: String,
|
|
358
|
+
}
|
|
359
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
360
|
+
pub struct ValidationReport {
|
|
361
|
+
pub valid: bool,
|
|
362
|
+
pub compatibility: Compatibility,
|
|
363
|
+
pub issues: Vec<ValidationIssue>,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fn unique(
|
|
367
|
+
issues: &mut Vec<ValidationIssue>,
|
|
368
|
+
category: &str,
|
|
369
|
+
values: impl IntoIterator<Item = String>,
|
|
370
|
+
) {
|
|
371
|
+
let mut seen = BTreeSet::new();
|
|
372
|
+
for value in values {
|
|
373
|
+
if value.trim().is_empty() {
|
|
374
|
+
issues.push(ValidationIssue {
|
|
375
|
+
category: "structural".into(),
|
|
376
|
+
path: category.into(),
|
|
377
|
+
message: "name cannot be empty".into(),
|
|
378
|
+
});
|
|
379
|
+
} else if !seen.insert(value.clone()) {
|
|
380
|
+
issues.push(ValidationIssue {
|
|
381
|
+
category: "structural".into(),
|
|
382
|
+
path: format!("{category}.{value}"),
|
|
383
|
+
message: format!("duplicate {category} name"),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
fn contains_plaintext_secret(value: &Value) -> bool {
|
|
389
|
+
match value {
|
|
390
|
+
Value::Object(map) => map.iter().any(|(key, value)| {
|
|
391
|
+
matches!(
|
|
392
|
+
key.to_ascii_lowercase().as_str(),
|
|
393
|
+
"password" | "secret" | "token" | "api_key" | "apikey" | "private_key"
|
|
394
|
+
) && value.as_str().is_some_and(|v| !v.is_empty())
|
|
395
|
+
|| contains_plaintext_secret(value)
|
|
396
|
+
}),
|
|
397
|
+
Value::Array(values) => values.iter().any(contains_plaintext_secret),
|
|
398
|
+
_ => false,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
pub fn validate_manifest(
|
|
403
|
+
manifest: &ApplicationManifest,
|
|
404
|
+
expected_tenant: &str,
|
|
405
|
+
expected_application: &str,
|
|
406
|
+
previous: Option<&ApplicationManifest>,
|
|
407
|
+
) -> ValidationReport {
|
|
408
|
+
let mut issues = vec![];
|
|
409
|
+
if manifest.schema != MANIFEST_SCHEMA_VERSION {
|
|
410
|
+
issues.push(ValidationIssue {
|
|
411
|
+
category: "compatibility".into(),
|
|
412
|
+
path: "schema".into(),
|
|
413
|
+
message: format!("unsupported manifest schema {}", manifest.schema),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if manifest.tenant_id != expected_tenant {
|
|
417
|
+
issues.push(ValidationIssue {
|
|
418
|
+
category: "security".into(),
|
|
419
|
+
path: "tenant_id".into(),
|
|
420
|
+
message: "cross-tenant manifest rejected".into(),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if manifest.application_id != expected_application {
|
|
424
|
+
issues.push(ValidationIssue {
|
|
425
|
+
category: "security".into(),
|
|
426
|
+
path: "application_id".into(),
|
|
427
|
+
message: "cross-application manifest rejected".into(),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
unique(
|
|
431
|
+
&mut issues,
|
|
432
|
+
"collections",
|
|
433
|
+
manifest.collections.iter().map(|v| v.name.clone()),
|
|
434
|
+
);
|
|
435
|
+
unique(
|
|
436
|
+
&mut issues,
|
|
437
|
+
"indexes",
|
|
438
|
+
manifest.indexes.iter().map(|v| v.name.clone()),
|
|
439
|
+
);
|
|
440
|
+
unique(
|
|
441
|
+
&mut issues,
|
|
442
|
+
"workflows",
|
|
443
|
+
manifest.workflows.iter().map(|v| v.name.clone()),
|
|
444
|
+
);
|
|
445
|
+
unique(
|
|
446
|
+
&mut issues,
|
|
447
|
+
"actions",
|
|
448
|
+
manifest.actions.iter().map(|v| v.name.clone()),
|
|
449
|
+
);
|
|
450
|
+
unique(
|
|
451
|
+
&mut issues,
|
|
452
|
+
"capabilities",
|
|
453
|
+
manifest.capabilities.iter().map(|v| v.name.clone()),
|
|
454
|
+
);
|
|
455
|
+
unique(
|
|
456
|
+
&mut issues,
|
|
457
|
+
"connections",
|
|
458
|
+
manifest.connections.iter().map(|v| v.name.clone()),
|
|
459
|
+
);
|
|
460
|
+
unique(
|
|
461
|
+
&mut issues,
|
|
462
|
+
"routes",
|
|
463
|
+
manifest.ui.routes.iter().map(|v| v.path.clone()),
|
|
464
|
+
);
|
|
465
|
+
unique(
|
|
466
|
+
&mut issues,
|
|
467
|
+
"artifacts",
|
|
468
|
+
manifest.artifacts.iter().map(|v| v.name.clone()),
|
|
469
|
+
);
|
|
470
|
+
unique(
|
|
471
|
+
&mut issues,
|
|
472
|
+
"environments",
|
|
473
|
+
manifest.environments.iter().map(|v| v.name.clone()),
|
|
474
|
+
);
|
|
475
|
+
let collections: BTreeMap<_, _> = manifest
|
|
476
|
+
.collections
|
|
477
|
+
.iter()
|
|
478
|
+
.map(|v| (v.name.as_str(), v))
|
|
479
|
+
.collect();
|
|
480
|
+
for collection in &manifest.collections {
|
|
481
|
+
unique(
|
|
482
|
+
&mut issues,
|
|
483
|
+
&format!("collections.{}.fields", collection.name),
|
|
484
|
+
collection.fields.iter().map(|v| v.name.clone()),
|
|
485
|
+
);
|
|
486
|
+
for field in &collection.fields {
|
|
487
|
+
if !crate::state_contract::valid_manifest_field_type(&field.field_type) {
|
|
488
|
+
issues.push(issue(
|
|
489
|
+
"state_schema",
|
|
490
|
+
format!("collections.{}.fields.{}", collection.name, field.name),
|
|
491
|
+
"unsupported field type",
|
|
492
|
+
));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
let field_exists = |collection: &str, field: &str| {
|
|
497
|
+
collections
|
|
498
|
+
.get(collection)
|
|
499
|
+
.is_some_and(|value| value.fields.iter().any(|candidate| candidate.name == field))
|
|
500
|
+
};
|
|
501
|
+
for index in &manifest.indexes {
|
|
502
|
+
if !collections.contains_key(index.collection.as_str()) {
|
|
503
|
+
issues.push(issue(
|
|
504
|
+
"semantic",
|
|
505
|
+
format!("indexes.{}", index.name),
|
|
506
|
+
"collection does not exist",
|
|
507
|
+
));
|
|
508
|
+
}
|
|
509
|
+
for field in &index.fields {
|
|
510
|
+
if !field_exists(&index.collection, field) {
|
|
511
|
+
issues.push(issue(
|
|
512
|
+
"semantic",
|
|
513
|
+
format!("indexes.{}.fields.{field}", index.name),
|
|
514
|
+
"field does not exist",
|
|
515
|
+
));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for reference in &manifest.references {
|
|
520
|
+
if !field_exists(&reference.from_collection, &reference.from_field)
|
|
521
|
+
|| !field_exists(&reference.to_collection, &reference.to_field)
|
|
522
|
+
{
|
|
523
|
+
issues.push(issue(
|
|
524
|
+
"semantic",
|
|
525
|
+
format!("references.{}", reference.name),
|
|
526
|
+
"referenced collection or field does not exist",
|
|
527
|
+
));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
let capabilities: BTreeSet<_> = manifest
|
|
531
|
+
.capabilities
|
|
532
|
+
.iter()
|
|
533
|
+
.map(|v| v.name.as_str())
|
|
534
|
+
.collect();
|
|
535
|
+
let workflows: BTreeSet<_> = manifest.workflows.iter().map(|v| v.name.as_str()).collect();
|
|
536
|
+
let queries: BTreeSet<_> = manifest.queries.iter().map(|v| v.name.as_str()).collect();
|
|
537
|
+
let bindings: BTreeSet<_> = manifest
|
|
538
|
+
.ui
|
|
539
|
+
.bindings
|
|
540
|
+
.iter()
|
|
541
|
+
.map(|v| v.name.as_str())
|
|
542
|
+
.collect();
|
|
543
|
+
let connections: BTreeSet<_> = manifest
|
|
544
|
+
.connections
|
|
545
|
+
.iter()
|
|
546
|
+
.map(|v| v.name.as_str())
|
|
547
|
+
.collect();
|
|
548
|
+
for workflow in &manifest.workflows {
|
|
549
|
+
for step in &workflow.steps {
|
|
550
|
+
if let Some(capability) = &step.capability {
|
|
551
|
+
if !capabilities.contains(capability.as_str()) {
|
|
552
|
+
issues.push(issue(
|
|
553
|
+
"semantic",
|
|
554
|
+
format!("workflows.{}.steps.{}", workflow.name, step.name),
|
|
555
|
+
"capability does not exist",
|
|
556
|
+
));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for agent in &manifest.agents {
|
|
562
|
+
for capability in &agent.capabilities {
|
|
563
|
+
if !capabilities.contains(capability.as_str()) {
|
|
564
|
+
issues.push(issue(
|
|
565
|
+
"security",
|
|
566
|
+
format!("agents.{}.capabilities", agent.name),
|
|
567
|
+
"agent capability is not application-bound",
|
|
568
|
+
));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for trigger in &manifest.triggers {
|
|
573
|
+
if !workflows.contains(trigger.workflow.as_str()) {
|
|
574
|
+
issues.push(issue(
|
|
575
|
+
"semantic",
|
|
576
|
+
format!("triggers.{}", trigger.name),
|
|
577
|
+
"workflow does not exist",
|
|
578
|
+
));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for schedule in &manifest.schedules {
|
|
582
|
+
if !workflows.contains(schedule.workflow.as_str()) {
|
|
583
|
+
issues.push(issue(
|
|
584
|
+
"semantic",
|
|
585
|
+
format!("schedules.{}", schedule.name),
|
|
586
|
+
"workflow does not exist",
|
|
587
|
+
));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
for query in &manifest.queries {
|
|
591
|
+
if !collections.contains_key(query.collection.as_str()) {
|
|
592
|
+
issues.push(issue(
|
|
593
|
+
"semantic",
|
|
594
|
+
format!("queries.{}", query.name),
|
|
595
|
+
"collection does not exist",
|
|
596
|
+
));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for action in &manifest.actions {
|
|
600
|
+
if !collections.contains_key(action.collection.as_str()) {
|
|
601
|
+
issues.push(issue(
|
|
602
|
+
"semantic",
|
|
603
|
+
format!("actions.{}", action.name),
|
|
604
|
+
"collection does not exist",
|
|
605
|
+
));
|
|
606
|
+
}
|
|
607
|
+
if action
|
|
608
|
+
.input_fields
|
|
609
|
+
.iter()
|
|
610
|
+
.any(|field| !field_exists(&action.collection, field))
|
|
611
|
+
{
|
|
612
|
+
issues.push(issue(
|
|
613
|
+
"semantic",
|
|
614
|
+
format!("actions.{}.input_fields", action.name),
|
|
615
|
+
"action field does not exist",
|
|
616
|
+
));
|
|
617
|
+
}
|
|
618
|
+
if action
|
|
619
|
+
.required_capabilities
|
|
620
|
+
.iter()
|
|
621
|
+
.any(|capability| !capabilities.contains(capability.as_str()))
|
|
622
|
+
{
|
|
623
|
+
issues.push(issue(
|
|
624
|
+
"security",
|
|
625
|
+
format!("actions.{}.required_capabilities", action.name),
|
|
626
|
+
"action capability is not application-bound",
|
|
627
|
+
));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for binding in &manifest.ui.bindings {
|
|
631
|
+
if binding
|
|
632
|
+
.collection
|
|
633
|
+
.as_deref()
|
|
634
|
+
.is_some_and(|v| !collections.contains_key(v))
|
|
635
|
+
|| binding
|
|
636
|
+
.query
|
|
637
|
+
.as_deref()
|
|
638
|
+
.is_some_and(|v| !queries.contains(v))
|
|
639
|
+
{
|
|
640
|
+
issues.push(issue(
|
|
641
|
+
"semantic",
|
|
642
|
+
format!("ui.bindings.{}", binding.name),
|
|
643
|
+
"state or query binding does not exist",
|
|
644
|
+
));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for form in &manifest.ui.forms {
|
|
648
|
+
if !collections.contains_key(form.collection.as_str())
|
|
649
|
+
|| form
|
|
650
|
+
.fields
|
|
651
|
+
.iter()
|
|
652
|
+
.any(|field| !field_exists(&form.collection, field))
|
|
653
|
+
{
|
|
654
|
+
issues.push(issue(
|
|
655
|
+
"semantic",
|
|
656
|
+
format!("ui.forms.{}", form.name),
|
|
657
|
+
"form collection or field does not exist",
|
|
658
|
+
));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
for dashboard in &manifest.ui.dashboards {
|
|
662
|
+
if dashboard
|
|
663
|
+
.bindings
|
|
664
|
+
.iter()
|
|
665
|
+
.any(|v| !bindings.contains(v.as_str()))
|
|
666
|
+
{
|
|
667
|
+
issues.push(issue(
|
|
668
|
+
"semantic",
|
|
669
|
+
format!("ui.dashboards.{}", dashboard.name),
|
|
670
|
+
"dashboard binding does not exist",
|
|
671
|
+
));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
for artifact in &manifest.artifacts {
|
|
675
|
+
if !artifact.uri.starts_with("artifact://sha256:") {
|
|
676
|
+
issues.push(issue(
|
|
677
|
+
"structural",
|
|
678
|
+
format!("artifacts.{}", artifact.name),
|
|
679
|
+
"artifact URI must be content-addressed",
|
|
680
|
+
));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if manifest.deployment.environments.iter().any(|v| {
|
|
684
|
+
!manifest
|
|
685
|
+
.environments
|
|
686
|
+
.iter()
|
|
687
|
+
.any(|candidate| &candidate.name == v)
|
|
688
|
+
}) {
|
|
689
|
+
issues.push(issue(
|
|
690
|
+
"semantic",
|
|
691
|
+
"deployment.environments",
|
|
692
|
+
"deployment references an unknown environment",
|
|
693
|
+
));
|
|
694
|
+
}
|
|
695
|
+
for connection in &manifest.connections {
|
|
696
|
+
if contains_plaintext_secret(&connection.configuration) {
|
|
697
|
+
issues.push(issue(
|
|
698
|
+
"security",
|
|
699
|
+
format!("connections.{}.configuration", connection.name),
|
|
700
|
+
"plaintext secrets are forbidden; use secret references",
|
|
701
|
+
));
|
|
702
|
+
}
|
|
703
|
+
for secret in &connection.secrets {
|
|
704
|
+
if secret.secret_id.trim().is_empty() {
|
|
705
|
+
issues.push(issue(
|
|
706
|
+
"security",
|
|
707
|
+
format!("connections.{}.secrets", connection.name),
|
|
708
|
+
"secret reference is empty",
|
|
709
|
+
));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for environment in &manifest.environments {
|
|
714
|
+
if environment.configuration.iter().any(|(key, value)| {
|
|
715
|
+
matches!(
|
|
716
|
+
key.to_ascii_lowercase().as_str(),
|
|
717
|
+
"password" | "secret" | "token" | "api_key" | "apikey" | "private_key"
|
|
718
|
+
) && !value.is_empty()
|
|
719
|
+
}) {
|
|
720
|
+
issues.push(issue(
|
|
721
|
+
"security",
|
|
722
|
+
format!("environments.{}.configuration", environment.name),
|
|
723
|
+
"plaintext environment secrets are forbidden; use connection secret references",
|
|
724
|
+
));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
for policy in &manifest.policies {
|
|
728
|
+
if let Some(name) = policy.resource.strip_prefix("connection:") {
|
|
729
|
+
if !connections.contains(name) {
|
|
730
|
+
issues.push(issue(
|
|
731
|
+
"semantic",
|
|
732
|
+
format!("policies.{}", policy.name),
|
|
733
|
+
"connection resource does not exist",
|
|
734
|
+
));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
let state_schema = crate::state_contract::schema_from_manifest(manifest, "validation");
|
|
739
|
+
let schema_validation = crate::state_contract::validate_schema(&state_schema);
|
|
740
|
+
for schema_issue in schema_validation.issues {
|
|
741
|
+
issues.push(issue(
|
|
742
|
+
"state_schema",
|
|
743
|
+
format!("state_schema.{}", schema_issue.path),
|
|
744
|
+
&format!("{}: {}", schema_issue.code, schema_issue.message),
|
|
745
|
+
));
|
|
746
|
+
}
|
|
747
|
+
if let Some(previous) = previous {
|
|
748
|
+
let before = crate::state_contract::schema_from_manifest(previous, "previous");
|
|
749
|
+
let schema_changed = before.collections != state_schema.collections;
|
|
750
|
+
if schema_changed && manifest.state_schema_version <= previous.state_schema_version {
|
|
751
|
+
issues.push(issue(
|
|
752
|
+
"compatibility",
|
|
753
|
+
"state_schema_version",
|
|
754
|
+
"state_schema_version must increase when state schema changes",
|
|
755
|
+
));
|
|
756
|
+
}
|
|
757
|
+
if manifest.state_schema_version < previous.state_schema_version {
|
|
758
|
+
issues.push(issue(
|
|
759
|
+
"compatibility",
|
|
760
|
+
"state_schema_version",
|
|
761
|
+
"state_schema_version cannot decrease",
|
|
762
|
+
));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
let compatibility = if !issues.is_empty() {
|
|
766
|
+
Compatibility::Invalid
|
|
767
|
+
} else if let Some(previous) = previous {
|
|
768
|
+
compatibility(previous, manifest)
|
|
769
|
+
} else {
|
|
770
|
+
Compatibility::Compatible
|
|
771
|
+
};
|
|
772
|
+
ValidationReport {
|
|
773
|
+
valid: issues.is_empty(),
|
|
774
|
+
compatibility,
|
|
775
|
+
issues,
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
fn issue(category: &str, path: impl Into<String>, message: &str) -> ValidationIssue {
|
|
779
|
+
ValidationIssue {
|
|
780
|
+
category: category.into(),
|
|
781
|
+
path: path.into(),
|
|
782
|
+
message: message.into(),
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
fn compatibility(before: &ApplicationManifest, after: &ApplicationManifest) -> Compatibility {
|
|
786
|
+
for old in &before.collections {
|
|
787
|
+
match after.collections.iter().find(|v| v.name == old.name) {
|
|
788
|
+
None => return Compatibility::Breaking,
|
|
789
|
+
Some(new) => {
|
|
790
|
+
for field in &old.fields {
|
|
791
|
+
match new.fields.iter().find(|v| v.name == field.name) {
|
|
792
|
+
None => return Compatibility::Breaking,
|
|
793
|
+
Some(value) if value.field_type != field.field_type => {
|
|
794
|
+
return Compatibility::RequiresMigration
|
|
795
|
+
}
|
|
796
|
+
_ => {}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
Compatibility::Compatible
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
fn sorted(mut manifest: ApplicationManifest) -> ApplicationManifest {
|
|
806
|
+
macro_rules! sort_name {
|
|
807
|
+
($field:expr) => {
|
|
808
|
+
$field.sort_by(|a, b| a.name.cmp(&b.name));
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
sort_name!(manifest.collections);
|
|
812
|
+
for collection in &mut manifest.collections {
|
|
813
|
+
sort_name!(collection.fields);
|
|
814
|
+
}
|
|
815
|
+
sort_name!(manifest.indexes);
|
|
816
|
+
sort_name!(manifest.references);
|
|
817
|
+
sort_name!(manifest.queries);
|
|
818
|
+
sort_name!(manifest.actions);
|
|
819
|
+
sort_name!(manifest.workflows);
|
|
820
|
+
for workflow in &mut manifest.workflows {
|
|
821
|
+
sort_name!(workflow.steps);
|
|
822
|
+
}
|
|
823
|
+
sort_name!(manifest.triggers);
|
|
824
|
+
sort_name!(manifest.schedules);
|
|
825
|
+
sort_name!(manifest.agents);
|
|
826
|
+
for agent in &mut manifest.agents {
|
|
827
|
+
agent.capabilities.sort();
|
|
828
|
+
}
|
|
829
|
+
sort_name!(manifest.capabilities);
|
|
830
|
+
sort_name!(manifest.connections);
|
|
831
|
+
sort_name!(manifest.policies);
|
|
832
|
+
sort_name!(manifest.ui.bindings);
|
|
833
|
+
sort_name!(manifest.ui.forms);
|
|
834
|
+
sort_name!(manifest.ui.dashboards);
|
|
835
|
+
manifest.ui.routes.sort_by(|a, b| a.path.cmp(&b.path));
|
|
836
|
+
sort_name!(manifest.environments);
|
|
837
|
+
sort_name!(manifest.artifacts);
|
|
838
|
+
manifest.deployment.environments.sort();
|
|
839
|
+
manifest
|
|
840
|
+
}
|
|
841
|
+
pub fn canonical_bytes(manifest: &ApplicationManifest) -> Result<Vec<u8>, String> {
|
|
842
|
+
serde_json::to_vec(&sorted(manifest.clone())).map_err(|e| e.to_string())
|
|
843
|
+
}
|
|
844
|
+
pub fn manifest_hash(manifest: &ApplicationManifest) -> Result<String, String> {
|
|
845
|
+
Ok(format!(
|
|
846
|
+
"sha256:{:x}",
|
|
847
|
+
Sha256::digest(canonical_bytes(manifest)?)
|
|
848
|
+
))
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
852
|
+
#[serde(rename_all = "snake_case")]
|
|
853
|
+
pub enum RevisionStatus {
|
|
854
|
+
Committed,
|
|
855
|
+
}
|
|
856
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
857
|
+
pub struct ApplicationDraft {
|
|
858
|
+
pub draft_id: String,
|
|
859
|
+
pub application_id: String,
|
|
860
|
+
pub tenant_id: String,
|
|
861
|
+
pub base_revision_id: Option<String>,
|
|
862
|
+
pub manifest: ApplicationManifest,
|
|
863
|
+
pub version: u64,
|
|
864
|
+
pub updated_at: u64,
|
|
865
|
+
pub updated_by: String,
|
|
866
|
+
}
|
|
867
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
868
|
+
pub struct ApplicationRevision {
|
|
869
|
+
pub revision_id: String,
|
|
870
|
+
pub application_id: String,
|
|
871
|
+
pub tenant_id: String,
|
|
872
|
+
pub revision_number: u64,
|
|
873
|
+
pub parent_revision_id: Option<String>,
|
|
874
|
+
pub manifest_hash: String,
|
|
875
|
+
pub manifest: ApplicationManifest,
|
|
876
|
+
pub created_by: String,
|
|
877
|
+
pub created_at: u64,
|
|
878
|
+
pub status: RevisionStatus,
|
|
879
|
+
}
|
|
880
|
+
pub fn draft_as_revision(draft: &ApplicationDraft) -> Result<ApplicationRevision, String> {
|
|
881
|
+
let hash = manifest_hash(&draft.manifest)?;
|
|
882
|
+
Ok(ApplicationRevision {
|
|
883
|
+
revision_id: format!(
|
|
884
|
+
"appdraft://{}/{}/{}@{}",
|
|
885
|
+
draft.tenant_id, draft.application_id, draft.draft_id, draft.version
|
|
886
|
+
),
|
|
887
|
+
application_id: draft.application_id.clone(),
|
|
888
|
+
tenant_id: draft.tenant_id.clone(),
|
|
889
|
+
revision_number: 0,
|
|
890
|
+
parent_revision_id: draft.base_revision_id.clone(),
|
|
891
|
+
manifest_hash: hash,
|
|
892
|
+
manifest: draft.manifest.clone(),
|
|
893
|
+
created_by: draft.updated_by.clone(),
|
|
894
|
+
created_at: draft.updated_at,
|
|
895
|
+
status: RevisionStatus::Committed,
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
899
|
+
pub struct RevisionPromotion {
|
|
900
|
+
pub application_id: String,
|
|
901
|
+
pub from_revision: Option<String>,
|
|
902
|
+
pub to_revision: String,
|
|
903
|
+
pub environment: String,
|
|
904
|
+
pub promoted_by: String,
|
|
905
|
+
pub promoted_at: u64,
|
|
906
|
+
pub reason: String,
|
|
907
|
+
#[serde(default)]
|
|
908
|
+
pub diff_hash: String,
|
|
909
|
+
#[serde(default)]
|
|
910
|
+
pub correlation_id: String,
|
|
911
|
+
#[serde(default)]
|
|
912
|
+
pub rollback: bool,
|
|
913
|
+
}
|
|
914
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
915
|
+
pub struct RevisionAuditEvent {
|
|
916
|
+
pub event: String,
|
|
917
|
+
pub tenant_id: String,
|
|
918
|
+
pub application_id: String,
|
|
919
|
+
pub subject: String,
|
|
920
|
+
pub revision_id: Option<String>,
|
|
921
|
+
pub parent_revision_id: Option<String>,
|
|
922
|
+
pub manifest_hash: Option<String>,
|
|
923
|
+
pub authorization_decision: bool,
|
|
924
|
+
pub timestamp: u64,
|
|
925
|
+
pub correlation_id: String,
|
|
926
|
+
#[serde(default)]
|
|
927
|
+
pub environment: Option<String>,
|
|
928
|
+
#[serde(default)]
|
|
929
|
+
pub from_revision: Option<String>,
|
|
930
|
+
#[serde(default)]
|
|
931
|
+
pub to_revision: Option<String>,
|
|
932
|
+
#[serde(default)]
|
|
933
|
+
pub diff_hash: Option<String>,
|
|
934
|
+
}
|
|
935
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
936
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
937
|
+
pub enum ChangeSafety {
|
|
938
|
+
Safe,
|
|
939
|
+
Additive,
|
|
940
|
+
Modified,
|
|
941
|
+
RequiresMigration,
|
|
942
|
+
Breaking,
|
|
943
|
+
SecuritySensitive,
|
|
944
|
+
RuntimeSensitive,
|
|
945
|
+
}
|
|
946
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
947
|
+
#[serde(rename_all = "snake_case")]
|
|
948
|
+
pub enum ChangeKind {
|
|
949
|
+
Added,
|
|
950
|
+
Removed,
|
|
951
|
+
Modified,
|
|
952
|
+
}
|
|
953
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
954
|
+
pub struct ManifestChange {
|
|
955
|
+
pub section: String,
|
|
956
|
+
pub name: String,
|
|
957
|
+
pub kind: ChangeKind,
|
|
958
|
+
pub safety: ChangeSafety,
|
|
959
|
+
#[serde(default)]
|
|
960
|
+
pub detail: Option<String>,
|
|
961
|
+
}
|
|
962
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
963
|
+
pub struct ManifestDiff {
|
|
964
|
+
pub from_revision: Option<String>,
|
|
965
|
+
pub to_revision: String,
|
|
966
|
+
pub changes: Vec<ManifestChange>,
|
|
967
|
+
pub compatibility: Compatibility,
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
971
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
972
|
+
pub enum DiffClassification {
|
|
973
|
+
Unchanged,
|
|
974
|
+
Additive,
|
|
975
|
+
Modified,
|
|
976
|
+
MigrationRequired,
|
|
977
|
+
Breaking,
|
|
978
|
+
SecuritySensitive,
|
|
979
|
+
RuntimeSensitive,
|
|
980
|
+
}
|
|
981
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
982
|
+
pub struct MigrationOperation {
|
|
983
|
+
pub section: String,
|
|
984
|
+
pub resource: String,
|
|
985
|
+
pub operation: String,
|
|
986
|
+
pub destructive: bool,
|
|
987
|
+
}
|
|
988
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
989
|
+
pub struct MigrationPlan {
|
|
990
|
+
pub from_revision: Option<String>,
|
|
991
|
+
pub to_revision: String,
|
|
992
|
+
pub operations: Vec<MigrationOperation>,
|
|
993
|
+
pub destructive_operations: Vec<MigrationOperation>,
|
|
994
|
+
pub reversible: bool,
|
|
995
|
+
}
|
|
996
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
997
|
+
pub struct SecurityChange {
|
|
998
|
+
pub section: String,
|
|
999
|
+
pub resource: String,
|
|
1000
|
+
pub description: String,
|
|
1001
|
+
}
|
|
1002
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1003
|
+
pub struct ApplicationRevisionDiff {
|
|
1004
|
+
pub application_id: String,
|
|
1005
|
+
pub from_revision: Option<String>,
|
|
1006
|
+
pub to_revision: String,
|
|
1007
|
+
pub changes: Vec<ManifestChange>,
|
|
1008
|
+
pub classification: DiffClassification,
|
|
1009
|
+
pub migrations: Vec<MigrationOperation>,
|
|
1010
|
+
pub security_changes: Vec<SecurityChange>,
|
|
1011
|
+
pub compatibility: Compatibility,
|
|
1012
|
+
pub migration_plan: MigrationPlan,
|
|
1013
|
+
pub diff_hash: String,
|
|
1014
|
+
#[serde(default)]
|
|
1015
|
+
pub state_schema_compatibility: Option<crate::state_contract::SchemaCompatibilityReport>,
|
|
1016
|
+
}
|
|
1017
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
1018
|
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
1019
|
+
pub enum PreviewDataMode {
|
|
1020
|
+
Empty,
|
|
1021
|
+
Snapshot,
|
|
1022
|
+
Cloned,
|
|
1023
|
+
SharedReadOnly,
|
|
1024
|
+
}
|
|
1025
|
+
impl Default for PreviewDataMode {
|
|
1026
|
+
fn default() -> Self {
|
|
1027
|
+
Self::Empty
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1031
|
+
pub struct PreviewPolicy {
|
|
1032
|
+
#[serde(default)]
|
|
1033
|
+
pub state: PreviewDataMode,
|
|
1034
|
+
#[serde(default)]
|
|
1035
|
+
pub artifacts: PreviewDataMode,
|
|
1036
|
+
#[serde(default)]
|
|
1037
|
+
pub connections: PreviewDataMode,
|
|
1038
|
+
#[serde(default = "default_true")]
|
|
1039
|
+
pub workflows_enabled: bool,
|
|
1040
|
+
}
|
|
1041
|
+
fn default_true() -> bool {
|
|
1042
|
+
true
|
|
1043
|
+
}
|
|
1044
|
+
impl Default for PreviewPolicy {
|
|
1045
|
+
fn default() -> Self {
|
|
1046
|
+
Self {
|
|
1047
|
+
state: PreviewDataMode::Empty,
|
|
1048
|
+
artifacts: PreviewDataMode::SharedReadOnly,
|
|
1049
|
+
connections: PreviewDataMode::Empty,
|
|
1050
|
+
workflows_enabled: true,
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
1055
|
+
pub struct ApplicationPreview {
|
|
1056
|
+
pub preview_id: String,
|
|
1057
|
+
pub application_id: String,
|
|
1058
|
+
pub tenant_id: String,
|
|
1059
|
+
pub revision_id: String,
|
|
1060
|
+
pub environment: String,
|
|
1061
|
+
pub based_on_revision: Option<String>,
|
|
1062
|
+
pub policy: PreviewPolicy,
|
|
1063
|
+
pub created_by: String,
|
|
1064
|
+
pub created_at: u64,
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
1068
|
+
struct RevisionRecords {
|
|
1069
|
+
drafts: Vec<ApplicationDraft>,
|
|
1070
|
+
revisions: Vec<ApplicationRevision>,
|
|
1071
|
+
promotions: Vec<RevisionPromotion>,
|
|
1072
|
+
#[serde(default)]
|
|
1073
|
+
previews: Vec<ApplicationPreview>,
|
|
1074
|
+
environment_pointers: BTreeMap<String, BTreeMap<String, String>>,
|
|
1075
|
+
audit: Vec<RevisionAuditEvent>,
|
|
1076
|
+
}
|
|
1077
|
+
#[derive(Clone)]
|
|
1078
|
+
pub struct ApplicationStore {
|
|
1079
|
+
path: PathBuf,
|
|
1080
|
+
records: Arc<RwLock<RevisionRecords>>,
|
|
1081
|
+
}
|
|
1082
|
+
fn now() -> u64 {
|
|
1083
|
+
SystemTime::now()
|
|
1084
|
+
.duration_since(UNIX_EPOCH)
|
|
1085
|
+
.unwrap_or_default()
|
|
1086
|
+
.as_secs()
|
|
1087
|
+
}
|
|
1088
|
+
fn opaque_id(prefix: &str, seed: &str) -> String {
|
|
1089
|
+
let digest = Sha256::digest(format!("{}:{seed}", now()).as_bytes());
|
|
1090
|
+
format!(
|
|
1091
|
+
"{prefix}_{}",
|
|
1092
|
+
digest[..12]
|
|
1093
|
+
.iter()
|
|
1094
|
+
.map(|byte| format!("{byte:02x}"))
|
|
1095
|
+
.collect::<String>()
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
impl ApplicationStore {
|
|
1100
|
+
pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
|
|
1101
|
+
let path = path.into();
|
|
1102
|
+
let records = if path.exists() {
|
|
1103
|
+
serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
|
|
1104
|
+
.map_err(|e| e.to_string())?
|
|
1105
|
+
} else {
|
|
1106
|
+
RevisionRecords::default()
|
|
1107
|
+
};
|
|
1108
|
+
Ok(Self {
|
|
1109
|
+
path,
|
|
1110
|
+
records: Arc::new(RwLock::new(records)),
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
fn persist(&self, r: &RevisionRecords) -> Result<(), String> {
|
|
1114
|
+
if let Some(parent) = self.path.parent() {
|
|
1115
|
+
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
1116
|
+
}
|
|
1117
|
+
let tmp = self.path.with_extension("tmp");
|
|
1118
|
+
fs::write(
|
|
1119
|
+
&tmp,
|
|
1120
|
+
serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?,
|
|
1121
|
+
)
|
|
1122
|
+
.map_err(|e| e.to_string())?;
|
|
1123
|
+
fs::rename(tmp, &self.path).map_err(|e| e.to_string())
|
|
1124
|
+
}
|
|
1125
|
+
fn event(
|
|
1126
|
+
r: &mut RevisionRecords,
|
|
1127
|
+
event: &str,
|
|
1128
|
+
tenant: &str,
|
|
1129
|
+
app: &str,
|
|
1130
|
+
subject: &str,
|
|
1131
|
+
revision: Option<&ApplicationRevision>,
|
|
1132
|
+
parent: Option<String>,
|
|
1133
|
+
) {
|
|
1134
|
+
r.audit.push(RevisionAuditEvent {
|
|
1135
|
+
event: event.into(),
|
|
1136
|
+
tenant_id: tenant.into(),
|
|
1137
|
+
application_id: app.into(),
|
|
1138
|
+
subject: subject.into(),
|
|
1139
|
+
revision_id: revision.map(|v| v.revision_id.clone()),
|
|
1140
|
+
parent_revision_id: revision
|
|
1141
|
+
.and_then(|v| v.parent_revision_id.clone())
|
|
1142
|
+
.or(parent),
|
|
1143
|
+
manifest_hash: revision.map(|v| v.manifest_hash.clone()),
|
|
1144
|
+
authorization_decision: true,
|
|
1145
|
+
timestamp: now(),
|
|
1146
|
+
correlation_id: opaque_id("corr", subject),
|
|
1147
|
+
environment: None,
|
|
1148
|
+
from_revision: None,
|
|
1149
|
+
to_revision: None,
|
|
1150
|
+
diff_hash: None,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
pub fn create_draft(
|
|
1154
|
+
&self,
|
|
1155
|
+
tenant: &str,
|
|
1156
|
+
app: &str,
|
|
1157
|
+
name: &str,
|
|
1158
|
+
actor: &str,
|
|
1159
|
+
base: Option<&str>,
|
|
1160
|
+
) -> Result<ApplicationDraft, String> {
|
|
1161
|
+
let mut r = self
|
|
1162
|
+
.records
|
|
1163
|
+
.write()
|
|
1164
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1165
|
+
let manifest = match base {
|
|
1166
|
+
Some(id) => r
|
|
1167
|
+
.revisions
|
|
1168
|
+
.iter()
|
|
1169
|
+
.find(|v| v.revision_id == id && v.application_id == app && v.tenant_id == tenant)
|
|
1170
|
+
.ok_or("base revision not found")?
|
|
1171
|
+
.manifest
|
|
1172
|
+
.clone(),
|
|
1173
|
+
None => ApplicationManifest::empty(tenant, app, name),
|
|
1174
|
+
};
|
|
1175
|
+
let draft = ApplicationDraft {
|
|
1176
|
+
draft_id: opaque_id("draft", &format!("{app}:{actor}:{}", r.drafts.len())),
|
|
1177
|
+
application_id: app.into(),
|
|
1178
|
+
tenant_id: tenant.into(),
|
|
1179
|
+
base_revision_id: base.map(str::to_string),
|
|
1180
|
+
manifest,
|
|
1181
|
+
version: 1,
|
|
1182
|
+
updated_at: now(),
|
|
1183
|
+
updated_by: actor.into(),
|
|
1184
|
+
};
|
|
1185
|
+
r.drafts.push(draft.clone());
|
|
1186
|
+
Self::event(
|
|
1187
|
+
&mut r,
|
|
1188
|
+
"draft.created",
|
|
1189
|
+
tenant,
|
|
1190
|
+
app,
|
|
1191
|
+
actor,
|
|
1192
|
+
None,
|
|
1193
|
+
base.map(str::to_string),
|
|
1194
|
+
);
|
|
1195
|
+
self.persist(&r)?;
|
|
1196
|
+
Ok(draft)
|
|
1197
|
+
}
|
|
1198
|
+
pub fn get_draft(&self, tenant: &str, app: &str, id: &str) -> Option<ApplicationDraft> {
|
|
1199
|
+
self.records
|
|
1200
|
+
.read()
|
|
1201
|
+
.ok()?
|
|
1202
|
+
.drafts
|
|
1203
|
+
.iter()
|
|
1204
|
+
.find(|v| v.draft_id == id && v.application_id == app && v.tenant_id == tenant)
|
|
1205
|
+
.cloned()
|
|
1206
|
+
}
|
|
1207
|
+
pub fn update_draft(
|
|
1208
|
+
&self,
|
|
1209
|
+
tenant: &str,
|
|
1210
|
+
app: &str,
|
|
1211
|
+
id: &str,
|
|
1212
|
+
version: u64,
|
|
1213
|
+
manifest: ApplicationManifest,
|
|
1214
|
+
actor: &str,
|
|
1215
|
+
) -> Result<ApplicationDraft, String> {
|
|
1216
|
+
let report = validate_manifest(&manifest, tenant, app, None);
|
|
1217
|
+
if !report.valid {
|
|
1218
|
+
return Err(format!(
|
|
1219
|
+
"invalid manifest: {}",
|
|
1220
|
+
serde_json::to_string(&report).unwrap_or_default()
|
|
1221
|
+
));
|
|
1222
|
+
}
|
|
1223
|
+
let mut r = self
|
|
1224
|
+
.records
|
|
1225
|
+
.write()
|
|
1226
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1227
|
+
let index = r
|
|
1228
|
+
.drafts
|
|
1229
|
+
.iter()
|
|
1230
|
+
.position(|v| v.draft_id == id && v.application_id == app && v.tenant_id == tenant)
|
|
1231
|
+
.ok_or("draft not found")?;
|
|
1232
|
+
if r.drafts[index].version != version {
|
|
1233
|
+
return Err(format!("version_conflict:{}", r.drafts[index].version));
|
|
1234
|
+
}
|
|
1235
|
+
r.drafts[index].manifest = manifest;
|
|
1236
|
+
r.drafts[index].version += 1;
|
|
1237
|
+
r.drafts[index].updated_at = now();
|
|
1238
|
+
r.drafts[index].updated_by = actor.into();
|
|
1239
|
+
let result = r.drafts[index].clone();
|
|
1240
|
+
Self::event(
|
|
1241
|
+
&mut r,
|
|
1242
|
+
"draft.updated",
|
|
1243
|
+
tenant,
|
|
1244
|
+
app,
|
|
1245
|
+
actor,
|
|
1246
|
+
None,
|
|
1247
|
+
result.base_revision_id.clone(),
|
|
1248
|
+
);
|
|
1249
|
+
self.persist(&r)?;
|
|
1250
|
+
Ok(result)
|
|
1251
|
+
}
|
|
1252
|
+
pub fn validate_draft(
|
|
1253
|
+
&self,
|
|
1254
|
+
tenant: &str,
|
|
1255
|
+
app: &str,
|
|
1256
|
+
id: &str,
|
|
1257
|
+
actor: &str,
|
|
1258
|
+
) -> Result<ValidationReport, String> {
|
|
1259
|
+
let mut r = self
|
|
1260
|
+
.records
|
|
1261
|
+
.write()
|
|
1262
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1263
|
+
let draft = r
|
|
1264
|
+
.drafts
|
|
1265
|
+
.iter()
|
|
1266
|
+
.find(|v| v.draft_id == id && v.application_id == app && v.tenant_id == tenant)
|
|
1267
|
+
.ok_or("draft not found")?
|
|
1268
|
+
.clone();
|
|
1269
|
+
let previous = draft
|
|
1270
|
+
.base_revision_id
|
|
1271
|
+
.as_ref()
|
|
1272
|
+
.and_then(|base| r.revisions.iter().find(|v| &v.revision_id == base))
|
|
1273
|
+
.map(|v| &v.manifest);
|
|
1274
|
+
let report = validate_manifest(&draft.manifest, tenant, app, previous);
|
|
1275
|
+
Self::event(
|
|
1276
|
+
&mut r,
|
|
1277
|
+
if report.valid {
|
|
1278
|
+
"draft.validated"
|
|
1279
|
+
} else {
|
|
1280
|
+
"revision.rejected"
|
|
1281
|
+
},
|
|
1282
|
+
tenant,
|
|
1283
|
+
app,
|
|
1284
|
+
actor,
|
|
1285
|
+
None,
|
|
1286
|
+
draft.base_revision_id,
|
|
1287
|
+
);
|
|
1288
|
+
self.persist(&r)?;
|
|
1289
|
+
Ok(report)
|
|
1290
|
+
}
|
|
1291
|
+
pub fn commit(
|
|
1292
|
+
&self,
|
|
1293
|
+
tenant: &str,
|
|
1294
|
+
app: &str,
|
|
1295
|
+
id: &str,
|
|
1296
|
+
actor: &str,
|
|
1297
|
+
) -> Result<ApplicationRevision, String> {
|
|
1298
|
+
let mut r = self
|
|
1299
|
+
.records
|
|
1300
|
+
.write()
|
|
1301
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1302
|
+
let draft = r
|
|
1303
|
+
.drafts
|
|
1304
|
+
.iter()
|
|
1305
|
+
.find(|v| v.draft_id == id && v.application_id == app && v.tenant_id == tenant)
|
|
1306
|
+
.ok_or("draft not found")?
|
|
1307
|
+
.clone();
|
|
1308
|
+
let previous = draft
|
|
1309
|
+
.base_revision_id
|
|
1310
|
+
.as_ref()
|
|
1311
|
+
.and_then(|base| r.revisions.iter().find(|v| &v.revision_id == base))
|
|
1312
|
+
.map(|v| &v.manifest);
|
|
1313
|
+
let report = validate_manifest(&draft.manifest, tenant, app, previous);
|
|
1314
|
+
if !report.valid {
|
|
1315
|
+
return Err(format!(
|
|
1316
|
+
"invalid manifest: {}",
|
|
1317
|
+
serde_json::to_string(&report).unwrap_or_default()
|
|
1318
|
+
));
|
|
1319
|
+
}
|
|
1320
|
+
let hash = manifest_hash(&draft.manifest)?;
|
|
1321
|
+
let number = r
|
|
1322
|
+
.revisions
|
|
1323
|
+
.iter()
|
|
1324
|
+
.filter(|v| v.application_id == app)
|
|
1325
|
+
.map(|v| v.revision_number)
|
|
1326
|
+
.max()
|
|
1327
|
+
.unwrap_or(0)
|
|
1328
|
+
+ 1;
|
|
1329
|
+
let identity = Sha256::digest(
|
|
1330
|
+
format!(
|
|
1331
|
+
"{tenant}\0{app}\0{}\0{hash}",
|
|
1332
|
+
draft.base_revision_id.as_deref().unwrap_or("")
|
|
1333
|
+
)
|
|
1334
|
+
.as_bytes(),
|
|
1335
|
+
);
|
|
1336
|
+
let revision = ApplicationRevision {
|
|
1337
|
+
revision_id: format!("apprev://{tenant}/{app}/sha256:{identity:x}"),
|
|
1338
|
+
application_id: app.into(),
|
|
1339
|
+
tenant_id: tenant.into(),
|
|
1340
|
+
revision_number: number,
|
|
1341
|
+
parent_revision_id: draft.base_revision_id.clone(),
|
|
1342
|
+
manifest_hash: hash,
|
|
1343
|
+
manifest: sorted(draft.manifest),
|
|
1344
|
+
created_by: actor.into(),
|
|
1345
|
+
created_at: now(),
|
|
1346
|
+
status: RevisionStatus::Committed,
|
|
1347
|
+
};
|
|
1348
|
+
r.revisions.push(revision.clone());
|
|
1349
|
+
r.drafts.retain(|v| v.draft_id != id);
|
|
1350
|
+
Self::event(
|
|
1351
|
+
&mut r,
|
|
1352
|
+
"revision.created",
|
|
1353
|
+
tenant,
|
|
1354
|
+
app,
|
|
1355
|
+
actor,
|
|
1356
|
+
Some(&revision),
|
|
1357
|
+
None,
|
|
1358
|
+
);
|
|
1359
|
+
self.persist(&r)?;
|
|
1360
|
+
Ok(revision)
|
|
1361
|
+
}
|
|
1362
|
+
pub fn revisions(&self, tenant: &str, app: &str) -> Vec<ApplicationRevision> {
|
|
1363
|
+
let mut values = self
|
|
1364
|
+
.records
|
|
1365
|
+
.read()
|
|
1366
|
+
.map(|r| {
|
|
1367
|
+
r.revisions
|
|
1368
|
+
.iter()
|
|
1369
|
+
.filter(|v| v.tenant_id == tenant && v.application_id == app)
|
|
1370
|
+
.cloned()
|
|
1371
|
+
.collect::<Vec<_>>()
|
|
1372
|
+
})
|
|
1373
|
+
.unwrap_or_default();
|
|
1374
|
+
values.sort_by_key(|v| std::cmp::Reverse(v.revision_number));
|
|
1375
|
+
values
|
|
1376
|
+
}
|
|
1377
|
+
pub fn revision(&self, tenant: &str, app: &str, id: &str) -> Option<ApplicationRevision> {
|
|
1378
|
+
self.records
|
|
1379
|
+
.read()
|
|
1380
|
+
.ok()?
|
|
1381
|
+
.revisions
|
|
1382
|
+
.iter()
|
|
1383
|
+
.find(|v| {
|
|
1384
|
+
v.tenant_id == tenant
|
|
1385
|
+
&& v.application_id == app
|
|
1386
|
+
&& (v.revision_id == id || v.revision_number.to_string() == id)
|
|
1387
|
+
})
|
|
1388
|
+
.cloned()
|
|
1389
|
+
}
|
|
1390
|
+
pub fn promote(
|
|
1391
|
+
&self,
|
|
1392
|
+
tenant: &str,
|
|
1393
|
+
app: &str,
|
|
1394
|
+
id: &str,
|
|
1395
|
+
environment: &str,
|
|
1396
|
+
actor: &str,
|
|
1397
|
+
reason: &str,
|
|
1398
|
+
) -> Result<RevisionPromotion, String> {
|
|
1399
|
+
let expected = self.pointers(app).get(environment).cloned();
|
|
1400
|
+
self.move_environment_pointer(
|
|
1401
|
+
tenant,
|
|
1402
|
+
app,
|
|
1403
|
+
id,
|
|
1404
|
+
environment,
|
|
1405
|
+
expected.as_deref(),
|
|
1406
|
+
actor,
|
|
1407
|
+
reason,
|
|
1408
|
+
true,
|
|
1409
|
+
false,
|
|
1410
|
+
)
|
|
1411
|
+
}
|
|
1412
|
+
#[allow(clippy::too_many_arguments)]
|
|
1413
|
+
pub fn move_environment_pointer(
|
|
1414
|
+
&self,
|
|
1415
|
+
tenant: &str,
|
|
1416
|
+
app: &str,
|
|
1417
|
+
id: &str,
|
|
1418
|
+
environment: &str,
|
|
1419
|
+
expected_current_revision: Option<&str>,
|
|
1420
|
+
actor: &str,
|
|
1421
|
+
reason: &str,
|
|
1422
|
+
approve_destructive_changes: bool,
|
|
1423
|
+
rollback: bool,
|
|
1424
|
+
) -> Result<RevisionPromotion, String> {
|
|
1425
|
+
let mut r = self
|
|
1426
|
+
.records
|
|
1427
|
+
.write()
|
|
1428
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1429
|
+
let revision = r
|
|
1430
|
+
.revisions
|
|
1431
|
+
.iter()
|
|
1432
|
+
.find(|v| {
|
|
1433
|
+
v.tenant_id == tenant
|
|
1434
|
+
&& v.application_id == app
|
|
1435
|
+
&& (v.revision_id == id || v.revision_number.to_string() == id)
|
|
1436
|
+
})
|
|
1437
|
+
.ok_or("revision not found")?
|
|
1438
|
+
.clone();
|
|
1439
|
+
if !revision
|
|
1440
|
+
.manifest
|
|
1441
|
+
.environments
|
|
1442
|
+
.iter()
|
|
1443
|
+
.any(|v| v.name == environment)
|
|
1444
|
+
{
|
|
1445
|
+
return Err("environment is not defined by the revision".into());
|
|
1446
|
+
}
|
|
1447
|
+
let from = r
|
|
1448
|
+
.environment_pointers
|
|
1449
|
+
.get(app)
|
|
1450
|
+
.and_then(|v| v.get(environment))
|
|
1451
|
+
.cloned();
|
|
1452
|
+
if from.as_deref() != expected_current_revision {
|
|
1453
|
+
return Err(format!(
|
|
1454
|
+
"expected_revision_mismatch:{}",
|
|
1455
|
+
from.unwrap_or_default()
|
|
1456
|
+
));
|
|
1457
|
+
}
|
|
1458
|
+
let before = from
|
|
1459
|
+
.as_deref()
|
|
1460
|
+
.and_then(|previous| r.revisions.iter().find(|v| v.revision_id == previous));
|
|
1461
|
+
let diff = application_revision_diff(before, &revision)?;
|
|
1462
|
+
if !approve_destructive_changes
|
|
1463
|
+
&& diff.migration_plan.operations.iter().any(|v| v.destructive)
|
|
1464
|
+
{
|
|
1465
|
+
return Err("destructive_changes_require_approval".into());
|
|
1466
|
+
}
|
|
1467
|
+
r.environment_pointers
|
|
1468
|
+
.entry(app.into())
|
|
1469
|
+
.or_default()
|
|
1470
|
+
.insert(environment.into(), revision.revision_id.clone());
|
|
1471
|
+
let promotion = RevisionPromotion {
|
|
1472
|
+
application_id: app.into(),
|
|
1473
|
+
from_revision: from.clone(),
|
|
1474
|
+
to_revision: revision.revision_id.clone(),
|
|
1475
|
+
environment: environment.into(),
|
|
1476
|
+
promoted_by: actor.into(),
|
|
1477
|
+
promoted_at: now(),
|
|
1478
|
+
reason: reason.into(),
|
|
1479
|
+
diff_hash: diff.diff_hash,
|
|
1480
|
+
correlation_id: opaque_id("corr", &format!("{app}:{environment}:{actor}")),
|
|
1481
|
+
rollback,
|
|
1482
|
+
};
|
|
1483
|
+
r.promotions.push(promotion.clone());
|
|
1484
|
+
Self::event(
|
|
1485
|
+
&mut r,
|
|
1486
|
+
if rollback {
|
|
1487
|
+
"revision.rollback"
|
|
1488
|
+
} else {
|
|
1489
|
+
"revision.promoted"
|
|
1490
|
+
},
|
|
1491
|
+
tenant,
|
|
1492
|
+
app,
|
|
1493
|
+
actor,
|
|
1494
|
+
Some(&revision),
|
|
1495
|
+
None,
|
|
1496
|
+
);
|
|
1497
|
+
if let Some(event) = r.audit.last_mut() {
|
|
1498
|
+
event.event = if rollback {
|
|
1499
|
+
"application.revision.rolled_back".into()
|
|
1500
|
+
} else {
|
|
1501
|
+
"application.revision.promoted".into()
|
|
1502
|
+
};
|
|
1503
|
+
event.environment = Some(environment.into());
|
|
1504
|
+
event.from_revision = from;
|
|
1505
|
+
event.to_revision = Some(revision.revision_id.clone());
|
|
1506
|
+
event.diff_hash = Some(promotion.diff_hash.clone());
|
|
1507
|
+
event.correlation_id = promotion.correlation_id.clone();
|
|
1508
|
+
}
|
|
1509
|
+
self.persist(&r)?;
|
|
1510
|
+
Ok(promotion)
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
pub fn history(&self, tenant: &str, app: &str, environment: &str) -> Vec<RevisionPromotion> {
|
|
1514
|
+
self.records
|
|
1515
|
+
.read()
|
|
1516
|
+
.map(|r| {
|
|
1517
|
+
r.promotions
|
|
1518
|
+
.iter()
|
|
1519
|
+
.filter(|v| {
|
|
1520
|
+
v.application_id == app
|
|
1521
|
+
&& v.environment == environment
|
|
1522
|
+
&& r.revisions.iter().any(|rev| {
|
|
1523
|
+
rev.tenant_id == tenant && rev.revision_id == v.to_revision
|
|
1524
|
+
})
|
|
1525
|
+
})
|
|
1526
|
+
.cloned()
|
|
1527
|
+
.rev()
|
|
1528
|
+
.collect()
|
|
1529
|
+
})
|
|
1530
|
+
.unwrap_or_default()
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
pub fn create_preview(
|
|
1534
|
+
&self,
|
|
1535
|
+
tenant: &str,
|
|
1536
|
+
app: &str,
|
|
1537
|
+
id: &str,
|
|
1538
|
+
actor: &str,
|
|
1539
|
+
policy: PreviewPolicy,
|
|
1540
|
+
) -> Result<ApplicationPreview, String> {
|
|
1541
|
+
let revision = self.revision(tenant, app, id).ok_or("revision not found")?;
|
|
1542
|
+
if manifest_hash(&revision.manifest)? != revision.manifest_hash {
|
|
1543
|
+
return Err("revision integrity check failed".into());
|
|
1544
|
+
}
|
|
1545
|
+
let mut r = self
|
|
1546
|
+
.records
|
|
1547
|
+
.write()
|
|
1548
|
+
.map_err(|_| "application store lock poisoned")?;
|
|
1549
|
+
let preview = ApplicationPreview {
|
|
1550
|
+
preview_id: opaque_id(
|
|
1551
|
+
"preview",
|
|
1552
|
+
&format!("{app}:{}:{actor}", revision.revision_id),
|
|
1553
|
+
),
|
|
1554
|
+
application_id: app.into(),
|
|
1555
|
+
tenant_id: tenant.into(),
|
|
1556
|
+
revision_id: revision.revision_id,
|
|
1557
|
+
environment: format!("preview/{actor}"),
|
|
1558
|
+
based_on_revision: r
|
|
1559
|
+
.environment_pointers
|
|
1560
|
+
.get(app)
|
|
1561
|
+
.and_then(|v| v.get("production"))
|
|
1562
|
+
.cloned(),
|
|
1563
|
+
policy,
|
|
1564
|
+
created_by: actor.into(),
|
|
1565
|
+
created_at: now(),
|
|
1566
|
+
};
|
|
1567
|
+
r.previews.push(preview.clone());
|
|
1568
|
+
self.persist(&r)?;
|
|
1569
|
+
Ok(preview)
|
|
1570
|
+
}
|
|
1571
|
+
pub fn preview(&self, tenant: &str, app: &str, id: &str) -> Option<ApplicationPreview> {
|
|
1572
|
+
self.records
|
|
1573
|
+
.read()
|
|
1574
|
+
.ok()?
|
|
1575
|
+
.previews
|
|
1576
|
+
.iter()
|
|
1577
|
+
.find(|v| v.tenant_id == tenant && v.application_id == app && v.preview_id == id)
|
|
1578
|
+
.cloned()
|
|
1579
|
+
}
|
|
1580
|
+
pub fn pointers(&self, app: &str) -> BTreeMap<String, String> {
|
|
1581
|
+
self.records
|
|
1582
|
+
.read()
|
|
1583
|
+
.ok()
|
|
1584
|
+
.and_then(|r| r.environment_pointers.get(app).cloned())
|
|
1585
|
+
.unwrap_or_default()
|
|
1586
|
+
}
|
|
1587
|
+
pub fn diff(&self, tenant: &str, app: &str, id: &str) -> Result<ManifestDiff, String> {
|
|
1588
|
+
let target = self.revision(tenant, app, id).ok_or("revision not found")?;
|
|
1589
|
+
let before = target
|
|
1590
|
+
.parent_revision_id
|
|
1591
|
+
.as_deref()
|
|
1592
|
+
.and_then(|parent| self.revision(tenant, app, parent));
|
|
1593
|
+
Ok(diff_manifests(before.as_ref(), &target))
|
|
1594
|
+
}
|
|
1595
|
+
pub fn revision_diff(
|
|
1596
|
+
&self,
|
|
1597
|
+
tenant: &str,
|
|
1598
|
+
app: &str,
|
|
1599
|
+
from: Option<&str>,
|
|
1600
|
+
to: &str,
|
|
1601
|
+
) -> Result<ApplicationRevisionDiff, String> {
|
|
1602
|
+
let after = self.revision(tenant, app, to).ok_or("revision not found")?;
|
|
1603
|
+
let before = match from {
|
|
1604
|
+
Some(id) => Some(
|
|
1605
|
+
self.revision(tenant, app, id)
|
|
1606
|
+
.ok_or("from revision not found")?,
|
|
1607
|
+
),
|
|
1608
|
+
None => after
|
|
1609
|
+
.parent_revision_id
|
|
1610
|
+
.as_deref()
|
|
1611
|
+
.and_then(|id| self.revision(tenant, app, id)),
|
|
1612
|
+
};
|
|
1613
|
+
application_revision_diff(before.as_ref(), &after)
|
|
1614
|
+
}
|
|
1615
|
+
pub fn audit(&self, tenant: &str, app: &str) -> Vec<RevisionAuditEvent> {
|
|
1616
|
+
self.records
|
|
1617
|
+
.read()
|
|
1618
|
+
.map(|r| {
|
|
1619
|
+
r.audit
|
|
1620
|
+
.iter()
|
|
1621
|
+
.filter(|v| v.tenant_id == tenant && v.application_id == app)
|
|
1622
|
+
.cloned()
|
|
1623
|
+
.collect()
|
|
1624
|
+
})
|
|
1625
|
+
.unwrap_or_default()
|
|
1626
|
+
}
|
|
1627
|
+
pub fn export_revision(&self, tenant: &str, app: &str, id: &str) -> Result<Value, String> {
|
|
1628
|
+
let revision = self.revision(tenant, app, id).ok_or("revision not found")?;
|
|
1629
|
+
let manifest = canonical_bytes(&revision.manifest)?;
|
|
1630
|
+
let checksum = format!("sha256:{:x}", Sha256::digest(&manifest));
|
|
1631
|
+
Ok(
|
|
1632
|
+
serde_json::json!({"format":"feltdb-app/v1","revision":revision,"files":{"manifest.json":String::from_utf8(manifest).map_err(|e|e.to_string())?},"checksums":{"manifest.json":checksum}}),
|
|
1633
|
+
)
|
|
1634
|
+
}
|
|
1635
|
+
pub fn import_draft(
|
|
1636
|
+
&self,
|
|
1637
|
+
tenant: &str,
|
|
1638
|
+
app: &str,
|
|
1639
|
+
name: &str,
|
|
1640
|
+
actor: &str,
|
|
1641
|
+
bundle: &Value,
|
|
1642
|
+
) -> Result<ApplicationDraft, String> {
|
|
1643
|
+
if bundle.get("format").and_then(Value::as_str) != Some("feltdb-app/v1") {
|
|
1644
|
+
return Err("unsupported application bundle".into());
|
|
1645
|
+
}
|
|
1646
|
+
let text = bundle
|
|
1647
|
+
.pointer("/files/manifest.json")
|
|
1648
|
+
.and_then(Value::as_str)
|
|
1649
|
+
.ok_or("bundle manifest is missing")?;
|
|
1650
|
+
let expected = bundle
|
|
1651
|
+
.pointer("/checksums/manifest.json")
|
|
1652
|
+
.and_then(Value::as_str)
|
|
1653
|
+
.ok_or("bundle checksum is missing")?;
|
|
1654
|
+
let actual = format!("sha256:{:x}", Sha256::digest(text.as_bytes()));
|
|
1655
|
+
if actual != expected {
|
|
1656
|
+
return Err("bundle checksum mismatch".into());
|
|
1657
|
+
}
|
|
1658
|
+
let mut manifest: ApplicationManifest =
|
|
1659
|
+
serde_json::from_str(text).map_err(|e| e.to_string())?;
|
|
1660
|
+
manifest.tenant_id = tenant.into();
|
|
1661
|
+
manifest.application_id = app.into();
|
|
1662
|
+
let report = validate_manifest(&manifest, tenant, app, None);
|
|
1663
|
+
if !report.valid {
|
|
1664
|
+
return Err(format!(
|
|
1665
|
+
"invalid manifest: {}",
|
|
1666
|
+
serde_json::to_string(&report).unwrap_or_default()
|
|
1667
|
+
));
|
|
1668
|
+
}
|
|
1669
|
+
let mut draft = self.create_draft(tenant, app, name, actor, None)?;
|
|
1670
|
+
draft = self.update_draft(tenant, app, &draft.draft_id, draft.version, manifest, actor)?;
|
|
1671
|
+
Ok(draft)
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
fn named_map<T: Serialize>(values: &[T]) -> BTreeMap<String, Value> {
|
|
1676
|
+
values
|
|
1677
|
+
.iter()
|
|
1678
|
+
.filter_map(|v| {
|
|
1679
|
+
let value = serde_json::to_value(v).ok()?;
|
|
1680
|
+
Some((value.get("name")?.as_str()?.to_string(), value))
|
|
1681
|
+
})
|
|
1682
|
+
.collect()
|
|
1683
|
+
}
|
|
1684
|
+
fn section_changes<T: Serialize>(
|
|
1685
|
+
changes: &mut Vec<ManifestChange>,
|
|
1686
|
+
section: &str,
|
|
1687
|
+
before: &[T],
|
|
1688
|
+
after: &[T],
|
|
1689
|
+
default: ChangeSafety,
|
|
1690
|
+
) {
|
|
1691
|
+
let left = named_map(before);
|
|
1692
|
+
let right = named_map(after);
|
|
1693
|
+
for (name, value) in &right {
|
|
1694
|
+
match left.get(name) {
|
|
1695
|
+
None => changes.push(ManifestChange {
|
|
1696
|
+
section: section.into(),
|
|
1697
|
+
name: name.clone(),
|
|
1698
|
+
kind: ChangeKind::Added,
|
|
1699
|
+
safety: default.clone(),
|
|
1700
|
+
detail: None,
|
|
1701
|
+
}),
|
|
1702
|
+
Some(old) if old != value => changes.push(ManifestChange {
|
|
1703
|
+
section: section.into(),
|
|
1704
|
+
name: name.clone(),
|
|
1705
|
+
kind: ChangeKind::Modified,
|
|
1706
|
+
safety: default.clone(),
|
|
1707
|
+
detail: None,
|
|
1708
|
+
}),
|
|
1709
|
+
_ => {}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
for name in left.keys().filter(|name| !right.contains_key(*name)) {
|
|
1713
|
+
changes.push(ManifestChange {
|
|
1714
|
+
section: section.into(),
|
|
1715
|
+
name: name.clone(),
|
|
1716
|
+
kind: ChangeKind::Removed,
|
|
1717
|
+
safety: ChangeSafety::Breaking,
|
|
1718
|
+
detail: Some("resource removal requires explicit destructive-change approval".into()),
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
pub fn diff_manifests(
|
|
1723
|
+
before: Option<&ApplicationRevision>,
|
|
1724
|
+
after: &ApplicationRevision,
|
|
1725
|
+
) -> ManifestDiff {
|
|
1726
|
+
let empty = ApplicationManifest::empty(
|
|
1727
|
+
&after.tenant_id,
|
|
1728
|
+
&after.application_id,
|
|
1729
|
+
&after.manifest.metadata.name,
|
|
1730
|
+
);
|
|
1731
|
+
let left = before.map(|v| &v.manifest).unwrap_or(&empty);
|
|
1732
|
+
let mut changes = vec![];
|
|
1733
|
+
section_changes(
|
|
1734
|
+
&mut changes,
|
|
1735
|
+
"collections",
|
|
1736
|
+
&left.collections,
|
|
1737
|
+
&after.manifest.collections,
|
|
1738
|
+
ChangeSafety::RequiresMigration,
|
|
1739
|
+
);
|
|
1740
|
+
section_changes(
|
|
1741
|
+
&mut changes,
|
|
1742
|
+
"indexes",
|
|
1743
|
+
&left.indexes,
|
|
1744
|
+
&after.manifest.indexes,
|
|
1745
|
+
ChangeSafety::Safe,
|
|
1746
|
+
);
|
|
1747
|
+
section_changes(
|
|
1748
|
+
&mut changes,
|
|
1749
|
+
"workflows",
|
|
1750
|
+
&left.workflows,
|
|
1751
|
+
&after.manifest.workflows,
|
|
1752
|
+
ChangeSafety::Safe,
|
|
1753
|
+
);
|
|
1754
|
+
section_changes(
|
|
1755
|
+
&mut changes,
|
|
1756
|
+
"actions",
|
|
1757
|
+
&left.actions,
|
|
1758
|
+
&after.manifest.actions,
|
|
1759
|
+
ChangeSafety::SecuritySensitive,
|
|
1760
|
+
);
|
|
1761
|
+
section_changes(
|
|
1762
|
+
&mut changes,
|
|
1763
|
+
"agents",
|
|
1764
|
+
&left.agents,
|
|
1765
|
+
&after.manifest.agents,
|
|
1766
|
+
ChangeSafety::SecuritySensitive,
|
|
1767
|
+
);
|
|
1768
|
+
section_changes(
|
|
1769
|
+
&mut changes,
|
|
1770
|
+
"capabilities",
|
|
1771
|
+
&left.capabilities,
|
|
1772
|
+
&after.manifest.capabilities,
|
|
1773
|
+
ChangeSafety::SecuritySensitive,
|
|
1774
|
+
);
|
|
1775
|
+
section_changes(
|
|
1776
|
+
&mut changes,
|
|
1777
|
+
"connections",
|
|
1778
|
+
&left.connections,
|
|
1779
|
+
&after.manifest.connections,
|
|
1780
|
+
ChangeSafety::SecuritySensitive,
|
|
1781
|
+
);
|
|
1782
|
+
section_changes(
|
|
1783
|
+
&mut changes,
|
|
1784
|
+
"policies",
|
|
1785
|
+
&left.policies,
|
|
1786
|
+
&after.manifest.policies,
|
|
1787
|
+
ChangeSafety::SecuritySensitive,
|
|
1788
|
+
);
|
|
1789
|
+
let left_routes: BTreeMap<_, _> = left
|
|
1790
|
+
.ui
|
|
1791
|
+
.routes
|
|
1792
|
+
.iter()
|
|
1793
|
+
.map(|v| (v.path.clone(), v.view.clone()))
|
|
1794
|
+
.collect();
|
|
1795
|
+
let right_routes: BTreeMap<_, _> = after
|
|
1796
|
+
.manifest
|
|
1797
|
+
.ui
|
|
1798
|
+
.routes
|
|
1799
|
+
.iter()
|
|
1800
|
+
.map(|v| (v.path.clone(), v.view.clone()))
|
|
1801
|
+
.collect();
|
|
1802
|
+
for (path, view) in &right_routes {
|
|
1803
|
+
changes.push(ManifestChange {
|
|
1804
|
+
section: "routes".into(),
|
|
1805
|
+
name: path.clone(),
|
|
1806
|
+
kind: if left_routes.contains_key(path) {
|
|
1807
|
+
if left_routes.get(path) == Some(view) {
|
|
1808
|
+
continue;
|
|
1809
|
+
} else {
|
|
1810
|
+
ChangeKind::Modified
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
ChangeKind::Added
|
|
1814
|
+
},
|
|
1815
|
+
safety: ChangeSafety::Safe,
|
|
1816
|
+
detail: None,
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
for path in left_routes
|
|
1820
|
+
.keys()
|
|
1821
|
+
.filter(|path| !right_routes.contains_key(*path))
|
|
1822
|
+
{
|
|
1823
|
+
changes.push(ManifestChange {
|
|
1824
|
+
section: "routes".into(),
|
|
1825
|
+
name: path.clone(),
|
|
1826
|
+
kind: ChangeKind::Removed,
|
|
1827
|
+
safety: ChangeSafety::Breaking,
|
|
1828
|
+
detail: Some("route removal may break existing navigation".into()),
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
section_changes(
|
|
1832
|
+
&mut changes,
|
|
1833
|
+
"forms",
|
|
1834
|
+
&left.ui.forms,
|
|
1835
|
+
&after.manifest.ui.forms,
|
|
1836
|
+
ChangeSafety::Safe,
|
|
1837
|
+
);
|
|
1838
|
+
section_changes(
|
|
1839
|
+
&mut changes,
|
|
1840
|
+
"dashboards",
|
|
1841
|
+
&left.ui.dashboards,
|
|
1842
|
+
&after.manifest.ui.dashboards,
|
|
1843
|
+
ChangeSafety::Safe,
|
|
1844
|
+
);
|
|
1845
|
+
ManifestDiff {
|
|
1846
|
+
from_revision: before.map(|v| v.revision_id.clone()),
|
|
1847
|
+
to_revision: after.revision_id.clone(),
|
|
1848
|
+
changes,
|
|
1849
|
+
compatibility: compatibility(left, &after.manifest),
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
pub fn application_revision_diff(
|
|
1854
|
+
before: Option<&ApplicationRevision>,
|
|
1855
|
+
after: &ApplicationRevision,
|
|
1856
|
+
) -> Result<ApplicationRevisionDiff, String> {
|
|
1857
|
+
if manifest_hash(&after.manifest)? != after.manifest_hash {
|
|
1858
|
+
return Err("revision integrity check failed".into());
|
|
1859
|
+
}
|
|
1860
|
+
if let Some(value) = before {
|
|
1861
|
+
if value.application_id != after.application_id || value.tenant_id != after.tenant_id {
|
|
1862
|
+
return Err("cross-application revision comparison is forbidden".into());
|
|
1863
|
+
}
|
|
1864
|
+
if manifest_hash(&value.manifest)? != value.manifest_hash {
|
|
1865
|
+
return Err("from revision integrity check failed".into());
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
let legacy = diff_manifests(before, after);
|
|
1869
|
+
let state_schema_compatibility = before.map(|before| {
|
|
1870
|
+
crate::state_contract::compare_schemas(
|
|
1871
|
+
&crate::state_contract::schema_from_revision(before),
|
|
1872
|
+
&crate::state_contract::schema_from_revision(after),
|
|
1873
|
+
)
|
|
1874
|
+
});
|
|
1875
|
+
let migrations = legacy
|
|
1876
|
+
.changes
|
|
1877
|
+
.iter()
|
|
1878
|
+
.filter(|change| {
|
|
1879
|
+
matches!(
|
|
1880
|
+
change.safety,
|
|
1881
|
+
ChangeSafety::RequiresMigration | ChangeSafety::Breaking
|
|
1882
|
+
)
|
|
1883
|
+
})
|
|
1884
|
+
.map(|change| MigrationOperation {
|
|
1885
|
+
section: change.section.clone(),
|
|
1886
|
+
resource: change.name.clone(),
|
|
1887
|
+
operation: match change.kind {
|
|
1888
|
+
ChangeKind::Added => "add",
|
|
1889
|
+
ChangeKind::Modified => "alter",
|
|
1890
|
+
ChangeKind::Removed => "remove",
|
|
1891
|
+
}
|
|
1892
|
+
.into(),
|
|
1893
|
+
destructive: matches!(change.kind, ChangeKind::Removed)
|
|
1894
|
+
|| matches!(change.safety, ChangeSafety::Breaking)
|
|
1895
|
+
|| (change.section == "collections"
|
|
1896
|
+
&& matches!(legacy.compatibility, Compatibility::Breaking)),
|
|
1897
|
+
})
|
|
1898
|
+
.collect::<Vec<_>>();
|
|
1899
|
+
let security_changes = legacy
|
|
1900
|
+
.changes
|
|
1901
|
+
.iter()
|
|
1902
|
+
.filter(|change| matches!(change.safety, ChangeSafety::SecuritySensitive))
|
|
1903
|
+
.map(|change| SecurityChange {
|
|
1904
|
+
section: change.section.clone(),
|
|
1905
|
+
resource: change.name.clone(),
|
|
1906
|
+
description: format!("{:?} security-sensitive resource", change.kind),
|
|
1907
|
+
})
|
|
1908
|
+
.collect::<Vec<_>>();
|
|
1909
|
+
let schema_breaking = state_schema_compatibility.as_ref().is_some_and(|report| {
|
|
1910
|
+
matches!(
|
|
1911
|
+
report.classification,
|
|
1912
|
+
crate::state_contract::SchemaCompatibility::Destructive
|
|
1913
|
+
| crate::state_contract::SchemaCompatibility::Incompatible
|
|
1914
|
+
)
|
|
1915
|
+
});
|
|
1916
|
+
let classification = if legacy.changes.is_empty() {
|
|
1917
|
+
DiffClassification::Unchanged
|
|
1918
|
+
} else if schema_breaking
|
|
1919
|
+
|| matches!(legacy.compatibility, Compatibility::Breaking)
|
|
1920
|
+
|| legacy
|
|
1921
|
+
.changes
|
|
1922
|
+
.iter()
|
|
1923
|
+
.any(|v| matches!(v.safety, ChangeSafety::Breaking))
|
|
1924
|
+
{
|
|
1925
|
+
DiffClassification::Breaking
|
|
1926
|
+
} else if !security_changes.is_empty() {
|
|
1927
|
+
DiffClassification::SecuritySensitive
|
|
1928
|
+
} else if !migrations.is_empty() {
|
|
1929
|
+
DiffClassification::MigrationRequired
|
|
1930
|
+
} else if legacy
|
|
1931
|
+
.changes
|
|
1932
|
+
.iter()
|
|
1933
|
+
.any(|v| matches!(v.safety, ChangeSafety::RuntimeSensitive))
|
|
1934
|
+
{
|
|
1935
|
+
DiffClassification::RuntimeSensitive
|
|
1936
|
+
} else if legacy
|
|
1937
|
+
.changes
|
|
1938
|
+
.iter()
|
|
1939
|
+
.all(|v| matches!(v.kind, ChangeKind::Added))
|
|
1940
|
+
{
|
|
1941
|
+
DiffClassification::Additive
|
|
1942
|
+
} else {
|
|
1943
|
+
DiffClassification::Modified
|
|
1944
|
+
};
|
|
1945
|
+
let destructive_operations = migrations
|
|
1946
|
+
.iter()
|
|
1947
|
+
.filter(|v| v.destructive)
|
|
1948
|
+
.cloned()
|
|
1949
|
+
.collect::<Vec<_>>();
|
|
1950
|
+
let migration_plan = MigrationPlan {
|
|
1951
|
+
from_revision: legacy.from_revision.clone(),
|
|
1952
|
+
to_revision: legacy.to_revision.clone(),
|
|
1953
|
+
operations: migrations.clone(),
|
|
1954
|
+
destructive_operations,
|
|
1955
|
+
reversible: !migrations.iter().any(|v| v.destructive),
|
|
1956
|
+
};
|
|
1957
|
+
let hash_input = serde_json::json!({"application_id":after.application_id,"from_revision":legacy.from_revision,
|
|
1958
|
+
"to_revision":legacy.to_revision,"changes":legacy.changes,"classification":classification,
|
|
1959
|
+
"migrations":migrations,"security_changes":security_changes,"compatibility":legacy.compatibility,"migration_plan":migration_plan,"state_schema_compatibility":state_schema_compatibility});
|
|
1960
|
+
let diff_hash = format!(
|
|
1961
|
+
"sha256:{:x}",
|
|
1962
|
+
Sha256::digest(serde_json::to_vec(&hash_input).map_err(|e| e.to_string())?)
|
|
1963
|
+
);
|
|
1964
|
+
Ok(ApplicationRevisionDiff {
|
|
1965
|
+
application_id: after.application_id.clone(),
|
|
1966
|
+
from_revision: legacy.from_revision,
|
|
1967
|
+
to_revision: legacy.to_revision,
|
|
1968
|
+
changes: legacy.changes,
|
|
1969
|
+
classification,
|
|
1970
|
+
migrations,
|
|
1971
|
+
security_changes,
|
|
1972
|
+
compatibility: legacy.compatibility,
|
|
1973
|
+
migration_plan,
|
|
1974
|
+
diff_hash,
|
|
1975
|
+
state_schema_compatibility,
|
|
1976
|
+
})
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
#[cfg(test)]
|
|
1980
|
+
mod tests {
|
|
1981
|
+
use super::*;
|
|
1982
|
+
fn store(name: &str) -> ApplicationStore {
|
|
1983
|
+
ApplicationStore::load(
|
|
1984
|
+
std::env::temp_dir().join(format!("feltdb-app-{name}-{}.json", now())),
|
|
1985
|
+
)
|
|
1986
|
+
.unwrap()
|
|
1987
|
+
}
|
|
1988
|
+
#[test]
|
|
1989
|
+
fn canonical_hash_ignores_named_section_order() {
|
|
1990
|
+
let mut a = ApplicationManifest::empty("t", "a", "App");
|
|
1991
|
+
a.collections = vec![
|
|
1992
|
+
CollectionDefinition {
|
|
1993
|
+
name: "z".into(),
|
|
1994
|
+
fields: vec![],
|
|
1995
|
+
},
|
|
1996
|
+
CollectionDefinition {
|
|
1997
|
+
name: "a".into(),
|
|
1998
|
+
fields: vec![],
|
|
1999
|
+
},
|
|
2000
|
+
];
|
|
2001
|
+
let mut b = a.clone();
|
|
2002
|
+
b.collections.reverse();
|
|
2003
|
+
assert_eq!(manifest_hash(&a).unwrap(), manifest_hash(&b).unwrap());
|
|
2004
|
+
}
|
|
2005
|
+
#[test]
|
|
2006
|
+
fn revision_is_immutable_and_rollback_moves_pointer() {
|
|
2007
|
+
let s = store("lifecycle");
|
|
2008
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2009
|
+
let r1 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2010
|
+
s.promote("t", "a", &r1.revision_id, "production", "owner", "initial")
|
|
2011
|
+
.unwrap();
|
|
2012
|
+
let d = s
|
|
2013
|
+
.create_draft("t", "a", "App", "owner", Some(&r1.revision_id))
|
|
2014
|
+
.unwrap();
|
|
2015
|
+
let r2 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2016
|
+
s.promote("t", "a", &r2.revision_id, "production", "owner", "upgrade")
|
|
2017
|
+
.unwrap();
|
|
2018
|
+
s.promote("t", "a", &r1.revision_id, "production", "owner", "rollback")
|
|
2019
|
+
.unwrap();
|
|
2020
|
+
assert_eq!(s.pointers("a")["production"], r1.revision_id);
|
|
2021
|
+
assert_eq!(s.revisions("t", "a").len(), 2);
|
|
2022
|
+
}
|
|
2023
|
+
#[test]
|
|
2024
|
+
fn stale_update_and_plaintext_secret_are_rejected() {
|
|
2025
|
+
let s = store("conflict");
|
|
2026
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2027
|
+
let mut manifest = d.manifest.clone();
|
|
2028
|
+
manifest.connections.push(ConnectionBinding {
|
|
2029
|
+
name: "db".into(),
|
|
2030
|
+
connection_id: "c".into(),
|
|
2031
|
+
configuration: serde_json::json!({"password":"bad"}),
|
|
2032
|
+
secrets: vec![],
|
|
2033
|
+
});
|
|
2034
|
+
assert!(s
|
|
2035
|
+
.update_draft("t", "a", &d.draft_id, d.version, manifest, "owner")
|
|
2036
|
+
.is_err());
|
|
2037
|
+
let updated = s
|
|
2038
|
+
.update_draft(
|
|
2039
|
+
"t",
|
|
2040
|
+
"a",
|
|
2041
|
+
&d.draft_id,
|
|
2042
|
+
d.version,
|
|
2043
|
+
d.manifest.clone(),
|
|
2044
|
+
"owner",
|
|
2045
|
+
)
|
|
2046
|
+
.unwrap();
|
|
2047
|
+
assert!(s
|
|
2048
|
+
.update_draft("t", "a", &d.draft_id, d.version, updated.manifest, "owner")
|
|
2049
|
+
.unwrap_err()
|
|
2050
|
+
.starts_with("version_conflict:"));
|
|
2051
|
+
}
|
|
2052
|
+
#[test]
|
|
2053
|
+
fn export_import_preserves_hash() {
|
|
2054
|
+
let s = store("portable");
|
|
2055
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2056
|
+
let r = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2057
|
+
let bundle = s.export_revision("t", "a", &r.revision_id).unwrap();
|
|
2058
|
+
let imported = s
|
|
2059
|
+
.import_draft("t", "b", "Imported", "owner", &bundle)
|
|
2060
|
+
.unwrap();
|
|
2061
|
+
assert_eq!(
|
|
2062
|
+
manifest_hash(&imported.manifest).unwrap(),
|
|
2063
|
+
manifest_hash(&ApplicationManifest {
|
|
2064
|
+
tenant_id: "t".into(),
|
|
2065
|
+
application_id: "b".into(),
|
|
2066
|
+
..r.manifest
|
|
2067
|
+
})
|
|
2068
|
+
.unwrap()
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
#[test]
|
|
2072
|
+
fn promotion_is_compare_and_swap_and_history_is_durable() {
|
|
2073
|
+
let s = store("promotion-cas");
|
|
2074
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2075
|
+
let r1 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2076
|
+
s.move_environment_pointer(
|
|
2077
|
+
"t",
|
|
2078
|
+
"a",
|
|
2079
|
+
&r1.revision_id,
|
|
2080
|
+
"production",
|
|
2081
|
+
None,
|
|
2082
|
+
"owner",
|
|
2083
|
+
"initial",
|
|
2084
|
+
true,
|
|
2085
|
+
false,
|
|
2086
|
+
)
|
|
2087
|
+
.unwrap();
|
|
2088
|
+
let d = s
|
|
2089
|
+
.create_draft("t", "a", "App", "owner", Some(&r1.revision_id))
|
|
2090
|
+
.unwrap();
|
|
2091
|
+
let r2 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2092
|
+
let d = s
|
|
2093
|
+
.create_draft("t", "a", "App", "owner", Some(&r1.revision_id))
|
|
2094
|
+
.unwrap();
|
|
2095
|
+
let r3 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2096
|
+
s.move_environment_pointer(
|
|
2097
|
+
"t",
|
|
2098
|
+
"a",
|
|
2099
|
+
&r2.revision_id,
|
|
2100
|
+
"production",
|
|
2101
|
+
Some(&r1.revision_id),
|
|
2102
|
+
"owner",
|
|
2103
|
+
"A",
|
|
2104
|
+
true,
|
|
2105
|
+
false,
|
|
2106
|
+
)
|
|
2107
|
+
.unwrap();
|
|
2108
|
+
let error = s
|
|
2109
|
+
.move_environment_pointer(
|
|
2110
|
+
"t",
|
|
2111
|
+
"a",
|
|
2112
|
+
&r3.revision_id,
|
|
2113
|
+
"production",
|
|
2114
|
+
Some(&r1.revision_id),
|
|
2115
|
+
"owner",
|
|
2116
|
+
"B",
|
|
2117
|
+
true,
|
|
2118
|
+
false,
|
|
2119
|
+
)
|
|
2120
|
+
.unwrap_err();
|
|
2121
|
+
assert!(error.starts_with("expected_revision_mismatch:"));
|
|
2122
|
+
assert_eq!(s.pointers("a")["production"], r2.revision_id);
|
|
2123
|
+
assert_eq!(s.history("t", "a", "production").len(), 2);
|
|
2124
|
+
}
|
|
2125
|
+
#[test]
|
|
2126
|
+
fn preview_remains_bound_when_production_moves() {
|
|
2127
|
+
let s = store("preview-bound");
|
|
2128
|
+
let d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2129
|
+
let r1 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2130
|
+
s.move_environment_pointer(
|
|
2131
|
+
"t",
|
|
2132
|
+
"a",
|
|
2133
|
+
&r1.revision_id,
|
|
2134
|
+
"production",
|
|
2135
|
+
None,
|
|
2136
|
+
"owner",
|
|
2137
|
+
"initial",
|
|
2138
|
+
true,
|
|
2139
|
+
false,
|
|
2140
|
+
)
|
|
2141
|
+
.unwrap();
|
|
2142
|
+
let preview = s
|
|
2143
|
+
.create_preview(
|
|
2144
|
+
"t",
|
|
2145
|
+
"a",
|
|
2146
|
+
&r1.revision_id,
|
|
2147
|
+
"editor",
|
|
2148
|
+
PreviewPolicy::default(),
|
|
2149
|
+
)
|
|
2150
|
+
.unwrap();
|
|
2151
|
+
let d = s
|
|
2152
|
+
.create_draft("t", "a", "App", "owner", Some(&r1.revision_id))
|
|
2153
|
+
.unwrap();
|
|
2154
|
+
let r2 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2155
|
+
s.move_environment_pointer(
|
|
2156
|
+
"t",
|
|
2157
|
+
"a",
|
|
2158
|
+
&r2.revision_id,
|
|
2159
|
+
"production",
|
|
2160
|
+
Some(&r1.revision_id),
|
|
2161
|
+
"owner",
|
|
2162
|
+
"next",
|
|
2163
|
+
true,
|
|
2164
|
+
false,
|
|
2165
|
+
)
|
|
2166
|
+
.unwrap();
|
|
2167
|
+
assert_eq!(
|
|
2168
|
+
s.preview("t", "a", &preview.preview_id)
|
|
2169
|
+
.unwrap()
|
|
2170
|
+
.revision_id,
|
|
2171
|
+
r1.revision_id
|
|
2172
|
+
);
|
|
2173
|
+
assert_eq!(s.pointers("a")["production"], r2.revision_id);
|
|
2174
|
+
}
|
|
2175
|
+
#[test]
|
|
2176
|
+
fn destructive_change_requires_acknowledgement_and_audit_binds_diff() {
|
|
2177
|
+
let s = store("destructive-gate");
|
|
2178
|
+
let mut d = s.create_draft("t", "a", "App", "owner", None).unwrap();
|
|
2179
|
+
d.manifest.collections.push(CollectionDefinition {
|
|
2180
|
+
name: "records".into(),
|
|
2181
|
+
fields: vec![],
|
|
2182
|
+
});
|
|
2183
|
+
d = s
|
|
2184
|
+
.update_draft("t", "a", &d.draft_id, d.version, d.manifest, "owner")
|
|
2185
|
+
.unwrap();
|
|
2186
|
+
let r1 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2187
|
+
s.move_environment_pointer(
|
|
2188
|
+
"t",
|
|
2189
|
+
"a",
|
|
2190
|
+
&r1.revision_id,
|
|
2191
|
+
"production",
|
|
2192
|
+
None,
|
|
2193
|
+
"owner",
|
|
2194
|
+
"initial",
|
|
2195
|
+
true,
|
|
2196
|
+
false,
|
|
2197
|
+
)
|
|
2198
|
+
.unwrap();
|
|
2199
|
+
let mut d = s
|
|
2200
|
+
.create_draft("t", "a", "App", "owner", Some(&r1.revision_id))
|
|
2201
|
+
.unwrap();
|
|
2202
|
+
d.manifest.collections.clear();
|
|
2203
|
+
d.manifest.state_schema_version += 1;
|
|
2204
|
+
d = s
|
|
2205
|
+
.update_draft("t", "a", &d.draft_id, d.version, d.manifest, "owner")
|
|
2206
|
+
.unwrap();
|
|
2207
|
+
let r2 = s.commit("t", "a", &d.draft_id, "owner").unwrap();
|
|
2208
|
+
assert_eq!(
|
|
2209
|
+
s.move_environment_pointer(
|
|
2210
|
+
"t",
|
|
2211
|
+
"a",
|
|
2212
|
+
&r2.revision_id,
|
|
2213
|
+
"production",
|
|
2214
|
+
Some(&r1.revision_id),
|
|
2215
|
+
"owner",
|
|
2216
|
+
"remove",
|
|
2217
|
+
false,
|
|
2218
|
+
false
|
|
2219
|
+
)
|
|
2220
|
+
.unwrap_err(),
|
|
2221
|
+
"destructive_changes_require_approval"
|
|
2222
|
+
);
|
|
2223
|
+
let promotion = s
|
|
2224
|
+
.move_environment_pointer(
|
|
2225
|
+
"t",
|
|
2226
|
+
"a",
|
|
2227
|
+
&r2.revision_id,
|
|
2228
|
+
"production",
|
|
2229
|
+
Some(&r1.revision_id),
|
|
2230
|
+
"owner",
|
|
2231
|
+
"approved",
|
|
2232
|
+
true,
|
|
2233
|
+
false,
|
|
2234
|
+
)
|
|
2235
|
+
.unwrap();
|
|
2236
|
+
let audit = s.audit("t", "a");
|
|
2237
|
+
let event = audit.last().unwrap();
|
|
2238
|
+
assert_eq!(event.event, "application.revision.promoted");
|
|
2239
|
+
assert_eq!(
|
|
2240
|
+
event.diff_hash.as_deref(),
|
|
2241
|
+
Some(promotion.diff_hash.as_str())
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
}
|