Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c84cd9e6c | ||
|
|
19a4055bb5 | ||
|
|
1bc6daf31b | ||
|
|
9208f7409a | ||
|
|
a80af7a97f | ||
|
|
83a51832d7 | ||
|
|
9e63a258d0 | ||
|
|
328d90fb88 | ||
|
|
ca5ab288f6 | ||
|
|
5fd0f72221 | ||
|
|
5003dc0377 | ||
|
|
0a78d130e5 | ||
|
|
4295aad88e | ||
|
|
9dfd3b1105 | ||
|
|
22e8714040 | ||
|
|
11e7ede8a4 | ||
|
|
27315d35cf | ||
|
|
525c639041 | ||
|
|
42c80fa9e1 | ||
|
|
56e0830700 | ||
|
|
0844b35ebb | ||
|
|
566739e02c | ||
|
|
38b56a7877 | ||
|
|
0596503db5 | ||
|
|
3668b02b94 | ||
|
|
c0159ca0a1 | ||
|
|
159ed53103 | ||
|
|
723e676b75 | ||
|
|
661114370f | ||
|
|
a1d7b9ad5a | ||
|
|
5548b0f4e9 | ||
|
|
295cc59e1e | ||
|
|
6a7e4ebd5e | ||
|
|
9e8146192c | ||
|
|
6171d747db | ||
|
|
84609c0373 | ||
|
|
28d5e7f939 | ||
|
|
4128380a3d | ||
|
|
cf57d3fd8f | ||
|
|
da8a46cfe8 | ||
|
|
ed50130a6a | ||
|
|
bd2123166f | ||
|
|
d9db931bd0 | ||
|
|
69f2efb591 | ||
|
|
568674fef7 | ||
|
|
3c6b70845c | ||
|
|
35ef72081f | ||
|
|
59c42b2086 | ||
|
|
3b8164f1c0 | ||
|
|
8877f893e5 | ||
|
|
043ae17fbf | ||
|
|
944f086ec7 | ||
|
|
dd93cfd613 | ||
|
|
64cc292d7e | ||
|
|
7e889adf71 | ||
|
|
7e9ef899c1 | ||
|
|
03e729d103 | ||
|
|
eab335b755 | ||
|
|
f42b58f789 | ||
|
|
5737752744 | ||
|
|
f79199777d | ||
|
|
b9d0884335 | ||
|
|
6a6d5747a3 | ||
|
|
b19c4a2593 | ||
|
|
a934e2a69f | ||
|
|
af346f6066 | ||
|
|
7b0cfceb68 | ||
|
|
26021865c1 | ||
|
|
7440b8d009 | ||
|
|
0cb67e856f | ||
|
|
038a0a25ce | ||
|
|
1b27ce319a | ||
|
|
7563569c8a | ||
|
|
efff54157b | ||
|
|
2000ec3f48 | ||
|
|
7c800705bf | ||
|
|
19511012f8 | ||
|
|
0e8ad3a8e1 | ||
|
|
b43e405947 | ||
|
|
d5269c8662 | ||
|
|
aa5bf7d5ef | ||
|
|
510de183b1 | ||
|
|
db5e7c80cf | ||
|
|
38be3ba7f0 | ||
|
|
a19638d112 | ||
|
|
1cbddeb104 | ||
|
|
301aeda5f3 | ||
|
|
853ace3828 | ||
|
|
8ccc6762d4 | ||
|
|
6d01007ea6 | ||
|
|
02463ba55d | ||
|
|
85af10ebad | ||
|
|
0778f8f0ae | ||
|
|
6afc1160b6 | ||
|
|
c32edcfc6f | ||
|
|
740359ffd9 | ||
|
|
a309deb479 | ||
|
|
0fd722e829 | ||
|
|
2e6cc71f7a | ||
|
|
84403aec0c | ||
|
|
b28b1b9f24 | ||
|
|
810c7fbac3 | ||
|
|
304044da40 | ||
|
|
419d92e810 | ||
|
|
23112b111c | ||
|
|
b547308e05 | ||
|
|
3c3c5c6e03 | ||
|
|
1601d5a335 | ||
|
|
96b3d60a4a | ||
|
|
3dfbad6f03 | ||
|
|
d5c5415756 | ||
|
|
6dfd2401c9 | ||
|
|
31923f70e7 | ||
|
|
8cd4f199f1 | ||
|
|
1b9b298827 | ||
|
|
bce7b3a551 | ||
|
|
17d3d3e63a | ||
|
|
5c961c446f | ||
|
|
b3865d240f |
@@ -1,22 +0,0 @@
|
||||
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/anthropic-consumer
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
|
||||
# No serve-time entrypoints: every container of this module names its command (`run` on a
|
||||
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
|
||||
@@ -14,19 +14,10 @@
|
||||
"secrets": {
|
||||
"model-access": "${dir:state}/access-token"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"emits": [
|
||||
"usage.session"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -46,66 +37,39 @@
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-apply",
|
||||
"network": "host",
|
||||
"type": "process",
|
||||
"name": "anthropic-consumer-apply",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"apply/index.js"
|
||||
],
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/apply/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
|
||||
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"id": "usage",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-usage",
|
||||
"network": "host",
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/usage/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
|
||||
"MESH_TOOLS_MAIN": "/app/dist/main.js"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
|
||||
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
|
||||
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"apply/index.js",
|
||||
"usage/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"usage/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
||||
// attribution is done — just the totals (port map "don't-map" #3).
|
||||
//
|
||||
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
|
||||
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
|
||||
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
|
||||
// the runtime as this module; the totals are also written to a file so the reading is observable
|
||||
// without one.
|
||||
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
|
||||
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
||||
|
||||
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
|
||||
@@ -116,22 +119,20 @@ function atomicWrite(path: string, content: string): void {
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
|
||||
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
|
||||
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[main, "emit", "usage.session", JSON.stringify(body)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", (err) => {
|
||||
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
await emit("usage.session", body);
|
||||
} catch (err) {
|
||||
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
|
||||
// runtime's handshake is answered while a long first reading is still under way.
|
||||
const EVERY_MS = 5 * 60 * 1000;
|
||||
const tick = (): void => {
|
||||
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, EVERY_MS);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# asus-zephyrus-g14
|
||||
|
||||
The hardware module for the **ASUS ROG Zephyrus G14** laptop: its vendor daemon and platform
|
||||
profiles, the hybrid GPU's mode and driver options, suspend, the lid and power key, low battery,
|
||||
the backlights, the vendor keys and the touchpad (novox/hq research 027/03 *Power management on
|
||||
the laptop*, research 026/05, to-be 42 phase 3).
|
||||
|
||||
## Why this name
|
||||
|
||||
A module is named after the hardware model, never the node (novox/hq ADR 0112; research 026/03:
|
||||
no flavors, no machine names). `asus-zephyrus-g14` is the model family exactly as the firmware
|
||||
reports it (`/sys/class/dmi/id/product_family` = `ROG Zephyrus G14`). The module's code checks that
|
||||
value and its switcher does nothing on any other model, and `zephyrus_check` reports it.
|
||||
|
||||
A wider name such as `asus-rog-laptop` would promise what this module cannot keep. Its contents
|
||||
belong to this family: the vendor-key scan codes, the eDP panel beside an NVIDIA dGPU, and the NVIDIA
|
||||
D3 workaround. A second G14 is assigned the same module. Another ROG model gets its own.
|
||||
|
||||
Written against the GA403 (2024, Ryzen 8945HS, RTX 4070 Laptop, hybrid). Older G14 years have the same
|
||||
daemons and probably the same keys. Their GPU options are unverified.
|
||||
|
||||
## What it owns
|
||||
|
||||
| | what | how |
|
||||
|---|---|---|
|
||||
| package | `asusctl` (asusd + client) | the distribution's package (`extra`). The machine was found with a local build of 6.4.0. The host only asserts *present*, so the switch to 6.5.0 from `extra` happens at the next `pacman -Syu` (or `pacman -S asusctl`). `zephyrus_check` flags a local build |
|
||||
| package | `upower`, `playerctl`, `xorg-xinput` | what the low-battery drop-in, the media keys and the touchpad key use |
|
||||
| service | `asusd` running (static unit: no boot state to declare), `supergfxd` running and enabled | |
|
||||
| archive | `/usr/local/lib/asus-zephyrus-g14/bin/` | the module's scripts, from `files/bin` (below) |
|
||||
| file | `/etc/modprobe.d/g14-nvidia-power.conf` | `NVreg_DynamicPowerManagement=0x00` (runtime D3 off: the ACPI D-Notifier hang) and `NVreg_PreserveVideoMemoryAllocations=1`. The path is adopted (ADR 0182) |
|
||||
| file | `/etc/modprobe.d/video-brightness-switch.conf` | `video.brightness_switch_enabled=0`, so the ACPI video driver does not also move a backlight on the keys. The file was on the machine and owned by nothing |
|
||||
| file ×3 | `systemd-{suspend,hibernate,suspend-then-hibernate}.service.d/asus-zephyrus-g14-nvidia.conf` | `Wants=` the matching `nvidia-*` sleep units and `nvidia-resume` (see *suspend units* below) |
|
||||
| file | `nvidia-powerd.service.d/asus-zephyrus-g14.conf` | `ConditionKernelCommandLine=zephyrus.nvidia-powerd`: Dynamic Boost runs only when the operator opts in at boot |
|
||||
| file | `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid suspend, on battery, on mains and docked. `systemd-logind` is reloaded, never restarted |
|
||||
| file | `/etc/udev/rules.d/90-backlight.rules` | backlights writable by the `video` group. `systemd-udevd` is reloaded |
|
||||
| file | `triggerhappy.service.d/asus-zephyrus-g14.conf` | `thd … --user ${machine:account}`: the triggers run as the operator's account (below) |
|
||||
| file | `/etc/triggerhappy/triggers.d/asus-g14.conf` | the vendor keys: media (`KEY_PROG1/3/4`), panel brightness, touchpad (`KEY_F21`). The path is adopted, because two trigger files would fire every key twice |
|
||||
| file | `/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf` | low battery at 15/10/7 %; at 7 % **suspend**, not power off. A drop-in over the package's own file |
|
||||
| file | `/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf` | tap to click, natural scrolling, acceleration 0.15, as an X input class |
|
||||
|
||||
**What it does not own, on purpose:**
|
||||
|
||||
- `/etc/asusd/*.ron` belong to asusd, which rewrites them whenever a setting changes. RON is not a
|
||||
format the host writes into (ADR 0102 speaks JSON and marked blocks). Owning the file whole would
|
||||
repeat the predecessor's freeze: the measured file already differs from the one the predecessor
|
||||
shipped. The settings the module needs are set through asusd, by its code (below).
|
||||
- `/etc/supergfxd.conf` and `/etc/modprobe.d/supergfxd.conf` belong to supergfxd, which writes both.
|
||||
- The swap file, its unit and the swap partition are the machine's swap layout (research 027,
|
||||
question 3). They are not this module's, nor `memory-pressure`'s.
|
||||
- The i3 fragments (`~/.config/i3/config.d/10-asus.conf`, `20-g14.conf`) and the keyboard-backlight
|
||||
notifier they start belong to phase 2 (the `i3` module).
|
||||
|
||||
## Software outside the distribution (ADR 0205, research 027 question 1)
|
||||
|
||||
`supergfxctl` (5.2.7, from the asus-linux repository, which is no longer configured) and
|
||||
`triggerhappy` (AUR) are **kept as found, and depended on**. The module declares no package for
|
||||
either, because the host installs from the official repositories only. It declares their services
|
||||
(`supergfxd` running, `triggerhappy` running), so on a machine without them the host refuses the
|
||||
service by name: *does not exist on this machine*. The refusal is loud, never a silent pass.
|
||||
`zephyrus_check` names both as foreign.
|
||||
|
||||
This module does not choose between the options of research 027 question 1. Under the starting
|
||||
position (P2: the build machine builds AUR packages into a repository the mesh serves), both become
|
||||
`package` resources here, and a fresh G14 installs them. **Until P2 exists, a fresh G14 is blocked
|
||||
on installing these two by hand.** ADR 0205's vendored archive (P1) does not fit: supergfxctl is a
|
||||
daemon with a system-bus policy and udev rules, and triggerhappy is C.
|
||||
|
||||
A later option for the keys: the module's own Go code could read the vendor keys from evdev, which
|
||||
the operator's account may do through the `input` group. That would retire triggerhappy entirely.
|
||||
It is not done here, because it would put the keys behind the node's runtime, and the runtime
|
||||
restarts a bundle that dies only on its next call (below).
|
||||
|
||||
## The long-running code: the profile switcher (ADR 0198)
|
||||
|
||||
The module's Go bundle serves the tools and runs the platform-profile switcher in the same process.
|
||||
The node's runtime launches the bundle at the runtime's start. It replaces the predecessor's
|
||||
`auto-profile`, a user unit that woke every five seconds, on battery too.
|
||||
|
||||
- **Policy** (constants until settings exist, issue 168): battery → `Quiet`; mains → `Balanced`;
|
||||
mains with the CPU at or above 50 % for 3 samples of 10 s → `Performance`, back to `Balanced` after
|
||||
3 samples at or below 20 %. Between the lines nothing moves (hysteresis). iowait counts as idle.
|
||||
- **Woken by events, not a poll.** The kernel's power-supply uevents (netlink, group 1) wake the
|
||||
switcher. Any account may listen on that group, and it needs no daemon, bus client or dependency;
|
||||
upower re-announces the same changes but would need a D-Bus client in the bundle. The CPU is
|
||||
sampled only on mains, every 10 s, because only there does the answer depend on it. On battery,
|
||||
a safety re-read every 5 minutes covers an event lost across a suspend. If the uevent socket
|
||||
cannot be opened, the switcher polls every 10 s and says so in `zephyrus_profile_policy`.
|
||||
- **The battery decides the source.** A battery that is *discharging* means battery, whatever any
|
||||
adapter says. The predecessor took any `online` file reading 1 as mains, and on this model the USB-C
|
||||
ports report `online`. Batteries of `scope=Device` (a mouse, a headset) are ignored.
|
||||
- **It acts on a change of its decision, never to restore one.** A profile someone chose by hand (the
|
||||
profile key, asusctl, `zephyrus_profile`) stays until the power source changes or the load crosses
|
||||
a line. The predecessor re-asserted its choice every five seconds, which made the profile key
|
||||
useless. **Starting is not a decision**: the runtime restarts the bundle on every push that changes
|
||||
one, and a push must not reset the operator's profile.
|
||||
- **A hold.** `zephyrus_profile` holds the profile it sets for 60 min (`hold_minutes`). A change of
|
||||
power source ends the hold.
|
||||
- **One assertion at start:** through asusctl, the charge limit (80 %) and asusd's own on-mains and
|
||||
on-battery profiles (`Balanced`, `Quiet`), each read first and set only if it differs. asusd's own
|
||||
switching on a change of power source then agrees with the switcher's. A limit set later with
|
||||
`zephyrus_charge_limit` stands until the bundle next starts. For a one-off full charge, use its
|
||||
`oneshot`.
|
||||
- **Events:** `profile.switched` (`profile`, `from`, `reason`, `source`), published through the
|
||||
runtime.
|
||||
|
||||
No root is involved. asusd's and supergfxd's bus policies admit the `users` and `wheel` groups, and the
|
||||
runtime runs as the operator's account. The one write that may escalate is the panel's backlight,
|
||||
when the udev rule has not run yet. It uses `sudo -n` and never prompts. Every command is bounded at
|
||||
20 s.
|
||||
|
||||
**Known limit.** The runtime restarts a launched bundle that exits *on its next tool call*, not at
|
||||
once (mesh-tools `launch.ts`), so a crashed switcher stays down until a tool is called. ADR 0198 §1
|
||||
says *started again when it exits*. The switcher recovers from a panic and reports it in
|
||||
`zephyrus_profile_policy` and `zephyrus_check`, but a crash of the process is the runtime's to restart.
|
||||
|
||||
## The vendor keys and the scripts
|
||||
|
||||
triggerhappy opens the input devices as root, then **drops to the operator's account with its groups**
|
||||
(`initgroups`: `input`, `video`). The packaged unit already drops to `nobody`, and the module's drop-in
|
||||
names the account instead. The predecessor replaced the packaged unit with one that ran every trigger
|
||||
as root, then `su`-ed to a named person with a hard-coded uid and display, and sourced a file of
|
||||
secrets on the way (research 027 question 2). Now:
|
||||
|
||||
- `zephyrus-session CMD…`: runs a command in the account's graphical session. It sets the account's
|
||||
own bus (`/run/user/<uid>/bus`) and finds the display from logind, or from a process of the account
|
||||
that has one. Nothing is sourced.
|
||||
- `zephyrus-backlight + | - | N`: the panel in 5 % steps, never below 1 %. **The panel is the
|
||||
backlight under the eDP connector**, because this model also registers `nvidia_0`, which moves
|
||||
nothing. The predecessor named `amdgpu_bl1` literally.
|
||||
- `zephyrus-notify ID TEXT`: one replacing notification, through `busctl` (the service manager's
|
||||
client, so no libnotify).
|
||||
- `zephyrus-touchpad reset | toggle`: bound to the touchpad key (`KEY_F21`).
|
||||
|
||||
**Media keys** go to MPRIS through `playerctl`. The predecessor's Plex fallback needed a Plex token
|
||||
from the secrets file and is dropped until a module can be handed a secret (research 027 question 2).
|
||||
|
||||
## The touchpad: an input class instead of a sleep hook
|
||||
|
||||
The predecessor re-ran `xinput` from `/etc/systemd/system-sleep/` after every resume, as a named person
|
||||
on a guessed display, because settings made with `xinput` are lost when the device initialises again.
|
||||
An X input class is applied by X **every time the device appears**: at login, on hotplug and after a
|
||||
resume. So the cause is fixed, and the hook is gone. The class matches any touchpad on the machine,
|
||||
which is the model's, so it holds across G14 years whose touchpads differ. It takes effect at the next
|
||||
X start. `zephyrus-touchpad reset` stays as the manual form.
|
||||
|
||||
## Suspend units without enabling them
|
||||
|
||||
`nvidia-suspend`, `-hibernate`, `-suspend-then-hibernate` and `-resume` are enabled with links in the
|
||||
sleep services' `.wants` directories. The mesh makes no links (ADR 0012). The host's service shape
|
||||
cannot declare them either: it may only say *running* or *stopped*, and *running* on a one-shot that
|
||||
last failed would start `nvidia-sleep.sh suspend` with the machine awake. So the module asks for them
|
||||
from the other side: a drop-in on each sleep service that `Wants=` them. The units' own
|
||||
`Before=`/`After=` order them. The found links stay and are harmless.
|
||||
|
||||
`suspend-then-hibernate` now also gets `nvidia-suspend-then-hibernate`, which the machine lacked.
|
||||
|
||||
The drop-ins take effect at the service manager's next `daemon-reload`. In the same apply, the restart
|
||||
of `triggerhappy` (whose drop-in changes) performs one.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | r/a | what |
|
||||
|---|---|---|
|
||||
| `zephyrus_brightness` | r/a | panel (percent or ±step, floor 1 %) and keyboard (off/low/med/high, 0-3, ±) through asusd |
|
||||
| `zephyrus_battery` | r | charge, energy in Wh, health (full ÷ design), cycles (the firmware reports 0, and this is said), limit, watts, hours left |
|
||||
| `zephyrus_charge_limit` | r/a | 20-100 through asusd; `oneshot` |
|
||||
| `zephyrus_gpu_mode` | r/a | mode, supported modes, dGPU power, the pending mode and action; says that asusd switches the mode on every change of power source |
|
||||
| `zephyrus_profile` | r/a | active, on-mains and on-battery profile, kernel platform profile; set with a hold |
|
||||
| `zephyrus_thermals` | r | every hwmon temperature and fan, the hottest, the dGPU's temperature **only when it is awake** (nvidia-smi wakes a suspended GPU) |
|
||||
| `zephyrus_power_draw` | r | battery flow, APU package power (PPT), dGPU draw when awake, power source and why |
|
||||
| `zephyrus_profile_policy` | r | what the switcher would choose now and why: source, recent load against the thresholds, decision, hold, last switch, what woke it, what it asserted at start, and whether the predecessor's switcher still runs |
|
||||
| `zephyrus_fan_curves` | r | asusd's curves per profile and fan |
|
||||
| `zephyrus_check` | r | every expectation: model, packages (local or foreign), daemons, nvidia-powerd, sleep units, the NVIDIA options **in force** (`/proc/driver/nvidia/params`), charge limit, one authority each over the profile and the GPU mode, predecessor leftovers. It also lists what it did not check |
|
||||
|
||||
`profile` is a candidate verb for a future `node-power-profile` seat (research 027/03). That seat has
|
||||
no record yet, so this is the module's own tool.
|
||||
|
||||
## Found on the laptop, 2026-10-04 (read-only)
|
||||
|
||||
- **Two authorities over the GPU mode.** `asusd.ron` has `ac_command: "supergfxctl -m Hybrid"` and
|
||||
`bat_command: "supergfxctl -m Integrated"`, so asusd switches the GPU mode on every change of power
|
||||
source. **supergfxd 5.2.7 cannot read logind's sessions** (`manager is an invalid variant`, every
|
||||
boot), so a switch that needs a logout times out. `zephyrus_check` reports both. The fix is the
|
||||
operator's, in asusd's file: clear both commands, or update supergfxctl once it can be packaged.
|
||||
- **`brightness.conf` did nothing.** `HandleBrightnessKey` is not a logind key, and logind logs
|
||||
*Unknown key … ignoring* at every start. The module does not carry it. The brightness keys were
|
||||
always triggerhappy's, with the ACPI video switch off.
|
||||
- **Two profile switchers** would run at once until `auto-profile` is stopped (below).
|
||||
- **asusctl is a local build** (6.4.0, *Unknown Packager*) beside a foreign `asusctl-debug`.
|
||||
|
||||
## When assigned to the laptop: what changes
|
||||
|
||||
1. `/usr/local/lib/asus-zephyrus-g14/` appears (four scripts).
|
||||
2. Written over found files (each original kept once by the host): `g14-nvidia-power.conf` and
|
||||
`video-brightness-switch.conf` (same options, so no change until the next boot either),
|
||||
`logind.conf.d/power.conf` (same keys; logind reloaded), `90-backlight.rules` (same effect;
|
||||
udevd reloaded), `triggers.d/asus-g14.conf` (now the module's scripts).
|
||||
3. New: the three sleep drop-ins (behaviour gained: `nvidia-suspend-then-hibernate`), the
|
||||
nvidia-powerd drop-in (no effect while it is masked), the triggerhappy drop-in, the UPower drop-in
|
||||
(same values as today), and the touchpad input class (at the next X start).
|
||||
4. `daemon-reload` and a `triggerhappy` restart. thd now runs as the account and the keys run the
|
||||
module's scripts. `upower` restarts.
|
||||
5. Packages, asusd and supergfxd: already as declared, so nothing changes. asusctl stays the local
|
||||
6.4.0 until the next upgrade.
|
||||
6. The node runtime restarts with the new bundle. The switcher asserts the limit (80, already) and
|
||||
asusd's profiles (Balanced and Quiet, already), so it sets nothing. It takes the current decision
|
||||
as applied and acts from the first event on.
|
||||
|
||||
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
|
||||
|
||||
**On the laptop:**
|
||||
|
||||
1. `systemctl --user disable --now auto-profile.service`, then delete
|
||||
`~/.config/systemd/user/auto-profile.service` and `~/scripts/auto-profile`. **Do this right after
|
||||
the push**, or two switchers run at once.
|
||||
2. `~/scripts/asus-bright`, `~/scripts/asusctl-kbd-bright`, `~/scripts/xrandr-bright`,
|
||||
`~/scripts/as-user`, `~/scripts/media-control`: no trigger and no i3 binding uses them any more.
|
||||
3. `~/scripts/xinput-reset-touchpad`: still started and bound by `~/.config/i3/config.d/20-g14.conf`.
|
||||
Point those two lines at `/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset`, or wait for
|
||||
the i3 module (phase 2) to rewrite the fragment.
|
||||
4. `/etc/systemd/system/triggerhappy.service`: the predecessor's replacement of the packaged unit. The
|
||||
module's drop-in works over either, so delete it and `systemctl daemon-reload` to return to the
|
||||
packaged unit (`Type=notify`, socket).
|
||||
5. `/etc/systemd/logind.conf.d/brightness.conf`: the unknown key, which does nothing.
|
||||
6. `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`: replaced by the input class.
|
||||
7. `/etc/UPower/UPower.conf`: the predecessor's replacement of the package's file. Its values are now
|
||||
the module's drop-in. Restore the package's copy (`rm` it, then `pacman -S upower`).
|
||||
8. Optional: `systemctl disable nvidia-suspend nvidia-resume nvidia-hibernate` (the drop-ins carry them
|
||||
now), `/etc/asusd/*.ron-old` and `fan_curves.ron.bak`, the foreign `asusctl-debug` package, and
|
||||
`pacman -S asusctl` for the distribution's build.
|
||||
|
||||
**Stays the machine's:** `/swapfile` and `/etc/systemd/system/swapfile.swap` (the swap layout),
|
||||
`/etc/udev/rules.d/91-monitor-hotplug.rules` (the display's, phase 2), and the i3 fragments.
|
||||
|
||||
**On the desktop** (the predecessor's G14 flavor reached it; part was removed on 2026-10-04): none of
|
||||
this module applies there. Still present and to be deleted:
|
||||
`/etc/systemd/logind.conf.d/brightness.conf`, `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`,
|
||||
`~/scripts/xinput-reset-touchpad`, `~/scripts/xrandr-bright` and
|
||||
`~/.config/i3/scripts/kbd-brightness-notify.sh`.
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...` in this directory. Every tool runs against a tree standing in for `/sys`, `/proc` and
|
||||
`/etc`, and an injected runner answering with what asusctl 6.4 and supergfxctl 5.2 said on the laptop.
|
||||
The tests cover:
|
||||
|
||||
- the power-source rule;
|
||||
- battery arithmetic from `charge_*`;
|
||||
- the eDP panel choice;
|
||||
- brightness bounds;
|
||||
- the policy's sustain, relax and hysteresis, with iowait counted as idle;
|
||||
- the switcher: no act at start, one switch per change of source, a published event, boost from
|
||||
samples, holds, retry after failure, start-up assertions only where they differ, inert on another
|
||||
model;
|
||||
- the uevent filter;
|
||||
- the manifest: tools listed equal tools served, no machine named, triggers exist, every key runs a
|
||||
shipped executable script, every script passes `bash -n`.
|
||||
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The vendor daemons are reached through their own command-line clients, which speak to them on the
|
||||
// system bus. Their bus policy admits the `users` and `wheel` groups, so none of this needs root.
|
||||
|
||||
// Profiles are the platform profiles asusd offers on this model, in its spelling.
|
||||
var Profiles = []string{"Quiet", "Balanced", "Performance"}
|
||||
|
||||
// canonicalProfile accepts any case and answers asusd's spelling, or an error naming the choices.
|
||||
func canonicalProfile(s string) (string, error) {
|
||||
for _, p := range Profiles {
|
||||
if strings.EqualFold(strings.TrimSpace(s), p) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("profile %q is not one of %s", s, strings.Join(Profiles, ", "))
|
||||
}
|
||||
|
||||
// ProfileState is what asusd says about the platform profile.
|
||||
type ProfileState struct {
|
||||
Active string `json:"active"`
|
||||
OnAC string `json:"on_ac,omitempty"`
|
||||
Battery string `json:"on_battery,omitempty"`
|
||||
Platform string `json:"platform_profile,omitempty"`
|
||||
Choices string `json:"platform_profile_choices,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
activeProfile = regexp.MustCompile(`(?m)^Active profile:\s*(\S+)`)
|
||||
acProfile = regexp.MustCompile(`(?m)^AC profile\s+(\S+)`)
|
||||
batteryProfile = regexp.MustCompile(`(?m)^Battery profile\s+(\S+)`)
|
||||
)
|
||||
|
||||
// ParseProfileGet reads `asusctl profile get`.
|
||||
func ParseProfileGet(out string) (ProfileState, error) {
|
||||
var p ProfileState
|
||||
if m := activeProfile.FindStringSubmatch(out); m != nil {
|
||||
p.Active = m[1]
|
||||
} else {
|
||||
return p, fmt.Errorf("asusctl profile get said no active profile: %q", strings.TrimSpace(out))
|
||||
}
|
||||
if m := acProfile.FindStringSubmatch(out); m != nil {
|
||||
p.OnAC = m[1]
|
||||
}
|
||||
if m := batteryProfile.FindStringSubmatch(out); m != nil {
|
||||
p.Battery = m[1]
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Profile reads the platform profile from asusd and the kernel.
|
||||
func (m *Machine) Profile(ctx context.Context) (ProfileState, error) {
|
||||
out, err := m.Run(ctx, "asusctl", "profile", "get")
|
||||
if err != nil {
|
||||
return ProfileState{}, vendor("asusctl", err)
|
||||
}
|
||||
p, err := ParseProfileGet(out)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.Platform = m.read("/sys/firmware/acpi/platform_profile")
|
||||
p.Choices = m.read("/sys/firmware/acpi/platform_profile_choices")
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SetProfile has asusd switch the active profile.
|
||||
func (m *Machine) SetProfile(ctx context.Context, profile string) error {
|
||||
_, err := m.Run(ctx, "asusctl", "profile", "set", profile)
|
||||
return vendor("asusctl", err)
|
||||
}
|
||||
|
||||
var chargeLimit = regexp.MustCompile(`charge limit:\s*(\d+)\s*%`)
|
||||
|
||||
// ChargeLimit is the battery's charge limit as asusd reports it.
|
||||
func (m *Machine) ChargeLimit(ctx context.Context) (int, error) {
|
||||
out, err := m.Run(ctx, "asusctl", "battery", "info")
|
||||
if err != nil {
|
||||
return 0, vendor("asusctl", err)
|
||||
}
|
||||
g := chargeLimit.FindStringSubmatch(out)
|
||||
if g == nil {
|
||||
return 0, fmt.Errorf("asusctl battery info said no limit: %q", strings.TrimSpace(out))
|
||||
}
|
||||
n, _ := strconv.Atoi(g[1])
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Keyboard backlight levels in asusd's spelling, index = the kernel's brightness value.
|
||||
var KeyboardLevels = []string{"off", "low", "med", "high"}
|
||||
|
||||
var ledLevel = regexp.MustCompile(`(?i)brightness:\s*(off|low|med|high)`)
|
||||
|
||||
// ParseLeds reads `asusctl leds get`.
|
||||
func ParseLeds(out string) (string, error) {
|
||||
g := ledLevel.FindStringSubmatch(out)
|
||||
if g == nil {
|
||||
return "", fmt.Errorf("asusctl leds get said no level: %q", strings.TrimSpace(out))
|
||||
}
|
||||
return strings.ToLower(g[1]), nil
|
||||
}
|
||||
|
||||
// FanCurve is one fan's curve in one profile: eight points of temperature (°C) and duty (0-255).
|
||||
type FanCurve struct {
|
||||
Fan string `json:"fan"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Temp []int `json:"temp_c"`
|
||||
PWM []int `json:"pwm"`
|
||||
}
|
||||
|
||||
var (
|
||||
fanBlock = regexp.MustCompile(`(?s)fan:\s*(\w+),\s*pwm:\s*\(([^)]*)\),\s*temp:\s*\(([^)]*)\),\s*enabled:\s*(true|false)`)
|
||||
)
|
||||
|
||||
// ParseFanCurves reads `asusctl fan-curve --mod-profile <p>`.
|
||||
func ParseFanCurves(out string) []FanCurve {
|
||||
var curves []FanCurve
|
||||
for _, g := range fanBlock.FindAllStringSubmatch(out, -1) {
|
||||
curves = append(curves, FanCurve{Fan: g[1], PWM: ints(g[2]), Temp: ints(g[3]), Enabled: g[4] == "true"})
|
||||
}
|
||||
return curves
|
||||
}
|
||||
|
||||
func ints(list string) []int {
|
||||
var out []int
|
||||
for _, f := range strings.Split(list, ",") {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(f)); err == nil {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GPUState is what supergfxd says about the hybrid GPU.
|
||||
type GPUState struct {
|
||||
Mode string `json:"mode"`
|
||||
Supported []string `json:"supported"`
|
||||
Power string `json:"dgpu_power,omitempty"`
|
||||
PendingAction string `json:"pending_action,omitempty"`
|
||||
PendingMode string `json:"pending_mode,omitempty"`
|
||||
Vendor string `json:"dgpu_vendor,omitempty"`
|
||||
}
|
||||
|
||||
// ParseSupported reads `supergfxctl -s`: `[Integrated, Hybrid, AsusMuxDgpu]`.
|
||||
func ParseSupported(out string) []string {
|
||||
out = strings.Trim(strings.TrimSpace(out), "[]")
|
||||
var modes []string
|
||||
for _, f := range strings.Split(out, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
modes = append(modes, f)
|
||||
}
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
// GPU reads supergfxd.
|
||||
func (m *Machine) GPU(ctx context.Context) (GPUState, error) {
|
||||
var g GPUState
|
||||
mode, err := m.Run(ctx, "supergfxctl", "-g")
|
||||
if err != nil {
|
||||
return g, vendor("supergfxctl", err)
|
||||
}
|
||||
g.Mode = strings.TrimSpace(mode)
|
||||
if s, err := m.Run(ctx, "supergfxctl", "-s"); err == nil {
|
||||
g.Supported = ParseSupported(s)
|
||||
}
|
||||
if s, err := m.Run(ctx, "supergfxctl", "-S"); err == nil {
|
||||
g.Power = strings.TrimSpace(s)
|
||||
}
|
||||
if s, err := m.Run(ctx, "supergfxctl", "-p"); err == nil {
|
||||
g.PendingAction = strings.TrimSpace(s)
|
||||
}
|
||||
if s, err := m.Run(ctx, "supergfxctl", "-P"); err == nil {
|
||||
g.PendingMode = strings.TrimSpace(s)
|
||||
}
|
||||
if s, err := m.Run(ctx, "supergfxctl", "-V"); err == nil {
|
||||
g.Vendor = strings.TrimSpace(s)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// vendor names a vendor client that is not installed, rather than passing on "executable file not
|
||||
// found". asusctl is in the distribution's repositories; supergfxctl is not, and the module keeps it as
|
||||
// it was found until the mesh can build packages from the user repository (research 027, question 1).
|
||||
func vendor(name string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if notInstalled(err) {
|
||||
switch name {
|
||||
case "supergfxctl":
|
||||
return fmt.Errorf("supergfxctl is not installed: it is not in the distribution's repositories, " +
|
||||
"and this module keeps the copy it finds rather than install one (novox/hq research 027, question 1)")
|
||||
default:
|
||||
return fmt.Errorf("%s is not installed; the module's package resource installs it", name)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// AsusdConfig is the few settings of asusd's own file that decide what this module's code does. The
|
||||
// file is asusd's: it rewrites it whenever a setting changes, so the module reads it and never writes
|
||||
// it.
|
||||
type AsusdConfig struct {
|
||||
ChargeLimit *int `json:"charge_control_end_threshold,omitempty"`
|
||||
ProfileOnAC string `json:"platform_profile_on_ac,omitempty"`
|
||||
ProfileOnBattery string `json:"platform_profile_on_battery,omitempty"`
|
||||
ChangesProfileOnAC *bool `json:"change_platform_profile_on_ac,omitempty"`
|
||||
ChangesProfileOnBatt *bool `json:"change_platform_profile_on_battery,omitempty"`
|
||||
ACCommand string `json:"ac_command,omitempty"`
|
||||
BatteryCommand string `json:"bat_command,omitempty"`
|
||||
DisablesPowerdOnBatt *bool `json:"disable_nvidia_powerd_on_battery,omitempty"`
|
||||
}
|
||||
|
||||
var ronField = regexp.MustCompile(`(?m)^\s{4}([a-z_]+):\s*(.*?),?\s*$`)
|
||||
|
||||
// ParseAsusdRon reads the top-level scalar fields of asusd.ron. RON is not a format the mesh
|
||||
// speaks; these are one line each, and nothing nested is read.
|
||||
func ParseAsusdRon(text string) AsusdConfig {
|
||||
var c AsusdConfig
|
||||
for _, g := range ronField.FindAllStringSubmatch(text, -1) {
|
||||
key, value := g[1], strings.TrimSuffix(strings.TrimSpace(g[2]), ",")
|
||||
unquoted := strings.Trim(value, `"`)
|
||||
boolean := func() *bool { b := value == "true"; return &b }
|
||||
switch key {
|
||||
case "charge_control_end_threshold":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
c.ChargeLimit = &n
|
||||
}
|
||||
case "platform_profile_on_ac":
|
||||
c.ProfileOnAC = unquoted
|
||||
case "platform_profile_on_battery":
|
||||
c.ProfileOnBattery = unquoted
|
||||
case "change_platform_profile_on_ac":
|
||||
c.ChangesProfileOnAC = boolean()
|
||||
case "change_platform_profile_on_battery":
|
||||
c.ChangesProfileOnBatt = boolean()
|
||||
case "ac_command":
|
||||
c.ACCommand = unquoted
|
||||
case "bat_command":
|
||||
c.BatteryCommand = unquoted
|
||||
case "disable_nvidia_powerd_on_battery":
|
||||
c.DisablesPowerdOnBatt = boolean()
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Asusd reads asusd's file; nil when it is not there.
|
||||
func (m *Machine) Asusd() *AsusdConfig {
|
||||
text := m.read("/etc/asusd/asusd.ron")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
c := ParseAsusdRon(text)
|
||||
return &c
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What asusctl 6.4 and supergfxctl 5.2 said on the laptop on 2026-10-04.
|
||||
const fanCurveQuiet = `
|
||||
Fan curves for Quiet
|
||||
|
||||
[
|
||||
(
|
||||
fan: CPU,
|
||||
pwm: (2, 0, 10, 20, 35, 55, 80, 100),
|
||||
temp: (35, 45, 50, 55, 60, 65, 70, 80),
|
||||
enabled: true,
|
||||
),
|
||||
(
|
||||
fan: GPU,
|
||||
pwm: (0, 0, 10, 20, 35, 65, 90, 115),
|
||||
temp: (35, 45, 50, 55, 60, 65, 70, 80),
|
||||
enabled: false,
|
||||
),
|
||||
]
|
||||
`
|
||||
|
||||
const asusdRon = `(
|
||||
charge_control_end_threshold: 80,
|
||||
base_charge_control_end_threshold: 0,
|
||||
disable_nvidia_powerd_on_battery: true,
|
||||
ac_command: "supergfxctl -m Hybrid",
|
||||
bat_command: "supergfxctl -m Integrated",
|
||||
platform_profile_linked_epp: true,
|
||||
platform_profile_on_battery: Quiet,
|
||||
change_platform_profile_on_battery: true,
|
||||
platform_profile_on_ac: Balanced,
|
||||
change_platform_profile_on_ac: true,
|
||||
ac_profile_tunings: {
|
||||
Quiet: (
|
||||
enabled: false,
|
||||
group: {},
|
||||
),
|
||||
},
|
||||
)`
|
||||
|
||||
func TestAsusctlsAnswersAreRead(t *testing.T) {
|
||||
p, err := ParseProfileGet(profileGetBalanced)
|
||||
if err != nil || p.Active != "Balanced" || p.OnAC != "Balanced" || p.Battery != "Quiet" {
|
||||
t.Fatalf("%+v %v", p, err)
|
||||
}
|
||||
if _, err := ParseProfileGet("something else"); err == nil {
|
||||
t.Fatal("an answer with no profile was read as one")
|
||||
}
|
||||
if l, err := ParseLeds("Current keyboard led brightness: High\n"); err != nil || l != "high" {
|
||||
t.Fatalf("%q %v", l, err)
|
||||
}
|
||||
curves := ParseFanCurves(fanCurveQuiet)
|
||||
if len(curves) != 2 || curves[0].Fan != "CPU" || curves[0].PWM[7] != 100 || curves[0].Temp[0] != 35 || curves[1].Enabled {
|
||||
t.Fatalf("%+v", curves)
|
||||
}
|
||||
if got := ParseSupported("[Integrated, Hybrid, AsusMuxDgpu]\n"); strings.Join(got, ",") != "Integrated,Hybrid,AsusMuxDgpu" {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsusdsFileIsReadForWhatDecidesTheModulesCodeAndNothingNested(t *testing.T) {
|
||||
c := ParseAsusdRon(asusdRon)
|
||||
if *c.ChargeLimit != 80 || c.ProfileOnAC != "Balanced" || c.ProfileOnBattery != "Quiet" ||
|
||||
c.ACCommand != "supergfxctl -m Hybrid" || c.BatteryCommand != "supergfxctl -m Integrated" ||
|
||||
!*c.ChangesProfileOnAC || !*c.DisablesPowerdOnBatt {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingVendorClientIsNamedWithWhyItIsMissing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.fails["supergfxctl"] = notFound
|
||||
_, err := f.machine().GPU(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "research 027") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.fails["asusctl"] = notFound
|
||||
_, err = f.machine().Profile(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "package resource installs it") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGPUModeIsSetOnlyWhenTheMachineSupportsItAndAsusdsSwitchingIsSaid(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.answers["supergfxctl -g"] = "Hybrid\n"
|
||||
f.answers["supergfxctl -s"] = "[Integrated, Hybrid, AsusMuxDgpu]\n"
|
||||
f.file("/etc/asusd/asusd.ron", asusdRon)
|
||||
m := f.machine()
|
||||
if _, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "Vfio"}); err == nil {
|
||||
t.Fatal("an unsupported mode was accepted")
|
||||
}
|
||||
out, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "integrated"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.called("supergfxctl -m Integrated") {
|
||||
t.Fatalf("calls %v", f.calls)
|
||||
}
|
||||
if _, said := out.(map[string]any)["asusd_switches_it"]; !said {
|
||||
t.Fatalf("asusd's own switching of the mode was not said: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChargeLimitIsBoundedAndSetThroughAsusd(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.answers["asusctl battery info"] = "Current battery charge limit: 60%\n"
|
||||
m := f.machine()
|
||||
for _, bad := range []any{float64(10), float64(101), "x", 55.5} {
|
||||
if _, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": bad}); err == nil {
|
||||
t.Errorf("limit %v was accepted", bad)
|
||||
}
|
||||
}
|
||||
out, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": float64(60)})
|
||||
if err != nil || !f.called("asusctl battery limit 60") || out.(map[string]any)["asusd_limit_percent"] != 60 {
|
||||
t.Fatalf("%+v %v %v", out, err, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProfileSetByToolIsHeldAndAnUnknownOneRefused(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.onMains()
|
||||
f.answers["asusctl profile get"] = profileGetBalanced
|
||||
m := f.machine()
|
||||
sw := NewSwitcher(m, nil)
|
||||
if _, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "Turbo"}); err == nil {
|
||||
t.Fatal("an unknown profile was accepted")
|
||||
}
|
||||
out, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "performance", "hold_minutes": float64(30)})
|
||||
if err != nil || !f.called("asusctl profile set Performance") {
|
||||
t.Fatalf("%v %v", err, f.calls)
|
||||
}
|
||||
if _, held := out.(map[string]any)["held_until"]; !held {
|
||||
t.Fatalf("not held: %+v", out)
|
||||
}
|
||||
if r := sw.Report(); r.Held != "Performance" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Check is one thing the module expects of the machine, and whether it holds.
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// CheckReport is what zephyrus_check answers. NotChecked says what it did not look at, because a
|
||||
// check that reads as clean while skipping something is the predecessor's verifier again.
|
||||
type CheckReport struct {
|
||||
Model string `json:"model"`
|
||||
Checks []Check `json:"checks"`
|
||||
Failing int `json:"failing"`
|
||||
NotChecked []string `json:"not_checked"`
|
||||
}
|
||||
|
||||
var (
|
||||
pacmanVersion = regexp.MustCompile(`(?m)^Version\s*:\s*(\S+)`)
|
||||
pacmanPackager = regexp.MustCompile(`(?m)^Packager\s*:\s*(.+)$`)
|
||||
nvidiaParam = regexp.MustCompile(`(?m)^(\w+):\s*(\S+)`)
|
||||
)
|
||||
|
||||
// ParseNvidiaParams reads /proc/driver/nvidia/params.
|
||||
func ParseNvidiaParams(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, g := range nvidiaParam.FindAllStringSubmatch(text, -1) {
|
||||
out[g[1]] = g[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// predecessorProcess finds a running process whose command line names the predecessor's script.
|
||||
func (m *Machine) predecessorProcess(name string) (int, bool) {
|
||||
for _, dir := range m.glob("/proc/[0-9]*") {
|
||||
cmd := strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ")
|
||||
if strings.Contains(cmd, "/"+name) && !strings.Contains(cmd, "zephyrus") {
|
||||
var pid int
|
||||
fmt.Sscanf(filepath.Base(dir), "%d", &pid)
|
||||
return pid, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (m *Machine) unitIs(ctx context.Context, verb, unit string) string {
|
||||
out, _ := m.Run(ctx, "systemctl", verb, unit)
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// Check reads every expectation and reports each.
|
||||
func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport {
|
||||
r := CheckReport{Model: m.Model(), NotChecked: []string{
|
||||
"the fan curves (asusd's own, read them with zephyrus_fan_curves)",
|
||||
"whether the initramfs carries the NVIDIA options (they are read from the running driver instead)",
|
||||
"the vendor keys themselves (press them)",
|
||||
}}
|
||||
add := func(name string, ok bool, format string, a ...any) {
|
||||
r.Checks = append(r.Checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, a...)})
|
||||
if !ok {
|
||||
r.Failing++
|
||||
}
|
||||
}
|
||||
add("model", m.ThisModel(), "the firmware reports %q; this module is for %q", r.Model, ModelFamily)
|
||||
|
||||
// asusctl: present, and from the distribution rather than a local build.
|
||||
if info, err := m.Run(ctx, "pacman", "-Qi", "asusctl"); err != nil {
|
||||
add("asusctl package", false, "not installed: %v", err)
|
||||
} else {
|
||||
v, p := "", ""
|
||||
if g := pacmanVersion.FindStringSubmatch(info); g != nil {
|
||||
v = g[1]
|
||||
}
|
||||
if g := pacmanPackager.FindStringSubmatch(info); g != nil {
|
||||
p = strings.TrimSpace(g[1])
|
||||
}
|
||||
local := p == "Unknown Packager"
|
||||
add("asusctl package", !local, "version %s, packager %s%s", v, p,
|
||||
map[bool]string{true: "; a local build — the distribution's package replaces it at the next upgrade (pacman -S asusctl)", false: ""}[local])
|
||||
}
|
||||
for _, foreign := range []string{"supergfxctl", "triggerhappy"} {
|
||||
_, err := m.Run(ctx, "pacman", "-Q", foreign)
|
||||
add(foreign+" package", err == nil, "%s; not in the distribution's repositories, kept as found (novox/hq research 027, question 1)",
|
||||
map[bool]string{true: "installed", false: "NOT installed"}[err == nil])
|
||||
}
|
||||
for _, unit := range []string{"asusd.service", "supergfxd.service", "triggerhappy.service"} {
|
||||
state := m.unitIs(ctx, "is-active", unit)
|
||||
add(unit, state == "active", "%s", state)
|
||||
}
|
||||
powerd := m.unitIs(ctx, "is-enabled", "nvidia-powerd.service")
|
||||
add("nvidia-powerd.service", powerd == "masked" || powerd == "disabled" || powerd == "" || strings.Contains(powerd, "not-found"),
|
||||
"%s; the module's drop-in keeps it from starting unless the kernel command line says zephyrus.nvidia-powerd", orNone(powerd))
|
||||
wants, _ := m.Run(ctx, "systemctl", "show", "-p", "Wants", "systemd-suspend.service")
|
||||
add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"),
|
||||
"systemd-suspend.service %s", strings.TrimSpace(wants))
|
||||
|
||||
params := ParseNvidiaParams(m.read("/proc/driver/nvidia/params"))
|
||||
if len(params) == 0 {
|
||||
add("nvidia options", false, "the NVIDIA driver is not loaded (no /proc/driver/nvidia/params)")
|
||||
} else {
|
||||
add("nvidia options", params["PreserveVideoMemoryAllocations"] == "1" && params["DynamicPowerManagement"] == "0",
|
||||
"PreserveVideoMemoryAllocations=%s DynamicPowerManagement=%s (want 1 and 0; a change applies when the driver loads again)",
|
||||
params["PreserveVideoMemoryAllocations"], params["DynamicPowerManagement"])
|
||||
}
|
||||
|
||||
for _, b := range m.Batteries() {
|
||||
ok := b.LimitPercent != nil && *b.LimitPercent == ChargeLimitPercent
|
||||
have := "unknown"
|
||||
if b.LimitPercent != nil {
|
||||
have = fmt.Sprintf("%d%%", *b.LimitPercent)
|
||||
}
|
||||
add("charge limit", ok, "%s is %s, the module's is %d%%", b.Name, have, ChargeLimitPercent)
|
||||
}
|
||||
|
||||
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
|
||||
add("one authority over the GPU mode", false,
|
||||
"asusd runs %q on mains and %q on battery: it switches the GPU mode on every change of power source, "+
|
||||
"so a mode set with zephyrus_gpu_mode lasts until the next one. Clear ac_command and bat_command in /etc/asusd/asusd.ron (asusd's file) to make it the operator's alone",
|
||||
c.ACCommand, c.BatteryCommand)
|
||||
}
|
||||
if out, err := m.Run(ctx, "journalctl", "-b", "-u", "supergfxd.service", "-g", "invalid variant", "-n", "1", "-q", "-o", "cat"); err == nil && strings.TrimSpace(out) != "" {
|
||||
add("supergfxd and logind", false, "supergfxd cannot read logind's sessions this boot (%s): a mode change that needs a logout times out", strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
if pid, ok := m.predecessorProcess("auto-profile"); ok {
|
||||
add("one profile switcher", false, "the predecessor's auto-profile still runs (pid %d) and switches the profile every five seconds; "+
|
||||
"stop it: systemctl --user disable --now auto-profile.service", pid)
|
||||
} else {
|
||||
add("one profile switcher", true, "no predecessor auto-profile is running")
|
||||
}
|
||||
if sw != nil {
|
||||
rep := sw.Report()
|
||||
add("profile switcher", rep.Running, "%s", orNone(firstNonEmpty(rep.Disabled, rep.LastError, "woken by "+rep.Watching)))
|
||||
}
|
||||
if home := os.Getenv("MESH_OPERATOR_HOME"); home != "" {
|
||||
var left []string
|
||||
for _, p := range PredecessorHomeFiles {
|
||||
if _, err := os.Stat(filepath.Join(m.Root, home, p)); err == nil {
|
||||
left = append(left, "~/"+p)
|
||||
}
|
||||
}
|
||||
add("predecessor files in the home", len(left) == 0, "%s", orNone(strings.Join(left, ", ")))
|
||||
} else {
|
||||
r.NotChecked = append(r.NotChecked, "the predecessor's files in the operator's home (MESH_OPERATOR_HOME is not set)")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// PredecessorHomeFiles are what the predecessor placed in the operator's home for this model and this
|
||||
// module replaces. The mesh removes nothing it did not make (novox/hq ADR 0182): the operator does,
|
||||
// once, and this list is how the check knows.
|
||||
var PredecessorHomeFiles = []string{
|
||||
"scripts/auto-profile",
|
||||
".config/systemd/user/auto-profile.service",
|
||||
"scripts/asus-bright",
|
||||
"scripts/asusctl-kbd-bright",
|
||||
"scripts/xrandr-bright",
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MinPanelPercent is the floor a brightness change never goes below: a panel at zero is a black
|
||||
// screen that looks like a dead machine, and the keys cannot be seen to bring it back.
|
||||
const MinPanelPercent = 1
|
||||
|
||||
// Panel is the internal display's backlight.
|
||||
type Panel struct {
|
||||
Device string `json:"device"`
|
||||
Percent float64 `json:"percent"`
|
||||
Raw int64 `json:"raw"`
|
||||
Max int64 `json:"max"`
|
||||
Others []string `json:"other_backlights,omitempty"`
|
||||
}
|
||||
|
||||
// panelDevice chooses the backlight that drives the internal panel.
|
||||
//
|
||||
// **This model registers two.** In hybrid mode the integrated GPU drives the panel (amdgpu_bl1,
|
||||
// beneath the eDP connector) and the discrete GPU's driver registers one of its own (nvidia_0) that
|
||||
// moves nothing. The predecessor's scripts named amdgpu_bl1 literally, which is right until the GPU
|
||||
// mode puts the panel on the other GPU. The one that sits under an eDP connector is the panel's; failing
|
||||
// that, the kernel's own preference: firmware, then platform, then raw.
|
||||
func (m *Machine) panelDevice() (string, []string, error) {
|
||||
all := m.glob("/sys/class/backlight/*")
|
||||
if len(all) == 0 {
|
||||
return "", nil, fmt.Errorf("this machine has no backlight in /sys/class/backlight")
|
||||
}
|
||||
names := make([]string, 0, len(all))
|
||||
for _, d := range all {
|
||||
names = append(names, path.Base(d))
|
||||
}
|
||||
sort.Strings(names)
|
||||
rank := func(name string) int {
|
||||
dir := "/sys/class/backlight/" + name
|
||||
if target, err := os.Readlink(m.path(dir)); err == nil && strings.Contains(target, "-eDP-") {
|
||||
return 0
|
||||
}
|
||||
switch m.read(dir + "/type") {
|
||||
case "firmware":
|
||||
return 1
|
||||
case "platform":
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
best := names[0]
|
||||
for _, n := range names[1:] {
|
||||
if rank(n) < rank(best) {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
var others []string
|
||||
for _, n := range names {
|
||||
if n != best {
|
||||
others = append(others, n)
|
||||
}
|
||||
}
|
||||
return best, others, nil
|
||||
}
|
||||
|
||||
// PanelBrightness reads the panel.
|
||||
func (m *Machine) PanelBrightness() (Panel, error) {
|
||||
dev, others, err := m.panelDevice()
|
||||
if err != nil {
|
||||
return Panel{}, err
|
||||
}
|
||||
dir := "/sys/class/backlight/" + dev
|
||||
raw, ok1 := m.readInt(dir + "/brightness")
|
||||
max, ok2 := m.readInt(dir + "/max_brightness")
|
||||
if !ok1 || !ok2 || max <= 0 {
|
||||
return Panel{}, fmt.Errorf("%s does not say its brightness", dir)
|
||||
}
|
||||
return Panel{Device: dev, Raw: raw, Max: max, Percent: round1(float64(raw) / float64(max) * 100), Others: others}, nil
|
||||
}
|
||||
|
||||
// PanelTarget turns a request — "40", "40%", "+5", "-10" — into the percentage to set, clamped to
|
||||
// [MinPanelPercent, 100].
|
||||
func PanelTarget(current float64, request string) (float64, error) {
|
||||
r := strings.TrimSuffix(strings.TrimSpace(request), "%")
|
||||
if r == "" {
|
||||
return 0, fmt.Errorf("panel needs a percentage (40) or a step (+5, -5)")
|
||||
}
|
||||
n, err := strconv.ParseFloat(r, 64)
|
||||
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return 0, fmt.Errorf("panel %q is not a percentage or a step", request)
|
||||
}
|
||||
target := n
|
||||
if strings.HasPrefix(r, "+") || strings.HasPrefix(r, "-") {
|
||||
target = current + n
|
||||
}
|
||||
return math.Max(MinPanelPercent, math.Min(100, target)), nil
|
||||
}
|
||||
|
||||
// SetPanel sets the panel to a percentage.
|
||||
func (m *Machine) SetPanel(ctx context.Context, request string) (Panel, error) {
|
||||
p, err := m.PanelBrightness()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
target, err := PanelTarget(p.Percent, request)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
raw := int64(math.Round(target / 100 * float64(p.Max)))
|
||||
if raw < 1 {
|
||||
raw = 1
|
||||
}
|
||||
if err := m.write(ctx, "/sys/class/backlight/"+p.Device+"/brightness", strconv.FormatInt(raw, 10)); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return m.PanelBrightness()
|
||||
}
|
||||
|
||||
// Keyboard is the keyboard's backlight.
|
||||
type Keyboard struct {
|
||||
Device string `json:"device"`
|
||||
Level string `json:"level"`
|
||||
Value int64 `json:"value"`
|
||||
Max int64 `json:"max"`
|
||||
}
|
||||
|
||||
// KeyboardBrightness reads the keyboard backlight from the kernel.
|
||||
func (m *Machine) KeyboardBrightness() (Keyboard, error) {
|
||||
found := m.glob("/sys/class/leds/*kbd_backlight*")
|
||||
if len(found) == 0 {
|
||||
return Keyboard{}, fmt.Errorf("this machine has no keyboard backlight in /sys/class/leds")
|
||||
}
|
||||
dir := found[0]
|
||||
v, ok1 := m.readInt(dir + "/brightness")
|
||||
max, ok2 := m.readInt(dir + "/max_brightness")
|
||||
if !ok1 || !ok2 {
|
||||
return Keyboard{}, fmt.Errorf("%s does not say its brightness", dir)
|
||||
}
|
||||
k := Keyboard{Device: path.Base(dir), Value: v, Max: max}
|
||||
if max == int64(len(KeyboardLevels)-1) && v >= 0 && v <= max {
|
||||
k.Level = KeyboardLevels[v]
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// KeyboardTarget turns a request — off/low/med/high, 0-3, "+", "-" — into asusd's level name.
|
||||
func KeyboardTarget(current int64, request string) (string, error) {
|
||||
r := strings.ToLower(strings.TrimSpace(request))
|
||||
switch r {
|
||||
case "medium":
|
||||
r = "med"
|
||||
case "+", "up":
|
||||
r = strconv.FormatInt(min64(current+1, int64(len(KeyboardLevels)-1)), 10)
|
||||
case "-", "down":
|
||||
r = strconv.FormatInt(max64(current-1, 0), 10)
|
||||
}
|
||||
for _, l := range KeyboardLevels {
|
||||
if r == l {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(r); err == nil && n >= 0 && n < len(KeyboardLevels) {
|
||||
return KeyboardLevels[n], nil
|
||||
}
|
||||
return "", fmt.Errorf("keyboard %q is not one of off, low, med, high, 0-3, + or -", request)
|
||||
}
|
||||
|
||||
// SetKeyboard has asusd set the keyboard backlight, so its own record of the level stays true.
|
||||
func (m *Machine) SetKeyboard(ctx context.Context, request string) (Keyboard, error) {
|
||||
k, err := m.KeyboardBrightness()
|
||||
if err != nil {
|
||||
return k, err
|
||||
}
|
||||
level, err := KeyboardTarget(k.Value, request)
|
||||
if err != nil {
|
||||
return k, err
|
||||
}
|
||||
if _, err := m.Run(ctx, "asusctl", "leds", "set", level); err != nil {
|
||||
return k, vendor("asusctl", err)
|
||||
}
|
||||
return m.KeyboardBrightness()
|
||||
}
|
||||
|
||||
func min64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// backlight makes a backlight the way sysfs does: a link from /sys/class/backlight into the device
|
||||
// tree, which is where the eDP connector shows.
|
||||
func (f *fake) backlight(name, device string, raw, max string) {
|
||||
dev := "/sys/devices/" + device + "/" + name
|
||||
f.file(dev+"/brightness", raw)
|
||||
f.file(dev+"/max_brightness", max)
|
||||
f.file(dev+"/type", "raw")
|
||||
link := filepath.Join(f.root, "/sys/class/backlight", name)
|
||||
os.MkdirAll(filepath.Dir(link), 0o755)
|
||||
if err := os.Symlink(filepath.Join(f.root, dev), link); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePanelIsTheBacklightUnderTheEDPConnectorNotTheDiscreteGPUs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.backlight("amdgpu_bl1", "pci0000:00/0000:65:00.0/drm/card1/card1-eDP-1", "199500", "399000")
|
||||
f.backlight("nvidia_0", "pci0000:00/0000:01:00.0/backlight", "100", "100")
|
||||
p, err := f.machine().PanelBrightness()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Device != "amdgpu_bl1" || p.Percent != 50 || len(p.Others) != 1 || p.Others[0] != "nvidia_0" {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPanelRequestIsAPercentageOrAStepAndNeverGoesDark(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cur float64
|
||||
req string
|
||||
want float64
|
||||
}{{50, "40", 40}, {50, "40%", 40}, {50, "+5", 55}, {50, "-10", 40}, {3, "-10", 1}, {98, "+5", 100}, {50, "0", 1}} {
|
||||
got, err := PanelTarget(c.cur, c.req)
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("%v %q: %v %v, want %v", c.cur, c.req, got, err, c.want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "bright", "NaN"} {
|
||||
if _, err := PanelTarget(50, bad); err == nil {
|
||||
t.Errorf("%q was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingThePanelWritesTheRawValue(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.backlight("amdgpu_bl1", "card1-eDP-1", "399000", "399000")
|
||||
p, err := f.machine().SetPanel(context.Background(), "25")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(f.root, "/sys/devices/card1-eDP-1/amdgpu_bl1/brightness"))
|
||||
if strings.TrimSpace(string(raw)) != "99750" || p.Percent != 25 {
|
||||
t.Fatalf("wrote %q, read back %+v", raw, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyboardIsSetThroughAsusdByLevel(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.file("/sys/class/leds/asus::kbd_backlight/brightness", "1")
|
||||
f.file("/sys/class/leds/asus::kbd_backlight/max_brightness", "3")
|
||||
k, err := f.machine().KeyboardBrightness()
|
||||
if err != nil || k.Level != "low" {
|
||||
t.Fatalf("%+v %v", k, err)
|
||||
}
|
||||
if _, err := f.machine().SetKeyboard(context.Background(), "+"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.called("asusctl leds set med") {
|
||||
t.Fatalf("calls: %v", f.calls)
|
||||
}
|
||||
for req, want := range map[string]string{"high": "high", "0": "off", "medium": "med", "-": "off"} {
|
||||
if got, err := KeyboardTarget(1, req); err != nil || got != want {
|
||||
t.Errorf("%q: %q %v", req, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := KeyboardTarget(1, "7"); err == nil {
|
||||
t.Error("level 7 was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
|
||||
// recording every command it was asked to run.
|
||||
type fake struct {
|
||||
t *testing.T
|
||||
root string
|
||||
mu sync.Mutex
|
||||
answers map[string]string
|
||||
fails map[string]error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
|
||||
}
|
||||
|
||||
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
|
||||
|
||||
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, line)
|
||||
if err, ok := f.fails[line]; ok {
|
||||
return "", err
|
||||
}
|
||||
if out, ok := f.answers[line]; ok {
|
||||
return out, nil
|
||||
}
|
||||
if err, ok := f.fails[name]; ok {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fake) called(line string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
if c == line {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fake) callsLike(prefix string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// file writes a file under the fake root.
|
||||
func (f *fake) file(path, content string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// supply writes one power supply's attributes.
|
||||
func (f *fake) supply(name string, attrs map[string]string) {
|
||||
for k, v := range attrs {
|
||||
f.file("/sys/class/power_supply/"+name+"/"+k, v+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
// onMains and onBattery are this model's two states as measured on 2026-10-04.
|
||||
func (f *fake) onMains() {
|
||||
f.supply("ACAD", map[string]string{"type": "Mains", "online": "1"})
|
||||
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Not charging", "capacity": "80"})
|
||||
}
|
||||
|
||||
func (f *fake) onBattery() {
|
||||
f.supply("ACAD", map[string]string{"type": "Mains", "online": "0"})
|
||||
f.supply("BAT1", map[string]string{"type": "Battery", "status": "Discharging", "capacity": "79"})
|
||||
}
|
||||
|
||||
var notFound = &exec.Error{Name: "x", Err: exec.ErrNotFound}
|
||||
|
||||
const profileGetBalanced = "Active profile: Balanced\n\nAC profile Balanced\nBattery profile Quiet\n"
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CommandTimeout bounds every command a tool or the switcher runs: a vendor daemon that hangs on its
|
||||
// bus must cost a tool call twenty seconds, never the runtime's thirty.
|
||||
const CommandTimeout = 20 * time.Second
|
||||
|
||||
// Runner runs one command and answers its standard output. It is injected so that every tool is
|
||||
// tested against recorded answers rather than this machine's daemons.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
|
||||
// command said on stderr, because "exit status 1" names nothing.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if said != "" {
|
||||
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
|
||||
}
|
||||
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
|
||||
// /sys and /proc and /etc) and a way to run commands.
|
||||
type Machine struct {
|
||||
Root string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// read is a file's content, trimmed; "" when it cannot be read.
|
||||
func (m *Machine) read(p string) string {
|
||||
b, err := os.ReadFile(m.path(p))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// readInt is a file holding one integer; ok false when it is absent or not a number.
|
||||
func (m *Machine) readInt(p string) (int64, bool) {
|
||||
s := m.read(p)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
func (m *Machine) glob(pattern string) []string {
|
||||
found, _ := filepath.Glob(m.path(pattern))
|
||||
out := make([]string, 0, len(found))
|
||||
for _, f := range found {
|
||||
rel, err := filepath.Rel(m.Root, f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, "/"+filepath.ToSlash(rel))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// write puts a value into a file of the kernel's (a backlight). Where the account may not write it
|
||||
// — the udev rule that gives the video group the panel has not run yet — it escalates with `sudo -n`,
|
||||
// which never prompts: the operator's account may escalate without one, and when it may not, the
|
||||
// tool says so in sudo's words.
|
||||
func (m *Machine) write(ctx context.Context, p, value string) error {
|
||||
err := os.WriteFile(m.path(p), []byte(value), 0)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrPermission) {
|
||||
return err
|
||||
}
|
||||
if _, serr := m.Run(ctx, "sudo", "-n", "sh", "-c", `printf '%s' "$1" > "$2"`, "sh", value, m.path(p)); serr != nil {
|
||||
return fmt.Errorf("%s is not writable by this account and sudo -n refused: %v", p, serr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notInstalled says a command failed because it is not on this machine at all.
|
||||
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
|
||||
|
||||
// round to one decimal, for watts and percentages a person reads.
|
||||
func round1(f float64) float64 {
|
||||
return float64(int64(f*10+sign(f)*0.5)) / 10
|
||||
}
|
||||
|
||||
func sign(f float64) float64 {
|
||||
if f < 0 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The asus-zephyrus-g14 module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
|
||||
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
|
||||
// code — the platform-profile switcher — beside them.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
m := Here()
|
||||
sw := NewSwitcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go sw.Run(ctx)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE).
|
||||
if err := stdio.Serve("", Tools(m, sw)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheManifestNamesExactlyTheToolsTheBundleServes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
var served []string
|
||||
for _, tool := range Tools(&Machine{Root: t.TempDir(), Run: newFake(t).run}, nil) {
|
||||
served = append(served, tool.Name)
|
||||
}
|
||||
sort.Strings(served)
|
||||
listed := append([]string(nil), m.Tools...)
|
||||
sort.Strings(listed)
|
||||
if strings.Join(served, ",") != strings.Join(listed, ",") {
|
||||
t.Fatalf("served %v, listed %v", served, listed)
|
||||
}
|
||||
if len(m.Emits) != 1 || m.Emits[0] != "profile.switched" {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
}
|
||||
|
||||
// The module names the model, never a node, a person or a user id (novox/hq ADR 0112), and every
|
||||
// trigger it names a service restart or reload on is one of its own resources.
|
||||
func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
ids[r["id"].(string)] = true
|
||||
}
|
||||
raw, _ := os.ReadFile("../../module.json")
|
||||
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
for _, key := range []string{"restart-on", "reload-on"} {
|
||||
list, _ := r[key].([]any)
|
||||
for _, id := range list {
|
||||
if !ids[id.(string)] {
|
||||
t.Errorf("%s %s names %v, which is not a resource", r["id"], key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every trigger runs a script the module ships, and every script parses.
|
||||
func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
var triggers string
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "vendor-keys" {
|
||||
triggers = r["content"].(string)
|
||||
}
|
||||
}
|
||||
if triggers == "" {
|
||||
t.Fatal("no vendor-keys resource")
|
||||
}
|
||||
for _, line := range strings.Split(triggers, "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if strings.HasPrefix(line, "#") || len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
script := strings.Fields(f[2])[0]
|
||||
local := filepath.Join("../../files/bin", filepath.Base(script))
|
||||
if !strings.HasPrefix(script, "/usr/local/lib/asus-zephyrus-g14/bin/") {
|
||||
t.Errorf("%s runs %s, which the module does not ship", f[0], script)
|
||||
} else if st, err := os.Stat(local); err != nil || st.Mode()&0o111 == 0 {
|
||||
t.Errorf("%s: %s is missing or not executable", f[0], local)
|
||||
}
|
||||
}
|
||||
scripts, _ := filepath.Glob("../../files/bin/*")
|
||||
if len(scripts) == 0 {
|
||||
t.Fatal("no scripts")
|
||||
}
|
||||
for _, s := range scripts {
|
||||
if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: %v %s", s, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The policy, as constants until the mesh has settings a module can read (novox/hq issue 168). The
|
||||
// values are the predecessor's, made explicit, and two of its behaviours are changed on purpose:
|
||||
//
|
||||
// - **Sustained, not momentary.** The predecessor boosted on one five-second sample above 50 %: a
|
||||
// compile's first second, a browser's tab restore. Here the load must stay above the line for
|
||||
// SustainSamples samples in a row, and below the lower line as long, before the profile moves.
|
||||
// - **Waiting on a disk is not load.** iowait is counted as idle: a machine stalled on its SSD does
|
||||
// not get faster with a higher power limit, only hotter.
|
||||
const (
|
||||
ProfileOnBattery = "Quiet"
|
||||
ProfileOnAC = "Balanced"
|
||||
ProfileUnderLoad = "Performance"
|
||||
|
||||
CPUHighPercent = 50.0 // on mains, sustained at or above this boosts to ProfileUnderLoad
|
||||
CPULowPercent = 20.0 // and sustained at or below this goes back to ProfileOnAC
|
||||
|
||||
SampleEvery = 10 * time.Second // CPU is sampled only on mains; on battery nothing is sampled
|
||||
SustainSamples = 3 // 30 s above CPUHighPercent to boost
|
||||
RelaxSamples = 3 // 30 s below CPULowPercent to relax
|
||||
|
||||
// SafetyRecheck is how often the power source is read when no event has said it changed: the
|
||||
// kernel's event is the trigger, and this only covers one lost across a suspend.
|
||||
SafetyRecheck = 5 * time.Minute
|
||||
|
||||
// DefaultHold is how long a profile chosen through the profile tool is kept before the switcher
|
||||
// may move it again. A change of power source ends a hold at once.
|
||||
DefaultHold = 60 * time.Minute
|
||||
|
||||
// ChargeLimitPercent is the battery charge limit the module asserts through asusd at start.
|
||||
ChargeLimitPercent = 80
|
||||
)
|
||||
|
||||
// Policy is the switcher's memory of recent load: how many samples in a row were above the upper line
|
||||
// or below the lower one, and whether it is boosted.
|
||||
type Policy struct {
|
||||
Boosted bool `json:"boosted"`
|
||||
Above int `json:"samples_above"`
|
||||
Below int `json:"samples_below"`
|
||||
Recent []float64 `json:"recent_cpu_percent"`
|
||||
BoostedSince time.Time `json:"boosted_since,omitempty"`
|
||||
}
|
||||
|
||||
// Observe takes one CPU sample (busy percent since the previous one) taken on mains.
|
||||
func (p *Policy) Observe(cpu float64, at time.Time) {
|
||||
p.Recent = append(p.Recent, round1(cpu))
|
||||
if len(p.Recent) > 6 {
|
||||
p.Recent = p.Recent[len(p.Recent)-6:]
|
||||
}
|
||||
switch {
|
||||
case cpu >= CPUHighPercent:
|
||||
p.Above++
|
||||
p.Below = 0
|
||||
if !p.Boosted && p.Above >= SustainSamples {
|
||||
p.Boosted = true
|
||||
p.BoostedSince = at
|
||||
}
|
||||
case cpu <= CPULowPercent:
|
||||
p.Below++
|
||||
p.Above = 0
|
||||
if p.Boosted && p.Below >= RelaxSamples {
|
||||
p.Boosted = false
|
||||
p.BoostedSince = time.Time{}
|
||||
}
|
||||
default:
|
||||
// Between the lines: no direction is sustained, and the profile stays where it is.
|
||||
p.Above, p.Below = 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
// Reset forgets the load, for a change of power source.
|
||||
func (p *Policy) Reset() { *p = Policy{} }
|
||||
|
||||
// Decision is what the switcher would choose, and why.
|
||||
type Decision struct {
|
||||
Profile string `json:"profile"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Decide is the policy: battery → ProfileOnBattery; mains → ProfileOnAC, or ProfileUnderLoad while
|
||||
// boosted.
|
||||
func (p *Policy) Decide(src Source) Decision {
|
||||
if !src.OnAC {
|
||||
return Decision{ProfileOnBattery, "on battery (" + src.Reason + ")"}
|
||||
}
|
||||
if p.Boosted {
|
||||
return Decision{ProfileUnderLoad, fmt.Sprintf("on mains (%s) and CPU load sustained at or above %s%% for %d samples of %s",
|
||||
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64), SustainSamples, SampleEvery)}
|
||||
}
|
||||
return Decision{ProfileOnAC, fmt.Sprintf("on mains (%s), and CPU load not sustained at or above %s%%",
|
||||
src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64))}
|
||||
}
|
||||
|
||||
// CPUTimes is the first line of /proc/stat: total and idle jiffies (iowait counted as idle).
|
||||
type CPUTimes struct{ Total, Idle uint64 }
|
||||
|
||||
// ParseProcStat reads the aggregate cpu line of /proc/stat.
|
||||
func ParseProcStat(text string) (CPUTimes, error) {
|
||||
line := strings.SplitN(text, "\n", 2)[0]
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 6 || f[0] != "cpu" {
|
||||
return CPUTimes{}, fmt.Errorf("/proc/stat does not start with the cpu line")
|
||||
}
|
||||
var t CPUTimes
|
||||
for i, s := range f[1:] {
|
||||
if i >= 8 { // user nice system idle iowait irq softirq steal; guest is already in user
|
||||
break
|
||||
}
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return CPUTimes{}, fmt.Errorf("/proc/stat: %v", err)
|
||||
}
|
||||
t.Total += n
|
||||
if i == 3 || i == 4 {
|
||||
t.Idle += n
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Busy is the percentage of time not idle between two readings.
|
||||
func Busy(before, after CPUTimes) (float64, bool) {
|
||||
if after.Total <= before.Total {
|
||||
return 0, false
|
||||
}
|
||||
total := float64(after.Total - before.Total)
|
||||
idle := float64(after.Idle - before.Idle)
|
||||
return (total - idle) / total * 100, true
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Supply is one entry of /sys/class/power_supply as the kernel reports it.
|
||||
type Supply struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Online *bool `json:"online,omitempty"`
|
||||
}
|
||||
|
||||
// Supplies is every power supply the kernel knows, sorted by name.
|
||||
func (m *Machine) Supplies() []Supply {
|
||||
var out []Supply
|
||||
for _, dir := range m.glob("/sys/class/power_supply/*") {
|
||||
s := Supply{
|
||||
Name: path.Base(dir),
|
||||
Type: m.read(dir + "/type"),
|
||||
Scope: m.read(dir + "/scope"),
|
||||
Status: m.read(dir + "/status"),
|
||||
}
|
||||
if v, ok := m.readInt(dir + "/online"); ok {
|
||||
on := v == 1
|
||||
s.Online = &on
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// system is a supply that powers this machine. A mouse's or a headset's battery reports scope
|
||||
// Device, and it says nothing about whether the laptop is on mains.
|
||||
func (s Supply) system() bool { return !strings.EqualFold(s.Scope, "Device") }
|
||||
|
||||
// Source is where the machine draws its power from, and why that was concluded.
|
||||
type Source struct {
|
||||
OnAC bool `json:"on_ac"`
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PowerSource decides mains or battery.
|
||||
//
|
||||
// **A battery that says it is discharging wins over any adapter that says it is online.** The
|
||||
// predecessor's script took any `online` file reading 1 as mains, and a USB-C port reports `online`
|
||||
// for things that do not power the machine. The battery's own status is the one fact that cannot be
|
||||
// misread: it discharges exactly when nothing outside is carrying the load. Only when no battery says
|
||||
// so are the adapters asked, and a machine with no system battery at all is on mains.
|
||||
func PowerSource(supplies []Supply) Source {
|
||||
batteries := 0
|
||||
for _, s := range supplies {
|
||||
if s.Type == "Battery" && s.system() {
|
||||
batteries++
|
||||
if strings.EqualFold(s.Status, "Discharging") {
|
||||
return Source{OnAC: false, Source: "battery", Reason: s.Name + " is discharging"}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range supplies {
|
||||
if (s.Type == "Mains" || strings.HasPrefix(s.Type, "USB")) && s.system() && s.Online != nil && *s.Online {
|
||||
return Source{OnAC: true, Source: "ac", Reason: s.Name + " (" + s.Type + ") is online"}
|
||||
}
|
||||
}
|
||||
if batteries == 0 {
|
||||
return Source{OnAC: true, Source: "ac", Reason: "this machine has no system battery"}
|
||||
}
|
||||
return Source{OnAC: false, Source: "battery", Reason: "no mains or USB supply is online"}
|
||||
}
|
||||
|
||||
// Battery is what the battery tool answers.
|
||||
type Battery struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ChargePercent *int64 `json:"charge_percent,omitempty"`
|
||||
EnergyWh *float64 `json:"energy_wh,omitempty"`
|
||||
FullWh *float64 `json:"full_wh,omitempty"`
|
||||
DesignWh *float64 `json:"design_wh,omitempty"`
|
||||
HealthPercent *float64 `json:"health_percent,omitempty"`
|
||||
Cycles *int64 `json:"cycles"`
|
||||
CyclesNote string `json:"cycles_note,omitempty"`
|
||||
LimitPercent *int64 `json:"charge_limit_percent,omitempty"`
|
||||
PowerW *float64 `json:"power_w,omitempty"`
|
||||
HoursRemaining *float64 `json:"hours_remaining,omitempty"`
|
||||
Technology string `json:"technology,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
}
|
||||
|
||||
// Batteries reads every system battery.
|
||||
func (m *Machine) Batteries() []Battery {
|
||||
var out []Battery
|
||||
for _, s := range m.Supplies() {
|
||||
if s.Type != "Battery" || !s.system() {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.battery(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Machine) battery(s Supply) Battery {
|
||||
dir := "/sys/class/power_supply/" + s.Name
|
||||
b := Battery{
|
||||
Name: s.Name, Status: s.Status,
|
||||
Technology: m.read(dir + "/technology"),
|
||||
Model: m.read(dir + "/model_name"),
|
||||
Manufacturer: strings.TrimSpace(m.read(dir + "/manufacturer")),
|
||||
}
|
||||
if v, ok := m.readInt(dir + "/capacity"); ok {
|
||||
b.ChargePercent = &v
|
||||
}
|
||||
// Energy in Wh: energy_* (µWh) where the firmware reports it, else charge_* (µAh) times the
|
||||
// design minimum voltage, which is how upower converts it too.
|
||||
wh := func(energy, charge string) *float64 {
|
||||
if v, ok := m.readInt(dir + "/" + energy); ok {
|
||||
f := round1(float64(v) / 1e6)
|
||||
return &f
|
||||
}
|
||||
c, okc := m.readInt(dir + "/" + charge)
|
||||
volts, okv := m.readInt(dir + "/voltage_min_design")
|
||||
if okc && okv {
|
||||
f := round1(float64(c) * float64(volts) / 1e12)
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
b.EnergyWh = wh("energy_now", "charge_now")
|
||||
b.FullWh = wh("energy_full", "charge_full")
|
||||
b.DesignWh = wh("energy_full_design", "charge_full_design")
|
||||
if b.FullWh != nil && b.DesignWh != nil && *b.DesignWh > 0 {
|
||||
h := round1(*b.FullWh / *b.DesignWh * 100)
|
||||
b.HealthPercent = &h
|
||||
}
|
||||
if v, ok := m.readInt(dir + "/cycle_count"); ok && v > 0 {
|
||||
b.Cycles = &v
|
||||
} else {
|
||||
b.CyclesNote = "the firmware does not report a cycle count (it reads 0)"
|
||||
}
|
||||
if v, ok := m.readInt(dir + "/charge_control_end_threshold"); ok {
|
||||
b.LimitPercent = &v
|
||||
}
|
||||
if w := m.batteryWatts(dir); w != nil {
|
||||
b.PowerW = w
|
||||
if strings.EqualFold(s.Status, "Discharging") && b.EnergyWh != nil && *w > 0.5 {
|
||||
h := round1(*b.EnergyWh / *w)
|
||||
b.HoursRemaining = &h
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// batteryWatts is how much the battery is giving or taking, in watts, unsigned: power_now where the
|
||||
// firmware reports it, else current times voltage.
|
||||
func (m *Machine) batteryWatts(dir string) *float64 {
|
||||
if v, ok := m.readInt(dir + "/power_now"); ok {
|
||||
f := round1(abs(float64(v)) / 1e6)
|
||||
return &f
|
||||
}
|
||||
i, oki := m.readInt(dir + "/current_now")
|
||||
u, oku := m.readInt(dir + "/voltage_now")
|
||||
if oki && oku {
|
||||
f := round1(abs(float64(i)) * float64(u) / 1e12)
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func abs(f float64) float64 {
|
||||
if f < 0 {
|
||||
return -f
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func on(b bool) *bool { return &b }
|
||||
|
||||
func TestADischargingBatteryWinsOverAnAdapterThatSaysOnline(t *testing.T) {
|
||||
got := PowerSource([]Supply{
|
||||
{Name: "BAT1", Type: "Battery", Status: "Discharging"},
|
||||
{Name: "ucsi-source-psy-USBC000:001", Type: "USB", Scope: "System", Online: on(true)},
|
||||
})
|
||||
if got.OnAC {
|
||||
t.Fatalf("a USB-C port reporting online while the battery discharges was read as mains: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainsOnlineIsAC(t *testing.T) {
|
||||
got := PowerSource([]Supply{
|
||||
{Name: "ACAD", Type: "Mains", Online: on(true)},
|
||||
{Name: "BAT1", Type: "Battery", Status: "Not charging"},
|
||||
})
|
||||
if !got.OnAC || got.Reason != "ACAD (Mains) is online" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPeripheralsBatteryDecidesNothing(t *testing.T) {
|
||||
got := PowerSource([]Supply{
|
||||
{Name: "hidpp_battery_0", Type: "Battery", Scope: "Device", Status: "Discharging"},
|
||||
{Name: "ACAD", Type: "Mains", Online: on(true)},
|
||||
{Name: "BAT1", Type: "Battery", Status: "Charging"},
|
||||
})
|
||||
if !got.OnAC {
|
||||
t.Fatalf("a mouse's discharging battery put the laptop on battery: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSupplyOnlineWithABatteryIsBatteryAndNoBatteryIsMains(t *testing.T) {
|
||||
if got := PowerSource([]Supply{{Name: "ACAD", Type: "Mains", Online: on(false)}, {Name: "BAT1", Type: "Battery", Status: "Unknown"}}); got.OnAC {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := PowerSource(nil); !got.OnAC {
|
||||
t.Fatalf("a machine with no battery is on mains: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBatteryIsReadInWattHoursFromChargeAndHealthAgainstDesign(t *testing.T) {
|
||||
f := newFake(t)
|
||||
// The laptop's own battery, as measured: charge_* in µAh, no energy_* and no power_now.
|
||||
f.supply("BAT1", map[string]string{
|
||||
"type": "Battery", "status": "Discharging", "capacity": "80",
|
||||
"charge_now": "3073000", "charge_full": "3865000", "charge_full_design": "4580000",
|
||||
"voltage_min_design": "15939000", "current_now": "1000000", "voltage_now": "16000000",
|
||||
"cycle_count": "0", "charge_control_end_threshold": "80", "manufacturer": "ASUS ",
|
||||
})
|
||||
bs := f.machine().Batteries()
|
||||
if len(bs) != 1 {
|
||||
t.Fatalf("%+v", bs)
|
||||
}
|
||||
b := bs[0]
|
||||
if *b.EnergyWh != 49 || *b.FullWh != 61.6 || *b.DesignWh != 73 || *b.HealthPercent != 84.4 {
|
||||
t.Fatalf("energy %v full %v design %v health %v", *b.EnergyWh, *b.FullWh, *b.DesignWh, *b.HealthPercent)
|
||||
}
|
||||
if b.Cycles != nil || b.CyclesNote == "" {
|
||||
t.Fatal("a cycle count of 0 is the firmware not reporting one, and said so")
|
||||
}
|
||||
if *b.LimitPercent != 80 || *b.PowerW != 16 || b.HoursRemaining == nil || *b.HoursRemaining != 3.1 {
|
||||
t.Fatalf("limit %v power %v hours %v", *b.LimitPercent, *b.PowerW, b.HoursRemaining)
|
||||
}
|
||||
if b.Manufacturer != "ASUS" {
|
||||
t.Fatalf("manufacturer %q", b.Manufacturer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The module's long-running code (novox/hq ADR 0198): the profile switcher, launched with the tools
|
||||
// by the node's runtime and running beside them in the same process.
|
||||
//
|
||||
// It replaces the predecessor's `auto-profile`, a user unit that woke every five seconds for ever —
|
||||
// read the adapters, read /proc/stat, maybe call asusctl — on battery too, where its only possible
|
||||
// answer was the one it had already given. Here the kernel's power-supply event is the trigger; the
|
||||
// CPU is sampled only on mains, where the answer depends on it; and on battery the process sleeps
|
||||
// until the adapter comes back.
|
||||
//
|
||||
// **It acts on a change of its decision, never to restore one.** A profile chosen by hand — the
|
||||
// vendor's profile key, asusctl in a terminal, the profile tool — stays until the power source
|
||||
// changes or the load crosses a line. The predecessor re-asserted its choice every five seconds and so
|
||||
// made the profile key useless on battery.
|
||||
|
||||
// Emitter publishes an event as the module; nil when the process is not under the runtime.
|
||||
type Emitter func(eventType string, body any) error
|
||||
|
||||
// Switcher is the switcher's state, shared with the tools that report it.
|
||||
type Switcher struct {
|
||||
m *Machine
|
||||
now func() time.Time
|
||||
emit Emitter
|
||||
|
||||
mu sync.Mutex
|
||||
policy Policy
|
||||
source *Source
|
||||
decision *Decision
|
||||
applied string
|
||||
appliedAt time.Time
|
||||
lastError string
|
||||
holdUntil time.Time
|
||||
holdOf string
|
||||
watching string
|
||||
cpuPrev *CPUTimes
|
||||
disabled string
|
||||
asserted []string
|
||||
}
|
||||
|
||||
func NewSwitcher(m *Machine, emit Emitter) *Switcher {
|
||||
return &Switcher{m: m, now: time.Now, emit: emit}
|
||||
}
|
||||
|
||||
// Model is the machine's product family as its firmware reports it.
|
||||
func (m *Machine) Model() string { return m.read("/sys/class/dmi/id/product_family") }
|
||||
|
||||
// ModelFamily is the family this module is written for.
|
||||
const ModelFamily = "ROG Zephyrus G14"
|
||||
|
||||
// ThisModel says whether the machine is the model this module is written for.
|
||||
func (m *Machine) ThisModel() bool { return strings.EqualFold(m.Model(), ModelFamily) }
|
||||
|
||||
// sampleCPU reads /proc/stat and answers the busy percentage since the previous reading.
|
||||
func (s *Switcher) sampleCPU() (float64, bool) {
|
||||
t, err := ParseProcStat(s.m.read("/proc/stat"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
prev := s.cpuPrev
|
||||
s.cpuPrev = &t
|
||||
if prev == nil {
|
||||
return 0, false
|
||||
}
|
||||
return Busy(*prev, t)
|
||||
}
|
||||
|
||||
// Evaluate reads the power source, takes a CPU sample when asked and on mains, decides, and applies
|
||||
// the decision when it changed. It is the whole of one wake-up and what the tests drive.
|
||||
func (s *Switcher) Evaluate(ctx context.Context, sample bool) {
|
||||
if body := s.evaluate(ctx, sample); body != nil && s.emit != nil {
|
||||
// Outside the lock: publishing waits for the bus, and the tools that report the switcher
|
||||
// must not wait with it.
|
||||
if err := s.emit("profile.switched", body); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "profile.switched not published: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluate is Evaluate under the lock; it answers the event to publish when it switched.
|
||||
func (s *Switcher) evaluate(ctx context.Context, sample bool) map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.disabled != "" {
|
||||
return nil
|
||||
}
|
||||
src := PowerSource(s.m.Supplies())
|
||||
now := s.now()
|
||||
first := s.source == nil
|
||||
if first || s.source.OnAC != src.OnAC {
|
||||
// A new power source: what was learnt about load on the other one says nothing here, and a
|
||||
// hold was for the source it was asked on.
|
||||
s.policy.Reset()
|
||||
s.cpuPrev = nil
|
||||
s.holdUntil = time.Time{}
|
||||
s.holdOf = ""
|
||||
s.sampleCPU() // the first reading on this source, so the next sample is a difference
|
||||
} else if sample && src.OnAC {
|
||||
if busy, ok := s.sampleCPU(); ok {
|
||||
s.policy.Observe(busy, now)
|
||||
}
|
||||
}
|
||||
s.source = &src
|
||||
d := s.policy.Decide(src)
|
||||
s.decision = &d
|
||||
// **Starting is not a reason to switch.** The runtime starts this process on every push that
|
||||
// changes a bundle; at boot and at every change of power source asusd has already applied its own
|
||||
// profile for the source, which AssertVendorSettings made the policy's. So the first decision is
|
||||
// taken as applied, and a profile someone chose by hand survives a push.
|
||||
if first {
|
||||
s.applied = d.Profile
|
||||
return nil
|
||||
}
|
||||
// Compared with what the switcher itself last applied, never with the profile in force: a profile
|
||||
// someone chose by hand is not a reason to act, a new decision is.
|
||||
if d.Profile == s.applied || now.Before(s.holdUntil) {
|
||||
return nil
|
||||
}
|
||||
from := s.applied
|
||||
if err := s.m.SetProfile(ctx, d.Profile); err != nil {
|
||||
s.lastError = err.Error() // and tried again at the next wake-up, since applied did not move
|
||||
return nil
|
||||
}
|
||||
s.lastError = ""
|
||||
s.applied, s.appliedAt = d.Profile, now
|
||||
body := map[string]any{"profile": d.Profile, "reason": d.Reason, "source": src.Source}
|
||||
if from != "" {
|
||||
body["from"] = from
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Hold keeps a profile chosen through the tool for a while: the switcher does not move it until the
|
||||
// hold ends or the power source changes.
|
||||
func (s *Switcher) Hold(profile string, d time.Duration) time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if d <= 0 {
|
||||
s.holdUntil, s.holdOf = time.Time{}, ""
|
||||
return time.Time{}
|
||||
}
|
||||
s.holdUntil, s.holdOf = s.now().Add(d), profile
|
||||
return s.holdUntil
|
||||
}
|
||||
|
||||
// Run is the switcher's life: assert asusd's settings once, then wake on each power-supply event, on
|
||||
// each CPU sample while on mains, and at SafetyRecheck otherwise.
|
||||
func (s *Switcher) Run(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.mu.Lock()
|
||||
s.disabled = fmt.Sprintf("the switcher stopped on a fault: %v", r)
|
||||
s.mu.Unlock()
|
||||
fmt.Fprintln(os.Stderr, s.disabled)
|
||||
}
|
||||
}()
|
||||
if !s.m.ThisModel() {
|
||||
s.mu.Lock()
|
||||
s.disabled = fmt.Sprintf("this machine reports %q, not %q: the switcher does not act on another model",
|
||||
s.m.Model(), ModelFamily)
|
||||
s.mu.Unlock()
|
||||
fmt.Fprintln(os.Stderr, s.disabled)
|
||||
return
|
||||
}
|
||||
s.AssertVendorSettings(ctx)
|
||||
|
||||
events, err := listenPowerSupply(ctx)
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
s.watching = "polling every " + SampleEvery.String() + ": " + err.Error()
|
||||
} else {
|
||||
s.watching = "the kernel's power-supply events"
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.Evaluate(ctx, false)
|
||||
timer := time.NewTimer(s.interval(err != nil))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case _, open := <-events:
|
||||
if !open {
|
||||
events = nil
|
||||
s.mu.Lock()
|
||||
s.watching = "polling every " + SampleEvery.String() + ": the uevent socket closed"
|
||||
s.mu.Unlock()
|
||||
err = fmt.Errorf("closed")
|
||||
continue
|
||||
}
|
||||
// Settle: an adapter change arrives as several events within a moment.
|
||||
time.Sleep(time.Second)
|
||||
s.Evaluate(ctx, false)
|
||||
case <-timer.C:
|
||||
s.Evaluate(ctx, true)
|
||||
timer.Reset(s.interval(err != nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// interval is how long to sleep: a CPU sample's period on mains (or with no events to wake on), the
|
||||
// safety recheck on battery.
|
||||
func (s *Switcher) interval(polling bool) time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if polling || s.source == nil || s.source.OnAC {
|
||||
return SampleEvery
|
||||
}
|
||||
return SafetyRecheck
|
||||
}
|
||||
|
||||
// AssertVendorSettings puts asusd's own settings where the module wants them, once at start: the
|
||||
// battery charge limit, and the profiles asusd itself switches to on mains and on battery, so that the
|
||||
// vendor daemon's own switching and this module's never disagree. Each is read first and set only if
|
||||
// it differs. A value changed later with a tool stands until the next start.
|
||||
func (s *Switcher) AssertVendorSettings(ctx context.Context) []string {
|
||||
var said []string
|
||||
if limit, err := s.m.ChargeLimit(ctx); err != nil {
|
||||
said = append(said, "charge limit not read: "+err.Error())
|
||||
} else if limit != ChargeLimitPercent {
|
||||
if _, err := s.m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(ChargeLimitPercent)); err != nil {
|
||||
said = append(said, "charge limit not set: "+vendor("asusctl", err).Error())
|
||||
} else {
|
||||
said = append(said, fmt.Sprintf("charge limit %d%% → %d%%", limit, ChargeLimitPercent))
|
||||
}
|
||||
} else {
|
||||
said = append(said, fmt.Sprintf("charge limit already %d%%", ChargeLimitPercent))
|
||||
}
|
||||
p, err := s.m.Profile(ctx)
|
||||
if err != nil {
|
||||
said = append(said, "asusd's profiles not read: "+err.Error())
|
||||
} else {
|
||||
for _, want := range []struct{ flag, have, want, what string }{
|
||||
{"-a", p.OnAC, ProfileOnAC, "on mains"},
|
||||
{"-b", p.Battery, ProfileOnBattery, "on battery"},
|
||||
} {
|
||||
if want.have == "" || strings.EqualFold(want.have, want.want) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.m.Run(ctx, "asusctl", "profile", "set", want.flag, want.want); err != nil {
|
||||
said = append(said, "asusd's profile "+want.what+" not set: "+err.Error())
|
||||
} else {
|
||||
said = append(said, fmt.Sprintf("asusd's profile %s %s → %s", want.what, want.have, want.want))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.asserted = said
|
||||
s.mu.Unlock()
|
||||
for _, line := range said {
|
||||
fmt.Fprintln(os.Stderr, line)
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// SwitcherReport is the switcher's state as the profile-policy tool shows it.
|
||||
type SwitcherReport struct {
|
||||
Running bool `json:"running"`
|
||||
Disabled string `json:"disabled,omitempty"`
|
||||
Watching string `json:"woken_by,omitempty"`
|
||||
Source *Source `json:"source,omitempty"`
|
||||
Decision *Decision `json:"decision,omitempty"`
|
||||
Load Policy `json:"load"`
|
||||
LastApplied string `json:"last_applied,omitempty"`
|
||||
LastAppliedAt *time.Time `json:"last_applied_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
HeldUntil *time.Time `json:"held_until,omitempty"`
|
||||
Held string `json:"held_profile,omitempty"`
|
||||
AssertedAtStart []string `json:"asserted_at_start,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Switcher) Report() SwitcherReport {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r := SwitcherReport{
|
||||
Running: s.watching != "" && s.disabled == "", Disabled: s.disabled, Watching: s.watching,
|
||||
Source: s.source, Decision: s.decision, Load: s.policy, LastApplied: s.applied,
|
||||
LastAppliedAt: when(s.appliedAt), LastError: s.lastError, AssertedAtStart: s.asserted,
|
||||
}
|
||||
if s.now().Before(s.holdUntil) {
|
||||
r.HeldUntil, r.Held = when(s.holdUntil), s.holdOf
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// when is a time for a report: absent rather than the zero time.
|
||||
func when(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTheLoadMustBeSustainedToBoostAndToRelaxAndBetweenTheLinesNothingMoves(t *testing.T) {
|
||||
var p Policy
|
||||
at := time.Now()
|
||||
mains := Source{OnAC: true, Reason: "ACAD (Mains) is online"}
|
||||
for i := 0; i < SustainSamples-1; i++ {
|
||||
p.Observe(90, at)
|
||||
}
|
||||
if p.Decide(mains).Profile != ProfileOnAC {
|
||||
t.Fatal("boosted before the load was sustained")
|
||||
}
|
||||
p.Observe(35, at) // between the lines breaks the run
|
||||
p.Observe(90, at)
|
||||
if p.Boosted {
|
||||
t.Fatal("a broken run still counted")
|
||||
}
|
||||
for i := 0; i < SustainSamples; i++ {
|
||||
p.Observe(CPUHighPercent, at)
|
||||
}
|
||||
if d := p.Decide(mains); d.Profile != ProfileUnderLoad {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
p.Observe(35, at)
|
||||
if !p.Boosted {
|
||||
t.Fatal("load between the lines relaxed the boost")
|
||||
}
|
||||
for i := 0; i < RelaxSamples; i++ {
|
||||
p.Observe(5, at)
|
||||
}
|
||||
if p.Decide(mains).Profile != ProfileOnAC {
|
||||
t.Fatal("did not relax after a sustained low")
|
||||
}
|
||||
p.Boosted = true
|
||||
if d := p.Decide(Source{OnAC: false, Reason: "BAT1 is discharging"}); d.Profile != ProfileOnBattery {
|
||||
t.Fatalf("battery: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOWaitIsIdle(t *testing.T) {
|
||||
a, err := ParseProcStat("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := ParseProcStat("cpu 150 0 150 700 200 0 0 0 0 0\n")
|
||||
busy, ok := Busy(a, b)
|
||||
if !ok || busy != 50 {
|
||||
t.Fatalf("%v %v", busy, ok)
|
||||
}
|
||||
if _, ok := Busy(b, b); ok {
|
||||
t.Fatal("no time passed and a load was answered")
|
||||
}
|
||||
if _, err := ParseProcStat("intr 1 2"); err == nil {
|
||||
t.Fatal("a file without the cpu line was read")
|
||||
}
|
||||
}
|
||||
|
||||
func switcherOn(t *testing.T) (*fake, *Switcher, *[]map[string]any) {
|
||||
f := newFake(t)
|
||||
f.onMains()
|
||||
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0\n")
|
||||
var emitted []map[string]any
|
||||
sw := NewSwitcher(f.machine(), func(_ string, body any) error {
|
||||
emitted = append(emitted, body.(map[string]any))
|
||||
return nil
|
||||
})
|
||||
return f, sw, &emitted
|
||||
}
|
||||
|
||||
func TestStartingIsNotAReasonToSwitch(t *testing.T) {
|
||||
f, sw, emitted := switcherOn(t)
|
||||
sw.Evaluate(context.Background(), false)
|
||||
if calls := f.callsLike("asusctl profile set"); len(calls) != 0 || len(*emitted) != 0 {
|
||||
t.Fatalf("the first decision acted: %v %v", calls, *emitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangeOfPowerSourceSwitchesOnceAndPublishesIt(t *testing.T) {
|
||||
f, sw, emitted := switcherOn(t)
|
||||
ctx := context.Background()
|
||||
sw.Evaluate(ctx, false)
|
||||
f.onBattery()
|
||||
sw.Evaluate(ctx, false)
|
||||
sw.Evaluate(ctx, true) // nothing changed: nothing done, and on battery nothing sampled
|
||||
if calls := f.callsLike("asusctl profile set"); len(calls) != 1 || calls[0] != "asusctl profile set Quiet" {
|
||||
t.Fatalf("%v", calls)
|
||||
}
|
||||
if len(*emitted) != 1 || (*emitted)[0]["profile"] != "Quiet" || (*emitted)[0]["from"] != "Balanced" {
|
||||
t.Fatalf("%v", *emitted)
|
||||
}
|
||||
f.onMains()
|
||||
sw.Evaluate(ctx, false)
|
||||
if calls := f.callsLike("asusctl profile set"); len(calls) != 2 || calls[1] != "asusctl profile set Balanced" {
|
||||
t.Fatalf("%v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSustainedLoadOnMainsBoostsFromSamples(t *testing.T) {
|
||||
f, sw, _ := switcherOn(t)
|
||||
ctx := context.Background()
|
||||
sw.Evaluate(ctx, false)
|
||||
var user int
|
||||
for i := 1; i <= SustainSamples; i++ {
|
||||
user += 90
|
||||
f.file("/proc/stat", "cpu "+itoa(user)+" 0 0 "+itoa(i*10)+" 0 0 0 0\n")
|
||||
sw.Evaluate(ctx, true)
|
||||
}
|
||||
if !f.called("asusctl profile set Performance") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHoldKeepsTheProfileUntilItEndsAndAFailureIsTriedAgain(t *testing.T) {
|
||||
f, sw, _ := switcherOn(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
sw.now = func() time.Time { return now }
|
||||
sw.Evaluate(ctx, false)
|
||||
f.onBattery()
|
||||
sw.Evaluate(ctx, false) // source change ends any hold; switches to Quiet
|
||||
sw.Hold("Performance", time.Hour)
|
||||
f.fails["asusctl profile set Balanced"] = errors.New("asusd is restarting")
|
||||
f.onMains()
|
||||
sw.Evaluate(ctx, false) // a change of source: the hold ends, the switch is attempted and fails
|
||||
if r := sw.Report(); r.LastError == "" || r.Held != "" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
delete(f.fails, "asusctl profile set Balanced")
|
||||
sw.Evaluate(ctx, false)
|
||||
if r := sw.Report(); r.LastApplied != "Balanced" || r.LastError != "" {
|
||||
t.Fatalf("not tried again: %+v", r)
|
||||
}
|
||||
// A hold on the same source keeps a new decision from acting until it ends.
|
||||
sw.Hold("Quiet", time.Hour)
|
||||
sw.policy.Boosted = true
|
||||
sw.Evaluate(ctx, false)
|
||||
if f.called("asusctl profile set Performance") {
|
||||
t.Fatal("switched during a hold")
|
||||
}
|
||||
now = now.Add(2 * time.Hour)
|
||||
sw.Evaluate(ctx, false)
|
||||
if !f.called("asusctl profile set Performance") {
|
||||
t.Fatal("did not act once the hold ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsusdsSettingsAreSetOnlyWhereTheyDiffer(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.answers["asusctl battery info"] = "Current battery charge limit: 100%\n"
|
||||
f.answers["asusctl profile get"] = "Active profile: Balanced\nAC profile Performance\nBattery profile Quiet\n"
|
||||
sw := NewSwitcher(f.machine(), nil)
|
||||
sw.AssertVendorSettings(context.Background())
|
||||
if !f.called("asusctl battery limit 80") || !f.called("asusctl profile set -a Balanced") || f.called("asusctl profile set -b Quiet") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSwitcherDoesNotActOnAnotherModel(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.file("/sys/class/dmi/id/product_family", "ROG Strix\n")
|
||||
sw := NewSwitcher(f.machine(), nil)
|
||||
done := make(chan struct{})
|
||||
go func() { sw.Run(context.Background()); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the switcher ran on another model")
|
||||
}
|
||||
if r := sw.Report(); r.Running || r.Disabled == "" || len(f.calls) != 0 {
|
||||
t.Fatalf("%+v %v", r, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyPowerSupplyUeventsWake(t *testing.T) {
|
||||
yes := []byte("change@/devices/LNXSYSTM:00/ACPI0003:00/power_supply/ACAD\x00ACTION=change\x00SUBSYSTEM=power_supply\x00POWER_SUPPLY_ONLINE=0\x00")
|
||||
no := []byte("change@/devices/virtual/net/wlan0\x00ACTION=change\x00SUBSYSTEM=net\x00")
|
||||
if !powerSupplyEvent(yes) || powerSupplyEvent(no) {
|
||||
t.Fatal("the uevent filter")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sensor is one temperature, fan or power reading from hwmon.
|
||||
type Sensor struct {
|
||||
Chip string `json:"chip"`
|
||||
Label string `json:"label"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// DGPU is the discrete GPU as the PCI bus and its driver see it.
|
||||
type DGPU struct {
|
||||
Address string `json:"pci_address"`
|
||||
Runtime string `json:"runtime_status"`
|
||||
Name string `json:"name,omitempty"`
|
||||
TempC *float64 `json:"temp_c,omitempty"`
|
||||
PowerW *float64 `json:"power_w,omitempty"`
|
||||
PState string `json:"pstate,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W).
|
||||
func (m *Machine) hwmon(kind string) []Sensor {
|
||||
var out []Sensor
|
||||
for _, dir := range m.glob("/sys/class/hwmon/hwmon*") {
|
||||
chip := m.read(dir + "/name")
|
||||
inputs := m.glob(dir + "/" + kind + "*_input")
|
||||
if kind == "power" {
|
||||
inputs = append(inputs, m.glob(dir+"/power*_average")...)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
v, ok := m.readInt(in)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
base := path.Base(in)
|
||||
stem := base[:strings.LastIndex(base, "_")]
|
||||
label := m.read(dir + "/" + stem + "_label")
|
||||
if label == "" {
|
||||
label = base
|
||||
} else if strings.HasSuffix(base, "_average") {
|
||||
label += " (average)"
|
||||
}
|
||||
value := float64(v)
|
||||
switch kind {
|
||||
case "temp":
|
||||
value = round1(value / 1000)
|
||||
case "power":
|
||||
value = round1(value / 1e6)
|
||||
}
|
||||
out = append(out, Sensor{Chip: chip, Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Chip != out[j].Chip {
|
||||
return out[i].Chip < out[j].Chip
|
||||
}
|
||||
return out[i].Label < out[j].Label
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for
|
||||
// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which
|
||||
// is the power a reading of power draw should not cost.
|
||||
func (m *Machine) dgpu(ctx context.Context) *DGPU {
|
||||
for _, dir := range m.glob("/sys/bus/pci/devices/*") {
|
||||
if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") {
|
||||
continue
|
||||
}
|
||||
g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")}
|
||||
if g.Runtime != "active" {
|
||||
g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read"
|
||||
return g
|
||||
}
|
||||
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits")
|
||||
if err != nil {
|
||||
g.Note = "nvidia-smi: " + err.Error()
|
||||
return g
|
||||
}
|
||||
f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",")
|
||||
if len(f) >= 4 {
|
||||
g.Name = strings.TrimSpace(f[0])
|
||||
if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil {
|
||||
g.TempC = &t
|
||||
}
|
||||
if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil {
|
||||
w = round1(w)
|
||||
g.PowerW = &w
|
||||
}
|
||||
g.PState = strings.TrimSpace(f[3])
|
||||
}
|
||||
return g
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Thermals is what the thermals tool answers.
|
||||
type Thermals struct {
|
||||
Temperatures []Sensor `json:"temperatures_c"`
|
||||
Fans []Sensor `json:"fans_rpm"`
|
||||
DGPU *DGPU `json:"dgpu,omitempty"`
|
||||
Profile string `json:"platform_profile,omitempty"`
|
||||
Hottest *Sensor `json:"hottest,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) Thermals(ctx context.Context) Thermals {
|
||||
t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx),
|
||||
Profile: m.read("/sys/firmware/acpi/platform_profile")}
|
||||
if t.Temperatures == nil {
|
||||
t.Temperatures = []Sensor{}
|
||||
}
|
||||
if t.Fans == nil {
|
||||
t.Fans = []Sensor{}
|
||||
}
|
||||
for i := range t.Temperatures {
|
||||
if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value {
|
||||
h := t.Temperatures[i]
|
||||
t.Hottest = &h
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// PowerDraw is what the power-draw tool answers.
|
||||
type PowerDraw struct {
|
||||
Source Source `json:"source"`
|
||||
BatteryW *float64 `json:"battery_w,omitempty"`
|
||||
BatteryFlow string `json:"battery_flow,omitempty"`
|
||||
CPUPackageW *float64 `json:"apu_package_w,omitempty"`
|
||||
DGPU *DGPU `json:"dgpu,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func (m *Machine) PowerDraw(ctx context.Context) PowerDraw {
|
||||
p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx),
|
||||
Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"}
|
||||
for _, b := range m.Batteries() {
|
||||
if b.PowerW != nil {
|
||||
w := *b.PowerW
|
||||
p.BatteryW = &w
|
||||
switch strings.ToLower(b.Status) {
|
||||
case "discharging":
|
||||
p.BatteryFlow = "discharging"
|
||||
case "charging":
|
||||
p.BatteryFlow = "charging"
|
||||
default:
|
||||
p.BatteryFlow = strings.ToLower(b.Status)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model.
|
||||
for _, s := range m.hwmon("power") {
|
||||
if s.Chip == "amdgpu" && s.Label == "PPT" {
|
||||
w := s.Value
|
||||
p.CPUPackageW = &w
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tools is the module's tools, over one machine and its switcher.
|
||||
func Tools(m *Machine, sw *Switcher) []stdio.Tool {
|
||||
ctx := context.Background
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "zephyrus_brightness",
|
||||
Description: "Read or set the internal panel's and the keyboard's backlight. With no argument, reads both. " +
|
||||
"panel is a percentage (40) or a step (+5, -10), never below 1 %; keyboard is off, low, med, high, 0-3, + or -.",
|
||||
Input: map[string]any{
|
||||
"panel": map[string]any{"type": "string", "description": "percentage or step, e.g. 40, +5, -10"},
|
||||
"keyboard": map[string]any{"type": "string", "description": "off, low, med, high, 0-3, + or -"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
out := map[string]any{}
|
||||
if p := str(args, "panel"); p != "" {
|
||||
got, err := m.SetPanel(ctx(), p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["panel"] = got
|
||||
} else if got, err := m.PanelBrightness(); err == nil {
|
||||
out["panel"] = got
|
||||
} else {
|
||||
out["panel_error"] = err.Error()
|
||||
}
|
||||
if k := str(args, "keyboard"); k != "" {
|
||||
got, err := m.SetKeyboard(ctx(), k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["keyboard"] = got
|
||||
} else if got, err := m.KeyboardBrightness(); err == nil {
|
||||
out["keyboard"] = got
|
||||
} else {
|
||||
out["keyboard_error"] = err.Error()
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_battery",
|
||||
Description: "The battery: charge, energy, health (full against design), cycles, the charge limit, the power it gives or takes, and time left when discharging.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return map[string]any{"source": PowerSource(m.Supplies()), "batteries": orEmpty(m.Batteries())}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_charge_limit",
|
||||
Description: fmt.Sprintf("Read or set the battery charge limit through asusd. limit is 20-100; oneshot charges to full once "+
|
||||
"and goes back to the limit. The module asserts %d %% again when its process next starts.", ChargeLimitPercent),
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "20-100"},
|
||||
"oneshot": map[string]any{"type": "boolean", "description": "charge to full once, keeping the limit"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) { return ChargeLimitTool(ctx(), m, args) },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_gpu_mode",
|
||||
Description: "Read or set the hybrid GPU's mode through supergfxd: Integrated, Hybrid or AsusMuxDgpu as the machine supports. " +
|
||||
"Answers the mode, the discrete GPU's power state, any pending mode and the action it waits for (a logout, a reboot), " +
|
||||
"and whether asusd will switch it again on the next change of power source.",
|
||||
Input: map[string]any{
|
||||
"mode": map[string]any{"type": "string", "description": "a supported mode, e.g. Integrated or Hybrid"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) { return GPUModeTool(ctx(), m, args) },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_profile",
|
||||
Description: "Read or set the platform profile (Quiet, Balanced, Performance) through asusd. A profile set here is held " +
|
||||
fmt.Sprintf("for hold_minutes (default %d, 0 for none) before the module's switcher may move it; a change of power source ends the hold.", int(DefaultHold.Minutes())),
|
||||
Input: map[string]any{
|
||||
"profile": map[string]any{"type": "string", "enum": Profiles},
|
||||
"hold_minutes": map[string]any{"type": "integer", "description": "how long the switcher leaves it (default 60, at most 1440)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) { return ProfileTool(ctx(), m, sw, args) },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_thermals",
|
||||
Description: "Every temperature and fan the hardware reports (°C, RPM), the hottest, the platform profile, and the discrete GPU's temperature when it is awake (it is not woken to be read).",
|
||||
Run: func(map[string]any) (any, error) { return m.Thermals(ctx()), nil },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_power_draw",
|
||||
Description: "What the machine draws: the battery's flow in watts, the APU's package power, the discrete GPU's draw when awake, and the power source with the reason it was decided.",
|
||||
Run: func(map[string]any) (any, error) { return m.PowerDraw(ctx()), nil },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_profile_policy",
|
||||
Description: "What the module's profile switcher would choose now and why: the power source, recent CPU load against the thresholds, " +
|
||||
"the decision, the profile in force, any hold, what woke it, and what it asserted in asusd at start.",
|
||||
Run: func(map[string]any) (any, error) { return PolicyTool(ctx(), m, sw), nil },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_fan_curves",
|
||||
Description: "The fan curves asusd holds for each profile (or one profile): per fan, eight points of temperature and duty.",
|
||||
Input: map[string]any{
|
||||
"profile": map[string]any{"type": "string", "enum": Profiles},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) { return FanCurvesTool(ctx(), m, args) },
|
||||
},
|
||||
{
|
||||
Name: "zephyrus_check",
|
||||
Description: "Check what this module expects of the machine: the model, the vendor packages and daemons, the NVIDIA options in force, " +
|
||||
"suspend and resume, the charge limit, one authority each over the profile and the GPU mode, and the predecessor's leftovers. Says what it did not check.",
|
||||
Run: func(map[string]any) (any, error) { return m.Check(ctx(), sw), nil },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ChargeLimitTool reads or sets the limit.
|
||||
func ChargeLimitTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
||||
out := map[string]any{"module_limit_percent": ChargeLimitPercent}
|
||||
if v, given := args["limit"]; given && v != nil {
|
||||
n, err := whole(v, "limit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n < 20 || n > 100 {
|
||||
return nil, fmt.Errorf("limit %d is outside 20-100", n)
|
||||
}
|
||||
if _, err := m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(n)); err != nil {
|
||||
return nil, vendor("asusctl", err)
|
||||
}
|
||||
out["set"] = n
|
||||
}
|
||||
if b, _ := args["oneshot"].(bool); b {
|
||||
if _, err := m.Run(ctx, "asusctl", "battery", "oneshot"); err != nil {
|
||||
return nil, vendor("asusctl", err)
|
||||
}
|
||||
out["oneshot"] = "charging to full once; the limit returns after"
|
||||
}
|
||||
if n, err := m.ChargeLimit(ctx); err == nil {
|
||||
out["asusd_limit_percent"] = n
|
||||
} else {
|
||||
out["asusd_error"] = err.Error()
|
||||
}
|
||||
for _, b := range m.Batteries() {
|
||||
if b.LimitPercent != nil {
|
||||
out["kernel_limit_percent"] = *b.LimitPercent
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GPUModeTool reads or sets the GPU mode.
|
||||
func GPUModeTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
||||
g, err := m.GPU(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
if want := str(args, "mode"); want != "" {
|
||||
mode := ""
|
||||
for _, s := range g.Supported {
|
||||
if strings.EqualFold(s, want) {
|
||||
mode = s
|
||||
}
|
||||
}
|
||||
if mode == "" {
|
||||
return nil, fmt.Errorf("mode %q is not one this machine supports (%s)", want, strings.Join(g.Supported, ", "))
|
||||
}
|
||||
said, err := m.Run(ctx, "supergfxctl", "-m", mode)
|
||||
if err != nil {
|
||||
return nil, vendor("supergfxctl", err)
|
||||
}
|
||||
out["requested"] = mode
|
||||
if s := strings.TrimSpace(said); s != "" {
|
||||
out["supergfxctl_said"] = s
|
||||
}
|
||||
if g, err = m.GPU(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out["gpu"] = g
|
||||
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
|
||||
out["asusd_switches_it"] = map[string]string{"on_ac": c.ACCommand, "on_battery": c.BatteryCommand,
|
||||
"note": "asusd runs these on every change of power source, so a mode set here lasts until the next one"}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProfileTool reads or sets the profile.
|
||||
func ProfileTool(ctx context.Context, m *Machine, sw *Switcher, args map[string]any) (any, error) {
|
||||
out := map[string]any{}
|
||||
if want := str(args, "profile"); want != "" {
|
||||
p, err := canonicalProfile(want)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hold := DefaultHold
|
||||
if v, given := args["hold_minutes"]; given && v != nil {
|
||||
n, err := whole(v, "hold_minutes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n < 0 {
|
||||
return nil, fmt.Errorf("hold_minutes must not be negative")
|
||||
}
|
||||
hold = time.Duration(min(n, 1440)) * time.Minute
|
||||
}
|
||||
if err := m.SetProfile(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["set"] = p
|
||||
if sw != nil {
|
||||
if until := sw.Hold(p, hold); !until.IsZero() {
|
||||
out["held_until"] = until
|
||||
}
|
||||
}
|
||||
}
|
||||
state, err := m.Profile(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["profile"] = state
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PolicyTool reports the switcher and, independently of it, what the policy says now.
|
||||
func PolicyTool(ctx context.Context, m *Machine, sw *Switcher) any {
|
||||
out := map[string]any{
|
||||
"thresholds": map[string]any{
|
||||
"on_battery": ProfileOnBattery, "on_ac": ProfileOnAC, "under_load": ProfileUnderLoad,
|
||||
"cpu_high_percent": CPUHighPercent, "cpu_low_percent": CPULowPercent,
|
||||
"sample_every": SampleEvery.String(), "sustain_samples": SustainSamples, "relax_samples": RelaxSamples,
|
||||
"set_by": "constants until settings exist (novox/hq issue 168)",
|
||||
},
|
||||
}
|
||||
if sw != nil {
|
||||
out["switcher"] = sw.Report()
|
||||
} else {
|
||||
var p Policy
|
||||
out["decision_now"] = p.Decide(PowerSource(m.Supplies()))
|
||||
}
|
||||
if state, err := m.Profile(ctx); err == nil {
|
||||
out["in_force"] = state
|
||||
} else {
|
||||
out["in_force_error"] = err.Error()
|
||||
}
|
||||
if pid, ok := m.predecessorProcess("auto-profile"); ok {
|
||||
out["second_switcher"] = fmt.Sprintf("the predecessor's auto-profile still runs (pid %d) and overrides this every five seconds", pid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FanCurvesTool reads asusd's fan curves.
|
||||
func FanCurvesTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
||||
profiles := Profiles
|
||||
if want := str(args, "profile"); want != "" {
|
||||
p, err := canonicalProfile(want)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = []string{p}
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, p := range profiles {
|
||||
said, err := m.Run(ctx, "asusctl", "fan-curve", "--mod-profile", strings.ToLower(p))
|
||||
if err != nil {
|
||||
return nil, vendor("asusctl", err)
|
||||
}
|
||||
out[p] = orEmpty(ParseFanCurves(said))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func str(args map[string]any, key string) string {
|
||||
s, _ := args[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// whole is an integer argument given as a JSON number or a numeric string.
|
||||
func whole(v any, key string) (int, error) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n != math.Trunc(n) {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
|
||||
}
|
||||
return int(n), nil
|
||||
case string:
|
||||
i, err := strconv.Atoi(strings.TrimSpace(n))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// The kernel announces every change of a power supply — an adapter plugged or pulled, a battery
|
||||
// starting or stopping to discharge — as a uevent on a netlink socket that any account may listen
|
||||
// on. That is the event the switcher reacts to: no daemon, no bus client, no polling.
|
||||
//
|
||||
// upower re-announces the same changes on the system bus, and listening there would need a D-Bus
|
||||
// client in the bundle; udev's re-broadcast (netlink group 2) carries a libudev header. The kernel's
|
||||
// own group (1) is the source both of them read.
|
||||
|
||||
// powerSupplyEvent says whether a uevent is about a power supply.
|
||||
func powerSupplyEvent(msg []byte) bool {
|
||||
for _, field := range bytes.Split(msg, []byte{0}) {
|
||||
if bytes.Equal(field, []byte("SUBSYSTEM=power_supply")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// listenPowerSupply opens the kernel's uevent socket and sends on the channel for each power-supply
|
||||
// event, never blocking: a burst of events is one wake-up. It stops when ctx ends.
|
||||
func listenPowerSupply(ctx context.Context) (<-chan struct{}, error) {
|
||||
fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, syscall.NETLINK_KOBJECT_UEVENT)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening the kernel's uevent socket: %w", err)
|
||||
}
|
||||
if err := syscall.Bind(fd, &syscall.SockaddrNetlink{Family: syscall.AF_NETLINK, Groups: 1}); err != nil {
|
||||
syscall.Close(fd)
|
||||
return nil, fmt.Errorf("joining the kernel's uevent group: %w", err)
|
||||
}
|
||||
events := make(chan struct{}, 1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
syscall.Close(fd)
|
||||
}()
|
||||
go func() {
|
||||
defer close(events)
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, _, err := syscall.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if err == syscall.EINTR || err == syscall.ENOBUFS {
|
||||
// ENOBUFS: events were dropped. Treat it as one, since a dropped one may have
|
||||
// been the adapter.
|
||||
if err == syscall.ENOBUFS {
|
||||
select {
|
||||
case events <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
if powerSupplyEvent(buf[:n]) {
|
||||
select {
|
||||
case events <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %.
|
||||
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
|
||||
#
|
||||
# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also
|
||||
# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group
|
||||
# through the module's udev rule, so the vendor-key trigger needs no root.
|
||||
set -u
|
||||
STEP=5
|
||||
dev=""
|
||||
for d in /sys/class/backlight/*; do
|
||||
[ -e "$d" ] || continue
|
||||
case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac
|
||||
done
|
||||
if [ -z "$dev" ]; then
|
||||
for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done
|
||||
fi
|
||||
[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; }
|
||||
cur=$(cat "$dev/brightness")
|
||||
max=$(cat "$dev/max_brightness")
|
||||
pct=$(( cur * 100 / max ))
|
||||
case "${1:-}" in
|
||||
+|up|Up) pct=$(( pct + STEP )) ;;
|
||||
-|down|Down) pct=$(( pct - STEP )) ;;
|
||||
''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;;
|
||||
*) pct=$1 ;;
|
||||
esac
|
||||
(( pct < 1 )) && pct=1
|
||||
(( pct > 100 )) && pct=100
|
||||
new=$(( max * pct / 100 ))
|
||||
(( new < 1 )) && new=1
|
||||
printf '%s' "$new" >"$dev/brightness" || exit 1
|
||||
exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the
|
||||
# same ID, through the session's notification service on its bus. busctl is the service manager's
|
||||
# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module.
|
||||
set -u
|
||||
id=${1:-0}
|
||||
summary=${2:-}
|
||||
uid=$(id -u)
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||
busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \
|
||||
org.freedesktop.Notifications Notify susssasa{sv}i \
|
||||
asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside
|
||||
# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of
|
||||
# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
|
||||
#
|
||||
# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to
|
||||
# a named person with a hard-coded user id and display, and sourced a file of secrets on the way.
|
||||
# Here the account is whoever runs it, the bus is that account's, and the display is the one the
|
||||
# account's own session uses. Nothing is sourced.
|
||||
set -u
|
||||
uid=$(id -u)
|
||||
export XDG_RUNTIME_DIR="/run/user/${uid}"
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
|
||||
home=$(getent passwd "$uid" | cut -d: -f6)
|
||||
[ -n "$home" ] && export HOME="$home"
|
||||
if [ -z "${DISPLAY:-}" ]; then
|
||||
# The login manager may not record the display with logind; any process of this account that
|
||||
# has one says which it is.
|
||||
for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do
|
||||
d=$(loginctl show-session "$s" -p Display --value 2>/dev/null)
|
||||
if [ -n "$d" ]; then export DISPLAY="$d"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${DISPLAY:-}" ]; then
|
||||
for pid in $(pgrep -u "$uid" 2>/dev/null); do
|
||||
env=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null) || continue
|
||||
d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1)
|
||||
if [ -n "$d" ]; then
|
||||
export DISPLAY="$d"
|
||||
a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1)
|
||||
[ -n "$a" ] && export XAUTHORITY="$a"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
: "${XAUTHORITY:=${HOME}/.Xauthority}"
|
||||
export XAUTHORITY
|
||||
exec "$@"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off.
|
||||
# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue.
|
||||
#
|
||||
# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf),
|
||||
# which X applies every time the device appears — after a resume too, which is what the predecessor's
|
||||
# sleep hook existed for. This is the manual form, bound to the touchpad key.
|
||||
set -u
|
||||
here=$(dirname "$0")
|
||||
name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad')
|
||||
[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; }
|
||||
x() { "$here/zephyrus-session" xinput "$@"; }
|
||||
case "${1:-reset}" in
|
||||
reset)
|
||||
x set-prop "$name" "libinput Tapping Enabled" 1
|
||||
x set-prop "$name" "libinput Natural Scrolling Enabled" 1
|
||||
x set-prop "$name" "libinput Accel Speed" 0.15
|
||||
x enable "$name"
|
||||
"$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset"
|
||||
;;
|
||||
toggle)
|
||||
if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then
|
||||
x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off"
|
||||
else
|
||||
x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on"
|
||||
fi
|
||||
;;
|
||||
*) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,5 @@
|
||||
module asuszephyrusg14
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"emits": [
|
||||
"profile.switched"
|
||||
],
|
||||
"tools": [
|
||||
"zephyrus_brightness",
|
||||
"zephyrus_battery",
|
||||
"zephyrus_charge_limit",
|
||||
"zephyrus_gpu_mode",
|
||||
"zephyrus_profile",
|
||||
"zephyrus_thermals",
|
||||
"zephyrus_power_draw",
|
||||
"zephyrus_profile_policy",
|
||||
"zephyrus_fan_curves",
|
||||
"zephyrus_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "asusctl",
|
||||
"type": "package",
|
||||
"package": "asusctl"
|
||||
},
|
||||
{
|
||||
"id": "playerctl",
|
||||
"type": "package",
|
||||
"package": "playerctl"
|
||||
},
|
||||
{
|
||||
"id": "xinput",
|
||||
"type": "package",
|
||||
"package": "xorg-xinput"
|
||||
},
|
||||
{
|
||||
"id": "asusd",
|
||||
"type": "service",
|
||||
"unit": "asusd.service",
|
||||
"state": "running"
|
||||
},
|
||||
{
|
||||
"id": "supergfxd",
|
||||
"type": "service",
|
||||
"unit": "supergfxd.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "scripts",
|
||||
"type": "archive",
|
||||
"path": "/usr/local/lib/asus-zephyrus-g14",
|
||||
"artifact": "scripts"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
||||
},
|
||||
{
|
||||
"id": "video-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-then-hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend-then-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "powerd-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "powerd-opt-in",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
||||
},
|
||||
{
|
||||
"id": "logind-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/logind.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "logind-power",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/logind.conf.d/power.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The power key and the lid suspend, on battery, on mains and docked alike.\n[Login]\nHandlePowerKey=suspend\nHandleLidSwitch=suspend\nHandleLidSwitchExternalPower=suspend\nHandleLidSwitchDocked=suspend\n"
|
||||
},
|
||||
{
|
||||
"id": "logind",
|
||||
"type": "service",
|
||||
"unit": "systemd-logind.service",
|
||||
"reload-on": [
|
||||
"logind-power"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backlight-rule",
|
||||
"type": "file",
|
||||
"path": "/etc/udev/rules.d/90-backlight.rules",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
||||
},
|
||||
{
|
||||
"id": "udev",
|
||||
"type": "service",
|
||||
"unit": "systemd-udevd.service",
|
||||
"reload-on": [
|
||||
"backlight-rule"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "triggerhappy-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/triggerhappy.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "triggerhappy-as-account",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/triggerhappy.service.d/asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# triggerhappy runs the vendor-key triggers as the operator's account, not as root: it opens the input\n# devices first and then drops to the account with its groups (input, video), so the triggers reach the\n# account's own session bus and the panel through the video group, with no su and no hard-coded user.\n[Service]\nExecStart=\nExecStart=/usr/bin/thd --triggers /etc/triggerhappy/triggers.d/ --socket /run/thd.socket --user ${machine:account} --deviceglob /dev/input/event*\n"
|
||||
},
|
||||
{
|
||||
"id": "vendor-keys",
|
||||
"type": "file",
|
||||
"path": "/etc/triggerhappy/triggers.d/asus-g14.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ROG Zephyrus G14's vendor keys, which reach no X client. Run as the operator's account (see the\n# module's drop-in on triggerhappy.service).\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||
},
|
||||
{
|
||||
"id": "triggerhappy",
|
||||
"type": "service",
|
||||
"unit": "triggerhappy.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"triggerhappy-as-account",
|
||||
"vendor-keys",
|
||||
"scripts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "upower-package",
|
||||
"type": "package",
|
||||
"package": "upower"
|
||||
},
|
||||
{
|
||||
"id": "upower-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/UPower/UPower.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "low-battery",
|
||||
"type": "file",
|
||||
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
||||
},
|
||||
{
|
||||
"id": "upower",
|
||||
"type": "service",
|
||||
"unit": "upower.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"low-battery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "xorg-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/X11/xorg.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "touchpad",
|
||||
"type": "file",
|
||||
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools-go",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/zephyrus",
|
||||
"binary": "zephyrus",
|
||||
"loads": [
|
||||
"zephyrus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "scripts",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so
|
||||
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
|
||||
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
|
||||
# the siblings laid out beside it.
|
||||
|
||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
|
||||
# They are different images on purpose — the first carries a compiler and the second must not, or
|
||||
# every running container would carry one it never invokes. The mesh answers both with the copies it
|
||||
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
|
||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
|
||||
# nobody told stops here and says which module to build first.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||
# node_modules — the module is compiled against exactly the toolkit it will run against.
|
||||
WORKDIR /app/modules/audit-logger
|
||||
COPY . .
|
||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
||||
# was assembled.
|
||||
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
|
||||
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
|
||||
# imports anything — `run` exists for a step that works offline and exits, and would leave this
|
||||
# with nothing to subscribe to.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
|
||||
@@ -5,27 +5,21 @@
|
||||
"consumes": [
|
||||
"**"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js"
|
||||
],
|
||||
"env": {
|
||||
"AUDIT_LOG": "${dir:trail}/audit.log"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40,21 +34,6 @@
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"type": "container",
|
||||
"name": "mesh-audit-logger",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:trail}:/trail"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"AUDIT_LOG": "/trail/audit.log"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
|
||||
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
|
||||
const path = join(dir, "audit.log");
|
||||
|
||||
// The audit-logger's whole behaviour: consume everything, record it.
|
||||
await on("**", async (event) => record(event, path));
|
||||
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
|
||||
|
||||
process.env.MESH_MODULE = "umami";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
@@ -24,7 +24,9 @@ test("audit-logger records every event to the trail as one line each", async ()
|
||||
|
||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
|
||||
// A module names its events locally (design 29); the module is the `source`, which together with
|
||||
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
|
||||
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
|
||||
assert.equal(lines[0].source, "umami");
|
||||
assert.equal(lines[0].node, "anchor");
|
||||
assert.equal(lines[0].body.domain, "my-app");
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# baserow's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/baserow
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/baserow/dist /app/modules/baserow/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/baserow/dist/tools/index.js
|
||||
+14
-36
@@ -25,8 +25,7 @@
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"admin": "${dir:state}/admin.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -92,45 +91,24 @@
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-baserow",
|
||||
"network": "baserow",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_BASEROW_URL": "http://baserow:80",
|
||||
"MESH_BASEROW_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
||||
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG GO_BASE
|
||||
ARG ALPINE_BASE
|
||||
# builder's own image: the build machine itself, compiled into a container.
|
||||
# build-agent's image: the build machine itself, compiled into a container (novox/hq ADR 0190).
|
||||
#
|
||||
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
|
||||
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
|
||||
@@ -0,0 +1,15 @@
|
||||
# build-agent
|
||||
|
||||
The mesh's build machine as a role every machine can hold (novox/hq ADR 0190). It holds the node seat
|
||||
`node-build-agent`: every holder pulls one build at a time from the role's one work queue when it is
|
||||
idle, so a tier of many images is built by as many machines as hold the seat and are online, and a
|
||||
machine that is off builds nothing and blocks nothing. The controller asks the role, never a machine;
|
||||
the outcome names the machine that built it.
|
||||
|
||||
What a holding machine needs is what the builder always needed, said here once: a container runtime
|
||||
(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and
|
||||
the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
|
||||
that repository's main (`build.artifacts[].context`); this module ships the packaging.
|
||||
|
||||
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
||||
same thing; retire that once this is assigned where it was.
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"module": "builder",
|
||||
"module": "build-agent",
|
||||
"version": "1",
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-build-machine",
|
||||
"scope": "mesh"
|
||||
"name": "node-build-agent",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
@@ -36,19 +37,19 @@
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "builder-env",
|
||||
"id": "agent-env",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/builder.env",
|
||||
"path": "${dir:mesh-state}/build-agent.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mesh-builder",
|
||||
"name": "mesh-build-agent",
|
||||
"artifact": "server",
|
||||
"env-file": [
|
||||
"${dir:mesh-state}/builder.env"
|
||||
"${dir:mesh-state}/build-agent.env"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:mesh-state}:/run/mesh:ro",
|
||||
@@ -56,7 +57,7 @@
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"restart-on": [
|
||||
"builder-env"
|
||||
"agent-env"
|
||||
],
|
||||
"network": "host"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# claude-code
|
||||
|
||||
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
|
||||
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
|
||||
|
||||
## What it owns
|
||||
|
||||
Two directories, declared, so the mesh refuses a second module owning either:
|
||||
|
||||
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
|
||||
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
|
||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||
removes a directory only when it is empty.
|
||||
|
||||
## What it writes
|
||||
|
||||
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
|
||||
whenever the node's tool runtime collects the module's tools:
|
||||
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
||||
|
||||
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
||||
this node a subscription token. Nothing else under the home is read or written.
|
||||
|
||||
## Over NATS
|
||||
|
||||
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
|
||||
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
|
||||
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
|
||||
|
||||
| what | how |
|
||||
|---|---|
|
||||
| the licence manager rotated a licence, or switched this node | its `licence.rotated` / `licence.switched` event; this module then asks `anthropic-licence-manager.current` for its token, sealed to the key it sends |
|
||||
| this node starts | it asks `current` once, so a node that was off catches up |
|
||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; it asks `anthropic-licence-manager.adopt` at once with the grant sealed to the manager's key — the one time a refresh token travels, because the login made the manager's stale |
|
||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||
|
||||
## Tools
|
||||
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_mcp_list`,
|
||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
|
||||
## Settings
|
||||
|
||||
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||
|
||||
- `role` — what this node is, in a few words; shown to every session.
|
||||
- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones
|
||||
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
|
||||
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
|
||||
module's own and cannot be set. Put a person's own servers here, or they stop loading.
|
||||
|
||||
## On a machine that carried the predecessor
|
||||
|
||||
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
|
||||
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
|
||||
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
|
||||
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
|
||||
|
||||
## Escalation
|
||||
|
||||
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
|
||||
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
|
||||
is written.
|
||||
@@ -0,0 +1,112 @@
|
||||
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq
|
||||
// ADR 0183, design 36 §5). Pure where it decides, so the rules are tested without a file.
|
||||
//
|
||||
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?,
|
||||
// scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the
|
||||
// one this module writes never carries one, and a full grant a login left behind is stripped the
|
||||
// moment the manager hands the node its own.
|
||||
//
|
||||
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same
|
||||
// licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a
|
||||
// switch to another licence is applied regardless, because across licences the expiries are
|
||||
// unrelated numbers.
|
||||
|
||||
import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export interface Grant {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAt: number;
|
||||
readonly refreshTokenExpiresAt?: number | null;
|
||||
readonly scopes?: readonly string[] | null;
|
||||
readonly subscriptionType?: string | null;
|
||||
readonly rateLimitTier?: string | null;
|
||||
}
|
||||
|
||||
export type ApplySource = "rotation" | "switch";
|
||||
|
||||
export type ApplyDecision =
|
||||
| { apply: true; reissued?: boolean }
|
||||
| { apply: false; reason: "already-current" }
|
||||
| { apply: false; reason: "not-newer"; localExpiresAt: number };
|
||||
|
||||
/** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */
|
||||
export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function sameGeneration(a?: number | null, b?: number | null): boolean {
|
||||
if (a == null || b == null) return true;
|
||||
return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision {
|
||||
if (!local?.accessToken) return { apply: true };
|
||||
if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" };
|
||||
const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt);
|
||||
if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) {
|
||||
return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) };
|
||||
}
|
||||
return reissued ? { apply: true, reissued: true } : { apply: true };
|
||||
}
|
||||
|
||||
type Oauth = Record<string, unknown> & { accessToken?: string; refreshToken?: string; expiresAt?: number };
|
||||
type Credentials = Record<string, unknown> & { claudeAiOauth?: Oauth };
|
||||
|
||||
export function readCredentials(path: string): Credentials | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The grant the file holds, or null. */
|
||||
export function grantOf(creds: Credentials | null): Grant | null {
|
||||
const o = creds?.claudeAiOauth;
|
||||
if (!o?.accessToken) return null;
|
||||
return {
|
||||
accessToken: o.accessToken,
|
||||
expiresAt: Number(o.expiresAt ?? 0),
|
||||
refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Does the file hold a full grant — a refresh token this module never writes, so a person's login? */
|
||||
export function holdsLogin(creds: Credentials | null): boolean {
|
||||
return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The handed grant laid over what is there — a rotation of the licence the node already holds — or,
|
||||
* for a switch, in place of it: the old licence's grant goes whole, scopes and subscription included,
|
||||
* and only keys outside the grant (another kind of credential the vendor keeps in the file) stay.
|
||||
* Either way, no refresh token survives.
|
||||
*/
|
||||
export function replacedBy(local: Credentials | null, grant: Grant): Credentials {
|
||||
const next: Credentials = { ...(local ?? {}) };
|
||||
delete next.claudeAiOauth;
|
||||
return withGrant(next, grant);
|
||||
}
|
||||
|
||||
/** Overlay the handed grant on what is there, and delete any refresh token. */
|
||||
export function withGrant(local: Credentials | null, grant: Grant): Credentials {
|
||||
const next: Credentials = { ...(local ?? {}) };
|
||||
const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) };
|
||||
oauth.accessToken = grant.accessToken;
|
||||
oauth.expiresAt = grant.expiresAt;
|
||||
for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) {
|
||||
const v = grant[k];
|
||||
if (v != null) oauth[k] = v as unknown;
|
||||
}
|
||||
delete oauth.refreshToken;
|
||||
next.claudeAiOauth = oauth;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Write atomically at 0600: a partial credentials file must never be read as a whole one. */
|
||||
export function writeCredentials(path: string, creds: Credentials): void {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${path}.mesh-tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's
|
||||
// own state file beside the home, `~/.claude.json` → `oauthAccount`. Read to attribute a login; written,
|
||||
// three keys and nothing else, when a licence is switched, so the file Claude Code shows the account from
|
||||
// names the account whose token it now holds (as the predecessor learned: two files that disagree make
|
||||
// a later login look like the wrong account).
|
||||
|
||||
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
|
||||
export interface Identity {
|
||||
readonly accountUuid: string;
|
||||
readonly emailAddress?: string;
|
||||
readonly organizationUuid?: string;
|
||||
}
|
||||
|
||||
export function readIdentity(stateFile: string): Identity | null {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(stateFile, "utf8")) as { oauthAccount?: Record<string, unknown> };
|
||||
const a = raw.oauthAccount;
|
||||
if (!a || typeof a.accountUuid !== "string") return null;
|
||||
return {
|
||||
accountUuid: a.accountUuid,
|
||||
emailAddress: typeof a.emailAddress === "string" ? a.emailAddress : undefined,
|
||||
organizationUuid: typeof a.organizationUuid === "string" ? a.organizationUuid : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the state file's account at `id`, keeping every other key as found. Returns whether the file
|
||||
* changed; a file that cannot be read as an object is left alone rather than replaced.
|
||||
*/
|
||||
export function writeIdentity(stateFile: string, id: Identity): boolean {
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(stateFile, "utf8")) as Record<string, unknown>;
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
} catch {
|
||||
raw = {};
|
||||
}
|
||||
const current = (raw.oauthAccount ?? {}) as Record<string, unknown>;
|
||||
if (current.accountUuid === id.accountUuid && current.emailAddress === id.emailAddress
|
||||
&& current.organizationUuid === id.organizationUuid) return false;
|
||||
raw.oauthAccount = { ...current, accountUuid: id.accountUuid, emailAddress: id.emailAddress, organizationUuid: id.organizationUuid };
|
||||
const tmp = `${stateFile}.mesh-tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(raw, null, 2), { mode: 0o600 });
|
||||
renameSync(tmp, stateFile);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint"
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"consumes": [
|
||||
"claude-licence-manager.licence.rotated",
|
||||
"claude-licence-manager.licence.switched"
|
||||
],
|
||||
"state": [
|
||||
"servers"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_pull",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "claude-code"
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
"type": "directory",
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "facts",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"merge": "json",
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// What claude-code does on a node, written against two things it is handed — a way to ask a tool on the
|
||||
// bus and a way to emit an event — so every path is tested without a bus (novox/hq design 36 §4–§5,
|
||||
// ADR 0183, ADR 0198).
|
||||
//
|
||||
// **Over NATS, in two kinds** (design 32 §10): an event says that something happened and carries no
|
||||
// secret, because a stream keeps it; a token travels on a request, which nothing keeps. So:
|
||||
// - the licence manager's `licence.rotated` and `licence.switched` events tell this module to ask the
|
||||
// seat for its current token, sealed to the key it sends with the request;
|
||||
// - a login a person made here — a refresh token this module never writes — is offered to the seat at
|
||||
// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the
|
||||
// manager's stale;
|
||||
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0201): one
|
||||
// key per server in the module's `servers` bucket — `all.<server>` for every node, `<node>.<server>`
|
||||
// for one — which every node watches. A node that joins later, or was off, reads the whole current set
|
||||
// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one.
|
||||
|
||||
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js";
|
||||
import { generateKeyPair, open, seal, type SealedBox } from "./seal.js";
|
||||
import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGrant, writeCredentials, type Grant } from "./grant.js";
|
||||
import { readIdentity, writeIdentity, type Identity } from "./identity.js";
|
||||
|
||||
export const SEAT = "anthropic-licence-manager";
|
||||
|
||||
export interface Paths {
|
||||
state: string;
|
||||
facts: string;
|
||||
settings: string;
|
||||
home: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
/** A tool on the bus: its address and arguments in, its JSON answer out. */
|
||||
export type Ask = (address: string, args: Record<string, unknown>) => Promise<unknown>;
|
||||
/** An event of this module's, by its local name. */
|
||||
export type Emit = (type: string, body: unknown) => Promise<void>;
|
||||
/** Write one managed file; answers what happened. */
|
||||
export type WriteManaged = (name: string, content: string) => string;
|
||||
|
||||
export const readJson = <T>(p: string, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, "utf8")) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const credentialsPath = (p: Paths) => join(p.home, ".claude", ".credentials.json");
|
||||
const accountPath = (p: Paths) => join(p.home, ".claude.json");
|
||||
const bindingPath = (p: Paths) => join(p.state, "licence.json");
|
||||
const apiKeyPath = (p: Paths) => join(p.state, "api-key");
|
||||
export const helperPath = (p: Paths) => join(p.state, "api-key-helper");
|
||||
const keyPath = (p: Paths) => join(p.state, "key.pem");
|
||||
const pubPath = (p: Paths) => join(p.state, "key.pub.pem");
|
||||
const registryPath = (p: Paths) => join(p.state, "mcp-servers.json");
|
||||
|
||||
export function keypair(p: Paths): { publicKey: string; privateKey: string } {
|
||||
if (!existsSync(keyPath(p))) {
|
||||
const k = generateKeyPair();
|
||||
writeFileSync(keyPath(p), k.privateKey, { mode: 0o600 });
|
||||
writeFileSync(pubPath(p), k.publicKey, { mode: 0o644 });
|
||||
}
|
||||
return { privateKey: readFileSync(keyPath(p), "utf8"), publicKey: readFileSync(pubPath(p), "utf8") };
|
||||
}
|
||||
|
||||
export function registered(p: Paths): Servers {
|
||||
return readJson<Servers>(registryPath(p), {});
|
||||
}
|
||||
|
||||
export function renderNow(p: Paths, write: WriteManaged): string[] {
|
||||
const facts = readJson<Facts | null>(p.facts, null);
|
||||
if (!facts?.console) throw new Error(`the mesh has not rendered ${p.facts} yet; nothing to write`);
|
||||
const files = render(facts, readJson<Settings>(p.settings, {}), readJson<Binding | null>(bindingPath(p), null),
|
||||
helperPath(p), registered(p));
|
||||
return Object.entries(files).map(([name, content]) => write(name, content));
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
/** What the seat answers to `current`: the licence this node is bound to and its token, sealed. */
|
||||
export interface Current {
|
||||
licence: string;
|
||||
kind: "subscription" | "api-key";
|
||||
sealed: SealedBox;
|
||||
identity?: Identity | null;
|
||||
}
|
||||
|
||||
/** Ask the seat for this node's current token and apply it. */
|
||||
export async function pull(p: Paths, ask: Ask, write: WriteManaged): Promise<Record<string, unknown>> {
|
||||
const answer = (await ask(`${SEAT}.current`, { node: p.node, public_key: keypair(p).publicKey })) as Current | null;
|
||||
if (!answer?.sealed) return { applied: false, reason: "the seat holds no licence for this node" };
|
||||
return apply(p, answer, write);
|
||||
}
|
||||
|
||||
/** Apply what the seat handed over. A switch replaces the grant whole and cleans up after the old licence. */
|
||||
export function apply(p: Paths, handed: Current, write: WriteManaged): Record<string, unknown> {
|
||||
const plain = open(handed.sealed, keypair(p).privateKey);
|
||||
const previous = readJson<Binding | null>(bindingPath(p), null);
|
||||
const switched = previous?.licence !== handed.licence;
|
||||
let outcome: Record<string, unknown> = { applied: true, licence: handed.licence, kind: handed.kind, switched };
|
||||
if (handed.kind === "api-key") {
|
||||
writeFileSync(apiKeyPath(p), plain.trim() + "\n", { mode: 0o600 });
|
||||
writeFileSync(helperPath(p), `#!/bin/sh\nexec cat '${apiKeyPath(p)}'\n`, { mode: 0o700 });
|
||||
chmodSync(helperPath(p), 0o700);
|
||||
} else {
|
||||
const grant = JSON.parse(plain) as Grant;
|
||||
const local = readCredentials(credentialsPath(p));
|
||||
const d = decideApply(grantOf(local), grant, switched ? "switch" : "rotation");
|
||||
if (d.apply) writeCredentials(credentialsPath(p), switched ? replacedBy(local, grant) : withGrant(local, grant));
|
||||
else outcome = { applied: false, licence: handed.licence, reason: "reason" in d ? d.reason : undefined }; // narrowed by hand: the build compiles without strict
|
||||
// Away from the API key: it goes, with its helper.
|
||||
rmSync(apiKeyPath(p), { force: true });
|
||||
rmSync(helperPath(p), { force: true });
|
||||
}
|
||||
if (switched && handed.identity?.accountUuid) {
|
||||
outcome.account = writeIdentity(accountPath(p), handed.identity) ? "updated" : "unchanged";
|
||||
}
|
||||
writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 });
|
||||
try {
|
||||
outcome.rendered = renderNow(p, write); // the key-helper comes or goes with the licence's kind
|
||||
} catch (err) {
|
||||
outcome.rendered = { failed: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** A licence event from the manager: is it for this node? */
|
||||
export function concerns(p: Paths, type: string, body: { licence?: string; node?: string }): boolean {
|
||||
if (type.endsWith("licence.switched")) return body.node === p.node;
|
||||
if (type.endsWith("licence.rotated")) return body.licence === readJson<Binding | null>(bindingPath(p), null)?.licence;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A refresh token in the credentials file is a login: this module never writes one. Offer it to the seat. */
|
||||
export async function offerLogin(p: Paths, ask: Ask): Promise<Record<string, unknown> | null> {
|
||||
const creds = readCredentials(credentialsPath(p));
|
||||
if (!holdsLogin(creds)) return null;
|
||||
const key = (await ask(`${SEAT}.public_key`, {})) as { public_key?: string } | null;
|
||||
if (!key?.public_key) throw new Error("the licence manager did not say what key to seal a login to");
|
||||
return (await ask(`${SEAT}.adopt`, {
|
||||
node: p.node,
|
||||
identity: readIdentity(accountPath(p)),
|
||||
sealed: seal(JSON.stringify(creds!.claudeAiOauth), key.public_key),
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---- MCP servers ----------------------------------------------------------------------------------
|
||||
|
||||
export interface Registration {
|
||||
name: string;
|
||||
entry?: Record<string, unknown>;
|
||||
/** Which nodes: this one (absent), every node running the module ("all"), or a list. */
|
||||
nodes?: "all" | string[];
|
||||
}
|
||||
|
||||
/** The `servers` state, as this module reaches it through the runtime (`state("servers")` in the SDK). */
|
||||
export interface ServerState {
|
||||
put(key: string, value: Record<string, unknown>): Promise<number>;
|
||||
delete(key: string): Promise<void>;
|
||||
keys(): Promise<string[]>;
|
||||
}
|
||||
|
||||
/** One change to the `servers` state, as a watch hands it over. */
|
||||
export interface ServerChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one. */
|
||||
export const keyOf = (scope: string, name: string) => `${scope}.${name}`;
|
||||
|
||||
/**
|
||||
* What this node takes from the `servers` state: the entries for every node and for this one, by key —
|
||||
* kept in memory from the watch, and written through to the module's own file whenever what applies here
|
||||
* changes, so the managed directory can be rendered without the bus.
|
||||
*/
|
||||
export class ServerView {
|
||||
private readonly entries = new Map<string, Record<string, unknown>>();
|
||||
constructor(private readonly p: Paths) {}
|
||||
|
||||
/** Take one change; answers whether what applies to this node changed. */
|
||||
take(c: ServerChange): boolean {
|
||||
const dot = c.key.indexOf(".");
|
||||
const scope = c.key.slice(0, dot), name = c.key.slice(dot + 1);
|
||||
if (dot <= 0 || (scope !== "all" && scope !== this.p.node)) return false;
|
||||
if (c.op === "put" && c.value && entryProblem(name, c.value) === null) this.entries.set(c.key, c.value);
|
||||
else this.entries.delete(c.key);
|
||||
return this.writeThrough();
|
||||
}
|
||||
|
||||
/** What applies here: every node's entries, with this node's own laid over them by server name. */
|
||||
effective(): Servers {
|
||||
const out: Record<string, Record<string, unknown>> = {};
|
||||
for (const scope of ["all", this.p.node]) {
|
||||
for (const [key, entry] of [...this.entries].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (key.startsWith(scope + ".")) out[key.slice(scope.length + 1)] = entry;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private writeThrough(): boolean {
|
||||
const now = JSON.stringify(this.effective(), null, 2) + "\n";
|
||||
let before = "";
|
||||
try {
|
||||
before = readFileSync(registryPath(this.p), "utf8");
|
||||
} catch {
|
||||
/* none yet */
|
||||
}
|
||||
if (now === before) return false;
|
||||
writeFileSync(registryPath(this.p), now, { mode: 0o600 });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A change from the watch: take it, and render when what applies here changed. */
|
||||
export function onServerChange(view: ServerView, c: ServerChange, p: Paths, write: WriteManaged): string | null {
|
||||
if (!view.take(c)) return null;
|
||||
renderNow(p, write);
|
||||
return `${c.op === "put" ? "registered" : "unregistered"} ${c.key}`;
|
||||
}
|
||||
|
||||
const scopesOf = (p: Paths, nodes: Registration["nodes"]): string[] =>
|
||||
nodes === undefined ? [p.node] : nodes === "all" ? ["all"] : nodes;
|
||||
|
||||
/**
|
||||
* Register (or with no entry, unregister) a server: a put (or delete) per scope in the `servers` state.
|
||||
* Taken into this node's view at once, so the answer says what it did here; every other node takes it
|
||||
* from its watch, and a node that joins later from the current state.
|
||||
*/
|
||||
export async function registerServer(p: Paths, r: Registration, servers: ServerState, view: ServerView,
|
||||
write: WriteManaged, others: () => Promise<string[]>): Promise<Record<string, unknown>> {
|
||||
if (r.entry) {
|
||||
const problem = entryProblem(r.name, r.entry);
|
||||
if (problem) return { registered: false, reason: problem };
|
||||
}
|
||||
const scopes = scopesOf(p, r.nodes);
|
||||
// Compared before and after rather than read from take(): this node's own watch may hand the view the
|
||||
// same change first, and then take() here finds nothing new although this call made it.
|
||||
const before = JSON.stringify(view.effective());
|
||||
for (const scope of scopes) {
|
||||
const key = keyOf(scope, r.name);
|
||||
if (r.entry) await servers.put(key, r.entry);
|
||||
else await servers.delete(key);
|
||||
view.take({ key, op: r.entry ? "put" : "delete", value: r.entry });
|
||||
}
|
||||
const changedHere = JSON.stringify(view.effective()) !== before;
|
||||
const here = scopes.includes("all") || scopes.includes(p.node);
|
||||
const answer: Record<string, unknown> = {
|
||||
[r.entry ? "registered" : "unregistered"]: r.name,
|
||||
on: r.nodes === undefined ? [p.node] : r.nodes,
|
||||
here: here ? (changedHere ? "changed" : "already so") : "not this node",
|
||||
rendered: changedHere ? renderNow(p, write) : [],
|
||||
};
|
||||
if (!r.entry && view.effective()[r.name]) {
|
||||
answer.still = `${r.name} still applies here from another registration (for every node, or for this one); unregister that too`;
|
||||
}
|
||||
if (r.nodes === undefined) {
|
||||
// The question the operator wanted asked: here only, or more?
|
||||
const elsewhere = (await others().catch(() => [] as string[])).filter((n) => n !== p.node);
|
||||
answer.also = elsewhere.length
|
||||
? `claude-code also runs on ${elsewhere.join(", ")}. To ${r.entry ? "register" : "unregister"} it there too, call again with nodes: "all" or a list of those nodes.`
|
||||
: `To do the same on every node running claude-code, call again with nodes: "all".`;
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
export { MANAGED_DIR };
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@novox/module-claude-code",
|
||||
"version": "0.1.0",
|
||||
"description": "claude-code — the operator's agent on a machine: its managed configuration, and the consumer side of the Anthropic licence manager (novox/hq design 36).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc seal.ts grant.ts identity.ts render.ts node.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
||||
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the
|
||||
// node holds, so what lands under /etc is tested without a machine.
|
||||
//
|
||||
// Three files, owned whole by this module:
|
||||
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
||||
// servers the operator declared for the mesh or this node. Exclusive by the
|
||||
// vendor's rule — a server not listed here does not load — which is why the
|
||||
// list is the module's settings and nothing else (operator's choice, 2026-10-03).
|
||||
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the
|
||||
// claude.ai connectors kept beside the managed servers, and — for an API-key
|
||||
// licence only — the key-helper. A person's preferences are theirs.
|
||||
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
||||
|
||||
export const MANAGED_DIR = "/etc/claude-code";
|
||||
|
||||
export interface Facts {
|
||||
readonly node: string;
|
||||
readonly console: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
readonly role?: string;
|
||||
/** Extra tool servers, in the vendor's `.mcp.json` entry shape, keyed by name. */
|
||||
readonly mcp_servers?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
export interface Binding {
|
||||
readonly licence: string;
|
||||
readonly kind: "subscription" | "api-key";
|
||||
}
|
||||
|
||||
export interface Rendered {
|
||||
readonly [file: string]: string;
|
||||
}
|
||||
|
||||
const MESH_ENTRY = "mesh";
|
||||
|
||||
export type Servers = Readonly<Record<string, Record<string, unknown>>>;
|
||||
|
||||
/** Whether an entry is one the vendor's managed file takes: a name of letters, digits, `-` and `_`, and
|
||||
* an http/sse server with a url or a stdio server with a command. Returns why not, or null. */
|
||||
export function entryProblem(name: string, entry: Record<string, unknown>): string | null {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) return `"${name}" is not a name the agent takes: letters, digits, - and _`;
|
||||
if (name === MESH_ENTRY) return `"${MESH_ENTRY}" is the mesh's own entry`;
|
||||
const type = entry?.type ?? "stdio";
|
||||
if (type === "http" || type === "sse" || type === "streamable-http") {
|
||||
return typeof entry.url === "string" && entry.url ? null : `an ${type} server needs a url`;
|
||||
}
|
||||
if (type === "stdio") return typeof entry.command === "string" && entry.command ? null : "a stdio server needs a command";
|
||||
return `"${String(type)}" is not a server type the agent knows (http, sse, stdio)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the three files. `registered` is the module's own list on this node — what was registered
|
||||
* through its tools — laid over the servers the operator set in its settings.
|
||||
*/
|
||||
export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string,
|
||||
registered: Servers = {}): Rendered {
|
||||
const servers: Record<string, unknown> = {};
|
||||
for (const [name, entry] of Object.entries({ ...(settings.mcp_servers ?? {}), ...registered })) {
|
||||
if (entryProblem(name, entry) !== null) continue; // the mesh's own entry, or one the agent would refuse
|
||||
servers[name] = entry;
|
||||
}
|
||||
servers[MESH_ENTRY] = { type: "http", url: facts.console };
|
||||
|
||||
const managed: Record<string, unknown> = {
|
||||
attribution: { commit: "", pr: "" },
|
||||
allowAllClaudeAiMcps: true,
|
||||
};
|
||||
if (binding?.kind === "api-key") managed.apiKeyHelper = helperPath;
|
||||
|
||||
return {
|
||||
"managed-mcp.json": json({ mcpServers: sortKeys(servers) }),
|
||||
"managed-settings.json": json(managed),
|
||||
"CLAUDE.md": instructions(facts, settings),
|
||||
};
|
||||
}
|
||||
|
||||
function json(v: unknown): string {
|
||||
return JSON.stringify(v, null, 2) + "\n";
|
||||
}
|
||||
|
||||
function sortKeys(o: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
|
||||
}
|
||||
|
||||
export function instructions(facts: Facts, settings: Settings): string {
|
||||
const role = settings.role?.trim() ? settings.role.trim() : "not stated — set it in this module's settings for the node";
|
||||
return `# This machine is a node of a Novox mesh
|
||||
|
||||
Written by the mesh's \`claude-code\` module. Edit the module's settings or the catalogue, never this file:
|
||||
it is rewritten whenever the module renders.
|
||||
|
||||
## Who this node is
|
||||
|
||||
- **Node:** \`${facts.node}\`
|
||||
- **Role:** ${role}
|
||||
- The other nodes, their roles and what runs where: ask the controller (\`mesh-controller.nodes\`,
|
||||
\`mesh-controller.node\`). Nothing here lists them, because a copy drifts.
|
||||
|
||||
## How a session on this mesh works
|
||||
|
||||
The console is the only way to the mesh: the MCP server named \`mesh\`. It offers five tools, and
|
||||
everything else is an address you find and call through them:
|
||||
|
||||
- \`mesh_search\` — words in, matching addresses out. \`mesh_describe\` — one address's arguments.
|
||||
- \`mesh_call\` — call an address. A seat the mesh holds once is \`<seat>.<verb>\` (the mesh's own verbs
|
||||
are \`mesh-controller.<verb>\`: \`status\`, \`plan\`, \`node\`, \`assign\`, \`push\`, \`settings\`);
|
||||
a module on a machine is \`<node>/<module>.<tool>\`.
|
||||
- \`mesh_overview\` and \`mesh_machine\` — the mesh's seats and machines, and what one machine runs.
|
||||
|
||||
- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the
|
||||
literal text before forming a hypothesis: the records module's \`records_search\`, then
|
||||
\`records_read\`.
|
||||
- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.
|
||||
- **A licence** through the \`anthropic-licence-manager\` seat's verbs. Never edit the agent's credentials
|
||||
file by hand, never print or ask for a token.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If
|
||||
unsure, \`mesh-controller.plan\` for the node says what the mesh writes there.
|
||||
- Never write to a store's database by hand; schema changes are numbered migrations.
|
||||
- Never push to a main branch: a branch, a pull request, and a human approval for every merge.
|
||||
- The mesh creates no symlinks, and nobody else does either.
|
||||
- A package is declared in a module, never installed by hand.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Commit messages are concise, in the imperative, about why.
|
||||
- Test before pushing: nodes update unattended.
|
||||
- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that
|
||||
// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for
|
||||
// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box — all from Node's own library, so a
|
||||
// bundle carries no dependency and no secret ever crosses the bus in the clear.
|
||||
//
|
||||
// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so
|
||||
// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it.
|
||||
|
||||
import {
|
||||
createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman,
|
||||
generateKeyPairSync, hkdfSync, randomBytes, type KeyObject,
|
||||
} from "node:crypto";
|
||||
|
||||
export interface SealedBox {
|
||||
readonly v: 1;
|
||||
readonly eph: string;
|
||||
readonly iv: string;
|
||||
readonly tag: string;
|
||||
readonly ct: string;
|
||||
}
|
||||
|
||||
/** A recipient's keypair, as the two PEM strings it is kept and published as. */
|
||||
export interface KeyPairPem {
|
||||
readonly publicKey: string;
|
||||
readonly privateKey: string;
|
||||
}
|
||||
|
||||
const INFO = Buffer.from("novox-mesh sealed box v1");
|
||||
|
||||
export function generateKeyPair(): KeyPairPem {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
||||
return {
|
||||
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
||||
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer {
|
||||
// The ephemeral and the recipient's public halves are bound into the key, so a box cannot be
|
||||
// re-addressed to another recipient by swapping its `eph`.
|
||||
return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32));
|
||||
}
|
||||
|
||||
function rawPublic(key: KeyObject): Buffer {
|
||||
return key.export({ type: "spki", format: "der" }).subarray(-32);
|
||||
}
|
||||
|
||||
export function seal(plaintext: string, recipientPublicPem: string): SealedBox {
|
||||
const recipient = createPublicKey(recipientPublicPem);
|
||||
const eph = generateKeyPairSync("x25519");
|
||||
const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
|
||||
const ephRaw = eph.publicKey.export({ type: "spki", format: "der" });
|
||||
const key = keyFor(secret, ephRaw, rawPublic(recipient));
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return {
|
||||
v: 1,
|
||||
eph: ephRaw.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
tag: cipher.getAuthTag().toString("base64"),
|
||||
ct: ct.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */
|
||||
export function open(box: SealedBox, privateKeyPem: string): string {
|
||||
if (!box || box.v !== 1) throw new Error("not a sealed box this module can open");
|
||||
const priv = createPrivateKey(privateKeyPem);
|
||||
const ephRaw = Buffer.from(box.eph, "base64");
|
||||
const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" });
|
||||
const secret = diffieHellman({ privateKey: priv, publicKey: eph });
|
||||
const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv)));
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(box.tag, "base64"));
|
||||
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant,
|
||||
} from "../dist/grant.js";
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
const HOUR = 3_600_000;
|
||||
const g = (over: Partial<Grant> = {}): Grant => ({
|
||||
accessToken: "tok-A", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR, ...over,
|
||||
});
|
||||
|
||||
test("a rotation applies a newer grant of the same licence", () => {
|
||||
assert.deepEqual(decideApply(g(), g({ accessToken: "tok-B", expiresAt: NOW + 2 * HOUR }), "rotation"), { apply: true });
|
||||
});
|
||||
|
||||
test("a rotation refuses a grant that arrived late and is older", () => {
|
||||
const d = decideApply(g({ accessToken: "new", expiresAt: NOW + 2 * HOUR }), g({ accessToken: "old" }), "rotation");
|
||||
assert.equal(d.apply === false && d.reason, "not-newer");
|
||||
});
|
||||
|
||||
test("a grant re-issued by a login is adopted even though it expires sooner (2026-09-05)", () => {
|
||||
const local = g({ expiresAt: NOW + 8 * HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR });
|
||||
const offered = g({ accessToken: "reissued", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 5 * 24 * HOUR });
|
||||
assert.deepEqual(decideApply(local, offered, "rotation"), { apply: true, reissued: true });
|
||||
});
|
||||
|
||||
test("a switch to another licence applies whatever the expiries say", () => {
|
||||
const local = g({ expiresAt: NOW + 8 * HOUR });
|
||||
assert.equal(decideApply(local, g({ accessToken: "other", expiresAt: NOW + HOUR }), "switch").apply, true);
|
||||
});
|
||||
|
||||
test("the same token is not rewritten", () => {
|
||||
assert.deepEqual(decideApply(g(), g(), "switch"), { apply: false, reason: "already-current" });
|
||||
});
|
||||
|
||||
test("a full grant left by a login is seen as a login, and stripped when the node's own is written", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-code-"));
|
||||
const path = join(dir, ".claude", ".credentials.json");
|
||||
writeFileSync(join(dir, "x"), "");
|
||||
const login = { claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW }, other: 1 };
|
||||
assert.equal(holdsLogin(login), true);
|
||||
writeCredentials(path, withGrant(login, g({ accessToken: "at-mesh", scopes: ["user:inference"] })));
|
||||
const back = readCredentials(path)!;
|
||||
assert.equal(holdsLogin(back), false);
|
||||
assert.equal(grantOf(back)!.accessToken, "at-mesh");
|
||||
assert.deepEqual(back.claudeAiOauth!.scopes, ["user:inference"]);
|
||||
assert.equal(back.other, 1, "a key the module does not know was lost");
|
||||
assert.ok(!readFileSync(path, "utf8").includes("rt-login"));
|
||||
assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readIdentity } from "../dist/identity.js";
|
||||
|
||||
test("the account is read from the agent's state file", () => {
|
||||
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
|
||||
writeFileSync(p, JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" }, other: 2 }));
|
||||
assert.deepEqual(readIdentity(p), { accountUuid: "u-1", emailAddress: "a@example.org", organizationUuid: undefined });
|
||||
});
|
||||
|
||||
test("no state file, or no account in it, is no identity rather than a guess", () => {
|
||||
assert.equal(readIdentity("/nonexistent/.claude.json"), null);
|
||||
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
|
||||
writeFileSync(p, "{}");
|
||||
assert.equal(readIdentity(p), null);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths,
|
||||
type ServerChange, type ServerState,
|
||||
} from "../dist/node.js";
|
||||
import { generateKeyPair, open, seal } from "../dist/seal.js";
|
||||
|
||||
const NOW = Date.now();
|
||||
function node(name = "laptop"): { p: Paths; written: Record<string, string> } {
|
||||
const root = mkdtempSync(join(tmpdir(), "cc-node-"));
|
||||
const p = { state: join(root, "state"), facts: join(root, "state", "facts.json"), settings: join(root, "state", "settings.json"), home: join(root, "home"), node: name };
|
||||
mkdirSync(p.state, { recursive: true });
|
||||
mkdirSync(join(p.home, ".claude"), { recursive: true });
|
||||
writeFileSync(p.facts, JSON.stringify({ node: name, console: "http://127.0.0.1:4270/mcp" }));
|
||||
writeFileSync(p.settings, JSON.stringify({ role: "", mcp_servers: {} }));
|
||||
return { p, written: {} };
|
||||
}
|
||||
const writer = (w: Record<string, string>) => (name: string, content: string) => { w[name] = content; return `${name}: written`; };
|
||||
const creds = (p: Paths) => JSON.parse(readFileSync(join(p.home, ".claude", ".credentials.json"), "utf8"));
|
||||
const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({
|
||||
licence, kind, identity,
|
||||
sealed: seal(kind === "api-key" ? token : JSON.stringify({ accessToken: token, expiresAt: NOW + 3_600_000, refreshTokenExpiresAt: NOW + 86_400_000, subscriptionType: licence }), keypair(p).publicKey),
|
||||
});
|
||||
|
||||
test("a pull asks the seat with this node's key and applies what it answers", async () => {
|
||||
const { p, written } = node();
|
||||
let asked: [string, Record<string, unknown>] | null = null;
|
||||
const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor(p, "personal", "at-1"); }, writer(written));
|
||||
assert.equal(asked![0], "anthropic-licence-manager.current");
|
||||
assert.equal(asked![1].node, "laptop");
|
||||
assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/);
|
||||
assert.equal(r.applied, true);
|
||||
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
|
||||
assert.ok(written["managed-mcp.json"]);
|
||||
});
|
||||
|
||||
test("a switch replaces the old licence's grant whole and points the account at the new one", () => {
|
||||
const { p, written } = node();
|
||||
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "old" }, projects: { keep: 1 } }));
|
||||
apply(p, grantFor(p, "personal", "at-1"), writer(written));
|
||||
const r = apply(p, grantFor(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written));
|
||||
assert.equal(r.switched, true);
|
||||
assert.equal(creds(p).claudeAiOauth.accessToken, "at-2");
|
||||
assert.equal(creds(p).claudeAiOauth.subscriptionType, "work", "the old licence's subscription type survived the switch");
|
||||
const account = JSON.parse(readFileSync(join(p.home, ".claude.json"), "utf8"));
|
||||
assert.equal(account.oauthAccount.accountUuid, "new");
|
||||
assert.deepEqual(account.projects, { keep: 1 });
|
||||
});
|
||||
|
||||
test("switching to the API key adds the key-helper; switching away removes the key and the helper", () => {
|
||||
const { p, written } = node();
|
||||
apply(p, grantFor(p, "api", "sk-key", "api-key"), writer(written));
|
||||
assert.ok(JSON.parse(written["managed-settings.json"]).apiKeyHelper);
|
||||
assert.ok(existsSync(join(p.state, "api-key")));
|
||||
apply(p, grantFor(p, "personal", "at-1"), writer(written));
|
||||
assert.ok(!("apiKeyHelper" in JSON.parse(written["managed-settings.json"])));
|
||||
assert.ok(!existsSync(join(p.state, "api-key")) && !existsSync(join(p.state, "api-key-helper")));
|
||||
});
|
||||
|
||||
test("a rotation event concerns the node bound to that licence; a switch event the node it names", () => {
|
||||
const { p, written } = node();
|
||||
apply(p, grantFor(p, "personal", "at-1"), writer(written));
|
||||
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "personal" }), true);
|
||||
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "work" }), false);
|
||||
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "laptop", licence: "work" }), true);
|
||||
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "server" }), false);
|
||||
});
|
||||
|
||||
test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => {
|
||||
const { p } = node();
|
||||
const manager = generateKeyPair();
|
||||
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } }));
|
||||
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-9" } }));
|
||||
const calls: [string, Record<string, unknown>][] = [];
|
||||
await offerLogin(p, async (address, args) => { calls.push([address, args]); return address.endsWith("public_key") ? { public_key: manager.publicKey } : { adopted: true }; });
|
||||
assert.deepEqual(calls.map((c) => c[0]), ["anthropic-licence-manager.public_key", "anthropic-licence-manager.adopt"]);
|
||||
const adopt = calls[1][1] as { identity: { accountUuid: string }; sealed: never };
|
||||
assert.equal(adopt.identity.accountUuid, "u-9");
|
||||
assert.equal(JSON.parse(open(adopt.sealed, manager.privateKey)).refreshToken, "rt-login");
|
||||
assert.ok(!JSON.stringify(adopt).includes("rt-login"), "the refresh token crossed in the clear");
|
||||
});
|
||||
|
||||
test("no refresh token in the file is no login, and nothing is asked", async () => {
|
||||
const { p } = node();
|
||||
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", expiresAt: NOW } }));
|
||||
assert.equal(await offerLogin(p, async () => { throw new Error("asked"); }), null);
|
||||
});
|
||||
|
||||
/** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */
|
||||
function bus() {
|
||||
const kept = new Map<string, Record<string, unknown>>();
|
||||
const watchers: ((c: ServerChange) => void)[] = [];
|
||||
const state: ServerState = {
|
||||
put: async (key, value) => { kept.set(key, value); watchers.forEach((w) => w({ key, op: "put", value })); return kept.size; },
|
||||
delete: async (key) => { kept.delete(key); watchers.forEach((w) => w({ key, op: "delete" })); },
|
||||
keys: async () => [...kept.keys()].sort(),
|
||||
};
|
||||
/** A node joining: its view takes the current state, then every change. */
|
||||
const join = (n: { p: Paths; written: Record<string, string> }) => {
|
||||
const view = new ServerView(n.p);
|
||||
for (const [key, value] of kept) onServerChange(view, { key, op: "put", value }, n.p, writer(n.written));
|
||||
watchers.push((c) => onServerChange(view, c, n.p, writer(n.written)));
|
||||
return view;
|
||||
};
|
||||
return { state, join, kept };
|
||||
}
|
||||
|
||||
test("registering a server here puts it under this node's key, renders it, and asks about the other nodes", async () => {
|
||||
const n = node();
|
||||
const b = bus();
|
||||
const view = b.join(n);
|
||||
const r = await registerServer(n.p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } },
|
||||
b.state, view, writer(n.written), async () => ["laptop", "server", "desktop"]);
|
||||
assert.equal(r.here, "changed");
|
||||
assert.match(String(r.also), /server, desktop/);
|
||||
assert.deepEqual([...b.kept.keys()], ["laptop.search"]);
|
||||
assert.ok(JSON.parse(n.written["managed-mcp.json"]).mcpServers.search);
|
||||
});
|
||||
|
||||
test("registering for every node reaches the others through their watch, and a node joining later reads it", async () => {
|
||||
const a = node("laptop"), s = node("server");
|
||||
const b = bus();
|
||||
const va = b.join(a);
|
||||
b.join(s);
|
||||
await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" },
|
||||
b.state, va, writer(a.written), async () => []);
|
||||
assert.deepEqual([...b.kept.keys()], ["all.docs"]);
|
||||
assert.deepEqual(registered(s.p).docs, { type: "stdio", command: "docs-mcp" });
|
||||
assert.ok(JSON.parse(s.written["managed-mcp.json"]).mcpServers.docs);
|
||||
// The gap events left: a node assigned after the registration takes the whole current set at start.
|
||||
const late = node("desktop");
|
||||
b.join(late);
|
||||
assert.deepEqual(registered(late.p).docs, { type: "stdio", command: "docs-mcp" });
|
||||
// Unregistering is a delete, and every node's view drops it.
|
||||
await registerServer(a.p, { name: "docs", nodes: "all" }, b.state, va, writer(a.written), async () => []);
|
||||
assert.equal(registered(s.p).docs, undefined);
|
||||
assert.equal(registered(late.p).docs, undefined);
|
||||
});
|
||||
|
||||
test("a node's own registration overrides the one for every node; other nodes' keys leave this one alone", async () => {
|
||||
const a = node("laptop"), s = node("server");
|
||||
const b = bus();
|
||||
const va = b.join(a);
|
||||
const vs = b.join(s);
|
||||
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://all" }, nodes: "all" }, b.state, va, writer(a.written), async () => []);
|
||||
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://laptop" } }, b.state, va, writer(a.written), async () => []);
|
||||
assert.equal(registered(a.p).x.url, "https://laptop");
|
||||
assert.equal(registered(s.p).x.url, "https://all");
|
||||
await registerServer(a.p, { name: "only", entry: { type: "http", url: "https://o" }, nodes: ["server"] }, b.state, va, writer(a.written), async () => []);
|
||||
assert.equal(registered(a.p).only, undefined);
|
||||
assert.equal(registered(s.p).only.url, "https://o");
|
||||
// Unregistering here leaves the every-node one applying, and says so.
|
||||
const r = await registerServer(a.p, { name: "x" }, b.state, va, writer(a.written), async () => []);
|
||||
assert.match(String(r.still), /still applies here/);
|
||||
assert.equal(registered(a.p).x.url, "https://all");
|
||||
assert.equal(vs.effective().x.url, "https://all");
|
||||
});
|
||||
|
||||
test("a bad entry is refused before anything is put; a repeated change changes nothing", async () => {
|
||||
const n = node();
|
||||
const b = bus();
|
||||
const view = b.join(n);
|
||||
const r = await registerServer(n.p, { name: "mesh", entry: { type: "http", url: "https://x" } }, b.state, view, writer(n.written), async () => []);
|
||||
assert.equal(r.registered, false);
|
||||
assert.equal(b.kept.size, 0);
|
||||
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), "registered all.a");
|
||||
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), null);
|
||||
assert.equal(onServerChange(view, { key: "server.b", op: "put", value: { type: "http", url: "https://b" } }, n.p, writer(n.written)), null);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { render } from "../dist/render.js";
|
||||
|
||||
const facts = { node: "workstation", console: "http://127.0.0.1:4270/mcp" };
|
||||
|
||||
test("the console is the `mesh` server, and an operator's servers are listed beside it", () => {
|
||||
const out = render(facts, { mcp_servers: { search: { type: "http", url: "https://s.example/mcp" } } }, null, "/h");
|
||||
const mcp = JSON.parse(out["managed-mcp.json"]);
|
||||
assert.deepEqual(Object.keys(mcp.mcpServers), ["mesh", "search"]);
|
||||
assert.deepEqual(mcp.mcpServers.mesh, { type: "http", url: facts.console });
|
||||
});
|
||||
|
||||
test("a setting cannot replace the mesh's own entry, and a name the vendor refuses is left out", () => {
|
||||
const out = render(facts, { mcp_servers: { mesh: { type: "http", url: "http://evil" }, "bad name": {} } }, null, "/h");
|
||||
const mcp = JSON.parse(out["managed-mcp.json"]);
|
||||
assert.equal(mcp.mcpServers.mesh.url, facts.console);
|
||||
assert.ok(!("bad name" in mcp.mcpServers));
|
||||
});
|
||||
|
||||
test("managed settings carry the mesh's keys only, and the key-helper only for an API-key licence", () => {
|
||||
const sub = JSON.parse(render(facts, {}, { licence: "personal", kind: "subscription" }, "/h")["managed-settings.json"]);
|
||||
assert.deepEqual(sub, { attribution: { commit: "", pr: "" }, allowAllClaudeAiMcps: true });
|
||||
const key = JSON.parse(render(facts, {}, { licence: "api", kind: "api-key" }, "/state/api-key-helper")["managed-settings.json"]);
|
||||
assert.equal(key.apiKeyHelper, "/state/api-key-helper");
|
||||
assert.ok(!("model" in key), "a preference is the person's");
|
||||
});
|
||||
|
||||
test("the instruction file names the node and its role, and no other node", () => {
|
||||
const md = render(facts, { role: "the laptop" }, null, "/h")["CLAUDE.md"];
|
||||
assert.match(md, /\*\*Node:\*\* `workstation`/);
|
||||
assert.match(md, /\*\*Role:\*\* the laptop/);
|
||||
assert.match(md, /mesh_call/);
|
||||
assert.match(md, /records_search/);
|
||||
});
|
||||
|
||||
test("rendering is deterministic, so an unchanged input writes nothing", () => {
|
||||
const s = { mcp_servers: { b: { type: "http", url: "https://b" }, a: { type: "http", url: "https://a" } } };
|
||||
assert.deepEqual(render(facts, s, null, "/h"), render(facts, s, null, "/h"));
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPair, open, seal } from "../dist/seal.js";
|
||||
|
||||
test("a box opens with its recipient's key and yields the value", () => {
|
||||
const k = generateKeyPair();
|
||||
assert.equal(open(seal("at-secret", k.publicKey), k.privateKey), "at-secret");
|
||||
});
|
||||
|
||||
test("a box sealed for one node does not open with another node's key", () => {
|
||||
const a = generateKeyPair();
|
||||
const b = generateKeyPair();
|
||||
assert.throws(() => open(seal("at-secret", a.publicKey), b.privateKey));
|
||||
});
|
||||
|
||||
test("a tampered box is refused, not opened to garbage", () => {
|
||||
const k = generateKeyPair();
|
||||
const box = seal("at-secret", k.publicKey);
|
||||
const ct = Buffer.from(box.ct, "base64");
|
||||
ct[0] ^= 0xff;
|
||||
assert.throws(() => open({ ...box, ct: ct.toString("base64") }, k.privateKey));
|
||||
});
|
||||
|
||||
test("two boxes of one value share nothing a reader could compare", () => {
|
||||
const k = generateKeyPair();
|
||||
const x = seal("at-secret", k.publicKey);
|
||||
const y = seal("at-secret", k.publicKey);
|
||||
assert.notEqual(x.ct, y.ct);
|
||||
assert.notEqual(x.eph, y.eph);
|
||||
assert.ok(!JSON.stringify(x).includes("at-secret"));
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
// claude-code's bundle (novox/hq design 36, ADR 0183). The node's runtime launches it over stdio, as the
|
||||
// operator account (ADR 0193), and is its bus (ADR 0198): it asks tools, emits and consumes through the
|
||||
// runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the
|
||||
// runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr.
|
||||
//
|
||||
// At start it renders the agent's managed directory, asks the licence manager for this node's token,
|
||||
// begins watching the credentials file for a login, takes the manager's licence events, and watches the
|
||||
// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the
|
||||
// logic.
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
|
||||
import {
|
||||
MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer,
|
||||
registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged,
|
||||
} from "../node.js";
|
||||
import { grantOf, holdsLogin, readCredentials } from "../grant.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const say = (line: string) => console.error(`[claude-code] ${line}`);
|
||||
const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
|
||||
|
||||
function pathsFrom(env: NodeJS.ProcessEnv): Paths | null {
|
||||
const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS;
|
||||
const settings = env.MESH_CLAUDE_CODE_SETTINGS, home = env.MESH_OPERATOR_HOME, node = env.MESH_NODE;
|
||||
if (!state || !facts || !settings || !home || !node) return null;
|
||||
return { state, facts, settings, home, node };
|
||||
}
|
||||
|
||||
/** Write one managed file as root, only when its content changed. */
|
||||
const writeManaged: WriteManaged = (name, content) => {
|
||||
const path = join(MANAGED_DIR, name);
|
||||
try {
|
||||
if (readFileSync(path, "utf8") === content) return `${name}: unchanged`;
|
||||
} catch {
|
||||
/* absent */
|
||||
}
|
||||
// From a file, never /dev/stdin: Node hands a child its input over a socket, which /dev/stdin cannot
|
||||
// open (ENXIO) — found on the first assignment, where nothing under /etc/claude-code was ever written.
|
||||
const staged = mkdtempSync(join(tmpdir(), "claude-code-"));
|
||||
const source = join(staged, name);
|
||||
writeFileSync(source, content, { mode: 0o644 });
|
||||
const asRoot = process.getuid?.() === 0;
|
||||
const cmd = asRoot ? ["install", "-D", "-m", "0644", source, path] : ["sudo", "-n", "install", "-D", "-m", "0644", source, path];
|
||||
const r = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8" });
|
||||
rmSync(staged, { recursive: true, force: true });
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`${name}: could not be written to ${MANAGED_DIR} (${(r.stderr || r.error?.message || "").trim()}); ` +
|
||||
`the module writes there through the operator account's passwordless sudo`);
|
||||
}
|
||||
return `${name}: written`;
|
||||
};
|
||||
|
||||
/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */
|
||||
const ask: Ask = async (address, args) => {
|
||||
const answer = (await broker().request<Record<string, unknown>, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {};
|
||||
const text = answer.content?.map((c) => c.text ?? "").join("") ?? "";
|
||||
if (answer.isError) throw new Error(`${address}: ${text}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
/** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */
|
||||
async function nodesRunningMe(): Promise<string[]> {
|
||||
const out = await ask("mesh-controller.modules", {});
|
||||
const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? "");
|
||||
const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? "";
|
||||
const on = line.split(" on ")[1] ?? "";
|
||||
return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function status(p: Paths): Record<string, unknown> {
|
||||
const creds = readCredentials(join(p.home, ".claude", ".credentials.json"));
|
||||
const grant = grantOf(creds);
|
||||
const managed = ["managed-mcp.json", "managed-settings.json", "CLAUDE.md"].map((f) => {
|
||||
try {
|
||||
return { file: join(MANAGED_DIR, f), fingerprint: fingerprint(readFileSync(join(MANAGED_DIR, f), "utf8")) };
|
||||
} catch {
|
||||
return { file: join(MANAGED_DIR, f), fingerprint: null };
|
||||
}
|
||||
});
|
||||
return {
|
||||
node: p.node,
|
||||
licence: readJson(join(p.state, "licence.json"), null),
|
||||
token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(),
|
||||
loginWaiting: holdsLogin(creds) } : null,
|
||||
managed,
|
||||
registered: Object.keys(registered(p)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */
|
||||
const servers = () => state<Record<string, unknown>>("servers") as unknown as ServerState;
|
||||
|
||||
/** What this node takes from that state, kept from the watch. One per process. */
|
||||
let view: ServerView | null = null;
|
||||
const viewOf = (p: Paths) => (view ??= new ServerView(p));
|
||||
|
||||
function tools(p: Paths): ToolDefinition[] {
|
||||
const nodesArg = { type: "string", description: 'more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only' };
|
||||
const nodesOf = (v: unknown): Registration["nodes"] =>
|
||||
v === undefined || v === "" ? undefined : v === "all" ? "all" : String(v).split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return [
|
||||
{
|
||||
name: "claude_code_status",
|
||||
description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
input: {},
|
||||
run: async () => status(p),
|
||||
},
|
||||
{
|
||||
name: "claude_code_render",
|
||||
description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
|
||||
input: {},
|
||||
run: async () => ({ rendered: renderNow(p, writeManaged) }),
|
||||
},
|
||||
{
|
||||
name: "claude_code_pull",
|
||||
description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its next event.",
|
||||
input: {},
|
||||
run: async () => pull(p, ask, writeManaged),
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_list",
|
||||
description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||
input: {},
|
||||
run: async () => ({ here: registered(p), everywhere: await servers().keys() }),
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_register",
|
||||
description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
||||
input: {
|
||||
name: { type: "string", description: "the server's name: letters, digits, - and _" },
|
||||
type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" },
|
||||
url: { type: "string", description: "an http or sse server's url" },
|
||||
command: { type: "string", description: "a stdio server's program" },
|
||||
args: { type: "array", description: "a stdio server's arguments" },
|
||||
env: { type: "object", description: "a stdio server's environment" },
|
||||
headers: { type: "object", description: "an http server's headers" },
|
||||
nodes: nodesArg,
|
||||
},
|
||||
run: async (a) => {
|
||||
const entry: Record<string, unknown> = { type: a.type ?? (a.url ? "http" : "stdio") };
|
||||
for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k];
|
||||
return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_unregister",
|
||||
description: "Remove an MCP server registered through this module, on this node or more.",
|
||||
input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg },
|
||||
run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("claude-code", (env) => {
|
||||
const p = pathsFrom(env);
|
||||
if (!p) return [];
|
||||
try {
|
||||
keypair(p);
|
||||
for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line);
|
||||
} catch (err) {
|
||||
say(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
return tools(p);
|
||||
});
|
||||
|
||||
// Launched by the runtime: the bus is there from the first line (ADR 0198). Outside it — a test, a
|
||||
// build — nothing below runs.
|
||||
const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null;
|
||||
if (p) {
|
||||
const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => {
|
||||
if (!concerns(p, event.type, event.body ?? {})) return;
|
||||
say(`${event.type} — asking ${SEAT} for this node's token`);
|
||||
say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) }))));
|
||||
}).catch(loud("the licence events"));
|
||||
|
||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited
|
||||
// where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the
|
||||
// bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle
|
||||
// unable to answer `initialize` in time and the module unserved (found on its first assignment). So it
|
||||
// watches beside the handshake and asks again until the state answers; until then the managed
|
||||
// directory holds what the file kept from the last run.
|
||||
const watchServers = (attempt = 0): void => {
|
||||
state<Record<string, unknown>>("servers").watch((c) => {
|
||||
try {
|
||||
const done = onServerChange(viewOf(p), c as ServerChange, p, writeManaged);
|
||||
if (done) say(done);
|
||||
} catch (err) {
|
||||
loud(`taking ${c.op} ${c.key}`)(err); // the view took it; the next render writes it
|
||||
}
|
||||
}).then(
|
||||
() => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`),
|
||||
(err) => {
|
||||
const wait = [2, 5, 10, 30][attempt] ?? 60;
|
||||
say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
|
||||
setTimeout(() => watchServers(attempt + 1), wait * 1000);
|
||||
});
|
||||
};
|
||||
watchServers();
|
||||
|
||||
// Catch up once at start: a node that was off takes its current token now.
|
||||
void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start"));
|
||||
|
||||
// A login: a refresh token appears in the credentials file. Polled, because the file is replaced by
|
||||
// rename and a watch on the old inode would go quiet.
|
||||
const credentials = join(p.home, ".claude", ".credentials.json");
|
||||
watchFile(credentials, { interval: 5000 }, () => {
|
||||
void offerLogin(p, ask).then((r) => { if (r) say(`a login here was offered to ${SEAT}: ${JSON.stringify(r)}`); },
|
||||
loud("offering a login to the licence manager"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "node.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# cloudflare-dns's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/cloudflare-dns
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/cloudflare-dns/dist /app/modules/cloudflare-dns/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/cloudflare-dns/dist/tools/index.js,/app/modules/cloudflare-dns/dist/provisioner/index.js
|
||||
@@ -18,20 +18,13 @@
|
||||
"public-dns": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"token": "${dir:state}/token"
|
||||
},
|
||||
"emits": [
|
||||
"record.created",
|
||||
"record.removed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -50,48 +43,30 @@
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-cloudflare-dns",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:grants}:/grants",
|
||||
"${dir:state}/token:/run/secrets/token:ro",
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# confluence's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/confluence
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/confluence/dist /app/modules/confluence/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/confluence/dist/tools/index.js
|
||||
@@ -3,16 +3,9 @@
|
||||
"version": "1",
|
||||
"slug": "confl",
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"token": "${dir:state}/token"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -26,46 +19,27 @@
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-runtime-confluence",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/token:/run/secrets/token:ro",
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh-registry:/var/lib/registry"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+221
-36
@@ -1,51 +1,236 @@
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails and the daemon are declared
|
||||
// resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see
|
||||
// module.json). This code exists only to read and steer the *live* state the daemon owns at
|
||||
// runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That
|
||||
// state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh
|
||||
// reconciles the config, never the ban list.
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
|
||||
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
|
||||
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is
|
||||
// banned now and until when, and the ban or release an operator asks for — the node-intrusion-
|
||||
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
|
||||
// mesh composes the jails and never writes the ban list.
|
||||
//
|
||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
|
||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
/** A command runner, so the verbs can be tested without a daemon. */
|
||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
||||
|
||||
/** The command as it is run: as given when this process is root, else through sudo without a
|
||||
* prompt. The daemon's socket answers only to root. */
|
||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||
if (uid === 0) return [cmd, args];
|
||||
return ["sudo", ["-n", cmd, ...args]];
|
||||
}
|
||||
|
||||
/** Whether a tool is on this machine: an executable of that name on the path, or where the
|
||||
* system keeps its administration. */
|
||||
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
|
||||
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
|
||||
return dirs.some((dir) => {
|
||||
try {
|
||||
accessSync(join(dir, tool), constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const execRunner: Runner = async (cmd, args) => {
|
||||
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
|
||||
const [program, argv] = escalated(cmd, args);
|
||||
try {
|
||||
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
|
||||
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
|
||||
// on its own stderr line, and the rest is the client's own answer.
|
||||
if (program === "sudo") {
|
||||
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
|
||||
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
|
||||
}
|
||||
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
|
||||
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
|
||||
}
|
||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
|
||||
}
|
||||
};
|
||||
|
||||
/** One jail as the daemon reports it. */
|
||||
export interface JailStatus {
|
||||
jail: string;
|
||||
/** What the jail is reading: files or journal matches, as fail2ban names them. */
|
||||
watching: string[];
|
||||
/** Addresses with failures counted against them right now, and all failures since the jail started. */
|
||||
failing: { now: number; total: number };
|
||||
/** Addresses held right now, and all bans since the jail started. */
|
||||
banned: { now: number; total: number; addresses: string[] };
|
||||
}
|
||||
|
||||
/** One ban as the daemon holds it. */
|
||||
export interface Ban {
|
||||
ip: string;
|
||||
jail: string;
|
||||
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
|
||||
since: string;
|
||||
/** When the ban ends; "never" for a permanent ban. */
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface JailSettings {
|
||||
jail: string;
|
||||
bantime: string;
|
||||
findtime: string;
|
||||
maxretry: number;
|
||||
ignoreip: string[];
|
||||
actions: string[];
|
||||
/** The log files the jail reads, when it reads files. */
|
||||
logpath: string[];
|
||||
/** The journal match the jail reads, when it reads the journal. */
|
||||
journalmatch: string;
|
||||
}
|
||||
|
||||
export class Fail2banClient {
|
||||
static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient {
|
||||
private readonly run: Runner;
|
||||
|
||||
constructor(run: Runner = execRunner) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
/** The daemon as this machine has it, through its own client. */
|
||||
static onThisMachine(): Fail2banClient {
|
||||
return new Fail2banClient();
|
||||
}
|
||||
|
||||
/** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */
|
||||
async status(jail?: string): Promise<string> {
|
||||
private client(...args: string[]): Promise<string> {
|
||||
return this.run("fail2ban-client", args);
|
||||
}
|
||||
|
||||
/** The jails the daemon runs, by name. */
|
||||
async jails(): Promise<string[]> {
|
||||
const out = await this.client("status");
|
||||
const m = out.match(/Jail list:\s*(.*)/);
|
||||
if (!m) return [];
|
||||
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Every jail with what it watches and holds, or one jail's detail. */
|
||||
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const jails: JailStatus[] = [];
|
||||
for (const name of names) {
|
||||
jails.push(parseJailStatus(name, await this.client("status", name)));
|
||||
}
|
||||
return { jails };
|
||||
}
|
||||
|
||||
/** Every address banned now, with the jail holding it and when the ban ends. */
|
||||
async banned(jail?: string): Promise<{ banned: Ban[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const banned: Ban[] = [];
|
||||
for (const name of names) {
|
||||
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
|
||||
}
|
||||
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
|
||||
return { banned };
|
||||
}
|
||||
|
||||
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
|
||||
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
|
||||
address(ip);
|
||||
name(jail);
|
||||
const out = await this.client("set", jail, "banip", ip);
|
||||
const added = Number.parseInt(out.trim(), 10) || 0;
|
||||
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
|
||||
return { banned: held, added };
|
||||
}
|
||||
|
||||
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
|
||||
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
|
||||
address(ip);
|
||||
let out: string;
|
||||
if (jail) {
|
||||
const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]);
|
||||
return stdout;
|
||||
name(jail);
|
||||
out = await this.client("set", jail, "unbanip", ip);
|
||||
} else {
|
||||
out = await this.client("unban", ip);
|
||||
}
|
||||
const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]);
|
||||
const match = overview.match(/Jail list:\s*(.+)/);
|
||||
if (!match) return overview;
|
||||
|
||||
const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean);
|
||||
const parts: string[] = [overview.trimEnd(), ""];
|
||||
for (const j of jails) {
|
||||
const { stdout } = await run("sudo", ["fail2ban-client", "status", j]);
|
||||
parts.push(`=== ${j} ===`, stdout.trimEnd(), "");
|
||||
}
|
||||
return parts.join("\n");
|
||||
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
|
||||
}
|
||||
|
||||
/** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */
|
||||
async ban(jail: string, ip: string): Promise<string> {
|
||||
const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Unban an IP from one jail, or from every jail when no jail is given. */
|
||||
async unban(ip: string, jail?: string): Promise<string> {
|
||||
const args = jail
|
||||
? ["fail2ban-client", "set", jail, "unbanip", ip]
|
||||
: ["fail2ban-client", "unban", ip];
|
||||
const { stdout } = await run("sudo", args);
|
||||
return stdout;
|
||||
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
|
||||
async settings(jail: string): Promise<JailSettings> {
|
||||
name(jail);
|
||||
const get = (key: string) => this.client("get", jail, key);
|
||||
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
|
||||
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
|
||||
get("journalmatch"),
|
||||
]);
|
||||
return {
|
||||
jail,
|
||||
bantime: bantime.trim(),
|
||||
findtime: findtime.trim(),
|
||||
maxretry: Number.parseInt(maxretry.trim(), 10),
|
||||
ignoreip: listed(ignoreip),
|
||||
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
|
||||
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
|
||||
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
|
||||
function listed(out: string): string[] {
|
||||
return out
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
|
||||
.filter((l, i) => i > 0 && l.length > 0);
|
||||
}
|
||||
|
||||
export function parseJailStatus(jail: string, out: string): JailStatus {
|
||||
const field = (label: string) => {
|
||||
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
|
||||
return m ? m[1].trim() : "";
|
||||
};
|
||||
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
|
||||
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
|
||||
return {
|
||||
jail,
|
||||
watching,
|
||||
failing: { now: num("Currently failed"), total: num("Total failed") },
|
||||
banned: {
|
||||
now: num("Currently banned"),
|
||||
total: num("Total banned"),
|
||||
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
|
||||
export function parseBans(jail: string, out: string): Ban[] {
|
||||
const bans: Ban[] = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
|
||||
if (!m) continue;
|
||||
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
|
||||
}
|
||||
return bans;
|
||||
}
|
||||
|
||||
function address(ip: string): void {
|
||||
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
|
||||
}
|
||||
|
||||
function name(jail: string): void {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,22 @@
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-intrusion-prevention",
|
||||
"scope": "node"
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"status",
|
||||
"banned",
|
||||
"ban",
|
||||
"unban"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"fail2ban_settings"
|
||||
],
|
||||
"jailing": {
|
||||
"into": "/etc/fail2ban/jail.d/mesh.conf",
|
||||
"filter-into": "/etc/fail2ban/filter.d"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
@@ -28,19 +41,31 @@
|
||||
"path": "/etc/fail2ban/action.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "filter-d",
|
||||
"type": "directory",
|
||||
"path": "/etc/fail2ban/filter.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "run-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/run/fail2ban",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "jail-local",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.d/sshd.conf",
|
||||
"mode": "0644",
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n"
|
||||
},
|
||||
{
|
||||
"id": "log",
|
||||
@@ -55,7 +80,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.d/recidive.conf",
|
||||
"mode": "0644",
|
||||
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n"
|
||||
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n"
|
||||
},
|
||||
{
|
||||
"id": "action-dualchain",
|
||||
@@ -81,8 +106,21 @@
|
||||
"jail-local",
|
||||
"jail-sshd",
|
||||
"jail-recidive",
|
||||
"action-dualchain"
|
||||
"action-dualchain",
|
||||
"composed-jails"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "@novox/module-fail2ban",
|
||||
"version": "0.1.0",
|
||||
"description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools live here.",
|
||||
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
|
||||
|
||||
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
|
||||
const RECIDIVE =
|
||||
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
|
||||
const SSHD =
|
||||
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n";
|
||||
const WITH_TIME =
|
||||
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
|
||||
|
||||
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
|
||||
return async (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
const key = args.join(" ");
|
||||
if (key in answers) return answers[key];
|
||||
throw new Error(`unexpected ${cmd} ${key}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("a jail's status is read into numbers, what it watches and who it holds", () => {
|
||||
const s = parseJailStatus("recidive", RECIDIVE);
|
||||
assert.deepEqual(s, {
|
||||
jail: "recidive",
|
||||
watching: ["/var/log/fail2ban.log"],
|
||||
failing: { now: 36, total: 149 },
|
||||
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
|
||||
});
|
||||
const j = parseJailStatus("sshd", SSHD);
|
||||
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
|
||||
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
|
||||
});
|
||||
|
||||
test("status covers every jail the daemon lists, or the one named", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
|
||||
const all = await f.status();
|
||||
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
|
||||
const one = await f.status("sshd");
|
||||
assert.equal(one.jails.length, 1);
|
||||
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
|
||||
});
|
||||
|
||||
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
|
||||
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
|
||||
assert.equal(bans.length, 3);
|
||||
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
|
||||
assert.equal(bans[2].until, "never");
|
||||
assert.deepEqual(parseBans("sshd", "\n"), []);
|
||||
});
|
||||
|
||||
test("banned gathers every jail's bans, soonest to end first", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
status: STATUS,
|
||||
"get recidive banip --with-time": WITH_TIME,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}));
|
||||
const { banned } = await f.banned();
|
||||
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
|
||||
});
|
||||
|
||||
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, calls));
|
||||
const r = await f.ban("198.51.100.7", "recidive");
|
||||
assert.equal(r.added, 1);
|
||||
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
|
||||
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
|
||||
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
|
||||
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test("unban releases from one jail or from every jail", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
|
||||
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
|
||||
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
|
||||
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
|
||||
});
|
||||
|
||||
test("a jail's settings are read from the daemon's listings", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}));
|
||||
assert.deepEqual(await f.settings("sshd"), {
|
||||
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
|
||||
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
|
||||
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
|
||||
});
|
||||
});
|
||||
|
||||
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
|
||||
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
|
||||
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
|
||||
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
|
||||
assert.equal(installed("sh"), true);
|
||||
assert.equal(installed("no-such-client-of-the-mesh"), false);
|
||||
});
|
||||
@@ -1,55 +1,62 @@
|
||||
// fail2ban's tools — reading and steering the live ban state. The jails themselves are declared
|
||||
// resources (module.json); these three touch what the running daemon holds: what is banned now,
|
||||
// and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this
|
||||
// is the only way to see or change it — the mesh reconciles the config, not the bans.
|
||||
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
|
||||
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings
|
||||
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
|
||||
// machine runs and written as declared resources; these touch only what the running daemon holds.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { Fail2banClient } from "../client.js";
|
||||
|
||||
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "fail2ban_status",
|
||||
name: "status",
|
||||
description:
|
||||
"fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jail: {
|
||||
type: "string",
|
||||
description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.",
|
||||
},
|
||||
},
|
||||
},
|
||||
run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }),
|
||||
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "fail2ban_ban",
|
||||
description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." },
|
||||
ip: { type: "string", description: "IP address to ban." },
|
||||
},
|
||||
required: ["jail", "ip"],
|
||||
},
|
||||
run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }),
|
||||
name: "banned",
|
||||
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "fail2ban_unban",
|
||||
description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.",
|
||||
name: "ban",
|
||||
description:
|
||||
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ip: { type: "string", description: "IP address to unban." },
|
||||
jail: { type: "string", description: "A specific jail; omit to unban from all jails." },
|
||||
},
|
||||
required: ["ip"],
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
|
||||
},
|
||||
run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }),
|
||||
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
|
||||
},
|
||||
{
|
||||
name: "unban",
|
||||
description: "Let one address go, from one jail or from every jail when none is named.",
|
||||
input: {
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "one jail (optional)" },
|
||||
},
|
||||
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv()));
|
||||
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "fail2ban_settings",
|
||||
description:
|
||||
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
input: { jail: { type: "string", description: "the jail" } },
|
||||
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fail2ban = Fail2banClient.onThisMachine();
|
||||
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
||||
// module holds it (ADR 0159, 0160). The module's own under its own.
|
||||
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
|
||||
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
|
||||
# consumer.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
|
||||
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
|
||||
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
|
||||
# the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
|
||||
# They are different images on purpose — the first carries a compiler and the second must not, or
|
||||
# every running container would carry one it never invokes. The mesh answers both with the copies it
|
||||
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
|
||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
|
||||
# nobody told stops here and says which module to build first.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||
# node_modules — the module is compiled against exactly the sdk it will run against.
|
||||
WORKDIR /app/modules/gitea
|
||||
COPY . .
|
||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
||||
# was assembled.
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
|
||||
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
|
||||
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
|
||||
# What a tool host should load from this module: its event consumer and its tools, which are
|
||||
# separate entrypoints because they are loaded by different things. The provisioner is the third,
|
||||
# and is not listed here — the declaration names it in the container's `args`, because it is what
|
||||
# this module's own container runs. One image, because they are one module and share a client.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js,/app/modules/gitea/dist/provisioner/index.js
|
||||
+31
-46
@@ -85,9 +85,6 @@
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -145,7 +142,8 @@
|
||||
"volumes": [
|
||||
"${dir:data}:/data"
|
||||
],
|
||||
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
|
||||
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it",
|
||||
"logging": "journald"
|
||||
},
|
||||
{
|
||||
"id": "admin-bootstrap",
|
||||
@@ -179,32 +177,6 @@
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-gitea",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:runtime-state}:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
@@ -218,24 +190,37 @@
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"jails": [
|
||||
{
|
||||
"name": "gitea",
|
||||
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# gitlab's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/gitlab
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/gitlab/dist /app/modules/gitlab/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/gitlab/dist/tools/index.js
|
||||
+14
-40
@@ -2,16 +2,9 @@
|
||||
"module": "gitlab",
|
||||
"version": "1",
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"token": "${dir:state}/token"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -25,46 +18,27 @@
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-runtime-gitlab",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/token:/run/secrets/token:ro",
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_GITLAB_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITLAB_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# grafana's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/grafana
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/grafana/dist /app/modules/grafana/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/grafana/dist/index.js,/app/modules/grafana/dist/tools/index.js
|
||||
+16
-36
@@ -5,8 +5,7 @@
|
||||
"alert.firing"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "${dir:mesh-state}/admin",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"admin": "${dir:mesh-state}/admin"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
@@ -112,25 +111,6 @@
|
||||
"mode": "0600",
|
||||
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-grafana",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
@@ -162,23 +142,23 @@
|
||||
"influxdb-api": "${dir:mesh-state}/influxdb-api"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# home-assistant's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/home-assistant
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
|
||||
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
|
||||
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
|
||||
# inside the serving sidecar too, and exit it.
|
||||
@@ -9,7 +9,6 @@
|
||||
"state.changed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker",
|
||||
"token": "${dir:mesh-state}/token"
|
||||
},
|
||||
"listens": [
|
||||
@@ -80,55 +79,27 @@
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-home-assistant",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/token:/run/secrets/token:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
|
||||
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
"id": "provisions-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/provisions.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123}\nMESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n"
|
||||
},
|
||||
{
|
||||
"id": "provisions",
|
||||
"type": "container",
|
||||
"name": "mesh-home-assistant-provisions",
|
||||
"network": "host",
|
||||
"run-once": true,
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/token:/run/secrets/token:ro",
|
||||
"${dir:written}:/var/lib/home-assistant-provisions",
|
||||
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
|
||||
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
|
||||
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
|
||||
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
|
||||
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
|
||||
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
|
||||
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
|
||||
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
|
||||
"type": "process",
|
||||
"name": "home-assistant-provisions",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"provisions/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
|
||||
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_PROVISIONS_DIR": "/run/provisions",
|
||||
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/home-assistant/dist/provisions/index.js"
|
||||
"run-once": true,
|
||||
"env-file": [
|
||||
"${dir:state}/provisions.env"
|
||||
],
|
||||
"restart-on": [
|
||||
"provisions-env",
|
||||
"bound-mqtt-topic",
|
||||
"secret-mqtt-topic",
|
||||
"bound-sonarr-api",
|
||||
@@ -137,8 +108,7 @@
|
||||
"secret-radarr-api",
|
||||
"bound-lidarr-api",
|
||||
"secret-lidarr-api"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
@@ -173,23 +143,25 @@
|
||||
"lidarr-api": "${dir:state}/lidarr-api.secret"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisions/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
|
||||
"MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token",
|
||||
"MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# icecast's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/icecast
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/icecast/dist /app/modules/icecast/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/icecast/dist/index.js,/app/modules/icecast/dist/tools/index.js
|
||||
+15
-37
@@ -28,9 +28,6 @@
|
||||
"stream.started",
|
||||
"stream.stopped"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
@@ -95,45 +92,26 @@
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-icecast",
|
||||
"network": "icecast",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_ICECAST_URL": "http://icecast:8000",
|
||||
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}",
|
||||
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# influxdb's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/influxdb
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
|
||||
@@ -11,7 +11,6 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"admin-token": "${dir:state}/admin-token.secret"
|
||||
},
|
||||
@@ -100,29 +99,6 @@
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-influxdb",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
|
||||
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
@@ -135,23 +111,25 @@
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
|
||||
"MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/api.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# jira's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/jira
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/jira/dist /app/modules/jira/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/jira/dist/tools/index.js
|
||||
+14
-40
@@ -2,16 +2,9 @@
|
||||
"module": "jira",
|
||||
"version": "1",
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"token": "${dir:state}/token"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -25,46 +18,27 @@
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-runtime-jira",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/token:/run/secrets/token:ro",
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_JIRA_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_JIRA_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# keycloak's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
||||
# resolved away.
|
||||
WORKDIR /app/modules/keycloak
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||
# ran no provisioner at all.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
|
||||
@@ -62,8 +62,7 @@
|
||||
"oidc-client": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"admin": "${dir:state}/admin.secret"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
@@ -144,49 +143,30 @@
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-keycloak",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:grants}:${dir:grants}:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
|
||||
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
|
||||
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
|
||||
#
|
||||
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
|
||||
# whatever an upstream serves today.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/lab
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git make ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
|
||||
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
|
||||
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
|
||||
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
|
||||
&& chmod 0755 /usr/local/bin/incus
|
||||
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
|
||||
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
|
||||
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
|
||||
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
|
||||
+58
-46
@@ -2,18 +2,10 @@
|
||||
"module": "lab",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
"container-runtime",
|
||||
"virtualisation"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "mesh"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -34,47 +26,67 @@
|
||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-lab",
|
||||
"network": "host",
|
||||
"env-file": [
|
||||
"${dir:state}/lab.env"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:work}:${dir:work}",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_LAB_WORK": "${dir:work}"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-env"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
{
|
||||
"id": "make",
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
"package": "python"
|
||||
},
|
||||
{
|
||||
"id": "file",
|
||||
"type": "package",
|
||||
"package": "file"
|
||||
},
|
||||
{
|
||||
"id": "iproute2",
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "sudo",
|
||||
"type": "package",
|
||||
"package": "sudo"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "incus",
|
||||
"type": "package",
|
||||
"package": "incus"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_LAB_WORK": "${dir:work}",
|
||||
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
||||
|
||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, "");
|
||||
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
|
||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
return [
|
||||
{
|
||||
name: "lab_check",
|
||||
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const dir = `${work}/check`;
|
||||
spawnSync("rm", ["-rf", dir]);
|
||||
@@ -38,6 +43,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
run: async (args) => {
|
||||
const forge = forgeOf();
|
||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||
@@ -83,4 +89,20 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
|
||||
export function wordIn(file: string | undefined, word: string): string {
|
||||
if (!file) return "";
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
const at = line.indexOf("=");
|
||||
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
registerModuleTools("lab", (env) => getLabTools(env));
|
||||
|
||||
@@ -100,6 +100,10 @@ node -e ${shellQuote(
|
||||
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||
)}
|
||||
${setState("building")}
|
||||
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
|
||||
# resolves it; nothing else is asked of it.
|
||||
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
||||
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
|
||||
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
|
||||
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# letta's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/letta
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/letta/dist /app/modules/letta/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/letta/dist/tools/index.js
|
||||
+14
-36
@@ -26,8 +26,7 @@
|
||||
},
|
||||
"own-secrets": {
|
||||
"server-password": "${dir:state}/server-password.secret",
|
||||
"openai-api-key": "${dir:state}/openai-api-key.secret",
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
"openai-api-key": "${dir:state}/openai-api-key.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -84,45 +83,24 @@
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-letta",
|
||||
"network": "letta",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_LETTA_URL": "http://letta:8283",
|
||||
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_LETTA_URL": "http://127.0.0.1:${port:8283}",
|
||||
"MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# mailu's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
||||
# resolved away.
|
||||
WORKDIR /app/modules/mailu
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||
# ran no provisioner at all.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
|
||||
+40
-43
@@ -135,11 +135,15 @@
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
|
||||
},
|
||||
{
|
||||
"name": "admin-api",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -305,6 +309,9 @@
|
||||
"name": "mailu-admin",
|
||||
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||
"network": "mailu",
|
||||
"ports": [
|
||||
"8080"
|
||||
],
|
||||
"env-file": [
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env",
|
||||
@@ -462,7 +469,8 @@
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
]
|
||||
],
|
||||
"logging": "journald"
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -472,31 +480,6 @@
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-mailu",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"id": "automx",
|
||||
"type": "container",
|
||||
@@ -516,16 +499,6 @@
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
},
|
||||
{
|
||||
"arg": "PYTHON_BASE",
|
||||
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
||||
@@ -533,9 +506,26 @@
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "automx",
|
||||
@@ -561,5 +551,12 @@
|
||||
},
|
||||
"grants": {
|
||||
"smtp": "${dir:grants}"
|
||||
}
|
||||
},
|
||||
"jails": [
|
||||
{
|
||||
"name": "mailu-front",
|
||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# memory-pressure
|
||||
|
||||
Compressed swap in RAM, systemd-oomd, and a guard that warns before the machine kills for memory. It
|
||||
can be assigned on any machine (novox/hq research 027/03, 026/05, to-be 42 phase 3). Memory pressure
|
||||
is not the laptop's alone.
|
||||
|
||||
## What it owns
|
||||
|
||||
| | what | notes |
|
||||
|---|---|---|
|
||||
| package | `zram-generator` | |
|
||||
| file | `/etc/systemd/zram-generator.conf` | `zram0`: `min(ram / 2, 16384)` MiB, zstd, swap priority 100. These are the laptop's values, adopted (the path too, ADR 0182). They scale with the machine: 15.3 GiB on 30 GiB of RAM, 16 GiB on the 125 GiB desktop. Read at boot, so a change applies at the next boot. Resizing a live device would mean swapping it off, which pushes what it holds back into RAM |
|
||||
| file | `/etc/sysctl.d/90-memory-pressure.conf` | `vm.page-cluster = 0` (no read-ahead on swap in RAM). Swappiness is left alone on purpose: the comment says why. `systemd-sysctl` is re-run |
|
||||
| file | `/etc/systemd/oomd.conf.d/memory-pressure.conf` | `SwapUsedLimit=90%`, `DefaultMemoryPressureLimit=60%`, `DefaultMemoryPressureDurationSec=20s` |
|
||||
| file | `/etc/systemd/system/-.slice.d/10-oomd.conf` | `ManagedOOMSwap=kill` |
|
||||
| file | `/etc/systemd/system/user@.service.d/10-oomd.conf` | `ManagedOOMMemoryPressure=kill`, limit 80 % |
|
||||
| service | `systemd-oomd` running, enabled | restarted on any of its drop-ins, after a `daemon-reload` |
|
||||
|
||||
The swap on disk is not this module's. Whether there is a swap file or a partition, and how large, is
|
||||
the machine's swap layout (research 027 question 3, the `kernel` module). The two workstations differ:
|
||||
the laptop has a 32 GiB swap file (priority 10) and a 16 GiB partition, and the desktop a 128 GiB
|
||||
partition and no zram. This module adds the zram tier above whatever is there.
|
||||
|
||||
## The long-running code: the guard (ADR 0198)
|
||||
|
||||
The Go bundle serves the tools and runs the guard in the same process, launched by the node's runtime.
|
||||
It replaces the predecessor's `mem-guard` user unit, which checked used RAM every 30 s and called
|
||||
`notify-send`.
|
||||
|
||||
- **Three signals:** RAM used ≥ 95 % (the predecessor's line, which it had raised from 90),
|
||||
swap used ≥ 80 % (oomd kills at 90 %), or memory pressure (PSI `some` avg10) ≥ 40 % (oomd acts at
|
||||
60 %, or 80 % for the user manager, over 20 s). Whichever comes first warns, and the warning says
|
||||
which.
|
||||
- **Every 10 s**, not 30: oomd's window is 20 s, so a check every 30 s could warn after oomd had
|
||||
already acted. Each check reads two files. PSI triggers would wake on pressure alone, but not on RAM
|
||||
or swap filling, so one timer reads all three rather than run two mechanisms.
|
||||
- **Once per episode.** After a warning the guard re-arms only when every value is below its clearing
|
||||
line: 88 % RAM, 70 % swap, 10 % pressure.
|
||||
- **The warning names what to close:** the three largest units by resident plus swapped memory, and
|
||||
in each its largest process.
|
||||
- **The desktop, from outside the session.** The runtime runs as the operator's account but outside
|
||||
the graphical session, with no session bus address and no `XDG_RUNTIME_DIR` (measured on the
|
||||
laptop's runtime unit). The guard names the account's own bus, `/run/user/<uid>/bus`, and calls
|
||||
`org.freedesktop.Notifications.Notify` with `busctl --user`. That client is the service manager's
|
||||
own, so nothing is installed, and a server pulls in no libnotify. Measured on 2026-10-04: from a
|
||||
clean environment as the account's uid, the notification service on that bus answered (dunst 1.13).
|
||||
The account is the runtime's `MESH_OPERATOR_ACCOUNT`. A runtime running as another uid could not
|
||||
authenticate on that bus, and the guard says so rather than try.
|
||||
- **And always an event:** `pressure.high` (reasons, percentages, the largest units) and
|
||||
`pressure.cleared` (how long it lasted) are published through the runtime, whether or not anyone is
|
||||
at a desktop. A machine without one has no session bus, and `memory_guard` says
|
||||
*no session bus*.
|
||||
|
||||
Thresholds are constants until settings exist (issue 168).
|
||||
|
||||
**Known limit.** The runtime restarts a launched bundle that exits on its next tool call, not at once.
|
||||
The guard recovers from a panic and reports it, but a crashed process waits for a call.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `memory_status` | RAM, swap and each device with its priority, zram and its ratio, PSI some/full, and the guard's verdict on them |
|
||||
| `memory_top` | the largest processes by resident plus swap, with their unit; `by=unit` sums per unit, which is what systemd-oomd chooses among |
|
||||
| `memory_oom_history` | what was killed, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and the service manager's *killed by the OOM killer*. `since` is checked against a short grammar before it reaches `journalctl` |
|
||||
| `memory_zram` | each zram device (algorithm, size, stored, compressed, RAM used, ratio, same-filled and incompressible pages), the generator's configuration, swappiness and page-cluster |
|
||||
| `memory_oomd` | whether oomd runs, and what it watches (`oomctl`) |
|
||||
| `memory_guard` | the guard's thresholds, last verdict, whether a warning stands, and whether the desktop can be reached |
|
||||
|
||||
## Found on 2026-10-04 (read-only)
|
||||
|
||||
- **On the laptop, systemd-oomd guards almost nothing a person runs.** Every desktop application runs
|
||||
in the login session's scope (`session-1.scope`), because the login manager and i3 start them there
|
||||
and not in per-application scopes under `user@.service`. The pressure kill on `user@.service`
|
||||
therefore watches 0.5 GiB. The swap kill on `-.slice`, when it fires, would choose the largest
|
||||
cgroup, which is the whole session scope: X, i3 and every application at once. `memory_top by=unit`
|
||||
shows it. **The fix is the graphical session's** (phase 2): launch applications in their own
|
||||
scopes, for example `systemd-run --user --scope` from the launcher, and oomd then kills one
|
||||
application. This module does not widen oomd's reach onto `user-.slice`, because there it would kill
|
||||
the session.
|
||||
- The desktop has no zram and oomd disabled. Assigning the module there is a change of behaviour:
|
||||
16 GiB of zram at the next boot, and oomd enabled with the same caveat about the session scope.
|
||||
|
||||
## When assigned to the laptop: what changes
|
||||
|
||||
1. Written over found files (originals kept once): `zram-generator.conf` (same values, so nothing
|
||||
until the next boot either), `-.slice.d/10-oomd.conf` and `user@.service.d/10-oomd.conf` (same
|
||||
keys).
|
||||
2. New: `sysctl.d/90-memory-pressure.conf` (the same `vm.page-cluster = 0` already in force) and
|
||||
`oomd.conf.d/memory-pressure.conf` (the same values as the predecessor's file beside it).
|
||||
3. `systemd-sysctl` is re-run (the values are unchanged), and `daemon-reload` and `systemd-oomd` are
|
||||
restarted.
|
||||
4. The node runtime restarts with the bundle, and the guard starts. **Until `mem-guard` is stopped,
|
||||
two notifiers run.**
|
||||
|
||||
## Predecessor files this module makes redundant — the operator removes them once (ADR 0182)
|
||||
|
||||
On the laptop:
|
||||
|
||||
1. `systemctl --user disable --now mem-guard.service`, then delete
|
||||
`~/.config/systemd/user/mem-guard.service` and `~/scripts/mem-guard.sh`.
|
||||
2. `/etc/systemd/oomd.conf.d/g14-oomd.conf`: the same values as the module's drop-in.
|
||||
3. `/etc/sysctl.d/90-g14-zram.conf`: the same key as the module's file.
|
||||
|
||||
The desktop had none of these.
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...`. They read a tree standing in for `/proc` and `/sys`, using the laptop's own swaps,
|
||||
zram statistics and PSI lines. They cover:
|
||||
|
||||
- the snapshot;
|
||||
- the guard's three lines, its once-per-episode latch and its clearing, with the largest named;
|
||||
- an unreachable desktop still publishing the event;
|
||||
- the notification being one `busctl` call on the account's bus;
|
||||
- grouping by unit;
|
||||
- the journal's three kinds of kill, with non-UTF-8 messages skipped;
|
||||
- `since` refused when it is an option;
|
||||
- *no match* read as no kills;
|
||||
- the manifest: tools listed equal tools served, events, no machine named, triggers exist.
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The module's long-running code (novox/hq ADR 0198): the guard that warns before the machine kills
|
||||
// something for memory. It replaces the predecessor's `mem-guard`, a user unit that checked used RAM
|
||||
// every thirty seconds and called notify-send.
|
||||
//
|
||||
// What changed, and why:
|
||||
//
|
||||
// - **Three signals, not one.** Used RAM alone misses the two things that decide whether a kill is
|
||||
// coming: swap filling (systemd-oomd kills at SwapUsedLimit) and stall pressure (it kills a unit
|
||||
// whose pressure stays over its limit for twenty seconds). The guard warns on whichever comes
|
||||
// first, and says which.
|
||||
// - **Ten seconds, not thirty.** oomd's own window is twenty seconds; a check every thirty can
|
||||
// notice only after oomd has acted. Reading two files every ten seconds costs nothing measurable.
|
||||
// The kernel's PSI triggers would wake on pressure alone, but not on RAM or swap filling, so the
|
||||
// guard reads all three on one timer rather than run two mechanisms.
|
||||
// - **The notification names what to close.** It lists the largest units, not only a percentage.
|
||||
// - **An event as well as a notification.** `pressure.high` and `pressure.cleared` reach the mesh
|
||||
// whether or not anyone is at the desktop — a server has no desktop at all.
|
||||
// - **No session needed.** The runtime runs as the operator's account but outside the graphical
|
||||
// session, so it has no session bus address. The guard names the account's own bus,
|
||||
// /run/user/<uid>/bus, and speaks to the notification service with busctl, the service manager's
|
||||
// client: nothing is installed for it, and a machine without a desktop simply has no such bus.
|
||||
|
||||
// Thresholds, as constants until settings exist (novox/hq issue 168). Used RAM is the predecessor's,
|
||||
// raised by it from 90 to 95 after the lower line fired during ordinary work.
|
||||
const (
|
||||
WarnUsedPercent = 95.0
|
||||
ClearUsedPercent = 88.0
|
||||
WarnSwapPercent = 80.0 // systemd-oomd's SwapUsedLimit is 90 %
|
||||
ClearSwapPercent = 70.0
|
||||
WarnPressureAvg10 = 40.0 // "some" avg10; oomd acts at 60 % (80 % for the user manager) over 20 s
|
||||
ClearPressureAvg10 = 10.0
|
||||
CheckEvery = 10 * time.Second
|
||||
NotificationID = 9010 // replaces the previous warning rather than stacking
|
||||
)
|
||||
|
||||
// Verdict is what one check of the machine concluded.
|
||||
type Verdict struct {
|
||||
High bool `json:"high"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Clear bool `json:"clear"`
|
||||
}
|
||||
|
||||
// Judge applies the thresholds to a snapshot. High is any warning line crossed; Clear is every value
|
||||
// below its clearing line, which is what re-arms the guard.
|
||||
func Judge(s Snapshot) Verdict {
|
||||
v := Verdict{Reasons: []string{}}
|
||||
clear := true
|
||||
if s.UsedPercent >= WarnUsedPercent {
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("RAM %.0f%% used (warns at %.0f%%)", s.UsedPercent, WarnUsedPercent))
|
||||
}
|
||||
if s.UsedPercent > ClearUsedPercent {
|
||||
clear = false
|
||||
}
|
||||
if s.SwapTotalGiB > 0 {
|
||||
if s.SwapPercent >= WarnSwapPercent {
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("swap %.0f%% used (warns at %.0f%%; systemd-oomd kills at 90%%)", s.SwapPercent, WarnSwapPercent))
|
||||
}
|
||||
if s.SwapPercent > ClearSwapPercent {
|
||||
clear = false
|
||||
}
|
||||
}
|
||||
if s.PressureSome != nil {
|
||||
if s.PressureSome.Avg10 >= WarnPressureAvg10 {
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("tasks stalled on memory %.0f%% of the last 10 s (warns at %.0f%%)", s.PressureSome.Avg10, WarnPressureAvg10))
|
||||
}
|
||||
if s.PressureSome.Avg10 > ClearPressureAvg10 {
|
||||
clear = false
|
||||
}
|
||||
}
|
||||
v.High = len(v.Reasons) > 0
|
||||
v.Clear = clear
|
||||
return v
|
||||
}
|
||||
|
||||
// Guard is the notifier's state, shared with the tools that report it.
|
||||
type Guard struct {
|
||||
m *Machine
|
||||
now func() time.Time
|
||||
emit func(string, any) error
|
||||
notify func(ctx context.Context, summary, body string) error
|
||||
|
||||
mu sync.Mutex
|
||||
latched bool
|
||||
since time.Time
|
||||
last *Verdict
|
||||
lastAt time.Time
|
||||
notified string
|
||||
err string
|
||||
desktop string
|
||||
stopped string
|
||||
}
|
||||
|
||||
func NewGuard(m *Machine, emit func(string, any) error) *Guard {
|
||||
g := &Guard{m: m, now: time.Now, emit: emit}
|
||||
g.notify = g.desktopNotify
|
||||
return g
|
||||
}
|
||||
|
||||
// Check is one wake-up: read, judge, and on crossing a line warn once; on clearing every line, re-arm.
|
||||
func (g *Guard) Check(ctx context.Context) {
|
||||
s, err := g.m.Snapshot()
|
||||
g.mu.Lock()
|
||||
if err != nil {
|
||||
g.err = err.Error()
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
v := Judge(s)
|
||||
now := g.now()
|
||||
g.last, g.lastAt, g.err = &v, now, ""
|
||||
warn := v.High && !g.latched
|
||||
cleared := g.latched && v.Clear
|
||||
if warn {
|
||||
g.latched, g.since = true, now
|
||||
}
|
||||
if cleared {
|
||||
g.latched = false
|
||||
}
|
||||
since := g.since
|
||||
g.mu.Unlock()
|
||||
|
||||
if warn {
|
||||
largest := ByUnit(g.m.Processes(), 3)
|
||||
var names []string
|
||||
for _, u := range largest {
|
||||
names = append(names, fmt.Sprintf("%s %.1f GiB", firstNonEmpty(u.Largest, u.Unit), (u.RSSMiB+u.SwapMiB)/1024))
|
||||
}
|
||||
body := strings.Join(v.Reasons, "; ")
|
||||
if len(names) > 0 {
|
||||
body += ". Largest: " + strings.Join(names, ", ")
|
||||
}
|
||||
body += ". systemd-oomd kills the worst unit if it climbs further."
|
||||
g.publish("pressure.high", map[string]any{"reasons": v.Reasons, "used_percent": s.UsedPercent,
|
||||
"swap_used_percent": s.SwapPercent, "pressure_some": s.PressureSome, "largest": largest})
|
||||
err := g.notify(ctx, "Memory is running low", body)
|
||||
g.mu.Lock()
|
||||
if err != nil {
|
||||
g.desktop = "not reached: " + err.Error()
|
||||
} else {
|
||||
g.desktop, g.notified = "reached", now.Format(time.RFC3339)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
}
|
||||
if cleared {
|
||||
g.publish("pressure.cleared", map[string]any{"used_percent": s.UsedPercent, "swap_used_percent": s.SwapPercent,
|
||||
"lasted_seconds": int(now.Sub(since).Seconds())})
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) publish(eventType string, body map[string]any) {
|
||||
if g.emit == nil {
|
||||
return
|
||||
}
|
||||
if err := g.emit(eventType, body); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s not published: %v\n", eventType, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Bus is the operator's session bus socket: the account the runtime names, else the one this process
|
||||
// runs as.
|
||||
func Bus() (string, error) {
|
||||
uid := os.Getuid()
|
||||
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the operator account %s: %w", name, err)
|
||||
}
|
||||
if n, err := strconv.Atoi(u.Uid); err == nil {
|
||||
if n != uid && uid != 0 {
|
||||
return "", fmt.Errorf("this process runs as uid %d and the operator's bus belongs to uid %d", uid, n)
|
||||
}
|
||||
uid = n
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("/run/user/%d/bus", uid), nil
|
||||
}
|
||||
|
||||
// desktopNotify sends one critical notification to the operator's session, replacing the previous one.
|
||||
func (g *Guard) desktopNotify(ctx context.Context, summary, body string) error {
|
||||
bus, err := Bus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(bus); err != nil {
|
||||
return fmt.Errorf("no session bus at %s (nobody is logged in to a desktop)", bus)
|
||||
}
|
||||
_, err = g.m.Run(ctx, "env", "DBUS_SESSION_BUS_ADDRESS=unix:path="+bus,
|
||||
"busctl", "--user", "call", "org.freedesktop.Notifications", "/org/freedesktop/Notifications",
|
||||
"org.freedesktop.Notifications", "Notify", "susssasa{sv}i",
|
||||
"memory-pressure", strconv.Itoa(NotificationID), "dialog-warning", summary, body, "0", "1", "urgency", "y", "2", "0")
|
||||
return err
|
||||
}
|
||||
|
||||
// Run is the guard's life: a check every CheckEvery.
|
||||
func (g *Guard) Run(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.mu.Lock()
|
||||
g.stopped = fmt.Sprintf("the guard stopped on a fault: %v", r)
|
||||
g.mu.Unlock()
|
||||
fmt.Fprintln(os.Stderr, g.stopped)
|
||||
}
|
||||
}()
|
||||
t := time.NewTicker(CheckEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
g.Check(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GuardReport is the guard as the guard tool shows it.
|
||||
type GuardReport struct {
|
||||
Running bool `json:"running"`
|
||||
Stopped string `json:"stopped,omitempty"`
|
||||
Warned bool `json:"warning_given"`
|
||||
WarnedSince *time.Time `json:"warning_since,omitempty"`
|
||||
LastCheck *time.Time `json:"last_check,omitempty"`
|
||||
LastVerdict *Verdict `json:"last_verdict,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Desktop string `json:"desktop"`
|
||||
LastNotified string `json:"last_notified,omitempty"`
|
||||
Thresholds map[string]any `json:"thresholds"`
|
||||
}
|
||||
|
||||
func (g *Guard) Report() GuardReport {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
r := GuardReport{
|
||||
Running: g.stopped == "" && !g.lastAt.IsZero(), Stopped: g.stopped, Warned: g.latched, LastCheck: when(g.lastAt),
|
||||
LastVerdict: g.last, LastError: g.err, Desktop: g.desktop, LastNotified: g.notified,
|
||||
Thresholds: map[string]any{
|
||||
"warn_used_percent": WarnUsedPercent, "clear_used_percent": ClearUsedPercent,
|
||||
"warn_swap_percent": WarnSwapPercent, "clear_swap_percent": ClearSwapPercent,
|
||||
"warn_pressure_some_avg10": WarnPressureAvg10, "clear_pressure_some_avg10": ClearPressureAvg10,
|
||||
"check_every": CheckEvery.String(), "set_by": "constants until settings exist (novox/hq issue 168)",
|
||||
},
|
||||
}
|
||||
if g.latched {
|
||||
r.WarnedSince = when(g.since)
|
||||
}
|
||||
if r.Desktop == "" {
|
||||
if bus, err := Bus(); err != nil {
|
||||
r.Desktop = "not reachable: " + err.Error()
|
||||
} else if _, err := os.Stat(bus); err != nil {
|
||||
r.Desktop = "no session bus at " + bus
|
||||
} else {
|
||||
r.Desktop = "reachable at " + bus + " (not yet used)"
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// when is a time for a report: absent rather than the zero time.
|
||||
func when(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and
|
||||
// recording every command it was asked to run.
|
||||
type fake struct {
|
||||
t *testing.T
|
||||
root string
|
||||
mu sync.Mutex
|
||||
answers map[string]string
|
||||
fails map[string]error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}}
|
||||
}
|
||||
|
||||
func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} }
|
||||
|
||||
func (f *fake) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, line)
|
||||
if err, ok := f.fails[line]; ok {
|
||||
return "", err
|
||||
}
|
||||
if out, ok := f.answers[line]; ok {
|
||||
return out, nil
|
||||
}
|
||||
if err, ok := f.fails[name]; ok {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fake) called(line string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
if c == line {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fake) callsLike(prefix string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// file writes a file under the fake root.
|
||||
func (f *fake) file(path, content string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CommandTimeout bounds every command a tool or the guard runs: a journal that takes long to search
|
||||
// must cost a tool call twenty seconds, never the runtime's thirty.
|
||||
const CommandTimeout = 20 * time.Second
|
||||
|
||||
// Runner runs one command and answers its standard output. It is injected so that every tool is
|
||||
// tested against recorded answers rather than this machine's daemons.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the
|
||||
// command said on stderr, because "exit status 1" names nothing.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if said != "" {
|
||||
return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said)
|
||||
}
|
||||
return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of
|
||||
// /proc, /sys and /etc) and a way to run commands.
|
||||
type Machine struct {
|
||||
Root string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} }
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// read is a file's content, trimmed; "" when it cannot be read.
|
||||
func (m *Machine) read(p string) string {
|
||||
b, err := os.ReadFile(m.path(p))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// readInt is a file holding one integer; ok false when it is absent or not a number.
|
||||
func (m *Machine) readInt(p string) (int64, bool) {
|
||||
s := m.read(p)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
func (m *Machine) glob(pattern string) []string {
|
||||
found, _ := filepath.Glob(m.path(pattern))
|
||||
out := make([]string, 0, len(found))
|
||||
for _, f := range found {
|
||||
rel, err := filepath.Rel(m.Root, f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, "/"+filepath.ToSlash(rel))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// notInstalled says a command failed because it is not on this machine at all.
|
||||
func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) }
|
||||
|
||||
// round to one decimal, for watts and percentages a person reads.
|
||||
func round1(f float64) float64 {
|
||||
return float64(int64(f*10+sign(f)*0.5)) / 10
|
||||
}
|
||||
|
||||
func sign(f float64) float64 {
|
||||
if f < 0 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// The memory-pressure module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the
|
||||
// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running
|
||||
// code — the guard that warns before the machine kills for memory — beside them.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
m := Here()
|
||||
g := NewGuard(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go g.Run(ctx)
|
||||
if err := stdio.Serve("", Tools(m, g)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var served []string
|
||||
for _, tool := range Tools(newFake(t).machine(), nil) {
|
||||
served = append(served, tool.Name)
|
||||
}
|
||||
sort.Strings(served)
|
||||
sort.Strings(m.Tools)
|
||||
if strings.Join(served, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Fatalf("served %v, listed %v", served, m.Tools)
|
||||
}
|
||||
sort.Strings(m.Emits)
|
||||
if strings.Join(m.Emits, ",") != "pressure.cleared,pressure.high" {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
for _, banned := range []string{"g14", "jochen", "/home/", "shanks", "/run/user/1000"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
ids[r["id"].(string)] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
list, _ := r["restart-on"].([]any)
|
||||
for _, id := range list {
|
||||
if !ids[id.(string)] {
|
||||
t.Errorf("%s restarts on %v, which is not a resource", r["id"], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const kib = 1024
|
||||
|
||||
// GiB turns bytes into gibibytes with one decimal, for a person reading.
|
||||
func GiB(bytes int64) float64 { return round1(float64(bytes) / (1 << 30)) }
|
||||
|
||||
// ParseMeminfo reads /proc/meminfo into bytes by field.
|
||||
func ParseMeminfo(text string) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(f) > 1 && f[1] == "kB" {
|
||||
n *= kib
|
||||
}
|
||||
out[key] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Pressure is one line of a PSI file: the share of time some (or all) tasks stalled on memory.
|
||||
type Pressure struct {
|
||||
Avg10 float64 `json:"avg10"`
|
||||
Avg60 float64 `json:"avg60"`
|
||||
Avg300 float64 `json:"avg300"`
|
||||
}
|
||||
|
||||
// ParsePSI reads /proc/pressure/memory: its `some` and `full` lines.
|
||||
func ParsePSI(text string) (some, full *Pressure) {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
p := &Pressure{}
|
||||
for _, kv := range f[1:] {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
n, _ := strconv.ParseFloat(v, 64)
|
||||
switch k {
|
||||
case "avg10":
|
||||
p.Avg10 = n
|
||||
case "avg60":
|
||||
p.Avg60 = n
|
||||
case "avg300":
|
||||
p.Avg300 = n
|
||||
}
|
||||
}
|
||||
switch f[0] {
|
||||
case "some":
|
||||
some = p
|
||||
case "full":
|
||||
full = p
|
||||
}
|
||||
}
|
||||
return some, full
|
||||
}
|
||||
|
||||
// SwapDevice is one line of /proc/swaps.
|
||||
type SwapDevice struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SizeGiB float64 `json:"size_gib"`
|
||||
UsedGiB float64 `json:"used_gib"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// ParseSwaps reads /proc/swaps (sizes in KiB).
|
||||
func ParseSwaps(text string) []SwapDevice {
|
||||
var out []SwapDevice
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if i == 0 || len(f) < 5 {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.ParseInt(f[2], 10, 64)
|
||||
used, _ := strconv.ParseInt(f[3], 10, 64)
|
||||
prio, _ := strconv.Atoi(f[4])
|
||||
out = append(out, SwapDevice{Name: f[0], Type: f[1], SizeGiB: GiB(size * kib), UsedGiB: GiB(used * kib), Priority: prio})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot is the machine's memory at one moment: what the status tool answers and what the guard
|
||||
// judges.
|
||||
type Snapshot struct {
|
||||
TotalGiB float64 `json:"total_gib"`
|
||||
AvailableGiB float64 `json:"available_gib"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
SwapTotalGiB float64 `json:"swap_total_gib"`
|
||||
SwapUsedGiB float64 `json:"swap_used_gib"`
|
||||
SwapPercent float64 `json:"swap_used_percent"`
|
||||
Swaps []SwapDevice `json:"swap_devices"`
|
||||
Zram []Zram `json:"zram"`
|
||||
PressureSome *Pressure `json:"pressure_some,omitempty"`
|
||||
PressureFull *Pressure `json:"pressure_full,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot reads /proc and /sys.
|
||||
func (m *Machine) Snapshot() (Snapshot, error) {
|
||||
mem := ParseMeminfo(m.read("/proc/meminfo"))
|
||||
total, avail := mem["MemTotal"], mem["MemAvailable"]
|
||||
if total <= 0 {
|
||||
return Snapshot{}, fmt.Errorf("/proc/meminfo says no MemTotal")
|
||||
}
|
||||
s := Snapshot{
|
||||
TotalGiB: GiB(total), AvailableGiB: GiB(avail),
|
||||
UsedPercent: round1(float64(total-avail) / float64(total) * 100),
|
||||
SwapTotalGiB: GiB(mem["SwapTotal"]),
|
||||
SwapUsedGiB: GiB(mem["SwapTotal"] - mem["SwapFree"]),
|
||||
Swaps: ParseSwaps(m.read("/proc/swaps")),
|
||||
Zram: m.Zram(),
|
||||
}
|
||||
if mem["SwapTotal"] > 0 {
|
||||
s.SwapPercent = round1(float64(mem["SwapTotal"]-mem["SwapFree"]) / float64(mem["SwapTotal"]) * 100)
|
||||
}
|
||||
if s.Swaps == nil {
|
||||
s.Swaps = []SwapDevice{}
|
||||
}
|
||||
s.PressureSome, s.PressureFull = ParsePSI(m.read("/proc/pressure/memory"))
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Zram is one compressed swap device in RAM.
|
||||
type Zram struct {
|
||||
Device string `json:"device"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
DiskSizeGiB float64 `json:"disk_size_gib"`
|
||||
StoredGiB float64 `json:"stored_gib"`
|
||||
CompressedGiB float64 `json:"compressed_gib"`
|
||||
RAMUsedGiB float64 `json:"ram_used_gib"`
|
||||
Ratio *float64 `json:"compression_ratio,omitempty"`
|
||||
SamePages int64 `json:"same_filled_pages"`
|
||||
HugePages int64 `json:"incompressible_pages"`
|
||||
}
|
||||
|
||||
// Zram reads every zram device's statistics from /sys/block.
|
||||
func (m *Machine) Zram() []Zram {
|
||||
out := []Zram{}
|
||||
for _, dir := range m.glob("/sys/block/zram*") {
|
||||
z := Zram{Device: path.Base(dir), Algorithm: activeAlgorithm(m.read(dir + "/comp_algorithm"))}
|
||||
if v, ok := m.readInt(dir + "/disksize"); ok {
|
||||
z.DiskSizeGiB = GiB(v)
|
||||
}
|
||||
// mm_stat: orig_data_size compr_data_size mem_used_total mem_limit mem_used_max same_pages
|
||||
// pages_compacted huge_pages …
|
||||
f := strings.Fields(m.read(dir + "/mm_stat"))
|
||||
n := func(i int) int64 {
|
||||
if i >= len(f) {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(f[i], 10, 64)
|
||||
return v
|
||||
}
|
||||
if len(f) >= 3 {
|
||||
z.StoredGiB, z.CompressedGiB, z.RAMUsedGiB = GiB(n(0)), GiB(n(1)), GiB(n(2))
|
||||
z.SamePages, z.HugePages = n(5), n(7)
|
||||
if n(1) > 0 {
|
||||
r := round1(float64(n(0)) / float64(n(1)))
|
||||
z.Ratio = &r
|
||||
}
|
||||
}
|
||||
out = append(out, z)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activeAlgorithm is the bracketed one of `lzo lz4 [zstd]`.
|
||||
func activeAlgorithm(s string) string {
|
||||
for _, f := range strings.Fields(s) {
|
||||
if strings.HasPrefix(f, "[") {
|
||||
return strings.Trim(f, "[]")
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Process is one process by the memory it holds.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
RSSMiB float64 `json:"rss_mib"`
|
||||
SwapMiB float64 `json:"swap_mib"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Group is the memory of every process in one systemd unit or scope.
|
||||
type Group struct {
|
||||
Unit string `json:"unit"`
|
||||
Processes int `json:"processes"`
|
||||
RSSMiB float64 `json:"rss_mib"`
|
||||
SwapMiB float64 `json:"swap_mib"`
|
||||
Largest string `json:"largest"`
|
||||
}
|
||||
|
||||
// unitOf is the last named unit in a process's cgroup path: the scope or service it lives in, which
|
||||
// is what systemd-oomd chooses among.
|
||||
func unitOf(cgroup string) string {
|
||||
line := strings.TrimSpace(strings.SplitN(cgroup, "\n", 2)[0])
|
||||
_, p, _ := strings.Cut(line, "::")
|
||||
parts := strings.Split(p, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if strings.HasSuffix(parts[i], ".scope") || strings.HasSuffix(parts[i], ".service") || strings.HasSuffix(parts[i], ".slice") {
|
||||
return parts[i]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Processes reads every process's resident and swapped memory. A process that ends while it is read
|
||||
// is skipped.
|
||||
func (m *Machine) Processes() []Process {
|
||||
var out []Process
|
||||
for _, dir := range m.glob("/proc/[0-9]*") {
|
||||
status := m.read(dir + "/status")
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
fields := ParseMeminfo(status)
|
||||
name := ""
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Name:"); ok {
|
||||
name = strings.TrimSpace(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
rss := fields["VmRSS"]
|
||||
swap := fields["VmSwap"]
|
||||
if rss == 0 && swap == 0 {
|
||||
continue // a kernel thread
|
||||
}
|
||||
pid, _ := strconv.Atoi(path.Base(dir))
|
||||
cmd := strings.TrimSpace(strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " "))
|
||||
if len(cmd) > 160 {
|
||||
cmd = cmd[:160] + "…"
|
||||
}
|
||||
out = append(out, Process{PID: pid, Name: name, RSSMiB: mib(rss), SwapMiB: mib(swap),
|
||||
Unit: unitOf(m.read(dir + "/cgroup")), Command: cmd})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mib(b int64) float64 { return round1(float64(b) / (1 << 20)) }
|
||||
|
||||
// Top is the largest processes by resident plus swapped memory.
|
||||
func Top(ps []Process, limit int) []Process {
|
||||
sort.Slice(ps, func(i, j int) bool { return ps[i].RSSMiB+ps[i].SwapMiB > ps[j].RSSMiB+ps[j].SwapMiB })
|
||||
if len(ps) > limit {
|
||||
ps = ps[:limit]
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
// ByUnit sums processes by their unit, largest first.
|
||||
func ByUnit(ps []Process, limit int) []Group {
|
||||
groups := map[string]*Group{}
|
||||
biggest := map[string]float64{}
|
||||
for _, p := range ps {
|
||||
g := groups[p.Unit]
|
||||
if g == nil {
|
||||
g = &Group{Unit: p.Unit}
|
||||
groups[p.Unit] = g
|
||||
}
|
||||
g.Processes++
|
||||
g.RSSMiB = round1(g.RSSMiB + p.RSSMiB)
|
||||
g.SwapMiB = round1(g.SwapMiB + p.SwapMiB)
|
||||
if p.RSSMiB+p.SwapMiB > biggest[p.Unit] {
|
||||
biggest[p.Unit] = p.RSSMiB + p.SwapMiB
|
||||
g.Largest = p.Name
|
||||
}
|
||||
}
|
||||
out := make([]Group, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, *g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].RSSMiB+out[i].SwapMiB > out[j].RSSMiB+out[j].SwapMiB })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const meminfo = `MemTotal: 32000000 kB
|
||||
MemFree: 905460 kB
|
||||
MemAvailable: 1000000 kB
|
||||
SwapTotal: 10000000 kB
|
||||
SwapFree: 5000000 kB
|
||||
`
|
||||
|
||||
// As /proc/swaps and zram read on the laptop on 2026-10-04.
|
||||
const swaps = `Filename Type Size Used Priority
|
||||
/swapfile file 33554428 0 10
|
||||
/dev/zram0 partition 16059900 7235584 100
|
||||
`
|
||||
|
||||
func (f *fake) machineWith(mem, psi string) *Machine {
|
||||
f.file("/proc/meminfo", mem)
|
||||
f.file("/proc/swaps", swaps)
|
||||
f.file("/proc/pressure/memory", psi)
|
||||
f.file("/sys/block/zram0/comp_algorithm", "lzo-rle lzo lz4 lz4hc [zstd] deflate 842\n")
|
||||
f.file("/sys/block/zram0/disksize", "16445341696\n")
|
||||
f.file("/sys/block/zram0/mm_stat", "14368768 5341499 6680576 0 6680576 2 0 0 0\n")
|
||||
return f.machine()
|
||||
}
|
||||
|
||||
const calm = "some avg10=0.00 avg60=0.02 avg300=0.00 total=14487028\nfull avg10=0.00 avg60=0.01 avg300=0.00 total=14230772\n"
|
||||
|
||||
func TestTheSnapshotReadsRAMSwapZramAndPressure(t *testing.T) {
|
||||
f := newFake(t)
|
||||
s, err := f.machineWith(meminfo, calm).Snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.UsedPercent != 96.9 || s.SwapPercent != 50 || len(s.Swaps) != 2 || s.Swaps[1].Priority != 100 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
z := s.Zram[0]
|
||||
if z.Algorithm != "zstd" || *z.Ratio != 2.7 || z.DiskSizeGiB != 15.3 || z.SamePages != 2 {
|
||||
t.Fatalf("%+v", z)
|
||||
}
|
||||
if s.PressureSome == nil || s.PressureSome.Avg60 != 0.02 || s.PressureFull == nil {
|
||||
t.Fatalf("%+v %+v", s.PressureSome, s.PressureFull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardWarnsOnWhicheverLineIsCrossedFirst(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
s Snapshot
|
||||
want string
|
||||
}{
|
||||
"ram": {Snapshot{UsedPercent: 96}, "RAM"},
|
||||
"swap": {Snapshot{UsedPercent: 50, SwapTotalGiB: 10, SwapPercent: 85}, "swap"},
|
||||
"pressure": {Snapshot{UsedPercent: 50, PressureSome: &Pressure{Avg10: 45}}, "stalled"},
|
||||
} {
|
||||
v := Judge(c.s)
|
||||
if !v.High || len(v.Reasons) != 1 || !strings.Contains(v.Reasons[0], c.want) || v.Clear {
|
||||
t.Errorf("%s: %+v", name, v)
|
||||
}
|
||||
}
|
||||
if v := Judge(Snapshot{UsedPercent: 90}); v.High || v.Clear {
|
||||
t.Errorf("between the lines is neither a warning nor clear: %+v", v)
|
||||
}
|
||||
if v := Judge(Snapshot{UsedPercent: 50, PressureSome: &Pressure{}}); v.High || !v.Clear {
|
||||
t.Errorf("%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardWarnsOnceNamesTheLargestAndClearsOnlyBelowEveryLine(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := f.machineWith(meminfo, calm)
|
||||
f.file("/proc/4242/status", "Name:\tfirefox\nVmRSS:\t 6291456 kB\nVmSwap:\t 1048576 kB\n")
|
||||
f.file("/proc/4242/cgroup", "0::/user.slice/user-1000.slice/session-1.scope\n")
|
||||
f.file("/proc/4242/cmdline", "/usr/lib/firefox/firefox\x00")
|
||||
var events []string
|
||||
g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil })
|
||||
var notes []string
|
||||
g.notify = func(_ context.Context, summary, body string) error {
|
||||
notes = append(notes, body)
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
g.Check(ctx)
|
||||
g.Check(ctx)
|
||||
if len(notes) != 1 || !strings.Contains(notes[0], "firefox 7.0 GiB") || !strings.Contains(notes[0], "RAM 97%") {
|
||||
t.Fatalf("%v", notes)
|
||||
}
|
||||
// Between the lines: still latched, nothing new.
|
||||
f.file("/proc/meminfo", strings.Replace(meminfo, "MemAvailable: 1000000", "MemAvailable: 3200000", 1))
|
||||
g.Check(ctx)
|
||||
if len(events) != 1 || !g.Report().Warned {
|
||||
t.Fatalf("%v %+v", events, g.Report())
|
||||
}
|
||||
f.file("/proc/meminfo", "MemTotal: 32000000 kB\nMemAvailable: 20000000 kB\nSwapTotal: 10000000 kB\nSwapFree: 9000000 kB\n")
|
||||
g.Check(ctx)
|
||||
if strings.Join(events, ",") != "pressure.high,pressure.cleared" || g.Report().Warned {
|
||||
t.Fatalf("%v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADesktopThatCannotBeReachedIsSaidAndTheEventStillGoes(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := f.machineWith(meminfo, calm)
|
||||
var events []string
|
||||
g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil })
|
||||
g.notify = func(context.Context, string, string) error { return errors.New("no session bus at /run/user/1000/bus") }
|
||||
g.Check(context.Background())
|
||||
if r := g.Report(); !strings.HasPrefix(r.Desktop, "not reached") || len(events) != 1 {
|
||||
t.Fatalf("%+v %v", r, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNotificationIsOneBusctlCallOnTheAccountsBus(t *testing.T) {
|
||||
f := newFake(t)
|
||||
g := NewGuard(f.machine(), nil)
|
||||
bus, err := Bus()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
err = g.desktopNotify(context.Background(), "s", "b")
|
||||
calls := f.callsLike("env DBUS_SESSION_BUS_ADDRESS=unix:path=" + bus + " busctl --user call org.freedesktop.Notifications")
|
||||
if err == nil && len(calls) != 1 {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "no session bus") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLargestAreSummedByTheUnitOomdChoosesAmong(t *testing.T) {
|
||||
ps := []Process{
|
||||
{PID: 1, Name: "firefox", RSSMiB: 600, Unit: "session-1.scope"},
|
||||
{PID: 2, Name: "Isolated Web Co", RSSMiB: 900, Unit: "session-1.scope"},
|
||||
{PID: 3, Name: "postgres", RSSMiB: 100, Unit: "docker-abc.scope"},
|
||||
}
|
||||
g := ByUnit(ps, 10)
|
||||
if len(g) != 2 || g[0].Unit != "session-1.scope" || g[0].RSSMiB != 1500 || g[0].Largest != "Isolated Web Co" || g[0].Processes != 2 {
|
||||
t.Fatalf("%+v", g)
|
||||
}
|
||||
if top := Top(ps, 1); top[0].PID != 2 {
|
||||
t.Fatalf("%+v", top)
|
||||
}
|
||||
if u := unitOf("0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foot-123.scope\n"); u != "app-foot-123.scope" {
|
||||
t.Fatal(u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheJournalIsReadForEveryKindOfKillAndSinceIsChecked(t *testing.T) {
|
||||
journal := strings.Join([]string{
|
||||
`{"__REALTIME_TIMESTAMP":"1759500000000000","MESSAGE":"Out of memory: Killed process 4242 (firefox) total-vm:1kB","_TRANSPORT":"kernel"}`,
|
||||
`{"__REALTIME_TIMESTAMP":"1759600000000000","MESSAGE":"Killed /user.slice/user-1000.slice/session-1.scope due to memory pressure for /user.slice being 84.12% > 80.00% for > 20s with reclaim activity","_SYSTEMD_UNIT":"systemd-oomd.service"}`,
|
||||
`{"__REALTIME_TIMESTAMP":"1759700000000000","MESSAGE":"docker.service: A process of this unit has been killed by the OOM killer.","_SYSTEMD_UNIT":"init.scope"}`,
|
||||
`{"__REALTIME_TIMESTAMP":"1759800000000000","MESSAGE":"Killed something else entirely","_SYSTEMD_UNIT":"bash.service"}`,
|
||||
`{"MESSAGE":[1,2,3]}`,
|
||||
}, "\n")
|
||||
kills := ParseJournal(journal)
|
||||
if len(kills) != 3 || kills[0].By != "service manager" || kills[0].Victim != "docker.service" ||
|
||||
kills[1].By != "systemd-oomd" || kills[2].Victim != "firefox (pid 4242)" || kills[2].Time.Year() != 2025 {
|
||||
t.Fatalf("%+v", kills)
|
||||
}
|
||||
f := newFake(t)
|
||||
m := f.machine()
|
||||
if _, err := m.OOMHistory(context.Background(), "--output=x", 10); err == nil {
|
||||
t.Fatal("an option was passed as since")
|
||||
}
|
||||
f.fails["journalctl --no-pager -q -o json --since -7 days -g Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer"] = errors.New("journalctl: exit status 1")
|
||||
k, err := m.OOMHistory(context.Background(), "7d", 10)
|
||||
if err != nil || len(k) != 0 {
|
||||
t.Fatalf("no match is no kills: %v %v", k, err)
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kill is one thing the machine killed for memory, from the journal.
|
||||
type Kill struct {
|
||||
Time time.Time `json:"time"`
|
||||
By string `json:"by"`
|
||||
Victim string `json:"victim,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// The three voices that report a kill: the kernel's OOM killer, systemd-oomd, and the service manager
|
||||
// saying one of its units lost a process to the OOM killer.
|
||||
var (
|
||||
kernelKill = regexp.MustCompile(`Out of memory: Killed process (\d+) \(([^)]*)\)`)
|
||||
oomdKill = regexp.MustCompile(`Killed (\S+) due to (.+)`)
|
||||
unitKilled = regexp.MustCompile(`^(\S+): A process of this unit has been killed by the OOM killer`)
|
||||
)
|
||||
|
||||
// sinceArg is a value journalctl understands after --since; anything else is refused before it
|
||||
// reaches the command line.
|
||||
var sinceArg = regexp.MustCompile(`^(-?\d+[smhdw]|today|yesterday|\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?)$`)
|
||||
|
||||
// ParseJournal reads `journalctl -o json` lines into kills, newest first.
|
||||
func ParseJournal(text string) []Kill {
|
||||
var out []Kill
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var e map[string]any
|
||||
if json.Unmarshal([]byte(line), &e) != nil {
|
||||
continue
|
||||
}
|
||||
msg, ok := e["MESSAGE"].(string) // a message that is not UTF-8 arrives as bytes; not one of these
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var k Kill
|
||||
if us, err := strconv.ParseInt(fmt.Sprint(e["__REALTIME_TIMESTAMP"]), 10, 64); err == nil {
|
||||
k.Time = time.UnixMicro(us).UTC()
|
||||
}
|
||||
switch {
|
||||
case kernelKill.MatchString(msg):
|
||||
g := kernelKill.FindStringSubmatch(msg)
|
||||
k.By, k.Victim = "kernel", g[2]+" (pid "+g[1]+")"
|
||||
case oomdKill.MatchString(msg) && strings.Contains(fmt.Sprint(e["_SYSTEMD_UNIT"], e["SYSLOG_IDENTIFIER"]), "oomd"):
|
||||
g := oomdKill.FindStringSubmatch(msg)
|
||||
k.By, k.Victim = "systemd-oomd", g[1]
|
||||
case unitKilled.MatchString(msg):
|
||||
k.By, k.Victim = "service manager", unitKilled.FindStringSubmatch(msg)[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
k.Message = msg
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
|
||||
return out
|
||||
}
|
||||
|
||||
// OOMHistory searches the journal since a time for every kill.
|
||||
func (m *Machine) OOMHistory(ctx context.Context, since string, limit int) ([]Kill, error) {
|
||||
if !sinceArg.MatchString(since) {
|
||||
return nil, fmt.Errorf("since %q is not -7d, 12h, today, yesterday or a date (2026-10-01)", since)
|
||||
}
|
||||
since = expandRelative(since)
|
||||
out, err := m.Run(ctx, "journalctl", "--no-pager", "-q", "-o", "json", "--since", since,
|
||||
"-g", "Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer")
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
if strings.HasSuffix(err.Error(), "exit status 1") {
|
||||
return []Kill{}, nil // journalctl exits 1 when a grep matches nothing
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
kills := ParseJournal(out)
|
||||
if kills == nil {
|
||||
kills = []Kill{}
|
||||
}
|
||||
if len(kills) > limit {
|
||||
kills = kills[:limit]
|
||||
}
|
||||
return kills, nil
|
||||
}
|
||||
|
||||
// expandRelative turns -7d (or 7d) into the "-7 days" form journalctl's --since reads.
|
||||
func expandRelative(s string) string {
|
||||
g := regexp.MustCompile(`^-?(\d+)([smhdw])$`).FindStringSubmatch(s)
|
||||
if g == nil {
|
||||
return s
|
||||
}
|
||||
unit := map[string]string{"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}[g[2]]
|
||||
return "-" + g[1] + " " + unit
|
||||
}
|
||||
|
||||
// Oomd is what systemd-oomd says it watches.
|
||||
type Oomd struct {
|
||||
Active string `json:"active"`
|
||||
Enabled string `json:"enabled"`
|
||||
Report []string `json:"oomctl"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) Oomd(ctx context.Context) Oomd {
|
||||
o := Oomd{Report: []string{}}
|
||||
a, _ := m.Run(ctx, "systemctl", "is-active", "systemd-oomd.service")
|
||||
e, _ := m.Run(ctx, "systemctl", "is-enabled", "systemd-oomd.service")
|
||||
o.Active, o.Enabled = strings.TrimSpace(a), strings.TrimSpace(e)
|
||||
out, err := m.Run(ctx, "oomctl")
|
||||
if err != nil {
|
||||
o.Note = "oomctl: " + err.Error()
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
o.Report = append(o.Report, strings.TrimRight(strings.ReplaceAll(line, "\t", " "), " "))
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tools is the module's tools over one machine and its guard.
|
||||
func Tools(m *Machine, g *Guard) []stdio.Tool {
|
||||
ctx := context.Background
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "memory_status",
|
||||
Description: "Memory now: RAM total, available and used, swap and each swap device with its priority, the compressed swap in RAM " +
|
||||
"(zram) and its ratio, and pressure stall (PSI some/full, avg10/60/300) — with the guard's verdict on it.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
s, err := m.Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"memory": s, "verdict": Judge(s)}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "memory_top",
|
||||
Description: "The largest processes by resident plus swapped memory, with the systemd unit each runs in; by=unit sums them per unit, which is what systemd-oomd chooses among.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many (default 10, at most 50)"},
|
||||
"by": map[string]any{"type": "string", "enum": []string{"process", "unit"}},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := bounded(args, "limit", 10, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ps := m.Processes()
|
||||
switch str(args, "by") {
|
||||
case "", "process":
|
||||
return map[string]any{"processes": orEmpty(Top(ps, limit))}, nil
|
||||
case "unit":
|
||||
return map[string]any{"units": orEmpty(ByUnit(ps, limit))}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("by is process or unit")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "memory_oom_history",
|
||||
Description: "What was killed for memory, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and units the service manager says lost a process to it.",
|
||||
Input: map[string]any{
|
||||
"since": map[string]any{"type": "string", "description": "-7d (default), 12h, today, yesterday or a date like 2026-10-01"},
|
||||
"limit": map[string]any{"type": "integer", "description": "how many (default 20, at most 100)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := bounded(args, "limit", 20, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
since := str(args, "since")
|
||||
if since == "" {
|
||||
since = "-7d"
|
||||
}
|
||||
kills, err := m.OOMHistory(ctx(), since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"since": since, "kills": kills}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "memory_zram",
|
||||
Description: "The compressed swap in RAM: each zram device's algorithm, size, what it stores, what that costs in RAM and the ratio, the generator's configuration, and the swap tunables beside it.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return map[string]any{
|
||||
"devices": m.Zram(),
|
||||
"configuration": m.read("/etc/systemd/zram-generator.conf"),
|
||||
"vm": map[string]string{
|
||||
"swappiness": m.read("/proc/sys/vm/swappiness"),
|
||||
"page-cluster": m.read("/proc/sys/vm/page-cluster"),
|
||||
},
|
||||
"note": "a change to the configuration applies at the next boot: swapping the device off to resize it would push what it holds back into RAM",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "memory_oomd",
|
||||
Description: "systemd-oomd: whether it runs, and what it watches — the cgroups, their limits and their pressure, as oomctl reports them.",
|
||||
Run: func(map[string]any) (any, error) { return m.Oomd(ctx()), nil },
|
||||
},
|
||||
{
|
||||
Name: "memory_guard",
|
||||
Description: "The module's guard that warns before the machine kills for memory: its thresholds, its last verdict, whether a warning stands, and whether the operator's desktop can be reached.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
if g == nil {
|
||||
return nil, fmt.Errorf("the guard does not run in this process")
|
||||
}
|
||||
return g.Report(), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func str(args map[string]any, key string) string {
|
||||
s, _ := args[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// bounded is an integer argument, defaulted, at least 1 and at most most.
|
||||
func bounded(args map[string]any, key string, fallback, most int) (int, error) {
|
||||
v, given := args[key]
|
||||
if !given || v == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
var n int
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
if x != math.Trunc(x) {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %v", key, x)
|
||||
}
|
||||
n = int(x)
|
||||
case string:
|
||||
i, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be a whole number, not %q", key, x)
|
||||
}
|
||||
n = i
|
||||
default:
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
if n < 1 {
|
||||
return 0, fmt.Errorf("%s must be at least 1", key)
|
||||
}
|
||||
return min(n, most), nil
|
||||
}
|
||||
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module memorypressure
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"module": "memory-pressure",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"emits": [
|
||||
"pressure.high",
|
||||
"pressure.cleared"
|
||||
],
|
||||
"tools": [
|
||||
"memory_status",
|
||||
"memory_top",
|
||||
"memory_oom_history",
|
||||
"memory_zram",
|
||||
"memory_oomd",
|
||||
"memory_guard"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "zram-generator",
|
||||
"type": "package",
|
||||
"package": "zram-generator"
|
||||
},
|
||||
{
|
||||
"id": "zram",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/zram-generator.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday\n# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd.\n# The swap on disk, its size and whether one exists at all are the machine's, not this module's.\n#\n# Read by the generator at boot: a change applies at the next boot.\n[zram0]\nzram-size = min(ram / 2, 16384)\ncompression-algorithm = zstd\nswap-priority = 100\n"
|
||||
},
|
||||
{
|
||||
"id": "sysctl-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/sysctl.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "swap-tunables",
|
||||
"type": "file",
|
||||
"path": "/etc/sysctl.d/90-memory-pressure.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in\n# RAM has none — the speculation only costs decompression and memory.\nvm.page-cluster = 0\n#\n# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the\n# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing\n# being avoided. Raise it only together with zram-size.\n"
|
||||
},
|
||||
{
|
||||
"id": "sysctl",
|
||||
"type": "service",
|
||||
"unit": "systemd-sysctl.service",
|
||||
"restart-on": [
|
||||
"swap-tunables"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "oomd-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/oomd.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "oomd-limits",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/oomd.conf.d/memory-pressure.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is\n# 90 % used, or when a watched unit stalls on memory for 20 s above its limit.\n[OOM]\nSwapUsedLimit=90%\nDefaultMemoryPressureLimit=60%\nDefaultMemoryPressureDurationSec=20s\n"
|
||||
},
|
||||
{
|
||||
"id": "root-slice-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/-.slice.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "root-slice",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/-.slice.d/10-oomd.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may act on swap exhaustion across the whole machine.\n[Slice]\nManagedOOMSwap=kill\n"
|
||||
},
|
||||
{
|
||||
"id": "user-manager-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/user@.service.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "user-manager",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/user@.service.d/10-oomd.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays\n# above 80 % — instead of the kernel freezing the machine.\n[Service]\nManagedOOMMemoryPressure=kill\nManagedOOMMemoryPressureLimit=80%\n"
|
||||
},
|
||||
{
|
||||
"id": "oomd",
|
||||
"type": "service",
|
||||
"unit": "systemd-oomd.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"oomd-limits",
|
||||
"root-slice",
|
||||
"user-manager"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools-go",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/memory-pressure",
|
||||
"binary": "memory-pressure",
|
||||
"loads": [
|
||||
"memory-pressure"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user