paratix 0.12.8 → 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/README.md CHANGED
@@ -119,29 +119,37 @@ Paratix can also manage file-backed swap directly. Use `swap.file(...)` to provi
119
119
 
120
120
  ## CLI
121
121
 
122
+ `paratix --help` lists these options directly, so they are discoverable without drilling into `paratix apply --help` first.
123
+
122
124
  ```text
123
125
  paratix apply <file> [options]
124
126
 
125
127
  Options:
126
- --diff
127
- --dry-run
128
- --env <key=value>
129
- --env-file <path>
130
- --first-run
131
- --reconnect-timeout <seconds>
132
- --verbose
133
- --version
134
- --help
128
+ --diff Combined with --dry-run: unified diff per changed module
129
+ --dry-run Only check, do not apply (some runtime restarts stay unverified)
130
+ --env <key=value...> Set environment values for the playbook (repeatable)
131
+ --env-file <path> Load environment values from a dotenv file
132
+ --filter <names> Run only the named recipes/modules (comma-separated, repeatable)
133
+ --first-run Set PARATIX_FIRST_RUN=true before loading the playbook
134
+ --reconnect-timeout <seconds> SSH reconnect timeout for reboots/port changes (max 86400)
135
+ --verbose Show full stack traces on error
136
+ --help Show help
135
137
  ```
136
138
 
139
+ `--filter <names>` restricts the run to the named recipes and modules. Names are matched anywhere in the tree, and every node that is not selected is shown as `skipped` instead of being executed. The option is comma-separated and repeatable, so `--filter rybbit,palamedes-examples` and `--filter rybbit --filter palamedes-examples` are equivalent. Selecting a recipe runs its whole subtree; a recipe that is not selected but contains a selected descendant is still descended into, so only the matching children run while its siblings are skipped. If several nodes share the same name, every one of them is selected. An unknown filter name aborts the run before it connects (exit code 2). The final run summary counts only top-level nodes, so nested skipped nodes are still shown but are not added to the `skipped` tally. `--filter` composes with `--dry-run` and the other flags.
140
+
137
141
  `--first-run` is meant for explicit bootstrap flows where a fresh server must be hardened first and the rest of the system should only be applied later.
138
142
 
139
143
  `--diff` only works together with `--dry-run`. When enabled, modules that opt in print a unified diff below their status line, showing exactly which lines or values would change. Without `--diff`, the dry-run output is unchanged. Diff-producing modules: `file.copy`, `file.template`, `sysctl.set`, `hostname.set`, `swap.file`, `swap.swappiness`, `swap.vfsCachePressure`, `cron.job`, `cron.absent`, `timer.scheduled`, `timer.absent`, `net.hosts`, `quadlet.container`.
140
144
 
145
+ `--reconnect-timeout <seconds>` sets how long Paratix keeps retrying the SSH connection after a module forces the connection to drop — after a `system.reboot` or after an SSH port change. When a module reboots the host, Paratix waits a short grace period and then reconnects within a **300 second window by default**, so hosts that need a few minutes to come back (fsck, cloud-init, slow POST) still succeed. The number of attempts follows the time window rather than a fixed cap, so the window is never cut short. Port-change reconnects use a shorter 120 second default because the port comes back almost immediately. Set `--reconnect-timeout` (up to 86400 seconds) to override both paths for exceptionally slow reboots.
146
+
141
147
  ## Documentation
142
148
 
143
149
  - For project scaffolding, see [`create-paratix`](https://www.npmjs.com/package/create-paratix).
144
- - For detailed authoring guidance and module reference inside this repo, see [llm-guide.md](./llm-guide.md).
150
+ - See the complete [TypeScript API and Module Reference](./llm-guide.md).
151
+ - For common runtime and connection failures, see [Troubleshooting](../../docs/user-guide/troubleshooting.md).
152
+ - Before upgrading an existing project, see the [migration notes](../../docs/user-guide/migration.md).
145
153
 
146
154
  ## License
147
155
 
@@ -1568,13 +1568,6 @@ function buildRepositoryUpdateFlag(name, expectedContent) {
1568
1568
  flagPrefix
1569
1569
  };
1570
1570
  }
1571
- function firstNonEmptyApt(text) {
1572
- for (const line of text.split("\n")) {
1573
- const trimmed = line.trim();
1574
- if (trimmed.length > 0) return trimmed;
1575
- }
1576
- return null;
1577
- }
1578
1571
  async function probeAptRepositoryInodeIdentity(ssh2, filePath) {
1579
1572
  const result = await ssh2.exec(`stat -c '%d:%i' ${shellQuote(filePath)}`, {
1580
1573
  ignoreExitCode: true,
@@ -1615,7 +1608,7 @@ async function rollbackRepositoryAfterUpdateFailure(parameters) {
1615
1608
  const failureMessage = `[apt.repository] apt-get update failed for ${name}`;
1616
1609
  if (rollbackUpdate.code !== 0) {
1617
1610
  return failedCommand(
1618
- `${failureMessage}; rollback succeeded but apt-get update on the restored sources also failed (exit code ${String(rollbackUpdate.code)}): ${firstNonEmptyApt(rollbackUpdate.stderr) ?? firstNonEmptyApt(rollbackUpdate.stdout) ?? "no output"}`,
1611
+ `${failureMessage}; rollback succeeded but apt-get update on the restored sources also failed (exit code ${String(rollbackUpdate.code)}): ${firstNonEmptyLine(rollbackUpdate.stderr) ?? firstNonEmptyLine(rollbackUpdate.stdout) ?? "no output"}`,
1619
1612
  updateResult
1620
1613
  );
1621
1614
  }
@@ -3252,6 +3245,15 @@ async function commandCheckPassed(ssh2, check, secrets) {
3252
3245
  });
3253
3246
  return result.code === 0;
3254
3247
  }
3248
+ function commandExecOptions(secrets, options) {
3249
+ const execOptions2 = {
3250
+ ignoreExitCode: true,
3251
+ secrets,
3252
+ silent: true
3253
+ };
3254
+ if (options?.timeout !== void 0) execOptions2.timeout = options.timeout;
3255
+ return execOptions2;
3256
+ }
3255
3257
  var command = {
3256
3258
  /**
3257
3259
  * Run an arbitrary shell command on the remote host.
@@ -3271,6 +3273,7 @@ var command = {
3271
3273
  * If it exits `0`, the command is considered already done.
3272
3274
  * @param options.name - An optional display name shown in the run output.
3273
3275
  * @param options.secrets - Secret values that must be redacted from command output.
3276
+ * @param options.timeout - Maximum runtime for the command in milliseconds.
3274
3277
  * @returns A Module that executes the shell command.
3275
3278
  *
3276
3279
  * @example
@@ -3292,11 +3295,7 @@ var command = {
3292
3295
  if (options?.check != null && await commandCheckPassed(ssh2, options.check, secrets)) {
3293
3296
  return { status: "ok" };
3294
3297
  }
3295
- const result = await ssh2.exec(cmd, {
3296
- ignoreExitCode: true,
3297
- secrets,
3298
- silent: true
3299
- });
3298
+ const result = await ssh2.exec(cmd, commandExecOptions(secrets, options));
3300
3299
  if (result.code !== 0) {
3301
3300
  return failedCommand(`[${moduleName}] command failed`, {
3302
3301
  ...result,
@@ -3323,10 +3322,12 @@ var EXEC_OPTS4 = { ignoreExitCode: true, silent: true };
3323
3322
  var UNIT_NAME_PATTERN = new RegExp("^[\\w@.\\-]+$", "v");
3324
3323
  var COMPOSE_CONFIG_MODE = "0600";
3325
3324
  var COMPOSE_CONFIG_STAGING_PREFIX = ".compose.yml.paratix-staging";
3325
+ var COMPOSE_IMAGE_INSPECT_FORMAT = "{{.Id}}\\n{{range .RepoDigests}}{{.}}\\n{{end}}";
3326
3326
  var SYSTEMD_UNIT_MODE = "0644";
3327
3327
  var SYSTEMD_UNIT_STAGING_PREFIX = ".compose-systemd-unit.paratix-staging";
3328
3328
  var C0_CONTROL_MAX_CODE_POINT = 31;
3329
3329
  var DELETE_CONTROL_CODE_POINT2 = 127;
3330
+ var COMPOSE_CONFIG_JSON_MAX_BYTES = 1048576;
3330
3331
  function requireComposeSsh(ssh2, action, projectDirectory) {
3331
3332
  return ssh2 ?? failed(`[compose.${action}] SSH connection is required for ${projectDirectory}`);
3332
3333
  }
@@ -3351,10 +3352,6 @@ var COMPOSE_UP_ACTION_KEYWORDS_REGEX = new RegExp("^(?:Creating|Recreating|Start
3351
3352
  function composeUpReportedChange(composeOutput) {
3352
3353
  return COMPOSE_UP_ACTION_KEYWORDS_REGEX.test(composeOutput);
3353
3354
  }
3354
- var COMPOSE_PULL_ACTION_REGEX = new RegExp("^(?:Pulling|Downloaded)\\s", "mv");
3355
- function composePullReportedChange(composeOutput) {
3356
- return COMPOSE_PULL_ACTION_REGEX.test(composeOutput);
3357
- }
3358
3355
  function validateComposeUpServices(services) {
3359
3356
  for (const service2 of services ?? []) {
3360
3357
  if (service2 === "") {
@@ -3392,6 +3389,148 @@ function parseComposeProjectName(stdout, projectDirectory) {
3392
3389
  return null;
3393
3390
  }
3394
3391
  }
3392
+ function isRecord(value) {
3393
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3394
+ }
3395
+ function readComposeServiceImage(service2) {
3396
+ if (!isRecord(service2)) return null;
3397
+ const { image } = service2;
3398
+ return typeof image === "string" && image.trim() !== "" ? image : null;
3399
+ }
3400
+ function collectComposeConfigImages(services) {
3401
+ const images = /* @__PURE__ */ new Set();
3402
+ for (const service2 of Object.values(services)) {
3403
+ const image = readComposeServiceImage(service2);
3404
+ if (image !== null) images.add(image);
3405
+ }
3406
+ return [...images].sort();
3407
+ }
3408
+ function parseComposeConfigImages(stdout) {
3409
+ if (Buffer.byteLength(stdout, "utf8") > COMPOSE_CONFIG_JSON_MAX_BYTES) return null;
3410
+ try {
3411
+ const parsed = JSON.parse(stdout);
3412
+ if (!isRecord(parsed)) return null;
3413
+ const { services } = parsed;
3414
+ if (!isRecord(services)) return null;
3415
+ return collectComposeConfigImages(services);
3416
+ } catch {
3417
+ return null;
3418
+ }
3419
+ }
3420
+ function buildComposeImageInspectCommand(runtime, image) {
3421
+ return `${runtime} image inspect --format '${COMPOSE_IMAGE_INSPECT_FORMAT}' -- ${shellQuote(image)}`;
3422
+ }
3423
+ function getComposeImageRepository(image) {
3424
+ const digestSeparatorIndex = image.indexOf("@");
3425
+ if (digestSeparatorIndex !== -1) return image.slice(0, digestSeparatorIndex);
3426
+ const lastSlashIndex = image.lastIndexOf("/");
3427
+ const lastColonIndex = image.lastIndexOf(":");
3428
+ return lastColonIndex > lastSlashIndex ? image.slice(0, lastColonIndex) : image;
3429
+ }
3430
+ function readComposeImageIdentifierFromInspectOutput(image, output) {
3431
+ const lines = output.split("\n").filter((line) => line.length > 0);
3432
+ if (lines.length === 0) return null;
3433
+ const [id, ...repoDigests] = lines;
3434
+ const repository = getComposeImageRepository(image);
3435
+ for (const repoDigest of repoDigests) {
3436
+ const separatorIndex = repoDigest.indexOf("@");
3437
+ if (separatorIndex === -1) continue;
3438
+ const repo = repoDigest.slice(0, separatorIndex);
3439
+ const digest = repoDigest.slice(separatorIndex + 1);
3440
+ if (repo === repository && digest.length > 0) return digest;
3441
+ }
3442
+ return id.length > 0 ? id : null;
3443
+ }
3444
+ async function resolveComposeConfigImages(parameters) {
3445
+ const result = await parameters.ssh.exec(
3446
+ `${composeCommand(parameters.runtime, parameters.projectDirectory)} config --format json`,
3447
+ EXEC_OPTS4
3448
+ );
3449
+ if (result.code !== 0) {
3450
+ return failedCommand(
3451
+ `[compose.pull] failed to resolve images for ${parameters.projectDirectory}`,
3452
+ result
3453
+ );
3454
+ }
3455
+ const images = parseComposeConfigImages(result.stdout);
3456
+ if (images === null) {
3457
+ return failed(
3458
+ `[compose.pull] failed to parse compose config images for ${parameters.projectDirectory}`
3459
+ );
3460
+ }
3461
+ return images;
3462
+ }
3463
+ async function inspectComposeImageBeforePull(parameters) {
3464
+ const result = await parameters.ssh.exec(
3465
+ buildComposeImageInspectCommand(parameters.runtime, parameters.image),
3466
+ EXEC_OPTS4
3467
+ );
3468
+ if (result.code !== 0) return null;
3469
+ return readComposeImageIdentifierFromInspectOutput(parameters.image, result.stdout);
3470
+ }
3471
+ async function inspectComposeImageAfterPull(parameters) {
3472
+ const result = await parameters.ssh.exec(
3473
+ buildComposeImageInspectCommand(parameters.runtime, parameters.image),
3474
+ EXEC_OPTS4
3475
+ );
3476
+ if (result.code !== 0) {
3477
+ return failedCommand(
3478
+ `[compose.pull] image inspect failed for ${parameters.image} in ${parameters.projectDirectory}`,
3479
+ result
3480
+ );
3481
+ }
3482
+ const imageIdentifier = readComposeImageIdentifierFromInspectOutput(
3483
+ parameters.image,
3484
+ result.stdout
3485
+ );
3486
+ if (imageIdentifier === null) {
3487
+ return failed(
3488
+ `[compose.pull] image inspect returned no digest or image ID for ${parameters.image} in ${parameters.projectDirectory}`
3489
+ );
3490
+ }
3491
+ return imageIdentifier;
3492
+ }
3493
+ async function snapshotComposeImagesBeforePull(parameters) {
3494
+ return new Map(
3495
+ await Promise.all(
3496
+ parameters.images.map(
3497
+ async (image) => [
3498
+ image,
3499
+ await inspectComposeImageBeforePull({
3500
+ image,
3501
+ runtime: parameters.runtime,
3502
+ ssh: parameters.ssh
3503
+ })
3504
+ ]
3505
+ )
3506
+ )
3507
+ );
3508
+ }
3509
+ async function snapshotComposeImagesAfterPull(parameters) {
3510
+ const entries = await Promise.all(
3511
+ parameters.images.map(async (image) => ({
3512
+ image,
3513
+ imageIdentifier: await inspectComposeImageAfterPull({
3514
+ image,
3515
+ projectDirectory: parameters.projectDirectory,
3516
+ runtime: parameters.runtime,
3517
+ ssh: parameters.ssh
3518
+ })
3519
+ }))
3520
+ );
3521
+ const snapshot = /* @__PURE__ */ new Map();
3522
+ for (const { image, imageIdentifier } of entries) {
3523
+ if (typeof imageIdentifier !== "string") return imageIdentifier;
3524
+ snapshot.set(image, imageIdentifier);
3525
+ }
3526
+ return snapshot;
3527
+ }
3528
+ function composeImageSnapshotChanged(parameters) {
3529
+ for (const [image, afterIdentifier] of parameters.after) {
3530
+ if (parameters.before.get(image) !== afterIdentifier) return true;
3531
+ }
3532
+ return false;
3533
+ }
3395
3534
  async function resolveComposeProjectName(parameters) {
3396
3535
  const result = await parameters.ssh.exec(
3397
3536
  `${composeCommand(parameters.runtime, parameters.projectDirectory)} config --format json`,
@@ -4050,13 +4189,33 @@ var compose = {
4050
4189
  ssh: connection
4051
4190
  });
4052
4191
  if (typeof runtime !== "string") return runtime;
4192
+ const images = await resolveComposeConfigImages({
4193
+ projectDirectory,
4194
+ runtime,
4195
+ ssh: connection
4196
+ });
4197
+ if (!Array.isArray(images)) return images;
4198
+ const beforePull = await snapshotComposeImagesBeforePull({
4199
+ images,
4200
+ runtime,
4201
+ ssh: connection
4202
+ });
4053
4203
  const result = await connection.exec(
4054
4204
  `${composeCommand(runtime, projectDirectory)} pull 2>&1`,
4055
4205
  EXEC_OPTS4
4056
4206
  );
4057
4207
  if (result.code !== 0)
4058
4208
  return failedCommand(`[compose.pull] failed for ${projectDirectory}`, result);
4059
- return { status: composePullReportedChange(result.stdout) ? "changed" : "ok" };
4209
+ const afterPull = await snapshotComposeImagesAfterPull({
4210
+ images,
4211
+ projectDirectory,
4212
+ runtime,
4213
+ ssh: connection
4214
+ });
4215
+ if (!(afterPull instanceof Map)) return afterPull;
4216
+ return {
4217
+ status: composeImageSnapshotChanged({ after: afterPull, before: beforePull }) ? "changed" : "ok"
4218
+ };
4060
4219
  },
4061
4220
  // eslint-disable-next-line @typescript-eslint/require-await -- Interface requires async
4062
4221
  async check() {
@@ -6185,7 +6344,7 @@ function describeType(value) {
6185
6344
  function isHostKeyMode(value) {
6186
6345
  return value === "accept-new" || value === "no" || value === "yes";
6187
6346
  }
6188
- function isRecord(value) {
6347
+ function isRecord2(value) {
6189
6348
  return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
6190
6349
  }
6191
6350
  function collectOptionalBooleanErrors(object, key, errors) {
@@ -6304,7 +6463,7 @@ function collectSshConfigErrors(value) {
6304
6463
  errors.push(`Invalid property 'ssh' (expected object, got ${describeType(value)})`);
6305
6464
  return errors;
6306
6465
  }
6307
- if (!isRecord(value)) {
6466
+ if (!isRecord2(value)) {
6308
6467
  errors.push("Invalid property 'ssh' (expected object, got object)");
6309
6468
  return errors;
6310
6469
  }
@@ -6329,7 +6488,8 @@ function normalizeServerDefinitionSshError(error) {
6329
6488
  function validateSshConfig(ssh2) {
6330
6489
  const errors = collectSshConfigErrors(ssh2);
6331
6490
  if (errors.length === 0) return;
6332
- throw new Error(`ServerDefinition: ${normalizeServerDefinitionSshError(errors[0])}`);
6491
+ const message = errors.map((error) => normalizeServerDefinitionSshError(error)).join("; ");
6492
+ throw new Error(`ServerDefinition: ${message}`);
6333
6493
  }
6334
6494
 
6335
6495
  // src/meta.ts
@@ -6372,7 +6532,7 @@ function normalizeMetaValueResolver(value) {
6372
6532
  return value;
6373
6533
  };
6374
6534
  }
6375
- function isRecord2(value) {
6535
+ function isRecord3(value) {
6376
6536
  return typeof value === "object" && value !== null;
6377
6537
  }
6378
6538
  function assertValidEnvironmentMetaEntry(candidate) {
@@ -6460,7 +6620,7 @@ function isSystemRebootMetaEntry(entry) {
6460
6620
  return entry.kind === SYSTEM_REBOOT_KIND;
6461
6621
  }
6462
6622
  function assertValidModuleMetaEntry(entry) {
6463
- if (!isRecord2(entry)) {
6623
+ if (!isRecord3(entry)) {
6464
6624
  throw new TypeError(`Invalid meta entry: expected object, got ${typeof entry}`);
6465
6625
  }
6466
6626
  switch (entry.kind) {
@@ -13000,7 +13160,20 @@ async function ensureSshDirectoryAndAuthorizedKeysAreNotSymlinks(conn, parameter
13000
13160
  const keysPath = shellQuote(authorizedKeysPath);
13001
13161
  const quotedUser = shellQuote(user2);
13002
13162
  const quotedPrimaryGroup = shellQuote(primaryGroup);
13003
- const command2 = `set -e; [ ! -L ${directory} ] || { echo '.ssh must not be a symlink' >&2; exit 1; }; if [ -e ${directory} ]; then [ -d ${directory} ] || { echo '.ssh must be a directory' >&2; exit 1; }; else mkdir -p ${directory}; fi; [ -d ${directory} ] && [ ! -L ${directory} ] || { echo '.ssh must be a real directory' >&2; exit 1; }; ssh_directory_identity=$(stat -c '%d:%i' ${directory}) || exit $?; chmod 700 ${directory} && chown ${quotedUser}:${quotedPrimaryGroup} ${directory}; [ ! -L ${directory} ] && [ -d ${directory} ] || { echo '.ssh must be a real directory' >&2; exit 1; }; post_ssh_directory_identity=$(stat -c '%d:%i' ${directory}) || exit $?; [ "$post_ssh_directory_identity" = "$ssh_directory_identity" ] || { echo '.ssh directory changed during metadata update' >&2; exit 1; }; [ ! -L ${keysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }`;
13163
+ const command2 = `set -e
13164
+ [ ! -L ${directory} ] || { echo '.ssh must not be a symlink' >&2; exit 1; }
13165
+ if [ -e ${directory} ]; then
13166
+ [ -d ${directory} ] || { echo '.ssh must be a directory' >&2; exit 1; }
13167
+ else
13168
+ mkdir -p ${directory}
13169
+ fi
13170
+ [ -d ${directory} ] && [ ! -L ${directory} ] || { echo '.ssh must be a real directory' >&2; exit 1; }
13171
+ ssh_directory_identity=$(stat -c '%d:%i' ${directory}) || exit $?
13172
+ chmod 700 ${directory} && chown ${quotedUser}:${quotedPrimaryGroup} ${directory}
13173
+ [ ! -L ${directory} ] && [ -d ${directory} ] || { echo '.ssh must be a real directory' >&2; exit 1; }
13174
+ post_ssh_directory_identity=$(stat -c '%d:%i' ${directory}) || exit $?
13175
+ [ "$post_ssh_directory_identity" = "$ssh_directory_identity" ] || { echo '.ssh directory changed during metadata update' >&2; exit 1; }
13176
+ [ ! -L ${keysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }`;
13004
13177
  const result = await conn.exec(command2, MUTATION_EXEC_OPTS);
13005
13178
  if (result.code !== 0) {
13006
13179
  const label = result.stderr.includes("authorized_keys must not be a symlink") ? "authorized_keys symlink check failed" : "failed to prepare .ssh directory";
@@ -13030,10 +13203,25 @@ async function stageAuthorizedKeysContent(conn, parameters) {
13030
13203
  const filterScratchPath = `${temporaryPath}.filter`;
13031
13204
  const quotedFilterScratchPath = shellQuote(filterScratchPath);
13032
13205
  const quotedKey = shellQuote(key);
13033
- const existingAuthorizedKeysGuard = `[ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }; [ -f ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must be a regular file' >&2; exit 1; }`;
13206
+ const existingAuthorizedKeysGuard = `[ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }
13207
+ [ -f ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must be a regular file' >&2; exit 1; }`;
13034
13208
  const readExistingAuthorizedKeys = `dd if=${quotedAuthorizedKeysPath} iflag=nofollow status=none of=${quotedTemporaryPath}`;
13035
13209
  const stage = state === "present" ? await conn.exec(
13036
- `{ if [ -e ${quotedAuthorizedKeysPath} ]; then ${existingAuthorizedKeysGuard}; ${readExistingAuthorizedKeys} || exit $?; grep -qxF -- ${quotedKey} ${quotedTemporaryPath}; grep_status=$?; if [ "$grep_status" -eq 0 ]; then :; elif [ "$grep_status" -eq 1 ]; then printf '%s\\n' ${quotedKey} >> ${quotedTemporaryPath}; else exit "$grep_status"; fi; else printf '%s\\n' ${quotedKey} > ${quotedTemporaryPath}; fi; }`,
13210
+ `{ if [ -e ${quotedAuthorizedKeysPath} ]; then
13211
+ ${existingAuthorizedKeysGuard}
13212
+ ${readExistingAuthorizedKeys} || exit $?
13213
+ grep -qxF -- ${quotedKey} ${quotedTemporaryPath}
13214
+ grep_status=$?
13215
+ if [ "$grep_status" -eq 0 ]; then
13216
+ :
13217
+ elif [ "$grep_status" -eq 1 ]; then
13218
+ printf '%s\\n' ${quotedKey} >> ${quotedTemporaryPath}
13219
+ else
13220
+ exit "$grep_status"
13221
+ fi
13222
+ else
13223
+ printf '%s\\n' ${quotedKey} > ${quotedTemporaryPath}
13224
+ fi; }`,
13037
13225
  MUTATION_EXEC_OPTS
13038
13226
  ) : (
13039
13227
  // R-0000044: use `grep -vxF` (whole-line match) to mirror the present
@@ -13045,7 +13233,20 @@ async function stageAuthorizedKeysContent(conn, parameters) {
13045
13233
  // TOCTOU where the path could become a symlink between the
13046
13234
  // `[ ! -L ]` guard and grep's open() call.
13047
13235
  await conn.exec(
13048
- `{ if [ -e ${quotedAuthorizedKeysPath} ]; then ${existingAuthorizedKeysGuard}; ${readExistingAuthorizedKeys} || exit $?; grep -vxF -- ${quotedKey} ${quotedTemporaryPath} > ${quotedFilterScratchPath}; grep_status=$?; if [ "$grep_status" -eq 0 ] || [ "$grep_status" -eq 1 ]; then mv -T -- ${quotedFilterScratchPath} ${quotedTemporaryPath} || exit $?; else rm -f -- ${quotedFilterScratchPath}; exit "$grep_status"; fi; else : > ${quotedTemporaryPath}; fi; }`,
13236
+ `{ if [ -e ${quotedAuthorizedKeysPath} ]; then
13237
+ ${existingAuthorizedKeysGuard}
13238
+ ${readExistingAuthorizedKeys} || exit $?
13239
+ grep -vxF -- ${quotedKey} ${quotedTemporaryPath} > ${quotedFilterScratchPath}
13240
+ grep_status=$?
13241
+ if [ "$grep_status" -eq 0 ] || [ "$grep_status" -eq 1 ]; then
13242
+ mv -T -- ${quotedFilterScratchPath} ${quotedTemporaryPath} || exit $?
13243
+ else
13244
+ rm -f -- ${quotedFilterScratchPath}
13245
+ exit "$grep_status"
13246
+ fi
13247
+ else
13248
+ : > ${quotedTemporaryPath}
13249
+ fi; }`,
13049
13250
  MUTATION_EXEC_OPTS
13050
13251
  )
13051
13252
  );
@@ -13066,7 +13267,38 @@ async function replaceAuthorizedKeysAtomically(conn, parameters) {
13066
13267
  const expectedAuthorizedKeysState = shellQuote(`600 ${user2} ${primaryGroup} regular file`);
13067
13268
  const quotedBackupPath = shellQuote(`${authorizedKeysPath}.paratix-backup`);
13068
13269
  const replace2 = await conn.exec(
13069
- `chmod 600 ${quotedTemporaryPath} && chown ${shellQuote(user2)}:${shellQuote(primaryGroup)} ${quotedTemporaryPath} && { expected_authorized_keys_hash=$(sha256sum ${quotedTemporaryPath} | cut -d' ' -f1) || exit $?; [ ! -L ${quotedSshDirectoryPath} ] || { echo '.ssh must not be a symlink' >&2; exit 1; }; [ -d ${quotedSshDirectoryPath} ] || { echo '.ssh must be a directory' >&2; exit 1; }; ssh_directory_state=$(stat -c '%a %U %G %F' ${quotedSshDirectoryPath}) || exit $?; [ "$ssh_directory_state" = ${expectedSshDirectoryState} ] || { echo '.ssh ownership changed before authorized_keys replace' >&2; exit 1; }; [ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }; rm -f -- ${quotedBackupPath}; backup_created=0; if [ -e ${quotedAuthorizedKeysPath} ]; then [ -f ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must be a regular file' >&2; exit 1; }; [ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }; ln -P -- ${quotedAuthorizedKeysPath} ${quotedBackupPath} || { echo 'failed to create authorized_keys backup hardlink' >&2; exit 1; }; backup_created=1; fi; [ ! -L ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must not be a symlink' >&2; exit 1; }; if ! mv -T -- ${quotedTemporaryPath} ${quotedAuthorizedKeysPath}; then if [ "$backup_created" = 1 ]; then mv -T -- ${quotedBackupPath} ${quotedAuthorizedKeysPath} || echo 'authorized_keys backup restore failed; backup is at '${quotedBackupPath} >&2; fi; echo 'authorized_keys replace failed' >&2; exit 1; fi; [ ! -e ${quotedTemporaryPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys replace did not consume temporary file' >&2; exit 1; }; [ ! -L ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must not be a symlink' >&2; exit 1; }; [ -f ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must be a regular file' >&2; exit 1; }; authorized_keys_state=$(stat -c '%a %U %G %F' ${quotedAuthorizedKeysPath}) || { rm -f -- ${quotedBackupPath}; exit 1; }; [ "$authorized_keys_state" = ${expectedAuthorizedKeysState} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys metadata changed during replace' >&2; exit 1; }; authorized_keys_hash=$(sha256sum ${quotedAuthorizedKeysPath} | cut -d' ' -f1) || { rm -f -- ${quotedBackupPath}; exit 1; }; [ "$authorized_keys_hash" = "$expected_authorized_keys_hash" ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys content changed during replace' >&2; exit 1; }; rm -f -- ${quotedBackupPath}; }`,
13270
+ `chmod 600 ${quotedTemporaryPath} && chown ${shellQuote(user2)}:${shellQuote(primaryGroup)} ${quotedTemporaryPath} && {
13271
+ expected_authorized_keys_hash=$(sha256sum ${quotedTemporaryPath} | cut -d' ' -f1) || exit $?
13272
+ [ ! -L ${quotedSshDirectoryPath} ] || { echo '.ssh must not be a symlink' >&2; exit 1; }
13273
+ [ -d ${quotedSshDirectoryPath} ] || { echo '.ssh must be a directory' >&2; exit 1; }
13274
+ ssh_directory_state=$(stat -c '%a %U %G %F' ${quotedSshDirectoryPath}) || exit $?
13275
+ [ "$ssh_directory_state" = ${expectedSshDirectoryState} ] || { echo '.ssh ownership changed before authorized_keys replace' >&2; exit 1; }
13276
+ [ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }
13277
+ rm -f -- ${quotedBackupPath}
13278
+ backup_created=0
13279
+ if [ -e ${quotedAuthorizedKeysPath} ]; then
13280
+ [ -f ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must be a regular file' >&2; exit 1; }
13281
+ [ ! -L ${quotedAuthorizedKeysPath} ] || { echo 'authorized_keys must not be a symlink' >&2; exit 1; }
13282
+ ln -P -- ${quotedAuthorizedKeysPath} ${quotedBackupPath} || { echo 'failed to create authorized_keys backup hardlink' >&2; exit 1; }
13283
+ backup_created=1
13284
+ fi
13285
+ [ ! -L ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must not be a symlink' >&2; exit 1; }
13286
+ if ! mv -T -- ${quotedTemporaryPath} ${quotedAuthorizedKeysPath}; then
13287
+ if [ "$backup_created" = 1 ]; then
13288
+ mv -T -- ${quotedBackupPath} ${quotedAuthorizedKeysPath} || echo 'authorized_keys backup restore failed; backup is at '${quotedBackupPath} >&2
13289
+ fi
13290
+ echo 'authorized_keys replace failed' >&2
13291
+ exit 1
13292
+ fi
13293
+ [ ! -e ${quotedTemporaryPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys replace did not consume temporary file' >&2; exit 1; }
13294
+ [ ! -L ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must not be a symlink' >&2; exit 1; }
13295
+ [ -f ${quotedAuthorizedKeysPath} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys must be a regular file' >&2; exit 1; }
13296
+ authorized_keys_state=$(stat -c '%a %U %G %F' ${quotedAuthorizedKeysPath}) || { rm -f -- ${quotedBackupPath}; exit 1; }
13297
+ [ "$authorized_keys_state" = ${expectedAuthorizedKeysState} ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys metadata changed during replace' >&2; exit 1; }
13298
+ authorized_keys_hash=$(sha256sum ${quotedAuthorizedKeysPath} | cut -d' ' -f1) || { rm -f -- ${quotedBackupPath}; exit 1; }
13299
+ [ "$authorized_keys_hash" = "$expected_authorized_keys_hash" ] || { rm -f -- ${quotedBackupPath}; echo 'authorized_keys content changed during replace' >&2; exit 1; }
13300
+ rm -f -- ${quotedBackupPath}
13301
+ }`,
13070
13302
  MUTATION_EXEC_OPTS
13071
13303
  );
13072
13304
  if (replace2.code !== 0) {
@@ -19070,4 +19302,3 @@ export {
19070
19302
  ufw,
19071
19303
  user
19072
19304
  };
19073
- //# sourceMappingURL=chunk-M3ZQJNKM.js.map