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