@homeflare/alchemy 0.15.0 → 0.19.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 +29 -0
- package/dist/linux/directory-lifecycle.d.ts +50 -0
- package/dist/linux/directory-lifecycle.d.ts.map +1 -0
- package/dist/linux/directory.d.ts +19 -0
- package/dist/linux/directory.d.ts.map +1 -0
- package/dist/linux/fake-linux-host.d.ts +35 -0
- package/dist/linux/fake-linux-host.d.ts.map +1 -0
- package/dist/linux/fake-systemd.d.ts +34 -0
- package/dist/linux/fake-systemd.d.ts.map +1 -0
- package/dist/linux/index.d.ts +27 -0
- package/dist/linux/index.d.ts.map +1 -0
- package/dist/linux/index.js +1554 -0
- package/dist/linux/index.js.map +37 -0
- package/dist/linux/providers.d.ts +17 -0
- package/dist/linux/providers.d.ts.map +1 -0
- package/dist/linux/region.d.ts +43 -0
- package/dist/linux/region.d.ts.map +1 -0
- package/dist/linux/remote-file-form.d.ts +47 -0
- package/dist/linux/remote-file-form.d.ts.map +1 -0
- package/dist/linux/remote-file-lifecycle.d.ts +36 -0
- package/dist/linux/remote-file-lifecycle.d.ts.map +1 -0
- package/dist/linux/remote-file-plan.d.ts +33 -0
- package/dist/linux/remote-file-plan.d.ts.map +1 -0
- package/dist/linux/remote-file.d.ts +28 -0
- package/dist/linux/remote-file.d.ts.map +1 -0
- package/dist/linux/ssh-command.d.ts +68 -0
- package/dist/linux/ssh-command.d.ts.map +1 -0
- package/dist/linux/ssh-runner.d.ts +22 -0
- package/dist/linux/ssh-runner.d.ts.map +1 -0
- package/dist/linux/ssh-scripts.d.ts +27 -0
- package/dist/linux/ssh-scripts.d.ts.map +1 -0
- package/dist/linux/systemctl.d.ts +67 -0
- package/dist/linux/systemctl.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +78 -0
- package/dist/linux/unit-form.d.ts.map +1 -0
- package/dist/linux/unit-handlers.d.ts +15 -0
- package/dist/linux/unit-handlers.d.ts.map +1 -0
- package/dist/linux/unit-lifecycle.d.ts +44 -0
- package/dist/linux/unit-lifecycle.d.ts.map +1 -0
- package/dist/linux/unit-settle.d.ts +31 -0
- package/dist/linux/unit-settle.d.ts.map +1 -0
- package/dist/linux/unit.d.ts +35 -0
- package/dist/linux/unit.d.ts.map +1 -0
- package/dist/netbox/client.d.ts +52 -0
- package/dist/netbox/client.d.ts.map +1 -0
- package/dist/netbox/constraint-guard.d.ts +26 -0
- package/dist/netbox/constraint-guard.d.ts.map +1 -0
- package/dist/netbox/constraints.d.ts +76 -0
- package/dist/netbox/constraints.d.ts.map +1 -0
- package/dist/netbox/generated/constraints/index.d.ts +19 -0
- package/dist/netbox/generated/constraints/index.d.ts.map +1 -0
- package/dist/netbox/generated/constraints/netbox-ipam.d.ts +21 -0
- package/dist/netbox/generated/constraints/netbox-ipam.d.ts.map +1 -0
- package/dist/netbox/index.d.ts +17 -0
- package/dist/netbox/index.d.ts.map +1 -0
- package/dist/netbox/index.js +331 -0
- package/dist/netbox/index.js.map +18 -0
- package/dist/netbox/prefix-form.d.ts +54 -0
- package/dist/netbox/prefix-form.d.ts.map +1 -0
- package/dist/netbox/prefix.d.ts +80 -0
- package/dist/netbox/prefix.d.ts.map +1 -0
- package/dist/netbox/resource.d.ts +94 -0
- package/dist/netbox/resource.d.ts.map +1 -0
- package/dist/netbox/values.d.ts +24 -0
- package/dist/netbox/values.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/apply-endpoints.d.ts +30 -0
- package/dist/proxmox/apply-endpoints.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon-form.d.ts +29 -1
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-flag.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +19 -0
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/constraints.d.ts +28 -5
- package/dist/proxmox/constraints.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pbs-config.d.ts +6 -1
- package/dist/proxmox/generated/constraints/pbs-config.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +6 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +6 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +6 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/access-users.d.ts +137 -0
- package/dist/proxmox/generated/pbs/access-users.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/access.d.ts +171 -0
- package/dist/proxmox/generated/pbs/access.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/admin-datastore-store.d.ts +252 -0
- package/dist/proxmox/generated/pbs/admin-datastore-store.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/admin-metrics.d.ts +147 -0
- package/dist/proxmox/generated/pbs/admin-metrics.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/admin.d.ts +217 -0
- package/dist/proxmox/generated/pbs/admin.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/backup.d.ts +114 -0
- package/dist/proxmox/generated/pbs/backup.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-access-pam.d.ts +218 -0
- package/dist/proxmox/generated/pbs/config-access-pam.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-access.d.ts +203 -0
- package/dist/proxmox/generated/pbs/config-access.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-datastore.d.ts +195 -0
- package/dist/proxmox/generated/pbs/config-datastore.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-media-pool.d.ts +207 -0
- package/dist/proxmox/generated/pbs/config-media-pool.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-notifications-matchers.d.ts +159 -0
- package/dist/proxmox/generated/pbs/config-notifications-matchers.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-notifications.d.ts +199 -0
- package/dist/proxmox/generated/pbs/config-notifications.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-remote.d.ts +175 -0
- package/dist/proxmox/generated/pbs/config-remote.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-sync.d.ts +145 -0
- package/dist/proxmox/generated/pbs/config-sync.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-tape.d.ts +200 -0
- package/dist/proxmox/generated/pbs/config-tape.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config-verify.d.ts +77 -0
- package/dist/proxmox/generated/pbs/config-verify.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/config.d.ts +119 -0
- package/dist/proxmox/generated/pbs/config.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/nodes-node-dns.d.ts +178 -0
- package/dist/proxmox/generated/pbs/nodes-node-dns.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/nodes-node-services.d.ts +205 -0
- package/dist/proxmox/generated/pbs/nodes-node-services.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +166 -0
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/nodes.d.ts +171 -0
- package/dist/proxmox/generated/pbs/nodes.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/ping.d.ts +18 -0
- package/dist/proxmox/generated/pbs/ping.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/pull.d.ts +37 -0
- package/dist/proxmox/generated/pbs/pull.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/push.d.ts +36 -0
- package/dist/proxmox/generated/pbs/push.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/reader.d.ts +41 -0
- package/dist/proxmox/generated/pbs/reader.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/root.d.ts +16 -0
- package/dist/proxmox/generated/pbs/root.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/status.d.ts +54 -0
- package/dist/proxmox/generated/pbs/status.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/tape-drive.d.ts +181 -0
- package/dist/proxmox/generated/pbs/tape-drive.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/tape-media.d.ts +126 -0
- package/dist/proxmox/generated/pbs/tape-media.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/tape.d.ts +90 -0
- package/dist/proxmox/generated/pbs/tape.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs/version.d.ts +20 -0
- package/dist/proxmox/generated/pbs/version.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs.d.ts +44 -662
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve/access-roles.d.ts +164 -0
- package/dist/proxmox/generated/pve/access-roles.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/access-users.d.ts +152 -0
- package/dist/proxmox/generated/pve/access-users.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/access.d.ts +221 -0
- package/dist/proxmox/generated/pve/access.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-acme-plugins-id.d.ts +45 -0
- package/dist/proxmox/generated/pve/cluster-acme-plugins-id.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-acme-plugins.d.ts +47 -0
- package/dist/proxmox/generated/pve/cluster-acme-plugins.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-acme-tos.d.ts +240 -0
- package/dist/proxmox/generated/pve/cluster-acme-tos.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-bulk-action.d.ts +200 -0
- package/dist/proxmox/generated/pve/cluster-bulk-action.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-firewall-refs.d.ts +109 -0
- package/dist/proxmox/generated/pve/cluster-firewall-refs.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-firewall.d.ts +225 -0
- package/dist/proxmox/generated/pve/cluster-firewall.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-ha.d.ts +199 -0
- package/dist/proxmox/generated/pve/cluster-ha.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-jobs.d.ts +183 -0
- package/dist/proxmox/generated/pve/cluster-jobs.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-metrics.d.ts +110 -0
- package/dist/proxmox/generated/pve/cluster-metrics.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-notifications-endpoints.d.ts +224 -0
- package/dist/proxmox/generated/pve/cluster-notifications-endpoints.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-notifications.d.ts +91 -0
- package/dist/proxmox/generated/pve/cluster-notifications.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-options.d.ts +85 -0
- package/dist/proxmox/generated/pve/cluster-options.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-qemu-custom-cpu-models-cputype.d.ts +48 -0
- package/dist/proxmox/generated/pve/cluster-qemu-custom-cpu-models-cputype.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-qemu-custom-cpu-models.d.ts +45 -0
- package/dist/proxmox/generated/pve/cluster-qemu-custom-cpu-models.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-replication.d.ts +130 -0
- package/dist/proxmox/generated/pve/cluster-replication.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +220 -0
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn-ipams.d.ts +229 -0
- package/dist/proxmox/generated/pve/cluster-sdn-ipams.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn-vnets-vnet.d.ts +229 -0
- package/dist/proxmox/generated/pve/cluster-sdn-vnets-vnet.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn-vnets.d.ts +51 -0
- package/dist/proxmox/generated/pve/cluster-sdn-vnets.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn-zones.d.ts +225 -0
- package/dist/proxmox/generated/pve/cluster-sdn-zones.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster-sdn.d.ts +202 -0
- package/dist/proxmox/generated/pve/cluster-sdn.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/cluster.d.ts +69 -0
- package/dist/proxmox/generated/pve/cluster.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-ceph.d.ts +167 -0
- package/dist/proxmox/generated/pve/nodes-node-ceph.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-config.d.ts +43 -0
- package/dist/proxmox/generated/pve/nodes-node-config.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-disks.d.ts +225 -0
- package/dist/proxmox/generated/pve/nodes-node-disks.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-firewall.d.ts +145 -0
- package/dist/proxmox/generated/pve/nodes-node-firewall.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-hardware.d.ts +195 -0
- package/dist/proxmox/generated/pve/nodes-node-hardware.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-firewall.d.ts +191 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-firewall.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-move-volume.d.ts +33 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-move-volume.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-mtunnel.d.ts +56 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-mtunnel.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-resize.d.ts +28 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-resize.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-rrd.d.ts +186 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid-rrd.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid.d.ts +191 -0
- package/dist/proxmox/generated/pve/nodes-node-lxc-vmid.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-network.d.ts +150 -0
- package/dist/proxmox/generated/pve/nodes-node-network.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-config.d.ts +301 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-config.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-dbus-vmstate.d.ts +170 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-dbus-vmstate.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-firewall-rules.d.ts +144 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-firewall-rules.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-move-disk.d.ts +34 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-move-disk.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-mtunnel.d.ts +137 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-mtunnel.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-status.d.ts +179 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid-status.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid.d.ts +143 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu-vmid.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu.d.ts +158 -0
- package/dist/proxmox/generated/pve/nodes-node-qemu.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-replication.d.ts +187 -0
- package/dist/proxmox/generated/pve/nodes-node-replication.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-services.d.ts +115 -0
- package/dist/proxmox/generated/pve/nodes-node-services.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-storage-storage.d.ts +202 -0
- package/dist/proxmox/generated/pve/nodes-node-storage-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-storage.d.ts +40 -0
- package/dist/proxmox/generated/pve/nodes-node-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-subscription.d.ts +173 -0
- package/dist/proxmox/generated/pve/nodes-node-subscription.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node-vzdump.d.ts +97 -0
- package/dist/proxmox/generated/pve/nodes-node-vzdump.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes-node.d.ts +207 -0
- package/dist/proxmox/generated/pve/nodes-node.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/nodes.d.ts +236 -0
- package/dist/proxmox/generated/pve/nodes.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/pools.d.ts +82 -0
- package/dist/proxmox/generated/pve/pools.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/storage.d.ts +159 -0
- package/dist/proxmox/generated/pve/storage.d.ts.map +1 -0
- package/dist/proxmox/generated/pve/version.d.ts +21 -0
- package/dist/proxmox/generated/pve/version.d.ts.map +1 -0
- package/dist/proxmox/generated/pve.d.ts +69 -4766
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.js +996 -372
- package/dist/proxmox/index.js.map +48 -32
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/metric-server-form.d.ts +11 -0
- package/dist/proxmox/metric-server-form.d.ts.map +1 -1
- package/dist/proxmox/metric-server.d.ts.map +1 -1
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-form.d.ts +15 -0
- package/dist/proxmox/node-network-form.d.ts.map +1 -1
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/notification-target-endpoint.d.ts +26 -0
- package/dist/proxmox/notification-target-endpoint.d.ts.map +1 -0
- package/dist/proxmox/notification-target-form.d.ts +7 -0
- package/dist/proxmox/notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-endpoint.d.ts +29 -0
- package/dist/proxmox/pbs-notification-target-endpoint.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu.d.ts +10 -0
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/resource-guard.d.ts +15 -0
- package/dist/proxmox/resource-guard.d.ts.map +1 -0
- package/dist/proxmox/resource-spec.d.ts +15 -4
- package/dist/proxmox/resource-spec.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +8 -0
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply.d.ts.map +1 -1
- package/dist/proxmox/sdn-subnet.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +11 -0
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/docs/linux-host-measured.md +56 -0
- package/docs/linux-host.md +186 -0
- package/docs/netbox.md +103 -0
- package/package.json +9 -1
- package/src/linux/directory-lifecycle.ts +220 -0
- package/src/linux/directory.test.ts +132 -0
- package/src/linux/directory.ts +71 -0
- package/src/linux/fake-linux-host.ts +197 -0
- package/src/linux/fake-systemd.ts +103 -0
- package/src/linux/index.ts +32 -0
- package/src/linux/providers.ts +26 -0
- package/src/linux/region.test.ts +89 -0
- package/src/linux/region.ts +118 -0
- package/src/linux/remote-file-form.ts +96 -0
- package/src/linux/remote-file-lifecycle.test.ts +191 -0
- package/src/linux/remote-file-lifecycle.ts +161 -0
- package/src/linux/remote-file-plan.ts +154 -0
- package/src/linux/remote-file.ts +88 -0
- package/src/linux/ssh-command.test.ts +140 -0
- package/src/linux/ssh-command.ts +99 -0
- package/src/linux/ssh-runner.ts +188 -0
- package/src/linux/ssh-scripts.ts +105 -0
- package/src/linux/systemctl.ts +145 -0
- package/src/linux/unit-form.ts +183 -0
- package/src/linux/unit-handlers.ts +73 -0
- package/src/linux/unit-lifecycle.test.ts +220 -0
- package/src/linux/unit-lifecycle.ts +193 -0
- package/src/linux/unit-settle.ts +110 -0
- package/src/linux/unit.ts +81 -0
- package/src/netbox/client.test.ts +67 -0
- package/src/netbox/client.ts +146 -0
- package/src/netbox/constraint-guard.ts +57 -0
- package/src/netbox/constraints.test.ts +114 -0
- package/src/netbox/constraints.ts +152 -0
- package/src/netbox/generated/constraints/index.ts +23 -0
- package/src/netbox/generated/constraints/netbox-ipam.ts +32 -0
- package/src/netbox/index.ts +34 -0
- package/src/netbox/prefix-form.test.ts +127 -0
- package/src/netbox/prefix-form.ts +84 -0
- package/src/netbox/prefix.ts +136 -0
- package/src/netbox/resource.ts +208 -0
- package/src/netbox/values.test.ts +66 -0
- package/src/netbox/values.ts +40 -0
- package/src/proxmox/api-token-form.ts +5 -0
- package/src/proxmox/apply-endpoints.ts +34 -0
- package/src/proxmox/ceph-daemon-form.ts +56 -2
- package/src/proxmox/ceph-daemon.ts +10 -21
- package/src/proxmox/ceph-flag.ts +1 -0
- package/src/proxmox/ceph-fs.ts +1 -0
- package/src/proxmox/ceph-osd-write.ts +30 -4
- package/src/proxmox/ceph-pool.ts +10 -0
- package/src/proxmox/constraints-dialect.test.ts +165 -0
- package/src/proxmox/constraints-forms.test.ts +220 -0
- package/src/proxmox/constraints-host-forms.test.ts +132 -0
- package/src/proxmox/constraints-live.test.ts +155 -0
- package/src/proxmox/constraints-node-forms.test.ts +188 -0
- package/src/proxmox/constraints-required.test.ts +172 -0
- package/src/proxmox/constraints.test.ts +8 -63
- package/src/proxmox/constraints.ts +31 -6
- package/src/proxmox/generated/constraints/index.ts +27 -3
- package/src/proxmox/generated/constraints/pbs-config.ts +63 -1
- package/src/proxmox/generated/constraints/pve-access.ts +15 -3
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +74 -0
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +27 -0
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +35 -0
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +55 -0
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +59 -0
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +80 -0
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +43 -0
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +90 -0
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +57 -0
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +31 -0
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +82 -0
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +77 -0
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +125 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +6 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +8 -3
- package/src/proxmox/generated/pbs/access-users.ts +137 -0
- package/src/proxmox/generated/pbs/access.ts +232 -0
- package/src/proxmox/generated/pbs/admin-datastore-store.ts +238 -0
- package/src/proxmox/generated/pbs/admin-metrics.ts +147 -0
- package/src/proxmox/generated/pbs/admin.ts +225 -0
- package/src/proxmox/generated/pbs/backup.ts +121 -0
- package/src/proxmox/generated/pbs/config-access-pam.ts +228 -0
- package/src/proxmox/generated/pbs/config-access.ts +228 -0
- package/src/proxmox/generated/pbs/config-datastore.ts +217 -0
- package/src/proxmox/generated/pbs/config-media-pool.ts +232 -0
- package/src/proxmox/generated/pbs/config-notifications-matchers.ts +187 -0
- package/src/proxmox/generated/pbs/config-notifications.ts +221 -0
- package/src/proxmox/generated/pbs/config-remote.ts +192 -0
- package/src/proxmox/generated/pbs/config-sync.ts +167 -0
- package/src/proxmox/generated/pbs/config-tape.ts +226 -0
- package/src/proxmox/generated/pbs/config-verify.ts +87 -0
- package/src/proxmox/generated/pbs/config.ts +135 -0
- package/src/proxmox/generated/pbs/nodes-node-dns.ts +229 -0
- package/src/proxmox/generated/pbs/nodes-node-services.ts +181 -0
- package/src/proxmox/generated/pbs/nodes-node.ts +244 -0
- package/src/proxmox/generated/pbs/nodes.ts +160 -0
- package/src/proxmox/generated/pbs/ping.ts +16 -0
- package/src/proxmox/generated/pbs/pull.ts +37 -0
- package/src/proxmox/generated/pbs/push.ts +36 -0
- package/src/proxmox/generated/pbs/reader.ts +41 -0
- package/src/proxmox/generated/pbs/root.ts +16 -0
- package/src/proxmox/generated/pbs/status.ts +50 -0
- package/src/proxmox/generated/pbs/tape-drive.ts +220 -0
- package/src/proxmox/generated/pbs/tape-media.ts +133 -0
- package/src/proxmox/generated/pbs/tape.ts +92 -0
- package/src/proxmox/generated/pbs/version.ts +16 -0
- package/src/proxmox/generated/pbs.ts +44 -787
- package/src/proxmox/generated/pve/access-roles.ts +168 -0
- package/src/proxmox/generated/pve/access-users.ts +155 -0
- package/src/proxmox/generated/pve/access.ts +217 -0
- package/src/proxmox/generated/pve/cluster-acme-plugins-id.ts +367 -0
- package/src/proxmox/generated/pve/cluster-acme-plugins.ts +366 -0
- package/src/proxmox/generated/pve/cluster-acme-tos.ts +232 -0
- package/src/proxmox/generated/pve/cluster-bulk-action.ts +227 -0
- package/src/proxmox/generated/pve/cluster-firewall-refs.ts +110 -0
- package/src/proxmox/generated/pve/cluster-firewall.ts +236 -0
- package/src/proxmox/generated/pve/cluster-ha.ts +203 -0
- package/src/proxmox/generated/pve/cluster-jobs.ts +190 -0
- package/src/proxmox/generated/pve/cluster-metrics.ts +116 -0
- package/src/proxmox/generated/pve/cluster-notifications-endpoints.ts +244 -0
- package/src/proxmox/generated/pve/cluster-notifications.ts +99 -0
- package/src/proxmox/generated/pve/cluster-options.ts +189 -0
- package/src/proxmox/generated/pve/cluster-qemu-custom-cpu-models-cputype.ts +272 -0
- package/src/proxmox/generated/pve/cluster-qemu-custom-cpu-models.ts +268 -0
- package/src/proxmox/generated/pve/cluster-replication.ts +127 -0
- package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +218 -0
- package/src/proxmox/generated/pve/cluster-sdn-ipams.ts +229 -0
- package/src/proxmox/generated/pve/cluster-sdn-vnets-vnet.ts +245 -0
- package/src/proxmox/generated/pve/cluster-sdn-vnets.ts +49 -0
- package/src/proxmox/generated/pve/cluster-sdn-zones.ts +224 -0
- package/src/proxmox/generated/pve/cluster-sdn.ts +201 -0
- package/src/proxmox/generated/pve/cluster.ts +71 -0
- package/src/proxmox/generated/pve/nodes-node-ceph.ts +167 -0
- package/src/proxmox/generated/pve/nodes-node-config.ts +56 -0
- package/src/proxmox/generated/pve/nodes-node-disks.ts +237 -0
- package/src/proxmox/generated/pve/nodes-node-firewall.ts +239 -0
- package/src/proxmox/generated/pve/nodes-node-hardware.ts +215 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid-firewall.ts +232 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid-move-volume.ts +1059 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid-mtunnel.ts +49 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid-resize.ts +285 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid-rrd.ts +184 -0
- package/src/proxmox/generated/pve/nodes-node-lxc-vmid.ts +240 -0
- package/src/proxmox/generated/pve/nodes-node-network.ts +234 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-config.ts +441 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-dbus-vmstate.ts +215 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-firewall-rules.ts +145 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-move-disk.ts +664 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-mtunnel.ts +188 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid-status.ts +233 -0
- package/src/proxmox/generated/pve/nodes-node-qemu-vmid.ts +187 -0
- package/src/proxmox/generated/pve/nodes-node-qemu.ts +204 -0
- package/src/proxmox/generated/pve/nodes-node-replication.ts +187 -0
- package/src/proxmox/generated/pve/nodes-node-services.ts +221 -0
- package/src/proxmox/generated/pve/nodes-node-storage-storage.ts +226 -0
- package/src/proxmox/generated/pve/nodes-node-storage.ts +40 -0
- package/src/proxmox/generated/pve/nodes-node-subscription.ts +168 -0
- package/src/proxmox/generated/pve/nodes-node-vzdump.ts +96 -0
- package/src/proxmox/generated/pve/nodes-node.ts +211 -0
- package/src/proxmox/generated/pve/nodes.ts +235 -0
- package/src/proxmox/generated/pve/pools.ts +78 -0
- package/src/proxmox/generated/pve/storage.ts +213 -0
- package/src/proxmox/generated/pve/version.ts +21 -0
- package/src/proxmox/generated/pve.ts +69 -7403
- package/src/proxmox/lxc-interior.test.ts +17 -1
- package/src/proxmox/lxc-lifecycle.ts +48 -9
- package/src/proxmox/metric-server-form.ts +10 -0
- package/src/proxmox/metric-server.ts +11 -2
- package/src/proxmox/network-apply.ts +4 -0
- package/src/proxmox/node-network-form.ts +19 -0
- package/src/proxmox/node-network.ts +9 -3
- package/src/proxmox/notification-target-endpoint.ts +46 -0
- package/src/proxmox/notification-target-form.ts +11 -0
- package/src/proxmox/notification-target.ts +5 -2
- package/src/proxmox/pbs-notification-target-endpoint.ts +45 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +16 -14
- package/src/proxmox/qemu.ts +9 -1
- package/src/proxmox/resource-guard.ts +61 -0
- package/src/proxmox/resource-spec.ts +16 -4
- package/src/proxmox/resource.ts +27 -27
- package/src/proxmox/sdn-apply.ts +4 -0
- package/src/proxmox/sdn-subnet.ts +5 -0
- package/src/proxmox/zfs-pool-write.ts +12 -0
- package/src/proxmox/zfs-pool.ts +2 -1
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts +0 -18
- package/dist/proxmox/generated/constraints/pve-cluster.d.ts.map +0 -1
- package/src/proxmox/generated/constraints/pve-cluster.ts +0 -174
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ssh wire format, with no connection: quoting, framing, and the fail-closed rule that a
|
|
3
|
+
* result without the framing marker is never read as data.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { ABSENT, frameScript, parseFramed, quoteArgv, shellQuote, sshArgv } from './ssh-command.ts';
|
|
7
|
+
import { parseStat, statScript, writeScript } from './ssh-scripts.ts';
|
|
8
|
+
|
|
9
|
+
const NONCE = 'a1b2c3d4';
|
|
10
|
+
|
|
11
|
+
describe('quoting', () => {
|
|
12
|
+
test('shell metacharacters survive as data', () => {
|
|
13
|
+
for (const arg of ['a b', '$HOME', '`id`', 'x;rm -rf /', "it's", 'new\nline', '*']) {
|
|
14
|
+
expect(shellQuote(arg)).toMatch(/^'.*'$/s);
|
|
15
|
+
}
|
|
16
|
+
expect(shellQuote("it's")).toBe(`'it'\\''s'`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('NUL is refused rather than truncated by the shell', () => {
|
|
20
|
+
expect(() => shellQuote('a\u0000b')).toThrow(/NUL/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('an argv becomes one command line', () => {
|
|
24
|
+
expect(quoteArgv(['systemctl', 'show', 'a b.service'])).toBe(
|
|
25
|
+
`'systemctl' 'show' 'a b.service'`,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('framing', () => {
|
|
31
|
+
test('the remote status comes back behind the nonce, and its stderr is clean', () => {
|
|
32
|
+
const stderr = `boom\n\n__HF_RC_${NONCE}=3\n`;
|
|
33
|
+
// ★ The command's own trailing newline is the command's; only the marker line is removed.
|
|
34
|
+
expect(parseFramed(stderr, NONCE)).toEqual({ exitCode: 3, stderr: 'boom\n' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a result with no marker is not an answer', () => {
|
|
38
|
+
// ⛔ This is the whole point: ssh exit 255 with empty output must not read as "not found".
|
|
39
|
+
expect(parseFramed('', NONCE)).toBeUndefined();
|
|
40
|
+
expect(
|
|
41
|
+
parseFramed('ssh: connect to host port 22: Connection refused\n', NONCE),
|
|
42
|
+
).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a marker under another nonce cannot speak for this runner', () => {
|
|
46
|
+
expect(parseFramed(`\n__HF_RC_deadbeef=0\n`, NONCE)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a program that prints the marker text itself does not win — the last one is ours', () => {
|
|
50
|
+
const stderr = `pretend: __HF_RC_${NONCE}=0\n\n__HF_RC_${NONCE}=7\n`;
|
|
51
|
+
expect(parseFramed(stderr, NONCE)?.exitCode).toBe(7);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the frame ends by echoing the status and exiting with it', () => {
|
|
55
|
+
const framed = frameScript('true', NONCE);
|
|
56
|
+
expect(framed).toContain('__hf=$?');
|
|
57
|
+
expect(framed).toContain(`__HF_RC_${NONCE}=%s`);
|
|
58
|
+
expect(framed.endsWith('exit "$__hf"')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a script that exits still reports — the subshell is load-bearing', async () => {
|
|
62
|
+
/**
|
|
63
|
+
* 🔴 MEASURED 2026-09-22 against a live host: without the subshell, `statScript`'s
|
|
64
|
+
* `else exit 66` left the shell before the marker printed, and a missing file came back as
|
|
65
|
+
* a transport failure. This runs the real framing through a real /bin/sh.
|
|
66
|
+
*/
|
|
67
|
+
const script = frameScript(statScript('/definitely-not-here-hf'), NONCE);
|
|
68
|
+
const shell = Bun.spawn(['/bin/sh', '-c', script], { stderr: 'pipe', stdout: 'pipe' });
|
|
69
|
+
const stderr = await new Response(shell.stderr).text();
|
|
70
|
+
expect(parseFramed(stderr, NONCE)?.exitCode).toBe(ABSENT);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('the ssh invocation', () => {
|
|
75
|
+
const argv = sshArgv('somewhere', 'true', { connectTimeoutSec: 10 });
|
|
76
|
+
|
|
77
|
+
test('never prompts and never weakens host verification', () => {
|
|
78
|
+
expect(argv).toContain('BatchMode=yes');
|
|
79
|
+
expect(argv.join(' ')).not.toContain('StrictHostKeyChecking');
|
|
80
|
+
expect(argv.join(' ')).not.toContain('UserKnownHostsFile');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('names a POSIX shell rather than trusting the login shell', () => {
|
|
84
|
+
expect(argv.at(-1)?.startsWith("/bin/sh -c '")).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a destination that could be read as an option is refused', () => {
|
|
88
|
+
expect(() => sshArgv('-oProxyCommand=evil', 'true', { connectTimeoutSec: 10 })).toThrow();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('the file scripts', () => {
|
|
93
|
+
test('stat asks about the link, not its target, and says so for a dangling one', () => {
|
|
94
|
+
const script = statScript('/etc/thing');
|
|
95
|
+
expect(script).toContain("[ -e '/etc/thing' ] || [ -L '/etc/thing' ]");
|
|
96
|
+
expect(script).not.toContain('stat -L');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the write stages, chowns, chmods, then renames — and cleans up on every failure', () => {
|
|
100
|
+
const script = writeScript('/etc/a.conf', '/etc/.a.conf.tmp', { gid: 0, mode: 0o640, uid: 0 });
|
|
101
|
+
const steps = script.split('\n');
|
|
102
|
+
expect(steps[0]).toBe('set -C');
|
|
103
|
+
expect(steps[1]).toBe('umask 077');
|
|
104
|
+
expect(script.indexOf('chown')).toBeLessThan(script.indexOf('chmod'));
|
|
105
|
+
expect(script).toContain("chmod 640 -- '/etc/.a.conf.tmp'");
|
|
106
|
+
expect(script).toContain("mv -f -- '/etc/.a.conf.tmp' '/etc/a.conf'");
|
|
107
|
+
expect(script.match(/rm -f --/g)).toHaveLength(4);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a declared owner with no group never becomes the user’s login group', () => {
|
|
111
|
+
expect(writeScript('/a', '/t', { mode: 0o644, uid: 42 })).toContain("chown 42 -- '/t'");
|
|
112
|
+
expect(writeScript('/a', '/t', { gid: 7, mode: 0o644 })).toContain("chown :7 -- '/t'");
|
|
113
|
+
expect(writeScript('/a', '/t', { mode: 0o644 })).not.toContain('chown');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('stat output', () => {
|
|
118
|
+
test('the measured shape parses, file type from the raw mode', () => {
|
|
119
|
+
// MEASURED 2026-09-22 on Debian 13: `/etc/hostname` → `81a4 644 0 0 4`.
|
|
120
|
+
expect(parseStat('81a4 644 0 0 4\n')).toEqual({
|
|
121
|
+
gid: 0,
|
|
122
|
+
kind: 'file',
|
|
123
|
+
mode: 0o644,
|
|
124
|
+
size: 4,
|
|
125
|
+
uid: 0,
|
|
126
|
+
});
|
|
127
|
+
expect(parseStat('41ed 755 0 0 4096')?.kind).toBe('directory');
|
|
128
|
+
expect(parseStat('a1ff 777 0 0 11')?.kind).toBe('symlink');
|
|
129
|
+
expect(parseStat('c000 666 0 0 0')?.kind).toBe('other');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('setuid bits come from the permission field, not the raw mode', () => {
|
|
133
|
+
expect(parseStat('89ed 4755 0 0 1')?.mode).toBe(0o4755);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('anything else is undefined, so the runner can refuse instead of guessing', () => {
|
|
137
|
+
expect(parseStat('stat: cannot statx')).toBeUndefined();
|
|
138
|
+
expect(parseStat('')).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format the ssh runner speaks: how an argv becomes one remote `sh` script, and how that
|
|
3
|
+
* script's real exit code comes back. Pure functions, so every quoting and framing rule is tested
|
|
4
|
+
* without opening a connection.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ `ssh host <words>` IS NOT AN EXECVE. ssh joins its trailing arguments with spaces and the
|
|
7
|
+
* REMOTE LOGIN SHELL parses the result, so an unquoted path with a space, a `$`, a backtick or a
|
|
8
|
+
* `;` is re-parsed as shell syntax on the other side. Everything therefore goes through
|
|
9
|
+
* `shellQuote`, and the remote command is always `/bin/sh -c '<script>'` — a POSIX shell named
|
|
10
|
+
* explicitly, so a csh or fish login shell only ever execs it.
|
|
11
|
+
* ⛔ AN EXIT CODE FROM ssh IS AMBIGUOUS. ssh exits 255 for its own failures (connection refused,
|
|
12
|
+
* host key changed, auth) and otherwise passes the remote exit code through — and a remote
|
|
13
|
+
* command may itself exit 255. A runner that read the code alone would report "the file is not
|
|
14
|
+
* there" for a dropped connection, which is the exact "never infer success from an empty result"
|
|
15
|
+
* failure. So every script ends by printing its own status on stderr behind a PER-RUNNER NONCE,
|
|
16
|
+
* and a result without that line is a transport failure, never data.
|
|
17
|
+
* ⚠️ THE NONCE IS NOT DECORATION. Without it a remote program that happens to print the marker on
|
|
18
|
+
* stderr could claim any exit code it liked; with a fresh random nonce per runner, the only thing
|
|
19
|
+
* that can produce the line is the script we sent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A script whose work found nothing at the path. ★ Chosen from the 64–113 range sh leaves free. */
|
|
23
|
+
export const ABSENT = 66;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One argument, safe for a POSIX shell. ⛔ Single quotes, because they quote EVERYTHING; the
|
|
27
|
+
* `'\''` dance is how a literal quote gets through.
|
|
28
|
+
*/
|
|
29
|
+
export const shellQuote = (arg: string): string => {
|
|
30
|
+
if (arg.includes('\x00')) throw new Error('ssh: an argument contains NUL');
|
|
31
|
+
return `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** An argv as one shell command line. */
|
|
35
|
+
export const quoteArgv = (argv: readonly string[]): string => argv.map(shellQuote).join(' ');
|
|
36
|
+
|
|
37
|
+
const marker = (nonce: string) => `__HF_RC_${nonce}=`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a script so its status comes back unambiguously.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ THE SCRIPT RUNS IN A SUBSHELL, AND THAT IS NOT COSMETIC. Several of the scripts end a branch
|
|
43
|
+
* with `exit <code>` to say "nothing was there"; at the top level that exit leaves the shell
|
|
44
|
+
* immediately and the marker line NEVER PRINTS — so the caller sees a framed result missing its
|
|
45
|
+
* frame and, correctly, calls it a transport failure. 🔴 MEASURED 2026-09-22 against a live
|
|
46
|
+
* Debian 13 host: `stat` of a missing path came back as "the remote command did not report a
|
|
47
|
+
* status (ssh exit 66)" instead of "nothing is at this path". Inside `( … )` the exit leaves only
|
|
48
|
+
* the subshell, `$?` carries the code out, and the frame always runs.
|
|
49
|
+
* ⚠️ The leading `\n` matters too: a program whose last stderr line has no newline would otherwise
|
|
50
|
+
* glue itself to the marker and the marker would not be found.
|
|
51
|
+
*/
|
|
52
|
+
export const frameScript = (script: string, nonce: string): string =>
|
|
53
|
+
`(\n${script}\n)\n__hf=$?\nprintf '\\n${marker(nonce)}%s\\n' "$__hf" >&2\nexit "$__hf"`;
|
|
54
|
+
|
|
55
|
+
export type Framed = { readonly exitCode: number; readonly stderr: string };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The remote exit code and the stderr that belongs to the command, or `undefined` when the marker
|
|
59
|
+
* is absent — which means the script never ran to its end: ssh failed, the connection dropped, or
|
|
60
|
+
* the remote shell could not parse what we sent. ⛔ The caller must treat `undefined` as an error,
|
|
61
|
+
* never as an answer.
|
|
62
|
+
*/
|
|
63
|
+
export const parseFramed = (stderr: string, nonce: string): Framed | undefined => {
|
|
64
|
+
const at = stderr.lastIndexOf(`\n${marker(nonce)}`);
|
|
65
|
+
if (at < 0) return undefined;
|
|
66
|
+
const digits = /^(\d+)\s*$/.exec(stderr.slice(at + marker(nonce).length + 1));
|
|
67
|
+
if (digits?.[1] === undefined) return undefined;
|
|
68
|
+
return { exitCode: Number(digits[1]), stderr: stderr.slice(0, at) };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The fixed ssh options, in front of anything a caller adds.
|
|
73
|
+
* ⛔ `BatchMode=yes` — a deploy that stops to ask for a passphrase is the failure "no password
|
|
74
|
+
* prompts" names, and with BatchMode ssh fails at once instead.
|
|
75
|
+
* ⛔ NOTHING HERE WEAKENS HOST VERIFICATION. No `StrictHostKeyChecking`, no `UserKnownHostsFile`:
|
|
76
|
+
* the operator's own ssh config and known_hosts decide who the host is, and a changed key stays
|
|
77
|
+
* a refusal. A runner that turned that off would make a man-in-the-middle a deploy target.
|
|
78
|
+
* ★ `-T`: no pseudo-terminal, so the remote shell's stdout stays the bytes the program wrote.
|
|
79
|
+
*/
|
|
80
|
+
export const sshArgv = (
|
|
81
|
+
host: string,
|
|
82
|
+
script: string,
|
|
83
|
+
options: { readonly connectTimeoutSec: number; readonly sshArgs?: readonly string[] },
|
|
84
|
+
): readonly string[] => {
|
|
85
|
+
if (host === '' || host.startsWith('-')) {
|
|
86
|
+
throw new Error(`ssh: ${JSON.stringify(host)} is not a usable destination`);
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
'ssh',
|
|
90
|
+
'-T',
|
|
91
|
+
'-o',
|
|
92
|
+
'BatchMode=yes',
|
|
93
|
+
'-o',
|
|
94
|
+
`ConnectTimeout=${String(options.connectTimeoutSec)}`,
|
|
95
|
+
...(options.sshArgs ?? []),
|
|
96
|
+
host,
|
|
97
|
+
`/bin/sh -c ${shellQuote(script)}`,
|
|
98
|
+
];
|
|
99
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sshRunner()` — the estate's Linux hosts behind the SAME HostRunner seam the launchd family
|
|
3
|
+
* already drives a Mac through (launchd/runner.ts). Every provider in this directory reaches its
|
|
4
|
+
* host only through it, so the whole Linux lifecycle runs against a fake in tests.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THE SAME SEAM AND NOT A SECOND ONE. HostRunner is nine promise-returning methods with no
|
|
7
|
+
* macOS in them; what was Mac-specific was launchctl and the plist, not the seam. Reusing it
|
|
8
|
+
* means HostFile, the ownership rules and `checkWrite` already work on Linux, and a stack can
|
|
9
|
+
* hand the same runner to a file, a directory and a unit.
|
|
10
|
+
* ⛔ NO PASSWORDS AND NO SILENT SUDO. Every connection is `BatchMode=yes` (ssh-command.ts), and
|
|
11
|
+
* `privileged` is false: this runner never calls sudo. A root-owned path therefore needs a
|
|
12
|
+
* destination whose ssh user IS root — which is how the estate already reaches its hypervisors —
|
|
13
|
+
* or a privileged runner of the caller's own. See docs/linux-host.md; the Linux twin of
|
|
14
|
+
* sudoRunner is deliberately a later, separate change with its own allowlist.
|
|
15
|
+
* ⛔ FAIL CLOSED. A connection that drops, a host key that changed, a shell that cannot parse the
|
|
16
|
+
* script: none of them produces a "not found". Without the framing marker the result is an
|
|
17
|
+
* Error, never data (ssh-command.ts parseFramed).
|
|
18
|
+
* ★ EXERCISED READ-ONLY AGAINST A LIVE HOST, 2026-09-22 (Debian 13, systemd 257, ssh user uid
|
|
19
|
+
* 1001): `stat` of a file, of a missing path and of a symlink (reported `symlink`, so the lstat
|
|
20
|
+
* contract holds over the wire); `readFile` of a file and of a missing path; `getent` user and
|
|
21
|
+
* group; a failing `exec`; and an argument of `a b '$HOME` + backtick + ` ;rm` arriving verbatim,
|
|
22
|
+
* which is the quoting proven rather than argued. Nothing was written.
|
|
23
|
+
* ⚠️ ONE CONNECTION PER CALL. No multiplexing is configured here: a deploy over a slow link pays a
|
|
24
|
+
* handshake per read. Pass `sshArgs: ['-o', 'ControlMaster=auto', …]` to opt into your own
|
|
25
|
+
* ControlPersist socket — the kit does not create one behind your back, because a stale socket
|
|
26
|
+
* outliving the deploy is a surprise nobody declared.
|
|
27
|
+
*/
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
import { groupQuery, parseGroup, parseUser, userQuery } from '../launchd/host-lookup.ts';
|
|
31
|
+
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
32
|
+
import { ABSENT, frameScript, parseFramed, sshArgv } from './ssh-command.ts';
|
|
33
|
+
import {
|
|
34
|
+
execScript,
|
|
35
|
+
parseStat,
|
|
36
|
+
readScript,
|
|
37
|
+
removeScript,
|
|
38
|
+
statScript,
|
|
39
|
+
writeScript,
|
|
40
|
+
} from './ssh-scripts.ts';
|
|
41
|
+
|
|
42
|
+
export type SshRunnerOptions = {
|
|
43
|
+
/** The destination exactly as the operator's own ssh config names it (`Host` block or user@host). */
|
|
44
|
+
readonly host: string;
|
|
45
|
+
/** Extra ssh options, after the fixed safety set and before the destination. */
|
|
46
|
+
readonly sshArgs?: readonly string[];
|
|
47
|
+
/** Kill a connection after this long. @default 120_000 */
|
|
48
|
+
readonly execTimeoutMs?: number;
|
|
49
|
+
/** ssh's own `ConnectTimeout`. @default 10 */
|
|
50
|
+
readonly connectTimeoutSec?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type Raw = { exitCode: number; stdout: string; stderr: string; signal: string | null };
|
|
54
|
+
|
|
55
|
+
const spawnSsh = (argv: readonly string[], timeoutMs: number, stdin?: Uint8Array): Promise<Raw> =>
|
|
56
|
+
new Promise((resolve, reject) => {
|
|
57
|
+
const [program, ...args] = argv as [string, ...string[]];
|
|
58
|
+
const child = spawn(program, args, {
|
|
59
|
+
shell: false,
|
|
60
|
+
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
61
|
+
timeout: timeoutMs,
|
|
62
|
+
});
|
|
63
|
+
const out: Buffer[] = [];
|
|
64
|
+
const err: Buffer[] = [];
|
|
65
|
+
child.stdout?.on('data', (chunk: Buffer) => out.push(chunk));
|
|
66
|
+
child.stderr?.on('data', (chunk: Buffer) => err.push(chunk));
|
|
67
|
+
child.on('error', (cause) => {
|
|
68
|
+
reject(new Error(`ssh: ${cause.message}`));
|
|
69
|
+
});
|
|
70
|
+
if (stdin !== undefined && child.stdin !== null) {
|
|
71
|
+
// ⚠️ EPIPE is normal here: the remote `cat` can die before we finish writing, and the real
|
|
72
|
+
// failure is the exit code we are about to read — not a crash in this process.
|
|
73
|
+
child.stdin.on('error', () => undefined);
|
|
74
|
+
child.stdin.end(stdin);
|
|
75
|
+
}
|
|
76
|
+
child.on('close', (exitCode, signal) => {
|
|
77
|
+
resolve({
|
|
78
|
+
exitCode: exitCode ?? -1,
|
|
79
|
+
signal,
|
|
80
|
+
stderr: Buffer.concat(err).toString('utf8'),
|
|
81
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** What the process on the far end did — or an Error, when we cannot say that it ran at all. */
|
|
87
|
+
export const runRemote = async (
|
|
88
|
+
options: Required<Pick<SshRunnerOptions, 'host' | 'connectTimeoutSec' | 'execTimeoutMs'>> &
|
|
89
|
+
Pick<SshRunnerOptions, 'sshArgs'>,
|
|
90
|
+
nonce: string,
|
|
91
|
+
script: string,
|
|
92
|
+
stdin?: Uint8Array,
|
|
93
|
+
): Promise<ExecResult> => {
|
|
94
|
+
const argv = sshArgv(options.host, frameScript(script, nonce), {
|
|
95
|
+
connectTimeoutSec: options.connectTimeoutSec,
|
|
96
|
+
...(options.sshArgs === undefined ? {} : { sshArgs: options.sshArgs }),
|
|
97
|
+
});
|
|
98
|
+
const raw = await spawnSsh(argv, options.execTimeoutMs, stdin);
|
|
99
|
+
const framed = parseFramed(raw.stderr, nonce);
|
|
100
|
+
if (framed === undefined) {
|
|
101
|
+
// ⛔ The command may or may not have run. Saying "absent" here is how a dropped link becomes a
|
|
102
|
+
// deleted config, so it is an error with ssh's own words (truncated, never the stdout).
|
|
103
|
+
throw new Error(
|
|
104
|
+
`ssh ${options.host}: the remote command did not report a status (ssh exit ` +
|
|
105
|
+
`${String(raw.exitCode)}${raw.signal === null ? '' : `, killed by ${raw.signal}`}): ` +
|
|
106
|
+
`${raw.stderr.trim().slice(0, 300)}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { exitCode: framed.exitCode, stderr: framed.stderr, stdout: raw.stdout };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const decodeBase64 = (text: string): Uint8Array =>
|
|
113
|
+
new Uint8Array(Buffer.from(text.replaceAll('\n', ''), 'base64'));
|
|
114
|
+
|
|
115
|
+
const failed = (host: string, what: string, result: ExecResult): Error =>
|
|
116
|
+
new Error(
|
|
117
|
+
`ssh ${host}: ${what} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Connect once to learn who we are, then hand back the runner.
|
|
122
|
+
* ⛔ THE PROBE IS THE FAIL-CLOSED GATE. `effectiveUid()` is synchronous on HostRunner, so the uid
|
|
123
|
+
* must be known before the first provider call; and `uname -s` refuses a host whose `stat -c`,
|
|
124
|
+
* `base64` and `mv -f` are not the Linux spellings this runner sends. A host that cannot answer
|
|
125
|
+
* both is a thrown Error at construction, not a stack that half-deploys.
|
|
126
|
+
*/
|
|
127
|
+
export const sshRunner = async (options: SshRunnerOptions): Promise<HostRunner> => {
|
|
128
|
+
const settings = {
|
|
129
|
+
connectTimeoutSec: options.connectTimeoutSec ?? 10,
|
|
130
|
+
execTimeoutMs: options.execTimeoutMs ?? 120_000,
|
|
131
|
+
host: options.host,
|
|
132
|
+
...(options.sshArgs === undefined ? {} : { sshArgs: options.sshArgs }),
|
|
133
|
+
};
|
|
134
|
+
const nonce = randomBytes(8).toString('hex');
|
|
135
|
+
const run = (script: string, stdin?: Uint8Array) => runRemote(settings, nonce, script, stdin);
|
|
136
|
+
const probe = await run('uname -s; id -u');
|
|
137
|
+
if (probe.exitCode !== 0) throw failed(options.host, 'uname -s; id -u', probe);
|
|
138
|
+
const [system, uid] = probe.stdout.trim().split('\n');
|
|
139
|
+
if (system !== 'Linux' || uid === undefined || !/^\d+$/.test(uid)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`ssh ${options.host}: expected a Linux host, got ${JSON.stringify(probe.stdout.trim())}. ` +
|
|
142
|
+
'sshRunner() sends GNU/BusyBox argv (stat -c, base64, mv -f); for a Mac use localRunner().',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const euid = Number(uid);
|
|
146
|
+
return {
|
|
147
|
+
effectiveUid: () => euid,
|
|
148
|
+
exec: (argv) => run(execScript(argv)),
|
|
149
|
+
lookupGroup: async (nameOrId) => {
|
|
150
|
+
const result = await run(execScript(groupQuery('linux', nameOrId)));
|
|
151
|
+
return result.exitCode === 0 ? parseGroup('linux', result.stdout) : undefined;
|
|
152
|
+
},
|
|
153
|
+
lookupUser: async (nameOrId) => {
|
|
154
|
+
const result = await run(execScript(userQuery('linux', nameOrId)));
|
|
155
|
+
return result.exitCode === 0 ? parseUser('linux', result.stdout) : undefined;
|
|
156
|
+
},
|
|
157
|
+
privileged: false,
|
|
158
|
+
readFile: async (path) => {
|
|
159
|
+
const result = await run(readScript(path));
|
|
160
|
+
if (result.exitCode === ABSENT) return undefined;
|
|
161
|
+
if (result.exitCode !== 0) throw failed(options.host, `read ${path}`, result);
|
|
162
|
+
return decodeBase64(result.stdout);
|
|
163
|
+
},
|
|
164
|
+
removeFile: async (path) => {
|
|
165
|
+
const result = await run(removeScript(path));
|
|
166
|
+
if (result.exitCode !== 0) throw failed(options.host, `remove ${path}`, result);
|
|
167
|
+
},
|
|
168
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
169
|
+
stat: async (path) => {
|
|
170
|
+
const result = await run(statScript(path));
|
|
171
|
+
if (result.exitCode === ABSENT) return undefined;
|
|
172
|
+
if (result.exitCode !== 0) throw failed(options.host, `stat ${path}`, result);
|
|
173
|
+
const parsed = parseStat(result.stdout);
|
|
174
|
+
// ⛔ Unparseable is an error, not "nothing there": a `stat` that printed something we do not
|
|
175
|
+
// understand is a host this runner does not know, and guessing would write over a file.
|
|
176
|
+
if (parsed === undefined) {
|
|
177
|
+
throw new Error(`ssh ${options.host}: stat ${path} printed an unexpected shape`);
|
|
178
|
+
}
|
|
179
|
+
return parsed;
|
|
180
|
+
},
|
|
181
|
+
writeFileAtomic: async (path, bytes, write) => {
|
|
182
|
+
const at = path.lastIndexOf('/');
|
|
183
|
+
const temp = `${path.slice(0, at)}/.${path.slice(at + 1)}.${randomBytes(6).toString('hex')}.tmp`;
|
|
184
|
+
const result = await run(writeScript(path, temp, write), bytes);
|
|
185
|
+
if (result.exitCode !== 0) throw failed(options.host, `write ${path}`, result);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four remote scripts a HostRunner needs, and the parser for the one that returns data.
|
|
3
|
+
* Pure: every shape here is asserted in ssh-scripts.test.ts without a connection.
|
|
4
|
+
*
|
|
5
|
+
* ★ MEASURED 2026-09-22 on Debian 13 (systemd 257, kernel 7.0.14-pve), read-only over ssh:
|
|
6
|
+
* $ stat -c '%f %a %u %g %s' /etc/hostname → 81a4 644 0 0 4 (exit 0)
|
|
7
|
+
* $ stat -c '%f %a %u %g %s' /nonexistent → "cannot statx" (exit 1)
|
|
8
|
+
* `%f` is the RAW mode in hex, so the file type is in it (0x8000 regular, 0x4000 directory,
|
|
9
|
+
* 0xa000 symlink) and `%a` is the permission bits alone. ⛔ GNU `stat` does NOT follow symlinks
|
|
10
|
+
* without `-L`, which is what makes this an lstat — the FileStat contract (runner.ts) requires it,
|
|
11
|
+
* because writing through a symlink changes a file the resource never named.
|
|
12
|
+
* ⚠️ COREUTILS, NOT POSIX. `stat -c`, `base64` and `mv -f` are GNU/BusyBox spellings, not POSIX
|
|
13
|
+
* ones; this runner is for Linux hosts and refuses anything else at the probe (ssh-runner.ts).
|
|
14
|
+
*/
|
|
15
|
+
import { ABSENT, quoteArgv, shellQuote } from './ssh-command.ts';
|
|
16
|
+
import type { FileStat, WriteOptions } from '../launchd/runner.ts';
|
|
17
|
+
|
|
18
|
+
/** ★ `-e` is false for a dangling symlink and `-L` is true for it, so both tests together mean
|
|
19
|
+
* "something is at this path" — the question stat(2) answers and `-e` alone does not. */
|
|
20
|
+
const IF_PRESENT = (path: string) => `if [ -e ${path} ] || [ -L ${path} ]; then`;
|
|
21
|
+
|
|
22
|
+
export const statScript = (path: string): string => {
|
|
23
|
+
const quoted = shellQuote(path);
|
|
24
|
+
return `${IF_PRESENT(quoted)} stat -c '%f %a %u %g %s' -- ${quoted}; else exit ${String(ABSENT)}; fi`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** ★ base64 on the wire: stdout then carries no byte that a shell, a pty or a locale can alter. */
|
|
28
|
+
export const readScript = (path: string): string => {
|
|
29
|
+
const quoted = shellQuote(path);
|
|
30
|
+
return `${IF_PRESENT(quoted)} base64 -- ${quoted}; else exit ${String(ABSENT)}; fi`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** ⛔ `rm -f` is idempotent for an absent path and still FAILS for a directory, which is what keeps
|
|
34
|
+
* HostRunner.removeFile from quietly removing something that is not a file. */
|
|
35
|
+
export const removeScript = (path: string): string => `rm -f -- ${shellQuote(path)}`;
|
|
36
|
+
|
|
37
|
+
export const execScript = (argv: readonly string[]): string => quoteArgv(argv);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write bytes (fed on stdin) so `path` holds the old file or the new one and never a torn mix.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ NOT `install`. GNU coreutils `install` opens the destination and writes THROUGH it — unlike
|
|
43
|
+
* macOS's, which stages and renames — so a deploy interrupted mid-write would leave a truncated
|
|
44
|
+
* config where a whole one used to be. Staging beside the target and `mv` (rename(2), same
|
|
45
|
+
* directory so never EXDEV) is the only atomic spelling available over a shell.
|
|
46
|
+
* ⛔ `set -C` and `umask 077`: the staged file is created O_EXCL, so a symlink or a file planted at
|
|
47
|
+
* the temp name is a refusal rather than a write through it, and it is unreadable by anyone else
|
|
48
|
+
* in the moment before its mode is set.
|
|
49
|
+
* ⚠️ chown BEFORE chmod: chown by root clears setuid/setgid, so the other order silently drops a
|
|
50
|
+
* declared 4755 — the same ordering localRunner keeps for the same reason.
|
|
51
|
+
* ⚠️ Each step removes the staged file before it fails, so a failed write leaves no litter beside
|
|
52
|
+
* a file someone else owns.
|
|
53
|
+
*/
|
|
54
|
+
export const writeScript = (path: string, temp: string, options: WriteOptions): string => {
|
|
55
|
+
const target = shellQuote(path);
|
|
56
|
+
const staged = shellQuote(temp);
|
|
57
|
+
const bail = `{ rm -f -- ${staged}; exit`;
|
|
58
|
+
/**
|
|
59
|
+
* ⛔ NEVER `chown <uid>:` — GNU chown reads a trailing colon as "and that user's LOGIN GROUP",
|
|
60
|
+
* which is a gid nobody declared. Each half is spelled only when it was asked for.
|
|
61
|
+
*/
|
|
62
|
+
const owner =
|
|
63
|
+
options.uid === undefined
|
|
64
|
+
? options.gid === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: `:${String(options.gid)}`
|
|
67
|
+
: options.gid === undefined
|
|
68
|
+
? String(options.uid)
|
|
69
|
+
: `${String(options.uid)}:${String(options.gid)}`;
|
|
70
|
+
const chown = owner === undefined ? [] : [`chown ${owner} -- ${staged} || ${bail} 72; }`];
|
|
71
|
+
return [
|
|
72
|
+
'set -C',
|
|
73
|
+
'umask 077',
|
|
74
|
+
`cat > ${staged} || ${bail} 71; }`,
|
|
75
|
+
...chown,
|
|
76
|
+
`chmod ${options.mode.toString(8).padStart(3, '0')} -- ${staged} || ${bail} 73; }`,
|
|
77
|
+
`mv -f -- ${staged} ${target} || ${bail} 74; }`,
|
|
78
|
+
].join('\n');
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const TYPE_BITS = 0xf000;
|
|
82
|
+
const KINDS = new Map<number, FileStat['kind']>([
|
|
83
|
+
[0x8000, 'file'],
|
|
84
|
+
[0x4000, 'directory'],
|
|
85
|
+
[0xa000, 'symlink'],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** `stat -c '%f %a %u %g %s'` output → a FileStat, or `undefined` when it is not that shape. */
|
|
89
|
+
export const parseStat = (stdout: string): FileStat | undefined => {
|
|
90
|
+
const fields = stdout.trim().split(/\s+/);
|
|
91
|
+
if (fields.length !== 5) return undefined;
|
|
92
|
+
const [rawMode, perms, uid, gid, size] = fields as [string, string, string, string, string];
|
|
93
|
+
const raw = Number.parseInt(rawMode, 16);
|
|
94
|
+
const mode = Number.parseInt(perms, 8);
|
|
95
|
+
const numbers = [raw, mode, Number(uid), Number(gid), Number(size)];
|
|
96
|
+
if (numbers.some((value) => !Number.isSafeInteger(value) || value < 0)) return undefined;
|
|
97
|
+
return {
|
|
98
|
+
gid: Number(gid),
|
|
99
|
+
kind: KINDS.get(raw & TYPE_BITS) ?? 'other',
|
|
100
|
+
// ⚠️ `%a` alone, never `%f & 0o7777`: they agree, and the permission field is the documented one.
|
|
101
|
+
mode,
|
|
102
|
+
size: Number(size),
|
|
103
|
+
uid: Number(uid),
|
|
104
|
+
};
|
|
105
|
+
};
|