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