@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.
Files changed (164) hide show
  1. package/dist/cli/commands.js +5 -2
  2. package/dist/cli/index.js +1 -1
  3. package/dist/create/cli.js +16 -1
  4. package/dist/create/create.js +30 -16
  5. package/dist/create/docker-compose-generator.js +48 -9
  6. package/dist/create/package-versions.js +1 -1
  7. package/dist/create/server-source/Cargo.lock +2238 -0
  8. package/dist/create/server-source/Cargo.toml +7 -0
  9. package/dist/create/server-source/crates/feltdb/Cargo.lock +175 -0
  10. package/dist/create/server-source/crates/feltdb/Cargo.toml +79 -0
  11. package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +370 -0
  12. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +589 -0
  13. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +259 -0
  14. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +446 -0
  15. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +272 -0
  16. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +378 -0
  17. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +414 -0
  18. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +486 -0
  19. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +273 -0
  20. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +587 -0
  21. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +513 -0
  22. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +395 -0
  23. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +469 -0
  24. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +409 -0
  25. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +627 -0
  26. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +383 -0
  27. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +298 -0
  28. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +1698 -0
  29. package/dist/create/server-source/crates/feltdb/src/acquisition.rs +286 -0
  30. package/dist/create/server-source/crates/feltdb/src/admission.rs +192 -0
  31. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +477 -0
  32. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +566 -0
  33. package/dist/create/server-source/crates/feltdb/src/analytics.rs +475 -0
  34. package/dist/create/server-source/crates/feltdb/src/application.rs +2244 -0
  35. package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +1070 -0
  36. package/dist/create/server-source/crates/feltdb/src/authorization.rs +1030 -0
  37. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +266 -0
  38. package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +8 -0
  39. package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +418 -0
  40. package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +482 -0
  41. package/dist/create/server-source/crates/feltdb/src/capability.rs +903 -0
  42. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +261 -0
  43. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +78 -0
  44. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +2214 -0
  45. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +194 -0
  46. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +427 -0
  47. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +453 -0
  48. package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +465 -0
  49. package/dist/create/server-source/crates/feltdb/src/convergence.rs +618 -0
  50. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +418 -0
  51. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +380 -0
  52. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +362 -0
  53. package/dist/create/server-source/crates/feltdb/src/cron.rs +294 -0
  54. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +474 -0
  55. package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +1003 -0
  56. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +536 -0
  57. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +364 -0
  58. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +235 -0
  59. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +207 -0
  60. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +316 -0
  61. package/dist/create/server-source/crates/feltdb/src/execution.rs +426 -0
  62. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +219 -0
  63. package/dist/create/server-source/crates/feltdb/src/indexing.rs +779 -0
  64. package/dist/create/server-source/crates/feltdb/src/lib.rs +2838 -0
  65. package/dist/create/server-source/crates/feltdb/src/materialization.rs +184 -0
  66. package/dist/create/server-source/crates/feltdb/src/metrics.rs +267 -0
  67. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +311 -0
  68. package/dist/create/server-source/crates/feltdb/src/observability.rs +366 -0
  69. package/dist/create/server-source/crates/feltdb/src/operation.rs +251 -0
  70. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +438 -0
  71. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +344 -0
  72. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +477 -0
  73. package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +166 -0
  74. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +261 -0
  75. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +560 -0
  76. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +3226 -0
  77. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +201 -0
  78. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +263 -0
  79. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +484 -0
  80. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +216 -0
  81. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +281 -0
  82. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +323 -0
  83. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +573 -0
  84. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +404 -0
  85. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +418 -0
  86. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +381 -0
  87. package/dist/create/server-source/crates/feltdb/src/phase_1c_three_node.rs +523 -0
  88. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +334 -0
  89. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +306 -0
  90. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +355 -0
  91. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +395 -0
  92. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +346 -0
  93. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +430 -0
  94. package/dist/create/server-source/crates/feltdb/src/production_api.rs +400 -0
  95. package/dist/create/server-source/crates/feltdb/src/provenance.rs +207 -0
  96. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +217 -0
  97. package/dist/create/server-source/crates/feltdb/src/references.rs +404 -0
  98. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +401 -0
  99. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +160 -0
  100. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +132 -0
  101. package/dist/create/server-source/crates/feltdb/src/routing.rs +409 -0
  102. package/dist/create/server-source/crates/feltdb/src/sharding.rs +523 -0
  103. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +3118 -0
  104. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +291 -0
  105. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +376 -0
  106. package/dist/create/server-source/crates/feltdb/src/storage.rs +267 -0
  107. package/dist/create/server-source/crates/feltdb/src/submission.rs +477 -0
  108. package/dist/create/server-source/crates/feltdb/src/sync.rs +483 -0
  109. package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +822 -0
  110. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +366 -0
  111. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +473 -0
  112. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +949 -0
  113. package/dist/create/server-source/crates/feltdb/src/transactions.rs +646 -0
  114. package/dist/create/server-source/crates/feltdb/src/trigger.rs +390 -0
  115. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +928 -0
  116. package/dist/create/server-source/crates/feltdb/src/workflow.rs +713 -0
  117. package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +510 -0
  118. package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +354 -0
  119. package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +594 -0
  120. package/dist/create/server-source/crates/feltdb/src/workload.rs +1310 -0
  121. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +23 -0
  122. package/dist/create/server-source/crates/feltdb-server/README.md +79 -0
  123. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +73 -0
  124. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +425 -0
  125. package/dist/create/server-source/crates/feltdb-server/src/artifacts.rs +449 -0
  126. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +59 -0
  127. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +308 -0
  128. package/dist/create/server-source/crates/feltdb-server/src/authorization.rs +228 -0
  129. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +416 -0
  130. package/dist/create/server-source/crates/feltdb-server/src/causal.rs +218 -0
  131. package/dist/create/server-source/crates/feltdb-server/src/certification.rs +223 -0
  132. package/dist/create/server-source/crates/feltdb-server/src/clock.rs +132 -0
  133. package/dist/create/server-source/crates/feltdb-server/src/cluster.rs +165 -0
  134. package/dist/create/server-source/crates/feltdb-server/src/connections.rs +1044 -0
  135. package/dist/create/server-source/crates/feltdb-server/src/content.rs +111 -0
  136. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +250 -0
  137. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +78 -0
  138. package/dist/create/server-source/crates/feltdb-server/src/key_provider.rs +247 -0
  139. package/dist/create/server-source/crates/feltdb-server/src/leases.rs +123 -0
  140. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +25 -0
  141. package/dist/create/server-source/crates/feltdb-server/src/main.rs +8715 -0
  142. package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +97 -0
  143. package/dist/create/server-source/crates/feltdb-server/src/portable_bundle.rs +350 -0
  144. package/dist/create/server-source/crates/feltdb-server/src/principals.rs +510 -0
  145. package/dist/create/server-source/crates/feltdb-server/src/providers.rs +269 -0
  146. package/dist/create/server-source/crates/feltdb-server/src/releases.rs +2563 -0
  147. package/dist/create/server-source/crates/feltdb-server/src/sessions.rs +156 -0
  148. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +1097 -0
  149. package/dist/create/server-source/crates/feltdb-server/src/versions.rs +264 -0
  150. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +31 -0
  151. package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +1086 -0
  152. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  153. package/dist/flowspec.d.ts +2 -0
  154. package/dist/flowspec.d.ts.map +1 -1
  155. package/dist/flowspec.js +28 -4
  156. package/dist/state-contract.d.ts +7 -1
  157. package/dist/state-contract.d.ts.map +1 -1
  158. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  159. package/dist/studio/components/index.js +1 -1
  160. package/dist/studio/{components-Q0Nx6gLE.js → components-bHTARBin.js} +156 -46
  161. package/dist/studio/index.js +31 -28
  162. package/dist/studio-app/assets/{index-BHOmLZF-.js → index-BqKquLuU.js} +8 -8
  163. package/dist/studio-app/index.html +1 -1
  164. package/package.json +1 -1
@@ -0,0 +1,2563 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use sha2::{Digest, Sha256};
3
+ use std::{
4
+ collections::BTreeMap,
5
+ fs,
6
+ path::PathBuf,
7
+ process::{Command, Stdio},
8
+ sync::{Arc, RwLock},
9
+ thread,
10
+ time::Duration,
11
+ time::{SystemTime, UNIX_EPOCH},
12
+ };
13
+
14
+ #[derive(Debug, Clone, Serialize, Deserialize)]
15
+ pub struct CiAttestation {
16
+ pub provider: String,
17
+ pub run_id: String,
18
+ pub commit: String,
19
+ pub status: String,
20
+ pub checks: BTreeMap<String, bool>,
21
+ pub artifacts: BTreeMap<String, String>,
22
+ pub generated_at: u64,
23
+ }
24
+ #[derive(Debug, Clone, Serialize, Deserialize)]
25
+ pub struct ReleaseArtifact {
26
+ pub name: String,
27
+ pub artifact_type: String,
28
+ pub digest: String,
29
+ pub size: u64,
30
+ }
31
+ #[derive(Debug, Clone, Serialize, Deserialize)]
32
+ pub struct ReleaseManifest {
33
+ pub release_id: String,
34
+ pub version: String,
35
+ pub application_id: String,
36
+ pub revision_id: String,
37
+ pub bundle_id: String,
38
+ pub source_commit: String,
39
+ pub artifacts: Vec<ReleaseArtifact>,
40
+ pub packages: Vec<ReleaseArtifact>,
41
+ pub runtime: BTreeMap<String, String>,
42
+ pub environment: BTreeMap<String, String>,
43
+ pub parent_release: Option<String>,
44
+ pub ci_attestation: CiAttestation,
45
+ pub release_attestation: Option<String>,
46
+ pub digest: String,
47
+ pub created_at: u64,
48
+ }
49
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
51
+ pub enum ReleaseStatus {
52
+ Candidate,
53
+ Verified,
54
+ Published,
55
+ Promoted,
56
+ Revoked,
57
+ }
58
+ #[derive(Debug, Clone, Serialize, Deserialize)]
59
+ pub struct Release {
60
+ pub manifest: ReleaseManifest,
61
+ pub status: ReleaseStatus,
62
+ pub created_by: String,
63
+ }
64
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
65
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
66
+ pub enum DeploymentState {
67
+ Draft,
68
+ Validated,
69
+ Published,
70
+ Ready,
71
+ Planned,
72
+ Approved,
73
+ Provisioning,
74
+ Deploying,
75
+ Starting,
76
+ HealthChecking,
77
+ Active,
78
+ Degraded,
79
+ Failed,
80
+ Stopping,
81
+ RollbackPending,
82
+ RollingBack,
83
+ RolledBack,
84
+ Stopped,
85
+ Destroying,
86
+ Destroyed,
87
+ }
88
+ impl Default for DeploymentState {
89
+ fn default() -> Self {
90
+ Self::Draft
91
+ }
92
+ }
93
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
94
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
95
+ pub enum DesiredDeploymentState {
96
+ #[default]
97
+ Active,
98
+ Stopped,
99
+ Destroyed,
100
+ }
101
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
102
+ #[serde(rename_all = "kebab-case")]
103
+ pub enum DeploymentEnvironment {
104
+ Local,
105
+ Preview,
106
+ Staging,
107
+ #[default]
108
+ Production,
109
+ }
110
+ impl DeploymentEnvironment {
111
+ pub fn as_str(self) -> &'static str {
112
+ match self {
113
+ Self::Local => "local",
114
+ Self::Preview => "preview",
115
+ Self::Staging => "staging",
116
+ Self::Production => "production",
117
+ }
118
+ }
119
+ }
120
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
121
+ #[serde(rename_all = "kebab-case")]
122
+ pub enum DeploymentProviderKind {
123
+ #[default]
124
+ Local,
125
+ Docker,
126
+ Fly,
127
+ Render,
128
+ Vercel,
129
+ }
130
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
131
+ #[serde(rename_all = "snake_case")]
132
+ pub enum DeploymentService {
133
+ Studio,
134
+ ControlPlane,
135
+ Worker,
136
+ ApplicationRuntime,
137
+ ArtifactStore,
138
+ }
139
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
140
+ #[serde(rename_all = "kebab-case")]
141
+ pub enum DeploymentProfile {
142
+ #[default]
143
+ AllInOne,
144
+ Split,
145
+ ControlPlaneOnly,
146
+ WorkerExpansion,
147
+ StudioOnly,
148
+ ApplicationOnly,
149
+ SelfHost,
150
+ }
151
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152
+ pub struct SecretReference {
153
+ pub name: String,
154
+ pub provider_reference: String,
155
+ }
156
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157
+ pub struct DomainBinding {
158
+ pub service: DeploymentService,
159
+ pub hostname: String,
160
+ }
161
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162
+ pub struct ScalingPolicy {
163
+ pub min_instances: u32,
164
+ pub max_instances: u32,
165
+ }
166
+ impl Default for ScalingPolicy {
167
+ fn default() -> Self {
168
+ Self {
169
+ min_instances: 1,
170
+ max_instances: 1,
171
+ }
172
+ }
173
+ }
174
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175
+ pub struct ReleasePolicy {
176
+ pub automatic_rollback: bool,
177
+ pub require_certification: bool,
178
+ }
179
+ impl Default for ReleasePolicy {
180
+ fn default() -> Self {
181
+ Self {
182
+ automatic_rollback: true,
183
+ require_certification: true,
184
+ }
185
+ }
186
+ }
187
+ #[derive(Debug, Clone, Serialize, Deserialize, Default)]
188
+ pub struct DeploymentHealth {
189
+ pub runtime_ready: bool,
190
+ pub application_ready: bool,
191
+ pub state_store_ready: bool,
192
+ pub sync_ready: bool,
193
+ pub workers_ready: bool,
194
+ pub connections_ready: bool,
195
+ pub required_capabilities_ready: bool,
196
+ }
197
+ impl DeploymentHealth {
198
+ pub fn required() -> Self {
199
+ Self {
200
+ runtime_ready: true,
201
+ application_ready: true,
202
+ state_store_ready: true,
203
+ sync_ready: true,
204
+ workers_ready: true,
205
+ connections_ready: true,
206
+ required_capabilities_ready: true,
207
+ }
208
+ }
209
+ pub fn healthy(&self) -> bool {
210
+ self.runtime_ready
211
+ && self.application_ready
212
+ && self.state_store_ready
213
+ && self.sync_ready
214
+ && self.workers_ready
215
+ && self.connections_ready
216
+ && self.required_capabilities_ready
217
+ }
218
+ }
219
+ #[derive(Debug, Clone, Serialize, Deserialize)]
220
+ pub struct DeploymentPlan {
221
+ pub application_id: String,
222
+ pub release_id: String,
223
+ #[serde(default)]
224
+ pub revision_id: String,
225
+ pub environment: DeploymentEnvironment,
226
+ pub workers: Vec<String>,
227
+ pub connections: Vec<String>,
228
+ pub required_secrets: Vec<String>,
229
+ pub artifacts: Vec<String>,
230
+ pub rollback_target: Option<String>,
231
+ pub health_requirements: DeploymentHealth,
232
+ #[serde(default)]
233
+ pub provider: DeploymentProviderKind,
234
+ #[serde(default)]
235
+ pub profile: DeploymentProfile,
236
+ #[serde(default)]
237
+ pub services: Vec<DeploymentService>,
238
+ #[serde(default)]
239
+ pub domains: Vec<DomainBinding>,
240
+ #[serde(default)]
241
+ pub configuration: BTreeMap<String, String>,
242
+ #[serde(default)]
243
+ pub secret_references: Vec<SecretReference>,
244
+ #[serde(default)]
245
+ pub scaling_policy: ScalingPolicy,
246
+ #[serde(default)]
247
+ pub release_policy: ReleasePolicy,
248
+ #[serde(default)]
249
+ pub dependencies: BTreeMap<DeploymentService, Vec<DeploymentService>>,
250
+ }
251
+ #[derive(Debug, Clone, Serialize, Deserialize)]
252
+ pub struct Deployment {
253
+ pub deployment_id: String,
254
+ pub plan: DeploymentPlan,
255
+ pub state: DeploymentState,
256
+ pub health: DeploymentHealth,
257
+ pub created_at: u64,
258
+ pub updated_at: u64,
259
+ #[serde(default)]
260
+ pub observed: ProviderObservation,
261
+ #[serde(default)]
262
+ pub previous_known_good_release: Option<String>,
263
+ #[serde(default)]
264
+ pub desired_state: DesiredDeploymentState,
265
+ #[serde(default)]
266
+ pub failure: Option<String>,
267
+ }
268
+ #[derive(Debug, Clone, Serialize, Deserialize, Default)]
269
+ pub struct ProviderObservation {
270
+ pub services: BTreeMap<DeploymentService, String>,
271
+ pub endpoints: BTreeMap<DeploymentService, String>,
272
+ pub provider_deployment_id: Option<String>,
273
+ pub observed_at: u64,
274
+ }
275
+ #[derive(Debug, Clone, Serialize, Deserialize)]
276
+ pub struct DeploymentCertification {
277
+ pub valid: bool,
278
+ pub release_verified: bool,
279
+ pub provider_valid: bool,
280
+ pub secrets_are_references: bool,
281
+ pub health_contract_present: bool,
282
+ pub diagnostics: Vec<String>,
283
+ }
284
+ #[derive(Debug, Clone, Serialize, Deserialize)]
285
+ pub struct AuthorizedDeploymentContext {
286
+ pub tenant_id: String,
287
+ pub application_id: String,
288
+ pub environment: DeploymentEnvironment,
289
+ pub release_id: String,
290
+ pub revision_id: String,
291
+ pub deployment_id: String,
292
+ pub desired_state: DesiredDeploymentState,
293
+ pub actor: String,
294
+ pub capabilities: Vec<String>,
295
+ pub expires_at: u64,
296
+ #[serde(default)]
297
+ pub credential_versions: BTreeMap<String, u64>,
298
+ #[serde(skip)]
299
+ pub credentials: BTreeMap<String, String>,
300
+ }
301
+ impl AuthorizedDeploymentContext {
302
+ fn system(plan: &DeploymentPlan, deployment_id: &str) -> Self {
303
+ Self {
304
+ tenant_id: "system".into(),
305
+ application_id: plan.application_id.clone(),
306
+ environment: plan.environment,
307
+ release_id: plan.release_id.clone(),
308
+ revision_id: plan.revision_id.clone(),
309
+ deployment_id: deployment_id.into(),
310
+ desired_state: DesiredDeploymentState::Active,
311
+ actor: "system".into(),
312
+ capabilities: vec!["deployment:execute".into()],
313
+ expires_at: now() + 300,
314
+ credential_versions: BTreeMap::new(),
315
+ credentials: BTreeMap::new(),
316
+ }
317
+ }
318
+ pub fn new(
319
+ tenant_id: String,
320
+ plan: &DeploymentPlan,
321
+ deployment_id: String,
322
+ actor: String,
323
+ credentials: BTreeMap<String, String>,
324
+ credential_versions: BTreeMap<String, u64>,
325
+ ) -> Self {
326
+ Self {
327
+ tenant_id,
328
+ application_id: plan.application_id.clone(),
329
+ environment: plan.environment,
330
+ release_id: plan.release_id.clone(),
331
+ revision_id: plan.revision_id.clone(),
332
+ deployment_id,
333
+ desired_state: DesiredDeploymentState::Active,
334
+ actor,
335
+ capabilities: vec!["deployment:execute".into()],
336
+ expires_at: now() + 300,
337
+ credential_versions,
338
+ credentials,
339
+ }
340
+ }
341
+ pub fn authorize(&self, plan: &DeploymentPlan) -> Result<(), String> {
342
+ if self.expires_at <= now() {
343
+ return Err("deployment execution context expired".into());
344
+ }
345
+ if self.application_id != plan.application_id
346
+ || self.release_id != plan.release_id
347
+ || self.revision_id != plan.revision_id
348
+ || self.environment != plan.environment
349
+ {
350
+ return Err("deployment execution context scope mismatch".into());
351
+ }
352
+ if !self
353
+ .capabilities
354
+ .iter()
355
+ .any(|value| value == "deployment:execute" || value == "*")
356
+ {
357
+ return Err("deployment:execute capability required".into());
358
+ }
359
+ Ok(())
360
+ }
361
+ }
362
+ #[derive(Debug, Clone, Serialize, Deserialize)]
363
+ pub struct DeploymentOperationAudit {
364
+ pub provider: DeploymentProviderKind,
365
+ pub deployment_id: String,
366
+ pub secret_references: Vec<String>,
367
+ pub credential_versions: BTreeMap<String, u64>,
368
+ pub operation: String,
369
+ pub actor: String,
370
+ pub timestamp: u64,
371
+ pub result: String,
372
+ }
373
+ pub trait DeploymentProvider: Send + Sync {
374
+ fn kind(&self) -> DeploymentProviderKind;
375
+ fn validate(&self, plan: &DeploymentPlan) -> Result<(), String> {
376
+ if plan.services.is_empty() {
377
+ return Err("deployment must select at least one service".into());
378
+ }
379
+ if self.kind() == DeploymentProviderKind::Vercel
380
+ && plan
381
+ .services
382
+ .iter()
383
+ .any(|service| *service != DeploymentService::Studio)
384
+ {
385
+ return Err("Vercel provider supports the Studio service only".into());
386
+ }
387
+ if plan.scaling_policy.min_instances > plan.scaling_policy.max_instances {
388
+ return Err("minimum instances cannot exceed maximum instances".into());
389
+ }
390
+ let requires_control_plane = plan.services.iter().any(|service| {
391
+ matches!(
392
+ service,
393
+ DeploymentService::Studio
394
+ | DeploymentService::Worker
395
+ | DeploymentService::ApplicationRuntime
396
+ )
397
+ });
398
+ if requires_control_plane
399
+ && !plan.services.contains(&DeploymentService::ControlPlane)
400
+ && !plan.configuration.contains_key("control_plane.url")
401
+ {
402
+ return Err("independent service deployment requires control_plane.url".into());
403
+ }
404
+ Ok(())
405
+ }
406
+ fn provision(&self, _: &AuthorizedDeploymentContext, _: &DeploymentPlan) -> Result<(), String>;
407
+ fn configure(
408
+ &self,
409
+ context: &AuthorizedDeploymentContext,
410
+ plan: &DeploymentPlan,
411
+ ) -> Result<(), String> {
412
+ context.authorize(plan)
413
+ }
414
+ fn deploy(
415
+ &self,
416
+ _: &AuthorizedDeploymentContext,
417
+ _: &DeploymentPlan,
418
+ _: &ReleaseManifest,
419
+ ) -> Result<(), String>;
420
+ fn status(
421
+ &self,
422
+ _: &AuthorizedDeploymentContext,
423
+ _: &DeploymentPlan,
424
+ ) -> Result<ProviderObservation, String> {
425
+ Ok(ProviderObservation::default())
426
+ }
427
+ fn health(
428
+ &self,
429
+ _: &AuthorizedDeploymentContext,
430
+ _: &DeploymentPlan,
431
+ ) -> Result<DeploymentHealth, String>;
432
+ fn scale(
433
+ &self,
434
+ _: &AuthorizedDeploymentContext,
435
+ _: &DeploymentPlan,
436
+ _: &ScalingPolicy,
437
+ ) -> Result<(), String> {
438
+ Ok(())
439
+ }
440
+ fn stop(&self, _: &AuthorizedDeploymentContext, _: &DeploymentPlan) -> Result<(), String>;
441
+ fn restart(&self, _: &AuthorizedDeploymentContext, _: &DeploymentPlan) -> Result<(), String> {
442
+ Ok(())
443
+ }
444
+ fn rollback(
445
+ &self,
446
+ _: &AuthorizedDeploymentContext,
447
+ _: &DeploymentPlan,
448
+ _: &ReleaseManifest,
449
+ ) -> Result<(), String>;
450
+ fn destroy(&self, _: &AuthorizedDeploymentContext, _: &DeploymentPlan) -> Result<(), String> {
451
+ Ok(())
452
+ }
453
+ }
454
+ #[derive(Default)]
455
+ pub struct LocalDeploymentProvider;
456
+ fn configured<'a>(plan: &'a DeploymentPlan, key: &str) -> Result<&'a str, String> {
457
+ plan.configuration
458
+ .get(key)
459
+ .map(String::as_str)
460
+ .ok_or_else(|| format!("missing deployment configuration: {key}"))
461
+ }
462
+ fn state_file(context: &AuthorizedDeploymentContext, suffix: &str) -> PathBuf {
463
+ let root = std::env::var("FELTDB_DEPLOYMENT_STATE_DIR")
464
+ .map(PathBuf::from)
465
+ .unwrap_or_else(|_| std::env::temp_dir().join("feltdb-deployments"));
466
+ root.join(format!(
467
+ "{}-{suffix}",
468
+ context.deployment_id.replace(['/', ':'], "_")
469
+ ))
470
+ }
471
+ fn execute_command(
472
+ context: &AuthorizedDeploymentContext,
473
+ program: &str,
474
+ args: &[String],
475
+ ) -> Result<String, String> {
476
+ let mut command = Command::new(program);
477
+ command.args(args);
478
+ for (name, value) in &context.credentials {
479
+ command.env(name, value);
480
+ }
481
+ let output = command
482
+ .output()
483
+ .map_err(|error| format!("unable to execute {program}: {error}"))?;
484
+ if !output.status.success() {
485
+ return Err(format!(
486
+ "{program} failed: {}",
487
+ String::from_utf8_lossy(&output.stderr).trim()
488
+ ));
489
+ }
490
+ Ok(String::from_utf8_lossy(&output.stdout).trim().into())
491
+ }
492
+ fn process_alive(pid: u32) -> bool {
493
+ Command::new("kill")
494
+ .args(["-0", &pid.to_string()])
495
+ .stdout(Stdio::null())
496
+ .stderr(Stdio::null())
497
+ .status()
498
+ .map(|status| status.success())
499
+ .unwrap_or(false)
500
+ }
501
+ fn endpoint_health(
502
+ context: &AuthorizedDeploymentContext,
503
+ plan: &DeploymentPlan,
504
+ ) -> Result<(), String> {
505
+ let url = plan
506
+ .configuration
507
+ .get("health.url")
508
+ .cloned()
509
+ .or_else(|| {
510
+ plan.domains
511
+ .first()
512
+ .map(|domain| format!("https://{}/ready", domain.hostname))
513
+ })
514
+ .ok_or("health.url or a domain binding is required")?;
515
+ let mut request = reqwest::blocking::Client::builder()
516
+ .timeout(Duration::from_secs(10))
517
+ .build()
518
+ .map_err(|error| error.to_string())?
519
+ .get(url);
520
+ if let Some(name) = plan.configuration.get("health.bearer_secret") {
521
+ let token = context
522
+ .credentials
523
+ .get(name)
524
+ .ok_or("health bearer credential unavailable")?;
525
+ request = request.bearer_auth(token);
526
+ }
527
+ let retries = plan
528
+ .configuration
529
+ .get("health.retries")
530
+ .and_then(|value| value.parse::<u32>().ok())
531
+ .unwrap_or(10);
532
+ let interval = plan
533
+ .configuration
534
+ .get("health.interval_ms")
535
+ .and_then(|value| value.parse::<u64>().ok())
536
+ .unwrap_or(500);
537
+ let mut last_error = "health endpoint was not attempted".to_string();
538
+ for _ in 0..retries.max(1) {
539
+ match request
540
+ .try_clone()
541
+ .ok_or("health request cannot be retried")?
542
+ .send()
543
+ {
544
+ Ok(response) if response.status().is_success() => return Ok(()),
545
+ Ok(response) => last_error = format!("health endpoint returned {}", response.status()),
546
+ Err(error) => last_error = format!("health request failed: {error}"),
547
+ }
548
+ thread::sleep(Duration::from_millis(interval));
549
+ }
550
+ Err(last_error)
551
+ }
552
+ fn start_local(context: &AuthorizedDeploymentContext, plan: &DeploymentPlan) -> Result<(), String> {
553
+ let program = configured(plan, "local.command")?;
554
+ let args: Vec<_> = plan
555
+ .configuration
556
+ .get("local.args")
557
+ .map(|value| value.split_whitespace().map(String::from).collect())
558
+ .unwrap_or_default();
559
+ let mut command = Command::new(program);
560
+ command
561
+ .args(args)
562
+ .stdout(Stdio::null())
563
+ .stderr(Stdio::null());
564
+ for (name, value) in &context.credentials {
565
+ command.env(name, value);
566
+ }
567
+ let mut child = command
568
+ .spawn()
569
+ .map_err(|error| format!("unable to start local deployment: {error}"))?;
570
+ thread::sleep(Duration::from_millis(100));
571
+ if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
572
+ return Err(format!("local deployment exited during startup: {status}"));
573
+ }
574
+ let path = state_file(context, "pid");
575
+ if let Some(parent) = path.parent() {
576
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
577
+ }
578
+ fs::write(path, child.id().to_string()).map_err(|error| error.to_string())
579
+ }
580
+ impl DeploymentProvider for LocalDeploymentProvider {
581
+ fn kind(&self) -> DeploymentProviderKind {
582
+ DeploymentProviderKind::Local
583
+ }
584
+ fn validate(&self, plan: &DeploymentPlan) -> Result<(), String> {
585
+ configured(plan, "local.command")?;
586
+ if !plan.configuration.contains_key("health.url")
587
+ && !plan.configuration.contains_key("health.command")
588
+ && plan.domains.is_empty()
589
+ {
590
+ return Err("local deployment requires health.url or a domain binding".into());
591
+ }
592
+ Ok(())
593
+ }
594
+ fn provision(
595
+ &self,
596
+ context: &AuthorizedDeploymentContext,
597
+ plan: &DeploymentPlan,
598
+ ) -> Result<(), String> {
599
+ context.authorize(plan)?;
600
+ if context.actor != "system"
601
+ && std::env::var("FELTDB_ALLOW_LOCAL_DEPLOYMENTS").as_deref() != Ok("true")
602
+ {
603
+ return Err("local process deployments are disabled on this control plane".into());
604
+ }
605
+ configured(plan, "local.command")?;
606
+ Ok(())
607
+ }
608
+ fn deploy(
609
+ &self,
610
+ context: &AuthorizedDeploymentContext,
611
+ plan: &DeploymentPlan,
612
+ _release: &ReleaseManifest,
613
+ ) -> Result<(), String> {
614
+ context.authorize(plan)?;
615
+ start_local(context, plan)
616
+ }
617
+ fn health(
618
+ &self,
619
+ context: &AuthorizedDeploymentContext,
620
+ plan: &DeploymentPlan,
621
+ ) -> Result<DeploymentHealth, String> {
622
+ context.authorize(plan)?;
623
+ if plan.configuration.contains_key("health.url") || !plan.domains.is_empty() {
624
+ endpoint_health(context, plan)?;
625
+ } else if let Some(command) = plan.configuration.get("health.command") {
626
+ execute_command(context, command, &[])?;
627
+ }
628
+ let pid: u32 = fs::read_to_string(state_file(context, "pid"))
629
+ .map_err(|_| "local deployment pid unavailable")?
630
+ .parse()
631
+ .map_err(|_| "invalid local deployment pid")?;
632
+ if !process_alive(pid) {
633
+ return Err("local deployment is not running".into());
634
+ }
635
+ Ok(DeploymentHealth::required())
636
+ }
637
+ fn status(
638
+ &self,
639
+ context: &AuthorizedDeploymentContext,
640
+ plan: &DeploymentPlan,
641
+ ) -> Result<ProviderObservation, String> {
642
+ let pid: u32 = fs::read_to_string(state_file(context, "pid"))
643
+ .map_err(|_| "local deployment pid unavailable")?
644
+ .parse()
645
+ .map_err(|_| "invalid local deployment pid")?;
646
+ let mut services = BTreeMap::new();
647
+ for service in &plan.services {
648
+ services.insert(
649
+ *service,
650
+ if process_alive(pid) {
651
+ "running".into()
652
+ } else {
653
+ "missing".into()
654
+ },
655
+ );
656
+ }
657
+ Ok(ProviderObservation {
658
+ services,
659
+ endpoints: BTreeMap::new(),
660
+ provider_deployment_id: Some(pid.to_string()),
661
+ observed_at: now(),
662
+ })
663
+ }
664
+ fn stop(
665
+ &self,
666
+ context: &AuthorizedDeploymentContext,
667
+ plan: &DeploymentPlan,
668
+ ) -> Result<(), String> {
669
+ context.authorize(plan)?;
670
+ if let Ok(pid) = fs::read_to_string(state_file(context, "pid")) {
671
+ let _ = execute_command(context, "kill", &[pid.trim().into()]);
672
+ }
673
+ Ok(())
674
+ }
675
+ fn restart(
676
+ &self,
677
+ context: &AuthorizedDeploymentContext,
678
+ plan: &DeploymentPlan,
679
+ ) -> Result<(), String> {
680
+ self.stop(context, plan)?;
681
+ start_local(context, plan)
682
+ }
683
+ fn rollback(
684
+ &self,
685
+ context: &AuthorizedDeploymentContext,
686
+ plan: &DeploymentPlan,
687
+ release: &ReleaseManifest,
688
+ ) -> Result<(), String> {
689
+ self.stop(context, plan)?;
690
+ self.deploy(context, plan, release)
691
+ }
692
+ fn destroy(
693
+ &self,
694
+ context: &AuthorizedDeploymentContext,
695
+ plan: &DeploymentPlan,
696
+ ) -> Result<(), String> {
697
+ self.stop(context, plan)?;
698
+ let _ = fs::remove_file(state_file(context, "pid"));
699
+ Ok(())
700
+ }
701
+ }
702
+
703
+ /// Executable infrastructure adapters behind the provider-neutral Rust contract.
704
+ /// Credentials exist only in the short-lived authorized execution context.
705
+ pub struct DeclarativeDeploymentProvider(pub DeploymentProviderKind);
706
+ impl DeploymentProvider for DeclarativeDeploymentProvider {
707
+ fn kind(&self) -> DeploymentProviderKind {
708
+ self.0
709
+ }
710
+ fn validate(&self, plan: &DeploymentPlan) -> Result<(), String> {
711
+ if plan.services.is_empty() {
712
+ return Err("deployment must select at least one service".into());
713
+ }
714
+ match self.0 {
715
+ DeploymentProviderKind::Docker => {
716
+ configured(plan, "image")?;
717
+ }
718
+ DeploymentProviderKind::Fly => {
719
+ configured(plan, "fly.app")?;
720
+ configured(plan, "image")?;
721
+ }
722
+ DeploymentProviderKind::Render => {
723
+ if !plan.configuration.contains_key("render.service_id")
724
+ && !plan.configuration.contains_key("render.create_payload")
725
+ {
726
+ return Err("Render requires render.service_id or render.create_payload".into());
727
+ }
728
+ }
729
+ DeploymentProviderKind::Vercel => {
730
+ if plan
731
+ .services
732
+ .iter()
733
+ .any(|service| *service != DeploymentService::Studio)
734
+ {
735
+ return Err("Vercel provider supports the Studio service only".into());
736
+ }
737
+ configured(plan, "vercel.project")?;
738
+ }
739
+ DeploymentProviderKind::Local => {}
740
+ }
741
+ if !plan.configuration.contains_key("health.url") && plan.domains.is_empty() {
742
+ return Err("deployment requires health.url or a domain binding".into());
743
+ }
744
+ if plan.scaling_policy.min_instances > plan.scaling_policy.max_instances {
745
+ return Err("minimum instances cannot exceed maximum instances".into());
746
+ }
747
+ let requires_control_plane = plan.services.iter().any(|service| {
748
+ matches!(
749
+ service,
750
+ DeploymentService::Studio
751
+ | DeploymentService::Worker
752
+ | DeploymentService::ApplicationRuntime
753
+ )
754
+ });
755
+ if requires_control_plane
756
+ && !plan.services.contains(&DeploymentService::ControlPlane)
757
+ && !plan.configuration.contains_key("control_plane.url")
758
+ {
759
+ return Err("independent service deployment requires control_plane.url".into());
760
+ }
761
+ Ok(())
762
+ }
763
+ fn provision(
764
+ &self,
765
+ context: &AuthorizedDeploymentContext,
766
+ plan: &DeploymentPlan,
767
+ ) -> Result<(), String> {
768
+ context.authorize(plan)?;
769
+ match self.0 {
770
+ DeploymentProviderKind::Docker => {
771
+ if context.actor != "system"
772
+ && std::env::var("FELTDB_ALLOW_LOCAL_DEPLOYMENTS").as_deref() != Ok("true")
773
+ {
774
+ return Err("Docker deployments are disabled on this control plane".into());
775
+ }
776
+ let image = configured(plan, "image")?.to_string();
777
+ if execute_command(
778
+ context,
779
+ "docker",
780
+ &["image".into(), "inspect".into(), image.clone()],
781
+ )
782
+ .is_err()
783
+ {
784
+ let build_context = configured(plan, "docker.context")?.to_string();
785
+ execute_command(
786
+ context,
787
+ "docker",
788
+ &["build".into(), "-t".into(), image, build_context],
789
+ )?;
790
+ }
791
+ }
792
+ DeploymentProviderKind::Fly => {
793
+ if !context.credentials.contains_key("FLY_API_TOKEN") {
794
+ return Err("FLY_API_TOKEN credential unavailable".into());
795
+ }
796
+ let app = configured(plan, "fly.app")?.to_string();
797
+ let result = execute_command(
798
+ context,
799
+ "flyctl",
800
+ &["apps".into(), "create".into(), app, "--json".into()],
801
+ );
802
+ if let Err(error) = result {
803
+ if !error.contains("already exists") {
804
+ return Err(error);
805
+ }
806
+ }
807
+ }
808
+ DeploymentProviderKind::Render => {
809
+ let payload = plan
810
+ .configuration
811
+ .get("render.create_payload")
812
+ .or_else(|| plan.configuration.get("render.update_payload"));
813
+ if let Some(payload) = payload {
814
+ let body: serde_json::Value = serde_json::from_str(payload)
815
+ .map_err(|error| format!("invalid Render service payload: {error}"))?;
816
+ if plan.configuration.contains_key("render.service_id") {
817
+ render_request(context, plan, "PATCH", "", Some(body))?;
818
+ } else {
819
+ let base = plan
820
+ .configuration
821
+ .get("render.api_base")
822
+ .map(String::as_str)
823
+ .unwrap_or("https://api.render.com/v1");
824
+ let response =
825
+ render_http(context, "POST", &format!("{base}/services"), Some(body))?;
826
+ let id = response
827
+ .get("id")
828
+ .or_else(|| response.pointer("/service/id"))
829
+ .and_then(|value| value.as_str())
830
+ .ok_or("Render create response omitted service id")?;
831
+ let path = state_file(context, "render-service");
832
+ if let Some(parent) = path.parent() {
833
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
834
+ }
835
+ fs::write(path, id).map_err(|error| error.to_string())?;
836
+ }
837
+ }
838
+ }
839
+ DeploymentProviderKind::Vercel => {}
840
+ DeploymentProviderKind::Local => return Err("invalid local provider adapter".into()),
841
+ }
842
+ Ok(())
843
+ }
844
+ fn deploy(
845
+ &self,
846
+ context: &AuthorizedDeploymentContext,
847
+ plan: &DeploymentPlan,
848
+ release: &ReleaseManifest,
849
+ ) -> Result<(), String> {
850
+ context.authorize(plan)?;
851
+ match self.0 {
852
+ DeploymentProviderKind::Docker => {
853
+ let name = plan
854
+ .configuration
855
+ .get("docker.name")
856
+ .cloned()
857
+ .unwrap_or_else(|| context.deployment_id.replace(['/', ':'], "-"));
858
+ let _ =
859
+ execute_command(context, "docker", &["rm".into(), "-f".into(), name.clone()]);
860
+ let mut args = vec!["run".into(), "-d".into(), "--name".into(), name];
861
+ if let Some(ports) = plan.configuration.get("docker.ports") {
862
+ for port in ports.split(',') {
863
+ args.extend(["-p".into(), port.trim().into()]);
864
+ }
865
+ }
866
+ if let Some(volumes) = plan.configuration.get("docker.volumes") {
867
+ for volume in volumes.split(',').filter(|value| !value.trim().is_empty()) {
868
+ args.extend(["--volume".into(), volume.trim().into()]);
869
+ }
870
+ }
871
+ for name in context.credentials.keys() {
872
+ args.extend(["--env".into(), name.clone()]);
873
+ }
874
+ args.push(
875
+ release
876
+ .runtime
877
+ .get("image")
878
+ .cloned()
879
+ .unwrap_or(configured(plan, "image")?.into()),
880
+ );
881
+ execute_command(context, "docker", &args)?;
882
+ }
883
+ DeploymentProviderKind::Fly => {
884
+ execute_command(
885
+ context,
886
+ "flyctl",
887
+ &[
888
+ "deploy".into(),
889
+ "--app".into(),
890
+ configured(plan, "fly.app")?.into(),
891
+ "--image".into(),
892
+ release
893
+ .runtime
894
+ .get("image")
895
+ .cloned()
896
+ .unwrap_or(configured(plan, "image")?.into()),
897
+ "--remote-only".into(),
898
+ ],
899
+ )?;
900
+ }
901
+ DeploymentProviderKind::Render => {
902
+ render_request(
903
+ context,
904
+ plan,
905
+ "POST",
906
+ "deploys",
907
+ Some(serde_json::json!({"clearCache":"do_not_clear"})),
908
+ )?;
909
+ }
910
+ DeploymentProviderKind::Vercel => {
911
+ if !context.credentials.contains_key("VERCEL_TOKEN") {
912
+ return Err("VERCEL_TOKEN credential unavailable".into());
913
+ }
914
+ let mut args = vec![
915
+ "deploy".into(),
916
+ "--yes".into(),
917
+ "--prod".into(),
918
+ "--name".into(),
919
+ configured(plan, "vercel.project")?.into(),
920
+ ];
921
+ if let Some(path) = release
922
+ .runtime
923
+ .get("vercel.path")
924
+ .or_else(|| plan.configuration.get("vercel.path"))
925
+ {
926
+ args.push(path.clone());
927
+ }
928
+ let deployment = execute_command(context, "vercel", &args)?;
929
+ let path = state_file(context, "vercel-deployment");
930
+ if let Some(parent) = path.parent() {
931
+ fs::create_dir_all(parent).map_err(|error| error.to_string())?;
932
+ }
933
+ fs::write(path, deployment).map_err(|error| error.to_string())?;
934
+ }
935
+ DeploymentProviderKind::Local => return Err("invalid local provider adapter".into()),
936
+ }
937
+ Ok(())
938
+ }
939
+ fn health(
940
+ &self,
941
+ context: &AuthorizedDeploymentContext,
942
+ plan: &DeploymentPlan,
943
+ ) -> Result<DeploymentHealth, String> {
944
+ endpoint_health(context, plan)?;
945
+ Ok(DeploymentHealth::required())
946
+ }
947
+ fn status(
948
+ &self,
949
+ context: &AuthorizedDeploymentContext,
950
+ plan: &DeploymentPlan,
951
+ ) -> Result<ProviderObservation, String> {
952
+ context.authorize(plan)?;
953
+ let provider_deployment_id = match self.0 {
954
+ DeploymentProviderKind::Docker => Some(execute_command(
955
+ context,
956
+ "docker",
957
+ &[
958
+ "inspect".into(),
959
+ "--format".into(),
960
+ "{{.State.Status}}".into(),
961
+ plan.configuration
962
+ .get("docker.name")
963
+ .cloned()
964
+ .unwrap_or_else(|| context.deployment_id.replace(['/', ':'], "-")),
965
+ ],
966
+ )?),
967
+ DeploymentProviderKind::Fly => Some(execute_command(
968
+ context,
969
+ "flyctl",
970
+ &[
971
+ "status".into(),
972
+ "--app".into(),
973
+ configured(plan, "fly.app")?.into(),
974
+ "--json".into(),
975
+ ],
976
+ )?),
977
+ DeploymentProviderKind::Render => {
978
+ Some(render_request(context, plan, "GET", "", None)?.to_string())
979
+ }
980
+ DeploymentProviderKind::Vercel => {
981
+ let deployment = fs::read_to_string(state_file(context, "vercel-deployment"))
982
+ .map_err(|_| "Vercel deployment identifier unavailable")?;
983
+ Some(execute_command(
984
+ context,
985
+ "vercel",
986
+ &["inspect".into(), deployment.trim().into()],
987
+ )?)
988
+ }
989
+ DeploymentProviderKind::Local => None,
990
+ };
991
+ let services = plan
992
+ .services
993
+ .iter()
994
+ .map(|service| (*service, "ready".into()))
995
+ .collect();
996
+ let endpoints = plan
997
+ .domains
998
+ .iter()
999
+ .map(|domain| (domain.service, format!("https://{}", domain.hostname)))
1000
+ .collect();
1001
+ Ok(ProviderObservation {
1002
+ services,
1003
+ endpoints,
1004
+ provider_deployment_id,
1005
+ observed_at: now(),
1006
+ })
1007
+ }
1008
+ fn scale(
1009
+ &self,
1010
+ context: &AuthorizedDeploymentContext,
1011
+ plan: &DeploymentPlan,
1012
+ policy: &ScalingPolicy,
1013
+ ) -> Result<(), String> {
1014
+ match self.0 {
1015
+ DeploymentProviderKind::Docker if policy.max_instances > 1 => {
1016
+ Err("Docker reference provider supports one instance per deployment".into())
1017
+ }
1018
+ DeploymentProviderKind::Docker => Ok(()),
1019
+ DeploymentProviderKind::Fly => {
1020
+ execute_command(
1021
+ context,
1022
+ "flyctl",
1023
+ &[
1024
+ "scale".into(),
1025
+ "count".into(),
1026
+ policy.min_instances.to_string(),
1027
+ "--app".into(),
1028
+ configured(plan, "fly.app")?.into(),
1029
+ "--yes".into(),
1030
+ ],
1031
+ )?;
1032
+ Ok(())
1033
+ }
1034
+ DeploymentProviderKind::Render => {
1035
+ render_request(
1036
+ context,
1037
+ plan,
1038
+ "PATCH",
1039
+ "",
1040
+ Some(serde_json::json!({"numInstances":policy.min_instances})),
1041
+ )?;
1042
+ Ok(())
1043
+ }
1044
+ DeploymentProviderKind::Vercel
1045
+ if policy.min_instances == 1 && policy.max_instances == 1 =>
1046
+ {
1047
+ Ok(())
1048
+ }
1049
+ DeploymentProviderKind::Vercel => Err("Vercel scaling is platform-managed".into()),
1050
+ DeploymentProviderKind::Local => Ok(()),
1051
+ }
1052
+ }
1053
+ fn stop(
1054
+ &self,
1055
+ context: &AuthorizedDeploymentContext,
1056
+ plan: &DeploymentPlan,
1057
+ ) -> Result<(), String> {
1058
+ match self.0 {
1059
+ DeploymentProviderKind::Docker => {
1060
+ execute_command(
1061
+ context,
1062
+ "docker",
1063
+ &[
1064
+ "stop".into(),
1065
+ plan.configuration
1066
+ .get("docker.name")
1067
+ .cloned()
1068
+ .unwrap_or_else(|| context.deployment_id.replace(['/', ':'], "-")),
1069
+ ],
1070
+ )?;
1071
+ }
1072
+ DeploymentProviderKind::Fly => {
1073
+ execute_command(
1074
+ context,
1075
+ "flyctl",
1076
+ &[
1077
+ "scale".into(),
1078
+ "count".into(),
1079
+ "0".into(),
1080
+ "--app".into(),
1081
+ configured(plan, "fly.app")?.into(),
1082
+ "--yes".into(),
1083
+ ],
1084
+ )?;
1085
+ }
1086
+ DeploymentProviderKind::Render => {
1087
+ render_request(context, plan, "POST", "suspend", None)?;
1088
+ }
1089
+ DeploymentProviderKind::Vercel => {
1090
+ let deployment = fs::read_to_string(state_file(context, "vercel-deployment"))
1091
+ .map_err(|_| "Vercel deployment identifier unavailable")?;
1092
+ execute_command(
1093
+ context,
1094
+ "vercel",
1095
+ &["remove".into(), deployment.trim().into(), "--yes".into()],
1096
+ )?;
1097
+ }
1098
+ DeploymentProviderKind::Local => {}
1099
+ }
1100
+ Ok(())
1101
+ }
1102
+ fn restart(
1103
+ &self,
1104
+ context: &AuthorizedDeploymentContext,
1105
+ plan: &DeploymentPlan,
1106
+ ) -> Result<(), String> {
1107
+ match self.0 {
1108
+ DeploymentProviderKind::Docker => {
1109
+ execute_command(
1110
+ context,
1111
+ "docker",
1112
+ &[
1113
+ "restart".into(),
1114
+ plan.configuration
1115
+ .get("docker.name")
1116
+ .cloned()
1117
+ .unwrap_or_else(|| context.deployment_id.replace(['/', ':'], "-")),
1118
+ ],
1119
+ )?;
1120
+ }
1121
+ DeploymentProviderKind::Fly => {
1122
+ execute_command(
1123
+ context,
1124
+ "flyctl",
1125
+ &[
1126
+ "apps".into(),
1127
+ "restart".into(),
1128
+ configured(plan, "fly.app")?.into(),
1129
+ ],
1130
+ )?;
1131
+ }
1132
+ DeploymentProviderKind::Render => {
1133
+ render_request(context, plan, "POST", "restart", None)?;
1134
+ }
1135
+ DeploymentProviderKind::Vercel => {
1136
+ let deployment = fs::read_to_string(state_file(context, "vercel-deployment"))
1137
+ .map_err(|_| "Vercel deployment identifier unavailable")?;
1138
+ let next = execute_command(
1139
+ context,
1140
+ "vercel",
1141
+ &[
1142
+ "redeploy".into(),
1143
+ deployment.trim().into(),
1144
+ "--target".into(),
1145
+ "production".into(),
1146
+ ],
1147
+ )?;
1148
+ fs::write(state_file(context, "vercel-deployment"), next)
1149
+ .map_err(|error| error.to_string())?;
1150
+ }
1151
+ DeploymentProviderKind::Local => {}
1152
+ }
1153
+ Ok(())
1154
+ }
1155
+ fn rollback(
1156
+ &self,
1157
+ context: &AuthorizedDeploymentContext,
1158
+ plan: &DeploymentPlan,
1159
+ release: &ReleaseManifest,
1160
+ ) -> Result<(), String> {
1161
+ if self.0 == DeploymentProviderKind::Render {
1162
+ let deploy_id = release
1163
+ .runtime
1164
+ .get("render.deploy_id")
1165
+ .ok_or("rollback release lacks render.deploy_id")?;
1166
+ render_request(
1167
+ context,
1168
+ plan,
1169
+ "POST",
1170
+ "rollback",
1171
+ Some(serde_json::json!({"deployId":deploy_id})),
1172
+ )?;
1173
+ Ok(())
1174
+ } else {
1175
+ self.deploy(context, plan, release)
1176
+ }
1177
+ }
1178
+ fn destroy(
1179
+ &self,
1180
+ context: &AuthorizedDeploymentContext,
1181
+ plan: &DeploymentPlan,
1182
+ ) -> Result<(), String> {
1183
+ match self.0 {
1184
+ DeploymentProviderKind::Docker => {
1185
+ let _ = execute_command(
1186
+ context,
1187
+ "docker",
1188
+ &[
1189
+ "rm".into(),
1190
+ "-f".into(),
1191
+ plan.configuration
1192
+ .get("docker.name")
1193
+ .cloned()
1194
+ .unwrap_or_else(|| context.deployment_id.replace(['/', ':'], "-")),
1195
+ ],
1196
+ );
1197
+ }
1198
+ DeploymentProviderKind::Fly => {
1199
+ execute_command(
1200
+ context,
1201
+ "flyctl",
1202
+ &[
1203
+ "apps".into(),
1204
+ "destroy".into(),
1205
+ configured(plan, "fly.app")?.into(),
1206
+ "--yes".into(),
1207
+ ],
1208
+ )?;
1209
+ }
1210
+ DeploymentProviderKind::Render => {
1211
+ render_request(context, plan, "DELETE", "", None)?;
1212
+ }
1213
+ DeploymentProviderKind::Vercel => {
1214
+ let deployment = fs::read_to_string(state_file(context, "vercel-deployment"))
1215
+ .map_err(|_| "Vercel deployment identifier unavailable")?;
1216
+ execute_command(
1217
+ context,
1218
+ "vercel",
1219
+ &["remove".into(), deployment.trim().into(), "--yes".into()],
1220
+ )?;
1221
+ let _ = fs::remove_file(state_file(context, "vercel-deployment"));
1222
+ }
1223
+ DeploymentProviderKind::Local => {}
1224
+ }
1225
+ Ok(())
1226
+ }
1227
+ }
1228
+
1229
+ fn render_request(
1230
+ context: &AuthorizedDeploymentContext,
1231
+ plan: &DeploymentPlan,
1232
+ method: &str,
1233
+ suffix: &str,
1234
+ body: Option<serde_json::Value>,
1235
+ ) -> Result<serde_json::Value, String> {
1236
+ let service = plan
1237
+ .configuration
1238
+ .get("render.service_id")
1239
+ .cloned()
1240
+ .or_else(|| fs::read_to_string(state_file(context, "render-service")).ok())
1241
+ .ok_or("Render service id unavailable")?;
1242
+ let base = plan
1243
+ .configuration
1244
+ .get("render.api_base")
1245
+ .map(String::as_str)
1246
+ .unwrap_or("https://api.render.com/v1");
1247
+ let url = format!(
1248
+ "{base}/services/{service}{}",
1249
+ if suffix.is_empty() {
1250
+ String::new()
1251
+ } else {
1252
+ format!("/{suffix}")
1253
+ }
1254
+ );
1255
+ render_http(context, method, &url, body)
1256
+ }
1257
+ fn render_http(
1258
+ context: &AuthorizedDeploymentContext,
1259
+ method: &str,
1260
+ url: &str,
1261
+ body: Option<serde_json::Value>,
1262
+ ) -> Result<serde_json::Value, String> {
1263
+ let token = context
1264
+ .credentials
1265
+ .get("RENDER_API_KEY")
1266
+ .ok_or("RENDER_API_KEY credential unavailable")?;
1267
+ let client = reqwest::blocking::Client::builder()
1268
+ .timeout(Duration::from_secs(30))
1269
+ .build()
1270
+ .map_err(|error| error.to_string())?;
1271
+ let mut request = match method {
1272
+ "POST" => client.post(url),
1273
+ "PATCH" => client.patch(url),
1274
+ "DELETE" => client.delete(url),
1275
+ _ => client.get(url),
1276
+ }
1277
+ .bearer_auth(token)
1278
+ .header("accept", "application/json");
1279
+ if let Some(body) = body {
1280
+ request = request.json(&body);
1281
+ }
1282
+ let response = request
1283
+ .send()
1284
+ .map_err(|error| format!("Render API request failed: {error}"))?;
1285
+ let status = response.status();
1286
+ let bytes = response.bytes().map_err(|error| error.to_string())?;
1287
+ if !status.is_success() {
1288
+ return Err(format!(
1289
+ "Render API returned {status}: {}",
1290
+ String::from_utf8_lossy(&bytes)
1291
+ ));
1292
+ }
1293
+ if bytes.is_empty() {
1294
+ return Ok(serde_json::json!({"status":status.as_u16()}));
1295
+ }
1296
+ serde_json::from_slice(&bytes).map_err(|error| error.to_string())
1297
+ }
1298
+
1299
+ macro_rules! declarative_provider {
1300
+ ($name:ident, $kind:expr) => {
1301
+ pub struct $name;
1302
+ impl DeploymentProvider for $name {
1303
+ fn kind(&self) -> DeploymentProviderKind {
1304
+ $kind
1305
+ }
1306
+ fn provision(
1307
+ &self,
1308
+ context: &AuthorizedDeploymentContext,
1309
+ plan: &DeploymentPlan,
1310
+ ) -> Result<(), String> {
1311
+ DeclarativeDeploymentProvider($kind).provision(context, plan)
1312
+ }
1313
+ fn configure(
1314
+ &self,
1315
+ context: &AuthorizedDeploymentContext,
1316
+ plan: &DeploymentPlan,
1317
+ ) -> Result<(), String> {
1318
+ DeclarativeDeploymentProvider($kind).configure(context, plan)
1319
+ }
1320
+ fn deploy(
1321
+ &self,
1322
+ context: &AuthorizedDeploymentContext,
1323
+ plan: &DeploymentPlan,
1324
+ release: &ReleaseManifest,
1325
+ ) -> Result<(), String> {
1326
+ DeclarativeDeploymentProvider($kind).deploy(context, plan, release)
1327
+ }
1328
+ fn status(
1329
+ &self,
1330
+ context: &AuthorizedDeploymentContext,
1331
+ plan: &DeploymentPlan,
1332
+ ) -> Result<ProviderObservation, String> {
1333
+ DeclarativeDeploymentProvider($kind).status(context, plan)
1334
+ }
1335
+ fn health(
1336
+ &self,
1337
+ context: &AuthorizedDeploymentContext,
1338
+ plan: &DeploymentPlan,
1339
+ ) -> Result<DeploymentHealth, String> {
1340
+ DeclarativeDeploymentProvider($kind).health(context, plan)
1341
+ }
1342
+ fn scale(
1343
+ &self,
1344
+ context: &AuthorizedDeploymentContext,
1345
+ plan: &DeploymentPlan,
1346
+ policy: &ScalingPolicy,
1347
+ ) -> Result<(), String> {
1348
+ DeclarativeDeploymentProvider($kind).scale(context, plan, policy)
1349
+ }
1350
+ fn stop(
1351
+ &self,
1352
+ context: &AuthorizedDeploymentContext,
1353
+ plan: &DeploymentPlan,
1354
+ ) -> Result<(), String> {
1355
+ DeclarativeDeploymentProvider($kind).stop(context, plan)
1356
+ }
1357
+ fn restart(
1358
+ &self,
1359
+ context: &AuthorizedDeploymentContext,
1360
+ plan: &DeploymentPlan,
1361
+ ) -> Result<(), String> {
1362
+ DeclarativeDeploymentProvider($kind).restart(context, plan)
1363
+ }
1364
+ fn rollback(
1365
+ &self,
1366
+ context: &AuthorizedDeploymentContext,
1367
+ plan: &DeploymentPlan,
1368
+ release: &ReleaseManifest,
1369
+ ) -> Result<(), String> {
1370
+ DeclarativeDeploymentProvider($kind).rollback(context, plan, release)
1371
+ }
1372
+ fn destroy(
1373
+ &self,
1374
+ context: &AuthorizedDeploymentContext,
1375
+ plan: &DeploymentPlan,
1376
+ ) -> Result<(), String> {
1377
+ DeclarativeDeploymentProvider($kind).destroy(context, plan)
1378
+ }
1379
+ }
1380
+ };
1381
+ }
1382
+ declarative_provider!(DockerDeploymentProvider, DeploymentProviderKind::Docker);
1383
+ declarative_provider!(FlyDeploymentProvider, DeploymentProviderKind::Fly);
1384
+ declarative_provider!(RenderDeploymentProvider, DeploymentProviderKind::Render);
1385
+ declarative_provider!(VercelDeploymentProvider, DeploymentProviderKind::Vercel);
1386
+
1387
+ pub fn provider_for(kind: DeploymentProviderKind) -> Box<dyn DeploymentProvider> {
1388
+ match kind {
1389
+ DeploymentProviderKind::Local => Box::new(LocalDeploymentProvider),
1390
+ DeploymentProviderKind::Docker => Box::new(DockerDeploymentProvider),
1391
+ DeploymentProviderKind::Fly => Box::new(FlyDeploymentProvider),
1392
+ DeploymentProviderKind::Render => Box::new(RenderDeploymentProvider),
1393
+ DeploymentProviderKind::Vercel => Box::new(VercelDeploymentProvider),
1394
+ }
1395
+ }
1396
+ #[derive(Debug, Default, Serialize, Deserialize)]
1397
+ struct Records {
1398
+ releases: BTreeMap<String, Release>,
1399
+ deployments: BTreeMap<String, Deployment>,
1400
+ active: BTreeMap<String, String>,
1401
+ #[serde(default)]
1402
+ operation_audit: Vec<DeploymentOperationAudit>,
1403
+ }
1404
+ #[derive(Clone)]
1405
+ pub struct ReleaseStore {
1406
+ path: PathBuf,
1407
+ records: Arc<RwLock<Records>>,
1408
+ }
1409
+ fn now() -> u64 {
1410
+ SystemTime::now()
1411
+ .duration_since(UNIX_EPOCH)
1412
+ .unwrap_or_default()
1413
+ .as_secs()
1414
+ }
1415
+ fn unique_timestamp() -> u128 {
1416
+ SystemTime::now()
1417
+ .duration_since(UNIX_EPOCH)
1418
+ .unwrap_or_default()
1419
+ .as_nanos()
1420
+ }
1421
+ fn digest<T: Serialize>(value: &T) -> Result<String, String> {
1422
+ let bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?;
1423
+ Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
1424
+ }
1425
+ fn manifest_digest(manifest: &ReleaseManifest) -> Result<String, String> {
1426
+ let mut value = manifest.clone();
1427
+ value.digest.clear();
1428
+ value.release_attestation = None;
1429
+ digest(&value)
1430
+ }
1431
+ impl ReleaseStore {
1432
+ pub fn load(path: impl Into<PathBuf>) -> Result<Self, String> {
1433
+ let path = path.into();
1434
+ let records = if path.exists() {
1435
+ serde_json::from_slice(&fs::read(&path).map_err(|e| e.to_string())?)
1436
+ .map_err(|e| e.to_string())?
1437
+ } else {
1438
+ Records::default()
1439
+ };
1440
+ Ok(Self {
1441
+ path,
1442
+ records: Arc::new(RwLock::new(records)),
1443
+ })
1444
+ }
1445
+ fn persist(&self, r: &Records) -> Result<(), String> {
1446
+ if let Some(p) = self.path.parent() {
1447
+ fs::create_dir_all(p).map_err(|e| e.to_string())?
1448
+ }
1449
+ let tmp = self.path.with_extension("tmp");
1450
+ fs::write(
1451
+ &tmp,
1452
+ serde_json::to_vec_pretty(r).map_err(|e| e.to_string())?,
1453
+ )
1454
+ .map_err(|e| e.to_string())?;
1455
+ fs::rename(tmp, &self.path).map_err(|e| e.to_string())
1456
+ }
1457
+ pub fn create(&self, mut manifest: ReleaseManifest, actor: &str) -> Result<Release, String> {
1458
+ if !manifest
1459
+ .ci_attestation
1460
+ .status
1461
+ .eq_ignore_ascii_case("success")
1462
+ || manifest.ci_attestation.artifacts.len() < manifest.artifacts.len()
1463
+ {
1464
+ return Err("release requires successful CI attestation for every artifact".into());
1465
+ }
1466
+ for artifact in &manifest.artifacts {
1467
+ if manifest.ci_attestation.artifacts.get(&artifact.name) != Some(&artifact.digest) {
1468
+ return Err(format!("artifact digest mismatch: {}", artifact.name));
1469
+ }
1470
+ }
1471
+ manifest.release_id = if manifest.release_id.is_empty() {
1472
+ format!(
1473
+ "release-{}-{}",
1474
+ manifest.application_id,
1475
+ digest(&manifest)?.trim_start_matches("sha256:")
1476
+ )
1477
+ } else {
1478
+ manifest.release_id
1479
+ };
1480
+ manifest.digest = manifest_digest(&manifest)?;
1481
+ let release = Release {
1482
+ manifest: manifest.clone(),
1483
+ status: ReleaseStatus::Candidate,
1484
+ created_by: actor.into(),
1485
+ };
1486
+ let mut r = self
1487
+ .records
1488
+ .write()
1489
+ .map_err(|_| "release store lock poisoned")?;
1490
+ if r.releases.contains_key(&release.manifest.release_id) {
1491
+ return Err("release already exists".into());
1492
+ }
1493
+ r.releases
1494
+ .insert(release.manifest.release_id.clone(), release.clone());
1495
+ self.persist(&r)?;
1496
+ Ok(release)
1497
+ }
1498
+ pub fn get(&self, id: &str) -> Option<Release> {
1499
+ self.records.read().ok()?.releases.get(id).cloned()
1500
+ }
1501
+ pub fn list(&self) -> Vec<Release> {
1502
+ self.records
1503
+ .read()
1504
+ .map(|r| r.releases.values().cloned().collect())
1505
+ .unwrap_or_default()
1506
+ }
1507
+ pub fn verify(&self, id: &str) -> Result<Release, String> {
1508
+ let mut r = self
1509
+ .records
1510
+ .write()
1511
+ .map_err(|_| "release store lock poisoned")?;
1512
+ let release = r.releases.get_mut(id).ok_or("release not found")?;
1513
+ if release.manifest.digest != manifest_digest(&release.manifest)? {
1514
+ return Err("release manifest digest mismatch".into());
1515
+ }
1516
+ if !release
1517
+ .manifest
1518
+ .ci_attestation
1519
+ .status
1520
+ .eq_ignore_ascii_case("success")
1521
+ {
1522
+ return Err("CI attestation is not successful".into());
1523
+ }
1524
+ release.status = ReleaseStatus::Verified;
1525
+ release.manifest.release_attestation = Some(manifest_digest(&release.manifest)?);
1526
+ let out = release.clone();
1527
+ self.persist(&r)?;
1528
+ Ok(out)
1529
+ }
1530
+ pub fn promote(&self, id: &str, app: &str, environment: &str) -> Result<Release, String> {
1531
+ let mut r = self
1532
+ .records
1533
+ .write()
1534
+ .map_err(|_| "release store lock poisoned")?;
1535
+ let release = r.releases.get_mut(id).ok_or("release not found")?;
1536
+ if !matches!(
1537
+ release.status,
1538
+ ReleaseStatus::Published | ReleaseStatus::Promoted
1539
+ ) {
1540
+ return Err("release must be published before promotion".into());
1541
+ }
1542
+ release.status = ReleaseStatus::Promoted;
1543
+ let out = release.clone();
1544
+ let _ = release;
1545
+ r.active.insert(format!("{app}\0{environment}"), id.into());
1546
+ self.persist(&r)?;
1547
+ Ok(out)
1548
+ }
1549
+ pub fn publish(&self, id: &str) -> Result<Release, String> {
1550
+ let mut records = self
1551
+ .records
1552
+ .write()
1553
+ .map_err(|_| "release store lock poisoned")?;
1554
+ let release = records.releases.get_mut(id).ok_or("release not found")?;
1555
+ if release.status != ReleaseStatus::Verified {
1556
+ return Err("release must be verified before publication".into());
1557
+ }
1558
+ release.status = ReleaseStatus::Published;
1559
+ let output = release.clone();
1560
+ self.persist(&records)?;
1561
+ Ok(output)
1562
+ }
1563
+ pub fn active(&self, app: &str, environment: &str) -> Option<Release> {
1564
+ let r = self.records.read().ok()?;
1565
+ r.active
1566
+ .get(&format!("{app}\0{environment}"))
1567
+ .and_then(|id| r.releases.get(id))
1568
+ .cloned()
1569
+ }
1570
+ pub fn plan(
1571
+ &self,
1572
+ release_id: &str,
1573
+ environment: &str,
1574
+ rollback_target: Option<String>,
1575
+ ) -> Result<DeploymentPlan, String> {
1576
+ self.plan_for(
1577
+ release_id,
1578
+ environment,
1579
+ DeploymentProviderKind::Local,
1580
+ DeploymentProfile::AllInOne,
1581
+ rollback_target,
1582
+ )
1583
+ }
1584
+ pub fn plan_for(
1585
+ &self,
1586
+ release_id: &str,
1587
+ environment: &str,
1588
+ provider: DeploymentProviderKind,
1589
+ profile: DeploymentProfile,
1590
+ rollback_target: Option<String>,
1591
+ ) -> Result<DeploymentPlan, String> {
1592
+ let release = self.get(release_id).ok_or("release not found")?;
1593
+ if !matches!(
1594
+ release.status,
1595
+ ReleaseStatus::Published | ReleaseStatus::Promoted
1596
+ ) {
1597
+ return Err("release is not published".into());
1598
+ }
1599
+ let environment_value = match environment.to_ascii_lowercase().as_str() {
1600
+ "local" => DeploymentEnvironment::Local,
1601
+ "preview" => DeploymentEnvironment::Preview,
1602
+ "staging" => DeploymentEnvironment::Staging,
1603
+ "production" => DeploymentEnvironment::Production,
1604
+ _ => return Err("environment must be local, preview, staging, or production".into()),
1605
+ };
1606
+ let rollback_target = rollback_target.or_else(|| {
1607
+ self.active(&release.manifest.application_id, environment)
1608
+ .map(|value| value.manifest.release_id)
1609
+ .filter(|value| value != release_id)
1610
+ });
1611
+ let services = match profile {
1612
+ DeploymentProfile::AllInOne
1613
+ | DeploymentProfile::Split
1614
+ | DeploymentProfile::SelfHost => vec![
1615
+ DeploymentService::Studio,
1616
+ DeploymentService::ControlPlane,
1617
+ DeploymentService::Worker,
1618
+ DeploymentService::ApplicationRuntime,
1619
+ DeploymentService::ArtifactStore,
1620
+ ],
1621
+ DeploymentProfile::ControlPlaneOnly => vec![
1622
+ DeploymentService::ControlPlane,
1623
+ DeploymentService::ArtifactStore,
1624
+ ],
1625
+ DeploymentProfile::WorkerExpansion => vec![DeploymentService::Worker],
1626
+ DeploymentProfile::StudioOnly => vec![DeploymentService::Studio],
1627
+ DeploymentProfile::ApplicationOnly => vec![DeploymentService::ApplicationRuntime],
1628
+ };
1629
+ let plan = DeploymentPlan {
1630
+ application_id: release.manifest.application_id,
1631
+ release_id: release_id.into(),
1632
+ revision_id: release.manifest.revision_id,
1633
+ environment: environment_value,
1634
+ workers: vec![],
1635
+ connections: vec![],
1636
+ required_secrets: vec![],
1637
+ artifacts: release
1638
+ .manifest
1639
+ .artifacts
1640
+ .iter()
1641
+ .map(|a| a.name.clone())
1642
+ .collect(),
1643
+ rollback_target,
1644
+ health_requirements: DeploymentHealth::required(),
1645
+ provider,
1646
+ profile,
1647
+ services,
1648
+ domains: vec![],
1649
+ configuration: BTreeMap::new(),
1650
+ secret_references: vec![],
1651
+ scaling_policy: ScalingPolicy::default(),
1652
+ release_policy: ReleasePolicy::default(),
1653
+ dependencies: BTreeMap::from([
1654
+ (
1655
+ DeploymentService::Studio,
1656
+ vec![DeploymentService::ControlPlane],
1657
+ ),
1658
+ (
1659
+ DeploymentService::Worker,
1660
+ vec![DeploymentService::ControlPlane],
1661
+ ),
1662
+ (
1663
+ DeploymentService::ApplicationRuntime,
1664
+ vec![DeploymentService::ControlPlane],
1665
+ ),
1666
+ (
1667
+ DeploymentService::ControlPlane,
1668
+ vec![DeploymentService::ArtifactStore],
1669
+ ),
1670
+ ]),
1671
+ };
1672
+ Ok(plan)
1673
+ }
1674
+ pub fn certify_deployment(&self, plan: &DeploymentPlan) -> DeploymentCertification {
1675
+ let mut diagnostics = Vec::new();
1676
+ let release_verified = self
1677
+ .get(&plan.release_id)
1678
+ .map(|release| {
1679
+ matches!(
1680
+ release.status,
1681
+ ReleaseStatus::Published | ReleaseStatus::Promoted
1682
+ )
1683
+ })
1684
+ .unwrap_or(false);
1685
+ if !release_verified {
1686
+ diagnostics.push("release is not published".into());
1687
+ }
1688
+ let provider_valid = match provider_for(plan.provider).validate(plan) {
1689
+ Ok(()) => true,
1690
+ Err(error) => {
1691
+ diagnostics.push(error);
1692
+ false
1693
+ }
1694
+ };
1695
+ let secrets_are_references = plan.required_secrets.iter().all(|name| {
1696
+ plan.secret_references.iter().any(|reference| {
1697
+ &reference.name == name && !reference.provider_reference.is_empty()
1698
+ })
1699
+ });
1700
+ if !secrets_are_references {
1701
+ diagnostics.push("every required secret must use a provider reference".into());
1702
+ }
1703
+ let health_contract_present = plan.health_requirements.healthy();
1704
+ if !health_contract_present {
1705
+ diagnostics.push("deployment health requirements are incomplete".into());
1706
+ }
1707
+ DeploymentCertification {
1708
+ valid: release_verified
1709
+ && provider_valid
1710
+ && secrets_are_references
1711
+ && health_contract_present,
1712
+ release_verified,
1713
+ provider_valid,
1714
+ secrets_are_references,
1715
+ health_contract_present,
1716
+ diagnostics,
1717
+ }
1718
+ }
1719
+ pub fn deploy(
1720
+ &self,
1721
+ plan: DeploymentPlan,
1722
+ provider: &dyn DeploymentProvider,
1723
+ ) -> Result<Deployment, String> {
1724
+ let deployment_id = format!(
1725
+ "deployment://{}-{}",
1726
+ plan.application_id,
1727
+ unique_timestamp()
1728
+ );
1729
+ let context = AuthorizedDeploymentContext::system(&plan, &deployment_id);
1730
+ self.deploy_authorized(plan, provider, context)
1731
+ }
1732
+ pub fn deploy_authorized(
1733
+ &self,
1734
+ plan: DeploymentPlan,
1735
+ provider: &dyn DeploymentProvider,
1736
+ context: AuthorizedDeploymentContext,
1737
+ ) -> Result<Deployment, String> {
1738
+ let release = self.get(&plan.release_id).ok_or("release not found")?;
1739
+ context.authorize(&plan)?;
1740
+ if provider.kind() != plan.provider {
1741
+ return Err("deployment plan provider does not match controller provider".into());
1742
+ }
1743
+ let certification = self.certify_deployment(&plan);
1744
+ if !certification.valid {
1745
+ return Err(format!(
1746
+ "deployment certification failed: {}",
1747
+ certification.diagnostics.join("; ")
1748
+ ));
1749
+ }
1750
+ let deployment = Deployment {
1751
+ deployment_id: context.deployment_id.clone(),
1752
+ plan,
1753
+ state: DeploymentState::Draft,
1754
+ health: DeploymentHealth::default(),
1755
+ created_at: now(),
1756
+ updated_at: now(),
1757
+ observed: ProviderObservation::default(),
1758
+ previous_known_good_release: None,
1759
+ desired_state: DesiredDeploymentState::Active,
1760
+ failure: None,
1761
+ };
1762
+ self.save_deployment(deployment.clone())?;
1763
+ let id = deployment.deployment_id.clone();
1764
+ let result = (|| {
1765
+ self.transition(&id, DeploymentState::Validated)?;
1766
+ self.transition(&id, DeploymentState::Published)?;
1767
+ self.transition(&id, DeploymentState::Provisioning)?;
1768
+ self.provider_operation(&context, &deployment.plan, "provision", || {
1769
+ provider.provision(&context, &deployment.plan)
1770
+ })?;
1771
+ self.provider_operation(&context, &deployment.plan, "configure", || {
1772
+ provider.configure(&context, &deployment.plan)
1773
+ })?;
1774
+ self.transition(&id, DeploymentState::Deploying)?;
1775
+ self.provider_operation(&context, &deployment.plan, "deploy", || {
1776
+ provider.deploy(&context, &deployment.plan, &release.manifest)
1777
+ })?;
1778
+ self.provider_operation(&context, &deployment.plan, "scale", || {
1779
+ provider.scale(&context, &deployment.plan, &deployment.plan.scaling_policy)
1780
+ })?;
1781
+ self.transition(&id, DeploymentState::HealthChecking)?;
1782
+ let health = self.provider_operation(&context, &deployment.plan, "health", || {
1783
+ provider.health(&context, &deployment.plan)
1784
+ })?;
1785
+ if !health.healthy() {
1786
+ return Err("deployment health contract failed".into());
1787
+ }
1788
+ let observed = self.provider_operation(&context, &deployment.plan, "status", || {
1789
+ provider.status(&context, &deployment.plan)
1790
+ })?;
1791
+ self.observe(&id, health, observed, DeploymentState::Active)
1792
+ })();
1793
+ match result {
1794
+ Ok(value) => Ok(value),
1795
+ Err(error) => {
1796
+ let failed = self.fail(&id, &error)?;
1797
+ if failed.plan.release_policy.automatic_rollback
1798
+ && failed.plan.rollback_target.is_some()
1799
+ {
1800
+ return self.rollback_authorized(&id, provider, context);
1801
+ }
1802
+ Ok(failed)
1803
+ }
1804
+ }
1805
+ }
1806
+ fn provider_operation<T>(
1807
+ &self,
1808
+ context: &AuthorizedDeploymentContext,
1809
+ plan: &DeploymentPlan,
1810
+ operation: &str,
1811
+ execute: impl FnOnce() -> Result<T, String>,
1812
+ ) -> Result<T, String> {
1813
+ let result = execute();
1814
+ let event = DeploymentOperationAudit {
1815
+ provider: plan.provider,
1816
+ deployment_id: context.deployment_id.clone(),
1817
+ secret_references: plan
1818
+ .secret_references
1819
+ .iter()
1820
+ .map(|reference| reference.provider_reference.clone())
1821
+ .collect(),
1822
+ credential_versions: context.credential_versions.clone(),
1823
+ operation: operation.into(),
1824
+ actor: context.actor.clone(),
1825
+ timestamp: now(),
1826
+ result: if result.is_ok() {
1827
+ "success".into()
1828
+ } else {
1829
+ "failed".into()
1830
+ },
1831
+ };
1832
+ if let Ok(mut records) = self.records.write() {
1833
+ records.operation_audit.push(event);
1834
+ let _ = self.persist(&records);
1835
+ }
1836
+ result
1837
+ }
1838
+ pub fn operation_audit(&self, deployment_id: &str) -> Vec<DeploymentOperationAudit> {
1839
+ self.records
1840
+ .read()
1841
+ .map(|records| {
1842
+ records
1843
+ .operation_audit
1844
+ .iter()
1845
+ .filter(|event| event.deployment_id == deployment_id)
1846
+ .cloned()
1847
+ .collect()
1848
+ })
1849
+ .unwrap_or_default()
1850
+ }
1851
+ fn save_deployment(&self, deployment: Deployment) -> Result<(), String> {
1852
+ let mut records = self
1853
+ .records
1854
+ .write()
1855
+ .map_err(|_| "release store lock poisoned")?;
1856
+ records
1857
+ .deployments
1858
+ .insert(deployment.deployment_id.clone(), deployment);
1859
+ self.persist(&records)
1860
+ }
1861
+ fn transition(&self, id: &str, state: DeploymentState) -> Result<Deployment, String> {
1862
+ let mut records = self
1863
+ .records
1864
+ .write()
1865
+ .map_err(|_| "release store lock poisoned")?;
1866
+ let deployment = records
1867
+ .deployments
1868
+ .get_mut(id)
1869
+ .ok_or("deployment not found")?;
1870
+ deployment.state = state;
1871
+ deployment.updated_at = now();
1872
+ let output = deployment.clone();
1873
+ self.persist(&records)?;
1874
+ Ok(output)
1875
+ }
1876
+ fn observe(
1877
+ &self,
1878
+ id: &str,
1879
+ health: DeploymentHealth,
1880
+ observed: ProviderObservation,
1881
+ state: DeploymentState,
1882
+ ) -> Result<Deployment, String> {
1883
+ let mut records = self
1884
+ .records
1885
+ .write()
1886
+ .map_err(|_| "release store lock poisoned")?;
1887
+ let deployment = records
1888
+ .deployments
1889
+ .get_mut(id)
1890
+ .ok_or("deployment not found")?;
1891
+ deployment.health = health;
1892
+ deployment.observed = observed;
1893
+ deployment.state = state;
1894
+ deployment.updated_at = now();
1895
+ let output = deployment.clone();
1896
+ self.persist(&records)?;
1897
+ Ok(output)
1898
+ }
1899
+ fn fail(&self, id: &str, error: &str) -> Result<Deployment, String> {
1900
+ let mut records = self
1901
+ .records
1902
+ .write()
1903
+ .map_err(|_| "release store lock poisoned")?;
1904
+ let deployment = records
1905
+ .deployments
1906
+ .get_mut(id)
1907
+ .ok_or("deployment not found")?;
1908
+ deployment.state = DeploymentState::Failed;
1909
+ deployment.failure = Some(error.into());
1910
+ deployment.updated_at = now();
1911
+ let output = deployment.clone();
1912
+ self.persist(&records)?;
1913
+ Ok(output)
1914
+ }
1915
+ pub fn deployment(&self, id: &str) -> Option<Deployment> {
1916
+ self.records.read().ok()?.deployments.get(id).cloned()
1917
+ }
1918
+ pub fn deployments(&self) -> Vec<Deployment> {
1919
+ self.records
1920
+ .read()
1921
+ .map(|records| records.deployments.values().cloned().collect())
1922
+ .unwrap_or_default()
1923
+ }
1924
+ pub fn reconcile(
1925
+ &self,
1926
+ id: &str,
1927
+ provider: &dyn DeploymentProvider,
1928
+ ) -> Result<Deployment, String> {
1929
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
1930
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
1931
+ self.reconcile_authorized(id, provider, context)
1932
+ }
1933
+ pub fn reconcile_authorized(
1934
+ &self,
1935
+ id: &str,
1936
+ provider: &dyn DeploymentProvider,
1937
+ context: AuthorizedDeploymentContext,
1938
+ ) -> Result<Deployment, String> {
1939
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
1940
+ context.authorize(&deployment.plan)?;
1941
+ if provider.kind() != deployment.plan.provider {
1942
+ return Err("deployment provider mismatch".into());
1943
+ }
1944
+ match deployment.desired_state {
1945
+ DesiredDeploymentState::Stopped => self.stop_authorized(id, provider, context),
1946
+ DesiredDeploymentState::Destroyed => {
1947
+ self.provider_operation(&context, &deployment.plan, "destroy", || {
1948
+ provider.destroy(&context, &deployment.plan)
1949
+ })?;
1950
+ self.transition(id, DeploymentState::Destroyed)
1951
+ }
1952
+ DesiredDeploymentState::Active => {
1953
+ if deployment.state == DeploymentState::Stopped {
1954
+ self.provider_operation(&context, &deployment.plan, "restart", || {
1955
+ provider.restart(&context, &deployment.plan)
1956
+ })?;
1957
+ }
1958
+ let health = self.provider_operation(&context, &deployment.plan, "health", || {
1959
+ provider.health(&context, &deployment.plan)
1960
+ });
1961
+ let observed =
1962
+ self.provider_operation(&context, &deployment.plan, "status", || {
1963
+ provider.status(&context, &deployment.plan)
1964
+ });
1965
+ if health.is_err() || observed.is_err() {
1966
+ self.transition(id, DeploymentState::Degraded)?;
1967
+ self.provider_operation(&context, &deployment.plan, "recreate", || {
1968
+ provider.restart(&context, &deployment.plan)
1969
+ })?;
1970
+ }
1971
+ let health = provider.health(&context, &deployment.plan)?;
1972
+ let observed = provider.status(&context, &deployment.plan)?;
1973
+ let state = if health.healthy() {
1974
+ DeploymentState::Active
1975
+ } else {
1976
+ DeploymentState::Failed
1977
+ };
1978
+ self.observe(id, health, observed, state)
1979
+ }
1980
+ }
1981
+ }
1982
+ pub fn restart(
1983
+ &self,
1984
+ id: &str,
1985
+ provider: &dyn DeploymentProvider,
1986
+ ) -> Result<Deployment, String> {
1987
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
1988
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
1989
+ self.restart_authorized(id, provider, context)
1990
+ }
1991
+ pub fn restart_authorized(
1992
+ &self,
1993
+ id: &str,
1994
+ provider: &dyn DeploymentProvider,
1995
+ context: AuthorizedDeploymentContext,
1996
+ ) -> Result<Deployment, String> {
1997
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
1998
+ self.provider_operation(&context, &deployment.plan, "restart", || {
1999
+ provider.restart(&context, &deployment.plan)
2000
+ })?;
2001
+ self.reconcile_authorized(id, provider, context)
2002
+ }
2003
+ pub fn scale(
2004
+ &self,
2005
+ id: &str,
2006
+ policy: ScalingPolicy,
2007
+ provider: &dyn DeploymentProvider,
2008
+ ) -> Result<Deployment, String> {
2009
+ if policy.min_instances > policy.max_instances {
2010
+ return Err("minimum instances cannot exceed maximum instances".into());
2011
+ }
2012
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
2013
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
2014
+ self.scale_authorized(id, policy, provider, context)
2015
+ }
2016
+ pub fn scale_authorized(
2017
+ &self,
2018
+ id: &str,
2019
+ policy: ScalingPolicy,
2020
+ provider: &dyn DeploymentProvider,
2021
+ context: AuthorizedDeploymentContext,
2022
+ ) -> Result<Deployment, String> {
2023
+ if policy.min_instances > policy.max_instances {
2024
+ return Err("minimum instances cannot exceed maximum instances".into());
2025
+ }
2026
+ let mut deployment = self.deployment(id).ok_or("deployment not found")?;
2027
+ self.provider_operation(&context, &deployment.plan, "scale", || {
2028
+ provider.scale(&context, &deployment.plan, &policy)
2029
+ })?;
2030
+ deployment.plan.scaling_policy = policy;
2031
+ deployment.updated_at = now();
2032
+ self.save_deployment(deployment.clone())?;
2033
+ Ok(deployment)
2034
+ }
2035
+ pub fn destroy(
2036
+ &self,
2037
+ id: &str,
2038
+ provider: &dyn DeploymentProvider,
2039
+ ) -> Result<Deployment, String> {
2040
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
2041
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
2042
+ self.destroy_authorized(id, provider, context)
2043
+ }
2044
+ pub fn destroy_authorized(
2045
+ &self,
2046
+ id: &str,
2047
+ provider: &dyn DeploymentProvider,
2048
+ context: AuthorizedDeploymentContext,
2049
+ ) -> Result<Deployment, String> {
2050
+ let mut deployment = self.deployment(id).ok_or("deployment not found")?;
2051
+ self.transition(id, DeploymentState::Destroying)?;
2052
+ self.provider_operation(&context, &deployment.plan, "destroy", || {
2053
+ provider.destroy(&context, &deployment.plan)
2054
+ })?;
2055
+ deployment.desired_state = DesiredDeploymentState::Destroyed;
2056
+ deployment.state = DeploymentState::Destroyed;
2057
+ deployment.updated_at = now();
2058
+ self.save_deployment(deployment.clone())?;
2059
+ Ok(deployment)
2060
+ }
2061
+ pub fn stop(&self, id: &str, provider: &dyn DeploymentProvider) -> Result<Deployment, String> {
2062
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
2063
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
2064
+ self.stop_authorized(id, provider, context)
2065
+ }
2066
+ pub fn stop_authorized(
2067
+ &self,
2068
+ id: &str,
2069
+ provider: &dyn DeploymentProvider,
2070
+ context: AuthorizedDeploymentContext,
2071
+ ) -> Result<Deployment, String> {
2072
+ let mut r = self
2073
+ .records
2074
+ .write()
2075
+ .map_err(|_| "release store lock poisoned")?;
2076
+ let d = r.deployments.get_mut(id).ok_or("deployment not found")?;
2077
+ d.state = DeploymentState::Stopping;
2078
+ let plan = d.plan.clone();
2079
+ let _ = d;
2080
+ self.persist(&r)?;
2081
+ drop(r);
2082
+ self.provider_operation(&context, &plan, "stop", || provider.stop(&context, &plan))?;
2083
+ let mut r = self
2084
+ .records
2085
+ .write()
2086
+ .map_err(|_| "release store lock poisoned")?;
2087
+ let d = r.deployments.get_mut(id).ok_or("deployment not found")?;
2088
+ d.desired_state = DesiredDeploymentState::Stopped;
2089
+ d.state = DeploymentState::Stopped;
2090
+ d.updated_at = now();
2091
+ let out = d.clone();
2092
+ self.persist(&r)?;
2093
+ Ok(out)
2094
+ }
2095
+ pub fn rollback(
2096
+ &self,
2097
+ id: &str,
2098
+ provider: &dyn DeploymentProvider,
2099
+ ) -> Result<Deployment, String> {
2100
+ let deployment = self.deployment(id).ok_or("deployment not found")?;
2101
+ let context = AuthorizedDeploymentContext::system(&deployment.plan, id);
2102
+ self.rollback_authorized(id, provider, context)
2103
+ }
2104
+ pub fn rollback_authorized(
2105
+ &self,
2106
+ id: &str,
2107
+ provider: &dyn DeploymentProvider,
2108
+ mut context: AuthorizedDeploymentContext,
2109
+ ) -> Result<Deployment, String> {
2110
+ let mut r = self
2111
+ .records
2112
+ .write()
2113
+ .map_err(|_| "release store lock poisoned")?;
2114
+ let target = {
2115
+ let d = r.deployments.get(id).ok_or("deployment not found")?;
2116
+ d.plan
2117
+ .rollback_target
2118
+ .clone()
2119
+ .ok_or("deployment has no rollback target")?
2120
+ };
2121
+ let plan = {
2122
+ let d = r.deployments.get(id).ok_or("deployment not found")?;
2123
+ d.plan.clone()
2124
+ };
2125
+ let release = r
2126
+ .releases
2127
+ .get(&target)
2128
+ .ok_or("rollback release not found")?
2129
+ .clone();
2130
+ let d = r.deployments.get_mut(id).ok_or("deployment not found")?;
2131
+ d.state = DeploymentState::RollingBack;
2132
+ let _ = d;
2133
+ self.persist(&r)?;
2134
+ drop(r);
2135
+ context.release_id = plan.release_id.clone();
2136
+ context.revision_id = plan.revision_id.clone();
2137
+ self.provider_operation(&context, &plan, "rollback", || {
2138
+ provider.rollback(&context, &plan, &release.manifest)
2139
+ })?;
2140
+ let health = self.provider_operation(&context, &plan, "rollback_health", || {
2141
+ provider.health(&context, &plan)
2142
+ })?;
2143
+ if !health.healthy() {
2144
+ return Err("rollback health contract failed".into());
2145
+ }
2146
+ let observed = self.provider_operation(&context, &plan, "rollback_status", || {
2147
+ provider.status(&context, &plan)
2148
+ })?;
2149
+ let mut r = self
2150
+ .records
2151
+ .write()
2152
+ .map_err(|_| "release store lock poisoned")?;
2153
+ let d = r.deployments.get_mut(id).ok_or("deployment not found")?;
2154
+ d.state = DeploymentState::Active;
2155
+ d.health = health;
2156
+ d.observed = observed;
2157
+ d.previous_known_good_release = Some(d.plan.release_id.clone());
2158
+ d.plan.release_id = target.clone();
2159
+ d.plan.revision_id = release.manifest.revision_id;
2160
+ d.updated_at = now();
2161
+ let out = d.clone();
2162
+ let active_key = format!(
2163
+ "{}\0{}",
2164
+ out.plan.application_id,
2165
+ out.plan.environment.as_str()
2166
+ );
2167
+ r.active.insert(active_key, target);
2168
+ self.persist(&r)?;
2169
+ Ok(out)
2170
+ }
2171
+ }
2172
+ #[cfg(test)]
2173
+ mod tests {
2174
+ use super::*;
2175
+ fn manifest() -> ReleaseManifest {
2176
+ let mut checks = BTreeMap::new();
2177
+ checks.insert("server".into(), "sha256:abc".into());
2178
+ ReleaseManifest {
2179
+ release_id: String::new(),
2180
+ version: "1.0.0".into(),
2181
+ application_id: "app".into(),
2182
+ revision_id: "rev".into(),
2183
+ bundle_id: "bundle".into(),
2184
+ source_commit: "commit".into(),
2185
+ artifacts: vec![ReleaseArtifact {
2186
+ name: "server".into(),
2187
+ artifact_type: "container".into(),
2188
+ digest: "sha256:abc".into(),
2189
+ size: 1,
2190
+ }],
2191
+ packages: vec![],
2192
+ runtime: BTreeMap::new(),
2193
+ environment: BTreeMap::new(),
2194
+ parent_release: None,
2195
+ ci_attestation: CiAttestation {
2196
+ provider: "local".into(),
2197
+ run_id: "1".into(),
2198
+ commit: "commit".into(),
2199
+ status: "success".into(),
2200
+ checks: BTreeMap::new(),
2201
+ artifacts: checks,
2202
+ generated_at: 1,
2203
+ },
2204
+ release_attestation: None,
2205
+ digest: String::new(),
2206
+ created_at: 1,
2207
+ }
2208
+ }
2209
+ #[test]
2210
+ fn verify_promote_deploy_and_stop() {
2211
+ let p = std::env::temp_dir().join(format!("release-{}", now()));
2212
+ let s = ReleaseStore::load(&p).unwrap();
2213
+ let r = s.create(manifest(), "owner").unwrap();
2214
+ let r = s.verify(&r.manifest.release_id).unwrap();
2215
+ assert_eq!(r.status, ReleaseStatus::Verified);
2216
+ let r = s.publish(&r.manifest.release_id).unwrap();
2217
+ assert_eq!(r.status, ReleaseStatus::Published);
2218
+ let r = s.promote(&r.manifest.release_id, "app", "staging").unwrap();
2219
+ assert_eq!(r.status, ReleaseStatus::Promoted);
2220
+ let mut plan = s.plan(&r.manifest.release_id, "staging", None).unwrap();
2221
+ plan.configuration
2222
+ .insert("local.command".into(), "sleep".into());
2223
+ plan.configuration.insert("local.args".into(), "30".into());
2224
+ plan.configuration
2225
+ .insert("health.command".into(), "true".into());
2226
+ let d = s.deploy(plan, &LocalDeploymentProvider).unwrap();
2227
+ assert_eq!(d.state, DeploymentState::Active);
2228
+ assert_eq!(
2229
+ s.stop(&d.deployment_id, &LocalDeploymentProvider)
2230
+ .unwrap()
2231
+ .state,
2232
+ DeploymentState::Stopped
2233
+ );
2234
+ let _ = fs::remove_file(p);
2235
+ }
2236
+ #[test]
2237
+ fn profiles_are_provider_neutral_and_vercel_is_studio_only() {
2238
+ let p = std::env::temp_dir().join(format!("release-profiles-{}", now()));
2239
+ let s = ReleaseStore::load(&p).unwrap();
2240
+ let release = s.create(manifest(), "owner").unwrap();
2241
+ let release = s.verify(&release.manifest.release_id).unwrap();
2242
+ let release = s.publish(&release.manifest.release_id).unwrap();
2243
+ let mut docker = s
2244
+ .plan_for(
2245
+ &release.manifest.release_id,
2246
+ "production",
2247
+ DeploymentProviderKind::Docker,
2248
+ DeploymentProfile::Split,
2249
+ None,
2250
+ )
2251
+ .unwrap();
2252
+ docker
2253
+ .configuration
2254
+ .insert("image".into(), "feltdb:test".into());
2255
+ docker
2256
+ .configuration
2257
+ .insert("health.url".into(), "https://example.test/ready".into());
2258
+ assert!(s.certify_deployment(&docker).valid);
2259
+ let mut plaintext_shape = docker.clone();
2260
+ plaintext_shape.required_secrets.push("DATABASE_URL".into());
2261
+ assert!(!s.certify_deployment(&plaintext_shape).valid);
2262
+ let mut fly = s
2263
+ .plan_for(
2264
+ &release.manifest.release_id,
2265
+ "production",
2266
+ DeploymentProviderKind::Fly,
2267
+ DeploymentProfile::Split,
2268
+ None,
2269
+ )
2270
+ .unwrap();
2271
+ fly.configuration
2272
+ .insert("image".into(), "feltdb:test".into());
2273
+ fly.configuration
2274
+ .insert("fly.app".into(), "feltdb-test".into());
2275
+ fly.configuration
2276
+ .insert("health.url".into(), "https://example.test/ready".into());
2277
+ assert_eq!(docker.services, fly.services);
2278
+ assert_eq!(docker.profile, fly.profile);
2279
+ let vercel = s
2280
+ .plan_for(
2281
+ &release.manifest.release_id,
2282
+ "production",
2283
+ DeploymentProviderKind::Vercel,
2284
+ DeploymentProfile::AllInOne,
2285
+ None,
2286
+ )
2287
+ .unwrap();
2288
+ assert!(provider_for(DeploymentProviderKind::Vercel)
2289
+ .validate(&vercel)
2290
+ .is_err());
2291
+ let worker = s
2292
+ .plan_for(
2293
+ &release.manifest.release_id,
2294
+ "production",
2295
+ DeploymentProviderKind::Vercel,
2296
+ DeploymentProfile::WorkerExpansion,
2297
+ None,
2298
+ )
2299
+ .unwrap();
2300
+ let error = provider_for(DeploymentProviderKind::Vercel)
2301
+ .validate(&worker)
2302
+ .unwrap_err();
2303
+ assert!(error.contains("Studio service only"));
2304
+ let mut studio = s
2305
+ .plan_for(
2306
+ &release.manifest.release_id,
2307
+ "production",
2308
+ DeploymentProviderKind::Vercel,
2309
+ DeploymentProfile::StudioOnly,
2310
+ None,
2311
+ )
2312
+ .unwrap();
2313
+ studio
2314
+ .configuration
2315
+ .insert("vercel.project".into(), "test".into());
2316
+ studio.configuration.insert(
2317
+ "control_plane.url".into(),
2318
+ "https://api.example.test".into(),
2319
+ );
2320
+ studio
2321
+ .configuration
2322
+ .insert("health.url".into(), "https://example.test/ready".into());
2323
+ assert!(provider_for(DeploymentProviderKind::Vercel)
2324
+ .validate(&studio)
2325
+ .is_ok());
2326
+ let _ = fs::remove_file(p);
2327
+ }
2328
+ #[test]
2329
+ fn deployment_state_survives_restart_and_reconciles() {
2330
+ let p = std::env::temp_dir().join(format!("release-restart-{}", now()));
2331
+ let s = ReleaseStore::load(&p).unwrap();
2332
+ let release = s.create(manifest(), "owner").unwrap();
2333
+ let release = s.verify(&release.manifest.release_id).unwrap();
2334
+ let release = s.publish(&release.manifest.release_id).unwrap();
2335
+ let mut plan = s
2336
+ .plan(&release.manifest.release_id, "staging", None)
2337
+ .unwrap();
2338
+ plan.configuration
2339
+ .insert("local.command".into(), "sleep".into());
2340
+ plan.configuration.insert("local.args".into(), "30".into());
2341
+ plan.configuration
2342
+ .insert("health.command".into(), "true".into());
2343
+ let deployment = s.deploy(plan, &LocalDeploymentProvider).unwrap();
2344
+ drop(s);
2345
+ let reloaded = ReleaseStore::load(&p).unwrap();
2346
+ assert_eq!(
2347
+ reloaded
2348
+ .deployment(&deployment.deployment_id)
2349
+ .unwrap()
2350
+ .state,
2351
+ DeploymentState::Active
2352
+ );
2353
+ assert_eq!(
2354
+ reloaded
2355
+ .reconcile(&deployment.deployment_id, &LocalDeploymentProvider)
2356
+ .unwrap()
2357
+ .state,
2358
+ DeploymentState::Active
2359
+ );
2360
+ let context =
2361
+ AuthorizedDeploymentContext::system(&deployment.plan, &deployment.deployment_id);
2362
+ let pid = fs::read_to_string(state_file(&context, "pid")).unwrap();
2363
+ let _ = Command::new("kill").arg(pid.trim()).status();
2364
+ let _ = fs::remove_file(state_file(&context, "pid"));
2365
+ thread::sleep(Duration::from_millis(50));
2366
+ assert_eq!(
2367
+ reloaded
2368
+ .reconcile(&deployment.deployment_id, &LocalDeploymentProvider)
2369
+ .unwrap()
2370
+ .state,
2371
+ DeploymentState::Active
2372
+ );
2373
+ assert!(reloaded
2374
+ .operation_audit(&deployment.deployment_id)
2375
+ .iter()
2376
+ .any(|event| event.operation == "recreate"));
2377
+ reloaded
2378
+ .stop(&deployment.deployment_id, &LocalDeploymentProvider)
2379
+ .unwrap();
2380
+ let _ = fs::remove_file(p);
2381
+ }
2382
+ #[test]
2383
+ fn rollback_verifies_previous_release_and_destroy_is_terminal() {
2384
+ let p = std::env::temp_dir().join(format!("release-rollback-{}", now()));
2385
+ let store = ReleaseStore::load(&p).unwrap();
2386
+ let first = store.create(manifest(), "owner").unwrap();
2387
+ let first = store.verify(&first.manifest.release_id).unwrap();
2388
+ let first = store.publish(&first.manifest.release_id).unwrap();
2389
+ let mut next_manifest = manifest();
2390
+ next_manifest.version = "1.1.0".into();
2391
+ next_manifest.revision_id = "rev-2".into();
2392
+ next_manifest.source_commit = "commit-2".into();
2393
+ let second = store.create(next_manifest, "owner").unwrap();
2394
+ let second = store.verify(&second.manifest.release_id).unwrap();
2395
+ let second = store.publish(&second.manifest.release_id).unwrap();
2396
+ let mut plan = store
2397
+ .plan(
2398
+ &second.manifest.release_id,
2399
+ "production",
2400
+ Some(first.manifest.release_id.clone()),
2401
+ )
2402
+ .unwrap();
2403
+ plan.configuration
2404
+ .insert("local.command".into(), "sleep".into());
2405
+ plan.configuration.insert("local.args".into(), "30".into());
2406
+ plan.configuration
2407
+ .insert("health.command".into(), "true".into());
2408
+ let deployment = store.deploy(plan, &LocalDeploymentProvider).unwrap();
2409
+ let rolled_back = store
2410
+ .rollback(&deployment.deployment_id, &LocalDeploymentProvider)
2411
+ .unwrap();
2412
+ assert_eq!(rolled_back.state, DeploymentState::Active);
2413
+ assert_eq!(rolled_back.plan.release_id, first.manifest.release_id);
2414
+ assert!(store
2415
+ .operation_audit(&deployment.deployment_id)
2416
+ .iter()
2417
+ .any(|event| event.operation == "rollback_health"));
2418
+ let destroyed = store
2419
+ .destroy(&deployment.deployment_id, &LocalDeploymentProvider)
2420
+ .unwrap();
2421
+ assert_eq!(destroyed.state, DeploymentState::Destroyed);
2422
+ let _ = fs::remove_file(p);
2423
+ }
2424
+ #[test]
2425
+ fn live_docker_provider_certification() {
2426
+ let Ok(image) = std::env::var("FELTDB_LIVE_DOCKER_IMAGE") else {
2427
+ return;
2428
+ };
2429
+ let port = std::env::var("FELTDB_LIVE_DOCKER_PORT").unwrap_or_else(|_| "18080".into());
2430
+ let p = std::env::temp_dir().join(format!("release-docker-{}", now()));
2431
+ let store = ReleaseStore::load(&p).unwrap();
2432
+ let release = store.create(manifest(), "ci").unwrap();
2433
+ let release = store.verify(&release.manifest.release_id).unwrap();
2434
+ let release = store.publish(&release.manifest.release_id).unwrap();
2435
+ let mut plan = store
2436
+ .plan_for(
2437
+ &release.manifest.release_id,
2438
+ "staging",
2439
+ DeploymentProviderKind::Docker,
2440
+ DeploymentProfile::AllInOne,
2441
+ None,
2442
+ )
2443
+ .unwrap();
2444
+ plan.configuration.insert("image".into(), image);
2445
+ plan.configuration.insert(
2446
+ "docker.name".into(),
2447
+ format!("feltdb-provider-cert-{}", std::process::id()),
2448
+ );
2449
+ plan.configuration
2450
+ .insert("docker.ports".into(), format!("{port}:8080"));
2451
+ plan.configuration.insert(
2452
+ "health.url".into(),
2453
+ format!("http://127.0.0.1:{port}/ready"),
2454
+ );
2455
+ plan.configuration
2456
+ .insert("health.retries".into(), "30".into());
2457
+ let data_dir =
2458
+ std::env::temp_dir().join(format!("feltdb-provider-cert-data-{}", std::process::id()));
2459
+ fs::create_dir_all(&data_dir).unwrap();
2460
+ plan.configuration.insert(
2461
+ "docker.volumes".into(),
2462
+ format!("{}:/data", data_dir.display()),
2463
+ );
2464
+ for name in [
2465
+ "FELTDB_MASTER_KEY",
2466
+ "FELTDB_SESSION_SECRET",
2467
+ "FELTDB_GRANT_SIGNING_KEY",
2468
+ ] {
2469
+ plan.required_secrets.push(name.into());
2470
+ plan.secret_references.push(SecretReference {
2471
+ name: name.into(),
2472
+ provider_reference: format!("ci/{name}"),
2473
+ });
2474
+ }
2475
+ let deployment_id = format!("deployment-docker-cert-{}", std::process::id());
2476
+ let context = AuthorizedDeploymentContext::new(
2477
+ "ci".into(),
2478
+ &plan,
2479
+ deployment_id,
2480
+ "system".into(),
2481
+ BTreeMap::from([
2482
+ (
2483
+ "FELTDB_MASTER_KEY".into(),
2484
+ "docker-certification-master-key-material".into(),
2485
+ ),
2486
+ (
2487
+ "FELTDB_SESSION_SECRET".into(),
2488
+ "docker-certification-session-secret-material".into(),
2489
+ ),
2490
+ (
2491
+ "FELTDB_GRANT_SIGNING_KEY".into(),
2492
+ "docker-certification-grant-signing-material".into(),
2493
+ ),
2494
+ ]),
2495
+ BTreeMap::from([
2496
+ ("FELTDB_MASTER_KEY".into(), 1),
2497
+ ("FELTDB_SESSION_SECRET".into(), 1),
2498
+ ("FELTDB_GRANT_SIGNING_KEY".into(), 1),
2499
+ ]),
2500
+ );
2501
+ let deployment = store
2502
+ .deploy_authorized(plan, &DockerDeploymentProvider, context)
2503
+ .unwrap();
2504
+ assert_eq!(deployment.state, DeploymentState::Active);
2505
+ let restarted = store
2506
+ .restart(&deployment.deployment_id, &DockerDeploymentProvider)
2507
+ .unwrap();
2508
+ assert_eq!(restarted.state, DeploymentState::Active);
2509
+ let destroyed = store
2510
+ .destroy(&deployment.deployment_id, &DockerDeploymentProvider)
2511
+ .unwrap();
2512
+ assert_eq!(destroyed.state, DeploymentState::Destroyed);
2513
+ let _ = fs::remove_file(p);
2514
+ let _ = fs::remove_dir_all(data_dir);
2515
+ }
2516
+ #[test]
2517
+ fn provider_credentials_are_ephemeral_and_audit_records_versions_only() {
2518
+ let p = std::env::temp_dir().join(format!("release-credentials-{}", now()));
2519
+ let store = ReleaseStore::load(&p).unwrap();
2520
+ let release = store.create(manifest(), "owner").unwrap();
2521
+ let release = store.verify(&release.manifest.release_id).unwrap();
2522
+ let release = store.publish(&release.manifest.release_id).unwrap();
2523
+ let mut plan = store
2524
+ .plan(&release.manifest.release_id, "staging", None)
2525
+ .unwrap();
2526
+ plan.configuration
2527
+ .insert("local.command".into(), "sleep".into());
2528
+ plan.configuration.insert("local.args".into(), "30".into());
2529
+ plan.configuration
2530
+ .insert("health.command".into(), "true".into());
2531
+ plan.required_secrets.push("PROVIDER_TOKEN".into());
2532
+ plan.secret_references.push(SecretReference {
2533
+ name: "PROVIDER_TOKEN".into(),
2534
+ provider_reference: "secret-provider-token".into(),
2535
+ });
2536
+ let deployment_id = format!("deployment-credentials-{}", unique_timestamp());
2537
+ let mut credentials = BTreeMap::new();
2538
+ credentials.insert("PROVIDER_TOKEN".into(), "never-persist-this-value".into());
2539
+ let mut versions = BTreeMap::new();
2540
+ versions.insert("PROVIDER_TOKEN".into(), 7);
2541
+ let context = AuthorizedDeploymentContext::new(
2542
+ "tenant".into(),
2543
+ &plan,
2544
+ deployment_id,
2545
+ "system".into(),
2546
+ credentials,
2547
+ versions,
2548
+ );
2549
+ let deployment = store
2550
+ .deploy_authorized(plan, &LocalDeploymentProvider, context.clone())
2551
+ .unwrap();
2552
+ let persisted = fs::read_to_string(&p).unwrap();
2553
+ assert!(!persisted.contains("never-persist-this-value"));
2554
+ let audit = store.operation_audit(&deployment.deployment_id);
2555
+ assert!(audit
2556
+ .iter()
2557
+ .any(|event| event.credential_versions.get("PROVIDER_TOKEN") == Some(&7)));
2558
+ store
2559
+ .stop_authorized(&deployment.deployment_id, &LocalDeploymentProvider, context)
2560
+ .unwrap();
2561
+ let _ = fs::remove_file(p);
2562
+ }
2563
+ }