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 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
137/// Host-side source for secret material.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
141#[serde(tag = "kind", rename_all = "snake_case")]
142pub enum SecretSource {
143 /// Read the value from a host environment variable at apply time.
144 Env {
145 /// Host environment variable name.
146 var: String,
147 },
148
149 /// Read the value from a host-side secret store reference.
150 Store {
151 /// Store-specific secret reference.
152 reference: String,
153 },
154}
155
156impl SecretSource {
157 /// Creates a host environment-variable source.
158 pub fn env(var: impl Into<String>) -> Self {
159 Self::Env { var: var.into() }
160 }
161}
162
163/// Serializable dry-run or apply plan for a sandbox modification.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
166pub struct SandboxModificationPlan {
167 /// Sandbox being modified.
168 pub sandbox: String,
169
170 /// Sandbox status used for classification.
171 pub status: String,
172
173 /// Whether the changes were applied.
174 pub applied: bool,
175
176 /// Modification policy used to produce the plan.
177 pub policy: ModificationPolicy,
178
179 /// Planned changes.
180 pub changes: Vec<PlannedChange>,
181
182 /// Conflicts that must be resolved before the patch can apply.
183 pub conflicts: Vec<ModificationConflict>,
184
185 /// Non-fatal warnings about the patch or current runtime capabilities.
186 pub warnings: Vec<ModificationWarning>,
187
188 /// Live resource resize outcomes, populated by apply when a live change ran.
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub resize_status: Vec<ResourceResizeStatus>,
191}
192
193/// One planned modification entry.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
196#[serde(tag = "kind", rename_all = "snake_case")]
197pub enum PlannedChange {
198 /// Ordinary config change.
199 Config(ConfigPlannedChange),
200
201 /// Secret change. Values are omitted by construction.
202 Secret(SecretPlannedChange),
203}
204
205/// Planned config change.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
208pub struct ConfigPlannedChange {
209 /// Config field being changed.
210 pub field: String,
211
212 /// Natural change type for table rendering.
213 pub change: ChangeKind,
214
215 /// Previous safe visible state.
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub before: Option<String>,
218
219 /// New safe visible state.
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub after: Option<String>,
222
223 /// When or whether the change can take effect.
224 pub disposition: ModificationDisposition,
225
226 /// Human-readable reason for this classification, when useful.
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub reason: Option<String>,
229}
230
231/// Planned secret change.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
234pub struct SecretPlannedChange {
235 /// Table field name. This is always `secret`.
236 pub field: String,
237
238 /// Stable secret identity, usually the environment variable name.
239 pub name: String,
240
241 /// Natural change type for table rendering.
242 pub change: SecretChangeKind,
243
244 /// Previous guest-visible reference or placeholder.
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub before_ref: Option<String>,
247
248 /// New guest-visible reference or placeholder.
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub after_ref: Option<String>,
251
252 /// When or whether the change can take effect.
253 pub disposition: ModificationDisposition,
254
255 /// Allowed hosts after the requested change.
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub allow_hosts: Vec<String>,
258
259 /// Human-readable reason for this classification, when useful.
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub reason: Option<String>,
262}
263
264/// Natural config change type for human output.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
267#[serde(rename_all = "lowercase")]
268pub enum ChangeKind {
269 /// A field is being added.
270 Added,
271
272 /// A field is being updated.
273 Updated,
274
275 /// A field is being removed.
276 Removed,
277}
278
279/// Natural secret change type for human output.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
282pub enum SecretChangeKind {
283 /// A secret placeholder is being added.
284 #[serde(rename = "added")]
285 Added,
286
287 /// A secret value is being rotated.
288 #[serde(rename = "rotated")]
289 Rotated,
290
291 /// A secret is being removed.
292 #[serde(rename = "removed")]
293 Removed,
294
295 /// A secret is being renamed.
296 #[serde(rename = "renamed")]
297 Renamed,
298
299 /// Allowed hosts are being updated.
300 #[serde(rename = "hosts updated")]
301 HostsUpdated,
302
303 /// The guest-visible placeholder is being updated.
304 #[serde(rename = "placeholder updated")]
305 PlaceholderUpdated,
306}
307
308/// When or whether a planned change can take effect.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
311pub enum ModificationDisposition {
312 /// Applies to the running VM now.
313 #[serde(rename = "live")]
314 Live,
315
316 /// Persists to desired config and applies the next time the sandbox starts.
317 #[serde(rename = "next start")]
318 NextStart,
319
320 /// Needs a restart before it can take effect.
321 #[serde(rename = "requires restart")]
322 RequiresRestart,
323
324 /// Cannot be changed by `modify`.
325 #[serde(rename = "unsupported")]
326 Unsupported,
327}
328
329/// Conflict that blocks applying a modification.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
332pub struct ModificationConflict {
333 /// Field with the conflict.
334 pub field: String,
335
336 /// Human-readable conflict description.
337 pub message: String,
338}
339
340/// Warning emitted while planning a modification.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
343pub struct ModificationWarning {
344 /// Field associated with the warning.
345 pub field: String,
346
347 /// Human-readable warning description.
348 pub message: String,
349}
350
351/// Resource kind used by live resize convergence reporting.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
354#[serde(rename_all = "snake_case")]
355pub enum ResourceKind {
356 /// vCPU count.
357 Cpus,
358
359 /// Guest memory.
360 Memory,
361}
362
363/// Runtime convergence state for an accepted resource resize.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
365#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
366#[serde(rename_all = "kebab-case")]
367pub enum ResourceConvergenceState {
368 /// The runtime accepted the request.
369 Accepted,
370
371 /// The guest and VMM are still converging on the requested state.
372 Converging,
373
374 /// Desired, actual, and enforced state match.
375 Applied,
376
377 /// The guest refused or failed to cooperate.
378 GuestRefused,
379
380 /// The resize failed.
381 Failed,
382}
383
384/// Status for a live resource resize.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
387pub struct ResourceResizeStatus {
388 /// Resource being resized.
389 pub resource: ResourceKind,
390
391 /// Requested value.
392 pub requested: String,
393
394 /// Actual value observed in the guest/runtime.
395 pub actual: String,
396
397 /// Host/VMM-enforced value.
398 pub enforced: String,
399
400 /// Convergence state.
401 pub state: ResourceConvergenceState,
402}
403
404//--------------------------------------------------------------------------------------------------
405// Trait Implementations
406//--------------------------------------------------------------------------------------------------
407
408impl std::fmt::Debug for SecretModificationPatch {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.debug_struct("SecretModificationPatch")
411 .field("name", &self.name)
412 .field("source", &self.source)
413 .field("value", &"[REDACTED]")
414 .field("placeholder", &self.placeholder)
415 .field("allowed_hosts", &self.allowed_hosts)
416 .finish()
417 }
418}