1use crate::base::paths::{HOME_DIR, KEEP_ALIVE};
14use crate::desktop::login::{self, Fill};
15use crate::engine::names;
16use crate::engine::run::{EngineError, capture};
17use crate::engine::{
18 Access, ContainerCreate, ContainerState, Engine, EngineCommand, Exec, HostUser, Mount, MountSource, Network,
19};
20use crate::profile::{SafeName, account};
21use crate::store::WorkspaceId;
22
23pub const STORE_DIR: &str = "/qcode-credentials";
26
27#[must_use]
30pub fn sign_out(profile: &SafeName) -> String {
31 names::credential_volume(profile.as_str())
32}
33
34#[must_use]
39pub fn is_stored(listing: &str, profile: &SafeName) -> bool {
40 listed(listing, &names::credential_volume(profile.as_str()))
41}
42
43fn listed(listing: &str, volume: &str) -> bool {
45 listing.lines().any(|line| line.trim() == volume)
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Home {
52 profile: SafeName,
53 workspace: WorkspaceId,
54}
55
56impl Home {
57 #[must_use]
59 pub fn new(profile: SafeName, workspace: WorkspaceId) -> Self {
60 Self { profile, workspace }
61 }
62
63 #[must_use]
65 pub fn profile(&self) -> &SafeName {
66 &self.profile
67 }
68
69 #[must_use]
71 pub fn workspace(&self) -> &WorkspaceId {
72 &self.workspace
73 }
74
75 #[must_use]
77 pub fn volume(&self) -> String {
78 names::home_volume(self.workspace.as_str(), self.profile.as_str())
79 }
80
81 #[must_use]
84 pub fn container(&self) -> String {
85 names::profile_container(self.workspace.as_str(), self.profile.as_str())
86 }
87
88 #[must_use]
91 pub fn settings_container(&self) -> String {
92 names::settings_container(self.workspace.as_str(), self.profile.as_str())
93 }
94
95 fn courier(&self) -> String {
97 format!("qcode-refresh-{}-{}", self.workspace, self.profile)
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Rewrite {
114 pub create: EngineCommand,
116 pub start: EngineCommand,
118 pub copy: EngineCommand,
120 pub merge: EngineCommand,
124 pub remove: EngineCommand,
126}
127
128#[must_use]
132pub fn rewrite(engine: &Engine, home: &Home, user: HostUser, fill: Fill) -> Rewrite {
133 let courier = home.courier();
134 let store = names::credential_volume(home.profile.as_str());
135 let volume = home.volume();
136 let mounts = [
137 Mount { source: MountSource::Volume(&store), target: STORE_DIR.as_ref(), access: Access::ReadOnly },
138 Mount { source: MountSource::Volume(&volume), target: HOME_DIR.as_ref(), access: Access::ReadWrite },
139 ];
140 let create = engine.create_container(&ContainerCreate {
141 name: &courier,
142 hostname: names::HOSTNAME,
143 labels: &[],
144 image: &names::profile_image(home.profile.as_str()),
145 mounts: &mounts,
146 network: Network::None,
147 user,
148 workdir: None,
149 command: KEEP_ALIVE,
150 });
151 let give = login::give(fill);
152 let words: Vec<&str> = give.iter().map(String::as_str).collect();
153 let copy = engine.exec_without_terminal(&Exec { container: &courier, command: &words });
154 let merge = account::merge();
155 let words: Vec<&str> = merge.iter().map(String::as_str).collect();
156 let merge = engine.exec_without_terminal(&Exec { container: &courier, command: &words });
157 Rewrite { create, start: engine.start_container(&courier), copy, merge, remove: engine.remove_container(&courier) }
158}
159
160fn run(rewrite: &Rewrite) -> Result<(), EngineError> {
165 let _ = capture(&rewrite.remove);
166 let copied = capture(&rewrite.create)
167 .and_then(|_| capture(&rewrite.start))
168 .and_then(|_| capture(&rewrite.copy))
169 .and_then(|_| capture(&rewrite.merge))
170 .map(|_| ());
171 let _ = capture(&rewrite.remove);
172 copied
173}
174
175pub fn home_exists(engine: &Engine, home: &Home) -> Result<bool, EngineError> {
182 Ok(listed(&capture(&engine.list_volumes())?, &home.volume()))
183}
184
185pub fn first_fill(engine: &Engine, home: &Home, user: HostUser) -> Result<(), EngineError> {
201 if !is_stored(&capture(&engine.list_volumes())?, &home.profile) {
202 return Ok(());
203 }
204 run(&rewrite(engine, home, user, Fill::Keep))
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct Refreshed {
210 pub written: Vec<WorkspaceId>,
212 pub running: Vec<WorkspaceId>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum RefreshError {
219 NotStored,
221 Engine(String),
224}
225
226impl From<EngineError> for RefreshError {
227 fn from(error: EngineError) -> Self {
228 Self::Engine(match error {
229 EngineError::NotRunnable { error, .. } => error.to_string(),
230 EngineError::Failed(failure) => failure.output,
231 EngineError::Cancelled { command } => {
232 format!("{} {}", command.program.display(), command.args.join(" ".as_ref()).to_string_lossy())
233 }
234 EngineError::TimedOut { command, after } => crate::engine::run::timed_out(&command, after),
235 })
236 }
237}
238
239pub fn refresh_all(
256 engine: &Engine,
257 profile: &SafeName,
258 workspaces: &[WorkspaceId],
259 user: HostUser,
260) -> Result<Refreshed, RefreshError> {
261 if !is_stored(&capture(&engine.list_volumes())?, profile) {
262 return Err(RefreshError::NotStored);
263 }
264 let mut done = Refreshed { written: Vec::new(), running: Vec::new() };
265 for workspace in workspaces {
266 let home = Home::new(profile.clone(), workspace.clone());
267 let running = capture(&engine.container_state(&home.container()))
270 .is_ok_and(|word| ContainerState::parse(&word).is_running());
271 if running {
272 done.running.push(workspace.clone());
273 continue;
274 }
275 run(&rewrite(engine, &home, user, Fill::Replace))?;
277 done.written.push(workspace.clone());
278 }
279 Ok(done)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::engine::EngineKind;
286
287 fn args(command: &EngineCommand) -> Vec<String> {
288 command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
289 }
290
291 fn home() -> Home {
292 Home::new(
293 SafeName::parse("claude-sub").expect("the name is safe"),
294 WorkspaceId::parse("my-app").expect("the id is legal"),
295 )
296 }
297
298 #[test]
299 fn a_home_is_named_after_its_workspace_and_its_profile() {
300 let home = home();
301 assert_eq!(home.volume(), "qcode-home-my-app-claude-sub");
302 assert_eq!(home.container(), "qcode-my-app-claude-sub");
303 assert_eq!(home.courier(), "qcode-refresh-my-app-claude-sub");
304 assert_eq!(home.profile().as_str(), "claude-sub");
305 assert_eq!(home.workspace().as_str(), "my-app");
306 }
307
308 #[test]
309 fn signing_out_names_the_volume_that_holds_the_login() {
310 let profile = SafeName::from_display("claude-sub").expect("has letters");
311 assert_eq!(sign_out(&profile), names::credential_volume(profile.as_str()));
312 }
313
314 #[test]
315 fn a_login_is_stored_when_the_listing_names_its_volume() {
316 let profile = SafeName::parse("claude-sub").expect("the name is safe");
317 assert!(is_stored("qcode-cred-codex\nqcode-cred-claude-sub\n", &profile));
318 assert!(!is_stored("qcode-cred-claude-sub-old\nqcode-home-p-claude-sub\n", &profile));
319 assert!(!is_stored("", &profile));
320 }
321
322 #[test]
323 fn the_courier_mounts_the_store_read_only_and_the_home_writable_off_the_network() {
324 let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
325 let rewrite = rewrite(&engine, &home(), HostUser::Ids { uid: 1000, gid: 1000 }, Fill::Keep);
326 assert_eq!(
327 args(&rewrite.create),
328 [
329 "create",
330 "--name",
331 "qcode-refresh-my-app-claude-sub",
332 "--hostname",
333 names::HOSTNAME,
334 "--userns=keep-id",
335 "--network=none",
336 "--volume",
337 "qcode-cred-claude-sub:/qcode-credentials:ro,z",
338 "--volume",
339 "qcode-home-my-app-claude-sub:/home/qcode:rw,z",
340 "--pull=never",
341 "qcode/profile/claude-sub",
342 KEEP_ALIVE[0],
343 KEEP_ALIVE[1],
344 KEEP_ALIVE[2],
345 ]
346 );
347 assert_eq!(args(&rewrite.start), ["start", "qcode-refresh-my-app-claude-sub"]);
348 let copy = args(&rewrite.copy);
349 assert_eq!(©[..4], ["exec", "qcode-refresh-my-app-claude-sub", "sh", "-c"]);
350 assert!(copy[4].ends_with("else cp -R /qcode-credentials/. /home/qcode/; fi"), "{copy:?}");
352 assert_eq!(copy.last().map(String::as_str), Some("keep"), "a first fill never replaces a login");
353 let merge = args(&rewrite.merge);
354 assert_eq!(&merge[..3], ["exec", "qcode-refresh-my-app-claude-sub", "node"]);
355 assert_eq!(&merge[merge.len() - 3..], [STORE_DIR, HOME_DIR, account::MERGE_DIR], "{merge:?}");
356 assert_eq!(args(&rewrite.remove), ["rm", "--force", "qcode-refresh-my-app-claude-sub"]);
357 }
358
359 #[test]
360 fn docker_spells_the_same_courier_with_its_own_user_flag() {
361 let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
362 let rewrite = rewrite(&engine, &home(), HostUser::Ids { uid: 1000, gid: 100 }, Fill::Replace);
363 let create = args(&rewrite.create);
364 assert_eq!(
365 &create[..7],
366 [
367 "create",
368 "--name",
369 "qcode-refresh-my-app-claude-sub",
370 "--hostname",
371 names::HOSTNAME,
372 "--user",
373 "1000:100"
374 ]
375 );
376 assert!(create.contains(&"qcode-cred-claude-sub:/qcode-credentials:ro".to_owned()), "{create:?}");
377 assert!(create.contains(&"qcode-home-my-app-claude-sub:/home/qcode:rw".to_owned()), "{create:?}");
378 assert_eq!(rewrite.copy.program, std::path::Path::new("/usr/bin/docker"));
379 }
380
381 #[test]
382 fn the_copy_runs_without_a_terminal_so_docker_takes_it_from_a_pipe() {
383 let engine = Engine::new(EngineKind::Docker, "/usr/bin/docker");
384 let rewrite = rewrite(&engine, &home(), HostUser::ImageDefault, Fill::Keep);
385 let copy = args(&rewrite.copy);
386 assert!(!copy.contains(&"--tty".to_owned()), "{copy:?}");
387 assert!(!copy.contains(&"--interactive".to_owned()), "{copy:?}");
388 }
389
390 #[test]
391 fn an_engine_that_is_not_there_answers_with_the_machines_words() {
392 let engine = Engine::new(EngineKind::Podman, "/qcode/no/such/engine");
395 let profile = SafeName::parse("claude-sub").expect("the name is safe");
396 let outcome =
397 refresh_all(&engine, &profile, &[WorkspaceId::parse("p").expect("legal")], HostUser::ImageDefault);
398 match outcome {
399 Err(RefreshError::Engine(words)) => assert!(!words.is_empty()),
400 other => panic!("expected the engine's failure, got {other:?}"),
401 }
402 assert!(matches!(first_fill(&engine, &home(), HostUser::ImageDefault), Err(EngineError::NotRunnable { .. })));
403 assert!(matches!(home_exists(&engine, &home()), Err(EngineError::NotRunnable { .. })));
404 }
405}