diff --git a/internal/beside/beside.go b/internal/beside/beside.go index 233f1ab6..3ae47abf 100644 --- a/internal/beside/beside.go +++ b/internal/beside/beside.go @@ -73,6 +73,11 @@ func Dir(t testing.TB, repository string) string { } return os.WriteFile(into, raw, 0o644) }) + if err == nil { + // The copy stands at its repository's root, as the clone does: what is read from a repository's root + // (the catalogue's predefined.json, found above a manifest by its .git) is found in the copy too. + err = os.WriteFile(filepath.Join(dir, ".git"), nil, 0o644) + } if err != nil { t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) } diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index cab13354..9685c506 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -7,7 +7,7 @@ The copy carries the catalogue's private details: domains, first names in module private addresses. That is acceptable while mesh-controller is private; if it ever goes public, this copy is a second place to clean besides the catalogue itself. -mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile +mesh-catalog 2246ccc492e59579a140752e6f574b2121d296af modules/*/module.json, modules/*/files, modules/*/templates, predefined.json mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock mesh-sdk 9205222a98bb0d460e3a827d69d2ff640c500b47 conformance/events/module-event.json @@ -16,7 +16,8 @@ To move them, from this repository's root, with the two repositories checked out rm -rf testdata/beside/mesh-catalog testdata/beside/mesh-host mkdir -p testdata/beside/mesh-catalog testdata/beside/mesh-host git -C ../mesh-catalog archive modules | tar -x -C testdata/beside/mesh-catalog --wildcards \ - 'modules/*/module.json' 'modules/nats/Dockerfile' + 'modules/*/module.json' 'modules/*/files' 'modules/*/templates' + git -C ../mesh-catalog show :predefined.json > testdata/beside/mesh-catalog/predefined.json find testdata/beside -name module.json -exec mv {} {}.captured \; git -C ../mesh-host archive examples/foundation-first-node.lock | tar -x -C testdata/beside/mesh-host diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme b/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme new file mode 100644 index 00000000..d848240a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme @@ -0,0 +1,5 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that +# read neither XCURSOR_THEME nor the X resources. +[Icon Theme] +Name=Default +Inherits=Adwaita diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme b/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme new file mode 100644 index 00000000..b98c8093 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme @@ -0,0 +1 @@ +Adwaita:dark diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files b/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files new file mode 100644 index 00000000..8a782fb8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files @@ -0,0 +1 @@ +/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme new file mode 100644 index 00000000..349e5d6b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme @@ -0,0 +1 @@ +qt6ct diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select new file mode 100644 index 00000000..1e8b3149 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select @@ -0,0 +1 @@ +6 diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override new file mode 100644 index 00000000..a0da7db8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override @@ -0,0 +1 @@ +Fusion diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size b/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size @@ -0,0 +1 @@ +24 diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme b/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme new file mode 100644 index 00000000..cb9676b8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme @@ -0,0 +1 @@ +Adwaita diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini b/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini new file mode 100644 index 00000000..a535ba4e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini @@ -0,0 +1,9 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at +# every push; adwaita_appearance switches dark and light for the running session. +[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-cursor-theme-name=Adwaita +gtk-cursor-theme-size=24 +gtk-font-name=Inter 11 +gtk-application-prefer-dark-theme=1 diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf b/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf new file mode 100644 index 00000000..d2e813fd --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push. +# +# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with +# no preference it uses whichever backend happens to be installed: fine while gtk is the only one, +# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves +# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets. +[preferred] +default=gtk +# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers +# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop. +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf b/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf new file mode 100644 index 00000000..7f396d26 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf @@ -0,0 +1,29 @@ +[Appearance] +color_scheme_path=/usr/share/qt6ct/colors/darker.conf +custom_palette=true +icon_theme=Adwaita +standard_dialogs=default +style=Fusion + +[Fonts] +fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0" +general="Inter,11,-1,5,50,0,0,0,0,0" + +[Interface] +activate_item_on_single_click=1 +buttonbox_layout=0 +cursor_flash_time=1000 +dialog_buttons_have_icons=1 +double_click_interval=400 +gui_effects=@Invalid() +keyboard_scheme=2 +menus_have_icons=true +show_shortcuts_in_context_menus=true +stylesheets=@Invalid() +toolbutton_style=4 +underline_shortcut=1 +wheel_scroll_lines=3 + +[Troubleshooting] +force_raster_widgets=1 +ignored_applications=@Invalid() diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal b/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal new file mode 100644 index 00000000..864b3cf6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal @@ -0,0 +1,10 @@ +# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is +# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the +# module's default; adwaita_appearance switches it for a session. +gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true +gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true +gsettings set org.gnome.desktop.interface cursor-size 24 || true +gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true +gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true diff --git a/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal b/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal new file mode 100644 index 00000000..02e7ec33 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal @@ -0,0 +1,3 @@ +! adwaita: the cursor, for X programs that take it from the resources. +Xcursor.theme: Adwaita +Xcursor.size: 24 diff --git a/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured b/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured index c02971ea..bf81c833 100644 --- a/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured @@ -1,6 +1,7 @@ { "module": "adwaita", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -10,27 +11,79 @@ "adwaita_icons", "adwaita_portal_check" ], - "environment": { - "variables": { - "GTK_THEME": "Adwaita:dark", - "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", - "QT_QPA_PLATFORMTHEME": "qt6ct", - "QT_STYLE_OVERRIDE": "Fusion", - "QT_SELECT": "6", - "XCURSOR_THEME": "Adwaita", - "XCURSOR_SIZE": "24" - } - }, - "shell": [ + "items": [ { - "for": "xresources", - "slot": "normal", - "code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" + "collection": "environment", + "name": "gtk-theme", + "attributes": { + "kind": "variable", + "name": "GTK_THEME" + }, + "template": "files/env-gtk-theme" }, { - "for": "xinitrc", - "slot": "normal", - "code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n" + "collection": "environment", + "name": "gtk2-rc-files", + "attributes": { + "kind": "variable", + "name": "GTK2_RC_FILES" + }, + "template": "files/env-gtk2-rc-files" + }, + { + "collection": "environment", + "name": "qt-qpa-platformtheme", + "attributes": { + "kind": "variable", + "name": "QT_QPA_PLATFORMTHEME" + }, + "template": "files/env-qt-qpa-platformtheme" + }, + { + "collection": "environment", + "name": "qt-style-override", + "attributes": { + "kind": "variable", + "name": "QT_STYLE_OVERRIDE" + }, + "template": "files/env-qt-style-override" + }, + { + "collection": "environment", + "name": "qt-select", + "attributes": { + "kind": "variable", + "name": "QT_SELECT" + }, + "template": "files/env-qt-select" + }, + { + "collection": "environment", + "name": "xcursor-theme", + "attributes": { + "kind": "variable", + "name": "XCURSOR_THEME" + }, + "template": "files/env-xcursor-theme" + }, + { + "collection": "environment", + "name": "xcursor-size", + "attributes": { + "kind": "variable", + "name": "XCURSOR_SIZE" + }, + "template": "files/env-xcursor-size" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + }, + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" } ], "resources": [ @@ -65,7 +118,7 @@ "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + "template": "files/gtk-settings.ini" }, { "id": "gtk4", @@ -73,7 +126,7 @@ "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + "template": "files/gtk-settings.ini" }, { "id": "qt6ct", @@ -81,7 +134,7 @@ "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", "owner": "${machine:account}", "mode": "0644", - "content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n" + "template": "files/qt6ct.conf" }, { "id": "portals", @@ -89,7 +142,7 @@ "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n" + "template": "files/portals.conf" }, { "id": "cursor", @@ -97,7 +150,7 @@ "path": "${machine:account-home}/.icons/default/index.theme", "owner": "${machine:account}", "mode": "0644", - "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n" + "template": "files/cursor-index.theme" } ], "build": { @@ -112,6 +165,11 @@ "loads": [ "adwaita-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names b/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names new file mode 100644 index 00000000..29cab310 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names @@ -0,0 +1,4 @@ +# The names the artifact store had before, from the setting older-store-names, written by the mesh. +# artifact_store_references reads every definition under these too (novox/hq issue 323). +# Replaced on every push; change the setting, never this file. +older=${config:older-store-names} diff --git a/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured b/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured index d17ac5d2..208329cd 100644 --- a/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured @@ -13,19 +13,21 @@ "artifact_store_references", "artifact_store_collect" ], - "settings": { - "older-store-names": { - "kind": "preference", - "default": "none", - "why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)" - } - }, + "config": { + "older-store-names": { + "type": "string", + "description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).", + "pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*", + "default": "none" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "store-names", @@ -33,7 +35,7 @@ "path": "${dir:state}/store-names", "mode": "0600", "trusted": false, - "content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n" + "template": "files/store-names" } ], "build": { @@ -52,6 +54,11 @@ "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight new file mode 100755 index 00000000..1a3ec312 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight @@ -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}%" diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display new file mode 100755 index 00000000..0b7d21b4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display @@ -0,0 +1,41 @@ +#!/bin/bash +# zephyrus-display primary | order — the laptop's internal panel among the outputs. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# primary makes the internal panel X's primary output, where the bars' tray goes. +# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the +# internal panel, even ones to the first external output, or all to the panel when it +# is alone. The predecessor's orden-workspaces. +# +# The panel is found, not named: the connected output whose name starts with eDP. The predecessor +# wrote eDP-1, which is what this model calls it today and not a promise. +set -u +here=$(dirname "$0") +panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }') +[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; } +case "${1:-}" in +primary) + exec xrandr --output "$panel" --primary + ;; +order) + external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }') + [ -n "$external" ] || external=$panel + # i3's workspace list, one object per workspace; num and output read from each. No jq: the + # fields are flat strings and numbers, and rect, the only nested value, holds neither name. + i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' | + while read -r ws; do + num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2) + out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4) + [ -n "$num" ] && [ "$num" -ge 0 ] || continue + if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi + [ "$out" = "$dest" ] && continue + i3-msg "workspace number $num; move workspace to output $dest" >/dev/null + done + if [ "$external" = "$panel" ]; then + "$here/zephyrus-notify" 7780 "Workspaces: all on the panel" + else + "$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external" + fi + ;; +*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;; +esac diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify new file mode 100755 index 00000000..aee878bd --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify @@ -0,0 +1,20 @@ +#!/bin/bash +# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set +# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once +# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module. +# +# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the +# predecessor's listener ran twice after one, showing every change twice. +set -u +here=$(dirname "$0") +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +levels=(Off Low Med High) +dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null | + while read -r line; do + if [[ $line =~ int32\ ([0-9]+) ]]; then + v=${BASH_REMATCH[1]} + "$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}" + fi + done diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media new file mode 100755 index 00000000..8b77d79a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media @@ -0,0 +1,27 @@ +#!/bin/bash +# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short +# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can +# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a +# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is +# reached; one that does not says so. +set -u +here=$(dirname "$0") +action=${1:-play-pause} +case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +if ! playerctl status >/dev/null 2>&1; then + "$here/zephyrus-notify" 7777 "Media: no player" + exit 0 +fi +playerctl "$action" 2>/dev/null +if [ "$action" = play-pause ]; then + sleep 0.2 + [ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused +else + label=${action^} +fi +"$here/zephyrus-notify" 7777 "Media: $label" diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify new file mode 100755 index 00000000..1e8273f9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify @@ -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 diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session new file mode 100755 index 00000000..0f21f919 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session @@ -0,0 +1,46 @@ +#!/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" +# The session's own window manager says best which display and authority the session uses: read +# them from its environment, as the desktop modules' session finder does. Then logind, then any +# process of this account that has a display. +from_environ() { + env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1 + d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1) + [ -n "$d" ] || return 1 + export DISPLAY="$d" + a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1) + [ -n "$a" ] && export XAUTHORITY="$a" + return 0 +} +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do + from_environ "$pid" && break + done +fi +if [ -z "${DISPLAY:-}" ]; then + 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 + from_environ "$pid" && break + done +fi +: "${XAUTHORITY:=${HOME}/.Xauthority}" +export XAUTHORITY +exec "$@" diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad new file mode 100755 index 00000000..637be369 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad @@ -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 diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured index 13a2fbf8..d1ee8d3b 100644 --- a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured @@ -1,6 +1,8 @@ { "module": "asus-zephyrus-g14", "version": "1", + "moved": true, + "templates": "templates", "capabilities": [ "package-manager", "service-manager" @@ -52,80 +54,85 @@ "id": "scripts", "type": "archive", "path": "/usr/local/lib/asus-zephyrus-g14", - "artifact": "scripts" + "artifact": "scripts", + "unknown": "program" }, { "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" + "template": "templates/modprobe.d/g14-nvidia-power.conf" }, { "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" + "template": "templates/modprobe.d/video-brightness-switch.conf" }, { "id": "suspend-drop-ins", "type": "directory", "path": "/etc/systemd/system/systemd-suspend.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "hibernate-drop-ins", "type": "directory", "path": "/etc/systemd/system/systemd-hibernate.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "suspend-then-hibernate-drop-ins", "type": "directory", "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" }, { "id": "powerd-drop-ins", "type": "directory", "path": "/etc/systemd/system/nvidia-powerd.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf" }, { "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" + "template": "templates/udev/rules.d/90-backlight.rules" }, { "id": "udev", @@ -144,14 +151,15 @@ "id": "upower-drop-ins", "type": "directory", "path": "/etc/UPower/UPower.conf.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf" }, { "id": "upower", @@ -167,14 +175,30 @@ "id": "xorg-drop-ins", "type": "directory", "path": "/etc/X11/xorg.conf.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf" + } + ], + "items": [ + { + "collection": "shell:after-wake:normal", + "template": "templates/after-wake" + }, + { + "collection": "contribution:node-hotkeys:trigger", + "template": "templates/hotkeys-triggers" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "templates/display-session" } ], "build": { @@ -194,31 +218,12 @@ "name": "scripts", "kind": "archive", "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] - }, - "contributions": [ - { - "seat": "node-hotkeys", - "kind": "trigger", - "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media 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" - }, - { - "seat": "node-display-session", - "kind": "config", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n" - }, - { - "seat": "node-display-session", - "kind": "config", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" - } - ], - "shell": [ - { - "for": "after-wake", - "slot": "normal", - "code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n" - } - ] + } } diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf new file mode 100644 index 00000000..f3d6bef5 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little, +# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's. +[UPower] +UsePercentageForPolicy=true +PercentageLow=15.0 +PercentageCritical=10.0 +PercentageAction=7.0 +CriticalPowerAction=Suspend +AllowRiskyCriticalPowerAction=true diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf new file mode 100644 index 00000000..f30830c6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf @@ -0,0 +1,12 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The touchpad's settings, applied by X every time the device appears — at login and after every +# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran +# xinput after a resume as a named person on a guessed display. +Section "InputClass" + Identifier "asus-zephyrus-g14 touchpad" + MatchIsTouchpad "on" + Option "Tapping" "on" + Option "NaturalScrolling" "true" + Option "AccelSpeed" "0.15" +EndSection diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake new file mode 100644 index 00000000..af60a9d9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake @@ -0,0 +1,7 @@ +# The touchpad's settings again after waking, in the operator's session: X applies the module's +# input class when the device appears, and this covers a wake that does not initialise it again. +# Runs as root from the power module; the reset itself runs as the session's owner. +sleep 2 +owner=$(ps -o user= -C i3 | head -n 1) +[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset +true diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session new file mode 100644 index 00000000..38f9e5bd --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session @@ -0,0 +1,30 @@ +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are +# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for +# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's. + +# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the +# screenshot grabs the pointer to select a region, which fails while the key is still held. +bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the +# external output. +bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order + +# The keyboard backlight's level, shown when it changes. +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify +# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the +# mesh: replaced at every push. Once the controller places contributions to node-display-session +# (ADR 0210), these become the module's contribution instead of a file in i3's directory. +# +# The model's panel and touchpad. + +# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where +# is the display server's (autorandr, run at every session start). +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary + +# The touchpad's settings again, by hand. X applies them itself whenever the device appears. +bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers new file mode 100644 index 00000000..5e4d165c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers @@ -0,0 +1,10 @@ +# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness, +# and the touchpad key. Each runs this module's own script, as the operator's account. +KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause +KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous +KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next +KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf new file mode 100644 index 00000000..78941022 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf @@ -0,0 +1,16 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics). +# +# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends +# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to +# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is +# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up. +# +# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still +# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this +# module's drop-ins on the sleep services ask for. +# +# A change here applies when the driver next loads: at the next boot. +options nvidia NVreg_PreserveVideoMemoryAllocations=1 +options nvidia NVreg_DynamicPowerManagement=0x00 diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf new file mode 100644 index 00000000..5b2de1d5 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf @@ -0,0 +1,6 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it +# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf). +# Applies when the module next loads: at the next boot. +options video brightness_switch_enabled=0 diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf new file mode 100644 index 00000000..4466dd08 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change +# of power source, and asusd starts it on mains. It runs only when the kernel command line says +# zephyrus.nvidia-powerd — an explicit opt-in, at boot. +[Unit] +ConditionKernelCommandLine=zephyrus.nvidia-powerd diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..1f56e3b8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-hibernate.service nvidia-resume.service diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..5813188a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 00000000..92a01117 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend.service nvidia-resume.service diff --git a/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules new file mode 100644 index 00000000..0a9a894d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules @@ -0,0 +1,5 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The backlights are writable by the video group, so the brightness keys and the module's brightness tool +# move the panel without root. +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured b/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured index 9f225050..122b94e7 100644 --- a/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured @@ -47,17 +47,20 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "trail", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "spool", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" } ], "capabilities": [ diff --git a/testdata/beside/mesh-catalog/modules/baserow/files/config.json b/testdata/beside/mesh-catalog/modules/baserow/files/config.json new file mode 100644 index 00000000..955ac244 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/baserow/files/config.json @@ -0,0 +1,4 @@ +{ + "password": "${secret:admin}", + "host": "${bound:route:name}" +} diff --git a/testdata/beside/mesh-catalog/modules/baserow/files/server.env b/testdata/beside/mesh-catalog/modules/baserow/files/server.env new file mode 100644 index 00000000..7b716b42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/baserow/files/server.env @@ -0,0 +1,7 @@ +DATABASE_HOST=${bound:postgres-database:at} +DATABASE_PORT=${bound:postgres-database:port} +DATABASE_NAME=${bound:postgres-database:as} +DATABASE_USER=${bound:postgres-database:as} +DATABASE_PASSWORD_FILE=/run/secrets/database +DISABLE_EMBEDDED_PSQL=true +BASEROW_PUBLIC_URL=https://${bound:route:name} diff --git a/testdata/beside/mesh-catalog/modules/baserow/module.json.captured b/testdata/beside/mesh-catalog/modules/baserow/module.json.captured index f80fab30..81196218 100644 --- a/testdata/beside/mesh-catalog/modules/baserow/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/baserow/module.json.captured @@ -46,31 +46,54 @@ } ] }, + "config": { + "host": { + "type": "string", + "description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.", + "trusted": true, + "default": "${bound:route:name}" + }, + "email": { + "type": "string", + "description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0755", - "owner": "9999:9999" + "owner": "9999:9999", + "unknown": "program" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n" + "template": "files/server.env" }, { "id": "net", @@ -102,8 +125,10 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["host", "email", "url"], + "protected": ["password"] } ], "build": { @@ -122,6 +147,11 @@ "MESH_BASEROW_URL": "http://127.0.0.1:${port:80}", "MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json b/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json new file mode 100644 index 00000000..e182cc3d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird b/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird new file mode 100755 index 00000000..3ce088a8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird @@ -0,0 +1,10 @@ +#!/bin/sh +# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push. +# +# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars, +# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do +# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a +# second start while one runs only hands over to it. What the preparation could not do it says here, and +# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back. +/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2 +exec /opt/betterbird/betterbird "$@" diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json b/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json new file mode 100644 index 00000000..70204462 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json @@ -0,0 +1,6 @@ +{ + "identities": {}, + "calendars": {}, + "address-books": {}, + "filters": {} +} diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop b/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop new file mode 100644 index 00000000..e409fcf8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop @@ -0,0 +1,25 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Betterbird +GenericName=Mail Client +Comment=Send and receive mail with Betterbird +Exec=/usr/local/bin/betterbird %u +Icon=/opt/betterbird/chrome/icons/default/default128.png +Terminal=false +Categories=Network;Email; +MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid; +StartupNotify=true +StartupWMClass=eu.betterbird.Betterbird +Keywords=Email;E-mail;Calendar;Thunderbird; +Actions=ComposeMessage;OpenAddressBook; +# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry: +# it starts Betterbird through the module's start, and names the icon in the installation, with no link. + +[Desktop Action ComposeMessage] +Name=Write new message +Exec=/usr/local/bin/betterbird -compose + +[Desktop Action OpenAddressBook] +Name=Open address book +Exec=/usr/local/bin/betterbird -addressbook diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json b/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json new file mode 100644 index 00000000..45fd9732 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json @@ -0,0 +1,3 @@ +{ + "prefs": {} +} diff --git a/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json b/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json new file mode 100644 index 00000000..4772f099 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json @@ -0,0 +1,3 @@ +{ + "tags": {} +} diff --git a/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured b/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured index 45383ab4..891f8832 100644 --- a/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured @@ -33,13 +33,59 @@ "betterbird_drift", "betterbird_reminder_test" ], + "config": { + "prefs": { + "type": "object", + "description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).", + "trusted": true, + "default": {} + }, + "accounts": { + "type": "object", + "description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).", + "trusted": true, + "default": {} + }, + "identities": { + "type": "object", + "description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "calendars": { + "type": "object", + "description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "address-books": { + "type": "object", + "description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "filters": { + "type": "object", + "description": "The message filters of each account, by account: what each matches and what it does with the mail.", + "trusted": true, + "default": {} + }, + "tags": { + "type": "object", + "description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.", + "trusted": true, + "default": {} + } + }, "resources": [ { "id": "configuration-dir", "type": "directory", "path": "${machine:account-home}/.config/betterbird", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out", + "knows": ["accounts-record.json", "configuration-record.json", "kept/"] }, { "id": "prefs", @@ -48,10 +94,10 @@ "owner": "${machine:account}", "mode": "0644", "merge": "json", - "takes-settings": [ + "takes-config": [ "prefs" ], - "content": "{\n \"prefs\": {}\n}\n", + "template": "files/prefs.json", "trusted": true }, { @@ -61,10 +107,10 @@ "owner": "${machine:account}", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "accounts" ], - "content": "{\n \"accounts\": {}\n}\n", + "template": "files/accounts.json", "trusted": true }, { @@ -74,13 +120,13 @@ "owner": "${machine:account}", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "identities", "calendars", "address-books", "filters" ], - "content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n", + "template": "files/configuration.json", "trusted": true }, { @@ -90,11 +136,11 @@ "owner": "${machine:account}", "mode": "0644", "merge": "json", - "takes-settings": [ + "takes-config": [ "tags" ], - "content": "{\n \"tags\": {}\n}\n", - "trusted": false + "template": "files/tags.json", + "trusted": true }, { "id": "community-package", @@ -106,45 +152,50 @@ "id": "installation", "type": "archive", "path": "/opt/betterbird", - "artifact": "archive" + "artifact": "archive", + "unknown": "program" }, { "id": "owl", "type": "archive", "path": "/opt/betterbird/owl", - "artifact": "owl" + "artifact": "owl", + "unknown": "program" }, { "id": "policies", "type": "archive", "path": "/opt/betterbird/distribution", - "artifact": "distribution" + "artifact": "distribution", + "unknown": "program" }, { "id": "spelling", "type": "archive", "path": "/opt/betterbird/defaults/pref", - "artifact": "defaults" + "artifact": "defaults", + "unknown": "program" }, { "id": "preparation", "type": "archive", "path": "/usr/local/lib/mesh-betterbird", - "artifact": "tools" + "artifact": "tools", + "unknown": "program" }, { "id": "start", "type": "file", "path": "/usr/local/bin/betterbird", "mode": "0755", - "content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n" + "template": "files/betterbird" }, { "id": "launcher", "type": "file", "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", "mode": "0644", - "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n" + "template": "files/eu.betterbird.Betterbird.desktop" } ], "build": { @@ -191,6 +242,11 @@ "name": "defaults", "kind": "archive", "from": "install/defaults-pref" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/brother/files/bucket.json b/testdata/beside/mesh-catalog/modules/brother/files/bucket.json new file mode 100644 index 00000000..b72b2331 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/brother/files/bucket.json @@ -0,0 +1,3 @@ +{ + "bucket": "${bound:s3-bucket:bucket}" +} diff --git a/testdata/beside/mesh-catalog/modules/brother/files/fetch.json b/testdata/beside/mesh-catalog/modules/brother/files/fetch.json new file mode 100644 index 00000000..abef425f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/brother/files/fetch.json @@ -0,0 +1,3 @@ +{ + "mesh-range": "${machine:mesh-range}" +} diff --git a/testdata/beside/mesh-catalog/modules/brother/files/settings.json b/testdata/beside/mesh-catalog/modules/brother/files/settings.json new file mode 100644 index 00000000..80aed322 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/brother/files/settings.json @@ -0,0 +1,9 @@ +{ + "scanner": "", + "scan-folder": "", + "printer": "", + "printer-certificate": "", + "resolution": 300, + "color-mode": "color", + "crop": true +} diff --git a/testdata/beside/mesh-catalog/modules/brother/files/store.json b/testdata/beside/mesh-catalog/modules/brother/files/store.json new file mode 100644 index 00000000..8c2075da --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/brother/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/testdata/beside/mesh-catalog/modules/brother/module.json.captured b/testdata/beside/mesh-catalog/modules/brother/module.json.captured new file mode 100644 index 00000000..df2bed16 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/brother/module.json.captured @@ -0,0 +1,314 @@ +{ + "module": "brother", + "version": "1", + "requires": [ + { + "name": "s3-bucket", + "shares": [ + { + "to": "seat:node-files", + "methods": [ + "put" + ] + } + ] + } + ], + "invokes": [ + "seat:node-files.upload" + ], + "binds": { + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "s3-bucket": "${dir:state}/store.secret" + }, + "config": { + "scanner": { + "type": "string", + "description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.", + "pattern": "^https?://.+", + "trusted": true, + "required": true + }, + "scan-folder": { + "type": "string", + "description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.", + "pattern": "^/.+$", + "trusted": true, + "required": true + }, + "printer": { + "type": "string", + "description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.", + "pattern": "^[A-Za-z0-9.:-]+$", + "trusted": true, + "required": true + }, + "printer-certificate": { + "type": "string", + "description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons; several, separated by commas, when the printer serves more than one (it serves an ECDSA and an RSA certificate for one name). The module trusts those certificates alone and refuses any other; the operator's, with no default.", + "pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}(, *([0-9A-F]{2}:){31}[0-9A-F]{2})*$", + "trusted": true, + "required": true + }, + "resolution": { + "type": "number", + "description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.", + "enum": [ + 100, + 200, + 300, + 600 + ], + "trusted": false, + "default": 300 + }, + "color-mode": { + "type": "string", + "description": "Whether the scan is in colour or in grays: color or gray.", + "enum": [ + "color", + "gray" + ], + "trusted": false, + "default": "color" + }, + "crop": { + "type": "boolean", + "description": "Whether the document is cut out of the platen's white lid; false files the whole platen. A feeder scan is never cropped.", + "trusted": false, + "default": true + } + }, + "own-secrets": { + "admin-password": { + "path": "${dir:state}/admin-password.secret", + "issued-by": "outside", + "prompt": "the admin password of the Brother printer", + "owner": "brother-device" + }, + "broker-device": { + "path": "${dir:state}/broker-device.secret", + "owner": "brother-device" + } + }, + "streams": [ + { + "name": "device-asks", + "events": [ + "device.asked" + ], + "max-age-days": 1, + "max-mib": 8, + "retention": "work-queue", + "max-deliveries": 3, + "duplicates": 24 + } + ], + "state": [ + { + "name": "device-answers", + "ttl-seconds": 600 + } + ], + "seats": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list", + "document" + ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "status", + "settings", + "print", + "upload" + ] + } + ], + "claims": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list", + "document" + ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "status", + "settings", + "print", + "upload" + ] + } + ], + "emits": [ + "scan.added" + ], + "tools": [ + "scanner.scan", + "scanner.status", + "scanner.list", + "scanner.document", + "printer.status", + "printer.settings", + "printer.print", + "printer.upload", + "brother_health" + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "brother-device", + "shell": "/usr/bin/nologin" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "owner": "${machine:account}", + "mode": "0644", + "takes-config": [ + "scanner", + "scan-folder", + "printer", + "printer-certificate", + "resolution", + "color-mode", + "crop" + ], + "merge": "json", + "template": "files/settings.json", + "trusted": true + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "owner": "brother-device", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "bucket", + "type": "file", + "path": "${dir:state}/bucket.json", + "mode": "0644", + "template": "files/bucket.json" + }, + { + "id": "fetch", + "type": "file", + "path": "${dir:state}/fetch.json", + "owner": "brother-device", + "mode": "0600", + "template": "files/fetch.json" + }, + { + "id": "device", + "type": "process", + "name": "brother-device", + "artifact": "tools", + "run": [ + "./brother", + "device" + ], + "user": "brother-device", + "env": { + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", + "BROTHER_BROKER": "${dir:state}/broker-device.secret", + "BROTHER_STORE": "${dir:state}/store.json", + "BROTHER_FETCH": "${dir:state}/fetch.json" + }, + "restart-on": [ + "settings", + "store", + "fetch" + ], + "bus": { + "queues": [ + "device-asks" + ], + "state": [ + "device-answers" + ], + "emits": [ + "scan.added" + ], + "invokes": [ + "seat:document-converter.convert" + ] + }, + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "brother-watch", + "artifact": "tools", + "run": [ + "./brother", + "watch" + ], + "user": "brother-device", + "health": { + "kind": "tool", + "tool": "brother_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/brother", + "binary": "brother", + "loads": [ + "brother" + ], + "env": { + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_BUCKET": "${dir:state}/bucket.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/buildah/module.json.captured b/testdata/beside/mesh-catalog/modules/buildah/module.json.captured new file mode 100644 index 00000000..70ddd0a4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/buildah/module.json.captured @@ -0,0 +1,21 @@ +{ + "module": "buildah", + "version": "1", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "provides": [ + { + "name": "rootless-image-build", + "reach": "machine" + } + ], + "resources": [ + { + "id": "buildah", + "type": "package", + "package": "buildah" + } + ] +} diff --git a/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor b/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor new file mode 100755 index 00000000..72576d2f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor @@ -0,0 +1,58 @@ +#!/bin/sh +# The mesh's internal certificate authority, trusted by this machine. +# +# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147). +# Editing it here lasts until the next apply. +# +# There is no prior trust to verify the fetch against — this is the thing that +# establishes it — so it is made over the mesh's own private network, which is +# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the +# route proxy fetch this root for itself). What comes back is checked here: a +# body that is not a certificate is refused now, rather than believed and then +# failed by whatever reads the trust store next. +set -eu + +ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}' +ANCHORS=/etc/ca-certificates/trust-source/anchors +ANCHOR="$ANCHORS/mesh-internal-ca.crt" + +# Arch's layout, said out loud rather than assumed: a machine that keeps its +# anchors elsewhere fails here, visibly, instead of writing a file nothing +# reads. That failure is the signal that this belongs in the host, where one +# operating system's difference lives (novox/hq ADR 0147, option 2). +[ -d "$ANCHORS" ] || { + echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2 + exit 1 +} + +case "${1:-}" in +install) + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + # The authority may still be starting, or this machine may have come up + # before it: two minutes of asking, then an honest failure. + n=0 + while [ "$n" -lt 60 ]; do + if curl --fail --silent --show-error --insecure --max-time 10 \ + --output "$tmp" "$ROOTS" && + grep -q 'BEGIN CERTIFICATE' "$tmp"; then + install -m 0644 "$tmp" "$ANCHOR" + update-ca-trust + exit 0 + fi + n=$((n + 1)) + sleep 2 + done + echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2 + exit 1 + ;; +remove) + # What stopping the unit does, and therefore what being unassigned does. + rm -f "$ANCHOR" + update-ca-trust + ;; +*) + echo "usage: $(basename "$0") install|remove" >&2 + exit 2 + ;; +esac diff --git a/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service b/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service new file mode 100644 index 00000000..2411560c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service @@ -0,0 +1,16 @@ +[Unit] +Description=The mesh's internal certificate authority, trusted by this machine +# novox/hq ADR 0147. Starting this unit places the mesh's root among this +# machine's trust anchors; stopping it takes the root away again, which is what +# the host does when the module is no longer assigned here. +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/anchor install +ExecStop=${dir:state}/anchor remove + +[Install] +WantedBy=multi-user.target diff --git a/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured b/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured index 1f7baf81..b3c9dc93 100644 --- a/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured @@ -25,21 +25,22 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "anchor", "type": "file", "path": "${dir:state}/anchor", "mode": "0755", - "content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n" + "template": "files/anchor" }, { "id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-ca-trust.service", "mode": "0644", - "content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "files/mesh-ca-trust.service" }, { "id": "trust", @@ -52,5 +53,14 @@ "unit" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account b/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account new file mode 100644 index 00000000..b081a6c3 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account @@ -0,0 +1 @@ +${machine:agent-account} diff --git a/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent b/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent new file mode 100755 index 00000000..e3d330f2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent @@ -0,0 +1,13 @@ +#!/bin/sh +# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this +# machine. Written whole at every push: an edit here is overwritten. +agent='${machine:agent-account}' +if [ "$(id -un)" = "$agent" ]; then + exec claude "$@" +fi +# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The +# operator's override of the guard travels only when it is set in this shell, as it would reach claude. +if [ -n "$MESH_GUARD_OVERRIDE" ]; then + exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@" +fi +exec sudo -iu "$agent" claude "$@" diff --git a/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json b/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json new file mode 100644 index 00000000..7fd72bd4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json @@ -0,0 +1,4 @@ +{ + "node": "${machine:name}", + "console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp" +} diff --git a/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json b/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json new file mode 100644 index 00000000..8284e660 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json @@ -0,0 +1,10 @@ +{ + "role": "", + "mcp_servers": {}, + "managed_settings": {}, + "instructions": {}, + "output_styles": {}, + "skills": {}, + "commands": {}, + "agents": {} +} diff --git a/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal b/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal new file mode 100644 index 00000000..b90b85f9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal @@ -0,0 +1,12 @@ +# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the +# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where +# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs +# the real claude: exec, sudo and its sh see no alias. +if [[ -r /etc/claude-code/agent-account ]]; then + () { + local agent=$(/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do + export "$pair" + done + [ -n "$DISPLAY" ] && break + set -- /tmp/.X11-unix/X* + if [ $# -eq 1 ] && [ -S "$1" ]; then + DISPLAY=":${1##*/X}" + export DISPLAY + else + sleep 1 + fi +done +if [ -z "$XAUTHORITY" ] && [ -r "$HOME/.Xauthority" ]; then + XAUTHORITY="$HOME/.Xauthority" + export XAUTHORITY +fi +# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits +# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from +# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly, +# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule) +# and lets go at once. Exit status 1 is "held" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own +# error. Another daemon winning the gap between look and start costs one restart, not a loop. +lock="${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock" +while [ -e "$lock" ]; do + flock -n "$lock" true + [ $? -eq 1 ] || break + sleep 2 +done +PATH="/usr/local/lib/mesh-clipmenu:$PATH" +export PATH +exec clipmenud diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel b/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel new file mode 100755 index 00000000..10f9bbd9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel @@ -0,0 +1,20 @@ +#!/bin/sh +# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push. +# +# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an +# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes, +# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is +# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that +# asks on its main thread freezes. So a read goes ahead only when the selection offers text. +case " $* " in +*" -o "* | *" --output "*) + selection=clipboard + case " $* " in + *" --primary "* | *" -p "*) selection=primary ;; + *" --secondary "* | *" -s "*) selection=secondary ;; + esac + targets=$(timeout 1 xclip -selection "$selection" -t TARGETS -o 2>/dev/null) || exit 1 + printf '%s\n' "$targets" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1 + ;; +esac +exec /usr/bin/xsel "$@" diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength new file mode 100644 index 00000000..60d3b2f4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength @@ -0,0 +1 @@ +15 diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips new file mode 100644 index 00000000..1b79f38e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips @@ -0,0 +1 @@ +500 diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections new file mode 100644 index 00000000..613d81a1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections @@ -0,0 +1 @@ +clipboard diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf b/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf new file mode 100644 index 00000000..41835e6e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf @@ -0,0 +1,5 @@ +# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at +# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which +# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the +# clipboard. +bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf b/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf new file mode 100644 index 00000000..799e22d3 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf @@ -0,0 +1,14 @@ +# clipmenud in the operator's account (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# The package's unit starts clipmenud with no DISPLAY; the module's launcher finds the display and puts the +# text-only xsel first on the PATH. The settings repeat the module's environment, which the account's +# service manager does not read. +[Unit] +StartLimitIntervalSec=0 + +[Service] +RestartSec=2s +Environment=CM_SELECTIONS=clipboard +Environment=CM_MAX_CLIPS=500 +Environment=CM_HISTLENGTH=15 +ExecStart= +ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session diff --git a/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured b/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured index e157350c..30ec9c0c 100644 --- a/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured @@ -1,6 +1,7 @@ { "module": "clipmenu", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -22,18 +23,38 @@ "clipmenu_clear", "clipmenu_delete" ], - "environment": { - "variables": { - "CM_SELECTIONS": "clipboard", - "CM_MAX_CLIPS": "500", - "CM_HISTLENGTH": "15" - } - }, - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\n# Through the module's xsel, which reads only a selection offering text (see the README).\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\" clipmenud &\n" + "collection": "environment", + "name": "cm-selections", + "attributes": { + "kind": "variable", + "name": "CM_SELECTIONS" + }, + "template": "files/env-cm-selections" + }, + { + "collection": "environment", + "name": "cm-max-clips", + "attributes": { + "kind": "variable", + "name": "CM_MAX_CLIPS" + }, + "template": "files/env-cm-max-clips" + }, + { + "collection": "environment", + "name": "cm-histlength", + "attributes": { + "kind": "variable", + "name": "CM_HISTLENGTH" + }, + "template": "files/env-cm-histlength" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-clipmenu.conf" } ], "resources": [ @@ -53,7 +74,42 @@ "type": "file", "path": "/usr/local/lib/mesh-clipmenu/xsel", "mode": "0755", - "content": "#!/bin/sh\n# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an\n# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes,\n# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is\n# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that\n# asks on its main thread freezes. So a read goes ahead only when the selection offers text.\ncase \" $* \" in\n*\" -o \"* | *\" --output \"*)\n\tselection=clipboard\n\tcase \" $* \" in\n\t*\" --primary \"* | *\" -p \"*) selection=primary ;;\n\t*\" --secondary \"* | *\" -s \"*) selection=secondary ;;\n\tesac\n\ttargets=$(timeout 1 xclip -selection \"$selection\" -t TARGETS -o 2>/dev/null) || exit 1\n\tprintf '%s\\n' \"$targets\" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1\n\t;;\nesac\nexec /usr/bin/xsel \"$@\"\n" + "template": "files/bin/xsel" + }, + { + "id": "daemon-dropins", + "type": "directory", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "daemon-dropin", + "type": "file", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/systemd/clipmenud-mesh.conf" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", + "mode": "0755", + "template": "files/bin/clipmenud-session" + }, + { + "id": "daemon", + "type": "service", + "unit": "clipmenud.service", + "scope": "user", + "user": "${machine:account}", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } } ], "build": { @@ -68,14 +124,12 @@ "loads": [ "clipmenu-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n" - } - ] + } } diff --git a/testdata/beside/mesh-catalog/modules/confluence/files/config.json b/testdata/beside/mesh-catalog/modules/confluence/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/confluence/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/confluence/module.json.captured b/testdata/beside/mesh-catalog/modules/confluence/module.json.captured index 18ff0ea6..caa0d1be 100644 --- a/testdata/beside/mesh-catalog/modules/confluence/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/confluence/module.json.captured @@ -5,20 +5,45 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the confluence tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the confluence tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] } ], "capabilities": [ @@ -40,6 +65,11 @@ "MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token", "MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/de-spiegel/module.json.captured b/testdata/beside/mesh-catalog/modules/de-spiegel/module.json.captured deleted file mode 100644 index 6ace95ec..00000000 --- a/testdata/beside/mesh-catalog/modules/de-spiegel/module.json.captured +++ /dev/null @@ -1,70 +0,0 @@ -{ - "module": "de-spiegel", - "version": "1", - "slug": "spiegel", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "de-spiegel", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:state}/route.json" - }, - "own-secrets": { - "smtp-user": "${dir:state}/smtp-user.secret", - "smtp-pass": "${dir:state}/smtp-pass.secret" - }, - "listens": [ - { - "name": "web", - "port": 35621, - "protocol": "tcp", - "from": "mesh", - "why": "the de-spiegel site and its /contact endpoint over http; its public name is a route grant, and route-proxy reaches it on this published port" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "server-env", - "type": "file", - "path": "${dir:state}/server.env", - "mode": "0600", - "content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n" - }, - { - "id": "net", - "type": "network", - "name": "de-spiegel" - }, - { - "id": "server", - "type": "container", - "name": "de-spiegel", - "image": "registry-api.novox.be/novox/de-spiegel@sha256:e144b72ce9c145870470d765343549f2c60211728cd118b9ff0e4029f36342ba", - "network": "de-spiegel", - "env-file": [ - "${dir:state}/server.env" - ], - "ports": [ - "35621" - ], - "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change", - "names-on-purpose": { - "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" - } - } - ] -} diff --git a/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json b/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json new file mode 100644 index 00000000..1ef16fd8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json @@ -0,0 +1,3 @@ +{ + "desktop-machines": [] +} diff --git a/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured b/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured index 309efbb4..115ce620 100644 --- a/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured @@ -45,6 +45,14 @@ "tools": [ "desk_channel_status" ], + "config": { + "desktop-machines": { + "type": "list", + "description": "The machines, by node name, whose desktop shows the operator's questions and notifications, in the order they are tried; empty shows them on none.", + "trusted": true, + "default": [] + } + }, "resources": [ { "id": "account", @@ -58,7 +66,8 @@ "type": "directory", "mode": "0700", "owner": "desk-channel", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "settings", @@ -66,8 +75,9 @@ "path": "${dir:state}/settings.json", "mode": "0600", "owner": "desk-channel", + "takes-config": ["desktop-machines"], "merge": "json", - "content": "{\n \"desktop-machines\": []\n}\n" + "template": "files/settings.json" } ], "build": { @@ -85,6 +95,11 @@ "env": { "MESH_DESK_SETTINGS": "${dir:state}/settings.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured b/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured new file mode 100644 index 00000000..094f8336 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured @@ -0,0 +1,38 @@ +{ + "module": "dev-tools", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "go-toolchain", + "node-toolchain" + ], + "resources": [ + { + "id": "c-compiler", + "type": "package", + "package": "gcc" + }, + { + "id": "make", + "type": "package", + "package": "make" + }, + { + "id": "python", + "type": "package", + "package": "python" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "jq", + "type": "package", + "package": "jq" + } + ] +} diff --git a/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured b/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured index 3b7ce911..9b48041d 100644 --- a/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured @@ -5,7 +5,9 @@ "disk_load", "disk_top", "disk_filesystems", - "disk_devices" + "disk_folders", + "disk_devices", + "cpu_top" ], "build": { "artifacts": [ diff --git a/testdata/beside/mesh-catalog/modules/distribution/module.json.captured b/testdata/beside/mesh-catalog/modules/distribution/module.json.captured index e368b824..eb9f7c24 100644 --- a/testdata/beside/mesh-catalog/modules/distribution/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/distribution/module.json.captured @@ -50,13 +50,15 @@ "id": "state", "type": "directory", "path": "/var/lib/mesh/registry", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "registry-data", "type": "directory", "path": "/var/lib/mesh-registry", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "store", diff --git a/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf b/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf new file mode 100644 index 00000000..10f3926a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf @@ -0,0 +1,87 @@ +# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than +# patched, because this module owns the file and a patch would leave whatever +# was there before to be discovered later. +# +# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the +# `mesh-dns-resolver` seat, which more than one machine may hold, each answering +# the same names from the same roster. Every node and every container lists every +# holder and no public resolver, so whichever answers first gives the one answer. +# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the +# mesh's, rendered into each holder; no other copy lives on any machine. + +# What the mesh computed: one wildcard per machine — its name and everything +# under it — and the mesh's own suffix as a local domain, so a name under it is +# answered here or not at all and is never asked upstream. Rewritten whenever a +# machine joins or leaves, which is why the service below restarts on it: a +# reload makes dnsmasq re-read hosts files, not its configuration, and a +# wildcard is configuration. +conf-file=/etc/mesh-resolver/nodes.conf + +# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to +# the private address of the machine its module runs on and the port its +# answerer is published on. Nothing in a zone is answered here; what names a zone +# holds is the module's. Rewritten whenever a zone is declared or moves, and the +# service restarts on it for the same reason as above. +conf-file=/etc/mesh-resolver/zones.conf + +# Where it answers: this machine's private address, so every node — and every +# container on every node, which copies its machine's resolvers — can ask; and +# loopback, for this machine's own use. Never a LAN address: a device that is +# not a member cannot reach what the mesh's names point at, and a LAN's resolver +# is its router's (ADR 0194). +# +# Named as an ADDRESS and not as the interface that carries it, on purpose. A +# container's query is addressed to this address but arrives on the runtime's +# bridge, and dnsmasq checks every query against what it was told to answer on: +# an `interface=` line admits queries by the interface they arrive on, a +# `listen-address=` line by the address they are sent to. With `interface=mesh0` +# alone, a query from a container was received and dropped without a word +# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on. +# +# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic +# re-reads the machine's addresses on every change of any link and closes each +# listener whose interface that reading did not find; a reading made while the +# container runtime deletes a bridge's virtual links fails ("index gone"), and +# then every listener is closed until a later change reads cleanly. On +# 2026-10-09 the private address was left unbound for twenty minutes, the daemon +# running and silent. bind-interfaces binds each address once, at start, and +# never closes it. It cannot bind an address that is not there yet, so the unit +# waits for it (the drop-in below); a new private address restarts the unit, as +# this file changing always did. +bind-interfaces +listen-address=${machine:address} +listen-address=127.0.0.1 + +# **It must never read resolv.conf to find out where to forward.** This +# machine's resolv.conf names this resolver — so a resolver that read it for +# upstreams would find itself, and every query it could not answer locally would +# loop until its receive queue filled. That is not theoretical: it filled with +# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop +# impossible: the upstreams are the two lines below, and nothing on the machine +# can redirect them. The mesh's own names never reach them: the local= line in +# the file above stops them here, answered or refused. +no-resolv +server=1.1.1.1 +server=8.8.8.8 + +# Both upstreams validate DNSSEC and say so with the Authenticated Data bit, +# and this passes that bit down rather than dropping it, which dnsmasq does +# unless told. It does not validate itself: an answer's trust is the upstream's +# and the path to it, which is what a forwarding resolver's trust always was. +# Without it a program that refuses to run behind a resolver that does not +# validate — a mail system's admin does exactly that check at start — cannot +# use this resolver (novox/hq 04-ISSUES/171). +proxy-dnssec + +# A name without a dot is never forwarded, and reverse lookups of private ranges +# are answered here rather than asking the world who 10.x is. +domain-needed +bogus-priv + +# **No hosts file, and no operator's files.** This machine's /etc/hosts is its +# own — the hosts module's block and the operator's lines (ADR 0199) — and the +# mesh's resolver answers every node, so a line written for one machine's own +# programs must not become an answer for all of them. Every per-node resolver +# went wrong exactly here: a hosts file read once at start, an operator's old +# line beside the mesh's, a drop-in read from a directory nobody owned. +no-hosts diff --git a/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf b/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf new file mode 100644 index 00000000..96d8d7d6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf @@ -0,0 +1,14 @@ +# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push. +# +# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this +# machine's private address is there: it waits up to 80 seconds for it, and a start that found it +# missing is tried again every ten seconds rather than left failed. +[Unit] +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1' +Restart=on-failure +RestartSec=10s diff --git a/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured b/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured index d868d771..58cc0da5 100644 --- a/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured @@ -58,7 +58,8 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "package", @@ -70,20 +71,21 @@ "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n#\n# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the\n# `mesh-dns-resolver` seat, which more than one machine may hold, each answering\n# the same names from the same roster. Every node and every container lists every\n# holder and no public resolver, so whichever answers first gives the one answer.\n# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the\n# mesh's, rendered into each holder; no other copy lives on any machine.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to\n# the private address of the machine its module runs on and the port its\n# answerer is published on. Nothing in a zone is answered here; what names a zone\n# holds is the module's. Rewritten whenever a zone is declared or moves, and the\n# service restarts on it for the same reason as above.\nconf-file=/etc/mesh-resolver/zones.conf\n\n# Where it answers: this machine's private address, so every node — and every\n# container on every node, which copies its machine's resolvers — can ask; and\n# loopback, for this machine's own use. Never a LAN address: a device that is\n# not a member cannot reach what the mesh's names point at, and a LAN's resolver\n# is its router's (ADR 0194).\n#\n# Named as an ADDRESS and not as the interface that carries it, on purpose. A\n# container's query is addressed to this address but arrives on the runtime's\n# bridge, and dnsmasq checks every query against what it was told to answer on:\n# an `interface=` line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With `interface=mesh0`\n# alone, a query from a container was received and dropped without a word\n# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on.\n#\n# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic\n# re-reads the machine's addresses on every change of any link and closes each\n# listener whose interface that reading did not find; a reading made while the\n# container runtime deletes a bridge's virtual links fails (\"index gone\"), and\n# then every listener is closed until a later change reads cleanly. On\n# 2026-10-09 the private address was left unbound for twenty minutes, the daemon\n# running and silent. bind-interfaces binds each address once, at start, and\n# never closes it. It cannot bind an address that is not there yet, so the unit\n# waits for it (the drop-in below); a new private address restarts the unit, as\n# this file changing always did.\nbind-interfaces\nlisten-address=${machine:address}\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** This\n# machine's resolv.conf names this resolver — so a resolver that read it for\n# upstreams would find itself, and every query it could not answer locally would\n# loop until its receive queue filled. That is not theoretical: it filled with\n# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop\n# impossible: the upstreams are the two lines below, and nothing on the machine\n# can redirect them. The mesh's own names never reach them: the local= line in\n# the file above stops them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded, and reverse lookups of private ranges\n# are answered here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# **No hosts file, and no operator's files.** This machine's /etc/hosts is its\n# own — the hosts module's block and the operator's lines (ADR 0199) — and the\n# mesh's resolver answers every node, so a line written for one machine's own\n# programs must not become an answer for all of them. Every per-node resolver\n# went wrong exactly here: a hosts file read once at start, an operator's old\n# line beside the mesh's, a drop-in read from a directory nobody owned.\nno-hosts\n" + "template": "files/dnsmasq.conf" }, { "id": "drop-ins", "type": "directory", "path": "/etc/systemd/system/dnsmasq.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "wait-for-address", "type": "file", "path": "/etc/systemd/system/dnsmasq.service.d/mesh.conf", "mode": "0644", - "content": "# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.\n#\n# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this\n# machine's private address is there: it waits up to 80 seconds for it, and a start that found it\n# missing is tried again every ten seconds rather than left failed.\n[Unit]\nAfter=network-online.target\nWants=network-online.target\nStartLimitIntervalSec=0\n\n[Service]\nExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo \"${machine:address} is not on this machine\" >&2; exit 1'\nRestart=on-failure\nRestartSec=10s\n" + "template": "files/mesh.conf" }, { "id": "service", @@ -105,7 +107,8 @@ "id": "watch-dir", "type": "directory", "path": "/var/lib/dnsmasq-watch", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "watch", @@ -148,6 +151,11 @@ "loads": [ "dnsmasq-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/docker/files/daemon.json b/testdata/beside/mesh-catalog/modules/docker/files/daemon.json new file mode 100644 index 00000000..83750f4b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/docker/files/daemon.json @@ -0,0 +1 @@ +{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} diff --git a/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service b/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service new file mode 100644 index 00000000..b9b92c1d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service @@ -0,0 +1,15 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Prune dangling images and unused build cache (the mesh's docker module) +# Never volumes, never a container, never an image a container uses: dangling +# images and build cache nothing refers to, unused for a week. What a person +# prunes beyond that is docker_prune's, by hand. +After=docker.service +ConditionPathExists=/run/docker.sock + +[Service] +Type=oneshot +Nice=19 +IOSchedulingClass=idle +ExecStart=/usr/bin/docker image prune --force --filter until=168h +ExecStart=/usr/bin/docker builder prune --force --filter until=168h diff --git a/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer b/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer new file mode 100644 index 00000000..82befd15 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer @@ -0,0 +1,11 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Weekly prune of dangling images and unused build cache (the mesh's docker module) + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf b/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf new file mode 100644 index 00000000..39190368 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf @@ -0,0 +1,5 @@ +# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and +# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452). +# Replaced on every push; change the settings, never this file. +size=${config:log-max-size} +files=${config:log-max-file} diff --git a/testdata/beside/mesh-catalog/modules/docker/module.json.captured b/testdata/beside/mesh-catalog/modules/docker/module.json.captured index 3d267b9d..fae954c3 100644 --- a/testdata/beside/mesh-catalog/modules/docker/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/docker/module.json.captured @@ -33,6 +33,7 @@ "docker_disk_usage", "docker_networks", "docker_volumes", + "docker_volumes_remove_anonymous", "docker_events", "docker_daemon_config", "docker_unlabelled", @@ -44,18 +45,23 @@ "docker exec cat /etc/resolv.conf" ] }, - "settings": { - "log-max-size": { - "kind": "preference", - "default": "100m", - "why": "each container's log is cut at this size and the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container: what the one machine with rotation set by hand has (novox/hq issue 452). A whole number from 1 with k, m or g after it, refused before daemon.json is written. The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again" + "config": { + "log-max-size": { + "type": "string", + "description": "The size each container's log is cut at, a whole number from 1 with k, m or g after it; the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container (novox/hq issue 452). The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again.", + "pattern": "[1-9][0-9]*[kmg]", + "trusted": true, + "default": "100m" + }, + "log-max-file": { + "type": "number", + "description": "How many of a container's log files of log-max-size the runtime keeps, a count from 1 to 99, the oldest dropped first; taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452).", + "minimum": 1, + "maximum": 99, + "trusted": true, + "default": 10 + } }, - "log-max-file": { - "kind": "preference", - "default": 10, - "why": "how many of a container's log files of log-max-size the runtime keeps, the oldest dropped first; a count from 1 to 99, refused before daemon.json is written, taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452)" - } - }, "resources": [ { "id": "package", @@ -78,7 +84,8 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "log-rotation", @@ -86,7 +93,7 @@ "path": "${dir:state}/log-rotation.conf", "mode": "0600", "trusted": true, - "content": "# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and\n# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452).\n# Replaced on every push; change the settings, never this file.\nsize=${setting:log-max-size}\nfiles=${setting:log-max-file}\n" + "template": "files/log-rotation.conf" }, { "id": "log-rotation-check", @@ -112,7 +119,7 @@ "mode": "0644", "into": "json", "trusted": true, - "content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"], \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"${setting:log-max-size}\", \"max-file\": \"${setting:log-max-file}\"}}\n" + "template": "files/daemon.json" }, { "id": "runtime", @@ -129,14 +136,14 @@ "type": "file", "path": "/etc/systemd/system/docker-prune.service", "mode": "0644", - "content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Prune dangling images and unused build cache (the mesh's docker module)\n# Never volumes, never a container, never an image a container uses: dangling\n# images and build cache nothing refers to, unused for a week. What a person\n# prunes beyond that is docker_prune's, by hand.\nAfter=docker.service\nConditionPathExists=/run/docker.sock\n\n[Service]\nType=oneshot\nNice=19\nIOSchedulingClass=idle\nExecStart=/usr/bin/docker image prune --force --filter until=168h\nExecStart=/usr/bin/docker builder prune --force --filter until=168h\n" + "template": "files/docker-prune.service" }, { "id": "prune-timer", "type": "file", "path": "/etc/systemd/system/docker-prune.timer", "mode": "0644", - "content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Weekly prune of dangling images and unused build cache (the mesh's docker module)\n\n[Timer]\nOnCalendar=weekly\nRandomizedDelaySec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n" + "template": "files/docker-prune.timer" }, { "id": "prune", @@ -162,6 +169,11 @@ "loads": [ "docker-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc b/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc new file mode 100644 index 00000000..afba676f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc @@ -0,0 +1,110 @@ +# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced +# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in +# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the +# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on. +# +# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file, +# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it. +# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst +# to read it again (its unit's reload, dunstctl reload); what is on screen stays. + +[global] + monitor = 0 + follow = none + + # Geometry + width = ${config:width} + height = (0, 300) + origin = top-right + offset = (10, 50) + notification_limit = 20 + + progress_bar = true + progress_bar_height = 10 + progress_bar_frame_width = 1 + progress_bar_min_width = 150 + progress_bar_max_width = 300 + + indicate_hidden = yes + transparency = 0 + separator_height = 2 + padding = 8 + horizontal_padding = 8 + text_icon_padding = 0 + frame_width = 3 + frame_color = "#de5200" + gap_size = 0 + separator_color = frame + sort = yes + corner_radius = 0 + + # Text: the interface face (research 026/04) + font = Inter ${config:font-size} + line_height = 0 + markup = full + format = "%s\n%b" + alignment = left + vertical_alignment = center + show_age_threshold = 60 + ellipsize = middle + ignore_newline = no + stack_duplicates = true + hide_duplicate_count = false + show_indicators = yes + + # Icons, from the desktop's icon theme + enable_recursive_icon_lookup = true + icon_theme = Adwaita + icon_position = left + min_icon_size = 32 + max_icon_size = 128 + + # History + sticky_history = yes + history_length = 20 + + # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in + # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that + # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the + # desktop's default browser. + dmenu = rofi -dmenu -no-custom -p dunst + browser = /usr/bin/xdg-open + always_run_script = true + + title = Dunst + class = Dunst + ignore_dbusclose = false + + # A tap answers (the touchpads have no middle button). do_action runs the notification's only + # action, or opens the context menu when it has several; without actions it opens its one link, + # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown. + mouse_left_click = do_action, close_current + mouse_middle_click = do_action, close_current + mouse_right_click = close_all + +[urgency_low] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_normal] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_critical] + background = "#000000" + foreground = "#ffffff" + frame_color = "#ff0000" + timeout = 0 + +# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's +# code, category mesh.secret) and any transient one leave no entry in the history, which +# node-notifier.history serves to every caller of the mesh's verbs. +[mesh-secret] + category = "mesh.secret" + history_ignore = yes + +[transient-history-ignore] + match_transient = yes + history_ignore = yes diff --git a/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf b/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf new file mode 100644 index 00000000..fec1f8de --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf @@ -0,0 +1,4 @@ +# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is +# chosen from the keyboard as a tap chooses it on the notification. +bindsym $mod+n exec --no-startup-id dunstctl context diff --git a/testdata/beside/mesh-catalog/modules/dunst/module.json.captured b/testdata/beside/mesh-catalog/modules/dunst/module.json.captured index f94d5d96..0c6983b4 100644 --- a/testdata/beside/mesh-catalog/modules/dunst/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/dunst/module.json.captured @@ -1,6 +1,7 @@ { "module": "dunst", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -22,18 +23,23 @@ "dunst_count", "dunst_reload" ], - "settings": { - "font-size": { - "kind": "preference", - "default": 10, - "why": "10 points of Inter reads well on a screen of about 100 DPI; a denser screen needs a larger size" + "config": { + "font-size": { + "type": "number", + "description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 10 + }, + "width": { + "type": "number", + "description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.", + "minimum": 50, + "maximum": 4000, + "default": 250 + } }, - "width": { - "kind": "preference", - "default": 250, - "why": "250 pixels fits a title of about forty characters at 10 points; a larger font needs a wider notification" - } - }, "resources": [ { "id": "package", @@ -50,14 +56,17 @@ "type": "directory", "path": "${machine:account-home}/.config/dunst", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "dropins", "type": "directory", "path": "${machine:account-home}/.config/dunst/dunstrc.d", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out", + "knows": ["50-slack.conf"] }, { "id": "configuration", @@ -66,7 +75,7 @@ "path": "${machine:account-home}/.config/dunst/dunstrc", "owner": "${machine:account}", "mode": "0644", - "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -no-custom -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" + "template": "files/dunstrc" }, { "id": "notifier", @@ -91,6 +100,11 @@ "loads": [ "dunst-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -99,11 +113,11 @@ "started", "paused" ], - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-dunst.conf" } ] } diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban b/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban new file mode 100644 index 00000000..b5395db6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban @@ -0,0 +1,7 @@ +/var/log/fail2ban.log { + missingok + notifempty + postrotate + /usr/bin/fail2ban-client flushlogs >/dev/null || true + endscript +} diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf b/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf new file mode 100644 index 00000000..2c9fc73d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf @@ -0,0 +1,40 @@ +# Fail2Ban action: ban in both INPUT and DOCKER-USER chains +# Used by recidive to block repeat offenders from both host and Docker services + +[INCLUDES] + +before = iptables.conf + +[Definition] + +type = allports + +actionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; } + { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; } + { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; } + +actionstop = -D INPUT -p -j f2b- 2>/dev/null || true + -D DOCKER-USER -p -j f2b- 2>/dev/null || true + -F f2b- + -X f2b- + +actioncheck = -n -L f2b- >/dev/null + +actionban = -I f2b- 1 -s -j + +actionunban = -D f2b- -s -j + +[Init] + +chain = INPUT +name = default +protocol = tcp +blocktype = REJECT --reject-with icmp-port-unreachable +returntype = RETURN +lockingopt = -w +iptables = iptables + +[Init?family=inet6] + +blocktype = REJECT --reject-with icmp6-port-unreachable +iptables = ip6tables diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local b/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local new file mode 100644 index 00000000..1fa0ffe7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local @@ -0,0 +1,48 @@ +[INCLUDES] + +before = paths-arch.conf + +[DEFAULT] + +# Never act on the machine itself or on a tunnel peer: the mesh's private range is +# ${machine:mesh-range}, named here rather than written as a value the module cannot +# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes. +# **A ban list never holds a neighbour.** The mesh's own range is named rather than written +# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own +# network, not the internet. On a machine behind a router that reflects local traffic, every +# client in the house arrives as the gateway's address — so one mistyped local request banned +# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186). +# +# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names +# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned +# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found +# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban +# reads it again within a second of a change, so a new value needs no restart. Empty until the operator +# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so. +ignoreip = 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 file:/var/lib/mesh-fail2ban/own-addresses + +# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace +# themselves at one try every ten minutes, under any ten-minute window; a day's window counts +# them, and a day's ban costs a person who mistyped three times once, from one address, while +# the mesh's own range is never banned at all. +bantime = 1d +findtime = 1d +maxretry = 3 + +# Ban through iptables, not through a firewall front-end the machine may not have. ufw is +# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out +# only at ban time: the service reports healthy, the jail counts the attempt, the ban command +# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a +# machine the mesh reported as protected. +# +# The action below is this module's own, already used by the recidive jail on every machine +# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is +# covered too. +banaction = iptables-allports-dualchain +banaction_allports = iptables-allports-dualchain + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json b/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json new file mode 100644 index 00000000..0eb96046 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json @@ -0,0 +1,3 @@ +{ + "own-addresses": [] +} diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses b/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses new file mode 100644 index 00000000..e69de29b diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf b/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf new file mode 100644 index 00000000..1f8786fc --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf @@ -0,0 +1,9 @@ +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services) +banaction = iptables-allports-dualchain +# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179). +bantime = 4w +findtime = 2w +maxretry = 2 diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf b/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf new file mode 100644 index 00000000..c8a30f29 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf @@ -0,0 +1,8 @@ +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s +maxretry = 3 +findtime = 1d +bantime = 1d diff --git a/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured b/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured index 8e6ffb92..446395f2 100644 --- a/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured @@ -17,55 +17,113 @@ } ], "tools": [ - "fail2ban_settings" + "fail2ban_settings", + "fail2ban_health" ], "jailing": { "into": "/etc/fail2ban/jail.d/mesh.conf", "filter-into": "/etc/fail2ban/filter.d" }, + "config": { + "own-addresses": { + "type": "list", + "description": "The addresses and networks outside the mesh that are the operator's own, each an IP address or a CIDR network: every jail ignores them beside the private ranges, the ban verb refuses them, and a failure a jail counted from one is said as a warning; empty is none.", + "trusted": true, + "default": [] + } + }, "resources": [ { "id": "package", "type": "package", "package": "fail2ban" }, + { + "id": "state-dir", + "type": "directory", + "path": "/var/lib/mesh-fail2ban", + "mode": "0755", + "unknown": "program" + }, + { + "id": "own-addresses-list", + "type": "file", + "path": "/var/lib/mesh-fail2ban/own-addresses", + "mode": "0644", + "create-once": true, + "template": "files/own-addresses" + }, { "id": "jail-d", "type": "directory", "path": "/etc/fail2ban/jail.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "action-d", "type": "directory", "path": "/etc/fail2ban/action.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "filter-d", "type": "directory", "path": "/etc/fail2ban/filter.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "run-dir", "type": "directory", "path": "/var/run/fail2ban", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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.\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" + "template": "files/jail.local" }, { "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 = 3\nfindtime = 1d\nbantime = 1d\n" + "template": "files/sshd.conf" + }, + { + "id": "own-addresses", + "type": "file", + "path": "/etc/fail2ban/mesh-own-addresses.json", + "mode": "0644", + "merge": "json", + "takes-config": [ + "own-addresses" + ], + "trusted": true, + "template": "files/mesh-own-addresses.json" + }, + { + "id": "watch", + "type": "process", + "name": "fail2ban-watch", + "artifact": "tools", + "run": [ + "./fail2ban-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "fail2ban_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } }, { "id": "log", @@ -73,28 +131,28 @@ "path": "/var/log/fail2ban.log", "mode": "0640", "create-once": true, - "content": "" + "template": "files/own-addresses" }, { "id": "jail-recidive", "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\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n" + "template": "files/recidive.conf" }, { "id": "action-dualchain", "type": "file", "path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf", "mode": "0644", - "content": "# Fail2Ban action: ban in both INPUT and DOCKER-USER chains\n# Used by recidive to block repeat offenders from both host and Docker services\n\n[INCLUDES]\n\nbefore = iptables.conf\n\n[Definition]\n\ntype = allports\n\nactionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; }\n { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; }\n { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; }\n\nactionstop = -D INPUT -p -j f2b- 2>/dev/null || true\n -D DOCKER-USER -p -j f2b- 2>/dev/null || true\n -F f2b-\n -X f2b-\n\nactioncheck = -n -L f2b- >/dev/null\n\nactionban = -I f2b- 1 -s -j \n\nactionunban = -D f2b- -s -j \n\n[Init]\n\nchain = INPUT\nname = default\nprotocol = tcp\nblocktype = REJECT --reject-with icmp-port-unreachable\nreturntype = RETURN\nlockingopt = -w\niptables = iptables \n\n[Init?family=inet6]\n\nblocktype = REJECT --reject-with icmp6-port-unreachable\niptables = ip6tables \n" + "template": "files/iptables-allports-dualchain.conf" }, { "id": "logrotate", "type": "file", "path": "/etc/logrotate.d/fail2ban", "mode": "0644", - "content": "/var/log/fail2ban.log {\n missingok\n notifempty\n postrotate\n /usr/bin/fail2ban-client flushlogs >/dev/null || true\n endscript\n}\n" + "template": "files/fail2ban" }, { "id": "run", @@ -108,7 +166,10 @@ "jail-recidive", "action-dualchain", "composed-jails" - ] + ], + "health": { + "kind": "unit" + } } ], "build": { @@ -123,6 +184,11 @@ "loads": [ "fail2ban-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/feh/files/fehbg b/testdata/beside/mesh-catalog/modules/feh/files/fehbg new file mode 100755 index 00000000..a3159bbb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/feh/files/fehbg @@ -0,0 +1,6 @@ +#!/bin/sh +# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The +# session's start runs it, and so may anything that wants the declared wallpaper back. The image is +# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session +# without touching this file. +feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg" diff --git a/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf b/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf new file mode 100644 index 00000000..40ac7da0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf @@ -0,0 +1,3 @@ +# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session. +bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg diff --git a/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal b/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal new file mode 100644 index 00000000..8e91e82e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal @@ -0,0 +1,2 @@ +# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session. +"$HOME/.fehbg" diff --git a/testdata/beside/mesh-catalog/modules/feh/module.json.captured b/testdata/beside/mesh-catalog/modules/feh/module.json.captured index 70a20714..d6e44073 100644 --- a/testdata/beside/mesh-catalog/modules/feh/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/feh/module.json.captured @@ -1,6 +1,7 @@ { "module": "feh", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -11,11 +12,16 @@ "feh_set", "feh_current" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.\n\"$HOME/.fehbg\"\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-feh.conf" } ], "resources": [ @@ -29,7 +35,8 @@ "type": "archive", "path": "${machine:account-home}/.local/share/feh/wallpapers", "owner": "${machine:account}", - "artifact": "wallpapers" + "artifact": "wallpapers", + "unknown": "move-out" }, { "id": "fehbg", @@ -37,7 +44,7 @@ "path": "${machine:account-home}/.fehbg", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n" + "template": "files/fehbg" } ], "build": { @@ -57,14 +64,12 @@ "loads": [ "feh-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n" - } - ] + } } diff --git a/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf b/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf new file mode 100644 index 00000000..76d0c879 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf @@ -0,0 +1,40 @@ + + + + + The mesh: the faces monospace, sans-serif, serif and emoji mean + + + Hack Nerd Fontmonospace + MesloLGS NFmonospace + Iosevka Nerd Fontmonospace + JetBrains Mono Nerd FontJetBrainsMono Nerd Font + + + monospaceJetBrainsMono Nerd Font + sans-serifInterNoto Sans + system-uiInter + serifNoto Serif + emojiNoto Color Emoji + + + + Symbols Nerd Font + Noto Color Emoji + + diff --git a/testdata/beside/mesh-catalog/modules/fonts/module.json.captured b/testdata/beside/mesh-catalog/modules/fonts/module.json.captured index e6684403..b2958da8 100644 --- a/testdata/beside/mesh-catalog/modules/fonts/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/fonts/module.json.captured @@ -44,7 +44,7 @@ "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf", "owner": "${machine:account}", "mode": "0644", - "content": "\n\n\n\n The mesh: the faces monospace, sans-serif, serif and emoji mean\n\n \n Hack Nerd Fontmonospace\n MesloLGS NFmonospace\n Iosevka Nerd Fontmonospace\n JetBrains Mono Nerd FontJetBrainsMono Nerd Font\n\n \n monospaceJetBrainsMono Nerd Font\n sans-serifInterNoto Sans\n system-uiInter\n serifNoto Serif\n emojiNoto Color Emoji\n\n \n \n Symbols Nerd Font\n Noto Color Emoji\n \n\n" + "template": "files/50-mesh-fonts.conf" } ], "build": { @@ -59,6 +59,11 @@ "loads": [ "fonts-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json b/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/config.json b/testdata/beside/mesh-catalog/modules/gitea/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json b/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json new file mode 100644 index 00000000..790b51f9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json @@ -0,0 +1 @@ +${consumers:forge-account} diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/origins b/testdata/beside/mesh-catalog/modules/gitea/files/origins new file mode 100644 index 00000000..6bbfb357 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/origins @@ -0,0 +1 @@ +https://${bound:route:name-web} diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json b/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json new file mode 100644 index 00000000..36626ccc --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json @@ -0,0 +1 @@ +{"repositories": ${config:repositories}} diff --git a/testdata/beside/mesh-catalog/modules/gitea/files/server.env b/testdata/beside/mesh-catalog/modules/gitea/files/server.env new file mode 100644 index 00000000..8cb88e22 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitea/files/server.env @@ -0,0 +1,7 @@ +GITEA__security__INTERNAL_TOKEN=${secret:internal-token} +GITEA__database__DB_TYPE=postgres +GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +GITEA__database__NAME=${bound:postgres-database:as} +GITEA__database__USER=${bound:postgres-database:as} +GITEA__database__PASSWD=${secret:postgres-database} +GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1 diff --git a/testdata/beside/mesh-catalog/modules/gitea/module.json.captured b/testdata/beside/mesh-catalog/modules/gitea/module.json.captured index 6280acff..31bbb265 100644 --- a/testdata/beside/mesh-catalog/modules/gitea/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/gitea/module.json.captured @@ -1,6 +1,7 @@ { "module": "gitea", "version": "1", + "moved": true, "requires": [ "postgres-database", "route", @@ -42,11 +43,50 @@ "issue.opened", "pull.merged", "pull.updated", - "pull.closed" + "pull.closed", + "forge.push", + "forge.create", + "forge.delete", + "forge.fork", + "forge.status", + "forge.pull-request.opened", + "forge.pull-request.reopened", + "forge.pull-request.synchronized", + "forge.pull-request.edited", + "forge.pull-request.closed", + "forge.pull-request.merged", + "forge.pull-request.labeled", + "forge.pull-request.unlabeled", + "forge.pull-request.assigned", + "forge.pull-request.unassigned", + "forge.pull-request.reviewed", + "forge.pull-request.review-requested", + "forge.pull-request.commented", + "forge.pull-request.changed", + "forge.issue-comment.created", + "forge.issue-comment.edited", + "forge.issue-comment.deleted", + "forge.issues.opened", + "forge.issues.reopened", + "forge.issues.closed", + "forge.issues.edited", + "forge.issues.labeled", + "forge.issues.unlabeled", + "forge.issues.assigned", + "forge.issues.unassigned", + "forge.issues.changed", + "forge.release.published", + "forge.release.updated", + "forge.release.deleted", + "forge.release.changed", + "forge.repository.created", + "forge.repository.deleted", + "forge.repository.changed", + "forge.wiki.changed", + "forge.other" ], "consumes": [ - "mesh-controller.checked", - "node-build-agent.started" + "mesh-controller.checked" ], "listens": [ { @@ -61,14 +101,22 @@ "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take" + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" + }, + { + "name": "forge-events", + "port": 3047, + "protocol": "tcp", + "from": "machine", + "why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)" } ], "serves": { "npm-package-registry": { "scheme": "http", "port": 3000, - "npm-path": "/api/packages/novox/npm/" + "npm-path": "/api/packages/novox/npm/", + "public-root": "https://${bound:route:name-web}/api/packages/novox/npm/" }, "git": { "scheme": "http", @@ -108,46 +156,92 @@ } } }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.", + "trusted": true, + "default": "" + }, + "agent-read-teams": { + "type": "object", + "description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"\": {\"repos\": [\"/\", ...], \"members\": [\"\", ...]}}. The forge gets a team agent-read- that reads only, with exactly those repositories and the accounts agent-; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.", + "trusted": true, + "default": {} + }, + "repositories": { + "type": "object", + "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.", + "trusted": true, + "default": {} + } + }, + "facts": { + "forge-host": { + "path": "/etc/gitea/forge-host", + "template": "{{range .Machines}}{{if eq .Name $.Node}}{{.FQDN}}{{end}}{{end}}\n" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state", - "mode": "0700" + "mode": "0700", + "unknown": "program" + }, + { + "id": "forge-patches", + "type": "directory", + "path": "/var/lib/mesh/forge-patches", + "mode": "0733", + "owner": "${machine:account}", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n" + "template": "files/server.env" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "1000:1000" + "owner": "1000:1000", + "unknown": "program" }, { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/api/healthz", + "status": 200, + "needs": "postgres-database" + }, "name": "gitea", "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", "env": { @@ -193,13 +287,89 @@ ], "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" }, + { + "id": "origins", + "type": "file", + "path": "${dir:mesh-state}/origins", + "mode": "0644", + "template": "files/origins" + }, { "id": "runtime-config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url" + ] + }, + { + "id": "agent-teams-file", + "type": "file", + "path": "${dir:mesh-state}/agent-teams.json", + "mode": "0600", + "template": "files/agent-teams.json", + "merge": "json", + "takes-config": [ + "agent-read-teams" + ] + }, + { + "id": "repositories-file", + "type": "file", + "path": "${dir:mesh-state}/repositories.json", + "mode": "0600", + "template": "files/repositories.json" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "agent-teams", + "type": "process", + "name": "gitea-agent-teams", + "artifact": "agent-teams", + "run": [ + "./agent-teams" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_AGENT_TEAMS_FILE": "${dir:mesh-state}/agent-teams.json" + }, + "restart-on": [ + "agent-teams-file" + ] + }, + { + "id": "repositories", + "type": "process", + "name": "gitea-repositories", + "artifact": "repositories", + "run": [ + "./repositories", + "sync" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json", + "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" + }, + "restart-on": [ + "repositories-file" + ] } ], "provides": [ @@ -214,6 +384,18 @@ { "name": "git", "scope": "mesh" + }, + { + "name": "gitea-read", + "scope": "mesh", + "credential": { + "issued-by": "software", + "tool": "gitea_agent_token" + } + }, + { + "name": "forge-account", + "scope": "mesh" } ], "build": { @@ -237,8 +419,12 @@ "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_TOOLS_USER": "mesh-tools", "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", - "MESH_RECEIVES": "${dir:grants}/npm.json" + "MESH_RECEIVES": "${dir:grants}/npm.json", + "MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047", + "MESH_GITEA_HOOK_PORT": "3047", + "MESH_GITEA_HOOK_HOST": "172.17.0.1" } }, { @@ -268,10 +454,74 @@ "loads": [ "forge" ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token", + "MESH_GITEA_PATCH_DIR": "${dir:forge-patches}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "agent-token", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-token", + "binary": "agent-token", + "loads": [ + "agent-token" + ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", "MESH_GITEA_ADMIN_USER": "mesh-admin", - "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins" + } + }, + { + "name": "agent-teams", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-teams", + "binary": "agent-teams", + "loads": [ + "agent-teams" + ] + }, + { + "name": "repositories", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/repositories", + "binary": "repositories", + "loads": [ + "repositories" + ] + }, + { + "name": "forge-account-provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge-account-provider", + "binary": "forge-account-provider", + "loads": [ + "forge-account-provider" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins", + "MESH_GITEA_SSH_PORT": "222", + "MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host" } } ] @@ -282,5 +532,17 @@ "failregex": "^.*Failed authentication attempt for .* from (?::\\d+)?\\s*$\n ^.*Invalid user .* from port \\d+\\s*$\n ^.*User \\S+ from not allowed because .*$", "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" } + ], + "state": [ + "bound", + "announced" + ], + "reads": [ + "mesh-controller.assignments", + "ssh-client.keys", + "mesh-build-go.keys", + "mesh-build-node.keys", + "mesh-build-image.keys", + "mesh-delivery-checker.owed" ] } diff --git a/testdata/beside/mesh-catalog/modules/gitlab/files/config.json b/testdata/beside/mesh-catalog/modules/gitlab/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gitlab/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured b/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured index a925e1bb..3cb33fb2 100644 --- a/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured @@ -4,20 +4,35 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "gitlab-url": { + "type": "string", + "description": "The address of the GitLab instance the gitlab tools call, as a URL such as https://gitlab.com, laid into the file as GITLAB_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "gitlab-url", + "as": "GITLAB_URL" + } + ] } ], "capabilities": [ @@ -39,6 +54,11 @@ "MESH_GITLAB_TOKEN_FILE": "${dir:state}/token", "MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login new file mode 100644 index 00000000..47833d99 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login @@ -0,0 +1,4 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the +# login screen unlocks the keyring, and the login session starts the keyring daemon with it. +auth optional pam_gnome_keyring.so +session optional pam_gnome_keyring.so auto_start diff --git a/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd new file mode 100644 index 00000000..dd43bb8f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd @@ -0,0 +1,3 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's +# password changes the login keyring's with it, so the next login still unlocks it. +password optional pam_gnome_keyring.so diff --git a/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first new file mode 100644 index 00000000..5fb92b81 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first @@ -0,0 +1,5 @@ +# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager +# starts on first use from its socket. Named here, for the session and every terminal it starts, until +# the account's environment can say a path under the runtime directory (see the module's README). +SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh" +export SSH_AUTH_SOCK diff --git a/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured b/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured index 0c796fc2..9f75b9ff 100644 --- a/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured @@ -1,6 +1,7 @@ { "module": "gnome-keyring", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -16,11 +17,11 @@ "gnome_keyring_collections", "gnome_keyring_ssh_keys" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "first", - "code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n" + "collection": "shell:xinitrc:first", + "name": "xinitrc-first", + "template": "files/xinitrc-first" } ], "resources": [ @@ -46,7 +47,7 @@ "mode": "0644", "into": "block", "at": "end", - "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n" + "template": "files/pam/login" }, { "id": "pam-passwd", @@ -55,7 +56,7 @@ "mode": "0644", "into": "block", "at": "end", - "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n" + "template": "files/pam/passwd" } ], "build": { @@ -70,6 +71,11 @@ "loads": [ "gnome-keyring-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/go/module.json.captured b/testdata/beside/mesh-catalog/modules/go/module.json.captured new file mode 100644 index 00000000..48a6bf57 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/go/module.json.captured @@ -0,0 +1,20 @@ +{ + "module": "go", + "version": "1", + "capabilities": [ + "package-manager" + ], + "provides": [ + { + "name": "go-toolchain", + "reach": "machine" + } + ], + "resources": [ + { + "id": "go", + "type": "package", + "package": "go" + } + ] +} diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret b/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret new file mode 100644 index 00000000..51bf424b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret @@ -0,0 +1 @@ +${secret:admin} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/config.json b/testdata/beside/mesh-catalog/modules/grafana/files/config.json new file mode 100644 index 00000000..567df9a7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/config.json @@ -0,0 +1,4 @@ +{ + "user": "admin", + "password": "${secret:admin}" +} diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api b/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api new file mode 100644 index 00000000..8329ab78 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api @@ -0,0 +1 @@ +${secret:influxdb-api} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml b/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml new file mode 100644 index 00000000..e3410057 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml @@ -0,0 +1,19 @@ +apiVersion: 1 +# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330): +# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and +# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because +# the mesh resets it. The password is read from the file the mesh delivers, never written here. +datasources: + - name: InfluxDB (mesh) + uid: mesh-influxdb-api + type: influxdb + access: proxy + url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port} + user: ${bound:influxdb-api:as} + isDefault: false + editable: false + jsonData: + dbName: ${bound:influxdb-api:bucket} + httpMode: POST + secureJsonData: + password: $__file{/run/secrets/influxdb/influxdb-api} diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret b/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret new file mode 100644 index 00000000..9146dc18 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret @@ -0,0 +1 @@ +${secret:oidc-client} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env b/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env new file mode 100644 index 00000000..bcc1e9c2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env @@ -0,0 +1,13 @@ +GF_SERVER_ROOT_URL=https://${bound:route:name} +GF_AUTH_GENERIC_OAUTH_ENABLED=true +GF_AUTH_GENERIC_OAUTH_NAME=Keycloak +GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as} +GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client +GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles +GF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path} +GF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path} +GF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path} +GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer' +GF_AUTH_GENERIC_OAUTH_USE_PKCE=true +GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true +GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true diff --git a/testdata/beside/mesh-catalog/modules/grafana/module.json.captured b/testdata/beside/mesh-catalog/modules/grafana/module.json.captured index 9ac69bcc..1cbf995f 100644 --- a/testdata/beside/mesh-catalog/modules/grafana/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/grafana/module.json.captured @@ -29,24 +29,41 @@ } ] }, + "config": { + "user": { + "type": "string", + "description": "The Grafana account the module's tools sign in as with the vault's password; the file's default is admin.", + "trusted": true, + "default": "admin" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Grafana at, as a URL; unset, the code falls back to its environment, else this machine's Grafana on its port.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "472:472" + "owner": "472:472", + "unknown": "program" }, { "id": "admin-secret", @@ -54,7 +71,7 @@ "path": "${dir:state}/admin.secret", "mode": "0400", "owner": "472:472", - "content": "${secret:admin}" + "template": "files/admin.secret" }, { "id": "oidc-secret", @@ -62,28 +79,30 @@ "path": "${dir:state}/oidc-client.secret", "mode": "0400", "owner": "472:472", - "content": "${secret:oidc-client}" + "template": "files/oidc-client.secret" }, { "id": "oidc-env", "type": "file", "path": "${dir:state}/oidc.env", "mode": "0644", - "content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n" + "template": "files/oidc.env" }, { "id": "influxdb-datasources", "type": "directory", "path": "${dir:state}/datasources", "mode": "0755", - "owner": "472:472" + "owner": "472:472", + "unknown": "program" }, { "id": "influxdb-credential", "type": "directory", "path": "${dir:state}/influxdb", "mode": "0700", - "owner": "472:472" + "owner": "472:472", + "unknown": "program" }, { "id": "influxdb-secret", @@ -91,7 +110,7 @@ "path": "${dir:influxdb-credential}/influxdb-api", "mode": "0400", "owner": "472:472", - "content": "${secret:influxdb-api}", + "template": "files/influxdb-api", "only-when-bound": "influxdb-api" }, { @@ -99,7 +118,7 @@ "type": "file", "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644", - "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "template": "files/mesh-influxdb.yaml", "only-when-bound": "influxdb-api" }, { @@ -140,8 +159,10 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["user", "url"], + "protected": ["password", "token"] } ], "requires": [ @@ -193,6 +214,11 @@ "MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}", "MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/hello-web/files/index.html b/testdata/beside/mesh-catalog/modules/hello-web/files/index.html new file mode 100644 index 00000000..0d513fd5 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/hello-web/files/index.html @@ -0,0 +1 @@ +hello from hello-web, routed by the mesh diff --git a/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured b/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured index 1811a1f1..005578cd 100644 --- a/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured @@ -31,14 +31,15 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "page", "type": "file", "path": "${dir:state}/index.html", "mode": "0644", - "content": "hello from hello-web, routed by the mesh\n" + "template": "files/index.html" }, { "id": "net", @@ -70,6 +71,11 @@ "name": "server", "kind": "upstream", "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json b/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env b/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env new file mode 100644 index 00000000..93aba0ca --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env @@ -0,0 +1,4 @@ +MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123} +MESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured b/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured index d35ae18e..9c4f99db 100644 --- a/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured @@ -45,28 +45,40 @@ } ] }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Home Assistant at, as a URL; unset, the code falls back to its environment, else this machine's Home Assistant on its port.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "written", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "server", @@ -86,15 +98,16 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"] }, { "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" + "template": "files/provisions.env" }, { "id": "provisions", @@ -185,6 +198,11 @@ "MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token", "MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/hostname/files/hostname b/testdata/beside/mesh-catalog/modules/hostname/files/hostname new file mode 100644 index 00000000..34c12a43 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/hostname/files/hostname @@ -0,0 +1 @@ +${config:hostname} diff --git a/testdata/beside/mesh-catalog/modules/hostname/files/hosts b/testdata/beside/mesh-catalog/modules/hostname/files/hosts new file mode 100644 index 00000000..7c9c0735 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/hostname/files/hosts @@ -0,0 +1,7 @@ +# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this +# block is the operator's: kept across every push, changed through the node-hostname verbs add and +# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver +# answers them. +127.0.0.1 localhost +::1 localhost +127.0.1.1 ${machine:name} diff --git a/testdata/beside/mesh-catalog/modules/hostname/module.json.captured b/testdata/beside/mesh-catalog/modules/hostname/module.json.captured index bd605df0..e84e7be2 100644 --- a/testdata/beside/mesh-catalog/modules/hostname/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/hostname/module.json.captured @@ -12,6 +12,14 @@ ] } ], + "config": { + "hostname": { + "type": "string", + "description": "The machine's host name, written to /etc/hostname: the operator's name for this machine, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "own", @@ -20,14 +28,14 @@ "mode": "0644", "into": "block", "at": "start", - "content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n" + "template": "files/hosts" }, { "id": "name", "type": "file", "path": "/etc/hostname", "mode": "0644", - "content": "${setting:hostname}\n" + "template": "files/hostname" } ], "build": { @@ -42,6 +50,11 @@ "loads": [ "hostname-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/i3/files/config b/testdata/beside/mesh-catalog/modules/i3/files/config new file mode 100644 index 00000000..c49398c9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3/files/config @@ -0,0 +1,201 @@ +# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; +# change the module instead. i3's user guide is the reference. +# +# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR +# 0212): the mesh places their lines near the end, each module's under a line naming it, where every +# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read +# after them, by the include at the very end, and is yours. +# +# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the +# result with i3 -C; it never reloads into a configuration with errors. + +# Font for window titles, and the bars below: the mesh's monospace face (research 026/04). +font pango:JetBrainsMono Nerd Font 11 + +# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login. +exec --no-startup-id dex --autostart --environment i3 + +######################################### +###### Keys #### +######################################### +# To find key symbols: xmodmap -pke / xmodmap -pm +set $mod Mod4 +set $alt Mod1 +set $shift Shift +set $ctrl Control + +# use these keys for focus, movement, and resize directions when reaching for +# the arrows is not convenient +set $left h +set $down j +set $up k +set $right l + +# use Mouse+$mod to drag floating windows to their wanted position +floating_modifier $mod + +# move tiling windows via drag & drop by left-clicking into the title bar, +# or left-clicking anywhere into the window while holding the floating modifier. +tiling_drag modifier titlebar + +# start a terminal: whichever the terminal module names in $TERMINAL +bindsym $mod+Return exec i3-sensible-terminal + +# kill focused window +bindsym $mod+$shift+q kill + +# change focus +bindsym $mod+$left focus left +bindsym $mod+$down focus down +bindsym $mod+$up focus up +bindsym $mod+$right focus right + +bindsym $mod+Left focus left +bindsym $mod+Down focus down +bindsym $mod+Up focus up +bindsym $mod+Right focus right + +# move focused window +bindsym $mod+$shift+$left move left +bindsym $mod+$shift+$down move down +bindsym $mod+$shift+$up move up +bindsym $mod+$shift+$right move right + +bindsym $mod+$shift+Left move left +bindsym $mod+$shift+Down move down +bindsym $mod+$shift+Up move up +bindsym $mod+$shift+Right move right + +# split in horizontal orientation +bindsym $mod+c split h +# split in vertical orientation +bindsym $mod+v split v + +# enter fullscreen mode for the focused container +bindsym $mod+f fullscreen toggle + +# change container layout (stacked, tabbed, toggle split) +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +bindsym $mod+e layout toggle split + +# toggle tiling / floating +bindsym $mod+$shift+space floating toggle + +# change focus between tiling / floating windows +bindsym $mod+space focus mode_toggle + +# focus the parent container +bindsym $mod+a focus parent + +# alt-tab functionality +bindsym $mod+Tab workspace back_and_forth + +######################################### +###### Workspace mgmt #### +######################################### +set $ws1 "1" +set $ws2 "2" +set $ws3 "3" +set $ws4 "4" +set $ws5 "5" +set $ws6 "6" +set $ws7 "7" +set $ws8 "8" +set $ws9 "9" +set $ws10 "10" + +bindsym $mod+1 workspace number $ws1 +bindsym $mod+2 workspace number $ws2 +bindsym $mod+3 workspace number $ws3 +bindsym $mod+4 workspace number $ws4 +bindsym $mod+5 workspace number $ws5 +bindsym $mod+6 workspace number $ws6 +bindsym $mod+7 workspace number $ws7 +bindsym $mod+8 workspace number $ws8 +bindsym $mod+9 workspace number $ws9 +bindsym $mod+0 workspace number $ws10 + +bindsym $mod+$shift+1 move container to workspace number $ws1 +bindsym $mod+$shift+2 move container to workspace number $ws2 +bindsym $mod+$shift+3 move container to workspace number $ws3 +bindsym $mod+$shift+4 move container to workspace number $ws4 +bindsym $mod+$shift+5 move container to workspace number $ws5 +bindsym $mod+$shift+6 move container to workspace number $ws6 +bindsym $mod+$shift+7 move container to workspace number $ws7 +bindsym $mod+$shift+8 move container to workspace number $ws8 +bindsym $mod+$shift+9 move container to workspace number $ws9 +bindsym $mod+$shift+0 move container to workspace number $ws10 + +######################################### +###### Window mgmt #### +######################################### +set $resize_px 10 px +bindsym $mod+$ctrl+$left resize shrink width $resize_px +bindsym $mod+$ctrl+$down resize grow height $resize_px +bindsym $mod+$ctrl+$up resize shrink height $resize_px +bindsym $mod+$ctrl+$right resize grow width $resize_px + +######################################### +###### Session mgmt #### +######################################### +bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'" + +# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and +# suspend and lid close too). +bindsym $mod+Delete exec --no-startup-id loginctl lock-session + +# reload the configuration file +bindsym $mod+$shift+c reload + +# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) +bindsym $mod+$shift+r restart + +######################################### +###### Borders #### +######################################### +default_border pixel 1 +smart_borders on + +######################################### +###### Gaps #### +######################################### +gaps inner 0 +gaps outer 0 + +# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are +# `pixel`, so no title bar shows: the colour says which window has focus. +# border background text indicator child_border +client.focused #de5200 #de5200 #1E2127 #de5200 #de5200 +client.urgent #900000 #900000 #ffffff #900000 #900000 + +######################################### +###### Until their modules carry them ## +######################################### +# Each line below belongs to something other than i3, named on its line. When that module is written +# it contributes the line to node-display-session, and the line goes from here in the same change. +# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals' +# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14, +# polychromatic). + +# the operator's scripts: volume, games volume, sessions, screenshot +bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up +bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down +bindsym XF86AudioMute exec --no-startup-id volume-notify mute +bindsym XF86AudioMicMute exec --no-startup-id mic-notify +bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5 +bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5 +bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh +bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +######################################### +###### Other modules' lines #### +######################################### +# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the +# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's +# under a line naming it. +${contribution:node-display-session:config} +######################################### +###### Your own files #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop b/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop new file mode 100644 index 00000000..04ad29f0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop @@ -0,0 +1 @@ +i3 diff --git a/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop b/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop new file mode 100644 index 00000000..04ad29f0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop @@ -0,0 +1 @@ +i3 diff --git a/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 b/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 new file mode 100755 index 00000000..4e269ad2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 @@ -0,0 +1,9 @@ +#!/bin/sh +# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's +# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3. +# Replaced at every push. +# +# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a +# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog). +# The session's output goes to the journal instead, under `journalctl -t x-session`. +exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc" diff --git a/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last b/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last new file mode 100644 index 00000000..e84ff3d7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last @@ -0,0 +1,2 @@ +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/testdata/beside/mesh-catalog/modules/i3/module.json.captured b/testdata/beside/mesh-catalog/modules/i3/module.json.captured index 5dee0726..1ec92426 100644 --- a/testdata/beside/mesh-catalog/modules/i3/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/i3/module.json.captured @@ -1,6 +1,7 @@ { "module": "i3", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -30,17 +31,29 @@ "i3_marks", "i3_scratchpad" ], - "environment": { - "variables": { - "XDG_CURRENT_DESKTOP": "i3", - "XDG_SESSION_DESKTOP": "i3" - } - }, - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "last", - "code": "# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log.\nexec i3 --shmlog-size=26214400\n" + "collection": "environment", + "name": "xdg-current-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_CURRENT_DESKTOP" + }, + "template": "files/env-xdg-current-desktop" + }, + { + "collection": "environment", + "name": "xdg-session-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_SESSION_DESKTOP" + }, + "template": "files/env-xdg-session-desktop" + }, + { + "collection": "shell:xinitrc:last", + "name": "xinitrc-last", + "template": "files/xinitrc-last" } ], "resources": [ @@ -59,14 +72,18 @@ "type": "directory", "path": "${machine:account-home}/.config/i3", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out", + "knows": ["scripts/"] }, { "id": "drop-ins", "type": "directory", "path": "${machine:account-home}/.config/i3/config.d", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out", + "knows": ["10-asus.conf", "20-g14.conf"] }, { "id": "config", @@ -74,14 +91,14 @@ "path": "${machine:account-home}/.config/i3/config", "owner": "${machine:account}", "mode": "0644", - "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR\n# 0212): the mesh places their lines near the end, each module's under a line naming it, where every\n# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read\n# after them, by the include at the very end, and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line to node-display-session, and the line goes from here in the same change.\n# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'\n# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,\n# polychromatic).\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' lines ####\n#########################################\n# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the\n# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's\n# under a line naming it.\n${contribution:node-display-session:config}\n#########################################\n###### Your own files ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n" + "template": "files/config" }, { "id": "session", "type": "file", "path": "/etc/lemurs/wms/i3", "mode": "0755", - "content": "#!/bin/sh\n# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's\n# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3.\n# Replaced at every push.\n#\n# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a\n# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog).\n# The session's output goes to the journal instead, under `journalctl -t x-session`.\nexec systemd-cat -t x-session /bin/sh \"$HOME/.xinitrc\"\n" + "template": "files/wms/i3" } ], "build": { @@ -96,6 +113,11 @@ "loads": [ "i3-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog new file mode 100755 index 00000000..63b2b029 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died. +# +# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not +# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the +# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the +# cause, which is i3bar's. +# +# Started once per session from the session's start, with the session's own pid; it ends when that +# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04. +set -uo pipefail + +session_pid="${1:-}" +interval="${I3_BAR_WATCHDOG_INTERVAL:-5}" +# Two misses in a row before acting: an i3 restart tears every bar down and starts them again. +confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}" +# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop. +cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}" + +log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; } + +for tool in i3-msg i3bar pgrep; do + command -v "$tool" >/dev/null 2>&1 || { + log "$tool is missing; not watching" + exit 1 + } +done + +declare -A missed=() fixed=() + +bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; } +outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; } +session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; } + +while session_alive; do + sleep "$interval" + socket="$(i3 --get-socketpath 2>/dev/null)" + [ -n "$socket" ] && [ -S "$socket" ] || continue + ids="$(bar_ids)" + [ -n "$ids" ] || continue + while read -r id; do + [ -n "$id" ] || continue + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + continue + fi + missed[$id]=$((${missed[$id]:-0} + 1)) + [ "${missed[$id]}" -ge "$confirm" ] || continue + now="$(date +%s)" + if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then + continue + fi + log "$id is gone; respawning it. Outputs now: $(outputs)" + nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 & + disown 2>/dev/null || true + fixed[$id]="$now" + sleep 2 + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + else + log "$id exited within 2s of respawning; check its status_command" + fi + done <<<"$ids" +done diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates new file mode 100755 index 00000000..1a70cf99 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for +# the bottom bar's custom block (json = true). Emits one JSON line. +# +# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw +# red error on every transient network failure, which on a roaming laptop is often. This keeps the +# last good result through a failure and shows an error only after `threshold` failures in a row +# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no +# root); AUR packages through paru when it is installed, and none counted when it is not. +set -uo pipefail + +threshold="${1:-10}" +icon="update" +state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates" +mkdir -p "$state_dir" +last="$state_dir/last_good.json" +failures="$state_dir/failures" + +json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; } +count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; } + +official="$(checkupdates 2>/dev/null)" +official_rc=$? +aur="" aur_rc=0 +if command -v paru >/dev/null 2>&1; then + aur="$(paru -Qua 2>/dev/null)" + aur_rc=$? +fi + +# checkupdates: 0 updates listed, 2 none pending; anything else is a failure. +if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then + o="$(count "$official")" + a="$(count "$aur")" + total=$((o + a)) + if [ "$total" -eq 0 ]; then + payload="$(json Good "up to date" "")" + else + # A kernel update wants a reboot: the one worth not putting off. + state=Info + printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning + label="$o + $a = $total updates" + [ "$a" -eq 0 ] && label="$total updates" + [ "$total" -eq 1 ] && label="1 update" + payload="$(json "$state" "$label" "$total")" + fi + printf '%s' "$payload" >"$last" + printf '0' >"$failures" + printf '%s\n' "$payload" + exit 0 +fi + +n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1)) +printf '%s' "$n" >"$failures" +if [ "$n" -ge "$threshold" ]; then + json Critical "update check failing (${n}x)" "!" + echo +elif [ -s "$last" ]; then + cat "$last" + echo +else + json Idle "" "" + echo +fi diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value new file mode 100755 index 00000000..8fea95e4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps +# on the bus, for a block it contributed. +# +# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves +# the value's text in this module's own directory; this prints it. A value not refreshed for thirty +# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an +# old number as if it were now. +dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values" +f="$dir/$1@$2" +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +fi diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml new file mode 100644 index 00000000..12e6805f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml @@ -0,0 +1,109 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. +${collected:contribution:node-bar:block:bar=bottom,place=resources} +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +${collected:contribution:node-bar:block:bar=bottom,place=status} +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf b/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf new file mode 100644 index 00000000..20acbb19 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf @@ -0,0 +1,34 @@ +# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom +# bar shows the machine; the top bar the focused window and the tray, on the primary output. The +# face is the monospace one every desktop module names. +bar { + font pango:JetBrainsMono Nerd Font 11 + position bottom + status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml + tray_output none + colors { + separator #ffffff + background #000000 + statusline #ffffff + # The text on an accent-coloured button is dark: light text on the accent was barely + # legible. + focused_workspace #de5200 #de5200 #000000 + active_workspace #de5200 #de5200 #000000 + inactive_workspace #000000 #000000 #ffffff + urgent_workspace #2f343a #900000 #ffffff + } +} + +bar { + font pango:JetBrainsMono Nerd Font 11 + position top + status_command i3status-rs ~/.config/i3status-rust/top-bar.toml + workspace_buttons no + tray_output primary + colors { + separator #ffffff + background #000000 + statusline #ffffff + } +} diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml b/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml new file mode 100644 index 00000000..9636a82d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml @@ -0,0 +1,135 @@ +# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +# Material from NerdFont +# Codepoints from the Nerd Fonts cheat sheet +backlight = [ + "\ue38d", # nf-weather-moon_new + "\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6 + "\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5 + "\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4 + "\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3 + "\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2 + "\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1 + "\ue3ce", # nf-weather-moon_alt_first_quarter + "\ue3cd", # nf-weather-moon_alt_waxing_crescent_6 + "\ue3cc", # nf-weather-moon_alt_waxing_crescent_5 + "\ue3cb", # nf-weather-moon_alt_waxing_crescent_4 + "\ue3ca", # nf-weather-moon_alt_waxing_crescent_3 + "\ue3c9", # nf-weather-moon_alt_waxing_crescent_2 + "\ue3c8", # nf-weather-moon_alt_waxing_crescent_1 + "\ue39b", # nf-weather-moon_full +] +bat_charging = "\U000f0084" # nf-md-battery_charging +bat_not_available = "\U000f0091" # nf-md-battery_unknown +bat = [ + "\U000f007a", # nf-md-battery_10 + "\U000f007b", # nf-md-battery_20 + "\U000f007c", # nf-md-battery_30 + "\U000f007d", # nf-md-battery_40 + "\U000f007e", # nf-md-battery_50 + "\U000f007f", # nf-md-battery_60 + "\U000f0080", # nf-md-battery_70 + "\U000f0081", # nf-md-battery_80 + "\U000f0082", # nf-md-battery_90 + "\U000f0079", # nf-md-battery +] +bell = "\U000f009c" # nf-md-bell_outline +bell-slash = "\U000f009b" # nf-md-bell_off +blackberry = "\uf307" # nf-md-blackberry +bluetooth = "\U000f00af" # nf-md-bluetooth +calendar = "\U000f00ed" # nf-md-calendar +cogs = "\U000f0493" # nf-md-cog +cpu = [ + "\U000F0F86", # nf-md-speedometer_slow + "\U000F0F85", # nf-md-speedometer_medium + "\U000F04C5", # nf-md-speedometer +] +cpu_boost_on = "\U000f0521" # nf-md-toggle_switch +cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +disk_drive = "\U000f02ca" # nf-md-harddisk +docker = "\uf308" # nf-linux-docker +github = "\U000f02a4" # nf-md-github +gpu = "\U000f0379" # nf-md-monitor +headphones = "\U000f02cb" # nf-md-headphones +joystick = "\U000f0297" # nf-md-gamepad_variant +keyboard = "\U000f030c" # nf-md-keyboard +mail = "\U000f01ee" # nf-md-email +memory_mem = "\U000f035b" # nf-md-memory +memory_swap = "\U000f02ca" # nf-md-harddisk +mouse = "\U000f037d" # nf-md-mouse +music = "\U000f075a" # nf-md-music +music_next = "\U000f04ad" # nf-md-skip_next +music_pause = "\U000f03e4" # nf-md-pause +music_play = "\U000f040a" # nf-md-play +music_prev = "\U000f04ae" # nf-md-skip_previous +net_bridge = "\U000f04aa" # nf-md-sitemap +net_down = "\U000f01da" # nf-md-download +net_loopback = "\U000f006f" # nf-md-backup_restore +net_modem = "\U000f03f2" # nf-md-phone +net_cellular = [ + "\U000F08FD", # nf-md-network_strength_off_outline + "\U000F08FE", # nf-md-network_strength_outline + "\U000F08F4", # nf-md-network_strength_1 + "\U000F08F6", # nf-md-network_strength_2 + "\U000F08F8", # nf-md-network_strength_3 + "\U000F08FA", # nf-md-network_strength_4 +] +net_up = "\U000f0552" # nf-md-upload +net_vpn = "\U000f0582" # nf-md-vpn +net_wired = "\U000f0200" # nf-md-ethernet +net_wireless = [ + "\U000F092F", # nf-md-wifi_strength_outline + "\U000F091F", # nf-md-wifi_strength_1 + "\U000F0922", # nf-md-wifi_strength_2 + "\U000F0925", # nf-md-wifi_strength_3 + "\U000F0928", # nf-md-wifi_strength_4 +] +notification = "\U000f009c" # nf-md-bell_outline +phone = "\U000f03f2" # nf-md-phone +phone_disconnected = "\U000f0658" # nf-md-phone_minus +ping = "\U000f051f" # nf-md-timer_sand +pomodoro = "\ue001" # nf-pom-pomodoro_done +pomodoro_break = "\U000f0176" # nf-md-coffee +pomodoro_paused = "\U000f03e4" # nf-md-pause +pomodoro_started = "\U000f040a" # nf-md-play +pomodoro_stopped = "\U000f04db" # nf-md-stop +resolution = "\U000f0293" # nf-md-fullscreen +tasks = "\U000f05c7" # nf-md-playlist_check +tea = "\U000f0d9e" # nf-md-tea +thermometer = [ + "\U000f10c3", # nf-md-thermometer_low + "\U000f050f", # nf-md-thermometer + "\U000f10c2", # nf-md-thermometer_high +] +time = "\U000f0150" # nf-md-clock_outline +toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +toggle_on = "\U000f0521" # nf-md-toggle_switch +unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default? +update = "\U000f03d5" # nf-md-package_up +uptime = "\U000f0153" # nf-md-clock_in +volume_muted = "\U000f075f" # nf-md-volume_mute +volume = [ + "\U000f057f", # nf-md-volume_low + "\U000f0580", # nf-md-volume_medium + "\U000f057e", # nf-md-volume_high +] +microphone_muted = "\U000f036d" # nf-md-microphone_off +microphone = [ + "\U000f036e", # nf-md-microphone_outline + "\U000f036c", # nf-md-microphone + "\U000f036c", # nf-md-microphone +] +xrandr = "\U000f037a" # nf-md-monitor_multiple + +# Weather icons (the names the weather block documents) +weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day) +weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night) +weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day) +weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night) +weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day) +weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night) +weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day) +weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night) +weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”) +weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day) +weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night) diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml b/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml new file mode 100644 index 00000000..be198e8a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml @@ -0,0 +1,37 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. +${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status} diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal b/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal new file mode 100644 index 00000000..888acdb8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal @@ -0,0 +1,3 @@ +# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this +# brings it back. Once per session, ending with the session's own process ($$). +"$HOME/.local/bin/i3bar-watchdog" "$$" & diff --git a/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured b/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured index 373338f4..1e07556b 100644 --- a/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured @@ -1,6 +1,7 @@ { "module": "i3status-rust", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -10,10 +11,7 @@ "claims": [ { "name": "node-bar", - "scope": "node", - "renders": { - "block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- else if eq .shows \"state\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote (printf \"~/.local/bin/i3status-value %s %s\" .options.state (or .options.key .machine))}}\ninterval = 5\nhide_when_empty = true\n{{- end}}\n\n" - } + "scope": "node" } ], "tools": [ @@ -22,11 +20,16 @@ "i3status_rust_block_run", "i3status_rust_themes" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this\n# brings it back. Once per session, ending with the session's own process ($$).\n\"$HOME/.local/bin/i3bar-watchdog\" \"$$\" &\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/60-i3status-rust.conf" } ], "resources": [ @@ -40,14 +43,16 @@ "type": "directory", "path": "${machine:account-home}/.config/i3status-rust", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "icons-dir", "type": "directory", "path": "${machine:account-home}/.config/i3status-rust/icons", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "top-bar", @@ -55,7 +60,7 @@ "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n\n# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept\n# so that one that does is never dropped.\n${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}\n" + "template": "files/top-bar.toml" }, { "id": "bottom-bar", @@ -63,7 +68,7 @@ "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: a block that depends on the hardware is contributed by the module that\n# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written\n# here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\n# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure\n# module judges by. Counting the cache as used read near full on a machine with room to spare.\nformat = \"$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from\n# what they show by this module's template, and only on a machine that has what they show.\n${contribution:node-bar:block:bar=bottom,place=resources}\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and\n# power draw, where the machine has them.\n${contribution:node-bar:block:bar=bottom,place=status}\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + "template": "files/bottom-bar.toml" }, { "id": "icons", @@ -71,7 +76,7 @@ "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\n# Material from NerdFont\n# Codepoints from the Nerd Fonts cheat sheet\nbacklight = [\n \"\\ue38d\", # nf-weather-moon_new\n \"\\ue3d4\", # nf-weather-moon_alt_waxing_gibbous_6\n \"\\ue3d3\", # nf-weather-moon_alt_waxing_gibbous_5\n \"\\ue3d2\", # nf-weather-moon_alt_waxing_gibbous_4\n \"\\ue3d1\", # nf-weather-moon_alt_waxing_gibbous_3\n \"\\ue3d0\", # nf-weather-moon_alt_waxing_gibbous_2\n \"\\ue3cf\", # nf-weather-moon_alt_waxing_gibbous_1\n \"\\ue3ce\", # nf-weather-moon_alt_first_quarter\n \"\\ue3cd\", # nf-weather-moon_alt_waxing_crescent_6\n \"\\ue3cc\", # nf-weather-moon_alt_waxing_crescent_5\n \"\\ue3cb\", # nf-weather-moon_alt_waxing_crescent_4\n \"\\ue3ca\", # nf-weather-moon_alt_waxing_crescent_3\n \"\\ue3c9\", # nf-weather-moon_alt_waxing_crescent_2\n \"\\ue3c8\", # nf-weather-moon_alt_waxing_crescent_1\n \"\\ue39b\", # nf-weather-moon_full\n]\nbat_charging = \"\\U000f0084\" # nf-md-battery_charging\nbat_not_available = \"\\U000f0091\" # nf-md-battery_unknown\nbat = [\n \"\\U000f007a\", # nf-md-battery_10\n \"\\U000f007b\", # nf-md-battery_20\n \"\\U000f007c\", # nf-md-battery_30\n \"\\U000f007d\", # nf-md-battery_40\n \"\\U000f007e\", # nf-md-battery_50\n \"\\U000f007f\", # nf-md-battery_60\n \"\\U000f0080\", # nf-md-battery_70\n \"\\U000f0081\", # nf-md-battery_80\n \"\\U000f0082\", # nf-md-battery_90\n \"\\U000f0079\", # nf-md-battery\n]\nbell = \"\\U000f009c\" # nf-md-bell_outline\nbell-slash = \"\\U000f009b\" # nf-md-bell_off\nblackberry = \"\\uf307\" # nf-md-blackberry\nbluetooth = \"\\U000f00af\" # nf-md-bluetooth\ncalendar = \"\\U000f00ed\" # nf-md-calendar\ncogs = \"\\U000f0493\" # nf-md-cog\ncpu = [\n\t\"\\U000F0F86\", # nf-md-speedometer_slow\n\t\"\\U000F0F85\", # nf-md-speedometer_medium\n\t\"\\U000F04C5\", # nf-md-speedometer\n]\ncpu_boost_on = \"\\U000f0521\" # nf-md-toggle_switch\ncpu_boost_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ndisk_drive = \"\\U000f02ca\" # nf-md-harddisk\ndocker = \"\\uf308\" # nf-linux-docker\ngithub = \"\\U000f02a4\" # nf-md-github\ngpu = \"\\U000f0379\" # nf-md-monitor\nheadphones = \"\\U000f02cb\" # nf-md-headphones\njoystick = \"\\U000f0297\" # nf-md-gamepad_variant\nkeyboard = \"\\U000f030c\" # nf-md-keyboard\nmail = \"\\U000f01ee\" # nf-md-email\nmemory_mem = \"\\U000f035b\" # nf-md-memory\nmemory_swap = \"\\U000f02ca\" # nf-md-harddisk\nmouse = \"\\U000f037d\" # nf-md-mouse\nmusic = \"\\U000f075a\" # nf-md-music\nmusic_next = \"\\U000f04ad\" # nf-md-skip_next\nmusic_pause = \"\\U000f03e4\" # nf-md-pause\nmusic_play = \"\\U000f040a\" # nf-md-play\nmusic_prev = \"\\U000f04ae\" # nf-md-skip_previous\nnet_bridge = \"\\U000f04aa\" # nf-md-sitemap\nnet_down = \"\\U000f01da\" # nf-md-download\nnet_loopback = \"\\U000f006f\" # nf-md-backup_restore\nnet_modem = \"\\U000f03f2\" # nf-md-phone\nnet_cellular = [\n \"\\U000F08FD\", # nf-md-network_strength_off_outline\n \"\\U000F08FE\", # nf-md-network_strength_outline\n \"\\U000F08F4\", # nf-md-network_strength_1\n \"\\U000F08F6\", # nf-md-network_strength_2\n \"\\U000F08F8\", # nf-md-network_strength_3\n \"\\U000F08FA\", # nf-md-network_strength_4\n]\nnet_up = \"\\U000f0552\" # nf-md-upload\nnet_vpn = \"\\U000f0582\" # nf-md-vpn\nnet_wired = \"\\U000f0200\" # nf-md-ethernet\nnet_wireless = [\n\t\"\\U000F092F\", # nf-md-wifi_strength_outline\n\t\"\\U000F091F\", # nf-md-wifi_strength_1\n\t\"\\U000F0922\", # nf-md-wifi_strength_2\n\t\"\\U000F0925\", # nf-md-wifi_strength_3\n\t\"\\U000F0928\", # nf-md-wifi_strength_4\n]\nnotification = \"\\U000f009c\" # nf-md-bell_outline\nphone = \"\\U000f03f2\" # nf-md-phone\nphone_disconnected = \"\\U000f0658\" # nf-md-phone_minus\nping = \"\\U000f051f\" # nf-md-timer_sand\npomodoro = \"\\ue001\" # nf-pom-pomodoro_done\npomodoro_break = \"\\U000f0176\" # nf-md-coffee\npomodoro_paused = \"\\U000f03e4\" # nf-md-pause\npomodoro_started = \"\\U000f040a\" # nf-md-play\npomodoro_stopped = \"\\U000f04db\" # nf-md-stop\nresolution = \"\\U000f0293\" # nf-md-fullscreen\ntasks = \"\\U000f05c7\" # nf-md-playlist_check\ntea = \"\\U000f0d9e\" # nf-md-tea\nthermometer = [\n \"\\U000f10c3\", # nf-md-thermometer_low\n \"\\U000f050f\", # nf-md-thermometer\n \"\\U000f10c2\", # nf-md-thermometer_high\n]\ntime = \"\\U000f0150\" # nf-md-clock_outline\ntoggle_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ntoggle_on = \"\\U000f0521\" # nf-md-toggle_switch\nunknown = \"\\U000f0186\" # nf-md-comment_question_outline | TODO: Make default?\nupdate = \"\\U000f03d5\" # nf-md-package_up\nuptime = \"\\U000f0153\" # nf-md-clock_in\nvolume_muted = \"\\U000f075f\" # nf-md-volume_mute\nvolume = [\n \"\\U000f057f\", # nf-md-volume_low\n \"\\U000f0580\", # nf-md-volume_medium\n \"\\U000f057e\", # nf-md-volume_high\n]\nmicrophone_muted = \"\\U000f036d\" # nf-md-microphone_off\nmicrophone = [\n\t\"\\U000f036e\", # nf-md-microphone_outline\n \"\\U000f036c\", # nf-md-microphone\n \"\\U000f036c\", # nf-md-microphone\n]\nxrandr = \"\\U000f037a\" # nf-md-monitor_multiple\n\n# Weather icons (the names the weather block documents)\nweather_sun = \"\\ue30d\" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)\nweather_moon = \"\\ue32b\" # nf-weather-night_clear (when weather is reported as “Clear” at night)\nweather_clouds = \"\\ue312\" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)\nweather_clouds_night = \"\\ue37e\" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)\nweather_fog = \"\\ue313\" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)\nweather_fog_night = \"\\ue346\" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)\nweather_rain = \"\\ue318\" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)\nweather_rain_night = \"\\ue325\" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)\nweather_snow = \"\\ue31a\" # nf-weather-snow (when weather is reported as “Snow”)\nweather_thunder = \"\\ue31d\" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)\nweather_thunder_night = \"\\ue32a\" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)\n" + "template": "files/icons/custom-icons.toml" }, { "id": "updates", @@ -79,7 +84,7 @@ "path": "${machine:account-home}/.local/bin/i3status-updates", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for\n# the bottom bar's custom block (json = true). Emits one JSON line.\n#\n# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw\n# red error on every transient network failure, which on a roaming laptop is often. This keeps the\n# last good result through a failure and shows an error only after `threshold` failures in a row\n# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no\n# root); AUR packages through paru when it is installed, and none counted when it is not.\nset -uo pipefail\n\nthreshold=\"${1:-10}\"\nicon=\"update\"\nstate_dir=\"${XDG_RUNTIME_DIR:-/tmp}/i3status-updates\"\nmkdir -p \"$state_dir\"\nlast=\"$state_dir/last_good.json\"\nfailures=\"$state_dir/failures\"\n\njson() { printf '{\"icon\":\"%s\",\"state\":\"%s\",\"text\":\"%s\",\"short_text\":\"%s\"}' \"$icon\" \"$1\" \"$2\" \"$3\"; }\ncount() { [ -n \"$1\" ] && printf '%s\\n' \"$1\" | grep -c . || echo 0; }\n\nofficial=\"$(checkupdates 2>/dev/null)\"\nofficial_rc=$?\naur=\"\" aur_rc=0\nif command -v paru >/dev/null 2>&1; then\n\taur=\"$(paru -Qua 2>/dev/null)\"\n\taur_rc=$?\nfi\n\n# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.\nif { [ \"$official_rc\" -eq 0 ] || [ \"$official_rc\" -eq 2 ]; } && [ \"$aur_rc\" -eq 0 ]; then\n\to=\"$(count \"$official\")\"\n\ta=\"$(count \"$aur\")\"\n\ttotal=$((o + a))\n\tif [ \"$total\" -eq 0 ]; then\n\t\tpayload=\"$(json Good \"up to date\" \"\")\"\n\telse\n\t\t# A kernel update wants a reboot: the one worth not putting off.\n\t\tstate=Info\n\t\tprintf '%s\\n' \"$official\" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning\n\t\tlabel=\"$o + $a = $total updates\"\n\t\t[ \"$a\" -eq 0 ] && label=\"$total updates\"\n\t\t[ \"$total\" -eq 1 ] && label=\"1 update\"\n\t\tpayload=\"$(json \"$state\" \"$label\" \"$total\")\"\n\tfi\n\tprintf '%s' \"$payload\" >\"$last\"\n\tprintf '0' >\"$failures\"\n\tprintf '%s\\n' \"$payload\"\n\texit 0\nfi\n\nn=$(($(cat \"$failures\" 2>/dev/null || echo 0) + 1))\nprintf '%s' \"$n\" >\"$failures\"\nif [ \"$n\" -ge \"$threshold\" ]; then\n\tjson Critical \"update check failing (${n}x)\" \"!\"\n\techo\nelif [ -s \"$last\" ]; then\n\tcat \"$last\"\n\techo\nelse\n\tjson Idle \"\" \"\"\n\techo\nfi\n" + "template": "files/bin/i3status-updates" }, { "id": "watchdog", @@ -87,7 +92,7 @@ "path": "${machine:account-home}/.local/bin/i3bar-watchdog", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" + "template": "files/bin/i3bar-watchdog" }, { "id": "value", @@ -95,7 +100,7 @@ "path": "${machine:account-home}/.local/bin/i3status-value", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n" + "template": "files/bin/i3status-value" } ], "build": { @@ -110,14 +115,12 @@ "loads": [ "i3status-rust-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - }, - "contributions": [ - { - "seat": "node-display-session", - "kind": "config", - "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" - } - ] + } } diff --git a/testdata/beside/mesh-catalog/modules/icecast/files/config.json b/testdata/beside/mesh-catalog/modules/icecast/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/icecast/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml b/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml new file mode 100644 index 00000000..de4dd792 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml @@ -0,0 +1,56 @@ + + + Earth + icemaster@localhost + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + ${secret:source} + ${secret:relay} + admin + ${secret:admin} + + + localhost + + 8000 + + +
+ + 1 + + /usr/share/icecast + /var/log/icecast + /usr/share/icecast/web + /usr/share/icecast/admin + + + + access.log + error.log + 3 + 10000 + + + 0 + + + icecast + icecast + + + diff --git a/testdata/beside/mesh-catalog/modules/icecast/module.json.captured b/testdata/beside/mesh-catalog/modules/icecast/module.json.captured index 2e320759..6f9ab549 100644 --- a/testdata/beside/mesh-catalog/modules/icecast/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/icecast/module.json.captured @@ -47,31 +47,48 @@ } ] }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Icecast at, as a URL; unset, the code falls back to its environment, else this machine's Icecast on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Icecast admin account the module's tools sign in as; unset, the code falls back to its environment, else admin.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "logs", "type": "directory", "mode": "0700", - "owner": "100:101" + "owner": "100:101", + "unknown": "program" }, { "id": "server-conf", "type": "file", "path": "${dir:state}/icecast.xml", "mode": "0600", - "content": "\n \n Earth\n icemaster@localhost\n \n 100\n 2\n 524288\n 30\n 15\n 10\n 1\n 65535\n \n \n ${secret:source}\n ${secret:relay}\n admin\n ${secret:admin}\n \n \n localhost\n \n 8000\n \n \n
\n \n 1\n \n /usr/share/icecast\n /var/log/icecast\n /usr/share/icecast/web\n /usr/share/icecast/admin\n \n \n \n access.log\n error.log\n 3\n 10000\n \n \n 0\n \n \n icecast\n icecast\n \n \n\n" + "template": "files/icecast.xml" }, { "id": "net", @@ -100,8 +117,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["url", "user"] } ], "build": { @@ -120,8 +138,14 @@ ], "env": { "MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}", - "MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json" + "MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_ICECAST_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/influxdb/files/config.json b/testdata/beside/mesh-catalog/modules/influxdb/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/influxdb/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/influxdb/files/server.env b/testdata/beside/mesh-catalog/modules/influxdb/files/server.env new file mode 100644 index 00000000..d2552e00 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/influxdb/files/server.env @@ -0,0 +1,6 @@ +DOCKER_INFLUXDB_INIT_MODE=setup +DOCKER_INFLUXDB_INIT_USERNAME=admin +DOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin +DOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token +DOCKER_INFLUXDB_INIT_ORG=mesh +DOCKER_INFLUXDB_INIT_BUCKET=default diff --git a/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured b/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured index bad5aee4..53389a5f 100644 --- a/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured @@ -26,12 +26,32 @@ "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant" } ], + "config": { + "org": { + "type": "string", + "description": "The organisation influxdb-api names to its consumers and the module's tools write to; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "mesh" + }, + "bucket": { + "type": "string", + "description": "The bucket influxdb-api names to its consumers and the module's tools write to; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "default" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach influxdb at, as a URL; empty is this machine's influxdb on its port.", + "trusted": true, + "default": "" + } + }, "serves": { "influxdb-api": { "scheme": "http", "port": 8086, - "org": "mesh", - "bucket": "default" + "org": "${config:org}", + "bucket": "${config:bucket}" } }, "receives": { @@ -68,37 +88,42 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "1000:1000" + "owner": "1000:1000", + "unknown": "program" }, { "id": "config", "type": "directory", "mode": "0700", - "owner": "1000:1000" + "owner": "1000:1000", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n" + "template": "files/server.env" }, { "id": "server", @@ -123,8 +148,14 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "bucket", + "org", + "url" + ], + "trusted": true } ], "requires": [ @@ -156,6 +187,11 @@ "MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/invoicing/files/api.env b/testdata/beside/mesh-catalog/modules/invoicing/files/api.env new file mode 100644 index 00000000..8eaee6f0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/invoicing/files/api.env @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=9000 +MONGO_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} +MONGO_DB=${bound:mongodb-database:as} +MINIO_BUCKET=${bound:s3-bucket:bucket} +MINIO_ENDPOINT=${bound:s3-bucket:at} +MINIO_PORT=${bound:s3-bucket:port} +MINIO_ACCESSKEY=${bound:s3-bucket:as} +MINIO_SECRET=${secret:s3-bucket} diff --git a/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured b/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured index 9f8dfcf5..29670a3e 100644 --- a/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured @@ -55,20 +55,22 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "api-env", "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=${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" + "template": "files/api.env" }, { "id": "net", @@ -113,5 +115,14 @@ "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" } } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/jira/files/config.json b/testdata/beside/mesh-catalog/modules/jira/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/jira/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/jira/module.json.captured b/testdata/beside/mesh-catalog/modules/jira/module.json.captured index 84a00079..9f98dd83 100644 --- a/testdata/beside/mesh-catalog/modules/jira/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/jira/module.json.captured @@ -4,20 +4,45 @@ "own-secrets": { "token": "${dir:state}/token" }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the jira tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the jira tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:state}/config.json", "merge": "json", - "content": "{}", - "mode": "0600" + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] } ], "capabilities": [ @@ -39,6 +64,11 @@ "MESH_JIRA_TOKEN_FILE": "${dir:state}/token", "MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/kernel/files/90-mesh-kernel-boot-entries.hook b/testdata/beside/mesh-catalog/modules/kernel/files/90-mesh-kernel-boot-entries.hook new file mode 100644 index 00000000..d671caaf --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/files/90-mesh-kernel-boot-entries.hook @@ -0,0 +1,13 @@ +# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the +# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module. +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Path +Target = usr/lib/modules/*/vmlinuz + +[Action] +Description = Placing the fallback kernel on the boot menu +When = PostTransaction +Exec = ${dir:state}/boot-entries run diff --git a/testdata/beside/mesh-catalog/modules/kernel/files/boot-entries.sh b/testdata/beside/mesh-catalog/modules/kernel/files/boot-entries.sh new file mode 100644 index 00000000..ed174906 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/files/boot-entries.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10, +# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the +# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed, +# upgraded or removed. Replaced on every push; change the module, never this file. +# +# boot-entries run [ [ []]] +# +# The three are arguments only so the module's tests can run this against a stand-in tree. +# +# It reads from the machine which loader booted it, never from a setting: +# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written +# from the command line the machine booted with. +# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only +# when the set of kernels in /boot differs from the menu's. +# neither it fails loudly. The default entry is never touched. +# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this +# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place. +set -eu + +root=/ +state='${dir:state}' +[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||') +[ $# -ge 3 ] && state=$3 +boot=$root/boot +hooks=$root/etc/pacman.d/hooks +efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f +ours=90-mesh-kernel-boot-entries.hook +# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit +# before a package writes half of them. +need_kib=98304 +[ $# -ge 4 ] && need_kib=$4 + +[ $# -ge 1 ] && [ "$1" = run ] || { + echo "usage: boot-entries run [ [ []]]" >&2 + exit 2 +} + +# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot. +copies_kernels() { + grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" && + grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1" +} + +if [ -d "$hooks" ]; then + for hook in "$hooks"/*.hook; do + [ -f "$hook" ] || continue + [ "$(basename "$hook")" = "$ours" ] && continue + copies_kernels "$hook" || continue + mkdir -p "$state/taken-over" + mv -f "$hook" "$state/taken-over/$(basename "$hook")" + echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name" + done +fi + +if [ -e "$efivar" ]; then + loader=systemd-boot +elif [ -f "$boot/grub/grub.cfg" ]; then + loader=grub +else + echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2 + exit 1 +fi + +# Room first, only while the fallback kernel is not in /boot yet. +if [ ! -e "$boot/vmlinuz-linux-lts" ]; then + free=$(df -Pk "$boot" | awk 'NR==2 {print $4}') + [ -n "$free" ] || free=0 + if [ "$free" -lt "$need_kib" ]; then + echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2 + exit 1 + fi +fi + +case "$loader" in +systemd-boot) + entries=$boot/loader/entries + cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//') + [ -n "$cmdline" ] || { + echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2 + exit 1 + } + want=$( + echo "title Arch Linux (linux-lts, fallback)" + echo "linux /vmlinuz-linux-lts" + for ucode in intel-ucode.img amd-ucode.img; do + [ -e "$boot/$ucode" ] && echo "initrd /$ucode" + done + echo "initrd /initramfs-linux-lts.img" + echo "options $cmdline" + ) + mkdir -p "$entries" + if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then + echo "systemd-boot: mesh-linux-lts.conf, as it was" + else + printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf" + echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was" + fi + ;; +grub) + cfg=$boot/grub/grub.cfg + menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u) + have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true) + if [ "$menu" = "$have" ]; then + echo "grub: the menu lists the kernels in /boot, left as it is" + else + # GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the + # default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still + # overrides it when it sets one), the fallback kernel never. + top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1) + top=$(printf '%s' "$top" | sed 's|.*/||') + case "$top" in + vmlinuz-linux-lts | "") top= ;; + esac + [ -n "$top" ] && [ -e "$boot/$top" ] || top= + if [ -z "$top" ]; then + if [ -e "$boot/vmlinuz-linux" ]; then + top=vmlinuz-linux + else + top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true) + fi + fi + if [ -n "$top" ]; then + GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg" + else + grub-mkconfig -o "$cfg" + fi + first=$top + [ -n "$first" ] || first="the menu's own order" + echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was" + fi + ;; +esac diff --git a/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh b/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh new file mode 100644 index 00000000..e0a98155 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746). +# Written by the mesh from this machine's setting transparent-huge-pages and run by +# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the +# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the +# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode +# moves straight to the new value and never passes back through the one the machine was found with. +# +# huge-pages apply|restore [ []] +# +# The two directories are arguments only so the module's tests can run this against a stand-in tree. +set -eu + +want='${config:transparent-huge-pages}' +sys=/sys/kernel/mm/transparent_hugepage +state='${dir:state}' +[ $# -ge 2 ] && sys=$2 +[ $# -ge 3 ] && state=$3 + +case "$want" in +always | madvise | never) ;; +*) + echo "the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never" >&2 + exit 2 + ;; +esac +[ -f "$sys/enabled" ] || { + echo "this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set" >&2 + exit 1 +} + +# The mode in force is the word the kernel brackets: "always [madvise] never". A file holding one bare +# word (the stand-in tree of the tests) is read as that word. +mode() { sed -n -e 's/.*\[\([a-z]*\)\].*/\1/p' -e t -e 's/^ *\([a-z]*\) *$/\1/p' "$sys/enabled"; } + +[ $# -ge 1 ] || set -- usage +case "$1" in +apply) + now=$(mode) + # What the machine had before the mesh first set it, kept once, so `restore` gives it back. + [ -s "$state/found" ] || printf '%s\n' "$now" >"$state/found" + if [ "$now" = "$want" ]; then + echo "transparent huge pages: $want, as set" + exit 0 + fi + printf '%s\n' "$want" >"$sys/enabled" + got=$(mode) + [ "$got" = "$want" ] || { + echo "wrote $want to $sys/enabled and the kernel says $got" >&2 + exit 1 + } + echo "transparent huge pages: $now -> $want" + # A file folio already in the page cache keeps its size: the active journal files, mapped by journald + # and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new + # files that are mapped under the new mode. A failed rotation is said and does not fail the setting. + # At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation + # while the service manager is still bringing the machine up. + booting=$(systemctl is-system-running 2>/dev/null || true) + case "$booting" in + initializing | starting) echo "the machine is still starting: nothing to rotate yet" ;; + *) + if [ "$want" != always ] && command -v journalctl >/dev/null 2>&1; then + journalctl --rotate || echo "the journal could not be rotated: its active files keep their huge pages until they are archived" >&2 + fi + ;; + esac + ;; +restore) + found=$(cat "$state/found" 2>/dev/null || true) + case "$found" in + always | madvise | never) + printf '%s\n' "$found" >"$sys/enabled" + echo "transparent huge pages: back to $found, as the machine had them" + ;; + *) echo "transparent huge pages: nothing recorded as found, left at $(mode)" ;; + esac + # Unassigned or shut down: nothing of the module is kept; the next start records what it finds again. + rm -f "$state/found" + ;; +*) + echo "usage: huge-pages apply|restore [ []]" >&2 + exit 2 + ;; +esac diff --git a/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-boot-entries.service b/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-boot-entries.service new file mode 100644 index 00000000..56cfa319 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-boot-entries.service @@ -0,0 +1,22 @@ +[Unit] +Description=The fallback kernel's place on this machine's boot menu +# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and +# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says +# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this +# unit, so the entry is in place before they are installed. +After=local-fs.target +Wants=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/boot-entries run +# It writes the boot partition, the pacman hook directory and its own state, and nothing else. +ProtectSystem=strict +ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service b/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service new file mode 100644 index 00000000..77a90595 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service @@ -0,0 +1,25 @@ +[Unit] +Description=Transparent huge pages as the mesh sets them on this machine +# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting +# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the +# mode the machine had before. It runs early in boot, before the services that fill the page cache. +DefaultDependencies=no +After=local-fs.target +Before=sysinit.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/huge-pages apply +ExecReload=${dir:state}/huge-pages apply +ExecStop=${dir:state}/huge-pages restore +# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket. +ProtectSystem=strict +ReadWritePaths=${dir:state} +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=sysinit.target diff --git a/testdata/beside/mesh-catalog/modules/kernel/module.json.captured b/testdata/beside/mesh-catalog/modules/kernel/module.json.captured new file mode 100644 index 00000000..2a63563e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/kernel/module.json.captured @@ -0,0 +1,125 @@ +{ + "module": "kernel", + "version": "1", + "capabilities": [ + "service-manager" + ], + "config": { + "transparent-huge-pages": { + "type": "string", + "description": "madvise is the value the mesh chose for every node (novox/hq ADR 0381), not the always the distribution's kernel boots with; a machine that needs another value is given it as its own setting; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)", + "trusted": true, + "default": "madvise" + } + }, + "tools": [ + "kernel_huge_pages", + "kernel_boot" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "huge-pages", + "type": "file", + "path": "${dir:state}/huge-pages", + "mode": "0755", + "trusted": true, + "template": "files/huge-pages.sh" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-huge-pages.service", + "mode": "0644", + "template": "files/mesh-kernel-huge-pages.service" + }, + { + "id": "apply", + "type": "service", + "unit": "mesh-kernel-huge-pages.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit" + ], + "reload-on": [ + "huge-pages" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "boot-entries", + "type": "file", + "path": "${dir:state}/boot-entries", + "mode": "0755", + "trusted": true, + "template": "files/boot-entries.sh" + }, + { + "id": "boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-boot-entries.service", + "mode": "0644", + "template": "files/mesh-kernel-boot-entries.service" + }, + { + "id": "boot-hook", + "type": "file", + "path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", + "mode": "0644", + "template": "files/90-mesh-kernel-boot-entries.hook" + }, + { + "id": "boot-apply", + "type": "service", + "unit": "mesh-kernel-boot-entries.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "boot-unit", + "boot-entries" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "linux-lts", + "type": "package", + "package": "linux-lts" + }, + { + "id": "linux-lts-headers", + "type": "package", + "package": "linux-lts-headers" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/kernel-tools", + "binary": "kernel-tools", + "loads": [ + "kernel-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env b/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env new file mode 100644 index 00000000..3e52ae5f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env @@ -0,0 +1,2 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${secret:admin} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/files/config.json b/testdata/beside/mesh-catalog/modules/keycloak/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/keycloak/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json b/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json new file mode 100644 index 00000000..b0af957c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json @@ -0,0 +1 @@ +${consumers:oidc-client} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/files/database.env b/testdata/beside/mesh-catalog/modules/keycloak/files/database.env new file mode 100644 index 00000000..454ee994 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/keycloak/files/database.env @@ -0,0 +1,3 @@ +KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +KC_DB_USERNAME=${bound:postgres-database:as} +KC_DB_PASSWORD=${secret:postgres-database} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env b/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env new file mode 100644 index 00000000..92f061a1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env @@ -0,0 +1 @@ +KC_HOSTNAME=https://${bound:route:name} diff --git a/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured b/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured index 8bbf9d82..847d5f4f 100644 --- a/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured @@ -1,6 +1,7 @@ { "module": "keycloak", "version": "1", + "moved": true, "upgrade": { "policy": "record", "why": "every person's sign-in to every site goes through it, and its new version migrates its database on start: a person takes each build, after a backup (hq ADR 0236)" @@ -63,7 +64,7 @@ "authorization-path": "/protocol/openid-connect/auth", "token-path": "/protocol/openid-connect/token", "userinfo-path": "/protocol/openid-connect/userinfo", - "issuer": "${setting:issuer}" + "issuer": "${config:issuer}" } }, "receives": { @@ -84,37 +85,73 @@ } } }, + "config": { + "url": { + "type": "string", + "description": "The address the module's provider reaches Keycloak's admin API at, as a URL; empty is this machine's Keycloak on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Keycloak administrator the module's provider signs in as; empty is admin.", + "trusted": true, + "default": "" + }, + "realm": { + "type": "string", + "description": "The realm the module's provider makes its consumers' clients in; empty is master.", + "trusted": true, + "default": "" + }, + "issuer": { + "type": "string", + "description": "The issuer Keycloak's tokens name and oidc consumers are told, as the realm's URL, such as https://keycloak.example.org/realms/Example; the operator's, with no default (novox/hq ADR 0112, ADR 0155).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" }, { "id": "admin-env", "type": "file", "path": "${dir:state}/admin.env", "mode": "0600", - "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n" + "template": "files/admin.env" }, { "id": "database-env", "type": "file", "path": "${dir:state}/database.env", "mode": "0600", - "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n" + "template": "files/database.env" }, { "id": "net", @@ -126,7 +163,7 @@ "type": "file", "path": "${dir:state}/hostname.env", "mode": "0644", - "content": "KC_HOSTNAME=https://${bound:route:name}\n" + "template": "files/hostname.env" }, { "id": "server", @@ -161,9 +198,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", "merge": "json", - "takes-settings": [ + "takes-config": [ "url", "user", "realm", @@ -188,9 +225,21 @@ "MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json", - "MESH_KEYCLOAK_CONTAINER": "keycloak" + "MESH_KEYCLOAK_CONTAINER": "keycloak", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/testdata/beside/mesh-catalog/modules/lab/files/lab.env b/testdata/beside/mesh-catalog/modules/lab/files/lab.env new file mode 100644 index 00000000..35aa1a29 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/lab/files/lab.env @@ -0,0 +1,3 @@ +MESH_LAB_FORGE=${config:forge} +MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json +MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret diff --git a/testdata/beside/mesh-catalog/modules/lab/module.json.captured b/testdata/beside/mesh-catalog/modules/lab/module.json.captured index fe523f30..ef82b87c 100644 --- a/testdata/beside/mesh-catalog/modules/lab/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/lab/module.json.captured @@ -5,6 +5,22 @@ "container-runtime", "virtualisation" ], + "requires": [ + "npm-package-registry", + "go-toolchain", + "node-toolchain" + ], + "contributes": { + "npm-package-registry": { + "access": "read" + } + }, + "binds": { + "npm-package-registry": "${dir:state}/package-registry.json" + }, + "secrets": { + "npm-package-registry": "${dir:state}/package-registry.secret" + }, "data": { "own": [ { @@ -15,25 +31,35 @@ } ] }, + "config": { + "forge": { + "type": "string", + "description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "work", "type": "directory", "path": "/var/lib/mesh-lab-runs", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "runtime-env", "type": "file", "path": "${dir:state}/lab.env", "mode": "0600", - "content": "MESH_LAB_FORGE=${setting:forge}\n" + "template": "files/lab.env" }, { "id": "git", @@ -60,16 +86,6 @@ "type": "package", "package": "iproute2" }, - { - "id": "npm", - "type": "package", - "package": "npm" - }, - { - "id": "go", - "type": "package", - "package": "go" - }, { "id": "incus", "type": "package", @@ -90,8 +106,15 @@ ], "env": { "MESH_LAB_WORK": "${dir:work}", - "MESH_LAB_ENV_FILE": "${dir:state}/lab.env" + "MESH_LAB_ENV_FILE": "${dir:state}/lab.env", + "MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json", + "MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml b/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml new file mode 100644 index 00000000..6e5667da --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml @@ -0,0 +1,369 @@ +# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced +# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with +# every option, as lemurs requires); what the mesh changes from it is marked "mesh:". +# +# The sessions offered are the executable files other modules place in the two scripts directories +# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there +# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are +# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is +# where the account's environment, the X resources and every module's session lines are. +# +# Lemurs configuration file. +# Contains all the customization options of lemurs. +# +# Note: that as of now you need to have all options in the selected +# configuration file. Otherwise Lemurs will not work. +# +# Colors: +# --------- +# There is a list of predefined colors. These include: +# - black +# - white +# - (dark) gray +# - (light) red +# - (light) blue +# - (light) green +# - (light) magenta +# - (light) cyan +# - (light) yellow +# - orange +# +# You can also utilize custom colors with hex color codes. +# "#87CEEB" will create a Sky Blue color. +# +# Note: If the color wasn't recognized, it will default to white. +# --------- +# +# Modifiers: +# --------- +# There is a number of modifiers you can use. These can be combined by +# delimiting them with a comma (e.g. "bold,italic"). The modifiers are: +# - bold +# - dim +# - italic +# - underlined +# - reverse +# - crossed out +# - hidden +# --------- +# + +# The tty which contains lemurs. This has to be mirrored in the lemurs.service +tty = 2 + +# Where to log the main lemurs control flow. +main_log_path = "/var/log/lemurs.log" + +# Where to log to for the client. The Client is the Desktop Environment or +# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY. +client_log_path = "/var/log/lemurs.client.log" + +# At which point to point the cache. If you want to disable the cache globally +# you can use `/dev/null`. +cache_path = "/var/cache/lemurs" + +# Disable all logging. This is overwritten by the `--no-log` flag. +do_log = true + +# The PAM service that should be used to login +pam_service = "lemurs" + +# Path to system shell that gets used to execute linux commands. In almost all +# cases, this should refer to a bash shell. +system_shell = "/bin/sh" + +# Initial state of the `PATH` environment variable. +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin" + +# The type flag that will be appended to the shell that calls the session +# environment. This may depend on your shell. Options: +# - 'none'. Disables calling a login shell +# - 'short'. Produces the `-l` flag. Supported by most shells. +# - 'long'. This produces the `--login` flag and is suited for bash and zsh. +shell_login_flag = "short" + +# Focus behaviour of fields when Lemurs is initially started +# +# Possible values: +# - default: Initially focus on first non-cached value +# - no-focus: No initial focus +# - environment: Initially focus on the environment selector +# - username: Initially focus on the username field +# - password: Initially focus on the password field +focus_behaviour = "default" + +# General settings for background style +[background] + +# Control whether to render background widget or not +show_background = false + +[background.style] +# Allow to set the default background color for the login shell +color = "black" +# Settings for the background block's borders +show_border = true +border_color = "white" + +[power_controls] +# The margin between hints +hint_margin = 2 + +# There are no additional entries by default +entries = [] + +# Example +# Reboot to another os option +#[[power_controls.entries]] +## The text in the top-left to display how to reboot. +#hint = "Reboot to OS" +# +## The color and modifiers of the hint in the top-left corner +#hint_color = "dark gray" +#hint_modifiers = "" +# +## The key used to reboot. Possibilities are F1 to F12. +#key = "F3" +## The command that is executed when the key is pressed +#cmd = "efibootmgr -n0 && systemctl reboot -l" + + +# If you want to remove the base_entries +# base_entries = [] + +# Shutdown option +[[power_controls.base_entries]] +# The text in the top-left to display how to shutdown. +hint = "Shutdown" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to shutdown. Possibilities are F1 to F12. +key = "F1" +# The command that is executed when the key is pressed +cmd = "systemctl poweroff -l" + +# Reboot option +[[power_controls.base_entries]] +# The text in the top-left to display how to reboot. +hint = "Reboot" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to reboot. Possibilities are F1 to F12. +key = "F2" +# The command that is executed when the key is pressed +cmd = "systemctl reboot -l" + +# Setting for the selector of the desktop environment you are using. +[environment_switcher] +# Terms: +# --------- +# Movers: indicators which show which direction one can move whilst selecting +# the desktop environment +# Selected: The currently selected desktop environment. +# Neighbours: The adjacent desktop environment to the one current selected +# +# Visualisation: +# +# < i3 bspwm awesome > +# +# ^ ^ ^ ^ ^ +# | | | | | +# mover | selected | mover +# | | +# neighbour neighbour +# --------- +# + +# Control the visibility of the switcher +# Options: +# - "visible" - Always show the switcher [default] +# - "hidden" - Always hide the switcher +# - [key] - F1-F12 to be able to toggle the visibility +# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen. +switcher_visibility = "F3" + +# The text in the top-left to display how to toggle the switcher. The text +# '%key%' will be replaced with the switcher_visibility key. This is not shown +# if switcher_visibility is set to "visible" or "hidden". +toggle_hint = "Switcher %key%" + +# The color and modifiers of the hint in the top-left corner +toggle_hint_color = "dark gray" +toggle_hint_modifiers = "" + + +# Show an option for the TTY shell when logging in as one of the environments. +# NOTE: it is always shown when no viable options are found. +include_tty_shell = false + +# Remember the selected environment after logging in for the next time +remember = true + +# Enables showing the movers +show_movers = true + +# Mover's color and modifiers whilst the selector is unfocused +mover_color = "dark gray" +mover_modifiers = "" + +# Mover's color and modifiers whilst the selector is focused +mover_color_focused = "orange" +mover_modifiers_focused = "bold" + +# The characters used to display the movers. Suggestions are: +# - "<" ">" +# - "<-" "->" +# - "<<" ">>" +# - "[" "]" +left_mover = "<" +right_mover = ">" + +# The margin between the movers and the neighbours or selected (depending on +# `show_neighbours`) +mover_margin = 1 + +# Enables showing the neighbours +show_neighbours = true + +# Neighbours' color and modifiers whilst the selector is unfocused +neighbour_color = "dark gray" +neighbour_modifiers = "" + +# Neighbours' color and modifiers whilst the selector is focused +neighbour_color_focused = "gray" +neighbour_modifiers_focused = "" + +# Margin between neighbours and selected +neighbour_margin = 1 + +# Selected's color and modifiers whilst the selector is unfocused +selected_color = "gray" +selected_modifiers = "underlined" + +# Selected's color and modifiers whilst the selector is focused +selected_color_focused = "white" +selected_modifiers_focused = "bold" + +# The length of the name of the desktop environment which is displayed. +max_display_length = 8 + +# The text used when no desktop environments are available +no_envs_text = "No environments..." + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is unfocused +no_envs_color = "white" +no_envs_modifiers = "" + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is focused +no_envs_color_focused = "red" +no_envs_modifiers_focused = "" + +[username_field] + +# Remember the username for the next time after a successful login attempt. +remember = true + +[username_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Login" + +# The title's color and modifiers whilst the username field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the username field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the username field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the username field is focused +border_color_focused = "orange" + +# Constrain the width of the username field +use_max_width = true +# The constraint of the username field's width +max_width = 48 + +[password_field] + +# The character used for replacement when typing a password. Leave empty for no +# feedback. +# Note: Only one character is accepted. +content_replacement_character = "*" + +[password_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Password" + +# The title's color and modifiers whilst the password field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the password field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the password field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the password field is focused +border_color_focused = "orange" + +# Constrain the width of the password field +use_max_width = true +# The constraint of the password field's width +max_width = 48 + +[x11] +# Where to log to for the XServer. +xserver_log_path = "/var/log/lemurs.xorg.log" + +# The value of the `DISPLAY` environment variable for X11 sessions +x11_display = ":1" + +# How many seconds to give the X server to start. To make it infinitely, put it +# to 0. +xserver_timeout_secs = 60 + +# Where to find the X11 server binary +# mesh: the server through the wrapper below, which adds -noreset. +xserver_path = "/etc/lemurs/xserver" + +# Where to find the X11 xauth binary +xauth_path = "/usr/bin/xauth" + +# Path to the directory where the startup scripts for the X11 sessions are found +scripts_path = "/etc/lemurs/wms" + +# Path to the xsetup script that is needed for the environment setup of the +# window manager. +xsetup_path = "/etc/lemurs/xsetup.sh" + +# The directory to use for desktop entries X11 sessions. +# mesh: an empty directory of the module's own, so no package's desktop entry is offered. +xsessions_path = "/etc/lemurs/xsessions" + +[wayland] +# Path to the directory where the startup scripts for the Wayland sessions are +# found +scripts_path = "/etc/lemurs/wayland" + +# The directory to use for desktop entries wayland sessions. +# mesh: likewise for Wayland. +wayland_sessions_path = "/etc/lemurs/wayland-sessions" diff --git a/testdata/beside/mesh-catalog/modules/lemurs/files/xserver b/testdata/beside/mesh-catalog/modules/lemurs/files/xserver new file mode 100755 index 00000000..5908f228 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/lemurs/files/xserver @@ -0,0 +1,8 @@ +#!/bin/sh +# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push. +# +# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the +# resource database. The session's start merges the X resources with xrdb before any long-lived +# client is connected, so the server reset twice in the four seconds after a login and threw the +# resources away: xterm fell back to the bitmap "fixed" font, without the mesh's face and colours. +exec /usr/bin/X "$@" -noreset diff --git a/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured b/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured index e759bcc1..702a9596 100644 --- a/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured @@ -30,26 +30,28 @@ "type": "file", "path": "/etc/lemurs/xserver", "mode": "0755", - "content": "#!/bin/sh\n# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push.\n#\n# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the\n# resource database. The session's start merges the X resources with xrdb before any long-lived\n# client is connected, so the server reset twice in the four seconds after a login and threw the\n# resources away: xterm fell back to the bitmap \"fixed\" font, without the mesh's face and colours.\nexec /usr/bin/X \"$@\" -noreset\n" + "template": "files/xserver" }, { "id": "config", "type": "file", "path": "/etc/lemurs/config.toml", "mode": "0644", - "content": "# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced\n# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with\n# every option, as lemurs requires); what the mesh changes from it is marked \"mesh:\".\n#\n# The sessions offered are the executable files other modules place in the two scripts directories\n# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there\n# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are\n# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is\n# where the account's environment, the X resources and every module's session lines are.\n#\n# Lemurs configuration file.\n# Contains all the customization options of lemurs.\n#\n# Note: that as of now you need to have all options in the selected\n# configuration file. Otherwise Lemurs will not work.\n#\n# Colors:\n# ---------\n# There is a list of predefined colors. These include:\n# - black\n# - white\n# - (dark) gray\n# - (light) red\n# - (light) blue\n# - (light) green\n# - (light) magenta\n# - (light) cyan\n# - (light) yellow\n# - orange\n#\n# You can also utilize custom colors with hex color codes.\n# \"#87CEEB\" will create a Sky Blue color.\n#\n# Note: If the color wasn't recognized, it will default to white.\n# ---------\n#\n# Modifiers:\n# ---------\n# There is a number of modifiers you can use. These can be combined by\n# delimiting them with a comma (e.g. \"bold,italic\"). The modifiers are:\n# - bold\n# - dim\n# - italic\n# - underlined\n# - reverse\n# - crossed out\n# - hidden\n# ---------\n#\n\n# The tty which contains lemurs. This has to be mirrored in the lemurs.service\ntty = 2\n\n# Where to log the main lemurs control flow.\nmain_log_path = \"/var/log/lemurs.log\"\n\n# Where to log to for the client. The Client is the Desktop Environment or\n# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.\nclient_log_path = \"/var/log/lemurs.client.log\"\n\n# At which point to point the cache. If you want to disable the cache globally\n# you can use `/dev/null`.\ncache_path = \"/var/cache/lemurs\"\n\n# Disable all logging. This is overwritten by the `--no-log` flag.\ndo_log = true\n\n# The PAM service that should be used to login\npam_service = \"lemurs\"\n\n# Path to system shell that gets used to execute linux commands. In almost all\n# cases, this should refer to a bash shell.\nsystem_shell = \"/bin/sh\"\n\n# Initial state of the `PATH` environment variable.\ninitial_path = \"/usr/local/sbin:/usr/local/bin:/usr/bin\"\n\n# The type flag that will be appended to the shell that calls the session\n# environment. This may depend on your shell. Options:\n# - 'none'. Disables calling a login shell\n# - 'short'. Produces the `-l` flag. Supported by most shells.\n# - 'long'. This produces the `--login` flag and is suited for bash and zsh.\nshell_login_flag = \"short\"\n\n# Focus behaviour of fields when Lemurs is initially started\n#\n# Possible values:\n# - default: Initially focus on first non-cached value\n# - no-focus: No initial focus\n# - environment: Initially focus on the environment selector\n# - username: Initially focus on the username field\n# - password: Initially focus on the password field\nfocus_behaviour = \"default\"\n\n# General settings for background style\n[background]\n\n# Control whether to render background widget or not\nshow_background = false\n\n[background.style]\n# Allow to set the default background color for the login shell\ncolor = \"black\"\n# Settings for the background block's borders\nshow_border = true\nborder_color = \"white\"\n\n[power_controls]\n# The margin between hints\nhint_margin = 2\n\n# There are no additional entries by default\nentries = []\n\n# Example\n# Reboot to another os option\n#[[power_controls.entries]]\n## The text in the top-left to display how to reboot.\n#hint = \"Reboot to OS\"\n#\n## The color and modifiers of the hint in the top-left corner\n#hint_color = \"dark gray\"\n#hint_modifiers = \"\"\n#\n## The key used to reboot. Possibilities are F1 to F12.\n#key = \"F3\"\n## The command that is executed when the key is pressed\n#cmd = \"efibootmgr -n0 && systemctl reboot -l\"\n\n\n# If you want to remove the base_entries\n# base_entries = []\n\n# Shutdown option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to shutdown.\nhint = \"Shutdown\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to shutdown. Possibilities are F1 to F12.\nkey = \"F1\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl poweroff -l\"\n\n# Reboot option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to reboot.\nhint = \"Reboot\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to reboot. Possibilities are F1 to F12.\nkey = \"F2\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl reboot -l\"\n\n# Setting for the selector of the desktop environment you are using.\n[environment_switcher]\n# Terms:\n# ---------\n# Movers: indicators which show which direction one can move whilst selecting\n# the desktop environment\n# Selected: The currently selected desktop environment.\n# Neighbours: The adjacent desktop environment to the one current selected\n#\n# Visualisation:\n#\n# < i3 bspwm awesome >\n#\n# ^ ^ ^ ^ ^\n# | | | | |\n# mover | selected | mover\n# | |\n# neighbour neighbour\n# ---------\n#\n\n# Control the visibility of the switcher\n# Options:\n# - \"visible\" - Always show the switcher [default]\n# - \"hidden\" - Always hide the switcher\n# - [key] - F1-F12 to be able to toggle the visibility\n# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.\nswitcher_visibility = \"F3\"\n\n# The text in the top-left to display how to toggle the switcher. The text\n# '%key%' will be replaced with the switcher_visibility key. This is not shown\n# if switcher_visibility is set to \"visible\" or \"hidden\".\ntoggle_hint = \"Switcher %key%\"\n\n# The color and modifiers of the hint in the top-left corner\ntoggle_hint_color = \"dark gray\"\ntoggle_hint_modifiers = \"\"\n\n\n# Show an option for the TTY shell when logging in as one of the environments.\n# NOTE: it is always shown when no viable options are found.\ninclude_tty_shell = false\n\n# Remember the selected environment after logging in for the next time\nremember = true\n\n# Enables showing the movers\nshow_movers = true\n\n# Mover's color and modifiers whilst the selector is unfocused\nmover_color = \"dark gray\"\nmover_modifiers = \"\"\n\n# Mover's color and modifiers whilst the selector is focused\nmover_color_focused = \"orange\"\nmover_modifiers_focused = \"bold\"\n\n# The characters used to display the movers. Suggestions are:\n# - \"<\" \">\"\n# - \"<-\" \"->\"\n# - \"<<\" \">>\"\n# - \"[\" \"]\"\nleft_mover = \"<\"\nright_mover = \">\"\n\n# The margin between the movers and the neighbours or selected (depending on\n# `show_neighbours`)\nmover_margin = 1\n\n# Enables showing the neighbours\nshow_neighbours = true\n\n# Neighbours' color and modifiers whilst the selector is unfocused\nneighbour_color = \"dark gray\"\nneighbour_modifiers = \"\"\n\n# Neighbours' color and modifiers whilst the selector is focused\nneighbour_color_focused = \"gray\"\nneighbour_modifiers_focused = \"\"\n\n# Margin between neighbours and selected\nneighbour_margin = 1\n\n# Selected's color and modifiers whilst the selector is unfocused\nselected_color = \"gray\"\nselected_modifiers = \"underlined\"\n\n# Selected's color and modifiers whilst the selector is focused\nselected_color_focused = \"white\"\nselected_modifiers_focused = \"bold\"\n\n# The length of the name of the desktop environment which is displayed.\nmax_display_length = 8\n\n# The text used when no desktop environments are available\nno_envs_text = \"No environments...\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is unfocused\nno_envs_color = \"white\"\nno_envs_modifiers = \"\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is focused\nno_envs_color_focused = \"red\"\nno_envs_modifiers_focused = \"\"\n\n[username_field]\n\n# Remember the username for the next time after a successful login attempt.\nremember = true\n\n[username_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Login\"\n\n# The title's color and modifiers whilst the username field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the username field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the username field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the username field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the username field\nuse_max_width = true\n# The constraint of the username field's width\nmax_width = 48\n\n[password_field]\n\n# The character used for replacement when typing a password. Leave empty for no\n# feedback.\n# Note: Only one character is accepted.\ncontent_replacement_character = \"*\"\n\n[password_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Password\"\n\n# The title's color and modifiers whilst the password field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the password field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the password field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the password field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the password field\nuse_max_width = true\n# The constraint of the password field's width\nmax_width = 48\n\n[x11]\n# Where to log to for the XServer.\nxserver_log_path = \"/var/log/lemurs.xorg.log\"\n\n# The value of the `DISPLAY` environment variable for X11 sessions\nx11_display = \":1\"\n\n# How many seconds to give the X server to start. To make it infinitely, put it\n# to 0.\nxserver_timeout_secs = 60\n\n# Where to find the X11 server binary\n# mesh: the server through the wrapper below, which adds -noreset.\nxserver_path = \"/etc/lemurs/xserver\"\n\n# Where to find the X11 xauth binary\nxauth_path = \"/usr/bin/xauth\"\n\n# Path to the directory where the startup scripts for the X11 sessions are found\nscripts_path = \"/etc/lemurs/wms\"\n\n# Path to the xsetup script that is needed for the environment setup of the\n# window manager.\nxsetup_path = \"/etc/lemurs/xsetup.sh\"\n\n# The directory to use for desktop entries X11 sessions.\n# mesh: an empty directory of the module's own, so no package's desktop entry is offered.\nxsessions_path = \"/etc/lemurs/xsessions\"\n\n[wayland]\n# Path to the directory where the startup scripts for the Wayland sessions are\n# found\nscripts_path = \"/etc/lemurs/wayland\"\n\n# The directory to use for desktop entries wayland sessions.\n# mesh: likewise for Wayland.\nwayland_sessions_path = \"/etc/lemurs/wayland-sessions\"\n" + "template": "files/config.toml" }, { "id": "xsessions", "type": "directory", "path": "/etc/lemurs/xsessions", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "wayland-sessions", "type": "directory", "path": "/etc/lemurs/wayland-sessions", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "service", @@ -71,6 +73,11 @@ "loads": [ "lemurs-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/letta/files/config.json b/testdata/beside/mesh-catalog/modules/letta/files/config.json new file mode 100644 index 00000000..8ed994da --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/letta/files/config.json @@ -0,0 +1,3 @@ +{ + "password": "${secret:server-password}" +} diff --git a/testdata/beside/mesh-catalog/modules/letta/files/pgpass b/testdata/beside/mesh-catalog/modules/letta/files/pgpass new file mode 100644 index 00000000..cd41b665 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/letta/files/pgpass @@ -0,0 +1 @@ +*:*:*:${bound:postgres-database:as}:${secret:postgres-database} diff --git a/testdata/beside/mesh-catalog/modules/letta/files/server.env b/testdata/beside/mesh-catalog/modules/letta/files/server.env new file mode 100644 index 00000000..fe2acb9e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/letta/files/server.env @@ -0,0 +1,6 @@ +LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +PGPASSFILE=/run/secrets/pgpass +LETTA_SERVER_PASSWORD=${secret:server-password} +OPENAI_API_KEY=${secret:openai-api-key} +SECURE=true +TZ=Europe/Brussels diff --git a/testdata/beside/mesh-catalog/modules/letta/files/start.sh b/testdata/beside/mesh-catalog/modules/letta/files/start.sh new file mode 100644 index 00000000..99555927 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/letta/files/start.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push. +# +# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268): +# letta/server/rest_api/app.py prints its server password when it starts in secure mode; +# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole. +# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server +# password is rewritten before the server starts. Either one failing refuses the start: a letta +# that does not start says why here, and one that leaks says nothing. +set -e +app=/app/letta/server/rest_api/app.py +sed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' "$app" +if grep -q 'print(.*random_password' "$app"; then + echo "letta: $app still prints the server password; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +case "$LETTA_PG_URI" in + *://*:*@*) + echo "letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)" >&2 + exit 1 + ;; +esac +exec ./letta/server/startup.sh diff --git a/testdata/beside/mesh-catalog/modules/letta/module.json.captured b/testdata/beside/mesh-catalog/modules/letta/module.json.captured index 50013b0c..de7baf64 100644 --- a/testdata/beside/mesh-catalog/modules/letta/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/letta/module.json.captured @@ -47,39 +47,49 @@ "why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's" } ], + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Letta at, as a URL; unset, the code falls back to its environment, else this machine's Letta on port 8283.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nPGPASSFILE=/run/secrets/pgpass\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n" + "template": "files/server.env" }, { "id": "pgpass", "type": "file", "path": "${dir:state}/pgpass", "mode": "0600", - "content": "*:*:*:${bound:postgres-database:as}:${secret:postgres-database}\n" + "template": "files/pgpass" }, { "id": "start", "type": "file", "path": "${dir:state}/start.sh", "mode": "0644", - "content": "#!/bin/sh\n# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push.\n#\n# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268):\n# letta/server/rest_api/app.py prints its server password when it starts in secure mode;\n# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole.\n# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server\n# password is rewritten before the server starts. Either one failing refuses the start: a letta\n# that does not start says why here, and one that leaks says nothing.\nset -e\napp=/app/letta/server/rest_api/app.py\nsed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' \"$app\"\nif grep -q 'print(.*random_password' \"$app\"; then\n echo \"letta: $app still prints the server password; not starting (novox/hq issue 268)\" >&2\n exit 1\nfi\ncase \"$LETTA_PG_URI\" in\n *://*:*@*)\n echo \"letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)\" >&2\n exit 1\n ;;\nesac\nexec ./letta/server/startup.sh\n" + "template": "files/start.sh" }, { "id": "net", @@ -113,8 +123,10 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"password\": \"${secret:server-password}\"\n}\n", - "merge": "json" + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"], + "protected": ["password"] } ], "build": { @@ -133,6 +145,11 @@ "MESH_LETTA_URL": "http://127.0.0.1:${port:8283}", "MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env b/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env new file mode 100644 index 00000000..9aca3962 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env @@ -0,0 +1,3 @@ +OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1 +OPENAI_MODEL=${bound:model-access:model} +OPENAI_API_KEY=local diff --git a/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured b/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured index e90fbf5c..73002092 100644 --- a/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured @@ -13,19 +13,30 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "openai-env", "type": "file", "path": "${dir:config}/openai.env", "mode": "0600", - "content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\nOPENAI_MODEL=${bound:model-access:model}\nOPENAI_API_KEY=local\n" + "template": "files/openai.env" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/localization/files/locale.conf b/testdata/beside/mesh-catalog/modules/localization/files/locale.conf new file mode 100644 index 00000000..fe405b50 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/localization/files/locale.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every +# push; an edit here is overwritten. Read at the next login. +LANG=en_US.UTF-8 diff --git a/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf b/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf new file mode 100644 index 00000000..88d96f68 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every +# push; an edit here is overwritten. Read at the next boot. +KEYMAP=us diff --git a/testdata/beside/mesh-catalog/modules/localization/module.json.captured b/testdata/beside/mesh-catalog/modules/localization/module.json.captured index 020b9026..0974ed38 100644 --- a/testdata/beside/mesh-catalog/modules/localization/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/localization/module.json.captured @@ -16,14 +16,14 @@ "type": "file", "path": "/etc/locale.conf", "mode": "0644", - "content": "# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every\n# push; an edit here is overwritten. Read at the next login.\nLANG=en_US.UTF-8\n" + "template": "files/locale.conf" }, { "id": "keymap", "type": "file", "path": "/etc/vconsole.conf", "mode": "0644", - "content": "# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every\n# push; an edit here is overwritten. Read at the next boot.\nKEYMAP=us\n" + "template": "files/vconsole.conf" }, { "id": "time-zone", @@ -50,6 +50,11 @@ "loads": [ "localization-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf b/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf new file mode 100644 index 00000000..d6461375 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf @@ -0,0 +1,32 @@ +# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits. +# Written whole at every push; an edit here is overwritten. Each package's own rules are in +# /etc/logrotate.d and stay the packages'. + +# Weekly, four weeks kept, a new empty log created after each rotation. +weekly +rotate 4 +create + +# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open +# loses nothing to the compression. +compress +delaycompress + +# A package's replaced configuration is never read as a rule. +tabooext + .pacorig .pacnew .pacsave + +include /etc/logrotate.d + +/var/log/wtmp { + monthly + create 0664 root utmp + minsize 1M + rotate 1 +} + +/var/log/btmp { + missingok + monthly + create 0600 root utmp + rotate 1 +} diff --git a/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured b/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured index 23d37678..8318451b 100644 --- a/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured @@ -25,7 +25,7 @@ "type": "file", "path": "/etc/logrotate.conf", "mode": "0644", - "content": "# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits.\n# Written whole at every push; an edit here is overwritten. Each package's own rules are in\n# /etc/logrotate.d and stay the packages'.\n\n# Weekly, four weeks kept, a new empty log created after each rotation.\nweekly\nrotate 4\ncreate\n\n# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open\n# loses nothing to the compression.\ncompress\ndelaycompress\n\n# A package's replaced configuration is never read as a rule.\ntabooext + .pacorig .pacnew .pacsave\n\ninclude /etc/logrotate.d\n\n/var/log/wtmp {\n monthly\n create 0664 root utmp\n minsize 1M\n rotate 1\n}\n\n/var/log/btmp {\n missingok\n monthly\n create 0600 root utmp\n rotate 1\n}\n" + "template": "files/logrotate.conf" }, { "id": "timer", @@ -47,6 +47,11 @@ "loads": [ "logrotate-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mailu/files/admin.env b/testdata/beside/mesh-catalog/modules/mailu/files/admin.env new file mode 100644 index 00000000..f066bb56 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mailu/files/admin.env @@ -0,0 +1,2 @@ +INITIAL_ADMIN_PW=${secret:admin} +API_TOKEN=${secret:api-token} diff --git a/testdata/beside/mesh-catalog/modules/mailu/files/config.json b/testdata/beside/mesh-catalog/modules/mailu/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mailu/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/mailu/files/database.env b/testdata/beside/mesh-catalog/modules/mailu/files/database.env new file mode 100644 index 00000000..ba70d3d6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mailu/files/database.env @@ -0,0 +1,5 @@ +DB_FLAVOR=postgresql +DB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +DB_USER=${bound:postgres-database:as} +DB_NAME=${bound:postgres-database:as} +DB_PW=${secret:postgres-database} diff --git a/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env b/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env new file mode 100644 index 00000000..2c977d30 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env @@ -0,0 +1,78 @@ +ADMIN_ADDRESS=mailu-admin +ANTISPAM_ADDRESS=mailu-antispam +ANTIVIRUS_ADDRESS=mailu-antivirus +IMAP_ADDRESS=mailu-imap +SMTP_ADDRESS=mailu-smtp +FRONT_ADDRESS=mailu-front +WEBMAIL_ADDRESS=mailu-webmail +WEBDAV_ADDRESS=mailu-webdav +REDIS_ADDRESS=mailu-redis +PORTS=25,80,443,465,993,995,4190,110,143,587 +DOMAIN=${config:domain} +HOSTNAMES=${bound:route:name-web} +POSTMASTER=admin +SITENAME=${config:sitename} +WEBSITE=${config:website} +TLS_FLAVOR=letsencrypt +SUBNET=192.168.203.0/24 +COMPOSE_PROJECT_NAME=mailu +ANTIVIRUS=clamav +WEBMAIL=roundcube +WEBDAV=radicale +FETCHMAIL_ENABLED=True +FETCHMAIL_DELAY=600 +ADMIN=true +WEB_ADMIN=/admin +WEB_WEBMAIL=/webmail +WEBROOT_REDIRECT=/webmail +API=true +WEB_API=/api +AUTH_RATELIMIT_IP=6000/hour +AUTH_RATELIMIT_USER=1000/day +CREDENTIAL_ROUNDS=12 +PASSWORD_SCHEME=PBKDF2 +DISABLE_STATISTICS=True +MESSAGE_SIZE_LIMIT=50000000 +MESSAGE_RATELIMIT=200/day +RECIPIENT_DELIMITER=+ +POSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128 +RELAYNETS= +RELAYHOST= +REJECT_UNLISTED_RECIPIENT= +DB_FLAVOR=postgresql +INITIAL_ADMIN_ACCOUNT=admin +INITIAL_ADMIN_DOMAIN=${config:domain} +INITIAL_ADMIN_MODE=ifmissing +SMTP_PORT=25 +SMTPS_PORT=465 +SUBMISSION_PORT=587 +POP3_PORT=110 +POP3S_PORT=995 +IMAP_PORT=143 +IMAPS_PORT=993 +HTTP_PORT=7080 +HTTPS_PORT=7443 +AUTOMX_PORT=4243 +AMX_SMTP_ADDRESS=${bound:route:name-web} +AMX_SMTP_PORT=587 +AMX_IMAP_ADDRESS=${bound:route:name-web} +AMX_IMAP_PORT=143 +AMX_MAIL_DOMAINS=${config:domain} +DMARC_RUA=admin +DMARC_RUF=admin +LETSENCRYPT_SHORTCHAIN=True +TZ=Etc/UTC +LOG_LEVEL=INFO +WELCOME=false +REAL_IP_HEADER=X-Real-IP +REAL_IP_FROM=${config:proxy-address} +COMPRESSION= +COMPRESS_LEVEL= +COMPRESSION_LEVEL= +BIND_ADDRESS4=127.0.0.1 +BIND_ADDRESS6=::1 +MAILU_VERSION=1.9 +DOCKER_ORG=mailu +DOCKER_PREFIX= +WELCOME_SUBJECT=Welcome to your new email account +WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly! diff --git a/testdata/beside/mesh-catalog/modules/mailu/files/secret.env b/testdata/beside/mesh-catalog/modules/mailu/files/secret.env new file mode 100644 index 00000000..c6f6b66b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mailu/files/secret.env @@ -0,0 +1 @@ +SECRET_KEY=${secret:secret-key} diff --git a/testdata/beside/mesh-catalog/modules/mailu/module.json.captured b/testdata/beside/mesh-catalog/modules/mailu/module.json.captured index 3a4a3568..93d72adb 100644 --- a/testdata/beside/mesh-catalog/modules/mailu/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mailu/module.json.captured @@ -5,6 +5,10 @@ "policy": "record", "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" }, + "unhealthy": { + "severity": "urgent", + "why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)" + }, "capabilities": [ "container-runtime" ], @@ -56,6 +60,19 @@ "api-token": "${dir:state}/api-token.secret" } }, + "tools": [ + "mailu_list_users", + "mailu_create_user", + "mailu_change_password", + "mailu_delete_user", + "mailu_list_aliases", + "mailu_create_alias", + "mailu_delete_alias", + "mailu_list_domains", + "mailu_read_mail", + "mailu_search_mail", + "mailu_health" + ], "emits": [ "user.created", "user.deleted", @@ -198,12 +215,6 @@ "class": "rebuildable", "why": "certificates, issued again" }, - { - "id": "automx", - "path": "${dir:data-automx}", - "class": "rebuildable", - "why": "client autoconfiguration, written again" - }, { "id": "fetchmail", "path": "${dir:data-fetchmail}", @@ -231,136 +242,188 @@ } } }, + "config": { + "domain": { + "type": "string", + "description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "proxy-address": { + "type": "string", + "description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.", + "trusted": true, + "required": true + }, + "sitename": { + "type": "string", + "description": "The name Mailu's web pages show for the mail service; the operator's, with no default.", + "trusted": true, + "required": true + }, + "website": { + "type": "string", + "description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.", + "trusted": true, + "required": true + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.", + "trusted": true, + "default": "" + }, + "container": { + "type": "string", + "description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" - }, - { - "id": "data-automx", - "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "config-env", "type": "file", "path": "${dir:state}/mailu.env", "mode": "0644", - "content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=${setting:domain}\nHOSTNAMES=${bound:route:name-web}\nPOSTMASTER=admin\nSITENAME=${setting:sitename}\nWEBSITE=${setting:website}\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=${setting:domain}\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=${bound:route:name-web}\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=${bound:route:name-web}\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=${setting:domain}\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=${setting:proxy-address}\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" + "template": "files/mailu.env" }, { "id": "secret-env", "type": "file", "path": "${dir:state}/secret.env", "mode": "0600", - "content": "SECRET_KEY=${secret:secret-key}\n" + "template": "files/secret.env" }, { "id": "database-env", "type": "file", "path": "${dir:state}/database.env", "mode": "0600", - "content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n" + "template": "files/database.env" }, { "id": "admin-env", "type": "file", "path": "${dir:state}/admin.env", "mode": "0600", - "content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n" + "template": "files/admin.env" }, { "id": "data-certs", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-data", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-dkim", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-mail", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-mailqueue", "type": "directory", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "data-filter", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-clamav", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-redis", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-webmail", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-dav", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-fetchmail", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-overrides-nginx", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-overrides-dovecot", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-overrides-postfix", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-overrides-rspamd", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data-overrides-roundcube", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "net", @@ -371,7 +434,7 @@ "id": "resolver", "type": "container", "name": "mailu-resolver", - "image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a", + "image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e", "health": { "kind": "runtime" }, @@ -443,7 +506,12 @@ "name": "mailu-smtp", "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", "health": { - "kind": "runtime" + "kind": "tool", + "tool": "mailu_health", + "interval": "60s", + "timeout": "30s", + "looks": 3, + "grace": "120s" }, "network": "mailu", "env-file": [ @@ -560,7 +628,12 @@ "name": "mailu-front", "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", "health": { - "kind": "runtime" + "kind": "exec", + "command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem", + "interval": "60s", + "timeout": "15s", + "looks": 3, + "grace": "120s" }, "network": "mailu", "env-file": [ @@ -591,14 +664,29 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"], "merge": "json" }, + { + "id": "program-automx", + "type": "archive", + "path": "${dir:state}/automx", + "artifact": "automx", + "unknown": "program" + }, { "id": "automx", "type": "container", "name": "mailu-automx", - "artifact": "automx", + "artifact": "alpine", + "health": { + "kind": "http", + "endpoint": "autoconfig", + "path": "/", + "status": 200, + "body": "Show Thunderbird-style" + }, "network": "mailu", "env-file": [ "${dir:state}/mailu.env" @@ -607,17 +695,18 @@ "4243" ], "volumes": [ - "${dir:data-automx}:/data" + "${dir:state}/automx:/opt/mesh:ro" + ], + "args": [ + "/opt/mesh/automx" + ], + "restart-on": [ + "program-automx", + "config-env" ] } ], "build": { - "on": [ - { - "arg": "PYTHON_BASE", - "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" - } - ], "artifacts": [ { "name": "code", @@ -637,14 +726,28 @@ "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_SMTP_CONTAINER": "mailu-smtp", "MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_RECEIVES": "${dir:grants}/mesh.json" } }, { "name": "automx", - "kind": "image", - "from": "automx/Dockerfile" + "kind": "bundle", + "language": "go", + "system": "alpine", + "from": "cmd/automx", + "binary": "automx" + }, + { + "name": "alpine", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -661,7 +764,7 @@ "serves": { "smtp": { "port": 587, - "domain": "${setting:domain}" + "domain": "${config:domain}" } }, "receives": { @@ -673,7 +776,7 @@ "jails": [ { "name": "mailu-front", - "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)", + "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: , server: \\S+:25,", "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" } ] diff --git a/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured b/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured index bd12abfa..ed242aa3 100644 --- a/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured @@ -18,7 +18,8 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "server", diff --git a/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml b/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml new file mode 100644 index 00000000..9b724010 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml @@ -0,0 +1,15 @@ +# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes +# from the environment. server_name is the homeserver's permanent identity: every user id, room id +# and signature in the database carries it, so it is the name this module is served under +# (${bound:route:name-homeserver}) and never changes once a database exists. +[global] +server_name = "${bound:route:name-homeserver}" +database_backend = "rocksdb" +database_path = "/var/lib/matrix-conduit/" +address = "0.0.0.0" +port = 6167 +max_request_size = 20000000 +allow_registration = false +allow_federation = true +allow_check_for_updates = true +trusted_servers = ["matrix.org"] diff --git a/testdata/beside/mesh-catalog/modules/matrix/files/element.json b/testdata/beside/mesh-catalog/modules/matrix/files/element.json new file mode 100644 index 00000000..305a89f7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/matrix/files/element.json @@ -0,0 +1,53 @@ +{ + "default_server_name": "${bound:route:name-homeserver}", + "default_server_config": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + }, + "brand": "Element", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ], + "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "uisi_autorageshake_app": "element-auto-uisi", + "show_labs_settings": true, + "room_directory": { + "servers": [ + "${bound:route:name-homeserver}", + "matrix.org", + "gitter.im", + "libera.chat" + ] + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + }, + "terms_and_conditions_links": [ + { + "url": "https://element.io/privacy", + "text": "Privacy Policy" + }, + { + "url": "https://element.io/cookie-policy", + "text": "Cookie Policy" + } + ], + "features": { + "feature_video_rooms": true, + "feature_rust_crypto": true + }, + "element_call": { + "url": "https://call.element.dev" + } +} diff --git a/testdata/beside/mesh-catalog/modules/matrix/module.json.captured b/testdata/beside/mesh-catalog/modules/matrix/module.json.captured index eded5f65..170719cc 100644 --- a/testdata/beside/mesh-catalog/modules/matrix/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/matrix/module.json.captured @@ -48,24 +48,133 @@ } ] }, + "config": { + "default_server_name": { + "type": "string", + "description": "Element's default_server_name setting of config.json: the homeserver Element signs in to by default, by name; the file's default is the module's own route.", + "trusted": true, + "default": "${bound:route:name-homeserver}" + }, + "default_server_config": { + "type": "object", + "description": "Element's default_server_config setting of config.json: the homeserver and identity server Element uses by default, by URL.", + "trusted": true, + "default": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + } + }, + "brand": { + "type": "string", + "description": "Element's brand setting of config.json: the name Element shows itself as.", + "trusted": true, + "default": "Element" + }, + "integrations_ui_url": { + "type": "string", + "description": "Element's integrations_ui_url setting of config.json: the integration manager's page, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/" + }, + "integrations_rest_url": { + "type": "string", + "description": "Element's integrations_rest_url setting of config.json: the integration manager's API, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/api" + }, + "integrations_widgets_urls": { + "type": "list", + "description": "Element's integrations_widgets_urls setting of config.json: the URLs whose widgets Element trusts as the integration manager's.", + "trusted": true, + "default": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ] + }, + "bug_report_endpoint_url": { + "type": "string", + "description": "Element's bug_report_endpoint_url setting of config.json: where Element sends a bug report, a URL; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": "" + }, + "uisi_autorageshake_app": { + "type": "string", + "description": "Element's uisi_autorageshake_app setting of config.json: the app name Element files an automatic report of an undecryptable message under.", + "trusted": true, + "default": "element-auto-uisi" + }, + "show_labs_settings": { + "type": "boolean", + "description": "Element's show_labs_settings setting of config.json: whether Element shows its Labs settings.", + "trusted": true, + "default": true + }, + "room_directory": { + "type": "object", + "description": "Element's room_directory setting of config.json: the servers whose room directories Element offers; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": {} + }, + "enable_presence_by_hs_url": { + "type": "object", + "description": "Element's enable_presence_by_hs_url setting of config.json: whether Element shows presence, per homeserver URL.", + "trusted": true, + "default": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + } + }, + "terms_and_conditions_links": { + "type": "list", + "description": "Element's terms_and_conditions_links setting of config.json: the links Element shows as its terms, each a URL and its text; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": [] + }, + "features": { + "type": "object", + "description": "Element's features setting of config.json: Element's feature switches, each on or off.", + "trusted": true, + "default": { + "feature_video_rooms": true, + "feature_rust_crypto": true + } + }, + "element_call": { + "type": "object", + "description": "Element's element_call setting of config.json: where Element starts a call with Element Call, a URL.", + "trusted": true, + "default": { + "url": "https://call.element.dev" + } + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "db", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "conduit-conf", "type": "file", "path": "${dir:state}/conduit.toml", "mode": "0644", - "content": "# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes\n# from the environment. server_name is the homeserver's permanent identity: every user id, room id\n# and signature in the database carries it, so it is the name this module is served under\n# (${bound:route:name-homeserver}) and never changes once a database exists.\n[global]\nserver_name = \"${bound:route:name-homeserver}\"\ndatabase_backend = \"rocksdb\"\ndatabase_path = \"/var/lib/matrix-conduit/\"\naddress = \"0.0.0.0\"\nport = 6167\nmax_request_size = 20000000\nallow_registration = false\nallow_federation = true\nallow_check_for_updates = true\ntrusted_servers = [\"matrix.org\"]\n", + "template": "files/conduit.toml", "names-on-purpose": { "matrix.org": "the federation's public key server, trusted by default; the world's, not this mesh's" } @@ -76,7 +185,8 @@ "path": "${dir:state}/element.json", "mode": "0644", "merge": "json", - "content": "{\n \"default_server_name\": \"${bound:route:name-homeserver}\",\n \"default_server_config\": {\n \"m.homeserver\": {\n \"base_url\": \"https://${bound:route:name-homeserver}\"\n },\n \"m.identity_server\": {\n \"base_url\": \"https://vector.im\"\n }\n },\n \"brand\": \"Element\",\n \"integrations_ui_url\": \"https://scalar.vector.im/\",\n \"integrations_rest_url\": \"https://scalar.vector.im/api\",\n \"integrations_widgets_urls\": [\n \"https://scalar.vector.im/_matrix/integrations/v1\",\n \"https://scalar.vector.im/api\",\n \"https://scalar-staging.vector.im/_matrix/integrations/v1\",\n \"https://scalar-staging.vector.im/api\",\n \"https://scalar-staging.riot.im/scalar/api\"\n ],\n \"bug_report_endpoint_url\": \"https://element.io/bugreports/submit\",\n \"uisi_autorageshake_app\": \"element-auto-uisi\",\n \"show_labs_settings\": true,\n \"room_directory\": {\n \"servers\": [\n \"${bound:route:name-homeserver}\",\n \"matrix.org\",\n \"gitter.im\",\n \"libera.chat\"\n ]\n },\n \"enable_presence_by_hs_url\": {\n \"https://matrix.org\": false,\n \"https://matrix-client.matrix.org\": false\n },\n \"terms_and_conditions_links\": [\n {\n \"url\": \"https://element.io/privacy\",\n \"text\": \"Privacy Policy\"\n },\n {\n \"url\": \"https://element.io/cookie-policy\",\n \"text\": \"Cookie Policy\"\n }\n ],\n \"features\": {\n \"feature_video_rooms\": true,\n \"feature_rust_crypto\": true\n },\n \"element_call\": {\n \"url\": \"https://call.element.dev\"\n }\n}\n", + "takes-config": ["default_server_name", "default_server_config", "brand", "integrations_ui_url", "integrations_rest_url", "integrations_widgets_urls", "bug_report_endpoint_url", "uisi_autorageshake_app", "show_labs_settings", "room_directory", "enable_presence_by_hs_url", "terms_and_conditions_links", "features", "element_call"], + "template": "files/element.json", "names-on-purpose": { "matrix.org": "the public room directory and the federation's largest homeserver; the world's", "matrix-client.matrix.org": "the same homeserver's client endpoint; the world's", @@ -134,5 +244,14 @@ "element-conf" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf b/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf new file mode 100644 index 00000000..39af6def --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf @@ -0,0 +1,8 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is +# 90 % used, or when a watched unit stalls on memory for 20 s above its limit. +[OOM] +SwapUsedLimit=90% +DefaultMemoryPressureLimit=60% +DefaultMemoryPressureDurationSec=20s diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf b/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf new file mode 100644 index 00000000..edae3de8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf @@ -0,0 +1,5 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may act on swap exhaustion across the whole machine. +[Slice] +ManagedOOMSwap=kill diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf b/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf new file mode 100644 index 00000000..678251cb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf @@ -0,0 +1,9 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in +# RAM has none — the speculation only costs decompression and memory. +vm.page-cluster = 0 +# +# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the +# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing +# being avoided. Raise it only together with zram-size. diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf b/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf new file mode 100644 index 00000000..8ef300c6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays +# above 80 % — instead of the kernel freezing the machine. +[Service] +ManagedOOMMemoryPressure=kill +ManagedOOMMemoryPressureLimit=80% diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf b/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf new file mode 100644 index 00000000..cccfb05a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday +# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd. +# The swap on disk, its size and whether one exists at all are the machine's, not this module's. +# +# Read by the generator at boot: a change applies at the next boot. +[zram0] +zram-size = min(ram / 2, 16384) +compression-algorithm = zstd +swap-priority = 100 diff --git a/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured b/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured index 95eeb71b..1af96e30 100644 --- a/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured @@ -28,20 +28,21 @@ "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" + "template": "files/zram-generator.conf" }, { "id": "sysctl-drop-ins", "type": "directory", "path": "/etc/sysctl.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "files/sysctl.conf" }, { "id": "sysctl", @@ -55,40 +56,43 @@ "id": "oomd-drop-ins", "type": "directory", "path": "/etc/systemd/oomd.conf.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "files/oomd.conf" }, { "id": "root-slice-drop-ins", "type": "directory", "path": "/etc/systemd/system/-.slice.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "files/root-slice.conf" }, { "id": "user-manager-drop-ins", "type": "directory", "path": "/etc/systemd/system/user@.service.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "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" + "template": "files/user-manager.conf" }, { "id": "oomd", @@ -115,6 +119,11 @@ "loads": [ "memory-pressure" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/Z.txt b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/Z.txt new file mode 100644 index 00000000..b6802534 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/Z.txt @@ -0,0 +1 @@ +z diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/a.txt b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/a.txt new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/a.txt @@ -0,0 +1 @@ +hello diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/bin/run b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/bin/run new file mode 100755 index 00000000..4163036e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/bin/run @@ -0,0 +1,2 @@ +#!/bin/sh +echo hi diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/sub/b.conf b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/sub/b.conf new file mode 100644 index 00000000..79c53955 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/sub/b.conf @@ -0,0 +1 @@ +nested diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge new file mode 100644 index 00000000..6bbf4e20 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig new file mode 100644 index 00000000..05252302 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts new file mode 100644 index 00000000..e0df6d81 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/files/registry b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured new file mode 100644 index 00000000..db0dbe1c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured @@ -0,0 +1,192 @@ +{ + "module": "mesh-build-go", + "version": "1", + "moved": true, + "runs-as": "mesh-build-go", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "state": [ + "toolchains", + "keys", + "steps" + ], + "claims": [ + { + "name": "node-build-go", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "reads": [ + "mesh-delivery-checker.kept" + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, + "tools": [ + "build_go_fetch", + "build_go_test", + "build_go_digest", + "build_go_build", + "build_go_release", + "build_go_step", + "build_go_steps", + "build_go_toolchains", + "build_go_status", + "build_go_health" + ], + "streams": [ + { + "name": "toolchains-go", + "events": ["toolchain.**"] + } + ], + "requires": [ + "forge-account", + "go-toolchain", + "artifact-store" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-build-go", + "runtime-health": { + "kind": "tool", + "tool": "build_go_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-build-go", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-build-go" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-build-go", + "place": ".", + "unknown": "program" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-build-go-work", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "path": "/var/lib/mesh-build-go-cache", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-go", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "ssh-dir", + "type": "directory", + "path": "${dir:state}/.ssh", + "mode": "0700", + "owner": "mesh-build-go", + "unknown": "program" + }, + { + "id": "ssh-config", + "type": "file", + "path": "${dir:ssh-dir}/config", + "owner": "mesh-build-go", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${dir:ssh-dir}/known_hosts", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/known_hosts" + }, + { + "id": "git-config", + "type": "file", + "path": "${dir:state}/.config/git/config", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/gitconfig" + }, + { + "id": "registry", + "type": "file", + "path": "${dir:state}/registry", + "owner": "mesh-build-go", + "mode": "0644", + "template": "files/registry" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + }, + { + "id": "bubblewrap", + "type": "package", + "package": "bubblewrap" + }, + { + "id": "postgresql", + "type": "package", + "package": "postgresql" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-build-go", + "binary": "mesh-build-go", + "loads": [ + "mesh-build-go" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/files/forge b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/forge new file mode 100644 index 00000000..6bbf4e20 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/files/gitconfig b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/gitconfig new file mode 100644 index 00000000..05252302 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/files/known_hosts b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/known_hosts new file mode 100644 index 00000000..e0df6d81 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/files/registry b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-image/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-build-image/module.json.captured new file mode 100644 index 00000000..2678972c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-image/module.json.captured @@ -0,0 +1,170 @@ +{ + "module": "mesh-build-image", + "version": "1", + "moved": true, + "runs-as": "mesh-build-image", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "state": [ + "keys", + "steps" + ], + "claims": [ + { + "name": "node-build-image", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, + "tools": [ + "build_image_fetch", + "build_image_build", + "build_image_copy", + "build_image_release", + "build_image_step", + "build_image_steps", + "build_image_status", + "build_image_health" + ], + "requires": [ + "forge-account", + "rootless-image-build", + "artifact-store" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-build-image", + "runtime-health": { + "kind": "tool", + "tool": "build_image_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-build-image", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-build-image" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-build-image", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-image", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-build-image-work", + "mode": "0700", + "owner": "mesh-build-image", + "unknown": "program" + }, + { + "id": "store", + "type": "directory", + "path": "/var/lib/mesh-build-image-store", + "mode": "0700", + "owner": "mesh-build-image", + "unknown": "program" + }, + { + "id": "ssh-dir", + "type": "directory", + "path": "${dir:state}/.ssh", + "mode": "0700", + "owner": "mesh-build-image", + "unknown": "program" + }, + { + "id": "ssh-config", + "type": "file", + "path": "${dir:ssh-dir}/config", + "owner": "mesh-build-image", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${dir:ssh-dir}/known_hosts", + "owner": "mesh-build-image", + "mode": "0644", + "template": "files/known_hosts" + }, + { + "id": "git-config", + "type": "file", + "path": "${dir:state}/.config/git/config", + "owner": "mesh-build-image", + "mode": "0644", + "template": "files/gitconfig" + }, + { + "id": "registry", + "type": "file", + "path": "${dir:state}/registry", + "owner": "mesh-build-image", + "mode": "0644", + "template": "files/registry" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-build-image", + "binary": "mesh-build-image", + "loads": [ + "mesh-build-image" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge new file mode 100644 index 00000000..6bbf4e20 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig new file mode 100644 index 00000000..05252302 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts new file mode 100644 index 00000000..e0df6d81 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/files/registry b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured new file mode 100644 index 00000000..c3b1cf84 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured @@ -0,0 +1,210 @@ +{ + "module": "mesh-build-node", + "version": "1", + "moved": true, + "runs-as": "mesh-build-node", + "capabilities": [ + "package-manager", + "user-namespaces" + ], + "state": [ + "toolchains", + "keys", + "steps" + ], + "claims": [ + { + "name": "node-build-node", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, + "tools": [ + "build_node_fetch", + "build_node_test", + "build_node_build", + "build_node_release", + "build_node_step", + "build_node_steps", + "build_node_toolchains", + "build_node_status", + "build_node_health" + ], + "streams": [ + { + "name": "toolchains-node", + "events": [ + "toolchain.**" + ] + } + ], + "requires": [ + "forge-account", + "npm-package-registry", + "node-toolchain", + "artifact-store" + ], + "contributes": { + "npm-package-registry": { + "access": "write" + } + }, + "binds": { + "npm-package-registry": "${dir:state}/package-registry.json" + }, + "secrets": { + "npm-package-registry": "${dir:state}/package-registry.secret" + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-build-node", + "runtime-health": { + "kind": "tool", + "tool": "build_node_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-build-node", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-build-node" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-build-node", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-node", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-build-node-work", + "mode": "0700", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "path": "/var/lib/mesh-build-node-cache", + "mode": "0711", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "ssh-dir", + "type": "directory", + "path": "${dir:state}/.ssh", + "mode": "0700", + "owner": "mesh-build-node", + "unknown": "program" + }, + { + "id": "ssh-config", + "type": "file", + "path": "${dir:ssh-dir}/config", + "owner": "mesh-build-node", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${dir:ssh-dir}/known_hosts", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/known_hosts" + }, + { + "id": "git-config", + "type": "file", + "path": "${dir:state}/.config/git/config", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/gitconfig" + }, + { + "id": "registry", + "type": "file", + "path": "${dir:state}/registry", + "owner": "mesh-build-node", + "mode": "0644", + "template": "files/registry" + }, + { + "id": "toolchain-dir", + "type": "archive", + "path": "${dir:state}/toolchain", + "owner": "mesh-build-node", + "artifact": "toolchain", + "unknown": "program" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + }, + { + "id": "bubblewrap", + "type": "package", + "package": "bubblewrap" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-build-node", + "binary": "mesh-build-node", + "loads": [ + "mesh-build-node" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "toolchain", + "kind": "archive", + "from": "toolchain" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url b/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured index b5f69958..b8f45096 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured @@ -26,7 +26,6 @@ "postgres-database": "${dir:state}/database.secret" }, "consumes": [ - "mesh-build-machine.built", "mesh-controller.built-before" ], "emits": [ @@ -40,14 +39,15 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "database-url", "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "prepare", @@ -85,6 +85,11 @@ "env": { "DATABASE_URL_FILE": "${dir:state}/database.url" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal b/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal new file mode 100644 index 00000000..117f2a5a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal @@ -0,0 +1,3 @@ +# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only. +# A script, sudo and a document meant to work everywhere say mesh-cli. +alias nox=mesh-cli diff --git a/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured index 3939eb35..681a3e55 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured @@ -1,19 +1,20 @@ { "module": "mesh-cli", "version": "1", + "moved": true, "resources": [ { "id": "program", "type": "archive", "artifact": "program", - "path": "/usr/local/bin" + "path": "/usr/local/bin", + "unknown": "program" } ], - "shell": [ + "items": [ { - "for": "zsh", - "slot": "normal", - "code": "# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.\n# A script, sudo and a document meant to work everywhere say mesh-cli.\nalias nox=mesh-cli\n" + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" } ], "build": { @@ -25,6 +26,11 @@ "system": "arch", "from": "cmd/mesh-cli", "binary": "mesh-cli" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured new file mode 100644 index 00000000..f93497e1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured @@ -0,0 +1,82 @@ +{ + "module": "mesh-config", + "version": "1", + "runs-as": "mesh-config", + "claims": [ + { + "name": "mesh-config", + "scope": "mesh", + "serves": [ + "config" + ] + } + ], + "emits": [ + "config-changed" + ], + "uses": [ + "operator-channel" + ], + "consumes": [ + "gitea.pull.merged", + "gitea.pull.closed", + "gitea.forge.pull-request.closed" + ], + "invokes": [ + "seat:mesh-controller.hand-act", + "seat:mesh-controller.warranted", + "gitea.gitea_get_pull_request" + ], + "reads": [ + "mesh-controller.assignments", + "mesh-controller.declared-config", + "mesh-controller.predefined" + ], + "state": [ + "values", + "resolved", + "predefined", + "proposals", + "pulls", + "kept" + ], + "logs": [ + "history" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-config", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-config", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-config" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-config", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-config", + "binary": "mesh-config", + "loads": [ + "mesh-config" + ] + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/files/settings.json new file mode 100644 index 00000000..848f5158 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/files/settings.json @@ -0,0 +1,4 @@ +{ + "refusal-stall-floor-minutes": 10, + "author-new-head-minutes": 120 +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured new file mode 100644 index 00000000..086ad999 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured @@ -0,0 +1,147 @@ +{ + "module": "mesh-delivery-gate", + "version": "1", + "slug": "gate", + "claims": [ + { + "name": "mesh-delivery", + "scope": "mesh", + "serves": [ + "deliveries", + "times", + "show", + "groups", + "what-if", + "checks", + "table", + "stalled", + "recheck", + "release", + "stop", + "close", + "retire-history" + ] + } + ], + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed", + "gitea.forge.status", + "gitea.forge.pull-request.edited", + "gitea.forge.pull-request.closed", + "mesh-controller.plan-moved", + "mesh-delivery-forge-bridge.forge.**", + "mesh-config.config-changed" + ], + "emits": [ + "transition", + "group", + "merged" + ], + "invokes": [ + "seat:mesh-controller.deliver", + "seat:mesh-controller.delivery-stop", + "seat:mesh-controller.delivery-backups-again", + "seat:mesh-controller.delivery-walks", + "seat:mesh-controller.plans", + "gitea.gitea_commit_statuses", + "gitea.gitea_contains", + "gitea.gitea_open_pulls" + ], + "state": [ + "deliveries", + "groups", + "pulls", + { + "name": "asks", + "history": 5 + }, + { + "name": "merges", + "history": 10 + }, + { + "name": "owed", + "history": 5 + }, + { + "name": "source", + "history": 64 + } + ], + "reads": [ + "mesh-delivery.deliveries", + "mesh-delivery.groups", + "mesh-delivery.pulls", + "mesh-delivery-teller.answers", + "mesh-delivery-checker.verdicts", + "mesh-controller.assignments", + "node-engine.have" + ], + "tools": [ + "delivery_status" + ], + "config": { + "refusal-stall-floor-minutes": { + "type": "number", + "description": "minutes, at least, before a merge refused because its trunk moved, whose check the gate asked again, is said as stalled; twice the repository's last check time when that is longer (ADR 0505 §4).", + "minimum": 1, + "maximum": 1440, + "trusted": false, + "default": 10 + }, + "author-new-head-minutes": { + "type": "number", + "description": "minutes a delivery that needs its author's new head (a conflict, a failed check, a rejected group) waits for one before the operator hears of it (ADR 0505 §5).", + "minimum": 1, + "maximum": 10080, + "trusted": false, + "default": 120 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": [ + "refusal-stall-floor-minutes", + "author-new-head-minutes" + ], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-gate", + "binary": "mesh-delivery-gate", + "loads": [ + "mesh-delivery-gate" + ], + "env": { + "MESH_DELIVERY_GATE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured deleted file mode 100644 index 1215633c..00000000 --- a/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured +++ /dev/null @@ -1,81 +0,0 @@ -{ - "module": "mesh-delivery", - "version": "1", - "slug": "deliver", - "claims": [ - { - "name": "mesh-delivery", - "scope": "mesh", - "serves": [ - "deliveries", - "times", - "show", - "groups", - "what-if", - "checks", - "table", - "stalled", - "recheck", - "release", - "stop", - "close", - "retire-history" - ] - } - ], - "consumes": [ - "gitea.pull.updated", - "gitea.pull.merged", - "gitea.pull.closed", - "mesh-controller.checked", - "mesh-controller.plan-moved" - ], - "emits": [ - "transition", - "group" - ], - "invokes": [ - "seat:mesh-controller.delivery-plan", - "seat:mesh-controller.delivery-order", - "seat:mesh-controller.delivery-check", - "seat:mesh-controller.deliver", - "seat:mesh-controller.delivery-stop", - "seat:mesh-controller.delivery-walks", - "gitea.gitea_note_append", - "gitea.gitea_delivery_view", - "gitea.gitea_commit_status", - "gitea.gitea_commit_statuses", - "gitea.gitea_contains", - "gitea.gitea_open_pulls" - ], - "state": [ - "deliveries", - "groups" - ], - "tools": [ - "delivery_status" - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - } - ], - "build": { - "artifacts": [ - { - "name": "tools", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/mesh-delivery", - "binary": "mesh-delivery", - "loads": [ - "mesh-delivery" - ] - } - ] - } -} diff --git a/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured new file mode 100644 index 00000000..915d2aef --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured @@ -0,0 +1,60 @@ +{ + "module": "mesh-files", + "version": "1", + "claims": [ + { + "name": "node-files", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account", + "write", + "copy", + "move", + "delete", + "mkdir", + "chmod", + "chown", + "trash", + "restore", + "upload", + "download" + ] + } + ], + "invokes": [ + "seat:operator-channel.tell", + "seat:object-store.link" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-files", + "binary": "mesh-files", + "loads": [ + "mesh-files" + ], + "env": { + "MESH_FILES_STATE": "${dir:state}" + } + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json new file mode 100644 index 00000000..1cb2d002 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json @@ -0,0 +1,169 @@ +{ + "first-number": 365, + "owners": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/brother", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/kernel", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-build-go", + "mesh-catalog modules/mesh-build-image", + "mesh-catalog modules/mesh-build-node", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-config", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/mesh-files", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-knowledge", + "mesh-catalog modules/mesh-mail", + "mesh-catalog modules/mesh-mail-notifier", + "mesh-catalog modules/mesh-mail-reader", + "mesh-catalog modules/mesh-mail-web", + "mesh-catalog modules/mesh-provisioner", + "mesh-catalog modules/mesh-provisioner-client", + "mesh-catalog modules/mesh-reads", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-controller modules/route-proxy", + "mesh-delivery", + "mesh-delivery modules/mesh-delivery-builder", + "mesh-delivery modules/mesh-delivery-checker", + "mesh-delivery modules/mesh-delivery-forge-bridge", + "mesh-delivery modules/mesh-delivery-indexer", + "mesh-delivery modules/mesh-delivery-teller", + "mesh-delivery modules/mesh-delivery-view", + "mesh-delivery modules/mesh-delivery-watchman", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools", + "okioki" + ], + "duplicate-check": "on", + "judge-model": "sonnet", + "forge-owner": "novox", + "stuck-after": "2h", + "worker-stale-after": "24h", + "delivered-not-resolved-after": "1h", + "decision-floor": 293, + "research-floor": 45 +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured index a6312a6a..830e1495 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured @@ -57,7 +57,7 @@ "consumes": [ "mesh-issues.opened", "mesh-issues.moved", - "mesh-delivery.transition" + "mesh-delivery-gate.transition" ], "invokes": [ "claude-code.claude_code_judge", @@ -75,18 +75,244 @@ "state": [ { "name": "issues", - "history": 8 + "history": 2 }, "counters", "grooming", "reservations" ], + "config": { + "first-number": { + "type": "number", + "description": "The number the first issue the tracker numbers gets, read once when its counter starts and never again; the import reads it as the boundary between the folders hq numbered and the tracker's issues.", + "minimum": 1, + "trusted": true, + "default": 365 + }, + "owners": { + "type": "list", + "description": "The registered places an issue's located-in may name: a repository, or a repository and a folder in it separated by a space.", + "trusted": true, + "default": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/brother", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/kernel", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-build-go", + "mesh-catalog modules/mesh-build-image", + "mesh-catalog modules/mesh-build-node", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-config", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/mesh-files", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-knowledge", + "mesh-catalog modules/mesh-mail", + "mesh-catalog modules/mesh-mail-notifier", + "mesh-catalog modules/mesh-mail-reader", + "mesh-catalog modules/mesh-mail-web", + "mesh-catalog modules/mesh-provisioner", + "mesh-catalog modules/mesh-provisioner-client", + "mesh-catalog modules/mesh-reads", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-controller modules/route-proxy", + "mesh-delivery", + "mesh-delivery modules/mesh-delivery-builder", + "mesh-delivery modules/mesh-delivery-checker", + "mesh-delivery modules/mesh-delivery-forge-bridge", + "mesh-delivery modules/mesh-delivery-indexer", + "mesh-delivery modules/mesh-delivery-teller", + "mesh-delivery modules/mesh-delivery-view", + "mesh-delivery modules/mesh-delivery-watchman", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools", + "okioki" + ] + }, + "duplicate-check": { + "type": "string", + "description": "Whether a new issue is checked against the open ones for a duplicate: off stops it, anything else is on (novox/hq ADR 0278 §7).", + "trusted": true, + "default": "on" + }, + "judge-model": { + "type": "string", + "description": "The model the duplicate check asks, by the agent's name for it, such as sonnet; empty is the tracker's own default.", + "trusted": true, + "default": "sonnet" + }, + "forge-owner": { + "type": "string", + "description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.", + "trusted": true, + "default": "novox" + }, + "stuck-after": { + "type": "string", + "description": "How long a fix may be merged and not delivered before it is said stuck, as a duration such as 2h (novox/hq ADR 0281); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "2h" + }, + "worker-stale-after": { + "type": "string", + "description": "How long an issue's worker stays current with nothing done on it, as a duration such as 24h (novox/hq ADR 0284 §4); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "24h" + }, + "delivered-not-resolved-after": { + "type": "string", + "description": "How long an issue may stay in fixing once every fix it names is delivered before the operator is warned, as a duration such as 1h (novox/hq issue 483); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "1h" + }, + "decision-floor": { + "type": "number", + "description": "The highest decision number already taken somewhere the tracker cannot read, such as an open branch of hq; the decision sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 293 + }, + "research-floor": { + "type": "number", + "description": "The highest research number already taken somewhere the tracker cannot read; the research sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 45 + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "settings", @@ -94,8 +320,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["first-number", "owners", "duplicate-check", "judge-model", "forge-owner", "stuck-after", "worker-stale-after", "delivered-not-resolved-after", "decision-floor", "research-floor"], "merge": "json", - "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\",\n \"worker-stale-after\": \"24h\",\n \"delivered-not-resolved-after\": \"1h\",\n \"decision-floor\": 293,\n \"research-floor\": 45\n}\n" + "template": "files/settings.json" } ], "build": { @@ -113,6 +340,11 @@ "env": { "MESH_ISSUES_SETTINGS": "${dir:state}/settings.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url b/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json new file mode 100644 index 00000000..6f682fed --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json @@ -0,0 +1,5 @@ +{ + "judge-model": "sonnet", + "drafting": "on", + "reserved-for": "168h" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured index dbb08b00..92b15f2d 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured @@ -23,13 +23,16 @@ "serves": [ "ask", "get", + "list", + "findings", "holds", "links", "history", "reserve", "propose", "import", - "return" + "return", + "withdraw" ] } ], @@ -40,13 +43,16 @@ "serves": [ "ask", "get", + "list", + "findings", "holds", "links", "history", "reserve", "propose", "import", - "return" + "return", + "withdraw" ] } ], @@ -77,19 +83,38 @@ "records.records_read", "seat:operator-channel.notify" ], + "config": { + "judge-model": { + "type": "string", + "description": "The model the knowledge base's drafting and confirming judgements ask, by the agent's name for it, such as sonnet; empty is sonnet.", + "default": "sonnet" + }, + "drafting": { + "type": "string", + "description": "Whether the knowledge base drafts and confirms clauses itself: off stops it, anything else is on.", + "default": "on" + }, + "reserved-for": { + "type": "string", + "description": "How long a reserved number waits for its proposal before it is withdrawn, as a duration such as 168h; empty is the knowledge base's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "default": "168h" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "database-url", "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "settings", @@ -97,8 +122,9 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["judge-model", "drafting", "reserved-for"], "merge": "json", - "content": "{\n \"judge-model\": \"sonnet\",\n \"drafting\": \"on\",\n \"reserved-for\": \"168h\"\n}\n" + "template": "files/settings.json" }, { "id": "prepare", @@ -156,6 +182,7 @@ "health": { "kind": "tool", "tool": "knowledge_health", + "needs": "postgres-database", "interval": "60s", "timeout": "10s", "looks": 2, @@ -180,6 +207,11 @@ "MESH_KNOWLEDGE_SETTINGS": "${dir:state}/settings.json", "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured new file mode 100644 index 00000000..9afba466 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured @@ -0,0 +1,68 @@ +{ + "module": "mesh-mail-notifier", + "version": "1", + "runs-as": "mesh-mail-notifier", + "claims": [ + { + "name": "mail-notifier", + "scope": "node", + "serves": ["status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:node-notifier.send", + "seat:mail-store.accounts" + ], + "emits": [ + "shown" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "tools": [ + "mail-notifier.status", + "mail_notifier_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-notifier", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-notifier", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-notifier" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-notifier", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-notifier", + "binary": "mesh-mail-notifier", + "loads": [ + "mesh-mail-notifier" + ] + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json new file mode 100644 index 00000000..84dd8003 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json @@ -0,0 +1,3 @@ +{ + "callers": ["mesh-mail-web"] +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured new file mode 100644 index 00000000..b83e8bb4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured @@ -0,0 +1,141 @@ +{ + "module": "mesh-mail-reader", + "version": "1", + "runs-as": "mesh-mail-reader", + "claims": [ + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:mail-store.read", + "seat:mail-store.accounts", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "messenger.messenger_check", + "seat:operator-channel.tell" + ], + "requires": ["postgres-database"], + "contributes": { + "postgres-database": { + "name": "mail-reader" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "config": { + "callers": { + "type": "list", + "description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), ., or person. (novox/hq ADR 0459 §2). mesh-mail-web alone by default.", + "trusted": true, + "default": ["mesh-mail-web"] + } + }, + "tools": [ + "mail-reading.entries", + "mail-reading.groups", + "mail-reading.correct", + "mail-reading.lessons", + "mail-reading.undo", + "mail-reading.status", + "mail_reader_health" + ], + "runtime-health": { + "kind": "tool", + "tool": "mail_reader_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-reader", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-reader", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-reader" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-reader", + "place": ".", + "unknown": "program" + }, + { + "id": "callers", + "trusted": true, + "type": "file", + "path": "${dir:state}/callers.json", + "mode": "0600", + "owner": "mesh-mail-reader", + "merge": "json", + "template": "files/callers.json", + "takes-config": ["callers"] + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "owner": "mesh-mail-reader", + "template": "files/database-url" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-reader-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail-reader", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-reader", + "binary": "mesh-mail-reader", + "loads": [ + "mesh-mail-reader" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_READER_CALLERS": "${dir:state}/callers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json b/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json new file mode 100644 index 00000000..f8646087 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json @@ -0,0 +1,5 @@ +{ + "client-id": "${bound:oidc-client:as}", + "client-secret": "${secret:oidc-client}", + "issuer": "${bound:oidc-client:issuer}" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json b/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json new file mode 100644 index 00000000..dea781c4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json @@ -0,0 +1,3 @@ +{ + "operators": [] +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured new file mode 100644 index 00000000..c6c0d3de --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured @@ -0,0 +1,131 @@ +{ + "module": "mesh-mail-web", + "version": "1", + "slug": "mail-web", + "runs-as": "mesh-mail-web", + "claims": [ + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + } + ], + "invokes": [ + "seat:mail-store.accounts", + "seat:mail-store.search", + "seat:mail-store.read", + "seat:mail-store.send", + "seat:mail-store.send-status", + "seat:mail-reading.entries", + "seat:mail-reading.groups", + "seat:mail-reading.status", + "seat:mail-reading.correct", + "seat:mail-reading.lessons", + "seat:mail-reading.undo" + ], + "reads": ["mesh-mail.accounts"], + "requires": ["route", "oidc-client"], + "contributes": { + "route": { + "label": "mail", + "endpoint": "web" + }, + "oidc-client": { + "label": "mail", + "endpoint": "web", + "callback": "/signed-in" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "oidc-client": "${dir:state}/oidc-bound.json" + }, + "secrets": { + "oidc-client": "${dir:state}/oidc-bound.secret" + }, + "listens": [ + { + "name": "web", + "port": 8099, + "protocol": "tcp", + "from": "mesh", + "why": "the mail web client's pages, over http; only the route proxy reaches it, and the operator's own browser through the internal name (novox/hq ADR 0440 §2) — its public name, if any, is a route grant" + } + ], + "config": { + "operators": { + "type": "list", + "description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).", + "trusted": true, + "default": [] + } + }, + "tools": [ + "mail-webclient.open", + "mail-webclient.status", + "mail_web_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-web", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-web", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-web" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-web", + "place": ".", + "unknown": "program" + }, + { + "id": "operators", + "trusted": true, + "type": "file", + "path": "${dir:state}/operators.json", + "mode": "0600", + "owner": "mesh-mail-web", + "merge": "json", + "template": "files/operators.json", + "takes-config": ["operators"] + }, + { + "id": "oidc-config", + "type": "file", + "path": "${dir:state}/oidc.json", + "mode": "0600", + "owner": "mesh-mail-web", + "template": "files/oidc.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-web", + "binary": "mesh-mail-web", + "loads": ["mesh-mail-web"], + "env": { + "MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json", + "MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json", + "MESH_MAIL_WEB_LISTEN": "${port:8099}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json b/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json new file mode 100644 index 00000000..e182cc3d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url b/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json b/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json new file mode 100644 index 00000000..458eabba --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json @@ -0,0 +1,3 @@ +{ + "grants": {} +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json b/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json new file mode 100644 index 00000000..8c2075da --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured new file mode 100644 index 00000000..735fbc54 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured @@ -0,0 +1,317 @@ +{ + "module": "mesh-mail", + "version": "1", + "slug": "mail", + "seats": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + }, + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + }, + { + "name": "mail-notifier", + "scope": "node", + "needs": ["node-notifier"], + "serves": ["status"] + }, + { + "name": "mail-reading", + "scope": "mesh", + "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + } + ], + "claims": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + } + ], + "requires": [ + "postgres-database", + "s3-bucket" + ], + "contributes": { + "postgres-database": { + "name": "mail" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "own-secrets": { + "mail-credential-*": { + "path": "${dir:state}/mail-credential-*.secret", + "issued-by": "outside", + "prompt": "the password of the mail account {member}", + "owner": "mesh-mail-send" + }, + "broker-send": { + "path": "${dir:state}/broker-send.secret", + "owner": "mesh-mail-send" + } + }, + "config": { + "accounts": { + "type": "object", + "description": "The mail accounts, each by a lower-case name that names it on the bus and its secret: its kind (imap), address, incoming and outgoing servers (host, port, security tls or starttls), and optionally the folders watched and the user it signs in as; no password, which is the own secret mail-credential- (novox/hq ADR 0359 §5).", + "trusted": true, + "default": {} + }, + "grants": { + "type": "object", + "description": "Per account, who may read its mail and who may send from it: a module's name (on every machine), ., or person.. Nobody by default; it restricts the stream MAIL and the tools mail_search, mail_read and mail_send alike (novox/hq ADR 0359 §4).", + "trusted": true, + "default": {} + } + }, + "tools": [ + "mail_accounts", + "mail_search", + "mail_read", + "mail_health", + "mail_send", + "mail_send_status", + "mail-store.accounts", + "mail-store.search", + "mail-store.read", + "mail-store.send", + "mail-store.send-status" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "sends", + "accounts", + "mails" + ], + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "streams": [ + { + "name": "mail", + "events": [ + "mail.*.message.added", + "mail.*.message.changed", + "mail.*.message.removed" + ], + "max-age-days": 7, + "grant": { + "config": "grants", + "events": "mail.{key}.**" + } + }, + { + "name": "mail-sends", + "events": [ + "mail.send.approved" + ], + "max-age-days": 1, + "max-mib": 64, + "retention": "work-queue", + "max-deliveries": 5, + "duplicates": 24 + } + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-send" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database-url" + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "accounts", + "trusted": true, + "type": "file", + "path": "${dir:state}/accounts.json", + "mode": "0644", + "merge": "json", + "template": "files/accounts.json", + "takes-config": [ + "accounts" + ] + }, + { + "id": "grants", + "trusted": true, + "type": "file", + "path": "${dir:state}/grants.json", + "mode": "0600", + "merge": "json", + "template": "files/grants.json", + "takes-config": [ + "grants" + ] + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-mail-keep", + "artifact": "tools", + "run": [ + "./mesh-mail", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json" + }, + "restart-on": [ + "database-url", + "store", + "accounts" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mail-watch", + "artifact": "tools", + "run": [ + "./mesh-mail", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url", + "accounts" + ], + "health": { + "kind": "tool", + "tool": "mail_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + }, + { + "id": "send", + "type": "process", + "name": "mesh-mail-send", + "artifact": "tools", + "run": [ + "./mesh-mail", + "send" + ], + "user": "mesh-mail-send", + "env": { + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}" + }, + "restart-on": [ + "accounts" + ], + "bus": { + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "queues": [ + "mail-sends" + ], + "state": [ + "sends" + ], + "asks": true, + "invokes": [ + "seat:operator-channel.tell" + ] + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail", + "binary": "mesh-mail", + "loads": [ + "mesh-mail" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_GRANTS": "${dir:state}/grants.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured new file mode 100644 index 00000000..095128a8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured new file mode 100644 index 00000000..650425b3 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "mesh-provisioner", + "version": "1", + "runs-as": "mesh-provisioner", + "claims": [ + { + "name": "mesh-provisioner", + "scope": "mesh", + "serves": [ + "provisioning" + ] + } + ], + "state": [ + "registry", + "needs", + "applied", + "collect" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner", + "binary": "mesh-provisioner", + "loads": [ + "mesh-provisioner" + ] + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured index 4a664485..18062752 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured @@ -33,7 +33,8 @@ "type": "directory", "mode": "0700", "owner": "mesh-reads", - "place": "." + "place": ".", + "unknown": "program" } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured index 87b6e6d7..44944c95 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured @@ -1,6 +1,7 @@ { "module": "mesh-vault", "version": "1", + "runs-as": "mesh-vault", "provides": [ { "name": "secret", @@ -10,18 +11,13 @@ } } ], - "capabilities": [ - "container-runtime" - ], "emits": [ "provisioned", "rotated", - "deprovisioned" + "secret-changed" ], - "consumes": [ - "mesh-vault.provisioned", - "mesh-vault.rotated", - "mesh-vault.deprovisioned" + "reads": [ + "mesh-controller.assignments" ], "receives": { "secret": "${dir:grants}/mesh.json" @@ -49,6 +45,12 @@ "path": "${dir:root}", "class": "valuable", "why": "the vault's root material" + }, + { + "id": "store", + "path": "${dir:store}", + "class": "valuable", + "why": "every secret the vault holds, sealed to its own key and to the operator key" } ], "consumers": { @@ -59,27 +61,61 @@ } } }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-vault", + "runtime-health": { + "kind": "tool", + "tool": "vault_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-vault", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-vault" + }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "owner": "mesh-vault", + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" }, { "id": "ledger", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" }, { "id": "root", "type": "directory", - "mode": "0700" + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "store", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" } ], "build": { @@ -87,29 +123,51 @@ { "name": "code", "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "index.js", - "tools/index.js", - "provisioner/index.js" - ], + "language": "go", + "system": "arch", + "from": "cmd/mesh-vault", + "binary": "mesh-vault", "loads": [ - "index.js", - "tools/index.js", - "provisioner/index.js" + "mesh-vault" ], "env": { "MESH_RECEIVES": "${dir:grants}/mesh.json", "MESH_VAULT_LEDGER": "${dir:ledger}", - "MESH_VAULT_ROOT": "${dir:root}" + "MESH_VAULT_ROOT": "${dir:root}", + "MESH_VAULT_STORE": "${dir:store}", + "MESH_VAULT_STATE": "${dir:state}" } } ] }, + "tools": [ + "secret_export", + "secret_holders", + "secret_verify", + "secret_handed", + "secret_asks", + "secret_vault_export", + "provisioner_retirement", + "provisioner_retire_approve", + "provisioner_retire_reject", + "provisioner_delete", + "vault_status", + "vault_health" + ], "claims": [ { "name": "mesh-vault", - "scope": "mesh" + "scope": "mesh", + "serves": [ + "get", + "accept", + "secret-ask", + "issued", + "rotate", + "move-in", + "node-key", + "move-asks" + ] } ] } diff --git a/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json b/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json new file mode 100644 index 00000000..84b9ecc1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json @@ -0,0 +1,3 @@ +{ + "telegram-chat-id": "" +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured index 6cbbfb33..e9543a5d 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured @@ -18,20 +18,30 @@ "watcher_test", "watcher_ping" ], + "config": { + "telegram-chat-id": { + "type": "string", + "description": "The Telegram chat the watcher reports to: the chat's id, a number, or @name of a channel; empty reports to none.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "settings", "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["telegram-chat-id"], "merge": "json", - "content": "{\n \"telegram-chat-id\": \"\"\n}\n" + "template": "files/settings.json" } ], "build": { @@ -50,6 +60,11 @@ "MESH_WATCHER_SETTINGS": "${dir:state}/settings.json", "MESH_WATCHER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/messenger/files/settings.json b/testdata/beside/mesh-catalog/modules/messenger/files/settings.json new file mode 100644 index 00000000..726c29cb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/messenger/files/settings.json @@ -0,0 +1,6 @@ +{ + "time-zone": "", + "only-what-needs-me": [], + "messages-per-hour": 100, + "settle-seconds": 120 +} diff --git a/testdata/beside/mesh-catalog/modules/messenger/module.json.captured b/testdata/beside/mesh-catalog/modules/messenger/module.json.captured index 3a12a074..d7da1d68 100644 --- a/testdata/beside/mesh-catalog/modules/messenger/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/messenger/module.json.captured @@ -11,11 +11,13 @@ "open", "history", "notify", + "tell", "asks" ], "accepts": [ "ask", - "cancel" + "cancel", + "condition" ], "emits": [ "decided" @@ -23,6 +25,7 @@ "by-caller": [ "ask", "cancel", + "condition", "decided" ], "records": [ @@ -62,6 +65,7 @@ "open", "history", "notify", + "tell", "asks" ] } @@ -110,6 +114,42 @@ "messenger_identities", "messenger_unlink" ], + "config": { + "time-zone": { + "type": "string", + "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", + "trusted": true, + "default": "" + }, + "desktop-machines": { + "type": "list", + "description": "The machines whose desktop showed the operator's messages, by node name. Nothing in this module reads it since the desk channel took the desktop over; it is taken so the file stays as a layer that still sets it makes it, until that layer drops it.", + "trusted": true, + "default": [] + }, + "only-what-needs-me": { + "type": "list", + "description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.", + "trusted": true, + "default": [] + }, + "messages-per-hour": { + "type": "number", + "description": "messages per channel per hour before the rest are held as one; 0 turns the pause off. The tell verb has its own cap of 10 and is not counted here.", + "minimum": 0, + "maximum": 1000, + "trusted": false, + "default": 100 + }, + "settle-seconds": { + "type": "number", + "description": "seconds a delivery that ended well still holds the messages about what it changed, so a problem that passes as the delivery settles is not sent (ADR 0505 §3); 0 holds nothing after the end.", + "minimum": 0, + "maximum": 3600, + "trusted": false, + "default": 120 + } + }, "resources": [ { "id": "account", @@ -123,15 +163,17 @@ "type": "directory", "mode": "0700", "place": ".", - "owner": "messenger" + "owner": "messenger", + "unknown": "program" }, { "id": "settings", "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds"], "merge": "json", - "content": "{\n \"time-zone\": \"\",\n \"only-what-needs-me\": []\n}\n", + "template": "files/settings.json", "owner": "messenger" } ], @@ -151,6 +193,11 @@ "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", "MESH_MESSENGER_STATE": "${dir:state}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/minio/files/object-store.json b/testdata/beside/mesh-catalog/modules/minio/files/object-store.json new file mode 100644 index 00000000..d2970bcc --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/minio/files/object-store.json @@ -0,0 +1,6 @@ +{ + "scheme": "http", + "at": "${machine:at}", + "port": "${port:9000}", + "region": "eu-west" +} diff --git a/testdata/beside/mesh-catalog/modules/minio/files/root.env b/testdata/beside/mesh-catalog/modules/minio/files/root.env new file mode 100644 index 00000000..2255260f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/minio/files/root.env @@ -0,0 +1,2 @@ +MINIO_ROOT_USER=meshroot +MINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console} diff --git a/testdata/beside/mesh-catalog/modules/minio/module.json.captured b/testdata/beside/mesh-catalog/modules/minio/module.json.captured index 7d6f525e..01a966af 100644 --- a/testdata/beside/mesh-catalog/modules/minio/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/minio/module.json.captured @@ -18,6 +18,27 @@ "requires": [ "route" ], + "seats": [ + { + "name": "object-store", + "scope": "mesh", + "serves": [ + "link" + ] + } + ], + "claims": [ + { + "name": "object-store", + "scope": "mesh", + "serves": [ + "link" + ] + } + ], + "invokes": [ + "seat:mesh-controller.seats" + ], "contributes": { "route": { "api": { @@ -92,25 +113,35 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "root-env", "type": "file", "path": "${dir:state}/root.env", "mode": "0600", - "content": "MINIO_ROOT_USER=meshroot\nMINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console}\n" + "template": "files/root.env" + }, + { + "id": "object-store", + "type": "file", + "path": "${dir:state}/object-store.json", + "mode": "0600", + "template": "files/object-store.json" }, { "id": "data", "type": "directory", "path": "/var/lib/minio-store", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "net", @@ -170,10 +201,32 @@ "MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret", "MESH_MINIO_REGION": "eu-west", + "MESH_MINIO_LINKS": "${dir:state}/links", "MESH_MINIO_MC_BIN": "mcli", "MESH_MINIO_MC_CONFIG": "${dir:state}/mc", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/object-store", + "binary": "object-store", + "loads": [ + "object-store" + ], + "env": { + "OBJECT_STORE_CONFIG": "${dir:state}/object-store.json", + "MESH_MINIO_LINKS": "${dir:state}/links", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/model-usage/files/database.url b/testdata/beside/mesh-catalog/modules/model-usage/files/database.url new file mode 100644 index 00000000..dc59da9a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/model-usage/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured b/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured index a167523f..50743a3c 100644 --- a/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured @@ -43,19 +43,21 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "database-url", "type": "file", "path": "${dir:state}/database.url", "mode": "0600", - "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + "template": "files/database.url" }, { "id": "spool", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "prepare", @@ -94,6 +96,11 @@ "DATABASE_URL_FILE": "${dir:state}/database.url", "USAGE_SPOOL": "${dir:spool}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret b/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret new file mode 100644 index 00000000..0ccabeea --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret @@ -0,0 +1 @@ +${secret:root} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured b/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured index e204b863..cbb12681 100644 --- a/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured @@ -81,22 +81,26 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "dumps", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "net", @@ -109,7 +113,7 @@ "path": "${dir:state}/server-root.secret", "mode": "0400", "owner": "999:999", - "content": "${secret:root}" + "template": "files/server-root.secret" }, { "id": "server", @@ -155,6 +159,11 @@ "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env b/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env new file mode 100644 index 00000000..65b710eb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env @@ -0,0 +1,7 @@ +MESH_PROVISION_MQTT=127.0.0.1:${port:1883} +MESH_PROVISION_ADMIN_USER=mesh-admin +MESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin +MESH_DYNSEC_FILE=${dir:data}/dynamic-security.json +MESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 +MESH_MQTT_CTRL_CONTAINER=mosquitto +MESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied diff --git a/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf b/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf new file mode 100644 index 00000000..500c2a69 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf @@ -0,0 +1,20 @@ +persistence true +persistence_location /mosquitto/data + +log_dest stdout +log_type warning +log_type error +log_type notice + +# Every client authenticates; identities and their per-topic ACLs are managed +# at runtime by the dynamic security plugin, whose store the plugin itself owns. +allow_anonymous false +plugin /usr/lib/mosquitto_dynamic_security.so +plugin_opt_config_file /mosquitto/data/dynamic-security.json + +# MQTT listener +listener 1883 + +# MQTT-over-WebSockets listener +listener 8081 +protocol websockets diff --git a/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured b/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured index afa4172f..21058e0a 100644 --- a/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured @@ -78,24 +78,28 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "1883:1883" + "owner": "1883:1883", + "unknown": "program" }, { "id": "server-conf", @@ -103,7 +107,7 @@ "path": "${dir:state}/mosquitto.conf", "mode": "0600", "owner": "1883:1883", - "content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n" + "template": "files/mosquitto.conf" }, { "id": "net", @@ -115,7 +119,7 @@ "type": "file", "path": "${dir:state}/bootstrap.env", "mode": "0600", - "content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\nMESH_MQTT_CTRL_CONTAINER=mosquitto\nMESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied\n" + "template": "files/bootstrap.env" }, { "id": "bootstrap", @@ -179,6 +183,11 @@ "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin", "MESH_MQTT_CTRL_CONTAINER": "mosquitto" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf b/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf new file mode 100644 index 00000000..1d5f87dd --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings +# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this. +# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry. +shares=${config:shares} +sources=${config:sources} +server=${bound:nfs-share:from} +at=${bound:nfs-share:at} +account=${machine:account} +node=${machine:name} +passwords=${dir:state} +state=${dir:state} diff --git a/testdata/beside/mesh-catalog/modules/mounts/module.json.captured b/testdata/beside/mesh-catalog/modules/mounts/module.json.captured index 108133c9..488ebb97 100644 --- a/testdata/beside/mesh-catalog/modules/mounts/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mounts/module.json.captured @@ -38,18 +38,20 @@ "per-machine": true } ], - "settings": { - "shares": { - "kind": "preference", - "default": "none", - "why": "a machine mounts no share of another until its assignment names one: space-separated name=mountpoint, each optionally :ro (hq ADR 0263 rule 5)" + "config": { + "shares": { + "type": "string", + "description": "The shares of another machine this machine mounts: space-separated name=mountpoint, each optionally :ro, or none (novox/hq ADR 0263 rule 5).", + "trusted": true, + "default": "none" + }, + "sources": { + "type": "string", + "description": "The occasional sources this machine mounts: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro, or none (novox/hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (novox/hq ADR 0283).", + "trusted": true, + "default": "none" + } }, - "sources": { - "kind": "preference", - "default": "none", - "why": "a machine has no occasional source until its assignment names one: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro (hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (hq ADR 0283)" - } - }, "own-secrets": { "smb-password-*": { "path": "${dir:state}/smb-password-*.secret", @@ -71,33 +73,37 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config-dir", "type": "directory", "path": "/etc/mesh-mounts", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "config", "type": "file", "path": "/etc/mesh-mounts/mounts.conf", "mode": "0644", - "content": "# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings\n# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this.\n# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry.\nshares=${setting:shares}\nsources=${setting:sources}\nserver=${bound:nfs-share:from}\nat=${bound:nfs-share:at}\naccount=${machine:account}\nnode=${machine:name}\npasswords=${dir:state}\nstate=${dir:state}\n" + "template": "files/mounts.conf" }, { "id": "bus-dir-parent", "type": "directory", "path": "/var/lib/mesh-mounts", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "bus-dir", "type": "directory", "path": "/var/lib/mesh-mounts/from-bus", "mode": "0755", - "owner": "${machine:account}" + "owner": "${machine:account}", + "unknown": "program" }, { "id": "apply", @@ -143,6 +149,11 @@ "loads": [ "mounts" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql b/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql new file mode 100644 index 00000000..fd2eccec --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql @@ -0,0 +1,14 @@ +SET NOCOUNT ON; +DECLARE @n sysname, @s nvarchar(max); +DECLARE c CURSOR LOCAL FAST_FORWARD FOR + SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL; +OPEN c; +FETCH NEXT FROM c INTO @n; +WHILE @@FETCH_STATUS = 0 +BEGIN + SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM'; + EXEC (@s); + FETCH NEXT FROM c INTO @n; +END +CLOSE c; +DEALLOCATE c; diff --git a/testdata/beside/mesh-catalog/modules/mssql/files/sa.env b/testdata/beside/mesh-catalog/modules/mssql/files/sa.env new file mode 100644 index 00000000..e7dd4445 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mssql/files/sa.env @@ -0,0 +1,2 @@ +ACCEPT_EULA=Y +MSSQL_SA_PASSWORD=${secret:sa} diff --git a/testdata/beside/mesh-catalog/modules/mssql/module.json.captured b/testdata/beside/mesh-catalog/modules/mssql/module.json.captured index 2ccbba55..ed4e4488 100644 --- a/testdata/beside/mesh-catalog/modules/mssql/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mssql/module.json.captured @@ -80,32 +80,36 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "sa-env", "type": "file", "path": "${dir:state}/sa.env", "mode": "0600", - "content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n" + "template": "files/sa.env" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "10001:0" + "owner": "10001:0", + "unknown": "program" }, { "id": "dumps", "type": "directory", "path": "${dir:data}/backup", "mode": "0700", - "owner": "10001:0" + "owner": "10001:0", + "unknown": "program" }, { "id": "net", @@ -134,7 +138,7 @@ "type": "file", "path": "${dir:state}/backup.sql", "mode": "0600", - "content": "SET NOCOUNT ON;\nDECLARE @n sysname, @s nvarchar(max);\nDECLARE c CURSOR LOCAL FAST_FORWARD FOR\n SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL;\nOPEN c;\nFETCH NEXT FROM c INTO @n;\nWHILE @@FETCH_STATUS = 0\nBEGIN\n SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM';\n EXEC (@s);\n FETCH NEXT FROM c INTO @n;\nEND\nCLOSE c;\nDEALLOCATE c;\n" + "template": "files/backup.sql" } ], "build": { @@ -159,6 +163,11 @@ "MESH_RECEIVES": "${dir:grants}/mesh.json", "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret b/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret new file mode 100644 index 00000000..e811e882 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret @@ -0,0 +1 @@ +${secret:postgres-database} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/n8n/files/server.env b/testdata/beside/mesh-catalog/modules/n8n/files/server.env new file mode 100644 index 00000000..2f74e0f8 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/n8n/files/server.env @@ -0,0 +1,12 @@ +N8N_HOST=${bound:route:name} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://${bound:route:name}/ +NODE_FUNCTION_ALLOW_BUILTIN=* +NODE_FUNCTION_ALLOW_EXTERNAL=* +DB_TYPE=postgresdb +DB_POSTGRESDB_HOST=${bound:postgres-database:at} +DB_POSTGRESDB_PORT=${bound:postgres-database:port} +DB_POSTGRESDB_DATABASE=${bound:postgres-database:as} +DB_POSTGRESDB_USER=${bound:postgres-database:as} +DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database diff --git a/testdata/beside/mesh-catalog/modules/n8n/module.json.captured b/testdata/beside/mesh-catalog/modules/n8n/module.json.captured index 7666572a..975247fa 100644 --- a/testdata/beside/mesh-catalog/modules/n8n/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/n8n/module.json.captured @@ -60,19 +60,22 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "1000:1000" + "owner": "1000:1000", + "unknown": "program" }, { "id": "cache", "type": "directory", "mode": "0700", - "owner": "999:999" + "owner": "999:999", + "unknown": "program" }, { "id": "database-secret", @@ -80,14 +83,14 @@ "path": "${dir:state}/n8n-database.secret", "mode": "0400", "owner": "1000:1000", - "content": "${secret:postgres-database}" + "template": "files/n8n-database.secret" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "N8N_HOST=${bound:route:name}\nN8N_PORT=5678\nN8N_PROTOCOL=https\nWEBHOOK_URL=https://${bound:route:name}/\nNODE_FUNCTION_ALLOW_BUILTIN=*\nNODE_FUNCTION_ALLOW_EXTERNAL=*\nDB_TYPE=postgresdb\nDB_POSTGRESDB_HOST=${bound:postgres-database:at}\nDB_POSTGRESDB_PORT=${bound:postgres-database:port}\nDB_POSTGRESDB_DATABASE=${bound:postgres-database:as}\nDB_POSTGRESDB_USER=${bound:postgres-database:as}\nDB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database\n" + "template": "files/server.env" }, { "id": "net", @@ -97,6 +100,13 @@ { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/healthz/readiness", + "status": 200, + "needs": "postgres-database" + }, "name": "n8n", "artifact": "server", "network": "n8n", @@ -152,6 +162,11 @@ "name": "server", "kind": "image", "from": "Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/nats/Dockerfile b/testdata/beside/mesh-catalog/modules/nats/Dockerfile deleted file mode 100644 index 9f57db4d..00000000 --- a/testdata/beside/mesh-catalog/modules/nats/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# nats's server image: the upstream server, plus an entrypoint that reloads it in place when the -# mesh rewrites its configuration. See entrypoint.sh for why that belongs here and not in the host. -# -# **Pinned to the multi-architecture index digest, not a platform's.** `docker manifest inspect` -# reports a platform manifest per architecture and the index that lists them; pinning a platform's -# digest builds on this workstation and fails on any node of another architecture, with an error -# that names a manifest rather than the mistake. This is the index — `docker pull` reports the same -# one, and `RepoDigests` confirms it. -# -# **The release the digest is, said here because a digest does not say it:** -# -# upstream: nats 2.11.17-alpine -# -# Kept equal to the server version cmd/nats-tools tests against (its go.mod), and a test there fails -# when they differ: that test is what says the server delivers every message to a consumer with -# several filters. 2.10.29 did not — it moved such a consumer past a message now and then without -# handing it over, and the controller never heard of a merge (novox/hq issue 266). -# -# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image: -# the module's code is the server, which upstream already built. There is no BUILD_BASE here on -# purpose — the server is not compiled; only the snapshot program below is. The upstream image is -# declared in the manifest under build.on and arrives as NATS_BASE, like every other base the mesh -# copies into its own store before a build (novox/hq ADR 0097); the digest above is the index one -# for the reason given. So does GO_BASE, the toolchain the snapshot program is built with. -# -# **One thing is compiled: the bus's snapshot program** (novox/hq ADR 0235). The machine's backup -# holder runs the module's declared dump — `docker exec` into this container — and the program asks -# the server for each stream through the snapshot API, writing a tar on stdout. It is here, beside -# the server, for two reasons: the dump runs where the server is without mounting anything into it, -# and a restore needs nats-server itself — the very binary this image already carries — to fill a new -# store. Its own Go module (snapshot/go.mod), so this build fetches only public modules. -ARG NATS_BASE -ARG GO_BASE -FROM ${GO_BASE} AS snapshot -WORKDIR /src -COPY snapshot/go.mod snapshot/go.sum ./ -RUN go mod download -COPY snapshot/*.go ./ -RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-nats-snapshot . - -FROM ${NATS_BASE} - -COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint -RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint -COPY --from=snapshot /mesh-nats-snapshot /usr/local/bin/mesh-nats-snapshot - -ENTRYPOINT ["/usr/local/bin/mesh-nats-entrypoint"] diff --git a/testdata/beside/mesh-catalog/modules/nats/files/nats.conf b/testdata/beside/mesh-catalog/modules/nats/files/nats.conf new file mode 100644 index 00000000..e1d33eb7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nats/files/nats.conf @@ -0,0 +1,46 @@ +# The nats module's own server settings. Declared by the module, because a port, a TLS path +# and a store directory are properties of the container this module raises: they live in its +# image and its mounts and change when it does. +# +# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the +# whole file would have to be kept in step with a Dockerfile it never sees. + +port: 4222 +# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish +# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222 +# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing +# (connection reset), and the only way in was `docker exec`. +http: 0.0.0.0:8222 + +# The mesh's own broker certificate — the one every machine already pins by fingerprint and the +# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no +# machine's pin changes when it moves, and no second certificate exists to be wrong about. +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" +} + +# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That +# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a +# host pins this server's exact certificate and authenticates with the password the mesh minted +# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection +# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is +# "client didn't provide a certificate", which reads as a client fault. +# +# TLS is still required: a tls block is what makes it required, and verify only decides whether +# client certificates are checked. What is given up is a second factor the mesh has no machinery +# to issue or rotate — a certificate per module per node — and what is kept is stronger than a +# name check in both directions: an exact pin outward, a per-user password inward. + +jetstream { + store_dir: "/data" +} + +# Every user of the mesh, composed by the controller and rewritten whenever a module is +# assigned, a node enrols or a person's access changes. +# +# **Relative, and in this same directory, because it has to be.** An absolute include path is +# resolved relative to the including file's directory, not from the root: nats-server given +# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for +# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server. +include accounts.conf diff --git a/testdata/beside/mesh-catalog/modules/nats/module.json.captured b/testdata/beside/mesh-catalog/modules/nats/module.json.captured index d8647b3e..106aac92 100644 --- a/testdata/beside/mesh-catalog/modules/nats/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/nats/module.json.captured @@ -44,7 +44,8 @@ "nats_backlog", "nats_buckets", "nats_users", - "nats_user_can" + "nats_user_can", + "nats_snapshot_verify" ], "guards": [ 8222 @@ -57,7 +58,7 @@ "class": "valuable", "active": "1d", "backup": { - "dump": "docker exec -i mesh-broker-nats mesh-nats-snapshot snapshot < ${dir:mesh-state}/broker > ${dir:snapshots}/bus.tar.partial && mv ${dir:snapshots}/bus.tar.partial ${dir:snapshots}/bus.tar || { rm -f ${dir:snapshots}/bus.tar.partial; exit 1; }", + "dump": "docker exec -i mesh-broker-nats /opt/mesh/mesh-nats-snapshot snapshot < ${dir:mesh-state}/broker > ${dir:snapshots}/bus.tar.partial && mv ${dir:snapshots}/bus.tar.partial ${dir:snapshots}/bus.tar || { rm -f ${dir:snapshots}/bus.tar.partial; exit 1; }", "into": "snapshots" }, "why": "the bus's streams and key-value buckets: the hand-act log, conditions, every module's state; written all the time, so copied by the server's own snapshot of each stream (novox/hq ADR 0235), never as live files" @@ -75,32 +76,57 @@ "id": "jetstream-data", "type": "directory", "path": "/var/lib/mesh-broker-nats", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "conf-dir", "type": "directory", "path": "/var/lib/nats-module/conf", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "snapshots", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "server-conf", "type": "file", "path": "/var/lib/nats-module/conf/nats.conf", - "content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\n# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish\n# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222\n# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing\n# (connection reset), and the only way in was `docker exec`.\nhttp: 0.0.0.0:8222\n\n# The mesh's own broker certificate — the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate — a certificate per module per node — and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n", + "template": "files/nats.conf", "mode": "0644" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "program-snapshot", + "type": "archive", + "path": "${dir:state}/snapshot", + "artifact": "snapshot", + "unknown": "program" + }, + { + "id": "program-entrypoint", + "type": "archive", + "path": "${dir:state}/entrypoint", + "artifact": "entrypoint", + "unknown": "program" + }, { "id": "server", "type": "container", @@ -112,9 +138,19 @@ "volumes": [ "/var/lib/mesh-broker-nats:/data", "/var/lib/nats-module/conf:/etc/nats:ro", - "${access:tls}:/tls:ro" + "${access:tls}:/tls:ro", + "${dir:state}/snapshot:/opt/mesh:ro", + "${dir:state}/entrypoint:/opt/mesh-entrypoint:ro" ], - "artifact": "server" + "args": [ + "sh", + "/opt/mesh-entrypoint/entrypoint.sh" + ], + "artifact": "server", + "restart-on": [ + "program-snapshot", + "program-entrypoint" + ] } ], "accesses": [ @@ -125,21 +161,24 @@ } ], "build": { - "on": [ - { - "arg": "NATS_BASE", - "image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00" - }, - { - "arg": "GO_BASE", - "image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c" - } - ], "artifacts": [ { "name": "server", - "kind": "image", - "from": "Dockerfile" + "kind": "upstream", + "from": "nats:2.11.17-alpine@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00" + }, + { + "name": "snapshot", + "kind": "bundle", + "language": "go", + "system": "alpine", + "from": "cmd/mesh-nats-snapshot", + "binary": "mesh-nats-snapshot" + }, + { + "name": "entrypoint", + "kind": "archive", + "from": "program" }, { "name": "tools", @@ -154,8 +193,14 @@ "env": { "MESH_NATS_MONITOR": "http://127.0.0.1:8222", "MESH_NATS_CONTAINER": "mesh-broker-nats", - "MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf" + "MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf", + "MESH_NATS_SNAPSHOTS_DIR": "${dir:snapshots}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured b/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured index ec01003a..bb2f0f32 100644 --- a/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured @@ -11,6 +11,7 @@ "netcheck_tcp", "netcheck_dns", "netcheck_http", + "netcheck_request", "netcheck_listening" ], "replaces": { diff --git a/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf b/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf new file mode 100644 index 00000000..f8c8e8a2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf @@ -0,0 +1,63 @@ +# Managed by the mesh (module networkmanager). Replaced on every push; edit the +# catalogue instead. +# +# This machine's uplink is NetworkManager's, and this file is the whole of what +# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh, +# and leave the private network's interface alone. Nothing more. The mesh never +# declares a connection profile, an address, a route, a wireless network or its +# credentials — those are joined at the machine, by the person using it, and +# the link they make is the only channel the mesh reaches this machine over. A +# push that got a link wrong could not be undone by the next one. +# +# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the +# operator keeps in this directory. NetworkManager reads the files here sorted by +# name and a later one wins a key it sets again — so a file of the operator's +# that sorts after this one (any name starting with a letter does) and sets dns= +# or unmanaged-devices= overrides it. That is the operator's to decide, and the +# reason this file sets nothing but the two keys it must. +# +# NetworkManager itself is the machine's: the mesh never starts, stops, enables +# or disables it (its service is declared with no state), because stopping it +# takes every link down, this machine's channel to the mesh included — and a +# module unassigned by mistake must not be able to do that. When this file +# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which +# re-reads its configuration — NetworkManager(8)), never restarted. + +[main] +# The resolver file is the mesh's: this module writes /etc/resolv.conf itself, +# listing the mesh's resolvers (novox/hq ADR 0223). Without this line +# NetworkManager rewrites that file on +# every connectivity change — every network joined, every lease renewed — +# and the mesh's resolver is silently replaced while every surface of the mesh +# still reads green. none: "NetworkManager will not modify resolv.conf. This +# implies rc-manager unmanaged" (NetworkManager.conf(5), 1.58). On an adopted +# machine the predecessor wrote the same line in a file of its own; both say one +# thing, and the predecessor's is retired by hand after the take. +# +# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file): +# it writes its own header and composes the options line itself, so it cannot +# write the mesh's file byte for byte, and the three uplink modules would write +# three different files for one fact. dns=none, and the file declared beside it. +dns=none + +[keyfile] +# mesh0 is the private network's interface: the mesh brings it up and the mesh +# alone configures it. A manager that considers every interface its own could +# try to configure it, or tear it down on a profile change. +# +# unmanaged-devices rather than a [device-mesh0] section with managed=0, because +# NetworkManager.conf(5) says a device unmanaged by this key "is strictly +# unmanaged and cannot be overruled by using the API like nmcli device set +# $IFNAME managed yes", while device*.managed "can be overruled at runtime via +# D-Bus". The same page adds that device*.managed "may be a better choice" for +# exactly those reasons — for an interface the operator might want to hand back +# at runtime. For the mesh's own interface, strict is the point. +# +# += rather than =: the same page documents appending to a list-valued key set +# earlier ("plugins+=another-plugin") as an extension of its key file format, +# and unmanaged-devices is a device list. = would replace whatever devices the +# operator already keeps NetworkManager away from; += adds this one to them +# (novox/hq ADR 0102: a list is added to, never replaced). A file of the +# operator's read after this one that sets the key with = replaces it again; +# that is the operator's to decide. +unmanaged-devices+=interface-name:mesh0 diff --git a/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured b/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured index 6d0cbaed..68ace345 100644 --- a/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured @@ -32,6 +32,11 @@ "loads": [ "uplink-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, @@ -52,7 +57,7 @@ "type": "file", "path": "/etc/NetworkManager/conf.d/50-mesh.conf", "mode": "0644", - "content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: this module writes /etc/resolv.conf itself,\n# listing the mesh's resolvers (novox/hq ADR 0223). Without this line\n# NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\n#\n# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file):\n# it writes its own header and composes the options line itself, so it cannot\n# write the mesh's file byte for byte, and the three uplink modules would write\n# three different files for one fact. dns=none, and the file declared beside it.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n" + "template": "files/50-mesh.conf" }, { "id": "service", diff --git a/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json b/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json new file mode 100644 index 00000000..fe032343 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json @@ -0,0 +1,6 @@ +{ + "onlyofficeUrl": "${bound:document-server:public-root}", + "onlyofficeInternalUrl": "http://${bound:document-server:at}:${bound:document-server:port}/", + "onlyofficeStorageUrl": "https://${bound:route:name}/", + "onlyofficeSecret": "${secret:document-server}" +} diff --git a/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env b/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env new file mode 100644 index 00000000..df6e20f6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env @@ -0,0 +1,14 @@ +POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +POSTGRES_DB=${bound:postgres-database:as} +POSTGRES_USER=${bound:postgres-database:as} +POSTGRES_PASSWORD=${secret:postgres-database} +NEXTCLOUD_ADMIN_USER=mesh-admin +NEXTCLOUD_ADMIN_PASSWORD=${secret:admin} +OBJECTSTORE_S3_HOST=${bound:s3-bucket:at} +OBJECTSTORE_S3_PORT=${bound:s3-bucket:port} +OBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket} +OBJECTSTORE_S3_KEY=${bound:s3-bucket:as} +OBJECTSTORE_S3_SECRET=${secret:s3-bucket} +OBJECTSTORE_S3_SSL=false +OBJECTSTORE_S3_USEPATH_STYLE=true +OBJECTSTORE_S3_REGION=${bound:s3-bucket:region} diff --git a/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured b/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured index 4d648e01..dd28417f 100644 --- a/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured @@ -5,7 +5,8 @@ "requires": [ "postgres-database", "s3-bucket", - "route" + "route", + "document-server" ], "contributes": { "postgres-database": { @@ -19,11 +20,13 @@ "binds": { "postgres-database": "${dir:state}/database.json", "s3-bucket": "${dir:state}/store.json", - "route": "${dir:state}/route.json" + "route": "${dir:state}/route.json", + "document-server": "${dir:state}/document-server.json" }, "secrets": { "postgres-database": "${dir:state}/database.secret", - "s3-bucket": "${dir:state}/store.secret" + "s3-bucket": "${dir:state}/store.secret", + "document-server": "${dir:state}/document-server.secret" }, "emits": [ "user.created", @@ -54,35 +57,66 @@ } ] }, + "config": { + "container": { + "type": "string", + "description": "The name of the container the module's tools run Nextcloud's occ in; empty is nextcloud.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Nextcloud's sharing API at, as a URL; empty is this machine's Nextcloud on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Nextcloud account the module's tools act as; empty is admin.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket}\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n" + "template": "files/server.env" }, { "id": "html", "type": "directory", "mode": "0750", - "owner": "33:33" + "owner": "33:33", + "unknown": "program" }, { "id": "server", "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/status.php", + "status": 200, + "body": "\"installed\":true", + "needs": "postgres-database" + }, "name": "nextcloud", "image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08", "env-file": [ @@ -101,7 +135,9 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["container", "url", "user"], + "protected": ["onlyofficeSecret"], "merge": "json" } ], @@ -125,6 +161,11 @@ "MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin", "MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf b/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf new file mode 100644 index 00000000..2640a8b2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf @@ -0,0 +1,12 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of +# the operator's that sorts after this one overrides it, and is theirs. +# +# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more +# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open. +[nfsd] +vers2=n +vers3=n +vers4=y +vers4.0=n +vers4.1=y +vers4.2=y diff --git a/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf b/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf new file mode 100644 index 00000000..f25f0261 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf @@ -0,0 +1,10 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment. +# Replaced on every push; change the `shares` setting, never this file. +# +# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…], +# which nodes may mount each and how. The module's process exports a share to a node's private address +# only when it is granted here and that node's mounts module wants it, every client mapped to the +# folder's owner, and only to addresses inside the range below. +shares=${config:shares} +grants=${config:grants} +range=${machine:mesh-range} diff --git a/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured b/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured index d67cddde..f9994d16 100644 --- a/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured @@ -51,13 +51,20 @@ "seat:mesh-controller.seats", "seat:mesh-controller.data" ], - "settings": { - "grants": { - "kind": "preference", - "default": "none", - "why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants" - } - }, + "config": { + "shares": { + "type": "string", + "description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).", + "trusted": true, + "required": true + }, + "grants": { + "type": "string", + "description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).", + "trusted": true, + "default": "none" + } + }, "facts": { "machines": { "path": "/etc/nfs-server/machines", @@ -88,13 +95,14 @@ "type": "file", "path": "/etc/nfs.conf.d/50-mesh.conf", "mode": "0644", - "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of\n# the operator's that sorts after this one overrides it, and is theirs.\n#\n# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more\n# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open.\n[nfsd]\nvers2=n\nvers3=n\nvers4=y\nvers4.0=n\nvers4.1=y\nvers4.2=y\n" + "template": "files/50-mesh.conf" }, { "id": "run-dir", "type": "directory", "path": "/run/nfs-server", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "server", @@ -113,14 +121,16 @@ "id": "wants-dir-parent", "type": "directory", "path": "/var/lib/nfs-server", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "wants-dir", "type": "directory", "path": "/var/lib/nfs-server/from-bus", "mode": "0755", - "owner": "${machine:account}" + "owner": "${machine:account}", + "unknown": "program" }, { "id": "exports", @@ -147,14 +157,15 @@ "id": "config-dir", "type": "directory", "path": "/etc/nfs-server", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "config", "type": "file", "path": "/etc/nfs-server/shares.conf", "mode": "0644", - "content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n" + "template": "files/shares.conf" } ], "build": { @@ -169,6 +180,11 @@ "loads": [ "nfs-server" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service b/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service new file mode 100644 index 00000000..5a6ccedb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service @@ -0,0 +1,14 @@ +[Unit] +Description=The mesh's packet filter, derived from what is assigned to this node +Wants=network-pre.target +Before=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/nftables.conf +ExecReload=nft -f /etc/nftables.conf +ExecStop=nft delete table inet mesh + +[Install] +WantedBy=multi-user.target diff --git a/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf b/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf new file mode 100644 index 00000000..f1a7bb0d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf @@ -0,0 +1,5 @@ +# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset +# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it. +[Service] +ExecStop= +ExecStop=nft delete table inet mesh diff --git a/testdata/beside/mesh-catalog/modules/nftables/module.json.captured b/testdata/beside/mesh-catalog/modules/nftables/module.json.captured index 2a80c58b..b7b0c83f 100644 --- a/testdata/beside/mesh-catalog/modules/nftables/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/nftables/module.json.captured @@ -37,14 +37,14 @@ "id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service", - "content": "[Unit]\nDescription=The mesh's packet filter, derived from what is assigned to this node\nWants=network-pre.target\nBefore=network-pre.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=nft -f /etc/nftables.conf\nExecReload=nft -f /etc/nftables.conf\nExecStop=nft delete table inet mesh\n\n[Install]\nWantedBy=multi-user.target\n", + "template": "files/mesh-filter.service", "mode": "0644" }, { "id": "stock-unit-stop", "type": "file", "path": "/etc/systemd/system/nftables.service.d/mesh.conf", - "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", + "template": "files/mesh.conf", "mode": "0644" }, { @@ -80,6 +80,11 @@ "entrypoints": [ "tools/index.js" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf b/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf new file mode 100644 index 00000000..3aebc873 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf @@ -0,0 +1,4 @@ +# The operator account's environment for its service manager and graphical session, generated by +# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push. +# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax. +${collected:environment:systemd} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh b/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh new file mode 100644 index 00000000..c28adf34 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh @@ -0,0 +1,5 @@ +# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203). +# Do not edit: this file is replaced at every push. Every line names the module that contributed it. +# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a +# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines. +${collected:environment:posix} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/node-env/module.json.captured b/testdata/beside/mesh-catalog/modules/node-env/module.json.captured index 43b27e88..85fd7e57 100644 --- a/testdata/beside/mesh-catalog/modules/node-env/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/node-env/module.json.captured @@ -1,6 +1,7 @@ { "module": "node-env", "version": "1", + "moved": true, "claims": [ { "name": "node-environment", @@ -13,14 +14,17 @@ "type": "directory", "path": "${machine:account-home}/.config/mesh", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out", + "knows": ["forge.credentials"] }, { "id": "environment-d", "type": "directory", "path": "${machine:account-home}/.config/environment.d", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "posix", @@ -28,7 +32,7 @@ "path": "${machine:account-home}/.config/mesh/environment.sh", "owner": "${machine:account}", "mode": "0644", - "content": "# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203).\n# Do not edit: this file is replaced at every push. Every line names the module that contributed it.\n# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a\n# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines.\n${environment:posix}" + "template": "files/environment.sh" }, { "id": "systemd", @@ -36,7 +40,16 @@ "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# The operator account's environment for its service manager and graphical session, generated by\n# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push.\n# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax.\n${environment:systemd}" + "template": "files/50-mesh.conf" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured b/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured new file mode 100644 index 00000000..c3573444 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nodejs/module.json.captured @@ -0,0 +1,25 @@ +{ + "module": "nodejs", + "version": "1", + "capabilities": [ + "package-manager" + ], + "provides": [ + { + "name": "node-toolchain", + "reach": "machine" + } + ], + "resources": [ + { + "id": "nodejs", + "type": "package", + "package": "nodejs" + }, + { + "id": "npm", + "type": "package", + "package": "npm" + } + ] +} diff --git a/testdata/beside/mesh-catalog/modules/nodered/files/config.json b/testdata/beside/mesh-catalog/modules/nodered/files/config.json new file mode 100644 index 00000000..a396fbd1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nodered/files/config.json @@ -0,0 +1,3 @@ +{ + "token": "${secret:api-token}" +} diff --git a/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env b/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env new file mode 100644 index 00000000..5331f3cf --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env @@ -0,0 +1,4 @@ +MESH_NODERED_URL=http://127.0.0.1:${port:1880} +MESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/testdata/beside/mesh-catalog/modules/nodered/files/settings.js b/testdata/beside/mesh-catalog/modules/nodered/files/settings.js new file mode 100644 index 00000000..0d0f3515 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nodered/files/settings.js @@ -0,0 +1,48 @@ +// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change +// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and +// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user +// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this. +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const ADMIN_PASSWORD = "${secret:admin}"; +const API_TOKEN = "${secret:api-token}"; +const ADMIN = { username: "admin", permissions: "*" }; + +const settings = JSON.parse(fs.readFileSync(path.join(__dirname, "settings.json"), "utf8")); +// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the +// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the +// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for. +if (settings.timeZone) process.env.TZ = settings.timeZone; +delete settings.timeZone; +delete settings.endpoints; +delete settings.mqtt; +delete settings.topics; + +function same(a, b) { + const x = crypto.createHash("sha256").update(String(a)).digest(); + const y = crypto.createHash("sha256").update(String(b)).digest(); + return crypto.timingSafeEqual(x, y); +} + +// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its +// settings held, so the password people already use keeps working. +function passwordMatches(given) { + if (/^\$2[aby]\$\d\d\$/.test(ADMIN_PASSWORD)) return require("bcryptjs").compare(String(given), ADMIN_PASSWORD); + return Promise.resolve(same(given, ADMIN_PASSWORD)); +} + +module.exports = Object.assign(settings, { + uiPort: 1880, + adminAuth: { + type: "credentials", + users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null), + authenticate: (username, password) => + username === ADMIN.username + ? passwordMatches(password).then((ok) => (ok ? ADMIN : null)) + : Promise.resolve(null), + // The module's own tools call the admin API with this bearer token. + tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: "mesh", permissions: "*" } : null), + }, +}); diff --git a/testdata/beside/mesh-catalog/modules/nodered/files/settings.json b/testdata/beside/mesh-catalog/modules/nodered/files/settings.json new file mode 100644 index 00000000..93cf1572 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/nodered/files/settings.json @@ -0,0 +1,14 @@ +{ + "flowFile": "flows.json", + "flowFilePretty": true, + "diagnostics": { "enabled": true, "ui": true }, + "runtimeState": { "enabled": false, "ui": false }, + "logging": { "console": { "level": "info", "metrics": false, "audit": false } }, + "exportGlobalContextKeys": false, + "externalModules": {}, + "editorTheme": { "projects": { "enabled": false } }, + "functionExternalModules": true, + "debugMaxLength": 1000, + "mqttReconnectTime": 15000, + "serialReconnectTime": 15000 +} diff --git a/testdata/beside/mesh-catalog/modules/nodered/module.json.captured b/testdata/beside/mesh-catalog/modules/nodered/module.json.captured index 3bd4a552..f8068dd1 100644 --- a/testdata/beside/mesh-catalog/modules/nodered/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/nodered/module.json.captured @@ -36,29 +36,135 @@ } ] }, + "config": { + "flow-file": { + "type": "string", + "description": "Node-RED's flowFile setting: the file in the user directory the flows are kept in.", + "trusted": true, + "default": "flows.json" + }, + "flow-file-pretty": { + "type": "boolean", + "description": "Node-RED's flowFilePretty setting: whether the flows file is written indented.", + "trusted": true, + "default": true + }, + "diagnostics": { + "type": "object", + "description": "Node-RED's diagnostics setting: whether its diagnostics report is on, and shown in the editor.", + "trusted": true, + "default": { + "enabled": true, + "ui": true + } + }, + "runtime-state": { + "type": "object", + "description": "Node-RED's runtimeState setting: whether flows may be started and stopped from the editor.", + "trusted": true, + "default": { + "enabled": false, + "ui": false + } + }, + "logging": { + "type": "object", + "description": "Node-RED's logging setting: the console log's level, and whether metrics and audit events are logged.", + "trusted": true, + "default": { + "console": { + "level": "info", + "metrics": false, + "audit": false + } + } + }, + "export-global-context-keys": { + "type": "boolean", + "description": "Node-RED's exportGlobalContextKeys setting: whether a function node may list the global context's keys.", + "trusted": true, + "default": false + }, + "external-modules": { + "type": "object", + "description": "Node-RED's externalModules setting: which modules the editor may install, and from where.", + "trusted": true, + "default": {} + }, + "editor-theme": { + "type": "object", + "description": "Node-RED's editorTheme setting: the editor's look and features, projects among them.", + "trusted": true, + "default": { + "projects": { + "enabled": false + } + } + }, + "function-external-modules": { + "type": "boolean", + "description": "Node-RED's functionExternalModules setting: whether a function node may load modules of its own.", + "trusted": true, + "default": true + }, + "debug-max-length": { + "type": "number", + "description": "Node-RED's debugMaxLength setting: the most characters a debug message shows, a count.", + "trusted": true, + "default": 1000 + }, + "mqtt-reconnect-time": { + "type": "number", + "description": "Node-RED's mqttReconnectTime setting: how long an MQTT node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "serial-reconnect-time": { + "type": "number", + "description": "Node-RED's serialReconnectTime setting: how long a serial node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "mqtt": { + "type": "object", + "description": "The broker nodes the module's MQTT step keeps pointed at the mesh's broker, as {\"brokers\": [their ids]}; empty, none.", + "trusted": true, + "default": {} + }, + "time-zone": { + "type": "string", + "description": "The time zone the flows schedule and format in, an IANA name such as Europe/Brussels; unset, the container's UTC.", + "trusted": true, + "default": "" + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "1000:1000" + "owner": "1000:1000", + "unknown": "program" }, { "id": "written", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "settings-code", @@ -66,7 +172,7 @@ "path": "${dir:state}/settings.js", "mode": "0600", "owner": "1000:1000", - "content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the\n// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\ndelete settings.mqtt;\ndelete settings.topics;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n" + "template": "files/settings.js" }, { "id": "settings", @@ -75,7 +181,8 @@ "mode": "0600", "owner": "1000:1000", "merge": "json", - "content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n" + "takes-config": [{"key": "flow-file", "as": "flowFile"}, {"key": "flow-file-pretty", "as": "flowFilePretty"}, "diagnostics", {"key": "runtime-state", "as": "runtimeState"}, "logging", {"key": "export-global-context-keys", "as": "exportGlobalContextKeys"}, {"key": "external-modules", "as": "externalModules"}, {"key": "editor-theme", "as": "editorTheme"}, {"key": "function-external-modules", "as": "functionExternalModules"}, {"key": "debug-max-length", "as": "debugMaxLength"}, {"key": "mqtt-reconnect-time", "as": "mqttReconnectTime"}, {"key": "serial-reconnect-time", "as": "serialReconnectTime"}, "mqtt", {"key": "time-zone", "as": "timeZone"}], + "template": "files/settings.json" }, { "id": "server", @@ -110,14 +217,14 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{\n \"token\": \"${secret:api-token}\"\n}\n" + "template": "files/config.json" }, { "id": "mqtt-env", "type": "file", "path": "${dir:state}/mqtt.env", "mode": "0600", - "content": "MESH_NODERED_URL=http://127.0.0.1:${port:1880}\nMESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n" + "template": "files/mqtt.env" }, { "id": "mqtt", @@ -179,6 +286,11 @@ "MESH_NODERED_URL": "http://127.0.0.1:${port:1880}", "MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/ollama/module.json.captured b/testdata/beside/mesh-catalog/modules/ollama/module.json.captured index 2291c632..2d5fb48c 100644 --- a/testdata/beside/mesh-catalog/modules/ollama/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/ollama/module.json.captured @@ -42,7 +42,8 @@ "id": "state", "type": "directory", "path": "/services/ollama", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "server", diff --git a/testdata/beside/mesh-catalog/modules/only-office/files/server.env b/testdata/beside/mesh-catalog/modules/only-office/files/server.env new file mode 100644 index 00000000..a92e1c8f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/only-office/files/server.env @@ -0,0 +1,6 @@ +JWT_ENABLED=true +JWT_SECRET=${secret:jwt} +JWT_HEADER=Authorization +JWT_IN_BODY=true +SECURE_LINK_SECRET=${secret:secure-link} +ALLOW_PRIVATE_IP_ADDRESS=true diff --git a/testdata/beside/mesh-catalog/modules/only-office/module.json.captured b/testdata/beside/mesh-catalog/modules/only-office/module.json.captured index 49f10efd..4cabbfd0 100644 --- a/testdata/beside/mesh-catalog/modules/only-office/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/only-office/module.json.captured @@ -2,6 +2,22 @@ "module": "only-office", "version": "1", "slug": "office", + "provides": [ + { + "name": "document-server", + "scope": "mesh", + "credential": { + "own": "jwt" + } + } + ], + "serves": { + "document-server": { + "scheme": "http", + "port": 9070, + "public-root": "https://${bound:route:name}/" + } + }, "capabilities": [ "container-runtime" ], @@ -14,11 +30,60 @@ "endpoint": "web" } }, + "seats": [ + { + "name": "document-converter", + "scope": "mesh", + "serves": [ + "convert" + ] + } + ], + "claims": [ + { + "name": "document-converter", + "scope": "mesh", + "serves": [ + "convert" + ] + } + ], + "streams": [ + { + "name": "convert-asks", + "events": [ + "convert.asked" + ], + "max-age-days": 1, + "max-mib": 8, + "retention": "work-queue", + "max-deliveries": 3, + "duplicates": 24 + } + ], + "state": [ + { + "name": "convert-answers", + "ttl-seconds": 600 + } + ], "binds": { "route": "${dir:state}/route.json" }, "own-secrets": { - "jwt": "${dir:state}/jwt.secret" + "jwt": { + "path": "${dir:state}/jwt.secret", + "owner": "only-office-converter", + "taken": "at-start" + }, + "secure-link": { + "path": "${dir:state}/secure-link.secret", + "taken": "at-start" + }, + "broker-converter": { + "path": "${dir:state}/broker-converter.secret", + "owner": "only-office-converter" + } }, "listens": [ { @@ -39,7 +104,7 @@ }, { "id": "lib", - "path": "${dir:lib}", + "path": "${dir:server-lib}", "class": "rebuildable", "why": "the document server's working files" }, @@ -79,50 +144,77 @@ { "id": "state", "type": "directory", - "mode": "0700", - "place": "." + "mode": "0711", + "place": ".", + "unknown": "program" + }, + { + "id": "converter-account", + "type": "user", + "name": "only-office-converter", + "shell": "/usr/bin/nologin" }, { "id": "server-env", "type": "file", "path": "${dir:state}/server.env", "mode": "0600", - "content": "JWT_ENABLED=true\nJWT_SECRET=${secret:jwt}\nJWT_HEADER=Authorization\nJWT_IN_BODY=true\nALLOW_PRIVATE_IP_ADDRESS=true\n" + "template": "files/server.env" + }, + { + "id": "box", + "type": "directory", + "mode": "0700", + "unknown": "program" }, { "id": "logs", "type": "directory", - "mode": "0700" + "path": "${dir:box}/logs", + "mode": "0700", + "unknown": "program" }, { "id": "data", "type": "directory", - "mode": "0700" + "path": "${dir:box}/data", + "mode": "0700", + "unknown": "program" }, { - "id": "lib", + "id": "server-lib", "type": "directory", - "mode": "0700" + "path": "${dir:box}/lib", + "mode": "0711", + "unknown": "program" }, { "id": "db", "type": "directory", - "mode": "0700" + "path": "${dir:box}/db", + "mode": "0700", + "unknown": "program" }, { "id": "rabbitmq", "type": "directory", - "mode": "0700" + "path": "${dir:box}/rabbitmq", + "mode": "0700", + "unknown": "program" }, { "id": "redis", "type": "directory", - "mode": "0700" + "path": "${dir:box}/redis", + "mode": "0700", + "unknown": "program" }, { "id": "fonts", "type": "directory", - "mode": "0700" + "path": "${dir:box}/fonts", + "mode": "0700", + "unknown": "program" }, { "id": "net", @@ -144,13 +236,60 @@ "volumes": [ "${dir:logs}:/var/log/onlyoffice", "${dir:data}:/var/www/onlyoffice/Data", - "${dir:lib}:/var/lib/onlyoffice", + "${dir:server-lib}:/var/lib/onlyoffice", "${dir:db}:/var/lib/postgresql", "${dir:rabbitmq}:/var/lib/rabbitmq", "${dir:redis}:/var/lib/redis", "${dir:fonts}:/usr/share/fonts/truetype/custom" ], - "secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible" + "secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET and SECURE_LINK_SECRET from the environment on every start and overwrites local.json and nginx's ds.conf; not convertible" + }, + { + "id": "converter", + "type": "process", + "name": "only-office-converter", + "artifact": "tools", + "run": [ + "./only-office", + "converter" + ], + "user": "only-office-converter", + "env": { + "ONLY_OFFICE_BROKER": "${dir:state}/broker-converter.secret", + "ONLY_OFFICE_JWT": "${dir:state}/jwt.secret", + "ONLY_OFFICE_SERVER": "http://127.0.0.1:9070" + }, + "bus": { + "queues": [ + "convert-asks" + ], + "state": [ + "convert-answers" + ] + }, + "health": { + "kind": "unit" + } } - ] + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/only-office", + "binary": "only-office", + "loads": [ + "only-office" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured b/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured index 90457d98..2e98414e 100644 --- a/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured @@ -19,12 +19,14 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "apply", diff --git a/testdata/beside/mesh-catalog/modules/pacman/files/keeper.json b/testdata/beside/mesh-catalog/modules/pacman/files/keeper.json new file mode 100644 index 00000000..8e340a94 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/pacman/files/keeper.json @@ -0,0 +1,5 @@ +{ + "upgrade-order": [], + "record-days": 180, + "round-start-minutes": 120 +} diff --git a/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf b/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf new file mode 100644 index 00000000..0390e254 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf @@ -0,0 +1,29 @@ +# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written +# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by +# the host. Owned whole because [options] cannot take a block by appending: anything added at the end +# of the file lands in the last repository's section. +# +# The repositories are the union of what the machines had enabled when the module was written +# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a +# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged +# alpm user, which pacman 7 creates. + +[options] +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +Color +VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[multilib] +Include = /etc/pacman.d/mirrorlist diff --git a/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf b/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf new file mode 100644 index 00000000..eb9e731b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf @@ -0,0 +1,8 @@ +# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly, +# through reflector.timer. Written whole at every push. Before the module, every machine's list was +# generated once and never again. +--save /etc/pacman.d/mirrorlist +--protocol https +--country Belgium,Netherlands,Luxembourg,Germany,France +--latest 20 +--sort rate diff --git a/testdata/beside/mesh-catalog/modules/pacman/module.json.captured b/testdata/beside/mesh-catalog/modules/pacman/module.json.captured index 98ef2440..97eaad3f 100644 --- a/testdata/beside/mesh-catalog/modules/pacman/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/pacman/module.json.captured @@ -11,14 +11,73 @@ "scope": "node" } ], + "reads": [ + "mesh-controller.declared-packages" + ], + "uses": [ + "operator-channel" + ], + "config": { + "upgrade-order": { + "type": "list", + "description": "The nodes in the order they upgrade, each by its name: the first opens each week's round on Saturday at 04:00 local time, and each node upgrades after the one before it passed its after-upgrade check (novox/hq ADR 0508 §5). Empty: no round starts, and every node's health says why. Falls back to the mesh-level key of the same name.", + "trusted": true, + "predefined": "upgrade-order", + "default": [] + }, + "record-days": { + "type": "number", + "description": "How many days a package record taken before an upgrade is kept on the bus before it is deleted (novox/hq ADR 0508 §2); a rollback can return only to a record still kept.", + "minimum": 1, + "trusted": false, + "default": 180 + }, + "round-start-minutes": { + "type": "number", + "description": "How many minutes a node whose turn in the round came may take to start its upgrade before the round is said to stand still, in that node's and the waiting nodes' health (novox/hq ADR 0508 §6).", + "minimum": 1, + "trusted": false, + "default": 120 + } + }, + "own-secrets": { + "broker-keeper": { + "path": "${dir:state}/broker-keeper.secret", + "owner": "pacman-keeper" + } + }, + "state": [ + "installed", + "updates", + "runs", + "records", + "rounds", + "asks" + ], + "emits": [ + "package.changed", + "upgrade.recorded", + "upgrade.kernel-held", + "upgrade.started", + "upgrade.finished", + "upgrade.failed", + "upgrade.checked", + "rollback.started", + "rollback.finished" + ], "tools": [ "pacman_search", "pacman_info", "pacman_installed", + "pacman_packages", "pacman_owns", "pacman_files", "pacman_updates", "pacman_upgrade", + "pacman_upgrade_health", + "pacman_upgrades", + "pacman_rollback", + "pacman_round", "pacman_orphans", "pacman_remove_orphans", "pacman_cache", @@ -39,7 +98,7 @@ "type": "file", "path": "/etc/pacman.conf", "mode": "0644", - "content": "# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written\n# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by\n# the host. Owned whole because [options] cannot take a block by appending: anything added at the end\n# of the file lands in the last repository's section.\n#\n# The repositories are the union of what the machines had enabled when the module was written\n# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a\n# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged\n# alpm user, which pacman 7 creates.\n\n[options]\nHoldPkg = pacman glibc\nArchitecture = auto\nCheckSpace\nColor\nVerbosePkgLists\nParallelDownloads = 5\nDownloadUser = alpm\nSigLevel = Required DatabaseOptional\nLocalFileSigLevel = Optional\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n" + "template": "files/pacman.conf" }, { "id": "contrib", @@ -56,21 +115,120 @@ "type": "file", "path": "/etc/xdg/reflector/reflector.conf", "mode": "0644", - "content": "# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly,\n# through reflector.timer. Written whole at every push. Before the module, every machine's list was\n# generated once and never again.\n--save /etc/pacman.d/mirrorlist\n--protocol https\n--country Belgium,Netherlands,Luxembourg,Germany,France\n--latest 20\n--sort rate\n" + "template": "files/reflector.conf" }, { "id": "mirror-refresh", "type": "service", "unit": "reflector.timer", "state": "running", - "boot": "enabled" + "boot": "enabled", + "health": { + "kind": "unit" + } }, { "id": "cache-cleaning", "type": "service", "unit": "paccache.timer", "state": "running", - "boot": "enabled" + "boot": "enabled", + "health": { + "kind": "unit" + } + }, + { + "id": "keeper-account", + "type": "user", + "name": "pacman-keeper", + "shell": "/usr/bin/nologin" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "place": "mesh", + "unknown": "program" + }, + { + "id": "keeper-config", + "type": "file", + "path": "${dir:state}/keeper.json", + "mode": "0644", + "takes-config": [ + "upgrade-order", + "record-days", + "round-start-minutes" + ], + "merge": "json", + "template": "files/keeper.json", + "trusted": true + }, + { + "id": "root-helper", + "type": "process", + "name": "pacman-root", + "artifact": "tools", + "run": [ + "./pacman-tools", + "root" + ], + "env": { + "PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock", + "PACMAN_ROOT_PEER": "pacman-keeper" + }, + "health": { + "kind": "unit" + } + }, + { + "id": "keeper", + "type": "process", + "name": "pacman-keeper", + "artifact": "tools", + "run": [ + "./pacman-tools", + "keeper" + ], + "user": "pacman-keeper", + "env": { + "PACMAN_KEEPER_BROKER": "${dir:state}/broker-keeper.secret", + "PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json", + "PACMAN_ROOT_SOCKET": "${dir:state}/root/helper.sock" + }, + "restart-on": [ + "keeper-config" + ], + "bus": { + "state": [ + "installed", + "updates", + "runs", + "records", + "rounds", + "asks" + ], + "emits": [ + "package.changed", + "upgrade.recorded", + "upgrade.kernel-held", + "upgrade.started", + "upgrade.finished", + "upgrade.failed", + "upgrade.checked", + "rollback.started", + "rollback.finished" + ], + "asks": true + }, + "health": { + "kind": "tool", + "tool": "pacman_upgrade_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } } ], "build": { @@ -84,7 +242,15 @@ "binary": "pacman-tools", "loads": [ "pacman-tools" - ] + ], + "env": { + "PACMAN_KEEPER_CONFIG": "${dir:state}/keeper.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured b/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured index 0ab336b8..101bdc27 100644 --- a/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured @@ -31,7 +31,8 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "net", diff --git a/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured b/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured index 518012f8..9c1c83fa 100644 --- a/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured @@ -31,7 +31,8 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "net", diff --git a/testdata/beside/mesh-catalog/modules/picom/files/picom.conf b/testdata/beside/mesh-catalog/modules/picom/files/picom.conf new file mode 100644 index 00000000..c6b85551 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/picom/files/picom.conf @@ -0,0 +1,38 @@ +# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is +# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to +# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim. + +# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research +# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every +# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or +# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is +# left unpainted or stale. +backend = "${config:backend}"; +use-damage = ${config:use-damage}; +vsync = true; + +fading = true; +fade-in-step = 0.05; +fade-out-step = 0.05; + +# Tiled windows hide their shadows and corners, so neither is drawn. +shadow = false; +corner-radius = 0; +blur-method = "none"; + +# "Focused" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach +# the toplevel picom tracks, and a terminal then never lifts to its focused opacity. +use-ewmh-active-win = true; + +# Window rules are applied in order, and a later match wins. Only terminals are translucent: +# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class; +# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is +# used wherever no rule sets one, which is what the window-opacity tool sets. +rules = ( + { match = "window_type = 'tooltip'"; fade = false; opacity = 0.95; }, + { match = "window_type = 'dock' || window_type = 'desktop'"; fade = false; }, + { match = "class_g = 'XTerm' && focused"; opacity = 0.95; }, + { match = "class_g = 'XTerm' && !focused"; opacity = 0.75; }, + # A full-screen window is opaque, a terminal included: a video or a game is never see-through. + { match = "fullscreen"; opacity = 1; } +); diff --git a/testdata/beside/mesh-catalog/modules/picom/module.json.captured b/testdata/beside/mesh-catalog/modules/picom/module.json.captured index 99d95644..cfdc3970 100644 --- a/testdata/beside/mesh-catalog/modules/picom/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/picom/module.json.captured @@ -13,18 +13,26 @@ "scope": "node" } ], - "settings": { - "backend": { - "kind": "preference", - "default": "glx", - "why": "glx draws with the graphics card and has served every machine so far; xrender draws on the CPU and is what to try where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345)" + "config": { + "backend": { + "type": "string", + "description": "How picom draws: glx with the graphics card, which has served every machine so far, or xrender on the CPU where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345); xr_glx_hybrid and egl are picom's other backends.", + "enum": [ + "glx", + "xrender", + "xr_glx_hybrid", + "egl" + ], + "trusted": true, + "default": "glx" + }, + "use-damage": { + "type": "boolean", + "description": "Whether picom repaints only what changed (true, picom's own default) or the whole screen each frame (false), which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345).", + "trusted": true, + "default": true + } }, - "use-damage": { - "kind": "preference", - "default": true, - "why": "true repaints only what changed, picom's own default; false repaints the whole screen each frame, which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345)" - } - }, "tools": [ "picom_restart", "picom_rules", @@ -47,7 +55,8 @@ "type": "directory", "path": "${machine:account-home}/.config/picom", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "configuration", @@ -55,7 +64,7 @@ "path": "${machine:account-home}/.config/picom/picom.conf", "owner": "${machine:account}", "mode": "0644", - "content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\n# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research\n# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every\n# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or\n# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is\n# left unpainted or stale.\nbackend = \"${setting:backend}\";\nuse-damage = ${setting:use-damage};\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n" + "template": "files/picom.conf" } ], "build": { @@ -70,6 +79,11 @@ "loads": [ "picom-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session b/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session new file mode 100644 index 00000000..bbdbd6f2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session @@ -0,0 +1,6 @@ +# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh: +# replaced at every push. This line is the tray's one start, at the session's start (exec, not +# exec_always, so a reload starts nothing). The package's own login helper starts it too while the +# application's setting "Start the tray applet when I log on" is ticked; polychromatic_check names +# that as a second start. +exec --no-startup-id polychromatic-tray-applet diff --git a/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured b/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured index eacb0bfe..4d6ba4b3 100644 --- a/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured @@ -1,6 +1,7 @@ { "module": "polychromatic", "version": "1", + "moved": true, "requires": [ "x11-display" ], @@ -9,11 +10,11 @@ "polychromatic_restart", "polychromatic_check" ], - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" } ], "build": { @@ -28,6 +29,11 @@ "loads": [ "polychromatic-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json b/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json new file mode 100644 index 00000000..30d160e4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json @@ -0,0 +1 @@ +${consumers:postgres-database} diff --git a/testdata/beside/mesh-catalog/modules/postgres/module.json.captured b/testdata/beside/mesh-catalog/modules/postgres/module.json.captured index b55ed746..e8a59069 100644 --- a/testdata/beside/mesh-catalog/modules/postgres/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/postgres/module.json.captured @@ -1,6 +1,7 @@ { "module": "postgres", "version": "1", + "moved": true, "upgrade": { "policy": "record", "why": "a provider whose restart drops every consumer on its machine, and whose new major version changes its data's format: a person takes each build, after a backup (hq ADR 0236)" @@ -60,8 +61,8 @@ "postgres-database": "${dir:grants}" }, "own-secrets": { - "superuser": "${dir:state}/superuser.secret", - "reader": "${dir:state}/reader.secret" + "superuser": "${dir:data}/superuser.secret", + "reader": "${dir:data}/reader.secret" }, "data": { "own": [ @@ -92,29 +93,49 @@ }, "resources": [ { - "id": "state", + "id": "data", "type": "directory", "mode": "0700", - "place": "." + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/postgres", + "owner": "postgres", + "mode": "0700", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "path": "${dir:data}/grants", + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:data}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" }, { "id": "store-data", "type": "directory", "path": "/var/lib/mesh-store", "mode": "0700", - "owner": "999:70" + "owner": "999:70", + "unknown": "program" }, { "id": "dumps", "type": "directory", "path": "${dir:store-data}/dumps", "mode": "0700", - "owner": "999:70" + "owner": "999:70", + "unknown": "program" }, { "id": "server", @@ -134,7 +155,7 @@ ], "volumes": [ "/var/lib/mesh-store:/var/lib/postgresql/data", - "${dir:state}/superuser.secret:/run/secrets/superuser:ro" + "${dir:data}/superuser.secret:/run/secrets/superuser:ro" ] }, { @@ -157,11 +178,23 @@ ], "env": { "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", - "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret", + "MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret", "MESH_RECEIVES": "${dir:grants}/mesh.json", - "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:data}/consumers.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment b/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment new file mode 100755 index 00000000..e7095e4e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment @@ -0,0 +1,90 @@ +#!/bin/sh +# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211). +# Shipped by the mesh's power module; edit the catalogue. +# +# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or +# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change. +# +# The moment's file holds what the controller placed: each module's code under a line `# `. +# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds +# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or +# stop the next module. Every outcome goes to the journal under the tag mesh-power. +set -u + +moment=${1:-} +force=${2:-} +moments=/etc/mesh-power/moments +bound=${MESH_POWER_BOUND:-30} +runtime=/run/mesh-power + +say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; } + +# The power source as the kernel says it: on mains when any Mains supply is online. A machine with +# no Mains supply (a desktop, a server, a USB-C source only) has no source to change. +source_now() { + found=no + for s in /sys/class/power_supply/*; do + [ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue + found=yes + [ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; } + done + [ "$found" = yes ] && echo on-battery || echo none +} + +case "$moment" in +supply) + now=$(source_now) + [ "$now" = none ] && exit 0 + mkdir -p "$runtime" + last=$(cat "$runtime/source" 2>/dev/null || true) + [ "$now" = "$last" ] && [ "$force" != --force ] && exit 0 + echo "$now" >"$runtime/source" + moment=$now + ;; +after-boot | before-sleep | after-wake | on-mains | on-battery) ;; +before-shutdown) + # Run only while the machine is really going down. The unit that runs it is stopped at shutdown, + # and also when the module is taken off the machine; the second must not run anyone's shutdown code. + state=$(systemctl is-system-running 2>/dev/null || true) + if [ "$state" != stopping ] && [ "$force" != --force ]; then + say "moment=before-shutdown skipped: the system is $state, not stopping" + exit 0 + fi + ;; +*) + echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2 + exit 2 + ;; +esac + +file="$moments/$moment" +[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; } + +work=$(mktemp -d) || exit 1 +trap 'rm -rf "$work"' EXIT + +# One file per module piece, in the order the controller placed them. A line that is exactly +# "# " opens a piece; lines before the first are the holder's own header. +awk -v dir="$work" ' + /^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next } + n > 0 { print > out } +' "$file" + +ran=0 failed=0 +for piece in "$work"/*; do + [ -e "$piece" ] || continue + module=${piece##*/} + module=${module#*-} + start=$(date +%s) + timeout --kill-after=5 "$bound" sh "$piece" "$piece.out" 2>&1 + rc=$? + took=$(($(date +%s) - start)) + ran=$((ran + 1)) + case $rc in + 0) say "moment=$moment module=$module ok in ${took}s" ;; + 124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;; + *) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;; + esac +done +say "moment=$moment: $ran piece(s), $failed failed" +exit 0 diff --git a/testdata/beside/mesh-catalog/modules/power/module.json.captured b/testdata/beside/mesh-catalog/modules/power/module.json.captured index 6411982c..a110d410 100644 --- a/testdata/beside/mesh-catalog/modules/power/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/power/module.json.captured @@ -1,6 +1,8 @@ { "module": "power", "version": "1", + "moved": true, + "templates": "templates", "capabilities": [ "package-manager", "service-manager" @@ -34,6 +36,88 @@ "power_run", "power_check" ], + "config": { + "handle-power-key": { + "type": "string", + "description": "What logind does when the power key is pressed: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch": { + "type": "string", + "description": "What logind does when the lid is closed on battery: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-external-power": { + "type": "string", + "description": "What logind does when the lid is closed on external power: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-docked": { + "type": "string", + "description": "What logind does when the lid is closed while docked or with another screen attached: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + } + }, "resources": [ { "id": "polkit", @@ -44,74 +128,78 @@ "id": "scripts", "type": "archive", "path": "/usr/local/lib/mesh-power", - "artifact": "scripts" + "artifact": "scripts", + "unknown": "program" }, { "id": "config-dir", "type": "directory", "path": "/etc/mesh-power", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "moments-dir", "type": "directory", "path": "/etc/mesh-power/moments", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "moment-after-boot", "type": "file", "path": "/etc/mesh-power/moments/after-boot", "mode": "0644", - "content": "# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last}" + "template": "templates/mesh-power/moments/after-boot" }, { "id": "moment-before-sleep", "type": "file", "path": "/etc/mesh-power/moments/before-sleep", "mode": "0644", - "content": "# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last}" + "template": "templates/mesh-power/moments/before-sleep" }, { "id": "moment-after-wake", "type": "file", "path": "/etc/mesh-power/moments/after-wake", "mode": "0644", - "content": "# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last}" + "template": "templates/mesh-power/moments/after-wake" }, { "id": "moment-before-shutdown", "type": "file", "path": "/etc/mesh-power/moments/before-shutdown", "mode": "0644", - "content": "# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last}" + "template": "templates/mesh-power/moments/before-shutdown" }, { "id": "moment-on-mains", "type": "file", "path": "/etc/mesh-power/moments/on-mains", "mode": "0644", - "content": "# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last}" + "template": "templates/mesh-power/moments/on-mains" }, { "id": "moment-on-battery", "type": "file", "path": "/etc/mesh-power/moments/on-battery", "mode": "0644", - "content": "# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last}" + "template": "templates/mesh-power/moments/on-battery" }, { "id": "logind-drop-ins", "type": "directory", "path": "/etc/systemd/logind.conf.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "logind-power", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# The power key and the lid, from this module's settings: the mesh's, then this machine's.\n[Login]\nHandlePowerKey=${setting:handle-power-key}\nHandleLidSwitch=${setting:handle-lid-switch}\nHandleLidSwitchExternalPower=${setting:handle-lid-switch-external-power}\nHandleLidSwitchDocked=${setting:handle-lid-switch-docked}\n" + "template": "templates/systemd/logind.conf.d/power.conf" }, { "id": "logind", @@ -126,107 +214,112 @@ "type": "file", "path": "/etc/systemd/system/mesh-power-after-boot.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nDescription=Every module's code after boot (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "templates/systemd/system/mesh-power-after-boot.service" }, { "id": "before-shutdown-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-before-shutdown.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at\n# shutdown it stops, and runs every module's code, while the network is still up.\n[Unit]\nDescription=Every module's code before shutdown (mesh power)\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/bin/true\nExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown\nTimeoutStopSec=120\n\n[Install]\nWantedBy=multi-user.target\n" + "template": "templates/systemd/system/mesh-power-before-shutdown.service" }, { "id": "before-sleep-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-before-sleep.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting\n# for a sleep is not a service whose state the mesh can declare.\n[Unit]\nDescription=Every module's code before sleep (mesh power)\nBefore=sleep.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep\n" + "template": "templates/systemd/system/mesh-power-before-sleep.service" }, { "id": "after-wake-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-after-wake.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after\n# them, so it runs once the machine is awake again. Never enabled.\n[Unit]\nDescription=Every module's code after waking (mesh power)\nAfter=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake\n" + "template": "templates/systemd/system/mesh-power-after-wake.service" }, { "id": "supply-unit", "type": "file", "path": "/etc/systemd/system/mesh-power-supply.service", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n#\n# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per\n# change of source.\n[Unit]\nDescription=Every module's code for the power source (mesh power)\n\n[Service]\nType=oneshot\nExecStart=/usr/local/lib/mesh-power/bin/power-moment supply\n" + "template": "templates/systemd/system/mesh-power-supply.service" }, { "id": "sleep-drop-ins", "type": "directory", "path": "/etc/systemd/system/sleep.target.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "sleep-wants", "type": "file", "path": "/etc/systemd/system/sleep.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-before-sleep.service\n" + "template": "templates/systemd/system/sleep.target.d/mesh-power.conf" }, { "id": "suspend-drop-ins", "type": "directory", "path": "/etc/systemd/system/suspend.target.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "suspend-wants", "type": "file", "path": "/etc/systemd/system/suspend.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/suspend.target.d/mesh-power.conf" }, { "id": "hibernate-drop-ins", "type": "directory", "path": "/etc/systemd/system/hibernate.target.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "hibernate-wants", "type": "file", "path": "/etc/systemd/system/hibernate.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/hibernate.target.d/mesh-power.conf" }, { "id": "hybrid-sleep-drop-ins", "type": "directory", "path": "/etc/systemd/system/hybrid-sleep.target.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "hybrid-sleep-wants", "type": "file", "path": "/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf" }, { "id": "suspend-then-hibernate-drop-ins", "type": "directory", "path": "/etc/systemd/system/suspend-then-hibernate.target.d", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "suspend-then-hibernate-wants", "type": "file", "path": "/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n[Unit]\nWants=mesh-power-after-wake.service\n" + "template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf" }, { "id": "udev-rule", "type": "file", "path": "/etc/udev/rules.d/90-mesh-power.rules", "mode": "0644", - "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n# Only the charger's change can switch the machine between mains and battery. A battery reports its\n# level many times a minute, and USB-C supplies come and go; matching them started the supply unit\n# every second or two on the laptop.\nSUBSYSTEM==\"power_supply\", ACTION==\"change\", ATTR{type}==\"Mains\", RUN+=\"/usr/bin/systemctl --no-block start mesh-power-supply.service\"\n" + "template": "templates/udev/rules.d/90-mesh-power.rules" }, { "id": "after-boot", @@ -246,7 +339,8 @@ "id": "draw-dir", "type": "directory", "path": "/run/mesh-power", - "mode": "0755" + "mode": "0755", + "unknown": "program" }, { "id": "draw", @@ -279,34 +373,36 @@ "name": "scripts", "kind": "archive", "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] }, - "contributions": [ + "items": [ { - "seat": "node-bar", - "kind": "block", + "collection": "contribution:node-bar:block", + "name": "battery", "if-capability": "battery", - "data": { + "attributes": { "bar": "bottom", "place": "status", - "order": 40, - "shows": "battery" - } + "order": "40" + }, + "template": "templates/bar-battery" }, { - "seat": "node-bar", - "kind": "block", + "collection": "contribution:node-bar:block", + "name": "power-draw", "if-capability": "power-meter", - "data": { + "attributes": { "bar": "bottom", "place": "status", - "order": 45, - "shows": "state", - "options": { - "state": "power.draw" - } - } + "order": "45" + }, + "template": "templates/bar-power-draw" } ] } diff --git a/testdata/beside/mesh-catalog/modules/power/templates/bar-battery b/testdata/beside/mesh-catalog/modules/power/templates/bar-battery new file mode 100644 index 00000000..d2386bab --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/bar-battery @@ -0,0 +1,9 @@ +[[block]] +block = "battery" +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" + diff --git a/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw b/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw new file mode 100644 index 00000000..3d0d3380 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw @@ -0,0 +1,6 @@ +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw ${machine:name}" +interval = 5 +hide_when_empty = true + diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot new file mode 100644 index 00000000..c5a90f6b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake new file mode 100644 index 00000000..2655c226 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown new file mode 100644 index 00000000..65cc76d3 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep new file mode 100644 index 00000000..e5fc2a5f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery new file mode 100644 index 00000000..58e41a97 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains new file mode 100644 index 00000000..b4a65866 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf new file mode 100644 index 00000000..9cf93bdc --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf @@ -0,0 +1,8 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# The power key and the lid, from this module's settings: the mesh's, then this machine's. +[Login] +HandlePowerKey=${config:handle-power-key} +HandleLidSwitch=${config:handle-lid-switch} +HandleLidSwitchExternalPower=${config:handle-lid-switch-external-power} +HandleLidSwitchDocked=${config:handle-lid-switch-docked} diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service new file mode 100644 index 00000000..b7045774 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service @@ -0,0 +1,13 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Description=Every module's code after boot (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot + +[Install] +WantedBy=multi-user.target diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service new file mode 100644 index 00000000..773b2284 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after +# them, so it runs once the machine is awake again. Never enabled. +[Unit] +Description=Every module's code after waking (mesh power) +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service new file mode 100644 index 00000000..52db0a4c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service @@ -0,0 +1,18 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at +# shutdown it stops, and runs every module's code, while the network is still up. +[Unit] +Description=Every module's code before shutdown (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/true +ExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service new file mode 100644 index 00000000..727587e3 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting +# for a sleep is not a service whose state the mesh can declare. +[Unit] +Description=Every module's code before sleep (mesh power) +Before=sleep.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service new file mode 100644 index 00000000..5096a00e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service @@ -0,0 +1,10 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per +# change of source. +[Unit] +Description=Every module's code for the power source (mesh power) + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment supply diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf new file mode 100644 index 00000000..096f7935 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-before-sleep.service diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf new file mode 100644 index 00000000..81223d3c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules b/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules new file mode 100644 index 00000000..d0a75bbf --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules @@ -0,0 +1,5 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# Only the charger's change can switch the machine between mains and battery. A battery reports its +# level many times a minute, and USB-C supplies come and go; matching them started the supply unit +# every second or two on the laptop. +SUBSYSTEM=="power_supply", ACTION=="change", ATTR{type}=="Mains", RUN+="/usr/bin/systemctl --no-block start mesh-power-supply.service" diff --git a/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal b/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal new file mode 100644 index 00000000..ba4fc1d6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal @@ -0,0 +1,3 @@ +# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration. +[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme +[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh diff --git a/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured b/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured index 574a01ba..da1bd4df 100644 --- a/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured @@ -1,27 +1,30 @@ { "module": "powerlevel10k", "version": "1", - "shell": [ - { - "for": "zsh", - "slot": "normal", - "code": "# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.\n[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh\n" - } - ], + "moved": true, "resources": [ { "id": "theme", "type": "archive", "path": "${machine:account-home}/.local/share/powerlevel10k", "owner": "${machine:account}", - "artifact": "theme" + "artifact": "theme", + "unknown": "move-out", + "knows": ["powerlevel9k.zsh-theme.zwc", "powerlevel10k.zsh-theme.zwc", "internal/p10k.zsh.zwc", "internal/icons.zsh.zwc", "internal/configure.zsh.zwc", "internal/worker.zsh.zwc", "internal/parser.zsh.zwc", "gitstatus/gitstatus.plugin.zsh.zwc", "gitstatus/install.zwc"] }, { "id": "configuration", "type": "archive", "path": "${machine:account-home}/.config/powerlevel10k", "owner": "${machine:account}", - "artifact": "configuration" + "artifact": "configuration", + "unknown": "move-out" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" } ], "build": { @@ -35,6 +38,11 @@ "name": "configuration", "kind": "archive", "from": "config" + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/records/files/config.json b/testdata/beside/mesh-catalog/modules/records/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/records/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/records/files/origin b/testdata/beside/mesh-catalog/modules/records/files/origin new file mode 100644 index 00000000..8c829dc9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/records/files/origin @@ -0,0 +1 @@ +${bound:git:scheme}://${bound:git:at}:${bound:git:port} diff --git a/testdata/beside/mesh-catalog/modules/records/module.json.captured b/testdata/beside/mesh-catalog/modules/records/module.json.captured index 1ba903da..db6f7e5a 100644 --- a/testdata/beside/mesh-catalog/modules/records/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/records/module.json.captured @@ -29,25 +29,37 @@ } ] }, + "config": { + "repository": { + "type": "string", + "description": "The repository the records module keeps a checkout of and serves the record from, as owner/name on the mesh's forge; the operator's, with no default (novox/hq ADR 0112).", + "pattern": "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + "trusted": true, + "required": true + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "checkout", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n", + "template": "files/config.json", + "takes-config": ["repository"], "merge": "json" }, { @@ -55,7 +67,7 @@ "type": "file", "path": "${dir:mesh-state}/origin", "mode": "0600", - "content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n" + "template": "files/origin" }, { "id": "git", @@ -80,6 +92,11 @@ "MESH_RECORDS_ORIGIN_FILE": "${dir:mesh-state}/origin", "MESH_RECORDS_DIR": "${dir:checkout}" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/redis/files/redis.conf b/testdata/beside/mesh-catalog/modules/redis/files/redis.conf new file mode 100644 index 00000000..ac27331f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/redis/files/redis.conf @@ -0,0 +1,3 @@ +requirepass ${secret:secret} +appendonly yes +dir /data diff --git a/testdata/beside/mesh-catalog/modules/redis/module.json.captured b/testdata/beside/mesh-catalog/modules/redis/module.json.captured index bdb344a4..58e11064 100644 --- a/testdata/beside/mesh-catalog/modules/redis/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/redis/module.json.captured @@ -68,25 +68,28 @@ "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "grants", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "data", "type": "directory", "mode": "0700", - "owner": "999:1000" + "owner": "999:1000", + "unknown": "program" }, { "id": "server-conf", "type": "file", "path": "${dir:state}/redis.conf", "mode": "0600", - "content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n", + "template": "files/redis.conf", "owner": "999:1000" }, { @@ -140,6 +143,11 @@ "MESH_PROVISION_REDIS": "127.0.0.1:${port:6379}", "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/default.secret" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/restic/files/backups.conf b/testdata/beside/mesh-catalog/modules/restic/files/backups.conf new file mode 100644 index 00000000..2f23f90f --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/restic/files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/restic/files/data.conf b/testdata/beside/mesh-catalog/modules/restic/files/data.conf new file mode 100644 index 00000000..f19e8499 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/restic/files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/restic/files/retention.conf b/testdata/beside/mesh-catalog/modules/restic/files/retention.conf new file mode 100644 index 00000000..cd7954c6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/restic/files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/testdata/beside/mesh-catalog/modules/restic/module.json.captured b/testdata/beside/mesh-catalog/modules/restic/module.json.captured index 604ce0c7..18ee811d 100644 --- a/testdata/beside/mesh-catalog/modules/restic/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/restic/module.json.captured @@ -1,6 +1,7 @@ { "module": "restic", "version": "1", + "moved": true, "claims": [ { "name": "node-backup", @@ -37,53 +38,67 @@ } ] }, - "settings": { - "keep-daily": { - "kind": "preference", - "default": 14, - "why": "two weeks of nightly restore points undo a mistake noticed within a fortnight; a count from 1 to 9999, read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } }, - "keep-weekly": { - "kind": "preference", - "default": 8, - "why": "two months of weekly restore points behind the dailies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" - }, - "keep-monthly": { - "kind": "preference", - "default": 6, - "why": "half a year of monthly restore points behind the weeklies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" - }, - "keep-hand-taken-days": { - "kind": "preference", - "default": 7, - "why": "a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453); a count from 1 to 9999, refused by the night like keep-daily; lowering it lets go of the restore points taken by hand that are older than the new count, past a later night, at the next thinning, and that cannot be undone" - } - }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "repository", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "declared", "type": "file", "path": "${dir:state}/backups.conf", "mode": "0600", - "content": "# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.\n${contribution:node-backup:backup}" + "template": "files/backups.conf" }, { "id": "data-declared", "type": "file", "path": "${dir:state}/data.conf", "mode": "0600", - "content": "# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.\n${contribution:node-backup:data}" + "template": "files/data.conf" }, { "id": "retention", @@ -91,7 +106,7 @@ "path": "${dir:state}/retention.conf", "mode": "0644", "trusted": true, - "content": "# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,\n# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.\ndaily=${setting:keep-daily}\nweekly=${setting:keep-weekly}\nmonthly=${setting:keep-monthly}\nhand=${setting:keep-hand-taken-days}\n" + "template": "files/retention.conf" }, { "id": "tool", @@ -170,6 +185,11 @@ "MESH_BACKUP_STATE": "${dir:state}", "MESH_BACKUP_DATA": "${dir:state}/data.conf" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu b/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu new file mode 100755 index 00000000..b7460e94 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu @@ -0,0 +1,26 @@ +#!/bin/sh +# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher +# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output, +# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat. +# +# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w) +# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The +# look is the launcher's theme. +set -u +n=$# +while [ "$n" -gt 0 ]; do + arg=$1 + shift + n=$((n - 1)) + case $arg in + -fn | -nb | -nf | -sb | -sf | -m | -w) + if [ "$n" -gt 0 ]; then + shift + n=$((n - 1)) + fi + ;; + -b | -f) ;; + *) set -- "$@" "$arg" ;; + esac +done +exec rofi -dmenu "$@" diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch b/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch new file mode 100755 index 00000000..788f23c4 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch @@ -0,0 +1,39 @@ +#!/bin/sh +# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's +# key bindings run. +# +# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press +# that opens nothing. So a failure within half a second is tried again, up to five times. A person +# closing the menu takes longer than that, and is never shown it a second time. +set -u +mode=${1:-drun} + +now_ms() { date +%s%3N; } + +attempt() { + i=0 + while [ "$i" -lt 5 ]; do + start=$(now_ms) + "$@" && return 0 + [ $(($(now_ms) - start)) -ge 500 ] && return 1 + i=$((i + 1)) + sleep 0.1 + done + return 1 +} + +case $mode in +drun | run | window | filebrowser) + attempt rofi -show "$mode" + ;; +sudo) + # A command line, run with sudo in the node's terminal. + command=$(attempt rofi -dmenu -p sudo &2 + exit 2 + ;; +esac diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu b/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu new file mode 100755 index 00000000..2bdf64c1 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down. +# +# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a +# verb of logind or the service manager, so nothing here names a window manager or a locker: +# - lock asks logind to lock the session, which the holder of node-lock-screen answers; +# - log out ends this login session, whichever window manager runs in it. +# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2). +set -u + +theme=mesh-powermenu +uptime="$(uptime -p | sed -e 's/^up //')" + +shutdown='󰤂' +reboot='󰜉' +lock='󰌾' +suspend='󰤄' +logout='󰍃' +yes='󰄲' +no='󰅖' + +menu() { + rofi -dmenu -p "Uptime: $uptime" -mesg "Uptime: $uptime" -theme "$theme" +} + +confirm() { + printf '%s\n%s\n' "$yes" "$no" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme "$theme" \ + -theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \ + -theme-str 'mainbox {children: [ "message", "listview" ];}' \ + -theme-str 'listview {columns: 2; lines: 1;}' \ + -theme-str 'element-text {horizontal-align: 0.5;}' \ + -theme-str 'textbox {horizontal-align: 0.5;}' +} + +session() { + # The login session this menu runs in: the session's own id, else logind's answer for this process. + if [ -n "${XDG_SESSION_ID:-}" ]; then + echo "$XDG_SESSION_ID" + else + loginctl show-session auto -p Id --value 2>/dev/null + fi +} + +confirmed() { [ "$(confirm)" = "$yes" ]; } + +chosen="$(printf '%s\n' "$lock" "$suspend" "$logout" "$reboot" "$shutdown" | menu)" || exit 0 +case $chosen in +"$lock") loginctl lock-session "$(session)" ;; +"$suspend") confirmed && systemctl suspend ;; +"$logout") confirmed && loginctl terminate-session "$(session)" ;; +"$reboot") confirmed && systemctl reboot ;; +"$shutdown") confirmed && systemctl poweroff ;; +esac +exit 0 diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi b/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi new file mode 100644 index 00000000..d123371c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi @@ -0,0 +1,54 @@ +/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at + * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from + * rofi's defaults, nothing else. Its look is the theme "mesh", in themes/ beside this file. */ + +configuration { + modi: "drun,run,window,filebrowser"; + show-icons: true; + case-sensitive: false; + normalize-match: true; + matching: "normal"; + tokenize: true; + cycle: true; + steal-focus: false; + click-to-exit: true; + sort: false; + + /* Applications: the user's and the system's desktop entries, read fresh each time so a + * program installed a minute ago is there. */ + drun-match-fields: "name,generic,exec,categories,keywords"; + drun-display-format: "{name} [({generic})]"; + drun-show-actions: false; + drun-url-launcher: "xdg-open"; + drun-use-desktop-cache: false; + drun-reload-desktop-cache: false; + run,drun { + fallback-icon: "application-x-addon"; + } + + /* A command run from `run` opens in the node's terminal when it asks for one. */ + terminal: "rofi-sensible-terminal"; + run-shell-command: "{terminal} -e {cmd}"; + ssh-command: "{terminal} -e {ssh-client} {host} [-p {port}]"; + parse-hosts: true; + parse-known-hosts: true; + + window-match-fields: "title,class,role,name,desktop"; + window-format: "{w} - {c} - {t:0}"; + + disable-history: false; + max-history-size: 25; + + filebrowser { + directories-first: true; + sorting-method: "name"; + } + + display-window: "Windows"; + display-run: "Run"; + display-ssh: "SSH"; + display-drun: "Apps"; + display-filebrowser: "Files"; +} + +@theme "mesh" diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf b/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf new file mode 100644 index 00000000..1395d2c9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf @@ -0,0 +1,8 @@ +# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after +# the variables it sets, so $mod is i3's. +bindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun +bindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run +bindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo +bindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window +bindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi b/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi new file mode 100644 index 00000000..5b837f44 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi @@ -0,0 +1,172 @@ +/** + * The theme "mesh-powermenu" (module rofi, novox/hq ADR 0208): the power menu's five buttons. + * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of + * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined. + **/ + +/*****----- Configuration -----*****/ +configuration { + show-icons: false; +} + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; +} + + +/*****----- Main Window -----*****/ +window { + /* properties for window widget */ + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 800px; + x-offset: 0px; + y-offset: 0px; + + /* properties for all widgets */ + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + cursor: "default"; + background-color: @background; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 30px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + children: [ "inputbar", "listview" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + children: [ "textbox-prompt-colon", "prompt"]; +} + +dummy { + background-color: transparent; +} + +textbox-prompt-colon { + enabled: true; + expand: false; + str: ""; + padding: 12px 16px; + border-radius: 0px; + background-color: @urgent; + text-color: @background; +} +prompt { + enabled: true; + padding: 12px; + border-radius: 0px; + background-color: @active; + text-color: @background; +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; +} +textbox { + background-color: inherit; + text-color: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; + placeholder-color: @foreground; + blink: true; + markup: true; +} +error-message { + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background; + text-color: @foreground; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 5; + lines: 1; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + cursor: "default"; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 0px; + margin: 0px; + padding: 40px 10px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; + cursor: pointer; +} +element-text { + font: "JetBrainsMono Nerd Font Bold 32"; + background-color: transparent; + text-color: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; +} +element selected.normal { + background-color: var(selected); + text-color: var(background); +} diff --git a/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi b/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi new file mode 100644 index 00000000..8be0f55d --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi @@ -0,0 +1,263 @@ +/** + * The theme "mesh" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names + * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of + * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is + * one file, and its face the monospace one every desktop module names. + **/ + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; + + border-colour: var(selected); + handle-colour: var(selected); + background-colour: var(background); + foreground-colour: var(foreground); + alternate-background: var(background-alt); + normal-background: var(background); + normal-foreground: var(foreground); + urgent-background: var(urgent); + urgent-foreground: var(background); + active-background: var(active); + active-foreground: var(background); + selected-normal-background: var(selected); + selected-normal-foreground: var(background); + selected-urgent-background: var(active); + selected-urgent-foreground: var(background); + selected-active-background: var(urgent); + selected-active-foreground: var(background); + alternate-normal-background: var(background); + alternate-normal-foreground: var(foreground); + alternate-urgent-background: var(urgent); + alternate-urgent-foreground: var(background); + alternate-active-background: var(active); + alternate-active-foreground: var(background); +} + +/*****----- Main Window -----*****/ +window { + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 600px; + x-offset: 0px; + y-offset: 0px; + enabled: true; + margin: 0px; + padding: 0px; + border: 1px solid; + border-radius: 0px; + border-color: @border-colour; + cursor: "default"; + background-color: @background-colour; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + children: [ "inputbar", "message", "listview", "mode-switcher" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px 0px 1px 0px; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + children: [ "prompt", "entry" ]; +} + +prompt { + enabled: true; + background-color: inherit; + text-color: inherit; +} +textbox-prompt-colon { + enabled: true; + expand: false; + str: "::"; + background-color: inherit; + text-color: inherit; +} +entry { + enabled: true; + background-color: inherit; + text-color: inherit; + cursor: text; + placeholder: "Search..."; + placeholder-color: inherit; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 1; + lines: 8; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + spacing: 0px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: "default"; +} +scrollbar { + handle-width: 5px; + handle-color: @handle-colour; + border-radius: 0px; + background-color: @alternate-background; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: pointer; +} +element normal.normal { + background-color: var(normal-background); + text-color: var(normal-foreground); +} +element normal.urgent { + background-color: var(urgent-background); + text-color: var(urgent-foreground); +} +element normal.active { + background-color: var(active-background); + text-color: var(active-foreground); +} +element selected.normal { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} +element selected.urgent { + background-color: var(selected-urgent-background); + text-color: var(selected-urgent-foreground); +} +element selected.active { + background-color: var(selected-active-background); + text-color: var(selected-active-foreground); +} +element alternate.normal { + background-color: var(alternate-normal-background); + text-color: var(alternate-normal-foreground); +} +element alternate.urgent { + background-color: var(alternate-urgent-background); + text-color: var(alternate-urgent-foreground); +} +element alternate.active { + background-color: var(alternate-active-background); + text-color: var(alternate-active-foreground); +} +element-icon { + background-color: transparent; + text-color: inherit; + size: 24px; + cursor: inherit; +} +element-text { + background-color: transparent; + text-color: inherit; + highlight: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.0; +} + +/*****----- Mode Switcher -----*****/ +mode-switcher { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +button { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: inherit; + cursor: pointer; +} +button selected { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +textbox { + font: "Inter 11"; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + vertical-align: 0.5; + horizontal-align: 0.0; + highlight: none; + placeholder-color: @foreground-colour; + blink: true; + markup: true; +} +error-message { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @background-colour; + text-color: @foreground-colour; +} diff --git a/testdata/beside/mesh-catalog/modules/rofi/module.json.captured b/testdata/beside/mesh-catalog/modules/rofi/module.json.captured index d603a199..fbd061d0 100644 --- a/testdata/beside/mesh-catalog/modules/rofi/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/rofi/module.json.captured @@ -1,6 +1,7 @@ { "module": "rofi", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -33,14 +34,16 @@ "type": "directory", "path": "${machine:account-home}/.config/rofi", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "themes-dir", "type": "directory", "path": "${machine:account-home}/.config/rofi/themes", "owner": "${machine:account}", - "mode": "0755" + "mode": "0755", + "unknown": "move-out" }, { "id": "configuration", @@ -48,7 +51,7 @@ "path": "${machine:account-home}/.config/rofi/config.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at\n * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from\n * rofi's defaults, nothing else. Its look is the theme \"mesh\", in themes/ beside this file. */\n\nconfiguration {\n\tmodi: \"drun,run,window,filebrowser\";\n\tshow-icons: true;\n\tcase-sensitive: false;\n\tnormalize-match: true;\n\tmatching: \"normal\";\n\ttokenize: true;\n\tcycle: true;\n\tsteal-focus: false;\n\tclick-to-exit: true;\n\tsort: false;\n\n\t/* Applications: the user's and the system's desktop entries, read fresh each time so a\n\t * program installed a minute ago is there. */\n\tdrun-match-fields: \"name,generic,exec,categories,keywords\";\n\tdrun-display-format: \"{name} [({generic})]\";\n\tdrun-show-actions: false;\n\tdrun-url-launcher: \"xdg-open\";\n\tdrun-use-desktop-cache: false;\n\tdrun-reload-desktop-cache: false;\n\trun,drun {\n\t\tfallback-icon: \"application-x-addon\";\n\t}\n\n\t/* A command run from `run` opens in the node's terminal when it asks for one. */\n\tterminal: \"rofi-sensible-terminal\";\n\trun-shell-command: \"{terminal} -e {cmd}\";\n\tssh-command: \"{terminal} -e {ssh-client} {host} [-p {port}]\";\n\tparse-hosts: true;\n\tparse-known-hosts: true;\n\n\twindow-match-fields: \"title,class,role,name,desktop\";\n\twindow-format: \"{w} - {c} - {t:0}\";\n\n\tdisable-history: false;\n\tmax-history-size: 25;\n\n\tfilebrowser {\n\t\tdirectories-first: true;\n\t\tsorting-method: \"name\";\n\t}\n\n\tdisplay-window: \"Windows\";\n\tdisplay-run: \"Run\";\n\tdisplay-ssh: \"SSH\";\n\tdisplay-drun: \"Apps\";\n\tdisplay-filebrowser: \"Files\";\n}\n\n@theme \"mesh\"\n" + "template": "files/config.rasi" }, { "id": "theme", @@ -56,7 +59,7 @@ "path": "${machine:account-home}/.config/rofi/themes/mesh.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/**\n * The theme \"mesh\" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names\n * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of\n * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is\n * one file, and its face the monospace one every desktop module names.\n **/\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n\n border-colour: var(selected);\n handle-colour: var(selected);\n background-colour: var(background);\n foreground-colour: var(foreground);\n alternate-background: var(background-alt);\n normal-background: var(background);\n normal-foreground: var(foreground);\n urgent-background: var(urgent);\n urgent-foreground: var(background);\n active-background: var(active);\n active-foreground: var(background);\n selected-normal-background: var(selected);\n selected-normal-foreground: var(background);\n selected-urgent-background: var(active);\n selected-urgent-foreground: var(background);\n selected-active-background: var(urgent);\n selected-active-foreground: var(background);\n alternate-normal-background: var(background);\n alternate-normal-foreground: var(foreground);\n alternate-urgent-background: var(urgent);\n alternate-urgent-foreground: var(background);\n alternate-active-background: var(active);\n alternate-active-foreground: var(background);\n}\n\n/*****----- Main Window -----*****/\nwindow {\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 600px;\n x-offset: 0px;\n y-offset: 0px;\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 1px solid;\n border-radius: 0px;\n border-color: @border-colour;\n cursor: \"default\";\n background-color: @background-colour;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n children: [ \"inputbar\", \"message\", \"listview\", \"mode-switcher\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px 0px 1px 0px;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n children: [ \"prompt\", \"entry\" ];\n}\n\nprompt {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n}\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"::\";\n background-color: inherit;\n text-color: inherit;\n}\nentry {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n cursor: text;\n placeholder: \"Search...\";\n placeholder-color: inherit;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 1;\n lines: 8;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n spacing: 0px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: \"default\";\n}\nscrollbar {\n handle-width: 5px;\n handle-color: @handle-colour;\n border-radius: 0px;\n background-color: @alternate-background;\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: pointer;\n}\nelement normal.normal {\n background-color: var(normal-background);\n text-color: var(normal-foreground);\n}\nelement normal.urgent {\n background-color: var(urgent-background);\n text-color: var(urgent-foreground);\n}\nelement normal.active {\n background-color: var(active-background);\n text-color: var(active-foreground);\n}\nelement selected.normal {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\nelement selected.urgent {\n background-color: var(selected-urgent-background);\n text-color: var(selected-urgent-foreground);\n}\nelement selected.active {\n background-color: var(selected-active-background);\n text-color: var(selected-active-foreground);\n}\nelement alternate.normal {\n background-color: var(alternate-normal-background);\n text-color: var(alternate-normal-foreground);\n}\nelement alternate.urgent {\n background-color: var(alternate-urgent-background);\n text-color: var(alternate-urgent-foreground);\n}\nelement alternate.active {\n background-color: var(alternate-active-background);\n text-color: var(alternate-active-foreground);\n}\nelement-icon {\n background-color: transparent;\n text-color: inherit;\n size: 24px;\n cursor: inherit;\n}\nelement-text {\n background-color: transparent;\n text-color: inherit;\n highlight: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n}\n\n/*****----- Mode Switcher -----*****/\nmode-switcher {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\nbutton {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: inherit;\n cursor: pointer;\n}\nbutton selected {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\ntextbox {\n font: \"Inter 11\";\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n highlight: none;\n placeholder-color: @foreground-colour;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @background-colour;\n text-color: @foreground-colour;\n}\n" + "template": "files/themes/mesh.rasi" }, { "id": "theme-powermenu", @@ -64,7 +67,7 @@ "path": "${machine:account-home}/.config/rofi/themes/mesh-powermenu.rasi", "owner": "${machine:account}", "mode": "0644", - "content": "/**\n * The theme \"mesh-powermenu\" (module rofi, novox/hq ADR 0208): the power menu's five buttons.\n * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of\n * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined.\n **/\n\n/*****----- Configuration -----*****/\nconfiguration {\n show-icons: false;\n}\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n}\n\n\n/*****----- Main Window -----*****/\nwindow {\n /* properties for window widget */\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 800px;\n x-offset: 0px;\n y-offset: 0px;\n\n /* properties for all widgets */\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n cursor: \"default\";\n background-color: @background;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 30px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n children: [ \"inputbar\", \"listview\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n children: [ \"textbox-prompt-colon\", \"prompt\"];\n}\n\ndummy {\n background-color: transparent;\n}\n\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"\";\n padding: 12px 16px;\n border-radius: 0px;\n background-color: @urgent;\n text-color: @background;\n}\nprompt {\n enabled: true;\n padding: 12px;\n border-radius: 0px;\n background-color: @active;\n text-color: @background;\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n}\ntextbox {\n background-color: inherit;\n text-color: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n placeholder-color: @foreground;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background;\n text-color: @foreground;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 5;\n lines: 1;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n cursor: \"default\";\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 0px;\n margin: 0px;\n padding: 40px 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n cursor: pointer;\n}\nelement-text {\n font: \"JetBrainsMono Nerd Font Bold 32\";\n background-color: transparent;\n text-color: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n}\nelement selected.normal {\n background-color: var(selected);\n text-color: var(background);\n}\n" + "template": "files/themes/mesh-powermenu.rasi" }, { "id": "dmenu", @@ -72,7 +75,7 @@ "path": "${machine:account-home}/.local/bin/dmenu", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher\n# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output,\n# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat.\n#\n# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w)\n# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The\n# look is the launcher's theme.\nset -u\nn=$#\nwhile [ \"$n\" -gt 0 ]; do\n\targ=$1\n\tshift\n\tn=$((n - 1))\n\tcase $arg in\n\t-fn | -nb | -nf | -sb | -sf | -m | -w)\n\t\tif [ \"$n\" -gt 0 ]; then\n\t\t\tshift\n\t\t\tn=$((n - 1))\n\t\tfi\n\t\t;;\n\t-b | -f) ;;\n\t*) set -- \"$@\" \"$arg\" ;;\n\tesac\ndone\nexec rofi -dmenu \"$@\"\n" + "template": "files/bin/dmenu" }, { "id": "launch", @@ -80,7 +83,7 @@ "path": "${machine:account-home}/.local/bin/rofi-launch", "owner": "${machine:account}", "mode": "0755", - "content": "#!/bin/sh\n# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's\n# key bindings run.\n#\n# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press\n# that opens nothing. So a failure within half a second is tried again, up to five times. A person\n# closing the menu takes longer than that, and is never shown it a second time.\nset -u\nmode=${1:-drun}\n\nnow_ms() { date +%s%3N; }\n\nattempt() {\n\ti=0\n\twhile [ \"$i\" -lt 5 ]; do\n\t\tstart=$(now_ms)\n\t\t\"$@\" && return 0\n\t\t[ $(($(now_ms) - start)) -ge 500 ] && return 1\n\t\ti=$((i + 1))\n\t\tsleep 0.1\n\tdone\n\treturn 1\n}\n\ncase $mode in\ndrun | run | window | filebrowser)\n\tattempt rofi -show \"$mode\"\n\t;;\nsudo)\n\t# A command line, run with sudo in the node's terminal.\n\tcommand=$(attempt rofi -dmenu -p sudo &2\n\texit 2\n\t;;\nesac\n" + "template": "files/bin/rofi-launch" }, { "id": "powermenu", @@ -88,7 +91,7 @@ "path": "${machine:account-home}/.local/bin/rofi-powermenu", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n" + "template": "files/bin/rofi-powermenu" } ], "build": { @@ -103,14 +106,19 @@ "loads": [ "rofi-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-rofi.conf" } ] } diff --git a/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json b/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json new file mode 100644 index 00000000..1d8d8c2a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json @@ -0,0 +1,6 @@ +{ + "dynamic": "/services/traefik/dynamic", + "entrypoint": "websecure", + "certificate-resolver": "le", + "machine": "host.docker.internal" +} diff --git a/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured b/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured index 10cb2d98..5234c78b 100644 --- a/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured @@ -1,6 +1,7 @@ { "module": "route-adapter", "version": "1", + "moved": true, "slug": "radapt", "capabilities": [ "container-runtime" @@ -25,26 +26,56 @@ "mode": "read-write" } ], + "config": { + "dynamic": { + "type": "string", + "description": "The directory the predecessor proxy's file provider reads, on this machine, as an absolute path.", + "pattern": "/.*", + "trusted": true, + "default": "/services/traefik/dynamic" + }, + "entrypoint": { + "type": "string", + "description": "The predecessor proxy's entry point it serves public HTTPS on, by its own name.", + "trusted": true, + "default": "websecure" + }, + "certificate-resolver": { + "type": "string", + "description": "The predecessor proxy's certificate resolver, by its own name.", + "trusted": true, + "default": "le" + }, + "machine": { + "type": "string", + "description": "How the predecessor's proxy reaches this machine: a host name or an address.", + "trusted": true, + "default": "host.docker.internal" + } + }, "resources": [ { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "routes-dir", "type": "directory", "path": "/var/lib/route-adapter/routes", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:state}/config.json", + "takes-config": ["dynamic", "entrypoint", "certificate-resolver", "machine"], "merge": "json", "mode": "0644", - "content": "{\n \"dynamic\": \"/services/traefik/dynamic\",\n \"entrypoint\": \"websecure\",\n \"certificate-resolver\": \"le\",\n \"machine\": \"host.docker.internal\"\n}\n" + "template": "files/config.json" }, { "id": "adapt", @@ -75,7 +106,29 @@ "entrypoints": [ "index.js" ] + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] - } + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] } diff --git a/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured b/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured deleted file mode 100644 index 8b95306b..00000000 --- a/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured +++ /dev/null @@ -1,230 +0,0 @@ -{ - "module": "route-proxy", - "version": "1", - "slug": "rproxy", - "capabilities": [ - "container-runtime" - ], - "provides": [ - { - "name": "route", - "scope": "mesh", - "identity": false - } - ], - "serves": { - "route": {} - }, - "receives": { - "route": "${dir:routes-dir}/mesh.json" - }, - "requires": [ - "internal-acme-ca" - ], - "binds": { - "internal-acme-ca": "${dir:state}/internal-acme-ca.json" - }, - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "http", - "port": 80, - "protocol": "tcp", - "from": "anywhere", - "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" - }, - { - "name": "https", - "port": 443, - "protocol": "tcp", - "from": "anywhere", - "why": "public HTTPS for every name the mesh routes here" - } - ], - "data": { - "own": [ - { - "id": "acme", - "path": "${dir:acme-cache}", - "class": "rebuildable", - "why": "issued certificates, issued again" - }, - { - "id": "ca", - "path": "${dir:ca-dir}", - "class": "cache", - "why": "the authority's roots, fetched again at every start" - } - ] - }, - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "routes-dir", - "type": "directory", - "path": "/var/lib/route-proxy/routes", - "mode": "0700" - }, - { - "id": "acme-cache", - "type": "directory", - "path": "/var/lib/route-proxy/acme", - "mode": "0700" - }, - { - "id": "ca-dir", - "type": "directory", - "path": "/var/lib/route-proxy/ca", - "mode": "0755" - }, - { - "id": "acme-env", - "type": "file", - "path": "${dir:state}/acme.env", - "mode": "0600", - "content": "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\nACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\nACME_ROOTS_PATH=\n" - }, - { - "id": "internal-acme-env", - "type": "file", - "path": "${dir:state}/internal-acme.env", - "mode": "0600", - "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" - }, - { - "id": "trust", - "type": "container", - "name": "route-proxy-trust", - "artifact": "trust", - "run-once": true, - "network": "host", - "env-file": [ - "${dir:state}/acme.env" - ], - "volumes": [ - "${dir:ca-dir}:/ca" - ], - "args": [ - "sh", - "-c", - "if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" - ], - "restart-on": [ - "acme-env" - ] - }, - { - "id": "internal-trust", - "type": "container", - "name": "route-proxy-internal-trust", - "artifact": "trust", - "run-once": true, - "network": "host", - "env-file": [ - "${dir:state}/internal-acme.env" - ], - "volumes": [ - "${dir:ca-dir}:/ca" - ], - "args": [ - "sh", - "-c", - "if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" - ], - "restart-on": [ - "internal-acme-env" - ] - }, - { - "id": "server", - "type": "container", - "name": "route-proxy", - "artifact": "server", - "network": "host", - "env-file": [ - "${dir:state}/acme.env", - "${dir:state}/internal-acme.env" - ], - "volumes": [ - "${dir:routes-dir}:/routes:ro", - "${dir:acme-cache}:/acme", - "${dir:ca-dir}:/ca:ro", - "${dir:mesh-state}/broker:/run/secrets/broker:ro" - ], - "env": { - "ROUTES": "/routes/mesh.json", - "LISTEN": ":80", - "TLS_LISTEN": ":443", - "ACME_CACHE": "/acme", - "ACME_CA_BUNDLE": "/ca/root.crt", - "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt", - "MESH_BROKER_FILE": "/run/secrets/broker" - }, - "restart-on": [ - "trust", - "acme-env", - "internal-trust", - "internal-acme-env" - ], - "logging": "journald", - "health": { - "kind": "tcp", - "endpoint": "http", - "interval": "10s", - "timeout": "5s", - "looks": 3, - "grace": "60s" - } - } - ], - "build": { - "artifacts": [ - { - "name": "server", - "kind": "image", - "from": "Dockerfile", - "compiles": "examples/route-proxy", - "context": { - "seat": "git", - "repository": "novox/mesh-controller", - "ref": "main" - } - }, - { - "name": "trust", - "kind": "upstream", - "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" - } - ], - "on": [ - { - "arg": "GO_BASE", - "image": "golang@sha256:6c2a5538f964f1c82f97ad14988bf05de100d922d159d0e398b54c7b0ca0c6c9" - }, - { - "arg": "ALPINE_BASE", - "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" - } - ] - }, - "jails": [ - { - "name": "route-proxy", - "failregex": "^.*TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from :\\d+$", - "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" - } - ] -} diff --git a/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock b/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock new file mode 100755 index 00000000..18008bd6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before +# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it. +# +# The operator's look, adopted from the predecessor's my-i3lock: the screen as it was, blurred, with +# an orange ring, the time and the date. That needs i3lock-color, from the distribution's user +# repository, kept as found until the mesh carries such software (novox/hq research 027, question 1). +# On a machine without it the distribution's i3lock shows the same blurred screen, taken here, with +# its own plain ring; failing that, black. +# +# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends +# once it is released. The locker must not inherit it, or the machine would wait for the unlock +# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is +# xss-lock's own documented pattern for i3lock. +set -u + +# One locker: a second press of the key, or a lock while locked, changes nothing. +if pgrep -xu "$EUID" i3lock >/dev/null; then + exit 0 +fi + +# The colour build numbers its versions .c. (2.13.c.5); its version line never says "color". +if i3lock --version 2>&1 | grep -qE '[0-9]\.c\.[0-9]'; then + blank='#00000000' clear='#ffffff22' accent='#ca4a00' wrong='#880000bb' verifying='#bb00bbbb' + options=( + --insidever-color="$clear" --ringver-color="$verifying" + --insidewrong-color="$clear" --ringwrong-color="$wrong" + --inside-color="$blank" --ring-color="$accent" --line-color="$blank" --separator-color="$accent" + --verif-color="$accent" --wrong-color="$accent" --time-color="$accent" --date-color="$accent" + --layout-color="$accent" --keyhl-color="$wrong" --bshl-color="$wrong" + --screen 1 --blur 5 --ring-width=7.0 --clock --indicator + --time-str="%H:%M:%S" --date-str="%A, %Y-%m-%d" + --time-font=sans-serif --date-font=sans-serif --verif-font=sans-serif + --wrong-font=sans-serif --layout-font=sans-serif --keylayout 1 + --show-failed-attempts --ignore-empty-password + ) +else + options=(--color=000000 --show-failed-attempts --ignore-empty-password) + shot="${XDG_RUNTIME_DIR:-/tmp}/screen-lock.png" + if command -v magick >/dev/null && magick import -window root -resize 25% -blur 0x3 -resize 400% "$shot" 2>/dev/null; then + options+=(--image="$shot") + fi +fi + +if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then + kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; } + trap kill_i3lock TERM INT + i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&- + exec {XSS_SLEEP_LOCK_FD}<&- + while kill_i3lock -0; do + sleep 0.5 + done +else + trap 'kill %%' TERM INT + i3lock --nofork "${options[@]}" & + wait +fi diff --git a/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal b/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal new file mode 100644 index 00000000..f7f7123e --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal @@ -0,0 +1,8 @@ +# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle, +# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on +# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and +# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the +# session's own process, which becomes the window manager). +xset s 1800 1800 +xset dpms 1800 1800 3600 +(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- "$HOME/.local/bin/screen-lock"; sleep 2; done) & diff --git a/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured b/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured index 28bf7e57..1791c798 100644 --- a/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured @@ -1,6 +1,7 @@ { "module": "screen-lock", "version": "1", + "moved": true, "capabilities": [ "package-manager" ], @@ -21,11 +22,11 @@ "screen_lock_inhibit", "screen_lock_locked" ], - "shell": [ + "items": [ { - "for": "xinitrc", - "slot": "normal", - "code": "# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,\n# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on\n# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and\n# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the\n# session's own process, which becomes the window manager).\nxset s 1800 1800\nxset dpms 1800 1800 3600\n(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- \"$HOME/.local/bin/screen-lock\"; sleep 2; done) &\n" + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" } ], "resources": [ @@ -51,7 +52,7 @@ "path": "${machine:account-home}/.local/bin/screen-lock", "owner": "${machine:account}", "mode": "0755", - "content": "#!/usr/bin/env bash\n# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before\n# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.\n#\n# The operator's look, adopted from the predecessor's my-i3lock: the screen as it was, blurred, with\n# an orange ring, the time and the date. That needs i3lock-color, from the distribution's user\n# repository, kept as found until the mesh carries such software (novox/hq research 027, question 1).\n# On a machine without it the distribution's i3lock shows the same blurred screen, taken here, with\n# its own plain ring; failing that, black.\n#\n# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends\n# once it is released. The locker must not inherit it, or the machine would wait for the unlock\n# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is\n# xss-lock's own documented pattern for i3lock.\nset -u\n\n# One locker: a second press of the key, or a lock while locked, changes nothing.\nif pgrep -xu \"$EUID\" i3lock >/dev/null; then\n\texit 0\nfi\n\n# The colour build numbers its versions .c. (2.13.c.5); its version line never says \"color\".\nif i3lock --version 2>&1 | grep -qE '[0-9]\\.c\\.[0-9]'; then\n\tblank='#00000000' clear='#ffffff22' accent='#ca4a00' wrong='#880000bb' verifying='#bb00bbbb'\n\toptions=(\n\t\t--insidever-color=\"$clear\" --ringver-color=\"$verifying\"\n\t\t--insidewrong-color=\"$clear\" --ringwrong-color=\"$wrong\"\n\t\t--inside-color=\"$blank\" --ring-color=\"$accent\" --line-color=\"$blank\" --separator-color=\"$accent\"\n\t\t--verif-color=\"$accent\" --wrong-color=\"$accent\" --time-color=\"$accent\" --date-color=\"$accent\"\n\t\t--layout-color=\"$accent\" --keyhl-color=\"$wrong\" --bshl-color=\"$wrong\"\n\t\t--screen 1 --blur 5 --ring-width=7.0 --clock --indicator\n\t\t--time-str=\"%H:%M:%S\" --date-str=\"%A, %Y-%m-%d\"\n\t\t--time-font=sans-serif --date-font=sans-serif --verif-font=sans-serif\n\t\t--wrong-font=sans-serif --layout-font=sans-serif --keylayout 1\n\t\t--show-failed-attempts --ignore-empty-password\n\t)\nelse\n\toptions=(--color=000000 --show-failed-attempts --ignore-empty-password)\n\tshot=\"${XDG_RUNTIME_DIR:-/tmp}/screen-lock.png\"\n\tif command -v magick >/dev/null && magick import -window root -resize 25% -blur 0x3 -resize 400% \"$shot\" 2>/dev/null; then\n\t\toptions+=(--image=\"$shot\")\n\tfi\nfi\n\nif [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then\n\tkill_i3lock() { pkill -xu \"$EUID\" \"$@\" i3lock; }\n\ttrap kill_i3lock TERM INT\n\ti3lock \"${options[@]}\" {XSS_SLEEP_LOCK_FD}<&-\n\texec {XSS_SLEEP_LOCK_FD}<&-\n\twhile kill_i3lock -0; do\n\t\tsleep 0.5\n\tdone\nelse\n\ttrap 'kill %%' TERM INT\n\ti3lock --nofork \"${options[@]}\" &\n\twait\nfi\n" + "template": "files/bin/screen-lock" } ], "build": { @@ -66,6 +67,11 @@ "loads": [ "screen-lock-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/searxng/files/config.json b/testdata/beside/mesh-catalog/modules/searxng/files/config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/searxng/files/config.json @@ -0,0 +1 @@ +{} diff --git a/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml b/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml new file mode 100644 index 00000000..6d7248ee --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml @@ -0,0 +1,16 @@ +{ + "use_default_settings": true, + "server": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + }, + "search": { + "formats": ["html", "json"] + }, + "valkey": { + "url": "valkey://valkey:6379/0" + } +} diff --git a/testdata/beside/mesh-catalog/modules/searxng/module.json.captured b/testdata/beside/mesh-catalog/modules/searxng/module.json.captured index f6951bdf..cee18e4e 100644 --- a/testdata/beside/mesh-catalog/modules/searxng/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/searxng/module.json.captured @@ -29,23 +29,99 @@ } ] }, + "config": { + "general": { + "type": "object", + "description": "SearXNG's general section of settings.yml, laid over the file's own and SearXNG's defaults: the instance's name, contact and debug switches.", + "trusted": true, + "default": {} + }, + "brand": { + "type": "object", + "description": "SearXNG's brand section of settings.yml, laid over the file's own and SearXNG's defaults: the links the pages show for the instance's source, issues and documentation.", + "trusted": true, + "default": {} + }, + "search": { + "type": "object", + "description": "SearXNG's search section of settings.yml, laid over the file's own and SearXNG's defaults: safe search, autocomplete, the result formats offered and how long an engine is suspended after an error.", + "trusted": true, + "default": { + "formats": [ + "html", + "json" + ] + } + }, + "server": { + "type": "object", + "description": "SearXNG's server section of settings.yml, laid over the file's own and SearXNG's defaults: the public base URL, the request method, the limiter and the image proxy; its secret_key is the vault's.", + "trusted": true, + "default": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + } + }, + "ui": { + "type": "object", + "description": "SearXNG's ui section of settings.yml, laid over the file's own and SearXNG's defaults: the theme, the default locale and how results are shown.", + "trusted": true, + "default": {} + }, + "outgoing": { + "type": "object", + "description": "SearXNG's outgoing section of settings.yml, laid over the file's own and SearXNG's defaults: how SearXNG reaches the engines: timeouts, connection pool and proxies.", + "trusted": true, + "default": {} + }, + "engines": { + "type": "list", + "description": "SearXNG's engines section of settings.yml, laid over the file's own and SearXNG's defaults: the engines to add or change, each by its name.", + "trusted": true, + "default": [] + }, + "plugins": { + "type": "object", + "description": "SearXNG's plugins section of settings.yml, laid over the file's own and SearXNG's defaults: the plugins to switch on or off, by their id.", + "trusted": true, + "default": {} + }, + "categories_as_tabs": { + "type": "object", + "description": "SearXNG's categories_as_tabs section of settings.yml, laid over the file's own and SearXNG's defaults: the categories shown as tabs above the results.", + "trusted": true, + "default": {} + }, + "preferences": { + "type": "object", + "description": "SearXNG's preferences section of settings.yml, laid over the file's own and SearXNG's defaults: the preferences a user cannot change (lock).", + "trusted": true, + "default": {} + } + }, "resources": [ { "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "valkey-data", "type": "directory", - "mode": "0700" + "mode": "0700", + "unknown": "program" }, { "id": "net", @@ -76,7 +152,7 @@ "path": "${dir:state}/settings.yml", "mode": "0600", "merge": "json", - "takes-settings": [ + "takes-config": [ "general", "brand", "search", @@ -88,7 +164,7 @@ "categories_as_tabs", "preferences" ], - "content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n" + "template": "files/settings.yml" }, { "id": "server", @@ -111,7 +187,7 @@ "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", - "content": "{}\n" + "template": "files/config.json" } ], "requires": [ @@ -142,6 +218,11 @@ "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", "MESH_SEARXNG_CONFIG_FILE": "${dir:mesh-state}/config.json" } + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/showcase/files/README.md b/testdata/beside/mesh-catalog/modules/showcase/files/README.md new file mode 100644 index 00000000..5c369ccc --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/showcase/files/README.md @@ -0,0 +1,6 @@ +# showcase's packed files + +Packed as an `archive` artifact and unpacked onto the machine by an `archive` resource. + +This exists to exercise the case inlining cannot serve: a tree of files that belongs on a machine +and would make a declaration enormous if it were carried inside one. diff --git a/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt b/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt new file mode 100644 index 00000000..0246772b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt @@ -0,0 +1 @@ +showcase diff --git a/testdata/beside/mesh-catalog/modules/showcase/module.json.captured b/testdata/beside/mesh-catalog/modules/showcase/module.json.captured index ab627311..e657e2ab 100644 --- a/testdata/beside/mesh-catalog/modules/showcase/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/showcase/module.json.captured @@ -1,6 +1,7 @@ { "module": "showcase", "version": "1", + "templates": "templates", "slug": "show", "capabilities": [ "container-runtime" @@ -74,6 +75,11 @@ "name": "helper", "kind": "upstream", "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" } ] }, @@ -95,26 +101,29 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "state", "type": "directory", "mode": "0755", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "settings", "type": "file", "path": "${dir:state}/showcase.env", "mode": "0600", - "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" + "template": "templates/showcase.env" }, { "id": "packed", "type": "archive", "path": "/opt/showcase", - "artifact": "files" + "artifact": "files", + "unknown": "program" }, { "id": "net", diff --git a/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env b/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env new file mode 100644 index 00000000..8ea4a902 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env @@ -0,0 +1,5 @@ +SHOWCASE_GREETING=hello +SHOWCASE_EVERY_SECONDS=30 +SHOWCASE_STATE=/var/lib/showcase +SHOWCASE_DATABASE=${bound:postgres-database:at} +SHOWCASE_LISTEN=${port:8080} diff --git a/testdata/beside/mesh-catalog/modules/slack/files/display-session b/testdata/beside/mesh-catalog/modules/slack/files/display-session new file mode 100644 index 00000000..01660f84 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/slack/files/display-session @@ -0,0 +1,11 @@ +# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push. +# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry. +# No XDG autostart entry starts it as well; slack_check names one if it appears. +exec --no-startup-id /usr/bin/slack --gtk-version=3 -s +# The chat window. Slack owns four X windows, and the only one i3 manages has the class "slack" in +# lower case; the three of class "Slack" (the packaged entry's StartupWMClass) are its unmanaged +# helpers and its tray icon, so a rule on class="Slack" matches nothing. window_role keeps a call or +# screen-share window out of the rules. $ws3 is i3's, in scope here. +for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3 +for_window [class="(?i)^slack$" window_role="browser-window"] floating disable +for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2 diff --git a/testdata/beside/mesh-catalog/modules/slack/module.json.captured b/testdata/beside/mesh-catalog/modules/slack/module.json.captured index b0b6d760..9feeedb3 100644 --- a/testdata/beside/mesh-catalog/modules/slack/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/slack/module.json.captured @@ -1,6 +1,7 @@ { "module": "slack", "version": "1", + "moved": true, "requires": [ "x11-display" ], @@ -22,14 +23,19 @@ "loads": [ "slack-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] }, - "contributions": [ + "items": [ { - "seat": "node-display-session", - "kind": "config", - "content": "# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n" + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" } ] } diff --git a/testdata/beside/mesh-catalog/modules/ssh-client/files/config b/testdata/beside/mesh-catalog/modules/ssh-client/files/config new file mode 100644 index 00000000..dac07fb6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ssh-client/files/config @@ -0,0 +1,6 @@ +# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh +# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order: +# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at +# every push. Everything below it is yours, kept as you wrote it, and applies to every host the +# files above leave unsettled. +Include ~/.ssh/config.d/* diff --git a/testdata/beside/mesh-catalog/modules/ssh-client/files/forge b/testdata/beside/mesh-catalog/modules/ssh-client/files/forge new file mode 100644 index 00000000..6bbf4e20 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ssh-client/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig b/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig new file mode 100644 index 00000000..05252302 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts b/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts new file mode 100644 index 00000000..e0df6d81 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured b/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured index e6ac8f1a..a6965445 100644 --- a/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured @@ -1,6 +1,7 @@ { "module": "ssh-client", "version": "1", + "moved": true, "tools": [ "ssh_client_hosts", "ssh_client_resolve", @@ -11,6 +12,12 @@ "ssh_client_known_host", "ssh_client_test" ], + "requires": [ + "forge-account" + ], + "state": [ + "keys" + ], "data": { "own": [ { @@ -27,14 +34,16 @@ "type": "directory", "path": "${machine:account-home}/.ssh", "owner": "${machine:account}", - "mode": "0700" + "mode": "0700", + "unknown": "module" }, { "id": "config-d", "type": "directory", "path": "${machine:account-home}/.ssh/config.d", "owner": "${machine:account}", - "mode": "0700" + "mode": "0700", + "unknown": "module" }, { "id": "config", @@ -44,7 +53,33 @@ "mode": "0600", "into": "block", "at": "start", - "content": "# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh\n# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order:\n# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at\n# every push. Everything below it is yours, kept as you wrote it, and applies to every host the\n# files above leave unsettled.\nInclude ~/.ssh/config.d/*\n" + "template": "files/config" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${machine:account-home}/.ssh/known_hosts", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/known_hosts" + }, + { + "id": "forge-host", + "type": "file", + "path": "${machine:account-home}/.ssh/config.d/10-forge", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "git-config", + "type": "file", + "path": "${machine:account-home}/.config/git/config", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/gitconfig" } ], "facts": { @@ -66,6 +101,11 @@ "loads": [ "ssh-client-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf b/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf new file mode 100644 index 00000000..86cd883a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf @@ -0,0 +1,10 @@ +# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead. +Port 22 +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +X11Forwarding no +PrintMotd no +AcceptEnv LANG LC_* diff --git a/testdata/beside/mesh-catalog/modules/sshd/module.json.captured b/testdata/beside/mesh-catalog/modules/sshd/module.json.captured index a675636b..066e058d 100644 --- a/testdata/beside/mesh-catalog/modules/sshd/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/sshd/module.json.captured @@ -29,7 +29,7 @@ "type": "file", "path": "/etc/ssh/sshd_config.d/10-mesh.conf", "mode": "0644", - "content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n" + "template": "files/10-mesh.conf" }, { "id": "run", @@ -41,5 +41,14 @@ "config" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/step-ca/files/config.json b/testdata/beside/mesh-catalog/modules/step-ca/files/config.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/step-ca/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/testdata/beside/mesh-catalog/modules/step-ca/files/init.env b/testdata/beside/mesh-catalog/modules/step-ca/files/init.env new file mode 100644 index 00000000..31d0896c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/step-ca/files/init.env @@ -0,0 +1,2 @@ +DOCKER_STEPCA_INIT_PASSWORD=${secret:password} +DOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1 diff --git a/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured b/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured index a391a8b4..b11d2b99 100644 --- a/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured @@ -43,21 +43,24 @@ "id": "mesh-state", "type": "directory", "mode": "0700", - "place": "mesh" + "place": "mesh", + "unknown": "program" }, { "id": "home", "type": "directory", "mode": "0700", "owner": "1000:1000", - "place": "." + "place": ".", + "unknown": "program" }, { "id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0644", - "content": "{}", + "template": "files/config.json", + "takes-config": [], "merge": "json" }, { @@ -65,7 +68,7 @@ "type": "file", "path": "${dir:mesh-state}/init.env", "mode": "0600", - "content": "DOCKER_STEPCA_INIT_PASSWORD=${secret:password}\nDOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1\n" + "template": "files/init.env" }, { "id": "server", @@ -90,5 +93,14 @@ ], "secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it" } - ] + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } } diff --git a/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator b/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator new file mode 100644 index 00000000..997a49a6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator @@ -0,0 +1,9 @@ +# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates +# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it; +# until this file, every machine said so only in a line set by hand in /etc/sudoers. +# Written whole at every push: an edit here is overwritten. A file of this directory whose name +# holds a dot or ends in ~ is not read by sudo; this name holds neither. +# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator +# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI). +Defaults use_pty +${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/testdata/beside/mesh-catalog/modules/sudo/module.json.captured b/testdata/beside/mesh-catalog/modules/sudo/module.json.captured index 386deb14..364ce5fc 100644 --- a/testdata/beside/mesh-catalog/modules/sudo/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/sudo/module.json.captured @@ -21,7 +21,7 @@ "type": "file", "path": "/etc/sudoers.d/10-mesh-operator", "mode": "0440", - "content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator\n# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI).\nDefaults use_pty\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n" + "template": "files/10-mesh-operator" } ], "build": { @@ -36,6 +36,11 @@ "loads": [ "sudo-tools" ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" } ] } diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env new file mode 100644 index 00000000..b1b877f0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env @@ -0,0 +1,14 @@ +LOGFLARE_NODE_HOST=127.0.0.1 +DB_USERNAME=supabase_admin +DB_DATABASE=_supabase +DB_HOSTNAME=supabase-db +DB_PORT=5432 +DB_PASSWORD=${secret:postgres} +DB_SCHEMA=_analytics +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_SINGLE_TENANT=true +LOGFLARE_SUPABASE_MODE=true +LOGFLARE_MIN_CLUSTER_SIZE=1 +POSTGRES_BACKEND_URL=postgresql://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +POSTGRES_BACKEND_SCHEMA=_analytics +LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs new file mode 100644 index 00000000..179776e0 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs @@ -0,0 +1,86 @@ +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# logflare 1.4.0 copies two of its settings into its own database once and never reads them again: +# LOGFLARE_API_KEY becomes the default user's api_key when that user is created, and +# POSTGRES_BACKEND_URL becomes each source's backend when that source is created. A changed value +# reaches neither: a rotated key is refused, and a moved database is never written to (every +# source here pointed at db:5432, a name that resolves nowhere, so nothing was stored). +# +# Run by the start script before logflare starts, through logflare's own code (its Repo, its Users +# and Backends contexts and their changesets): it makes both copies say what the environment says. +# It changes nothing when they already do, says what it changed, never prints the key or a URL's +# password, and stops the start when it cannot finish. +fail = fn why -> + IO.puts(:stderr, "analytics: #{why}; not starting") + System.halt(1) +end + +say = fn what -> IO.puts(:stderr, "analytics: #{what}") end + +key = System.get_env("LOGFLARE_API_KEY") +url = System.get_env("POSTGRES_BACKEND_URL") +schema = System.get_env("POSTGRES_BACKEND_SCHEMA") + +if key in [nil, ""], do: fail.("LOGFLARE_API_KEY is not set, so the key logflare must accept is unknown") +if url in [nil, ""], do: fail.("POSTGRES_BACKEND_URL is not set, so where logflare must store logs is unknown") + +where = fn u -> + case URI.parse(to_string(u)) do + %URI{host: host, port: port, path: path} when is_binary(host) -> "#{host}:#{port}#{path}" + _ -> "an unreadable URL" + end +end + +Application.ensure_all_started(:ssl) + +outcome = + Ecto.Migrator.with_repo(Logflare.Repo, fn _repo -> + # Backends.update_source_backend_config restarts the source's pipeline after saving, and looks + # it up in this registry to do so; logflare is not running yet, so the registry is empty and + # there is nothing to restart. + {:ok, _} = Registry.start_link(keys: :unique, name: Logflare.Backends.SourceRegistry) + + case Logflare.SingleTenant.get_default_user() do + nil -> + say.("no default user yet; logflare creates it from LOGFLARE_API_KEY and POSTGRES_BACKEND_URL as it starts") + :ok + + user -> + if user.api_key == key and is_nil(user.old_api_key) do + say.("the default user's API key is LOGFLARE_API_KEY already") + else + # old_api_key is cleared too: logflare's UI can swap it back in, and the key replaced + # here may be one that leaked. + case Logflare.Users.update_user_all_fields(user, %{api_key: key, old_api_key: nil}) do + {:ok, _} -> say.("the default user's API key was replaced with LOGFLARE_API_KEY") + {:error, changeset} -> fail.("the default user's API key could not be replaced: #{inspect(changeset.errors)}") + end + end + + results = + for source <- Logflare.Sources.list_sources_by_user(user), + backend <- Logflare.Backends.list_source_backends(source), + backend.type == :postgres do + if backend.config.url == url and backend.config.schema == schema do + :same + else + case Logflare.Backends.update_source_backend_config(backend, %{url: url, schema: schema}) do + {:ok, _} -> + say.("source #{source.name} stored logs at #{where.(backend.config.url)} schema #{inspect(backend.config.schema)}; now at #{where.(url)} schema #{inspect(schema)}") + + {:error, changeset} -> + fail.("source #{source.name}'s backend could not be pointed at #{where.(url)}: #{inspect(changeset.errors)}") + end + end + end + + say.("#{Enum.count(results, &(&1 == :same))} source backend(s) already at #{where.(url)}") + + :ok + end + end) + +case outcome do + {:ok, :ok, _} -> :ok + other -> fail.("logflare's database could not be reconciled: #{inspect(other)}") +end diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh new file mode 100644 index 00000000..bf39892b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# Two things logflare 1.4.0 does not do for itself, done here before it starts; either failing +# stops the start, with the reason as the last line of this container's log. +# +# 1. The API key never travels in a URL. logflare prints the whole URL of every request that fails, +# query string included, in the error report Plug.Cowboy writes ("Request: POST +# /api/logs?source_name=...&api_key=..."). That is an error, so no log level hides it while errors +# are logged at all (novox/hq issue 268). logflare reads the key from the x-api-key header as well, +# and the mesh's caller, vector, sends it there; this refuses to start logflare while the vector +# config it is given still passes the key in a URL: an analytics that does not start says why +# here, and one that leaks says nothing. +# +# 2. Its database says what its environment says. logflare copies LOGFLARE_API_KEY into its default +# user and POSTGRES_BACKEND_URL into every source's backend once, when it creates them, and never +# again; reconcile.exs makes both copies match the environment through logflare's own code, after +# its migrations and before it starts. That is what lets the mesh rotate the key by starting +# logflare again, and what moves the sources' backend when the database's address or password +# changes. +set -e +conf=/run/logflare/vector.yml +if [ ! -r "$conf" ]; then + echo "analytics: $conf is not there to check, so whether the API key travels in a URL is unknown; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +if grep -v '^[[:space:]]*#' "$conf" | grep -q 'api_key='; then + echo "analytics: $conf passes the API key in a URL, and logflare prints a failed request's URL; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +reconcile=/run/logflare/reconcile.exs +if [ ! -r "$reconcile" ]; then + echo "analytics: $reconcile is not there, so whether logflare's stored key and backends match its environment is unknown; not starting" >&2 + exit 1 +fi +cd /opt/app/rel/logflare/bin +# The tables reconcile.exs reads exist only once the migrations have run; run.sh runs them again, +# which finds nothing left to do. +./logflare eval Logflare.Release.migrate +./logflare eval 'Code.eval_file("/run/logflare/reconcile.exs")' +exec sh run.sh diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql new file mode 100644 index 00000000..90d88222 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql @@ -0,0 +1,2 @@ +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO '${secret:jwt}'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600'; diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql new file mode 100644 index 00000000..255c0f40 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql new file mode 100644 index 00000000..162c5b96 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql new file mode 100644 index 00000000..4d4b9ffb --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql new file mode 100644 index 00000000..2a05c0b2 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql @@ -0,0 +1,7 @@ +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql new file mode 100644 index 00000000..6236ae1b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql new file mode 100644 index 00000000..5837b861 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env new file mode 100644 index 00000000..5eade5e6 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env @@ -0,0 +1,6 @@ +JWT_SECRET=${secret:jwt} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_ROLE_KEY=${secret:service-role-key} +SUPABASE_DB_URL=postgresql://postgres:${secret:postgres}@supabase-db:5432/postgres +VERIFY_JWT=false diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts b/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts new file mode 100644 index 00000000..a094010b --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts @@ -0,0 +1,94 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function verifyJWT(jwt: string): Promise { + const encoder = new TextEncoder() + const secretKey = encoder.encode(JWT_SECRET) + try { + await jose.jwtVerify(jwt, secretKey) + } catch (err) { + console.error(err) + return false + } + return true +} + +serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await verifyJWT(token) + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env b/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env new file mode 100644 index 00000000..f29f778a --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env @@ -0,0 +1,28 @@ +GOTRUE_API_HOST=0.0.0.0 +GOTRUE_API_PORT=9999 +API_EXTERNAL_URL=https://${bound:route:name} +GOTRUE_DB_DRIVER=postgres +GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres +GOTRUE_SITE_URL=https://${bound:route:name} +GOTRUE_URI_ALLOW_LIST= +GOTRUE_DISABLE_SIGNUP=true +GOTRUE_JWT_ADMIN_ROLES=service_role +GOTRUE_JWT_AUD=authenticated +GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated +GOTRUE_JWT_EXP=3600 +GOTRUE_JWT_SECRET=${secret:jwt} +GOTRUE_EXTERNAL_EMAIL_ENABLED=false +GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_SMTP_ADMIN_EMAIL=admin@example.com +GOTRUE_SMTP_HOST=supabase-mail +GOTRUE_SMTP_PORT=2500 +GOTRUE_SMTP_USER=fake_mail_user +GOTRUE_SMTP_PASS=fake_mail_password +GOTRUE_SMTP_SENDER_NAME=fake_sender +GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify +GOTRUE_EXTERNAL_PHONE_ENABLED=false +GOTRUE_SMS_AUTOCONFIRM=true diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml b/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml new file mode 100644 index 00000000..e13bdb24 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml @@ -0,0 +1,242 @@ +# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in. +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: "${secret:anon-key}" + - username: service_role + keyauth_credentials: + - key: "${secret:service-role-key}" + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: "${secret:dashboard-user}" + password: "${secret:dashboard}" + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + url: http://supabase-auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + url: http://supabase-auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + url: http://supabase-auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://supabase-auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure REST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://supabase-rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://supabase-rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Storage routes: the storage server manages its own auth + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://supabase-storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://supabase-edge-functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## Analytics routes + - name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://supabase-analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://supabase-meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://supabase-studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env new file mode 100644 index 00000000..c9d25096 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env @@ -0,0 +1,6 @@ +PG_META_PORT=8080 +PG_META_DB_HOST=supabase-db +PG_META_DB_PORT=5432 +PG_META_DB_NAME=postgres +PG_META_DB_USER=supabase_admin +PG_META_DB_PASSWORD=${secret:postgres} diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env new file mode 100644 index 00000000..f02c7cd7 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env @@ -0,0 +1,14 @@ +PORT=4000 +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_PASSWORD=${secret:postgres} +DATABASE_URL=ecto://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +CLUSTER_POSTGRES=true +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +VAULT_ENC_KEY=${secret:pooler-vault} +API_JWT_SECRET=${secret:jwt} +METRICS_JWT_SECRET=${secret:jwt} +REGION=local +ERL_AFLAGS=-proto_dist inet_tcp +RELEASE_NODE=supavisor@127.0.0.1 +POOLER_POOL_MODE=transaction diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs b/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs new file mode 100644 index 00000000..61b5ba8c --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs @@ -0,0 +1,43 @@ +{:ok, _} = Application.ensure_all_started(:supavisor) + +# Written by the mesh (modules/supabase). Per-machine values come from settings.json (the module's +# one merge:json file); secrets come from the environment the image reads. +settings = "/etc/pooler/settings.json" |> File.read!() |> Jason.decode!() |> Map.get("pooler", %{}) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => to_string(settings["tenant"] || "default"), + "db_host" => "supabase-db", + "db_port" => System.get_env("POSTGRES_PORT"), + "db_database" => System.get_env("POSTGRES_DB"), + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => settings["max-client-connections"] || 100, + "default_pool_size" => settings["pool-size"] || 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => System.get_env("POSTGRES_PASSWORD"), + "mode_type" => System.get_env("POOLER_POOL_MODE"), + "pool_size" => settings["pool-size"] || 20, + "is_manager" => true + }] +} + +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> + {:ok, _} = Supavisor.Tenants.create_tenant(params) + + # A tenant made elsewhere (HAL named the database host "db") is pointed at this module's + # database container; nothing else it holds is changed. + %{db_host: host} = tenant when host != "supabase-db" -> + {:ok, _} = Supavisor.Tenants.update_tenant(tenant, %{"db_host" => "supabase-db"}) + + _ -> + :ok +end diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf b/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf new file mode 100644 index 00000000..17557532 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf @@ -0,0 +1,7 @@ +db-uri = "postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres" +db-schemas = "public,storage,graphql_public" +db-anon-role = "anon" +jwt-secret = "${secret:jwt}" +db-use-legacy-gucs = false +app.settings.jwt_secret = "${secret:jwt}" +app.settings.jwt_exp = "3600" diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env new file mode 100644 index 00000000..75e21369 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env @@ -0,0 +1,16 @@ +PORT=4000 +DB_HOST=supabase-db +DB_PORT=5432 +DB_USER=supabase_admin +DB_PASSWORD=${secret:postgres} +DB_NAME=postgres +DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime +DB_ENC_KEY=supabaserealtime +API_JWT_SECRET=${secret:jwt} +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +ERL_AFLAGS=-proto_dist inet_tcp +DNS_NODES='' +RLIMIT_NOFILE=10000 +APP_NAME=realtime +SEED_SELF_HOST=true +RUN_JANITOR=true diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/settings.json b/testdata/beside/mesh-catalog/modules/supabase/files/settings.json new file mode 100644 index 00000000..8ab8cfa9 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/settings.json @@ -0,0 +1,7 @@ +{ + "pooler": { + "tenant": "default", + "pool-size": 20, + "max-client-connections": 100 + } +} diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env new file mode 100644 index 00000000..fd5da860 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env @@ -0,0 +1,13 @@ +ANON_KEY=${secret:anon-key} +SERVICE_KEY=${secret:service-role-key} +POSTGREST_URL=http://supabase-rest:3000 +PGRST_JWT_SECRET=${secret:jwt} +DATABASE_URL=postgres://supabase_storage_admin:${secret:postgres}@supabase-db:5432/postgres +FILE_SIZE_LIMIT=52428800 +STORAGE_BACKEND=file +FILE_STORAGE_BACKEND_PATH=/var/lib/storage +TENANT_ID=stub +REGION=stub +GLOBAL_S3_BUCKET=stub +ENABLE_IMAGE_TRANSFORMATION=true +IMGPROXY_URL=http://supabase-imgproxy:5001 diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env b/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env new file mode 100644 index 00000000..303dbc57 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env @@ -0,0 +1,18 @@ +# Next.js listens on the address HOSTNAME names, and docker sets HOSTNAME to the container's id, +# so studio answered only on its network address while its image's health check asks localhost: +# unhealthy for ever while working. Every address, as the upstream compose file has it. +HOSTNAME=:: +STUDIO_PG_META_URL=http://supabase-meta:8080 +POSTGRES_PASSWORD=${secret:postgres} +DEFAULT_ORGANIZATION_NAME=Default Organization +DEFAULT_PROJECT_NAME=Default Project +OPENAI_API_KEY=${secret:openai} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_PUBLIC_URL=https://${bound:route:name} +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_KEY=${secret:service-role-key} +AUTH_JWT_SECRET=${secret:jwt} +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_URL=http://supabase-analytics:4000 +NEXT_PUBLIC_ENABLE_LOGS=true +NEXT_ANALYTICS_BACKEND_PROVIDER=postgres diff --git a/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml b/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml new file mode 100644 index 00000000..fb0f03d5 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml @@ -0,0 +1,258 @@ +# Written by the mesh (modules/supabase). +# +# Every sink hands logflare its API key in the x-api-key header, never as an api_key query parameter: +# logflare prints a failed request's whole URL, query string included, in its error report, so a +# key in the URL is a key in its log (novox/hq issue 268). supabase-analytics refuses to start +# while this file still puts the key in a URL. +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + include_containers: + - supabase-kong + - supabase-auth + - supabase-rest + - realtime-dev.supabase-realtime + - supabase-storage + - supabase-edge-functions + - supabase-db + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P