Skip to main content

microsandbox_types/
modify.rs

1//! Sandbox modification contract shared by the SDKs, the CLI, and future backends.
2//!
3//! These are the serializable request/response types behind `sandbox.modify()`:
4//! the patch a caller submits, and the plan that classifies each change. The
5//! builder and classification logic live in the SDK; this module owns only the
6//! wire-shaped data so any backend (local today, cloud later) and any language
7//! binding can speak the same contract.
8
9use serde::{Deserialize, Serialize};
10use zeroize::Zeroizing;
11
12use crate::domain::{EnvVar, SecretSubstitution, SecretViolationAction};
13
14//--------------------------------------------------------------------------------------------------
15// Types
16//--------------------------------------------------------------------------------------------------
17
18/// A requested sandbox modification.
19///
20/// This type is serializable so SDKs and the CLI can share one canonical
21/// contract. The only field that may carry raw secret material is the
22/// per-secret `value` inside [`SecretModificationPatch`]; plans derived from
23/// a patch are always value-free.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
26pub struct SandboxModificationPatch {
27    /// Desired effective vCPU count.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub cpus: Option<u8>,
30
31    /// Desired boot-time maximum possible vCPU count.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub max_cpus: Option<u8>,
34
35    /// Desired effective guest memory in MiB.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub memory_mib: Option<u32>,
38
39    /// Desired boot-time maximum hotpluggable memory in MiB.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub max_memory_mib: Option<u32>,
42
43    /// Desired root disk size in MiB. Managed kind: grow-only (the upper is a real ext4 image,
44    /// so shrinking risks data loss and is rejected). Tmpfs kind: any direction, effective next
45    /// boot. Disk-image kind: rejected (user-owned file). Accepts the legacy
46    /// `oci_upper_size_mib` wire spelling.
47    #[serde(alias = "oci_upper_size_mib", skip_serializing_if = "Option::is_none")]
48    pub root_disk_size_mib: Option<u32>,
49
50    /// Environment variables to set for future execs.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub env: Vec<EnvVar>,
53
54    /// Environment variable keys to remove.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub env_remove: Vec<String>,
57
58    /// Labels to set.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub labels: Vec<(String, String)>,
61
62    /// Label keys to remove.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub labels_remove: Vec<String>,
65
66    /// Desired working directory for future execs.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub workdir: Option<String>,
69
70    /// Desired secret specs, keyed by secret name. The planner diffs each
71    /// spec against the existing config to infer what changes.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub secrets: Vec<SecretModificationPatch>,
74
75    /// Secret names to remove. Removal is explicit: absence of a name from
76    /// `secrets` never means removal.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub secrets_remove: Vec<String>,
79}
80
81/// Policy selected for applying or planning a modification.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
84#[serde(rename_all = "snake_case")]
85pub enum ModificationPolicy {
86    /// Apply only changes that can complete without restarting the running sandbox.
87    #[default]
88    NoRestart,
89
90    /// Persist the desired config for the next start and leave any running VM unchanged.
91    NextStart,
92
93    /// Persist the patch and restart the sandbox if restart-required changes are present.
94    Restart,
95}
96
97/// A desired secret spec inside a modification patch.
98///
99/// The spec is declarative: it states the target state for one secret (source
100/// or value, placeholder, allowed hosts) and the planner infers the concrete
101/// change — added, rotated, hosts updated, placeholder updated — by diffing
102/// the spec against the existing config. Removal is explicit through
103/// [`SandboxModificationPatch::secrets_remove`].
104///
105/// Only `value` may carry secret material, and only in-process: it is
106/// [`Zeroizing`]-wrapped, redacted from `Debug` output, skipped by serde when
107/// empty, and never copied into the plan.
108#[derive(Clone, Default, Serialize, Deserialize)]
109#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
110pub struct SecretModificationPatch {
111    /// Stable secret identity, usually the environment variable name.
112    pub name: String,
113
114    /// Host-side source reference to resolve the value from. Mutually
115    /// exclusive with `value`.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub source: Option<SecretSource>,
118
119    /// Raw secret value supplied by the caller, for embedders that hold only
120    /// a value (e.g. from their own vault). Mutually exclusive with `source`.
121    /// A value-based apply persists the value into the durable config until a
122    /// later source-based rotate migrates the entry to a reference.
123    #[serde(default, skip_serializing_if = "String::is_empty")]
124    #[cfg_attr(feature = "ts", ts(type = "string"))]
125    pub value: Zeroizing<String>,
126
127    /// Guest-visible placeholder/reference, if explicitly requested.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub placeholder: Option<String>,
130
131    /// Desired allowed host patterns. Empty means "leave unchanged" for an
132    /// existing secret; a new secret needs at least one.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub allowed_hosts: Vec<String>,
135
136    /// Desired substitution locations. `None` leaves an existing policy unchanged.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub substitution: Option<SecretSubstitution>,
139
140    /// Desired hosts allowed to receive the placeholder unchanged.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub passthrough_hosts: Vec<String>,
143
144    /// Per-secret blocking action. `None` leaves an existing policy unchanged.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub violation_action: Option<SecretViolationAction>,
147
148    /// Whether substitution requires verified TLS identity.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub require_tls_identity: Option<bool>,
151}
152
153/// Host-side source for secret material.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
156#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
157#[serde(tag = "kind", rename_all = "snake_case")]
158pub enum SecretSource {
159    /// Read the value from a host environment variable at apply time.
160    Env {
161        /// Host environment variable name.
162        var: String,
163    },
164
165    /// Read the value from a host-side secret store reference.
166    Store {
167        /// Store-specific secret reference.
168        reference: String,
169    },
170}
171
172impl SecretSource {
173    /// Creates a host environment-variable source.
174    pub fn env(var: impl Into<String>) -> Self {
175        Self::Env { var: var.into() }
176    }
177}
178
179/// Serializable dry-run or apply plan for a sandbox modification.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
182pub struct SandboxModificationPlan {
183    /// Sandbox being modified.
184    pub sandbox: String,
185
186    /// Sandbox status used for classification.
187    pub status: String,
188
189    /// Whether the changes were applied.
190    pub applied: bool,
191
192    /// Modification policy used to produce the plan.
193    pub policy: ModificationPolicy,
194
195    /// Planned changes.
196    pub changes: Vec<PlannedChange>,
197
198    /// Conflicts that must be resolved before the patch can apply.
199    pub conflicts: Vec<ModificationConflict>,
200
201    /// Non-fatal warnings about the patch or current runtime capabilities.
202    pub warnings: Vec<ModificationWarning>,
203
204    /// Live resource resize outcomes, populated by apply when a live change ran.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub resize_status: Vec<ResourceResizeStatus>,
207}
208
209/// One planned modification entry.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
212#[serde(tag = "kind", rename_all = "snake_case")]
213pub enum PlannedChange {
214    /// Ordinary config change.
215    Config(ConfigPlannedChange),
216
217    /// Secret change. Values are omitted by construction.
218    Secret(SecretPlannedChange),
219}
220
221/// Planned config change.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
224pub struct ConfigPlannedChange {
225    /// Config field being changed.
226    pub field: String,
227
228    /// Natural change type for table rendering.
229    pub change: ChangeKind,
230
231    /// Previous safe visible state.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub before: Option<String>,
234
235    /// New safe visible state.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub after: Option<String>,
238
239    /// When or whether the change can take effect.
240    pub disposition: ModificationDisposition,
241
242    /// Human-readable reason for this classification, when useful.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub reason: Option<String>,
245}
246
247/// Planned secret change.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
250pub struct SecretPlannedChange {
251    /// Table field name. This is always `secret`.
252    pub field: String,
253
254    /// Stable secret identity, usually the environment variable name.
255    pub name: String,
256
257    /// Natural change type for table rendering.
258    pub change: SecretChangeKind,
259
260    /// Previous guest-visible reference or placeholder.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub before_ref: Option<String>,
263
264    /// New guest-visible reference or placeholder.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub after_ref: Option<String>,
267
268    /// When or whether the change can take effect.
269    pub disposition: ModificationDisposition,
270
271    /// Allowed hosts after the requested change.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub allow_hosts: Vec<String>,
274
275    /// Human-readable reason for this classification, when useful.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub reason: Option<String>,
278}
279
280/// Natural config change type for human output.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
283#[serde(rename_all = "lowercase")]
284pub enum ChangeKind {
285    /// A field is being added.
286    Added,
287
288    /// A field is being updated.
289    Updated,
290
291    /// A field is being removed.
292    Removed,
293}
294
295/// Natural secret change type for human output.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
298pub enum SecretChangeKind {
299    /// A secret placeholder is being added.
300    #[serde(rename = "added")]
301    Added,
302
303    /// A secret value is being rotated.
304    #[serde(rename = "rotated")]
305    Rotated,
306
307    /// A secret is being removed.
308    #[serde(rename = "removed")]
309    Removed,
310
311    /// A secret is being renamed.
312    #[serde(rename = "renamed")]
313    Renamed,
314
315    /// Allowed hosts are being updated.
316    #[serde(rename = "hosts updated")]
317    HostsUpdated,
318
319    /// The guest-visible placeholder is being updated.
320    #[serde(rename = "placeholder updated")]
321    PlaceholderUpdated,
322}
323
324/// When or whether a planned change can take effect.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
327pub enum ModificationDisposition {
328    /// Applies to the running VM now.
329    #[serde(rename = "live")]
330    Live,
331
332    /// Persists to desired config and applies the next time the sandbox starts.
333    #[serde(rename = "next start")]
334    NextStart,
335
336    /// Needs a restart before it can take effect.
337    #[serde(rename = "requires restart")]
338    RequiresRestart,
339
340    /// Cannot be changed by `modify`.
341    #[serde(rename = "unsupported")]
342    Unsupported,
343}
344
345/// Conflict that blocks applying a modification.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
348pub struct ModificationConflict {
349    /// Field with the conflict.
350    pub field: String,
351
352    /// Human-readable conflict description.
353    pub message: String,
354}
355
356/// Warning emitted while planning a modification.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
359pub struct ModificationWarning {
360    /// Field associated with the warning.
361    pub field: String,
362
363    /// Human-readable warning description.
364    pub message: String,
365}
366
367/// Resource kind used by live resize convergence reporting.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
370#[serde(rename_all = "snake_case")]
371pub enum ResourceKind {
372    /// vCPU count.
373    Cpus,
374
375    /// Guest memory.
376    Memory,
377}
378
379/// Runtime convergence state for an accepted resource resize.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
382#[serde(rename_all = "kebab-case")]
383pub enum ResourceConvergenceState {
384    /// The runtime accepted the request.
385    Accepted,
386
387    /// The guest and VMM are still converging on the requested state.
388    Converging,
389
390    /// Desired, actual, and enforced state match.
391    Applied,
392
393    /// The guest refused or failed to cooperate.
394    GuestRefused,
395
396    /// The resize failed.
397    Failed,
398}
399
400/// Status for a live resource resize.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
403pub struct ResourceResizeStatus {
404    /// Resource being resized.
405    pub resource: ResourceKind,
406
407    /// Requested value.
408    pub requested: String,
409
410    /// Actual value observed in the guest/runtime.
411    pub actual: String,
412
413    /// Host/VMM-enforced value.
414    pub enforced: String,
415
416    /// Convergence state.
417    pub state: ResourceConvergenceState,
418}
419
420//--------------------------------------------------------------------------------------------------
421// Trait Implementations
422//--------------------------------------------------------------------------------------------------
423
424impl std::fmt::Debug for SecretModificationPatch {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        f.debug_struct("SecretModificationPatch")
427            .field("name", &self.name)
428            .field("source", &self.source)
429            .field("value", &"[REDACTED]")
430            .field("placeholder", &self.placeholder)
431            .field("allowed_hosts", &self.allowed_hosts)
432            .finish()
433    }
434}