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