paratix 0.4.0 → 0.6.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.
@@ -9,7 +9,7 @@ import {
9
9
  shellQuote,
10
10
  sshdPortMeta,
11
11
  validateMode
12
- } from "./chunk-G3BMCQKU.js";
12
+ } from "./chunk-JJRF37BP.js";
13
13
 
14
14
  // src/moduleFailure.ts
15
15
  function firstNonEmptyLine(text) {
@@ -676,11 +676,111 @@ function parseContainerStates(stdout) {
676
676
  function sanitizeUnitValue(value) {
677
677
  return value.replaceAll(new RegExp("[\\n\\r]", "gv"), "");
678
678
  }
679
- function generateSystemdUnit(projectDirectory, name, runtime) {
679
+ function validateGeneratedSystemdUnitContent(content, unitFileName) {
680
+ if (content.trim() === "") {
681
+ return failed(`[compose.systemd] generated empty unit content for ${unitFileName}`);
682
+ }
683
+ if (!content.includes("[Unit]") || !content.includes("[Service]")) {
684
+ return failed(`[compose.systemd] generated invalid unit content for ${unitFileName}`);
685
+ }
686
+ return null;
687
+ }
688
+ async function verifyNonEmptySystemdUnit(parameters) {
689
+ const writtenContent = await parameters.connection.readFile(parameters.filePath);
690
+ if (writtenContent.trim() === "") return "empty";
691
+ return writtenContent.trim() === parameters.content.trim() ? "matches" : "unexpected";
692
+ }
693
+ async function cleanupComposeSystemdTarget(parameters) {
694
+ await parameters.connection.exec(`rm -f ${shellQuote(parameters.filePath)}`, {
695
+ ignoreExitCode: true,
696
+ silent: true
697
+ });
698
+ }
699
+ async function rewriteComposeSystemdUnitViaShell(parameters) {
700
+ const encodedContent = Buffer.from(parameters.content, "utf8").toString("base64");
701
+ await cleanupComposeSystemdTarget(parameters);
702
+ const result = await parameters.connection.exec(
703
+ `printf '%s' ${shellQuote(encodedContent)} | base64 -d > ${shellQuote(parameters.filePath)} && chmod ${shellQuote(SYSTEMD_UNIT_MODE)} ${shellQuote(parameters.filePath)} && chown ${shellQuote("root:root")} ${shellQuote(parameters.filePath)}`,
704
+ EXEC_OPTS2
705
+ );
706
+ if (result.code !== 0) {
707
+ return failedCommand(
708
+ `[compose.systemd] shell fallback write failed for ${parameters.unitFileName}`,
709
+ result
710
+ );
711
+ }
712
+ const fallbackVerification = await verifyNonEmptySystemdUnit(parameters);
713
+ if (fallbackVerification === "matches") return null;
714
+ await cleanupComposeSystemdTarget(parameters);
715
+ if (fallbackVerification === "empty") {
716
+ return failed(
717
+ `[compose.systemd] wrote empty unit file for ${parameters.unitFileName} even after shell fallback`
718
+ );
719
+ }
720
+ return failed(
721
+ `[compose.systemd] wrote unexpected unit content for ${parameters.unitFileName} even after shell fallback`
722
+ );
723
+ }
724
+ async function applyComposeSystemdUnit(parameters) {
725
+ await prepareComposeSystemdTarget(parameters);
726
+ try {
727
+ await parameters.connection.writeFile(parameters.filePath, parameters.content, {
728
+ mode: SYSTEMD_UNIT_MODE
729
+ });
730
+ } catch (error) {
731
+ const reason = error instanceof Error ? error.message : String(error);
732
+ return failed(`[compose.systemd] atomic write failed for ${parameters.unitFileName}: ${reason}`);
733
+ }
734
+ const writeVerification = await verifyNonEmptySystemdUnit(parameters);
735
+ if (writeVerification !== "matches") {
736
+ const fallbackFailure = await rewriteComposeSystemdUnitViaShell(parameters);
737
+ if (fallbackFailure != null) return fallbackFailure;
738
+ }
739
+ const result = await parameters.connection.exec("systemctl daemon-reload", EXEC_OPTS2);
740
+ return result.code === 0 ? { status: "changed" } : failedCommand(`[compose.systemd] daemon-reload failed for ${parameters.unitFileName}`, result);
741
+ }
742
+ async function checkComposeSystemdUnit(parameters) {
743
+ const runtime = await getRuntime(parameters.ssh, parameters.explicitRuntime);
744
+ if (!runtime) return NEEDS_APPLY;
745
+ const exists = await parameters.ssh.exists(parameters.filePath);
746
+ if (!exists) return NEEDS_APPLY;
747
+ const content = generateSystemdUnit(parameters.projectDirectory, parameters.serviceName, {
748
+ detached: parameters.detached,
749
+ runtime
750
+ });
751
+ const remoteContent = await parameters.ssh.readFile(parameters.filePath);
752
+ return remoteContent.trim() === content.trim() ? "ok" : NEEDS_APPLY;
753
+ }
754
+ function resolveComposeSystemdIdentity(options) {
755
+ const serviceName = options.name ?? `compose-${basename(options.projectDirectory)}`;
756
+ if (!UNIT_NAME_PATTERN.test(serviceName)) {
757
+ throw new Error(
758
+ `compose.systemd: name must match ${String(UNIT_NAME_PATTERN)}, got: ${serviceName}`
759
+ );
760
+ }
761
+ const unitFileName = `${serviceName}.service`;
762
+ return {
763
+ filePath: `/etc/systemd/system/${unitFileName}`,
764
+ serviceName,
765
+ unitFileName
766
+ };
767
+ }
768
+ async function prepareComposeSystemdTarget(parameters) {
769
+ await parameters.connection.exec(`systemctl unmask ${shellQuote(parameters.unitFileName)}`, {
770
+ ignoreExitCode: true,
771
+ silent: true
772
+ });
773
+ await parameters.connection.exec(`rm -f ${shellQuote(parameters.filePath)}`, {
774
+ ignoreExitCode: true,
775
+ silent: true
776
+ });
777
+ }
778
+ function generateSystemdUnit(projectDirectory, name, options) {
680
779
  const safeName = sanitizeUnitValue(name);
681
780
  const safeDirectory = sanitizeUnitValue(projectDirectory);
682
- const lines = ["[Unit]", `Description=Compose stack: ${safeName}`];
683
- if (runtime === "docker") {
781
+ const composeUpCommand = options.detached ? `/usr/bin/env ${options.runtime} compose up -d --remove-orphans` : `/usr/bin/env ${options.runtime} compose up --remove-orphans`;
782
+ const lines = ["[Unit]", `Description=Compose stack: ${safeName}`, "Wants=network-online.target"];
783
+ if (options.runtime === "docker") {
684
784
  lines.push("After=network-online.target docker.service");
685
785
  lines.push("Requires=docker.service");
686
786
  } else {
@@ -692,8 +792,11 @@ function generateSystemdUnit(projectDirectory, name, runtime) {
692
792
  "Type=oneshot",
693
793
  "RemainAfterExit=yes",
694
794
  `WorkingDirectory=${safeDirectory}`,
695
- `ExecStart=/usr/bin/env ${runtime} compose up -d`,
696
- `ExecStop=/usr/bin/env ${runtime} compose down`,
795
+ `ExecStart=${composeUpCommand}`,
796
+ `ExecStop=/usr/bin/env ${options.runtime} compose down`,
797
+ "TimeoutStartSec=0",
798
+ "StandardOutput=journal",
799
+ "StandardError=journal",
697
800
  "",
698
801
  "[Install]",
699
802
  "WantedBy=multi-user.target",
@@ -888,6 +991,7 @@ var compose = {
888
991
  * explicitly provided.
889
992
  *
890
993
  * @param options - Configuration for the systemd unit.
994
+ * @param options.detached - When true, use `compose up -d`; otherwise start attached.
891
995
  * @param options.projectDirectory - The project directory on the remote host.
892
996
  * @param options.name - Optional service name (without `.service` suffix).
893
997
  * @param options.runtime - Explicit container runtime override.
@@ -895,14 +999,11 @@ var compose = {
895
999
  */
896
1000
  systemd(options) {
897
1001
  const { projectDirectory, runtime: explicitRuntime } = options;
898
- const serviceName = options.name ?? `compose-${basename(projectDirectory)}`;
899
- if (!UNIT_NAME_PATTERN.test(serviceName)) {
900
- throw new Error(
901
- `compose.systemd: name must match ${String(UNIT_NAME_PATTERN)}, got: ${serviceName}`
902
- );
903
- }
904
- const unitFileName = `${serviceName}.service`;
905
- const filePath = `/etc/systemd/system/${unitFileName}`;
1002
+ const detached = options.detached ?? false;
1003
+ const { filePath, serviceName, unitFileName } = resolveComposeSystemdIdentity({
1004
+ name: options.name,
1005
+ projectDirectory
1006
+ });
906
1007
  return {
907
1008
  async apply(ssh2) {
908
1009
  const connection = requireComposeSsh(ssh2, "systemd", projectDirectory);
@@ -914,20 +1015,26 @@ var compose = {
914
1015
  ssh: connection
915
1016
  });
916
1017
  if (typeof runtime !== "string") return runtime;
917
- const content = generateSystemdUnit(projectDirectory, serviceName, runtime);
918
- await connection.writeFile(filePath, content, { mode: SYSTEMD_UNIT_MODE });
919
- const result = await connection.exec("systemctl daemon-reload", EXEC_OPTS2);
920
- return result.code === 0 ? { status: "changed" } : failedCommand(`[compose.systemd] daemon-reload failed for ${unitFileName}`, result);
1018
+ const content = generateSystemdUnit(projectDirectory, serviceName, { detached, runtime });
1019
+ const validationFailure = validateGeneratedSystemdUnitContent(content, unitFileName);
1020
+ if (validationFailure != null) return validationFailure;
1021
+ return applyComposeSystemdUnit({
1022
+ connection,
1023
+ content,
1024
+ filePath,
1025
+ unitFileName
1026
+ });
921
1027
  },
922
1028
  async check(ssh2) {
923
1029
  if (!ssh2) return NEEDS_APPLY;
924
- const rt = await getRuntime(ssh2, explicitRuntime);
925
- if (!rt) return NEEDS_APPLY;
926
- const exists = await ssh2.exists(filePath);
927
- if (!exists) return NEEDS_APPLY;
928
- const content = generateSystemdUnit(projectDirectory, serviceName, rt);
929
- const remoteContent = await ssh2.readFile(filePath);
930
- return remoteContent.trim() === content.trim() ? "ok" : NEEDS_APPLY;
1030
+ return checkComposeSystemdUnit({
1031
+ detached,
1032
+ explicitRuntime,
1033
+ filePath,
1034
+ projectDirectory,
1035
+ serviceName,
1036
+ ssh: ssh2
1037
+ });
931
1038
  },
932
1039
  name: `compose.systemd: ${unitFileName}`
933
1040
  };
@@ -1755,7 +1862,7 @@ function stat(remotePath) {
1755
1862
  };
1756
1863
  }
1757
1864
 
1758
- // src/modules/file.ts
1865
+ // src/modules/fileMetadataHelpers.ts
1759
1866
  var DEFAULT_FILE_WRITE_MODE2 = "0644";
1760
1867
  async function readOwnership(ssh2, remotePath) {
1761
1868
  const raw = await ssh2.output(`stat -c '%a %U %G' ${shellQuote(remotePath)}`);
@@ -1794,9 +1901,39 @@ function ownershipMatches(current, options) {
1794
1901
  if (expectsGroup && current.group !== expectedGroup) return false;
1795
1902
  return true;
1796
1903
  }
1904
+ function createMetadataModule(kind, remotePath, value) {
1905
+ const name = `file.${kind}: ${remotePath}`;
1906
+ return {
1907
+ async apply(ssh2) {
1908
+ if (!ssh2) return failed(`[${name}] SSH connection is required`);
1909
+ if (kind === "chmod") validateMode(value);
1910
+ await ssh2.exec(`${kind} ${shellQuote(value)} ${shellQuote(remotePath)}`, { silent: true });
1911
+ return { status: "changed" };
1912
+ },
1913
+ async check(ssh2) {
1914
+ if (!ssh2) return NEEDS_APPLY;
1915
+ if (!await ssh2.exists(remotePath)) return NEEDS_APPLY;
1916
+ const ownership = await readOwnership(ssh2, remotePath);
1917
+ const matches = kind === "chmod" ? { mode: value } : { owner: value };
1918
+ return ownershipMatches(ownership, matches) ? "ok" : NEEDS_APPLY;
1919
+ },
1920
+ name
1921
+ };
1922
+ }
1923
+
1924
+ // src/modules/file.ts
1797
1925
  function splitLines(content) {
1798
1926
  return content.split(new RegExp("\\r?\\n", "v"));
1799
1927
  }
1928
+ function findFirstMatchingLineIndex(lines, pattern) {
1929
+ return lines.findIndex((candidateLine) => pattern.test(candidateLine));
1930
+ }
1931
+ function splitLinesPreservingTrailingNewline(content) {
1932
+ const hasTrailingNewline = new RegExp("\\r?\\n$", "v").test(content);
1933
+ const lines = splitLines(content);
1934
+ if (hasTrailingNewline && lines.at(-1) === "") lines.pop();
1935
+ return { hasTrailingNewline, lines };
1936
+ }
1800
1937
  function validateAbsentPath(remotePath) {
1801
1938
  const trimmedPath = remotePath.trim();
1802
1939
  if (trimmedPath.length === 0) {
@@ -1836,6 +1973,12 @@ var file = {
1836
1973
  },
1837
1974
  assemble,
1838
1975
  block,
1976
+ chmod(remotePath, mode) {
1977
+ return createMetadataModule("chmod", remotePath, mode);
1978
+ },
1979
+ chown(remotePath, owner) {
1980
+ return createMetadataModule("chown", remotePath, owner);
1981
+ },
1839
1982
  /**
1840
1983
  * Upload a local file to the remote host.
1841
1984
  * The file is only transferred when the remote SHA-256 differs from the local one.
@@ -1936,13 +2079,17 @@ var file = {
1936
2079
  });
1937
2080
  } else {
1938
2081
  const content = await ssh2.readFile(remotePath);
2082
+ const { hasTrailingNewline, lines } = splitLinesPreservingTrailingNewline(content);
1939
2083
  const pattern = new RegExp(options.match, "mu");
1940
- if (!pattern.test(content)) {
2084
+ const matchingLineIndex = findFirstMatchingLineIndex(lines, pattern);
2085
+ if (matchingLineIndex === -1) {
1941
2086
  return failed(
1942
2087
  `[file.line: ${remotePath}] No line matching ${options.match} found for replacement`
1943
2088
  );
1944
2089
  }
1945
- const newContent = content.replace(pattern, line);
2090
+ lines[matchingLineIndex] = line;
2091
+ let newContent = lines.join("\n");
2092
+ if (hasTrailingNewline) newContent += "\n";
1946
2093
  const ownership = await readOwnership(ssh2, remotePath);
1947
2094
  await guardedWriteFile(ssh2, {
1948
2095
  mode: normalizeMode2(ownership.mode),
@@ -1961,7 +2108,8 @@ var file = {
1961
2108
  const lines = splitLines(content);
1962
2109
  if (options?.match != null) {
1963
2110
  const matchPattern = new RegExp(options.match, "mu");
1964
- const matchedLine = lines.find((candidateLine) => matchPattern.test(candidateLine));
2111
+ const matchedLineIndex = findFirstMatchingLineIndex(lines, matchPattern);
2112
+ const matchedLine = matchedLineIndex === -1 ? void 0 : lines[matchedLineIndex];
1965
2113
  return matchedLine === line ? "ok" : NEEDS_APPLY;
1966
2114
  }
1967
2115
  return lines.includes(line) ? "ok" : NEEDS_APPLY;
@@ -3212,6 +3360,309 @@ var pkg = {
3212
3360
  }
3213
3361
  };
3214
3362
 
3363
+ // src/modules/quadletHelpers.ts
3364
+ var CONTAINERS_SYSTEMD_DIRECTORY = "/etc/containers/systemd";
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
+ ];
3373
+ function sanitizeQuadletValue(value) {
3374
+ return value.replaceAll(new RegExp("[\\n\\r]", "gv"), "");
3375
+ }
3376
+ function renderQuadletLine(key, value) {
3377
+ return `${key}=${sanitizeQuadletValue(value)}`;
3378
+ }
3379
+ function renderQuadletRepeated(key, values) {
3380
+ return values.map((value) => renderQuadletLine(key, value));
3381
+ }
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) {
3386
+ if (value == null || value === "") return null;
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));
3396
+ }
3397
+ function compactQuadletLines(lines) {
3398
+ return lines.filter((line) => line != null);
3399
+ }
3400
+ function renderQuadletSection(name, lines) {
3401
+ return [`[${name}]`, ...lines, ""].join("\n");
3402
+ }
3403
+ function buildQuadletIdentityLines(options) {
3404
+ return [
3405
+ renderQuadletLine("Image", options.image),
3406
+ maybeRenderQuadletLine("ContainerName", options.containerName),
3407
+ maybeRenderQuadletLine("AutoUpdate", options.autoUpdate),
3408
+ maybeRenderQuadletLine("Pull", options.pull),
3409
+ maybeRenderQuadletLine("Entrypoint", options.entrypoint?.join(" ")),
3410
+ maybeRenderQuadletLine("Exec", options.exec?.join(" ")),
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),
3419
+ ...renderQuadletRepeated("Network", options.networks ?? []),
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 [
3449
+ ...renderQuadletRepeated("PublishPort", options.publishPorts ?? []),
3450
+ ...renderQuadletRepeated("ExposeHostPort", options.exposeHostPort ?? []),
3451
+ ...renderQuadletRepeated("Volume", options.volumes ?? []),
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"
3514
+ ]);
3515
+ }
3516
+ function buildQuadletInstallSection(options) {
3517
+ return renderQuadletSection("Install", [
3518
+ renderQuadletLine("WantedBy", options.wantedBy ?? "multi-user.target")
3519
+ ]);
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 getQuadletContainerFilePath(name) {
3526
+ return `${CONTAINERS_SYSTEMD_DIRECTORY}/${name}.container`;
3527
+ }
3528
+ function getQuadletContainerServiceName(options) {
3529
+ return options.serviceName ?? options.name;
3530
+ }
3531
+ function quadletPullOutputIndicatesChange(output) {
3532
+ return QUADLET_PULL_CHANGED_OUTPUT_PATTERNS.some((pattern) => output.includes(pattern));
3533
+ }
3534
+ var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
3535
+ function validateQuadletName(name) {
3536
+ if (!UNIT_NAME_PATTERN2.test(name)) {
3537
+ throw new Error(`quadlet: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
3538
+ }
3539
+ }
3540
+ function shellQuoteForQuadlet(value) {
3541
+ const escapedQuote = "'\\''";
3542
+ return `'${value.replaceAll("'", escapedQuote)}'`;
3543
+ }
3544
+
3545
+ // src/modules/quadlet.ts
3546
+ var CONTAINERS_SYSTEMD_DIRECTORY_COMMAND = "mkdir -p '/etc/containers/systemd'";
3547
+ var QUADLET_FILE_MODE = "0644";
3548
+ var SYSTEMCTL = "systemctl";
3549
+ function generateContainerQuadlet(options) {
3550
+ const serviceLines = buildQuadletServiceLines(options);
3551
+ const sections = [
3552
+ buildQuadletUnitSection(options),
3553
+ renderQuadletSection("Container", buildQuadletContainerLines(options)),
3554
+ ...serviceLines.length > 0 ? [renderQuadletSection("Service", serviceLines)] : [],
3555
+ buildQuadletInstallSection(options)
3556
+ ];
3557
+ return sections.join("\n").trimEnd();
3558
+ }
3559
+ async function createQuadletDirectory(ssh2) {
3560
+ return ssh2.exec(CONTAINERS_SYSTEMD_DIRECTORY_COMMAND, {
3561
+ ignoreExitCode: true,
3562
+ silent: true
3563
+ });
3564
+ }
3565
+ async function applyQuadletFile(parameters) {
3566
+ const mkdirResult = await createQuadletDirectory(parameters.ssh);
3567
+ if (mkdirResult.code !== 0) {
3568
+ return failedCommand(
3569
+ `[quadlet.container: ${parameters.name}] failed to create quadlet directory`,
3570
+ mkdirResult
3571
+ );
3572
+ }
3573
+ await parameters.ssh.writeFile(parameters.filePath, parameters.content, {
3574
+ mode: QUADLET_FILE_MODE
3575
+ });
3576
+ const daemonReload = await parameters.ssh.exec(`${SYSTEMCTL} daemon-reload`, {
3577
+ ignoreExitCode: true,
3578
+ silent: true
3579
+ });
3580
+ return daemonReload.code === 0 ? { status: "changed" } : failedCommand(
3581
+ `[quadlet.container: ${parameters.name}] systemctl daemon-reload failed`,
3582
+ daemonReload
3583
+ );
3584
+ }
3585
+ async function checkQuadletFile(parameters) {
3586
+ const exists = await parameters.ssh.exists(parameters.filePath);
3587
+ if (!exists) return NEEDS_APPLY;
3588
+ const remoteContent = await parameters.ssh.readFile(parameters.filePath);
3589
+ return remoteContent.trim() === parameters.content.trim() ? "ok" : NEEDS_APPLY;
3590
+ }
3591
+ var quadlet = {
3592
+ /**
3593
+ * Write a Podman Quadlet `.container` definition and reload systemd when it changes.
3594
+ *
3595
+ * The resulting generated service can be controlled with `service.enabled(name)`
3596
+ * and `service.running(name)`.
3597
+ *
3598
+ * @param options - Configuration for the Quadlet container definition.
3599
+ * @returns A Module that ensures the Quadlet file is present and up to date.
3600
+ */
3601
+ container(options) {
3602
+ validateQuadletName(options.name);
3603
+ const filePath = getQuadletContainerFilePath(options.name);
3604
+ const content = generateContainerQuadlet(options);
3605
+ return {
3606
+ async apply(ssh2) {
3607
+ if (!ssh2) return failed(`[quadlet.container: ${options.name}] SSH connection is required`);
3608
+ return applyQuadletFile({ content, filePath, name: options.name, ssh: ssh2 });
3609
+ },
3610
+ async check(ssh2) {
3611
+ if (!ssh2) return NEEDS_APPLY;
3612
+ return checkQuadletFile({ content, filePath, ssh: ssh2 });
3613
+ },
3614
+ name: `quadlet.container: ${options.name}`
3615
+ };
3616
+ },
3617
+ /**
3618
+ * Pull the latest image for a Quadlet-managed container and restart the service
3619
+ * only when the image changed.
3620
+ *
3621
+ * Accepts the same `name` and `image` fields as `quadlet.container(...)`, so a
3622
+ * shared config object can drive both deployment and targeted image refreshes.
3623
+ *
3624
+ * @param options - Image pull and restart configuration for the Quadlet service.
3625
+ * @returns A Module that updates the image and conditionally restarts the service.
3626
+ */
3627
+ updateImage(options) {
3628
+ validateQuadletName(options.name);
3629
+ if (options.serviceName != null) validateQuadletName(options.serviceName);
3630
+ const pullCommand = buildQuadletImagePullCommand(options);
3631
+ const serviceName = getQuadletContainerServiceName(options);
3632
+ return {
3633
+ async apply(ssh2) {
3634
+ if (!ssh2) return failed(`[quadlet.updateImage: ${options.name}] SSH connection is required`);
3635
+ const pullResult = await ssh2.exec(pullCommand, {
3636
+ ignoreExitCode: true,
3637
+ silent: true
3638
+ });
3639
+ if (pullResult.code !== 0) {
3640
+ return failedCommand(
3641
+ `[quadlet.updateImage: ${options.name}] podman pull failed`,
3642
+ pullResult
3643
+ );
3644
+ }
3645
+ if (!quadletPullOutputIndicatesChange(pullResult.stdout)) {
3646
+ return { status: "ok" };
3647
+ }
3648
+ const restartResult = await ssh2.exec(`${SYSTEMCTL} restart ${shellQuote(serviceName)}`, {
3649
+ ignoreExitCode: true,
3650
+ silent: true
3651
+ });
3652
+ return restartResult.code === 0 ? { status: "changed" } : failedCommand(
3653
+ `[quadlet.updateImage: ${options.name}] systemctl restart failed`,
3654
+ restartResult
3655
+ );
3656
+ },
3657
+ // eslint-disable-next-line @typescript-eslint/require-await -- Signal-style module
3658
+ async check() {
3659
+ return NEEDS_APPLY;
3660
+ },
3661
+ name: `quadlet.updateImage: ${options.name}`
3662
+ };
3663
+ }
3664
+ };
3665
+
3215
3666
  // src/modules/releaseUpgrade.ts
3216
3667
  var NONINTERACTIVE2 = "DEBIAN_FRONTEND=noninteractive";
3217
3668
  var CODENAME_RE = new RegExp("^[a-z]{3,20}$", "v");
@@ -3666,7 +4117,7 @@ var script = {
3666
4117
  };
3667
4118
 
3668
4119
  // src/modules/service.ts
3669
- var SYSTEMCTL = "systemctl";
4120
+ var SYSTEMCTL2 = "systemctl";
3670
4121
  var service = {
3671
4122
  /**
3672
4123
  * Ensure a systemd service is disabled and will not start on boot.
@@ -3677,7 +4128,7 @@ var service = {
3677
4128
  return {
3678
4129
  async apply(ssh2) {
3679
4130
  if (!ssh2) return failed(`[service.disabled: ${name}] SSH connection is required`);
3680
- const result = await ssh2.exec(`${SYSTEMCTL} disable ${shellQuote(name)}`, {
4131
+ const result = await ssh2.exec(`${SYSTEMCTL2} disable ${shellQuote(name)}`, {
3681
4132
  ignoreExitCode: true,
3682
4133
  silent: true
3683
4134
  });
@@ -3685,7 +4136,7 @@ var service = {
3685
4136
  },
3686
4137
  async check(ssh2) {
3687
4138
  if (!ssh2) return NEEDS_APPLY;
3688
- const enabled = await ssh2.test(`${SYSTEMCTL} is-enabled --quiet ${shellQuote(name)}`);
4139
+ const enabled = await ssh2.test(`${SYSTEMCTL2} is-enabled --quiet ${shellQuote(name)}`);
3689
4140
  return enabled ? "needs-apply" : "ok";
3690
4141
  },
3691
4142
  name: `service.disabled: ${name}`
@@ -3700,7 +4151,7 @@ var service = {
3700
4151
  return {
3701
4152
  async apply(ssh2) {
3702
4153
  if (!ssh2) return failed(`[service.enabled: ${name}] SSH connection is required`);
3703
- const result = await ssh2.exec(`${SYSTEMCTL} enable ${shellQuote(name)}`, {
4154
+ const result = await ssh2.exec(`${SYSTEMCTL2} enable ${shellQuote(name)}`, {
3704
4155
  ignoreExitCode: true,
3705
4156
  silent: true
3706
4157
  });
@@ -3708,7 +4159,7 @@ var service = {
3708
4159
  },
3709
4160
  async check(ssh2) {
3710
4161
  if (!ssh2) return NEEDS_APPLY;
3711
- return await ssh2.test(`${SYSTEMCTL} is-enabled --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
4162
+ return await ssh2.test(`${SYSTEMCTL2} is-enabled --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
3712
4163
  },
3713
4164
  name: `service.enabled: ${name}`
3714
4165
  };
@@ -3724,7 +4175,7 @@ var service = {
3724
4175
  async apply(ssh2) {
3725
4176
  if (!ssh2) return failed("[service.facts] SSH connection is required");
3726
4177
  const result = await ssh2.exec(
3727
- `${SYSTEMCTL} list-units --type=service --all --no-pager --no-legend`,
4178
+ `${SYSTEMCTL2} list-units --type=service --all --no-pager --no-legend`,
3728
4179
  { ignoreExitCode: true, silent: true }
3729
4180
  );
3730
4181
  if (result.code !== 0) {
@@ -3759,7 +4210,7 @@ var service = {
3759
4210
  return {
3760
4211
  async apply(ssh2) {
3761
4212
  if (!ssh2) return failed(`[service.reload: ${name}] SSH connection is required`);
3762
- const result = await ssh2.exec(`${SYSTEMCTL} reload ${shellQuote(name)}`, {
4213
+ const result = await ssh2.exec(`${SYSTEMCTL2} reload ${shellQuote(name)}`, {
3763
4214
  ignoreExitCode: true,
3764
4215
  silent: true
3765
4216
  });
@@ -3781,7 +4232,7 @@ var service = {
3781
4232
  return {
3782
4233
  async apply(ssh2) {
3783
4234
  if (!ssh2) return failed(`[service.restart: ${name}] SSH connection is required`);
3784
- const result = await ssh2.exec(`${SYSTEMCTL} restart ${shellQuote(name)}`, {
4235
+ const result = await ssh2.exec(`${SYSTEMCTL2} restart ${shellQuote(name)}`, {
3785
4236
  ignoreExitCode: true,
3786
4237
  silent: true
3787
4238
  });
@@ -3803,7 +4254,7 @@ var service = {
3803
4254
  return {
3804
4255
  async apply(ssh2) {
3805
4256
  if (!ssh2) return failed(`[service.running: ${name}] SSH connection is required`);
3806
- const result = await ssh2.exec(`${SYSTEMCTL} start ${shellQuote(name)}`, {
4257
+ const result = await ssh2.exec(`${SYSTEMCTL2} start ${shellQuote(name)}`, {
3807
4258
  ignoreExitCode: true,
3808
4259
  silent: true
3809
4260
  });
@@ -3811,7 +4262,7 @@ var service = {
3811
4262
  },
3812
4263
  async check(ssh2) {
3813
4264
  if (!ssh2) return NEEDS_APPLY;
3814
- return await ssh2.test(`${SYSTEMCTL} is-active --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
4265
+ return await ssh2.test(`${SYSTEMCTL2} is-active --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
3815
4266
  },
3816
4267
  name: `service.running: ${name}`
3817
4268
  };
@@ -3825,7 +4276,7 @@ var service = {
3825
4276
  return {
3826
4277
  async apply(ssh2) {
3827
4278
  if (!ssh2) return failed(`[service.stopped: ${name}] SSH connection is required`);
3828
- const result = await ssh2.exec(`${SYSTEMCTL} stop ${shellQuote(name)}`, {
4279
+ const result = await ssh2.exec(`${SYSTEMCTL2} stop ${shellQuote(name)}`, {
3829
4280
  ignoreExitCode: true,
3830
4281
  silent: true
3831
4282
  });
@@ -3833,7 +4284,7 @@ var service = {
3833
4284
  },
3834
4285
  async check(ssh2) {
3835
4286
  if (!ssh2) return NEEDS_APPLY;
3836
- const active = await ssh2.test(`${SYSTEMCTL} is-active --quiet ${shellQuote(name)}`);
4287
+ const active = await ssh2.test(`${SYSTEMCTL2} is-active --quiet ${shellQuote(name)}`);
3837
4288
  return active ? "needs-apply" : "ok";
3838
4289
  },
3839
4290
  name: `service.stopped: ${name}`
@@ -4124,7 +4575,7 @@ var DEFAULT_SSH_PORT3 = 22;
4124
4575
  var PRIVILEGE_SEPARATION_DIRECTORY = "/run/sshd";
4125
4576
  var SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
4126
4577
  var SSHD_CONFIG_MODE = "0644";
4127
- var SYSTEMCTL2 = "systemctl";
4578
+ var SYSTEMCTL3 = "systemctl";
4128
4579
  var REGEXP_SPECIAL = /* @__PURE__ */ new Set(["?", ".", "(", ")", "[", "]", "{", "}", "*", "\\", "^", "+", "|", "$"]);
4129
4580
  function escapeRegExp(s) {
4130
4581
  let result = "";
@@ -4162,14 +4613,14 @@ async function disableSocketActivatedSsh(ssh2) {
4162
4613
  });
4163
4614
  }
4164
4615
  async function resolveSshServiceUnit(ssh2) {
4165
- const sshdExists = await ssh2.exec(`${SYSTEMCTL2} cat sshd.service >/dev/null 2>&1`, {
4616
+ const sshdExists = await ssh2.exec(`${SYSTEMCTL3} cat sshd.service >/dev/null 2>&1`, {
4166
4617
  ignoreExitCode: true,
4167
4618
  silent: true
4168
4619
  });
4169
4620
  if (sshdExists.code === 0) {
4170
4621
  return "sshd";
4171
4622
  }
4172
- const sshExists = await ssh2.exec(`${SYSTEMCTL2} cat ssh.service >/dev/null 2>&1`, {
4623
+ const sshExists = await ssh2.exec(`${SYSTEMCTL3} cat ssh.service >/dev/null 2>&1`, {
4173
4624
  ignoreExitCode: true,
4174
4625
  silent: true
4175
4626
  });
@@ -4182,7 +4633,7 @@ async function resolveSshServiceUnit(ssh2) {
4182
4633
  }
4183
4634
  async function reloadSshd(ssh2) {
4184
4635
  const serviceUnit = await resolveSshServiceUnit(ssh2);
4185
- const result = await ssh2.exec(`${SYSTEMCTL2} reload ${serviceUnit}`, {
4636
+ const result = await ssh2.exec(`${SYSTEMCTL3} reload ${serviceUnit}`, {
4186
4637
  ignoreExitCode: true,
4187
4638
  silent: true
4188
4639
  });
@@ -4263,7 +4714,7 @@ async function applySshdPort(ssh2, targetPort) {
4263
4714
  try {
4264
4715
  await disableSocketActivatedSsh(ssh2);
4265
4716
  const serviceUnit = await resolveSshServiceUnit(ssh2);
4266
- await ssh2.exec(`${SYSTEMCTL2} restart ${serviceUnit}`, { silent: true });
4717
+ await ssh2.exec(`${SYSTEMCTL3} restart ${serviceUnit}`, { silent: true });
4267
4718
  } catch (error) {
4268
4719
  if (!isRestartDisconnect(error)) {
4269
4720
  ssh2.removePort(targetPort);
@@ -4374,8 +4825,259 @@ var sshd = {
4374
4825
  }
4375
4826
  };
4376
4827
 
4377
- // src/modules/sysctl.ts
4828
+ // src/modules/swapFileHelpers.ts
4829
+ import { dirname } from "path";
4378
4830
  var EXEC_OPTS8 = { ignoreExitCode: true, silent: true };
4831
+ var FSTAB_PATH2 = "/etc/fstab";
4832
+ var FSTAB_MODE2 = "0644";
4833
+ var KIBI = 1024;
4834
+ var POWER_0 = 0;
4835
+ var POWER_1 = 1;
4836
+ var POWER_2 = 2;
4837
+ var POWER_3 = 3;
4838
+ var POWER_4 = 4;
4839
+ var POWER_5 = 5;
4840
+ var SIZE_POWERS = {
4841
+ "": POWER_0,
4842
+ G: POWER_3,
4843
+ K: POWER_1,
4844
+ M: POWER_2,
4845
+ P: POWER_5,
4846
+ T: POWER_4
4847
+ };
4848
+ function isSizeUnit(unit) {
4849
+ return Object.hasOwn(SIZE_POWERS, unit);
4850
+ }
4851
+ function normalizeSizeToBytes(size) {
4852
+ if (typeof size === "number") {
4853
+ if (!Number.isInteger(size) || size <= 0) {
4854
+ throw new Error("swap.file: numeric size must be a positive integer byte count");
4855
+ }
4856
+ return size;
4857
+ }
4858
+ const trimmed = size.trim();
4859
+ const match = new RegExp("^(?<value>\\d+)(?<unit>[KMGTP]?)$", "iv").exec(trimmed);
4860
+ if (match?.groups == null) {
4861
+ throw new Error(`swap.file: unsupported size format "${size}"`);
4862
+ }
4863
+ const value = Number.parseInt(match.groups.value, 10);
4864
+ const unit = match.groups.unit.toUpperCase();
4865
+ if (!isSizeUnit(unit)) {
4866
+ throw new Error(`swap.file: unsupported size unit "${unit}"`);
4867
+ }
4868
+ return value * KIBI ** SIZE_POWERS[unit];
4869
+ }
4870
+ function normalizeSizeForCommand(size) {
4871
+ return typeof size === "number" ? String(size) : size.trim();
4872
+ }
4873
+ function buildSwapFstabLine(path, priority) {
4874
+ const options = priority == null ? "sw" : `sw,pri=${String(priority)}`;
4875
+ return `${path} none swap ${options} 0 0`;
4876
+ }
4877
+ function findFstabEntry2(fstabContent, path) {
4878
+ for (const line of fstabContent.split("\n")) {
4879
+ const trimmed = line.trim();
4880
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
4881
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4882
+ if (fields[0] === path) return trimmed;
4883
+ }
4884
+ return null;
4885
+ }
4886
+ function upsertFstabEntry2(fstabContent, path, newLine) {
4887
+ const lines = fstabContent.split("\n");
4888
+ const index = lines.findIndex((line) => {
4889
+ const trimmed = line.trim();
4890
+ if (trimmed === "" || trimmed.startsWith("#")) return false;
4891
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4892
+ return fields[0] === path;
4893
+ });
4894
+ if (index === -1) {
4895
+ while (lines.length > 0 && lines.at(-1)?.trim() === "") {
4896
+ lines.pop();
4897
+ }
4898
+ lines.push(newLine);
4899
+ } else {
4900
+ lines[index] = newLine;
4901
+ }
4902
+ return `${lines.join("\n")}
4903
+ `;
4904
+ }
4905
+ function removeFstabEntry2(fstabContent, path) {
4906
+ const lines = fstabContent.split("\n");
4907
+ const result = lines.filter((line) => {
4908
+ const trimmed = line.trim();
4909
+ if (trimmed === "" || trimmed.startsWith("#")) return true;
4910
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4911
+ return fields[0] !== path;
4912
+ });
4913
+ return `${result.join("\n")}
4914
+ `;
4915
+ }
4916
+ async function isSwapActive(ssh2, path) {
4917
+ const activeSwaps = await ssh2.lines("swapon --show=NAME --noheadings");
4918
+ return activeSwaps.some((line) => line.trim() === path);
4919
+ }
4920
+ async function hasSwapSignature(ssh2, path) {
4921
+ return ssh2.test(`swaplabel ${shellQuote(path)} >/dev/null 2>&1`);
4922
+ }
4923
+ async function readFileSizeInBytes(ssh2, path) {
4924
+ const output = await ssh2.output(`stat -c %s ${shellQuote(path)}`);
4925
+ return Number.parseInt(output.trim(), 10);
4926
+ }
4927
+ async function ensureSwapFstabState(parameters) {
4928
+ const fstabContent = await parameters.ssh.readFile(FSTAB_PATH2);
4929
+ const currentEntry = findFstabEntry2(fstabContent, parameters.path);
4930
+ if (parameters.desiredLine == null) {
4931
+ if (currentEntry == null) return false;
4932
+ const removedContent = removeFstabEntry2(fstabContent, parameters.path);
4933
+ await guardedWriteFile(parameters.ssh, {
4934
+ mode: FSTAB_MODE2,
4935
+ newContent: removedContent,
4936
+ originalContent: fstabContent,
4937
+ remotePath: FSTAB_PATH2
4938
+ });
4939
+ return true;
4940
+ }
4941
+ if (currentEntry === parameters.desiredLine) return false;
4942
+ const updatedContent = upsertFstabEntry2(fstabContent, parameters.path, parameters.desiredLine);
4943
+ await guardedWriteFile(parameters.ssh, {
4944
+ mode: FSTAB_MODE2,
4945
+ newContent: updatedContent,
4946
+ originalContent: fstabContent,
4947
+ remotePath: FSTAB_PATH2
4948
+ });
4949
+ return true;
4950
+ }
4951
+ async function ensureSwapFilePresent(parameters) {
4952
+ const createDirectoryResult = await parameters.ssh.exec(
4953
+ `mkdir -p ${shellQuote(dirname(parameters.path))}`,
4954
+ EXEC_OPTS8
4955
+ );
4956
+ if (createDirectoryResult.code !== 0) {
4957
+ return failedCommand(`[swap.file: ${parameters.path}] mkdir failed`, createDirectoryResult);
4958
+ }
4959
+ const createFileResult = await parameters.ssh.exec(
4960
+ `fallocate -l ${shellQuote(parameters.size)} ${shellQuote(parameters.path)} || dd if=/dev/zero of=${shellQuote(parameters.path)} bs=${shellQuote(parameters.size)} count=1 status=none`,
4961
+ EXEC_OPTS8
4962
+ );
4963
+ if (createFileResult.code !== 0) {
4964
+ return failedCommand(
4965
+ `[swap.file: ${parameters.path}] swap file creation failed`,
4966
+ createFileResult
4967
+ );
4968
+ }
4969
+ const chmodResult = await parameters.ssh.exec(
4970
+ `chmod ${shellQuote(parameters.mode)} ${shellQuote(parameters.path)}`,
4971
+ EXEC_OPTS8
4972
+ );
4973
+ if (chmodResult.code !== 0) {
4974
+ return failedCommand(`[swap.file: ${parameters.path}] chmod failed`, chmodResult);
4975
+ }
4976
+ const makeSwapResult = await parameters.ssh.exec(
4977
+ `mkswap ${shellQuote(parameters.path)}`,
4978
+ EXEC_OPTS8
4979
+ );
4980
+ return makeSwapResult.code === 0 ? true : failedCommand(`[swap.file: ${parameters.path}] mkswap failed`, makeSwapResult);
4981
+ }
4982
+ function normalizeSwapFileOptions(options) {
4983
+ const state = options.state ?? "present";
4984
+ return {
4985
+ expectedFstabLine: state === "present" ? buildSwapFstabLine(options.path, options.priority) : null,
4986
+ mode: options.mode ?? "0600",
4987
+ path: options.path,
4988
+ sizeBytes: normalizeSizeToBytes(options.size),
4989
+ sizeForCommand: normalizeSizeForCommand(options.size),
4990
+ state
4991
+ };
4992
+ }
4993
+ async function needsSwapRecreation(ssh2, options) {
4994
+ if (!await ssh2.exists(options.path)) return true;
4995
+ if (await readFileSizeInBytes(ssh2, options.path) !== options.sizeBytes) return true;
4996
+ return !await hasSwapSignature(ssh2, options.path);
4997
+ }
4998
+ async function hasSwapFstabEntry(ssh2, options) {
4999
+ const currentFstabContent = await ssh2.readFile(FSTAB_PATH2);
5000
+ return findFstabEntry2(currentFstabContent, options.path) === options.expectedFstabLine;
5001
+ }
5002
+ async function hasNoSwapFstabEntry(ssh2, path) {
5003
+ const currentFstabContent = await ssh2.readFile(FSTAB_PATH2);
5004
+ return findFstabEntry2(currentFstabContent, path) == null;
5005
+ }
5006
+
5007
+ // src/modules/swapHelpers.ts
5008
+ var EXEC_OPTS9 = { ignoreExitCode: true, silent: true };
5009
+ async function disableSwap(ssh2, path) {
5010
+ if (!await isSwapActive(ssh2, path)) return false;
5011
+ const result = await ssh2.exec(`swapoff ${shellQuote(path)}`, EXEC_OPTS9);
5012
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] swapoff failed`, result);
5013
+ }
5014
+ async function removeSwapFile(ssh2, path) {
5015
+ if (!await ssh2.exists(path)) return false;
5016
+ const result = await ssh2.exec(`rm -f ${shellQuote(path)}`, EXEC_OPTS9);
5017
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] rm failed`, result);
5018
+ }
5019
+ async function recreateSwapFile(ssh2, options) {
5020
+ if (!await needsSwapRecreation(ssh2, options)) return "ok";
5021
+ const disableResult = await disableSwap(ssh2, options.path);
5022
+ if (typeof disableResult !== "boolean") return disableResult;
5023
+ const removeResult = await removeSwapFile(ssh2, options.path);
5024
+ if (typeof removeResult !== "boolean") return removeResult;
5025
+ const createResult = await ensureSwapFilePresent({
5026
+ mode: options.mode,
5027
+ path: options.path,
5028
+ size: options.sizeForCommand,
5029
+ ssh: ssh2
5030
+ });
5031
+ return createResult === true ? "changed" : createResult;
5032
+ }
5033
+ async function enableSwap(ssh2, path) {
5034
+ if (await isSwapActive(ssh2, path)) return false;
5035
+ const result = await ssh2.exec(`swapon ${shellQuote(path)}`, EXEC_OPTS9);
5036
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] swapon failed`, result);
5037
+ }
5038
+ async function applyAbsentSwapFile(ssh2, options) {
5039
+ let swapChanged = false;
5040
+ const disableResult = await disableSwap(ssh2, options.path);
5041
+ if (typeof disableResult !== "boolean") return disableResult;
5042
+ if (disableResult) swapChanged = true;
5043
+ if (await ensureSwapFstabState({ desiredLine: null, path: options.path, ssh: ssh2 })) swapChanged = true;
5044
+ const removeResult = await removeSwapFile(ssh2, options.path);
5045
+ if (typeof removeResult !== "boolean") return removeResult;
5046
+ if (removeResult) swapChanged = true;
5047
+ return { status: swapChanged ? "changed" : "ok" };
5048
+ }
5049
+ async function applyPresentSwapFile(ssh2, options) {
5050
+ let swapChanged = false;
5051
+ const recreateResult = await recreateSwapFile(ssh2, options);
5052
+ if (typeof recreateResult !== "string") return recreateResult;
5053
+ if (recreateResult === "changed") swapChanged = true;
5054
+ const enableResult = await enableSwap(ssh2, options.path);
5055
+ if (typeof enableResult !== "boolean") return enableResult;
5056
+ if (enableResult) swapChanged = true;
5057
+ if (await ensureSwapFstabState({ desiredLine: options.expectedFstabLine, path: options.path, ssh: ssh2 })) {
5058
+ swapChanged = true;
5059
+ }
5060
+ return { status: swapChanged ? "changed" : "ok" };
5061
+ }
5062
+ async function applySwapFile(ssh2, options) {
5063
+ return options.state === "absent" ? applyAbsentSwapFile(ssh2, options) : applyPresentSwapFile(ssh2, options);
5064
+ }
5065
+ async function checkAbsent(ssh2, options) {
5066
+ if (await ssh2.exists(options.path)) return NEEDS_APPLY;
5067
+ if (await isSwapActive(ssh2, options.path)) return NEEDS_APPLY;
5068
+ return await hasNoSwapFstabEntry(ssh2, options.path) ? "ok" : NEEDS_APPLY;
5069
+ }
5070
+ async function checkPresent(ssh2, options) {
5071
+ if (await needsSwapRecreation(ssh2, options)) return NEEDS_APPLY;
5072
+ if (!await isSwapActive(ssh2, options.path)) return NEEDS_APPLY;
5073
+ return await hasSwapFstabEntry(ssh2, options) ? "ok" : NEEDS_APPLY;
5074
+ }
5075
+ async function checkSwapFile(ssh2, options) {
5076
+ return options.state === "absent" ? checkAbsent(ssh2, options) : checkPresent(ssh2, options);
5077
+ }
5078
+
5079
+ // src/modules/sysctl.ts
5080
+ var EXEC_OPTS10 = { ignoreExitCode: true, silent: true };
4379
5081
  var SYSCTL_DIR = "/etc/sysctl.d";
4380
5082
  var SYSCTL_CONFIG_MODE = "0644";
4381
5083
  function sanitizeKey(key) {
@@ -4411,28 +5113,28 @@ var sysctl = {
4411
5113
  if (!conn) return failed(`[sysctl.set: ${key}] SSH connection is required`);
4412
5114
  if (state === "present") {
4413
5115
  const assignment = `${key}=${value}`;
4414
- const result = await conn.exec(`sysctl -w ${shellQuote(assignment)}`, EXEC_OPTS8);
5116
+ const result = await conn.exec(`sysctl -w ${shellQuote(assignment)}`, EXEC_OPTS10);
4415
5117
  if (result.code !== 0) {
4416
5118
  return failedCommand(`[sysctl.set: ${key}] sysctl -w failed`, result);
4417
5119
  }
4418
5120
  await conn.writeFile(configPath, expectedContent, { mode: SYSCTL_CONFIG_MODE });
4419
5121
  } else {
4420
- await conn.exec(`rm -f ${shellQuote(configPath)}`, EXEC_OPTS8);
5122
+ await conn.exec(`rm -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4421
5123
  }
4422
5124
  return { status: "changed" };
4423
5125
  },
4424
5126
  async check(conn) {
4425
5127
  if (!conn) return NEEDS_APPLY;
4426
5128
  if (state === "present") {
4427
- const result = await conn.exec(`sysctl -n ${shellQuote(key)}`, EXEC_OPTS8);
5129
+ const result = await conn.exec(`sysctl -n ${shellQuote(key)}`, EXEC_OPTS10);
4428
5130
  const currentValue = result.stdout.trim();
4429
5131
  if (currentValue !== value) return NEEDS_APPLY;
4430
- const configExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS8);
5132
+ const configExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4431
5133
  if (configExists.code !== 0) return NEEDS_APPLY;
4432
5134
  const fileContent = await conn.readFile(configPath);
4433
5135
  return fileContent.trim() === expectedContent.trim() ? "ok" : NEEDS_APPLY;
4434
5136
  }
4435
- const fileExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS8);
5137
+ const fileExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4436
5138
  return fileExists.code === 0 ? NEEDS_APPLY : "ok";
4437
5139
  },
4438
5140
  name: state === "present" ? `sysctl.set: ${key}=${value}` : `sysctl.set: absent ${key}`
@@ -4440,6 +5142,53 @@ var sysctl = {
4440
5142
  }
4441
5143
  };
4442
5144
 
5145
+ // src/modules/swap.ts
5146
+ var swap = {
5147
+ /**
5148
+ * Ensure a file-backed swap area exists, is activated, and is persisted in `/etc/fstab`.
5149
+ *
5150
+ * @param options - Configuration for the swap file.
5151
+ * @param options.mode - File mode applied to the swap file. Defaults to `0600`.
5152
+ * @param options.path - Absolute path to the swap file, e.g. `/swapfile`.
5153
+ * @param options.priority - Optional swap priority written into the fstab entry.
5154
+ * @param options.size - Desired file size as bytes or a shell-friendly size string such as `2G`.
5155
+ * @param options.state - Whether the swap file should be `present` (default) or `absent`.
5156
+ * @returns A Module that manages the swap file lifecycle.
5157
+ */
5158
+ file(options) {
5159
+ const normalized = normalizeSwapFileOptions(options);
5160
+ return {
5161
+ async apply(ssh2) {
5162
+ if (!ssh2) return failed(`[swap.file: ${normalized.path}] SSH connection is required`);
5163
+ return applySwapFile(ssh2, normalized);
5164
+ },
5165
+ async check(ssh2) {
5166
+ if (!ssh2) return NEEDS_APPLY;
5167
+ return checkSwapFile(ssh2, normalized);
5168
+ },
5169
+ name: normalized.state === "present" ? `swap.file: ${normalized.path} (${normalized.sizeForCommand})` : `swap.file: absent ${normalized.path}`
5170
+ };
5171
+ },
5172
+ /**
5173
+ * Persist `vm.swappiness`.
5174
+ *
5175
+ * @param value - Desired swappiness value.
5176
+ * @returns A Module that manages `vm.swappiness`.
5177
+ */
5178
+ swappiness(value) {
5179
+ return sysctl.set("vm.swappiness", String(value));
5180
+ },
5181
+ /**
5182
+ * Persist `vm.vfs_cache_pressure`.
5183
+ *
5184
+ * @param value - Desired VFS cache pressure value.
5185
+ * @returns A Module that manages `vm.vfs_cache_pressure`.
5186
+ */
5187
+ vfsCachePressure(value) {
5188
+ return sysctl.set("vm.vfs_cache_pressure", String(value));
5189
+ }
5190
+ };
5191
+
4443
5192
  // src/modules/system.ts
4444
5193
  function parseOsRelease(content) {
4445
5194
  const result = {};
@@ -4610,8 +5359,8 @@ ${message}`);
4610
5359
  };
4611
5360
 
4612
5361
  // src/modules/systemd.ts
4613
- var SYSTEMCTL3 = "systemctl";
4614
- var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
5362
+ var SYSTEMCTL4 = "systemctl";
5363
+ var UNIT_NAME_PATTERN3 = new RegExp("^[\\w@.\\-]+$", "v");
4615
5364
  var SYSTEMD_UNIT_MODE2 = "0644";
4616
5365
  var systemd = {
4617
5366
  /**
@@ -4623,7 +5372,7 @@ var systemd = {
4623
5372
  return {
4624
5373
  async apply(ssh2) {
4625
5374
  if (!ssh2) return failed("[systemd.daemonReload] SSH connection is required");
4626
- const result = await ssh2.exec(`${SYSTEMCTL3} daemon-reload`, {
5375
+ const result = await ssh2.exec(`${SYSTEMCTL4} daemon-reload`, {
4627
5376
  ignoreExitCode: true,
4628
5377
  silent: true
4629
5378
  });
@@ -4645,7 +5394,7 @@ var systemd = {
4645
5394
  return {
4646
5395
  async apply(ssh2) {
4647
5396
  if (!ssh2) return failed(`[systemd.masked: ${name}] SSH connection is required`);
4648
- const result = await ssh2.exec(`${SYSTEMCTL3} mask ${shellQuote(name)}`, {
5397
+ const result = await ssh2.exec(`${SYSTEMCTL4} mask ${shellQuote(name)}`, {
4649
5398
  ignoreExitCode: true,
4650
5399
  silent: true
4651
5400
  });
@@ -4653,7 +5402,7 @@ var systemd = {
4653
5402
  },
4654
5403
  async check(ssh2) {
4655
5404
  if (!ssh2) return NEEDS_APPLY;
4656
- const result = await ssh2.exec(`${SYSTEMCTL3} is-enabled ${shellQuote(name)}`, {
5405
+ const result = await ssh2.exec(`${SYSTEMCTL4} is-enabled ${shellQuote(name)}`, {
4657
5406
  ignoreExitCode: true,
4658
5407
  silent: true
4659
5408
  });
@@ -4674,15 +5423,15 @@ var systemd = {
4674
5423
  * @returns A Module that ensures the unit file is present with the given content.
4675
5424
  */
4676
5425
  unit(name, content) {
4677
- if (!UNIT_NAME_PATTERN2.test(name)) {
4678
- throw new Error(`systemd.unit: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
5426
+ if (!UNIT_NAME_PATTERN3.test(name)) {
5427
+ throw new Error(`systemd.unit: name must match ${String(UNIT_NAME_PATTERN3)}, got: ${name}`);
4679
5428
  }
4680
5429
  const filePath = `/etc/systemd/system/${name}`;
4681
5430
  return {
4682
5431
  async apply(ssh2) {
4683
5432
  if (!ssh2) return failed(`[systemd.unit: ${name}] SSH connection is required`);
4684
5433
  await ssh2.writeFile(filePath, content, { mode: SYSTEMD_UNIT_MODE2 });
4685
- const result = await ssh2.exec(`${SYSTEMCTL3} daemon-reload`, {
5434
+ const result = await ssh2.exec(`${SYSTEMCTL4} daemon-reload`, {
4686
5435
  ignoreExitCode: true,
4687
5436
  silent: true
4688
5437
  });
@@ -4707,7 +5456,7 @@ var systemd = {
4707
5456
  return {
4708
5457
  async apply(ssh2) {
4709
5458
  if (!ssh2) return failed(`[systemd.unmasked: ${name}] SSH connection is required`);
4710
- const result = await ssh2.exec(`${SYSTEMCTL3} unmask ${shellQuote(name)}`, {
5459
+ const result = await ssh2.exec(`${SYSTEMCTL4} unmask ${shellQuote(name)}`, {
4711
5460
  ignoreExitCode: true,
4712
5461
  silent: true
4713
5462
  });
@@ -4715,7 +5464,7 @@ var systemd = {
4715
5464
  },
4716
5465
  async check(ssh2) {
4717
5466
  if (!ssh2) return NEEDS_APPLY;
4718
- const result = await ssh2.exec(`${SYSTEMCTL3} is-enabled ${shellQuote(name)}`, {
5467
+ const result = await ssh2.exec(`${SYSTEMCTL4} is-enabled ${shellQuote(name)}`, {
4719
5468
  ignoreExitCode: true,
4720
5469
  silent: true
4721
5470
  });
@@ -4979,6 +5728,7 @@ export {
4979
5728
  mount,
4980
5729
  net,
4981
5730
  op,
5731
+ quadlet,
4982
5732
  releaseUpgrade,
4983
5733
  rsync,
4984
5734
  script,
@@ -4986,9 +5736,10 @@ export {
4986
5736
  ssh,
4987
5737
  sshd,
4988
5738
  sysctl,
5739
+ swap,
4989
5740
  system,
4990
5741
  systemd,
4991
5742
  ufw,
4992
5743
  user
4993
5744
  };
4994
- //# sourceMappingURL=chunk-C45YPXCX.js.map
5745
+ //# sourceMappingURL=chunk-ENWMSERJ.js.map