paratix 0.5.0 → 0.7.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
@@ -104,6 +104,8 @@ Signals are deferred side effects such as `service.reload(...)` or `service.rest
104
104
 
105
105
  For Podman-native services, Paratix now also includes `quadlet.container(...)`. It writes a `.container` file under `/etc/containers/systemd`, reloads systemd when the content changes, and works cleanly with `service.enabled(...)` and `service.running(...)` for the generated service.
106
106
 
107
+ When you need a targeted image refresh outside the normal deploy flow, `quadlet.updateImage(...)` pulls exactly one image, reuses existing Podman registry auth on the host, optionally supports `authFile`, and only restarts the generated service when the pull actually downloaded a newer image. Changed runs now also print the new image ID in parentheses in the CLI output.
108
+
107
109
  ### Guards
108
110
 
109
111
  Paratix also supports declarative host-state guards. Use `when.packageInstalled(...)`, `when.commandExists(...)`, `when.fileExists(...)`, `when.pathExists(...)`, `when.symlinkExists(...)`, or `when.socketExists(...)` and their inverted forms to gate modules or recipes on remote host state without shell-heavy playbooks.
@@ -9,7 +9,7 @@ import {
9
9
  shellQuote,
10
10
  sshdPortMeta,
11
11
  validateMode
12
- } from "./chunk-MHPFGCEY.js";
12
+ } from "./chunk-JJRF37BP.js";
13
13
 
14
14
  // src/moduleFailure.ts
15
15
  function firstNonEmptyLine(text) {
@@ -3360,57 +3360,157 @@ var pkg = {
3360
3360
  }
3361
3361
  };
3362
3362
 
3363
- // src/modules/quadlet.ts
3363
+ // src/modules/quadletHelpers.ts
3364
3364
  var CONTAINERS_SYSTEMD_DIRECTORY = "/etc/containers/systemd";
3365
- var QUADLET_FILE_MODE = "0644";
3366
- var SYSTEMCTL = "systemctl";
3367
- var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
3365
+ var QUADLET_PULL_CHANGED_OUTPUT_PATTERNS = [
3366
+ "Copying blob",
3367
+ "Copying config",
3368
+ "Downloaded newer image",
3369
+ "Pulling fs layer",
3370
+ "Storing signatures",
3371
+ "Writing manifest"
3372
+ ];
3368
3373
  function sanitizeQuadletValue(value) {
3369
3374
  return value.replaceAll(new RegExp("[\\n\\r]", "gv"), "");
3370
3375
  }
3371
3376
  function renderQuadletLine(key, value) {
3372
3377
  return `${key}=${sanitizeQuadletValue(value)}`;
3373
3378
  }
3374
- function renderQuadletSection(name, lines) {
3375
- return [`[${name}]`, ...lines, ""].join("\n");
3376
- }
3377
- function validateQuadletName(name) {
3378
- if (!UNIT_NAME_PATTERN2.test(name)) {
3379
- throw new Error(`quadlet.container: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
3380
- }
3381
- }
3382
- function renderQuadletEnvironment(environment) {
3383
- return Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => renderQuadletLine("Environment", `${key}=${value}`));
3384
- }
3385
3379
  function renderQuadletRepeated(key, values) {
3386
3380
  return values.map((value) => renderQuadletLine(key, value));
3387
3381
  }
3388
- function maybeRenderQuadletLine(key, value, transform) {
3382
+ function renderQuadletKeyValue(key, record) {
3383
+ return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)).map(([k, v]) => renderQuadletLine(key, `${k}=${v}`));
3384
+ }
3385
+ function maybeRenderQuadletLine(key, value) {
3389
3386
  if (value == null || value === "") return null;
3390
- return renderQuadletLine(key, transform == null ? value : transform(value));
3387
+ return renderQuadletLine(key, value);
3388
+ }
3389
+ function maybeRenderQuadletBool(key, value) {
3390
+ if (value == null) return null;
3391
+ return renderQuadletLine(key, String(value));
3392
+ }
3393
+ function maybeRenderQuadletNumber(key, value) {
3394
+ if (value == null) return null;
3395
+ return renderQuadletLine(key, String(value));
3391
3396
  }
3392
3397
  function compactQuadletLines(lines) {
3393
3398
  return lines.filter((line) => line != null);
3394
3399
  }
3395
- function buildQuadletUnitSection(options) {
3396
- return renderQuadletSection("Unit", [
3397
- renderQuadletLine("Description", options.description ?? `Podman container: ${options.name}`),
3398
- "Wants=network-online.target",
3399
- "After=network-online.target"
3400
- ]);
3400
+ function renderQuadletSection(name, lines) {
3401
+ return [`[${name}]`, ...lines, ""].join("\n");
3401
3402
  }
3402
- function buildQuadletContainerLines(options) {
3403
- return compactQuadletLines([
3403
+ function buildQuadletIdentityLines(options) {
3404
+ return [
3404
3405
  renderQuadletLine("Image", options.image),
3405
3406
  maybeRenderQuadletLine("ContainerName", options.containerName),
3406
3407
  maybeRenderQuadletLine("AutoUpdate", options.autoUpdate),
3408
+ maybeRenderQuadletLine("Pull", options.pull),
3409
+ maybeRenderQuadletLine("Entrypoint", options.entrypoint?.join(" ")),
3407
3410
  maybeRenderQuadletLine("Exec", options.exec?.join(" ")),
3408
- maybeRenderQuadletLine("Restart", options.restart),
3411
+ maybeRenderQuadletLine("WorkingDir", options.workingDir),
3412
+ maybeRenderQuadletLine("User", options.user),
3413
+ maybeRenderQuadletLine("UserNS", options.userNs)
3414
+ ];
3415
+ }
3416
+ function buildQuadletNetworkLines(options) {
3417
+ return [
3418
+ maybeRenderQuadletLine("HostName", options.hostName),
3409
3419
  ...renderQuadletRepeated("Network", options.networks ?? []),
3410
- ...renderQuadletRepeated("PodmanArgs", options.podmanArgs ?? []),
3420
+ ...renderQuadletRepeated("DNS", options.dns ?? []),
3421
+ ...renderQuadletRepeated("DNSOption", options.dnsOption ?? []),
3422
+ ...renderQuadletRepeated("DNSSearch", options.dnsSearch ?? []),
3423
+ maybeRenderQuadletLine("IP", options.ip),
3424
+ maybeRenderQuadletLine("IP6", options.ip6)
3425
+ ];
3426
+ }
3427
+ function buildQuadletSecurityLines(options) {
3428
+ return [
3429
+ ...renderQuadletRepeated("AddCapability", options.addCapability ?? []),
3430
+ ...renderQuadletRepeated("DropCapability", options.dropCapability ?? []),
3431
+ maybeRenderQuadletBool("SecurityLabelDisable", options.securityLabelDisable),
3432
+ maybeRenderQuadletLine("SecurityLabelType", options.securityLabelType),
3433
+ maybeRenderQuadletLine("SeccompProfile", options.seccompProfile),
3434
+ maybeRenderQuadletBool("NoNewPrivileges", options.noNewPrivileges),
3435
+ maybeRenderQuadletBool("ReadOnly", options.readOnly)
3436
+ ];
3437
+ }
3438
+ function buildQuadletRuntimeLines(options) {
3439
+ return [
3440
+ maybeRenderQuadletBool("Notify", options.notify),
3441
+ maybeRenderQuadletBool("RunInit", options.runInit),
3442
+ maybeRenderQuadletLine("LogDriver", options.logDriver),
3443
+ maybeRenderQuadletLine("Timezone", options.timezone),
3444
+ maybeRenderQuadletNumber("StopTimeout", options.stopTimeout)
3445
+ ];
3446
+ }
3447
+ function buildQuadletStorageLines(options) {
3448
+ return [
3411
3449
  ...renderQuadletRepeated("PublishPort", options.publishPorts ?? []),
3450
+ ...renderQuadletRepeated("ExposeHostPort", options.exposeHostPort ?? []),
3412
3451
  ...renderQuadletRepeated("Volume", options.volumes ?? []),
3413
- ...renderQuadletEnvironment(options.environment ?? {})
3452
+ ...renderQuadletRepeated("Mount", options.mount ?? []),
3453
+ ...renderQuadletRepeated("Tmpfs", options.tmpfs ?? []),
3454
+ ...renderQuadletRepeated("AddDevice", options.addDevice ?? [])
3455
+ ];
3456
+ }
3457
+ function buildQuadletMetadataLines(options) {
3458
+ return [
3459
+ ...renderQuadletRepeated("Secret", options.secret ?? []),
3460
+ ...renderQuadletEnvironment(options.environment ?? {}),
3461
+ ...renderQuadletRepeated("EnvironmentFile", options.environmentFiles ?? []),
3462
+ ...renderQuadletKeyValue("Label", options.label ?? {}),
3463
+ ...renderQuadletKeyValue("Annotation", options.annotation ?? {})
3464
+ ];
3465
+ }
3466
+ function renderQuadletEnvironment(environment) {
3467
+ return Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => renderQuadletLine("Environment", `${key}=${value}`));
3468
+ }
3469
+ function buildQuadletTuningLines(options) {
3470
+ return [
3471
+ ...renderQuadletKeyValue("Sysctl", options.sysctl ?? {}),
3472
+ ...renderQuadletRepeated("Ulimit", options.ulimit ?? []),
3473
+ ...renderQuadletRepeated("GroupAdd", options.groupAdd ?? []),
3474
+ ...renderQuadletRepeated("Mask", options.mask ?? []),
3475
+ ...renderQuadletRepeated("Unmask", options.unmask ?? [])
3476
+ ];
3477
+ }
3478
+ function buildQuadletHealthcheckLines(options) {
3479
+ if (options.healthCmd == null) return [];
3480
+ return compactQuadletLines([
3481
+ renderQuadletLine("HealthCmd", options.healthCmd),
3482
+ maybeRenderQuadletLine("HealthInterval", options.healthInterval),
3483
+ maybeRenderQuadletLine("HealthTimeout", options.healthTimeout),
3484
+ maybeRenderQuadletNumber("HealthRetries", options.healthRetries),
3485
+ maybeRenderQuadletLine("HealthStartPeriod", options.healthStartPeriod),
3486
+ maybeRenderQuadletLine("HealthOnFailure", options.healthOnFailure)
3487
+ ]);
3488
+ }
3489
+ function buildQuadletContainerLines(options) {
3490
+ return compactQuadletLines([
3491
+ ...buildQuadletIdentityLines(options),
3492
+ ...buildQuadletNetworkLines(options),
3493
+ ...buildQuadletSecurityLines(options),
3494
+ ...buildQuadletRuntimeLines(options),
3495
+ ...renderQuadletRepeated("PodmanArgs", options.podmanArgs ?? []),
3496
+ ...buildQuadletStorageLines(options),
3497
+ ...buildQuadletMetadataLines(options),
3498
+ ...buildQuadletTuningLines(options),
3499
+ ...buildQuadletHealthcheckLines(options)
3500
+ ]);
3501
+ }
3502
+ function buildQuadletServiceLines(options) {
3503
+ return compactQuadletLines([
3504
+ maybeRenderQuadletLine("Restart", options.restart),
3505
+ maybeRenderQuadletNumber("TimeoutStartSec", options.timeoutStartSec),
3506
+ maybeRenderQuadletNumber("TimeoutStopSec", options.timeoutStopSec)
3507
+ ]);
3508
+ }
3509
+ function buildQuadletUnitSection(options) {
3510
+ return renderQuadletSection("Unit", [
3511
+ renderQuadletLine("Description", options.description ?? `Podman container: ${options.name}`),
3512
+ "Wants=network-online.target",
3513
+ "After=network-online.target"
3414
3514
  ]);
3415
3515
  }
3416
3516
  function buildQuadletInstallSection(options) {
@@ -3418,15 +3518,59 @@ function buildQuadletInstallSection(options) {
3418
3518
  renderQuadletLine("WantedBy", options.wantedBy ?? "multi-user.target")
3419
3519
  ]);
3420
3520
  }
3521
+ function buildQuadletImagePullCommand(options) {
3522
+ const authFileFlag = options.authFile == null ? "" : ` --authfile ${shellQuoteForQuadlet(options.authFile)}`;
3523
+ return `podman pull${authFileFlag} ${shellQuoteForQuadlet(options.image)} 2>&1`;
3524
+ }
3525
+ function buildQuadletImageInspectCommand(image) {
3526
+ return `podman image inspect --format '{{.Id}}' ${shellQuoteForQuadlet(image)}`;
3527
+ }
3528
+ function formatQuadletImageIdDetail(imageId) {
3529
+ return `(${imageId})`;
3530
+ }
3531
+ function getQuadletContainerFilePath(name) {
3532
+ return `${CONTAINERS_SYSTEMD_DIRECTORY}/${name}.container`;
3533
+ }
3534
+ function getQuadletContainerServiceName(options) {
3535
+ return options.serviceName ?? options.name;
3536
+ }
3537
+ function quadletPullOutputIndicatesChange(output) {
3538
+ return QUADLET_PULL_CHANGED_OUTPUT_PATTERNS.some((pattern) => output.includes(pattern));
3539
+ }
3540
+ function readQuadletImageIdFromInspectOutput(output) {
3541
+ for (const line of output.split("\n")) {
3542
+ const trimmed = line.trim();
3543
+ if (trimmed.length > 0) return trimmed;
3544
+ }
3545
+ return null;
3546
+ }
3547
+ var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
3548
+ function validateQuadletName(name) {
3549
+ if (!UNIT_NAME_PATTERN2.test(name)) {
3550
+ throw new Error(`quadlet: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
3551
+ }
3552
+ }
3553
+ function shellQuoteForQuadlet(value) {
3554
+ const escapedQuote = "'\\''";
3555
+ return `'${value.replaceAll("'", escapedQuote)}'`;
3556
+ }
3557
+
3558
+ // src/modules/quadlet.ts
3559
+ var CONTAINERS_SYSTEMD_DIRECTORY_COMMAND = "mkdir -p '/etc/containers/systemd'";
3560
+ var QUADLET_FILE_MODE = "0644";
3561
+ var SYSTEMCTL = "systemctl";
3421
3562
  function generateContainerQuadlet(options) {
3422
- return [
3563
+ const serviceLines = buildQuadletServiceLines(options);
3564
+ const sections = [
3423
3565
  buildQuadletUnitSection(options),
3424
3566
  renderQuadletSection("Container", buildQuadletContainerLines(options)),
3567
+ ...serviceLines.length > 0 ? [renderQuadletSection("Service", serviceLines)] : [],
3425
3568
  buildQuadletInstallSection(options)
3426
- ].join("\n").trimEnd();
3569
+ ];
3570
+ return sections.join("\n").trimEnd();
3427
3571
  }
3428
3572
  async function createQuadletDirectory(ssh2) {
3429
- return ssh2.exec(`mkdir -p ${shellQuote(CONTAINERS_SYSTEMD_DIRECTORY)}`, {
3573
+ return ssh2.exec(CONTAINERS_SYSTEMD_DIRECTORY_COMMAND, {
3430
3574
  ignoreExitCode: true,
3431
3575
  silent: true
3432
3576
  });
@@ -3457,6 +3601,58 @@ async function checkQuadletFile(parameters) {
3457
3601
  const remoteContent = await parameters.ssh.readFile(parameters.filePath);
3458
3602
  return remoteContent.trim() === parameters.content.trim() ? "ok" : NEEDS_APPLY;
3459
3603
  }
3604
+ async function inspectQuadletImageId(parameters) {
3605
+ const inspectResult = await parameters.ssh.exec(parameters.inspectCommand, {
3606
+ ignoreExitCode: true,
3607
+ silent: true
3608
+ });
3609
+ if (inspectResult.code !== 0) {
3610
+ return failedCommand(
3611
+ `[quadlet.updateImage: ${parameters.name}] podman image inspect failed`,
3612
+ inspectResult
3613
+ );
3614
+ }
3615
+ const imageId = readQuadletImageIdFromInspectOutput(inspectResult.stdout);
3616
+ if (imageId == null) {
3617
+ return failed(
3618
+ `[quadlet.updateImage: ${parameters.name}] podman image inspect returned no image ID`
3619
+ );
3620
+ }
3621
+ return imageId;
3622
+ }
3623
+ async function restartQuadletService(parameters) {
3624
+ const restartResult = await parameters.ssh.exec(
3625
+ `${SYSTEMCTL} restart ${shellQuote(parameters.serviceName)}`,
3626
+ {
3627
+ ignoreExitCode: true,
3628
+ silent: true
3629
+ }
3630
+ );
3631
+ return restartResult.code === 0 ? { detail: formatQuadletImageIdDetail(parameters.imageId), status: "changed" } : failedCommand(
3632
+ `[quadlet.updateImage: ${parameters.name}] systemctl restart failed`,
3633
+ restartResult
3634
+ );
3635
+ }
3636
+ async function applyQuadletImageUpdate(parameters) {
3637
+ const pullResult = await parameters.ssh.exec(parameters.pullCommand, {
3638
+ ignoreExitCode: true,
3639
+ silent: true
3640
+ });
3641
+ if (pullResult.code !== 0) {
3642
+ return failedCommand(`[quadlet.updateImage: ${parameters.name}] podman pull failed`, pullResult);
3643
+ }
3644
+ if (!quadletPullOutputIndicatesChange(pullResult.stdout)) {
3645
+ return { status: "ok" };
3646
+ }
3647
+ const imageId = await inspectQuadletImageId(parameters);
3648
+ if (typeof imageId !== "string") return imageId;
3649
+ return restartQuadletService({
3650
+ imageId,
3651
+ name: parameters.name,
3652
+ serviceName: parameters.serviceName,
3653
+ ssh: parameters.ssh
3654
+ });
3655
+ }
3460
3656
  var quadlet = {
3461
3657
  /**
3462
3658
  * Write a Podman Quadlet `.container` definition and reload systemd when it changes.
@@ -3465,24 +3661,11 @@ var quadlet = {
3465
3661
  * and `service.running(name)`.
3466
3662
  *
3467
3663
  * @param options - Configuration for the Quadlet container definition.
3468
- * @param options.name - Quadlet base name without `.container`.
3469
- * @param options.image - Container image reference.
3470
- * @param options.description - Optional systemd unit description.
3471
- * @param options.containerName - Optional explicit Podman container name.
3472
- * @param options.autoUpdate - Optional Podman auto-update policy.
3473
- * @param options.environment - Optional environment variables.
3474
- * @param options.exec - Optional command and arguments for `Exec=`.
3475
- * @param options.networks - Optional `Network=` entries.
3476
- * @param options.podmanArgs - Optional `PodmanArgs=` entries.
3477
- * @param options.publishPorts - Optional `PublishPort=` entries.
3478
- * @param options.restart - Optional `Restart=` policy in the `[Container]` section.
3479
- * @param options.volumes - Optional `Volume=` entries.
3480
- * @param options.wantedBy - Optional install target. Defaults to `multi-user.target`.
3481
3664
  * @returns A Module that ensures the Quadlet file is present and up to date.
3482
3665
  */
3483
3666
  container(options) {
3484
3667
  validateQuadletName(options.name);
3485
- const filePath = `${CONTAINERS_SYSTEMD_DIRECTORY}/${options.name}.container`;
3668
+ const filePath = getQuadletContainerFilePath(options.name);
3486
3669
  const content = generateContainerQuadlet(options);
3487
3670
  return {
3488
3671
  async apply(ssh2) {
@@ -3495,6 +3678,40 @@ var quadlet = {
3495
3678
  },
3496
3679
  name: `quadlet.container: ${options.name}`
3497
3680
  };
3681
+ },
3682
+ /**
3683
+ * Pull the latest image for a Quadlet-managed container and restart the service
3684
+ * only when the image changed.
3685
+ *
3686
+ * Accepts the same `name` and `image` fields as `quadlet.container(...)`, so a
3687
+ * shared config object can drive both deployment and targeted image refreshes.
3688
+ *
3689
+ * @param options - Image pull and restart configuration for the Quadlet service.
3690
+ * @returns A Module that updates the image and conditionally restarts the service.
3691
+ */
3692
+ updateImage(options) {
3693
+ validateQuadletName(options.name);
3694
+ if (options.serviceName != null) validateQuadletName(options.serviceName);
3695
+ const pullCommand = buildQuadletImagePullCommand(options);
3696
+ const inspectCommand = buildQuadletImageInspectCommand(options.image);
3697
+ const serviceName = getQuadletContainerServiceName(options);
3698
+ return {
3699
+ async apply(ssh2) {
3700
+ if (!ssh2) return failed(`[quadlet.updateImage: ${options.name}] SSH connection is required`);
3701
+ return applyQuadletImageUpdate({
3702
+ inspectCommand,
3703
+ name: options.name,
3704
+ pullCommand,
3705
+ serviceName,
3706
+ ssh: ssh2
3707
+ });
3708
+ },
3709
+ // eslint-disable-next-line @typescript-eslint/require-await -- Signal-style module
3710
+ async check() {
3711
+ return NEEDS_APPLY;
3712
+ },
3713
+ name: `quadlet.updateImage: ${options.name}`
3714
+ };
3498
3715
  }
3499
3716
  };
3500
3717
 
@@ -5577,4 +5794,4 @@ export {
5577
5794
  ufw,
5578
5795
  user
5579
5796
  };
5580
- //# sourceMappingURL=chunk-LI47NIKN.js.map
5797
+ //# sourceMappingURL=chunk-D4CS2GCH.js.map