|
root / src / incus.reef
incus.reef Reef 424 lines 15.9 KB
  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
/******************************************************************************
                __               ____                __   
               / /   ___  ____ _/ __/_____________ _/ /__ 
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ 

    (C)opyright 2026, Leafscale, LLC -  https://www.leafscale.com

    Project: repoman
   Filename: src/incus.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: Wrappers over the incus CLI (launch, delete, profile ops, device add with shift)
     
******************************************************************************/

module incus

import core.str
import sys.process as p
import sys.fd as fd
import core.result_generic as rg
import core.convert as convert

export
    type CaptureResult
    fn process_run_capture(program: string, args: [string]): CaptureResult
    fn process_run_silent(program: string, args: [string]): int
    fn validate_name(name: string): bool
    fn project_ensure(project: string, verbose: bool): rg.Result[bool, string]
    fn project_present(project: string): rg.Result[bool, string]
    fn container_exists(project: string, name: string, verbose: bool): rg.Result[bool, string]
    fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
    fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
    fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
    fn restart(project: string, name: string): rg.Result[bool, string]
    fn container_state(project: string, name: string): rg.Result[string, string]
    fn delete_container(project: string, name: string): rg.Result[bool, string]
    fn delete_profile(project: string, name: string): rg.Result[bool, string]
    fn host_uid(): rg.Result[string, string]
    fn profile_exists(project: string, name: string): rg.Result[bool, string]
    fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string]
    fn profile_get(project: string, name: string): rg.Result[string, string]
    fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string]
end export

// Spawn a program with both stdout and stderr redirected to /dev/null in the
// child. Used for existence-check probes where the upstream command's error
// output would confuse the user (e.g., `incus project show foo` writing
// "Error: Project not found" to stderr when foo doesn't exist — we already
// know that, the missing-ness is the signal we wanted).
//
// Implementation: fork → in child, open /dev/null and dup2 it over fd 1/2,
// then exec the program. Returns the child PID; caller waits as usual.
fn process_run_silent(program: string, args: [string]): int
    let pid: int = p.process_fork()
    if pid < 0
        return -1
    end if
    if pid == 0
        let dev_null: int = fd.fd_open("/dev/null", fd.O_WRONLY(), 0)
        if dev_null >= 0
            let _o: int = fd.fd_dup2(dev_null, fd.STDOUT())
            let _e: int = fd.fd_dup2(dev_null, fd.STDERR())
            let _c: int = fd.fd_close(dev_null)
        end if
        let _x: int = p.process_run_exec(program, args)
        p.exit_now(127)
    end if
    return pid
end process_run_silent

type CaptureResult = struct
    exit_code: int
    stdout: string
end CaptureResult

// Spawn `program args` and capture the child's stdout to a string.
// stderr passes through to parent terminal. Returns exit code + stdout.
// On any setup failure (fork, pipe, exec), exit_code is non-zero and
// stdout is empty.
fn process_run_capture(program: string, args: [string]): CaptureResult
    let pipe_fds: [int] = fd.fd_pipe()
    if pipe_fds.length() != 2
        return CaptureResult { exit_code: -1, stdout: "" }
    end if
    let read_fd: int = pipe_fds[0]
    let write_fd: int = pipe_fds[1]

    let pid: int = p.process_fork()
    if pid < 0
        let _r: int = fd.fd_close(read_fd)
        let _w: int = fd.fd_close(write_fd)
        return CaptureResult { exit_code: -1, stdout: "" }
    end if
    if pid == 0
        // Child: dup write end of pipe over stdout, close both ends, exec.
        let _o: int = fd.fd_dup2(write_fd, fd.STDOUT())
        let _c1: int = fd.fd_close(read_fd)
        let _c2: int = fd.fd_close(write_fd)
        let _x: int = p.process_run_exec(program, args)
        p.exit_now(127)
    end if

    // Parent: close write end, read until EOF, wait.
    let _cw: int = fd.fd_close(write_fd)
    mut output: string = ""
    let chunk_size: int = 4096
    mut keep_reading: bool = true
    while keep_reading
        let chunk: string = fd.fd_read(read_fd, chunk_size)
        if str.length(chunk) == 0
            keep_reading = false
        else
            output = output + chunk
        end if
    end while
    let _cr: int = fd.fd_close(read_fd)
    let exit: int = p.process_wait(pid)
    return CaptureResult { exit_code: exit, stdout: output }
end process_run_capture

// Spawn `program args` with the given string written to the child's stdin.
// stdout/stderr inherit the parent terminal. Returns exit code.
// On any setup failure (fork, pipe, exec), returns non-zero.
fn process_run_with_stdin(program: string, args: [string], input: string): int
    let pipe_fds: [int] = fd.fd_pipe()
    if pipe_fds.length() != 2
        return -1
    end if
    let read_fd: int = pipe_fds[0]
    let write_fd: int = pipe_fds[1]

    let pid: int = p.process_fork()
    if pid < 0
        let _r: int = fd.fd_close(read_fd)
        let _w: int = fd.fd_close(write_fd)
        return -1
    end if
    if pid == 0
        // Child: dup read end of pipe over stdin, close both ends, exec.
        let _i: int = fd.fd_dup2(read_fd, fd.STDIN())
        let _c1: int = fd.fd_close(read_fd)
        let _c2: int = fd.fd_close(write_fd)
        let _x: int = p.process_run_exec(program, args)
        p.exit_now(127)
    end if

    // Parent: close read end, write input, close write, wait.
    let _cr: int = fd.fd_close(read_fd)
    let _wn: int = fd.fd_write(write_fd, input)
    let _cw: int = fd.fd_close(write_fd)
    return p.process_wait(pid)
end process_run_with_stdin

// Query incus for the container's state. Uses `incus list --project <p>
// --format csv -c s <name>` which outputs just the STATE column ("RUNNING",
// "STOPPED", etc.) for matching containers. Empty output (no match) means
// the container doesn't exist in this project; we return "MISSING".
fn container_state(project: string, name: string): rg.Result[string, string]
    let cap: CaptureResult = process_run_capture("incus", [
        "list", "--project", project, "--format", "csv", "-c", "s", name
    ])
    if cap.exit_code != 0
        return @Result[string, string].Err("incus list exited with code " + convert.to_string(cap.exit_code))
    end if

    // Trim trailing newline + whitespace from stdout.
    let trimmed: string = str.trim_ws(cap.stdout)
    if str.length(trimmed) == 0
        return @Result[string, string].Ok("MISSING")
    end if
    return @Result[string, string].Ok(trimmed)
end container_state

// Get the current process's effective UID as a decimal string. Used by
// `repoman shell` to pass --user to incus exec so the in-container shell
// runs as the host user (matched against claude-share's UID-mapping).
// Reef stdlib doesn't expose getuid() publicly today, so we shell out to
// `id -u` and parse stdout.
fn host_uid(): rg.Result[string, string]
    let cap: CaptureResult = process_run_capture("id", ["-u"])
    if cap.exit_code != 0
        return @Result[string, string].Err("id -u exited with code " + convert.to_string(cap.exit_code))
    end if
    let trimmed: string = str.trim_ws(cap.stdout)
    if str.length(trimmed) == 0
        return @Result[string, string].Err("id -u returned empty output")
    end if
    return @Result[string, string].Ok(trimmed)
end host_uid

fn is_lower_alnum_or_hyphen(c: char): bool
    if c >= 'a' and c <= 'z'
        return true
    end if
    if c >= '0' and c <= '9'
        return true
    end if
    if c == '-'
        return true
    end if
    return false
end is_lower_alnum_or_hyphen

fn validate_name(name: string): bool
    let n: int = str.length(name)
    if n == 0
        return false
    end if
    if n > 63
        return false
    end if
    if name[0] == '-'
        return false
    end if
    mut i: int = 0
    while i < n
        if not is_lower_alnum_or_hyphen(name[i])
            return false
        end if
        i = i + 1
    end while
    return true
end validate_name

// Run `incus <args>`. Returns Ok(true) on exit 0, Err with a brief diagnostic
// on non-zero. Stderr inherits the parent terminal.
fn run_incus(args: [string]): rg.Result[bool, string]
    let pid: int = p.process_run("incus", args)
    if pid < 0
        return @Result[bool, string].Err("failed to spawn 'incus' (is it installed?)")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Err("incus exited with code " + convert.to_string(exit))
end run_incus

fn project_ensure(project: string, verbose: bool): rg.Result[bool, string]
    // `incus project show <name>` exits 0 if it exists, non-0 otherwise.
    // Quiet mode silences the YAML dump on success and the "Error:
    // Project not found" stderr on failure; verbose mode passes through.
    mut pid: int = -1
    if verbose
        pid = p.process_run("incus", ["project", "show", project])
    else
        pid = process_run_silent("incus", ["project", "show", project])
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    // Create with all features disabled so the project is a pure container
    // namespace and shares images/profiles/networks/storage with the default
    // project. Without this, profiles like 'claude-share' that live in the
    // default project would be invisible to containers in this project.
    return run_incus([
        "project", "create", project,
        "-c", "features.images=false",
        "-c", "features.profiles=false",
        "-c", "features.networks=false",
        "-c", "features.storage.volumes=false",
        "-c", "features.storage.buckets=false"
    ])
end project_ensure

fn project_present(project: string): rg.Result[bool, string]
    let pid: int = process_run_silent("incus", ["project", "show", project])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn 'incus project show'")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Ok(false)
end project_present

fn container_exists(project: string, name: string, verbose: bool): rg.Result[bool, string]
    // `incus info --project <p> <name>` exits 0 if it exists.
    // Quiet silences both info dump and error output; verbose passes through.
    mut pid: int = -1
    if verbose
        pid = p.process_run("incus", ["info", "--project", project, name])
    else
        pid = process_run_silent("incus", ["info", "--project", project, name])
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Ok(false)
end container_exists

fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
    let pn: int = profiles.length()
    // argv shape: ["launch", "--project", project, ("--profile" P)*, image, name]
    mut args: [string] = new [string](3 + 2 * pn + 2)
    args[0] = "launch"
    args[1] = "--project"
    args[2] = project
    mut i: int = 0
    while i < pn
        args[3 + i * 2] = "--profile"
        args[3 + i * 2 + 1] = profiles[i]
        i = i + 1
    end while
    args[3 + 2 * pn] = image
    args[3 + 2 * pn + 1] = name
    return run_incus(args)
end launch

fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
    return run_incus([
        "config", "device", "add",
        "--project", project,
        name,
        dev,
        "disk",
        "source=" + src,
        "path=" + dst
    ])
end device_add_disk

// Like device_add_disk but with extra options like ["shift=true",
// "readonly=true"] appended to the incus argv.
fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string]
    let on: int = opts.length()
    // Final argv: ["config", "device", "add", "--project", P, NAME, DEV, "disk",
    //             "source=...", "path=...", opts...]
    mut args: [string] = new [string](10 + on)
    args[0] = "config"
    args[1] = "device"
    args[2] = "add"
    args[3] = "--project"
    args[4] = project
    args[5] = name
    args[6] = dev
    args[7] = "disk"
    args[8] = "source=" + src
    args[9] = "path=" + dst
    mut i: int = 0
    while i < on
        args[10 + i] = opts[i]
        i = i + 1
    end while
    return run_incus(args)
end device_add_disk_opts

fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
    return run_incus([
        "config", "set",
        "--project", project,
        name,
        "environment." + key + "=" + val
    ])
end set_env_var

fn restart(project: string, name: string): rg.Result[bool, string]
    return run_incus(["restart", "--project", project, name])
end restart

fn delete_container(project: string, name: string): rg.Result[bool, string]
    return run_incus(["delete", "--project", project, "--force", name])
end delete_container

fn delete_profile(project: string, name: string): rg.Result[bool, string]
    return run_incus(["profile", "delete", "--project", project, name])
end delete_profile

// Returns Ok(true) if the named profile exists in the given project,
// Ok(false) otherwise. Errors only on subprocess failure.
fn profile_exists(project: string, name: string): rg.Result[bool, string]
    let pid: int = process_run_silent("incus", [
        "profile", "show", "--project", project, name
    ])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn 'incus profile show'")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Ok(false)
end profile_exists

// Idempotent: ensure the profile exists with the given YAML body.
// Step 1: try `incus profile create` (no-op error if exists).
// Step 2: `incus profile edit` reads stdin and replaces the profile body.
fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string]
    // Step 1: create (silent on failure — profile may already exist)
    let create_pid: int = process_run_silent("incus", [
        "profile", "create", "--project", project, name
    ])
    let _ce: int = p.process_wait(create_pid)

    // Step 2: edit with YAML on stdin
    let edit_exit: int = process_run_with_stdin("incus", [
        "profile", "edit", "--project", project, name
    ], yaml)
    if edit_exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Err("incus profile edit exited with code " + convert.to_string(edit_exit))
end profile_create_or_edit

// Returns the YAML body of the named profile (the same output as
// `incus profile show <project> <name>`). Errors if the profile doesn't
// exist or the subprocess fails.
fn profile_get(project: string, name: string): rg.Result[string, string]
    let cap: CaptureResult = process_run_capture("incus", [
        "profile", "show", "--project", project, name
    ])
    if cap.exit_code != 0
        return @Result[string, string].Err("incus profile show exited " + convert.to_string(cap.exit_code))
    end if
    return @Result[string, string].Ok(cap.stdout)
end profile_get

end module