anon-pi 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  resolveRunPlan,
80
80
  resolveRunVsStart,
81
81
  serializeMachineJson,
82
+ snapshotImageRef,
82
83
  serializeConfigJson,
83
84
  setImageWarning,
84
85
  keptContainerKey,
@@ -680,6 +681,8 @@ function runMachine(machineArgs: string[]): number {
680
681
  return machineSetImage(env, cmd.name, cmd.image);
681
682
  case 'rm':
682
683
  return machineRm(env, cmd.name, cmd.yes);
684
+ case 'snapshot':
685
+ return machineSnapshot(env, cmd.name, cmd.machine, cmd.imageTag);
683
686
  }
684
687
  } catch (e) {
685
688
  return reportAnonPiError(e);
@@ -827,6 +830,72 @@ function machineRm(env: AnonPiEnv, name: string, yes: boolean): number {
827
830
  return 0;
828
831
  }
829
832
 
833
+ /**
834
+ * `machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]`: commit a
835
+ * RUNNING jailed container into a new image and create <new-name> pinned to it.
836
+ * The user does interactive system work in a session (e.g. `sudo apt install`),
837
+ * then, WITHOUT having exited (the default `--rm` would have destroyed the
838
+ * container), preserves that exact environment as a new machine. The container
839
+ * to commit is AUTO-DETECTED from the running anon-pi containers (a picker when
840
+ * several are up); `-m <machine>` is an OPTIONAL filter, not a required source.
841
+ * podman pauses the container during commit and unpauses, so the live session
842
+ * survives. The new machine gets a FRESH home (image and home are orthogonal;
843
+ * snapshot keeps the SOFTWARE, not the conversations). Forced egress is
844
+ * untouched: commit is a local podman op, and the snapshot machine relaunches
845
+ * through the same forced-egress jail.
846
+ */
847
+ function machineSnapshot(
848
+ env: AnonPiEnv,
849
+ name: string,
850
+ machine: string | undefined,
851
+ imageTag: string | undefined,
852
+ ): number {
853
+ // Refuse to clobber an existing target machine FIRST (before netcage / any
854
+ // commit), so a name clash fails fast and never leaves an orphan image.
855
+ // Mirrors machine create.
856
+ const targetDir = machineDir(env, name);
857
+ if (existsSync(targetDir)) {
858
+ process.stderr.write(
859
+ `anon-pi: machine ${JSON.stringify(name)} already exists (${targetDir}). ` +
860
+ 'Pick a different <new-name> or `anon-pi machine rm` it first.\n',
861
+ );
862
+ return 1;
863
+ }
864
+
865
+ if (!hasNetcage()) return netcageMissing();
866
+
867
+ // Auto-detect the running anon-pi container to commit (optionally filtered by
868
+ // -m <machine>); reuses the forward/ports running-container resolution.
869
+ const target = resolveRunningContainer(machine, 'snapshot');
870
+ if (target === undefined) return 1;
871
+
872
+ const imageRef = imageTag ?? snapshotImageRef(name, new Date());
873
+ process.stderr.write(
874
+ `anon-pi: committing ${target.name} -> image ${imageRef} (pausing the container briefly)\u2026\n`,
875
+ );
876
+ const committed = spawnNetcage(['commit', target.ref, imageRef]);
877
+ if (committed !== 0) {
878
+ process.stderr.write(
879
+ `anon-pi: netcage commit failed; machine ${JSON.stringify(name)} NOT created.\n`,
880
+ );
881
+ return committed;
882
+ }
883
+
884
+ // Create the machine pinned to the committed image (its home seeds on first
885
+ // launch, exactly like `machine create`).
886
+ mkdirSync(machineHomeDir(env, name), {recursive: true});
887
+ writeFileSync(
888
+ machineJsonPath(env, name),
889
+ serializeMachineJson({image: imageRef}),
890
+ );
891
+ process.stdout.write(
892
+ `anon-pi: snapshotted ${target.name} into machine ${JSON.stringify(name)} ` +
893
+ `(image ${imageRef}) at ${targetDir}.\n` +
894
+ `Its home seeds fresh on first launch, e.g. \`anon-pi -m ${name} --shell\`.\n`,
895
+ );
896
+ return 0;
897
+ }
898
+
830
899
  // --- the destructive cleanup verbs (thin I/O over the pure resolvers) --------
831
900
  //
832
901
  // `--delete-home [<machine>]` and `--delete-project <project>` REPLACE the old
@@ -2047,11 +2116,24 @@ USAGE
2047
2116
  anon-pi machine list list machines and their images
2048
2117
  anon-pi machine set-image <name> <ref> re-pin the image (WARNS; no reseed)
2049
2118
  anon-pi machine rm <name> [--yes] delete the machine + its home
2119
+ anon-pi machine snapshot <new-name> [-m <machine>] [--image-tag <ref>]
2120
+ commit a RUNNING container into a
2121
+ new image + create <new-name>
2050
2122
 
2051
2123
  A machine is an image + a persistent host home (machines/<name>/{machine.json,home/}).
2052
2124
  The home is seeded on FIRST LAUNCH, not at create. \`set-image\` re-pins only and
2053
2125
  warns (the home was built for the old image); \`rm\` confirms on a TTY, skips with
2054
2126
  \`--yes\`, and aborts non-interactively without it.
2127
+
2128
+ \`snapshot\` captures the CURRENT filesystem of a RUNNING jailed container (e.g.
2129
+ after \`sudo apt install\`) into a new image and creates <new-name> pinned to it,
2130
+ so you can preserve an environment you built interactively WITHOUT having
2131
+ pre-decided \`--keep\`. The container is auto-detected from the running anon-pi
2132
+ containers (a picker when several are up); \`-m <machine>\` is an OPTIONAL filter,
2133
+ not a required source. The container must still be RUNNING (do not exit the
2134
+ session); podman pauses it briefly during the commit. The new machine gets a
2135
+ FRESH home (the image is the software, the home is separate). Same forced-egress
2136
+ jail on relaunch.
2055
2137
  `;
2056
2138
 
2057
2139
  // --- impure helpers ---------------------------------------------------------
@@ -2224,6 +2306,63 @@ function resolveForwardTarget(
2224
2306
  return matches.find((c) => c.ref === picked.project);
2225
2307
  }
2226
2308
 
2309
+ /**
2310
+ * Resolve the ONE running anon-pi container to act on, for `machine snapshot`.
2311
+ * The container is what matters; `machine` is an OPTIONAL narrowing filter
2312
+ * (undefined = every running anon-pi container qualifies). 0 => error (start a
2313
+ * session first), 1 => it, many => an arrow-key picker labelled by each
2314
+ * container's machine + project + name (so a cross-machine list is
2315
+ * distinguishable). Returns undefined on no-match or a cancelled pick (reason
2316
+ * printed). TTY needed only for the picker.
2317
+ */
2318
+ function resolveRunningContainer(
2319
+ machine: string | undefined,
2320
+ verb: string,
2321
+ ): ManagedContainer | undefined {
2322
+ const matches = resolveManagedMatches({
2323
+ containers: queryRunningContainers(),
2324
+ machine,
2325
+ project: undefined,
2326
+ });
2327
+ const scope =
2328
+ machine !== undefined ? ` for machine ${JSON.stringify(machine)}` : '';
2329
+ if (matches.length === 0) {
2330
+ process.stderr.write(
2331
+ `anon-pi: no running anon-pi container${scope}. ` +
2332
+ 'Start a session (e.g. `anon-pi <project>`), do your work, and ' +
2333
+ `WITHOUT exiting run \`anon-pi machine ${verb}\` from another terminal.\n`,
2334
+ );
2335
+ return undefined;
2336
+ }
2337
+ if (matches.length === 1) return matches[0];
2338
+
2339
+ if (!process.stdin.isTTY) {
2340
+ process.stderr.write(
2341
+ `anon-pi: ${matches.length} running containers${scope}; a terminal is needed ` +
2342
+ 'to pick one (or narrow with `-m <machine>`).\n',
2343
+ );
2344
+ return undefined;
2345
+ }
2346
+ const entries: MenuEntry[] = matches.map((c) => {
2347
+ const f = parseKeptKey(c.key);
2348
+ const proj = keyProject(f);
2349
+ const label = proj === '' ? '(shell)' : proj;
2350
+ return {
2351
+ kind: 'project',
2352
+ project: c.ref,
2353
+ label: `${f.machine ?? '?'} / ${label} [${c.name}]`,
2354
+ };
2355
+ });
2356
+ const picked = select(entries, {
2357
+ header: `anon-pi: pick a container to ${verb} (\u2191/\u2193 move, Enter select, Ctrl-C quit)`,
2358
+ });
2359
+ if (picked === undefined) {
2360
+ process.stderr.write('anon-pi: cancelled; nothing snapshotted.\n');
2361
+ return undefined;
2362
+ }
2363
+ return matches.find((c) => c.ref === picked.project);
2364
+ }
2365
+
2227
2366
  /**
2228
2367
  * `anon-pi forward [<project>] [--port <[hostPort:]jailPort>] [--bind <addr>]
2229
2368
  * [-m <machine>]`: open a host->jail port on a running anon-pi container. Wraps