arcbox-cli 0.8.1

Command-line interface for ArcBox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Temporary-root tests for `abctl uninstall`.
//!
//! Every path the command touches comes from [`Roots`], so a complete fake
//! install is laid out under a temporary directory and removed for real.
//! External commands go through a recording [`Host`] that answers "not
//! running" and "not loaded" unless a test says otherwise.

use std::ffi::OsStr;
use std::fs;
use std::os::unix::fs::symlink;
use std::os::unix::process::ExitStatusExt as _;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Output};
use std::sync::Mutex;

use anyhow::Result;
use arcbox_constants::paths::{ArcboxProfile, DOCKER_CLI_TOOLS, privileged};
use arcbox_docker::DockerContextManager;

use super::host::Host;
use super::inventory::{Roots, scan};
use super::steps::Outcome;
use super::{Step, run};
use crate::commands::setup;

type Answer = Box<dyn Fn(&str, &[&OsStr]) -> Option<Output> + Send + Sync>;

/// Records every command and answers each with `answer`, or with a silent
/// success when `answer` declines.
struct Recorder {
    calls: Mutex<Vec<String>>,
    answer: Answer,
}

impl Recorder {
    fn new() -> Self {
        Self::answering(|_, _| None)
    }

    fn answering(
        answer: impl Fn(&str, &[&OsStr]) -> Option<Output> + Send + Sync + 'static,
    ) -> Self {
        Self {
            calls: Mutex::new(Vec::new()),
            answer: Box::new(answer),
        }
    }

    fn calls(&self) -> Vec<String> {
        self.calls.lock().unwrap().clone()
    }
}

impl Host for Recorder {
    fn run(&self, program: &str, args: &[&OsStr]) -> Result<Output> {
        let line = std::iter::once(program.to_owned())
            .chain(args.iter().map(|arg| arg.to_string_lossy().into_owned()))
            .collect::<Vec<_>>()
            .join(" ");
        self.calls.lock().unwrap().push(line.clone());
        if let Some(output) = (self.answer)(program, args) {
            return Ok(output);
        }
        // What a clean Mac answers: the app is not running, launchd has no
        // such job, nothing is in the keychain.
        Ok(match (program, line.as_str()) {
            ("osascript", _) => output(0, "false\n", ""),
            (_, line) if line.contains("launchctl bootout") => {
                output(3, "", "Boot-out failed: 3: No such process\n")
            }
            ("security", line) if line.contains("find-certificate") => {
                output(44, "", "could not be found\n")
            }
            _ => output(0, "", ""),
        })
    }
}

fn output(code: i32, stdout: &str, stderr: &str) -> Output {
    Output {
        status: ExitStatus::from_raw(code << 8),
        stdout: stdout.into(),
        stderr: stderr.into(),
    }
}

/// A complete production install laid out under one temporary directory.
struct Install {
    _dir: tempfile::TempDir,
    roots: Roots,
}

fn write(path: &Path, content: &str) {
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(path, content).unwrap();
}

fn link(path: &Path, target: &str) {
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    symlink(target, path).unwrap();
}

impl Install {
    fn new() -> Self {
        let dir = tempfile::tempdir().unwrap();
        let home = dir.path().join("home");
        let roots = Roots {
            profile: ArcboxProfile::Production,
            data_dir: home.join(".arcbox"),
            docker_config: home.join(".docker"),
            docker_context: "arcbox".to_owned(),
            system: dir.path().join("system"),
            home,
        };
        let data = &roots.data_dir;
        let library = roots.home.join("Library");

        // The privileged helper and what it manages.
        write(&roots.system_path(privileged::HELPER_BINARY), "helper");
        write(&roots.system_path(privileged::HELPER_PLIST), "<plist/>");
        write(&roots.system_path(privileged::HELPER_SOCKET), "");
        write(&roots.system_path("/var/log/arcbox/helper.log"), "{}");
        write(
            &roots.system_path("/etc/resolver/arcbox.local"),
            "# managed by arcbox-helper\nnameserver 127.0.0.1\nport 5553\n",
        );
        write(
            &roots.hosts(),
            "##\n127.0.0.1\tlocalhost\n127.0.0.1\tArcBox\t# managed by arcbox-helper\n",
        );
        link(
            &roots.system_path(privileged::DOCKER_SOCKET),
            "/Users/alice/.arcbox/run/docker.sock",
        );
        let bin = roots.system_path("/usr/local/bin");
        for name in DOCKER_CLI_TOOLS {
            link(
                &bin.join(name),
                &format!("/Applications/ArcBox.app/Contents/MacOS/xbin/{name}"),
            );
        }
        link(
            &bin.join("abctl"),
            "/Applications/ArcBox.app/Contents/MacOS/bin/abctl",
        );

        // The user's own.
        write(
            &roots.launch_agent("com.arcboxlabs.desktop.daemon"),
            "<plist/>",
        );
        write(&roots.launch_agent("dev.arcbox.daemon"), "<plist/>");
        for sub in [
            "Application Support/com.arcboxlabs.desktop/state.json",
            "Caches/com.arcboxlabs.desktop/cache",
            "HTTPStorages/com.arcboxlabs.desktop/cookies",
            "Saved Application State/com.arcboxlabs.desktop.savedState/window",
            "Logs/arcbox/daemon.stdout.log",
        ] {
            write(&library.join(sub), "");
        }
        write(&roots.preferences(), "<plist/>");
        write(&data.join("data/docker.img"), "disk");
        write(&data.join("run/daemon.lock"), "");
        write(&data.join("bin/abctl"), "");
        write(&data.join("shell/init.zsh"), "");
        write(&data.join("tls/ca.pem"), "pem");
        write(&roots.app_bundle().join("Contents/Info.plist"), "<plist/>");

        // Integrations.
        let manager = DockerContextManager::with_config_dir(
            data.join("run/docker.sock"),
            roots.docker_config.clone(),
        );
        write(
            &roots.docker_config.join("config.json"),
            r#"{"currentContext":"desktop-linux","credsStore":"osxkeychain"}"#,
        );
        manager.enable().unwrap();
        write(
            &roots.home.join(".ssh/config"),
            "# Added by `abctl ssh install`: ssh <machine>@arcbox\nInclude ~/.arcbox/ssh/config\n\nHost *\n  User me\n",
        );

        Self { _dir: dir, roots }
    }

    /// Writes the shell profile under THIS home, as `setup install` leaves
    /// it, and returns its path. The shell is the one `setup` detects from
    /// `$SHELL` (zsh here, bash on a CI runner), and the path must come from
    /// the install's home, never from the process's own: a probe of the real
    /// login shell once pointed the removal at the developer's profile.
    async fn write_shell_profile(&self) -> PathBuf {
        let integration = setup::Integration::under(
            &self.roots.home,
            &self.roots.data_dir,
            self.roots.docker_config.clone(),
        )
        .await
        .unwrap();
        let init = self
            .roots
            .data_dir
            .join(format!("shell/init.{}", integration.shell_kind.as_str()));
        let source = match integration.shell_kind {
            setup::ShellKind::Fish => format!("source \"{}\"; or true", init.display()),
            setup::ShellKind::Zsh | setup::ShellKind::Bash => {
                format!("source \"{}\" 2>/dev/null || :", init.display())
            }
        };
        write(
            &integration.profile,
            &format!(
                "export KEEP=1\n\n# Added by ArcBox: command-line tools and integration\n{source} # managed by ArcBox\n"
            ),
        );
        integration.profile
    }

    fn roots(&self) -> &Roots {
        &self.roots
    }

    fn docker(&self) -> DockerContextManager {
        DockerContextManager::with_config_dir(
            self.roots.data_dir.join("run/docker.sock"),
            self.roots.docker_config.clone(),
        )
    }
}

async fn uninstall(install: &Install, host: &dyn Host, keep_data: bool) -> Vec<Step> {
    let residue = scan(install.roots(), keep_data);
    run(host, install.roots(), &residue, &mut |_| {}).await
}

fn failures(steps: &[Step]) -> Vec<String> {
    steps
        .iter()
        .filter(|step| step.failed())
        .map(ToString::to_string)
        .collect()
}

#[tokio::test]
async fn everything_arcbox_wrote_is_removed_and_nothing_else_is_touched() {
    let install = Install::new();
    let roots = install.roots();
    // OrbStack's link shares our xbin layout (#715); a stray real file in
    // /usr/local/bin is nobody's to delete.
    let bin = roots.system_path("/usr/local/bin");
    fs::remove_file(bin.join("docker-compose")).unwrap();
    link(
        &bin.join("docker-compose"),
        "/Applications/OrbStack.app/Contents/MacOS/xbin/docker-compose",
    );
    write(&bin.join("docker-credential-pass"), "real binary");
    write(&roots.home.join("ArcBox/README"), "the user's own folder");
    let shell_profile = install.write_shell_profile().await;

    let host = Recorder::new();
    let steps = uninstall(&install, &host, false).await;
    assert_eq!(failures(&steps), Vec::<String>::new());

    for absent in [
        roots.system_path(privileged::HELPER_BINARY),
        roots.system_path(privileged::HELPER_PLIST),
        roots.system_path(privileged::HELPER_SOCKET),
        roots.system_path("/var/log/arcbox"),
        roots.system_path("/etc/resolver/arcbox.local"),
        roots.system_path(privileged::DOCKER_SOCKET),
        bin.join("docker"),
        bin.join("docker-buildx"),
        bin.join("docker-credential-osxkeychain"),
        bin.join("abctl"),
        roots.launch_agent("com.arcboxlabs.desktop.daemon"),
        roots.launch_agent("dev.arcbox.daemon"),
        roots
            .home
            .join("Library/Application Support/com.arcboxlabs.desktop"),
        roots.home.join("Library/Caches/com.arcboxlabs.desktop"),
        roots
            .home
            .join("Library/HTTPStorages/com.arcboxlabs.desktop"),
        roots
            .home
            .join("Library/Saved Application State/com.arcboxlabs.desktop.savedState"),
        roots.home.join("Library/Logs/arcbox"),
        roots.preferences(),
        roots.data_dir.clone(),
        roots.app_bundle(),
    ] {
        assert!(
            absent.symlink_metadata().is_err(),
            "{} should be gone",
            absent.display()
        );
    }
    assert_eq!(
        fs::read_link(bin.join("docker-compose")).unwrap(),
        PathBuf::from("/Applications/OrbStack.app/Contents/MacOS/xbin/docker-compose")
    );
    assert_eq!(
        fs::read_to_string(bin.join("docker-credential-pass")).unwrap(),
        "real binary"
    );
    assert_eq!(
        fs::read_to_string(roots.hosts()).unwrap(),
        "##\n127.0.0.1\tlocalhost\n"
    );
    assert!(roots.home.join("ArcBox/README").exists());

    // The context in use is removed and the previous one restored (#716).
    let docker = install.docker();
    assert!(!docker.context_exists());
    assert_eq!(
        docker.current_context().unwrap().as_deref(),
        Some("desktop-linux")
    );
    assert_eq!(
        fs::read_to_string(roots.home.join(".ssh/config")).unwrap(),
        "Host *\n  User me\n"
    );
    assert_eq!(
        fs::read_to_string(&shell_profile).unwrap(),
        "export KEEP=1\n"
    );

    // launchd is asked by label, never `pkill`; the helper is unregistered
    // with sudo, and preferences go through `defaults`.
    let calls = host.calls();
    let uid = unsafe { libc::getuid() };
    assert!(calls.contains(&format!(
        "launchctl bootout gui/{uid}/com.arcboxlabs.desktop.daemon"
    )));
    assert!(
        calls.contains(&"sudo launchctl bootout system/com.arcboxlabs.desktop.helper".to_owned())
    );
    assert!(calls.contains(&"defaults delete com.arcboxlabs.desktop".to_owned()));
    assert!(
        !calls.iter().any(|call| call.contains("pkill")),
        "{calls:?}"
    );
}

#[tokio::test]
async fn keep_data_leaves_the_data_directory_and_homebrew_keeps_its_app() {
    let install = Install::new();
    let roots = install.roots();
    fs::create_dir_all(roots.system_path("/opt/homebrew/Caskroom/arcbox/1.37.0")).unwrap();

    let residue = scan(roots, true);
    assert!(residue.app_left_to_homebrew);
    let steps = run(&Recorder::new(), roots, &residue, &mut |_| {}).await;
    assert_eq!(failures(&steps), Vec::<String>::new());

    assert_eq!(
        fs::read_to_string(roots.data_dir.join("data/docker.img")).unwrap(),
        "disk"
    );
    assert!(!roots.data_dir.join("bin").exists());
    assert!(!roots.data_dir.join("run").exists());
    assert!(roots.app_bundle().exists());
}

/// A step that fails is reported as failed, and the rest still run (#716).
#[tokio::test]
async fn a_failed_step_is_reported_and_does_not_stop_the_others() {
    let install = Install::new();
    let host = Recorder::answering(|program, args| {
        let line = args
            .iter()
            .map(|arg| arg.to_string_lossy())
            .collect::<Vec<_>>()
            .join(" ");
        (program == "sudo" && line.contains("launchctl bootout system/"))
            .then(|| output(1, "", "Boot-out failed: 5: Input/output error\n"))
    });

    let steps = uninstall(&install, &host, false).await;
    let failed = failures(&steps);
    assert_eq!(failed.len(), 1, "{failed:?}");
    assert!(
        failed[0].starts_with("Unregistering the helper"),
        "{failed:?}"
    );
    assert!(failed[0].contains("Input/output error"), "{failed:?}");
    assert!(!install.roots().data_dir.exists());
}

/// A daemon started by `abctl daemon start` has no launchd job: it is found
/// through `daemon.lock` and stopped with SIGTERM (#716).
#[tokio::test]
async fn a_daemon_without_a_launchd_job_is_stopped_through_its_lock() {
    let install = Install::new();
    let lock = install.roots().data_dir.join("run/daemon.lock");
    // Stands in for the daemon: takes the flock and writes its PID, as the
    // daemon does, then waits for SIGTERM.
    let mut child = std::process::Command::new("/usr/bin/perl")
        .args([
            "-MFcntl=:flock",
            "-e",
            r#"open(my $f, ">", $ARGV[0]) or die; flock($f, LOCK_EX) or die; syswrite($f, "$$\n"); sleep 60 while 1;"#,
            lock.to_str().unwrap(),
        ])
        .spawn()
        .unwrap();
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    while !super::super::daemon::daemon_is_alive(&lock) {
        assert!(
            std::time::Instant::now() < deadline,
            "stand-in never took the lock"
        );
        std::thread::sleep(std::time::Duration::from_millis(20));
    }

    let steps = uninstall(&install, &Recorder::new(), false).await;
    assert_eq!(failures(&steps), Vec::<String>::new());
    let stop = steps
        .iter()
        .find(|step| step.label == "Stopping the daemon")
        .unwrap();
    assert!(matches!(stop.outcome, Ok(Outcome::Done)), "{stop}");
    let status = child.wait().unwrap();
    assert_eq!(status.signal(), Some(libc::SIGTERM));
}