Skip to content

Commit f1368ca

Browse files
refactor(hm-vm): SnapshotId exposes a public String field, allowing arbitrary IDs to be minted anywhere (#119)
1 parent 301c20b commit f1368ca

5 files changed

Lines changed: 63 additions & 34 deletions

File tree

crates/hm-exec/src/local/runner/vm.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async fn run_step_vm(vm: &HmVm, ctx: &StepContext, input: ExecutorInput) -> Resu
6161
};
6262

6363
let source = if let Some(ref snap) = input.parent_snapshot {
64-
ImageSource::Snapshot(SnapshotId(snap.0.clone()))
64+
ImageSource::Snapshot(SnapshotId::new(snap.0.clone()))
6565
} else {
6666
ImageSource::Image(
6767
input
@@ -137,13 +137,13 @@ async fn run_step_vm(vm: &HmVm, ctx: &StepContext, input: ExecutorInput) -> Resu
137137
tag: result
138138
.snapshot
139139
.as_ref()
140-
.map_or_else(String::new, |s| s.0.clone()),
140+
.map_or_else(String::new, ToString::to_string),
141141
});
142142
}
143143

144144
Ok(StepResult {
145145
exit_code: result.exit_code,
146-
committed_snapshot: result.snapshot.map(|s| SnapshotRef(s.0)),
146+
committed_snapshot: result.snapshot.map(|s| SnapshotRef(s.to_string())),
147147
artifacts: vec![],
148148
})
149149
}

crates/hm-vm/src/docker.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ impl VmBackend for DockerBackend {
112112

113113
#[instrument(skip(self, _config))]
114114
async fn restore(&self, snapshot: &SnapshotId, _config: &VmConfig) -> Result<Box<dyn Vm>> {
115-
let container_id = self.start_container(&snapshot.0).await?;
115+
let container_id = self.start_container(snapshot.as_ref()).await?;
116116
Ok(Box::new(DockerVm {
117117
client: self.client.clone(),
118118
container_id: Some(container_id),
@@ -121,22 +121,22 @@ impl VmBackend for DockerBackend {
121121

122122
#[instrument(skip(self))]
123123
async fn snapshot_exists(&self, snapshot: &SnapshotId) -> Result<bool> {
124-
self.image_exists_by_tag(&snapshot.0).await
124+
self.image_exists_by_tag(snapshot.as_ref()).await
125125
}
126126

127127
#[instrument(skip(self))]
128128
async fn remove_snapshot(&self, snapshot: &SnapshotId) -> Result<()> {
129129
self.client
130130
.remove_image(
131-
&snapshot.0,
131+
snapshot.as_ref(),
132132
Some(RemoveImageOptions {
133133
force: true,
134134
noprune: false,
135135
}),
136136
None,
137137
)
138138
.await
139-
.with_context(|| format!("removing image '{}'", snapshot.0))?;
139+
.with_context(|| format!("removing image '{snapshot}'"))?;
140140
Ok(())
141141
}
142142
}
@@ -346,7 +346,7 @@ impl Vm for DockerVm {
346346
.await
347347
.context("commit container")?;
348348
let full_tag = format!("{repo}:{tag}");
349-
Ok(SnapshotId(full_tag))
349+
Ok(SnapshotId::new(full_tag))
350350
}
351351

352352
#[instrument(skip(self))]

crates/hm-vm/src/registry.rs

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ impl ImageRegistry {
102102
}
103103

104104
drop(conn);
105-
snapshot.map(SnapshotId)
105+
snapshot.map(SnapshotId::new)
106106
}
107107

108108
/// Insert or update a cache entry.
@@ -118,10 +118,11 @@ impl ImageRegistry {
118118
};
119119

120120
// INSERT OR REPLACE handles both new and updated entries.
121+
let snapshot_id: &str = snapshot.as_ref();
121122
let _result = conn.execute(
122123
"INSERT OR REPLACE INTO snapshots (key, snapshot_id, accessed_at)
123124
VALUES (?1, ?2, ?3)",
124-
rusqlite::params![key, snapshot.0, now],
125+
rusqlite::params![key, snapshot_id, now],
125126
);
126127

127128
drop(conn);
@@ -149,7 +150,7 @@ impl ImageRegistry {
149150
}
150151

151152
drop(conn);
152-
snapshot.map(SnapshotId)
153+
snapshot.map(SnapshotId::new)
153154
}
154155

155156
/// Returns the number of cached entries.
@@ -193,7 +194,9 @@ impl ImageRegistry {
193194
};
194195

195196
let evicted: Vec<SnapshotId> = stmt
196-
.query_map([overflow], |row| row.get::<_, String>(0).map(SnapshotId))
197+
.query_map([overflow], |row| {
198+
row.get::<_, String>(0).map(SnapshotId::new)
199+
})
197200
.ok()
198201
.map(|rows| rows.filter_map(Result::ok).collect())
199202
.unwrap_or_default();
@@ -234,25 +237,25 @@ mod tests {
234237
#[test]
235238
fn put_then_get_returns_snapshot() {
236239
let (reg, _dir) = open_temp(10);
237-
let snap = SnapshotId("snap-abc".into());
240+
let snap = SnapshotId::new("snap-abc");
238241
let evicted = reg.put("my-key", &snap);
239242
assert!(evicted.is_empty());
240243

241244
let got = reg.get("my-key");
242-
assert_eq!(got, Some(SnapshotId("snap-abc".into())));
245+
assert_eq!(got, Some(SnapshotId::new("snap-abc")));
243246
}
244247

245248
#[test]
246249
fn get_updates_access_time() {
247250
let (reg, _dir) = open_temp(2);
248251

249252
// Insert a, then b. "a" is older by insertion order.
250-
reg.put("a", &SnapshotId("snap-a".into()));
253+
reg.put("a", &SnapshotId::new("snap-a"));
251254

252255
// Tiny sleep so timestamps differ.
253256
std::thread::sleep(std::time::Duration::from_secs(1));
254257

255-
reg.put("b", &SnapshotId("snap-b".into()));
258+
reg.put("b", &SnapshotId::new("snap-b"));
256259

257260
// Touch "a" so it becomes the most recently accessed.
258261
std::thread::sleep(std::time::Duration::from_secs(1));
@@ -261,10 +264,10 @@ mod tests {
261264
// Now insert "c" -- capacity is 2, so one must be evicted.
262265
// "b" should be evicted since "a" was touched more recently.
263266
std::thread::sleep(std::time::Duration::from_secs(1));
264-
let evicted = reg.put("c", &SnapshotId("snap-c".into()));
267+
let evicted = reg.put("c", &SnapshotId::new("snap-c"));
265268

266269
assert_eq!(evicted.len(), 1);
267-
assert_eq!(evicted[0], SnapshotId("snap-b".into()));
270+
assert_eq!(evicted[0], SnapshotId::new("snap-b"));
268271

269272
// "a" should still be present.
270273
assert!(reg.get("a").is_some());
@@ -276,16 +279,16 @@ mod tests {
276279
fn eviction_returns_overflow_entries() {
277280
let (reg, _dir) = open_temp(2);
278281

279-
reg.put("x", &SnapshotId("snap-x".into()));
282+
reg.put("x", &SnapshotId::new("snap-x"));
280283
std::thread::sleep(std::time::Duration::from_secs(1));
281-
reg.put("y", &SnapshotId("snap-y".into()));
284+
reg.put("y", &SnapshotId::new("snap-y"));
282285
std::thread::sleep(std::time::Duration::from_secs(1));
283286

284287
// This third insert should evict the oldest ("x").
285-
let evicted = reg.put("z", &SnapshotId("snap-z".into()));
288+
let evicted = reg.put("z", &SnapshotId::new("snap-z"));
286289

287290
assert_eq!(evicted.len(), 1);
288-
assert_eq!(evicted[0], SnapshotId("snap-x".into()));
291+
assert_eq!(evicted[0], SnapshotId::new("snap-x"));
289292
assert_eq!(reg.len(), 2);
290293
}
291294

@@ -296,25 +299,25 @@ mod tests {
296299

297300
{
298301
let reg = ImageRegistry::open(&db_path, 10).expect("open");
299-
reg.put("persistent", &SnapshotId("snap-persist".into()));
302+
reg.put("persistent", &SnapshotId::new("snap-persist"));
300303
assert_eq!(reg.len(), 1);
301304
// reg is dropped here, closing the connection.
302305
}
303306

304307
let reg2 = ImageRegistry::open(&db_path, 10).expect("reopen");
305308
assert_eq!(reg2.len(), 1);
306309
let got = reg2.get("persistent");
307-
assert_eq!(got, Some(SnapshotId("snap-persist".into())));
310+
assert_eq!(got, Some(SnapshotId::new("snap-persist")));
308311
}
309312

310313
#[test]
311314
fn invalidate_returns_removed_snapshot() {
312315
let (reg, _dir) = open_temp(10);
313-
let snap = SnapshotId("snap-rm".into());
316+
let snap = SnapshotId::new("snap-rm");
314317
reg.put("to-remove", &snap);
315318

316319
let removed = reg.invalidate("to-remove");
317-
assert_eq!(removed, Some(SnapshotId("snap-rm".into())));
320+
assert_eq!(removed, Some(SnapshotId::new("snap-rm")));
318321
assert!(reg.get("to-remove").is_none());
319322
assert_eq!(reg.len(), 0);
320323

crates/hm-vm/src/types.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,35 @@ pub enum SnapshotLabel {
5050
}
5151

5252
/// Opaque snapshot handle. Backend-specific contents.
53+
///
54+
/// The inner representation is private so a snapshot id can only be minted
55+
/// through [`SnapshotId::new`]; read access goes through the `AsRef<str>` /
56+
/// `Deref<Target = str>` impls or the `Display` impl. This keeps the handle a
57+
/// distinct domain newtype rather than an interchangeable `String`.
5358
#[derive(Clone, Debug, Hash, PartialEq, Eq, derive_more::Display)]
5459
#[display("{_0}")]
55-
pub struct SnapshotId(pub String);
60+
pub struct SnapshotId(String);
61+
62+
impl SnapshotId {
63+
/// Construct a snapshot handle from a backend-specific id.
64+
pub fn new(id: impl Into<String>) -> Self {
65+
Self(id.into())
66+
}
67+
}
68+
69+
impl AsRef<str> for SnapshotId {
70+
fn as_ref(&self) -> &str {
71+
&self.0
72+
}
73+
}
74+
75+
impl std::ops::Deref for SnapshotId {
76+
type Target = str;
77+
78+
fn deref(&self) -> &str {
79+
&self.0
80+
}
81+
}
5682

5783
/// Result of executing an action.
5884
#[derive(Clone, Debug)]

crates/hm-vm/src/vm.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ impl HmVm {
118118
let evicted = self.registry.put(key, &snap);
119119
for old in &evicted {
120120
if let Err(e) = self.backend.remove_snapshot(old).await {
121-
warn!(snapshot = %old.0, error = %e, "failed to remove evicted snapshot");
121+
warn!(snapshot = %old, error = %e, "failed to remove evicted snapshot");
122122
}
123123
}
124124
}
@@ -186,7 +186,7 @@ mod tests {
186186
async fn restore(&self, snapshot: &SnapshotId, _config: &VmConfig) -> Result<Box<dyn Vm>> {
187187
self.calls
188188
.lock()
189-
.map_or_else(|_| {}, |mut c| c.push(format!("restore:{}", snapshot.0)));
189+
.map_or_else(|_| {}, |mut c| c.push(format!("restore:{snapshot}")));
190190
Ok(Box::new(MockVm {
191191
calls: Arc::clone(&self.calls),
192192
exit_code: self.exit_code,
@@ -196,15 +196,15 @@ mod tests {
196196
async fn snapshot_exists(&self, snapshot: &SnapshotId) -> Result<bool> {
197197
self.calls.lock().map_or_else(
198198
|_| {},
199-
|mut c| c.push(format!("snapshot_exists:{}", snapshot.0)),
199+
|mut c| c.push(format!("snapshot_exists:{snapshot}")),
200200
);
201201
Ok(self.snapshot_exists)
202202
}
203203

204204
async fn remove_snapshot(&self, snapshot: &SnapshotId) -> Result<()> {
205205
self.calls.lock().map_or_else(
206206
|_| {},
207-
|mut c| c.push(format!("remove_snapshot:{}", snapshot.0)),
207+
|mut c| c.push(format!("remove_snapshot:{snapshot}")),
208208
);
209209
Ok(())
210210
}
@@ -246,7 +246,7 @@ mod tests {
246246
self.calls
247247
.lock()
248248
.map_or_else(|_| {}, |mut c| c.push(format!("snapshot:{label}")));
249-
Ok(SnapshotId(format!("snap-{label}")))
249+
Ok(SnapshotId::new(format!("snap-{label}")))
250250
}
251251

252252
async fn destroy(&mut self) -> Result<()> {
@@ -322,7 +322,7 @@ mod tests {
322322
let (registry, _dir) = open_temp_registry(10);
323323

324324
// Pre-populate the registry.
325-
registry.put("step-1", &SnapshotId("cached-snap".into()));
325+
registry.put("step-1", &SnapshotId::new("cached-snap"));
326326

327327
let hm = HmVm::new(Arc::new(backend.clone()), registry, VmConfig::default());
328328

@@ -339,7 +339,7 @@ mod tests {
339339

340340
assert_eq!(result.exit_code, 0);
341341
assert!(result.cached);
342-
assert_eq!(result.snapshot, Some(SnapshotId("cached-snap".into())));
342+
assert_eq!(result.snapshot, Some(SnapshotId::new("cached-snap")));
343343

344344
let log = calls(&backend);
345345
// Only snapshot_exists should have been called -- no create, exec, etc.

0 commit comments

Comments
 (0)