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