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}