Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
|
||||
|
||||
| path | class | from |
|
||||
|---|---|---|
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
|
||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
||||
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
||||
|
||||
@@ -18,10 +18,18 @@ type manifest struct {
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
|
||||
for _, it := range m.Items {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(it.Collection, ":")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[0] == "shell":
|
||||
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
|
||||
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
|
||||
if m.Environment.Variables == nil {
|
||||
m.Environment.Variables = map[string]string{}
|
||||
}
|
||||
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
pkgs = append(pkgs, r["package"].(string))
|
||||
continue
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
||||
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
||||
_, inline := r["content"]
|
||||
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
||||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
||||
}
|
||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
||||
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
||||
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
|
||||
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
||||
t.Fatalf("%v", qt)
|
||||
}
|
||||
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
||||
}
|
||||
}
|
||||
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
||||
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
||||
t.Fatalf("%v", gtk)
|
||||
}
|
||||
@@ -244,3 +270,31 @@ func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if name, ok := r["template"].(string); ok {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
|
||||
t.Errorf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Adwaita:dark
|
||||
@@ -0,0 +1 @@
|
||||
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||
@@ -0,0 +1 @@
|
||||
qt6ct
|
||||
@@ -0,0 +1 @@
|
||||
6
|
||||
@@ -0,0 +1 @@
|
||||
Fusion
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -0,0 +1 @@
|
||||
Adwaita
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
! adwaita: the cursor, for X programs that take it from the resources.
|
||||
Xcursor.theme: Adwaita
|
||||
Xcursor.size: 24
|
||||
+81
-23
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -13,13 +13,14 @@
|
||||
"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",
|
||||
@@ -33,7 +34,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 +53,11 @@
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
||||
home := "/home/operator"
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||
var own string
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-display-session" {
|
||||
own += c.Content
|
||||
}
|
||||
own := m.i3Lines()
|
||||
if own == "" || len(m.Contributions) != 0 {
|
||||
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
||||
}
|
||||
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||
|
||||
@@ -15,19 +15,41 @@ type manifest struct {
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
||||
// triggers, its text the template file beside the manifest.
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
||||
Contributions []any `json:"contributions"`
|
||||
}
|
||||
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
||||
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||
func (m manifest) i3Lines() string {
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-display-session:config" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
||||
// item of the collection contribution:node-hotkeys:trigger.
|
||||
func (m manifest) triggers() string {
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
||||
return c.Content
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-hotkeys:trigger" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
||||
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
||||
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Templates string `json:"templates"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Templates != "templates" {
|
||||
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
||||
}
|
||||
files := 0
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
files++
|
||||
name, _ := r["template"].(string)
|
||||
if !strings.HasPrefix(name, "templates/") {
|
||||
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
||||
continue
|
||||
}
|
||||
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
||||
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
t.Fatal("no file resource")
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "templates" {
|
||||
packs++
|
||||
if a["name"] != "templates" {
|
||||
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
||||
}
|
||||
// The templates archive is read by the controller, never unpacked onto the machine.
|
||||
for _, r := range m.Resources {
|
||||
if r["artifact"] == "templates" {
|
||||
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
||||
}
|
||||
}
|
||||
// And files/ holds what the machine runs, no template.
|
||||
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
||||
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"templates": "templates",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
@@ -59,14 +61,14 @@
|
||||
"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",
|
||||
@@ -79,7 +81,7 @@
|
||||
"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",
|
||||
@@ -92,7 +94,7 @@
|
||||
"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",
|
||||
@@ -105,7 +107,7 @@
|
||||
"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",
|
||||
@@ -118,14 +120,14 @@
|
||||
"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",
|
||||
@@ -151,7 +153,7 @@
|
||||
"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",
|
||||
@@ -174,7 +176,22 @@
|
||||
"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 +211,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+12
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"password": "${secret:admin}",
|
||||
"host": "${bound:route:name}"
|
||||
}
|
||||
@@ -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}
|
||||
@@ -46,6 +46,26 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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",
|
||||
@@ -70,7 +90,7 @@
|
||||
"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 +122,9 @@
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
||||
"template": "files/config.json",
|
||||
"merge": "json",
|
||||
"takes-config": ["host", "email", "url"],
|
||||
"protected": ["password"]
|
||||
}
|
||||
],
|
||||
@@ -123,6 +144,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ node set up from nothing, the declared accounts are written at the second start
|
||||
|
||||
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
|
||||
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
|
||||
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5).
|
||||
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
|
||||
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
|
||||
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ package main
|
||||
//
|
||||
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
|
||||
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
|
||||
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
|
||||
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
|
||||
// so a change to it is the operator's to approve.
|
||||
//
|
||||
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
||||
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
|
||||
|
||||
@@ -25,7 +25,7 @@ type manifest struct {
|
||||
Upgrade map[string]string `json:"upgrade"`
|
||||
Requires []string `json:"requires"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Config map[string]any `json:"config"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
@@ -114,13 +114,21 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
|
||||
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
|
||||
// definition, since each is the operator's own value; and each file's default declares nothing.
|
||||
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
|
||||
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
|
||||
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
|
||||
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
|
||||
// operator's setup still runs Betterbird; and each file's default declares nothing.
|
||||
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if len(m.Settings) != 0 {
|
||||
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
|
||||
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
||||
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
||||
}
|
||||
for key, raw := range m.Config {
|
||||
d, _ := raw.(map[string]any)
|
||||
def, _ := d["default"].(map[string]any)
|
||||
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
|
||||
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
|
||||
}
|
||||
}
|
||||
files := map[string]struct {
|
||||
path, mode string
|
||||
@@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
|
||||
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
|
||||
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
|
||||
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
|
||||
"tags": {tagsFile, "0644", []any{"tags"}, false},
|
||||
"tags": {tagsFile, "0644", []any{"tags"}, true},
|
||||
}
|
||||
merging := 0
|
||||
for _, r := range m.Resources {
|
||||
@@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
|
||||
for id, want := range files {
|
||||
r := resource(t, m, id)
|
||||
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
|
||||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
|
||||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
|
||||
t.Errorf("%s: %v", id, r)
|
||||
}
|
||||
var content map[string]any
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
|
||||
if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) {
|
||||
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
||||
}
|
||||
}
|
||||
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
|
||||
got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("its default declares no account: %v %v", got, err)
|
||||
}
|
||||
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string)))
|
||||
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
||||
if err != nil || len(prefs) != 0 {
|
||||
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
||||
}
|
||||
var c Configuration
|
||||
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
|
||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
|
||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -268,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
|
||||
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
start := resource(t, m, "start")
|
||||
content := start["content"].(string)
|
||||
content := templateText(t, start)
|
||||
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
||||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
||||
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
||||
}
|
||||
launcher := resource(t, m, "launcher")
|
||||
lc := launcher["content"].(string)
|
||||
lc := templateText(t, launcher)
|
||||
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
||||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
||||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"accounts": {}
|
||||
}
|
||||
Executable
+10
@@ -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 "$@"
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"identities": {},
|
||||
"calendars": {},
|
||||
"address-books": {},
|
||||
"filters": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"prefs": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tags": {}
|
||||
}
|
||||
@@ -33,6 +33,50 @@
|
||||
"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",
|
||||
@@ -48,10 +92,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 +105,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 +118,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 +134,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",
|
||||
@@ -137,14 +181,14 @@
|
||||
"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 +235,11 @@
|
||||
"name": "defaults",
|
||||
"kind": "archive",
|
||||
"from": "install/defaults-pref"
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
|
||||
|
||||
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
||||
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
MESH_BROKER_FILE=/run/mesh/broker
|
||||
MESH_NODE=${machine:name}
|
||||
MESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}
|
||||
MESH_PACKAGE_BINDING=/run/mesh/package-registry.json
|
||||
MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret
|
||||
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
|
||||
MESH_WORKSPACE=${dir:workspace}
|
||||
MESH_JUDGE_CACHE=${dir:judge-cache}
|
||||
@@ -75,7 +75,10 @@ func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) {
|
||||
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
content, _ := r["content"].(string)
|
||||
if r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
content := templateText(t, r)
|
||||
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
|
||||
if r["only-when-bound"] != "npm-package-registry" {
|
||||
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
|
||||
@@ -106,7 +109,7 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
||||
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "agent-env" {
|
||||
content, _ := r["content"].(string)
|
||||
content := templateText(t, r)
|
||||
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
|
||||
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
|
||||
}
|
||||
@@ -115,3 +118,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
||||
}
|
||||
t.Fatal("the build agent has no environment file")
|
||||
}
|
||||
|
||||
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
|
||||
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
|
||||
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
|
||||
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
|
||||
raw, err := os.ReadFile("module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Data struct {
|
||||
Own []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Class string `json:"class"`
|
||||
} `json:"own"`
|
||||
} `json:"data"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Template string `json:"template"`
|
||||
Volumes []string `json:"volumes"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, data, env, mounted := false, false, false, false
|
||||
for _, d := range m.Data.Own {
|
||||
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
|
||||
data = true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
switch {
|
||||
case r.ID == "judge-cache" && r.Type == "directory":
|
||||
dir = true
|
||||
case r.ID == "agent-env":
|
||||
env = strings.Contains(templateText(t, map[string]any{"id": r.ID, "template": r.Template}), "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
|
||||
case r.ID == "server":
|
||||
for _, v := range r.Volumes {
|
||||
if v == "${dir:judge-cache}:${dir:judge-cache}" {
|
||||
mounted = true
|
||||
}
|
||||
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
|
||||
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dir || !data || !env || !mounted {
|
||||
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
"path": "${dir:workspace}",
|
||||
"class": "cache",
|
||||
"why": "a build's working copy, cloned again for every build"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"path": "${dir:judge-cache}",
|
||||
"class": "cache",
|
||||
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -54,12 +60,17 @@
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "agent-env",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/build-agent.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
|
||||
"template": "files/build-agent.env"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
@@ -72,6 +83,7 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}:/run/mesh:ro",
|
||||
"${dir:workspace}:${dir:workspace}",
|
||||
"${dir:judge-cache}:${dir:judge-cache}",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"restart-on": [
|
||||
@@ -103,6 +115,11 @@
|
||||
"repository": "novox/mesh-controller",
|
||||
"ref": "main"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
],
|
||||
"on": [
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package buildagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -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
|
||||
@@ -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
|
||||
@@ -32,14 +32,14 @@
|
||||
"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 +52,14 @@
|
||||
"unit"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ whenever the node's tool runtime collects the module's tools:
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||
|
||||
@@ -55,6 +55,17 @@ same content, and never through a directory that is a symbolic link. A placed fi
|
||||
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||
items to report them; nothing else is read or written.
|
||||
|
||||
**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge
|
||||
mints this node's agent a token of its own account, `agent-<node>`, scoped to read repositories alone, and the vault
|
||||
keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's
|
||||
credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's
|
||||
home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it;
|
||||
and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was
|
||||
asked) is never placed. While one is held, `managed-settings.json` gives every session's environment
|
||||
`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file='<that path>'`: the
|
||||
path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed,
|
||||
printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push.
|
||||
|
||||
## Over NATS
|
||||
|
||||
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
|
||||
//
|
||||
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
|
||||
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
|
||||
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
|
||||
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
|
||||
// 0266: no other module writes there), and points every session's git at it through the managed settings'
|
||||
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
|
||||
// token is printed, typed on a command line or put in an environment a process listing shows.
|
||||
//
|
||||
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
|
||||
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
|
||||
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
|
||||
// been asked yet — is not placed, and what was placed before is taken back.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
||||
const (
|
||||
ForgeProvision = "gitea-read"
|
||||
forgeSecret = "gitea-read.secret"
|
||||
)
|
||||
|
||||
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
|
||||
|
||||
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
|
||||
// mesh's own.
|
||||
func (p Paths) forgeCredentials() string {
|
||||
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
|
||||
}
|
||||
|
||||
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
|
||||
// and nothing else. Anything else is refused, said without the value.
|
||||
func ForgeCredentialLines(raw string) (string, error) {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(line)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
|
||||
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
|
||||
"(the forge has not issued it yet, or something else was given)")
|
||||
}
|
||||
if pw, ok := u.User.Password(); !ok || pw == "" {
|
||||
return "", errors.New("a forge credential line names no token")
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "", errors.New("the forge's read credential is empty")
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
|
||||
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
|
||||
func ForgeCredentialHeld(p Paths) bool {
|
||||
raw, err := os.ReadFile(p.forgeSecret())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = ForgeCredentialLines(string(raw))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
|
||||
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
|
||||
func PlaceForgeCredential(p Paths) (string, error) {
|
||||
path := p.forgeCredentials()
|
||||
raw, err := os.ReadFile(p.forgeSecret())
|
||||
var lines string
|
||||
var why error
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
why = errors.New("this machine holds no forge read credential")
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
|
||||
default:
|
||||
lines, why = ForgeCredentialLines(string(raw))
|
||||
}
|
||||
if why != nil {
|
||||
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
|
||||
}
|
||||
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
|
||||
}
|
||||
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
|
||||
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
|
||||
}
|
||||
tmp := path + ".mesh-tmp"
|
||||
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
|
||||
return "", fmt.Errorf("the forge credential could not be written: %w", err)
|
||||
}
|
||||
if err := fsys.Chmod(tmp, 0o600); err != nil {
|
||||
_ = fsys.Remove(tmp)
|
||||
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
|
||||
}
|
||||
if err := fsys.Rename(tmp, path); err != nil {
|
||||
_ = fsys.Remove(tmp)
|
||||
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
|
||||
}
|
||||
|
||||
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
|
||||
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
|
||||
func forgeEnv(managed map[string]any, credentials string) {
|
||||
if credentials == "" {
|
||||
return
|
||||
}
|
||||
env := map[string]any{}
|
||||
if held, ok := managed["env"].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["GIT_CONFIG_COUNT"] = "1"
|
||||
env["GIT_CONFIG_KEY_0"] = "credential.helper"
|
||||
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
|
||||
managed["env"] = env
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token
|
||||
// alone, as the forge does for an account a team lets read it.
|
||||
func privateForge(t *testing.T) (origin, bare string) {
|
||||
t.Helper()
|
||||
backend := filepath.Join(gitExecPath(t), "git-http-backend")
|
||||
if _, err := os.Stat(backend); err != nil {
|
||||
t.Skipf("git-http-backend is not installed: %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
bare = filepath.Join(root, "private.git")
|
||||
work := t.TempDir()
|
||||
runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".")
|
||||
commit(t, work, "one")
|
||||
runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare)
|
||||
cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "agent-anchor" || pass != agentToken {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") {
|
||||
http.Error(w, "read only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cgiHandler.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL, bare
|
||||
}
|
||||
|
||||
func gitExecPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "--exec-path").Output()
|
||||
if err != nil {
|
||||
t.Skipf("git is not installed: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, env []string, name string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := runErr(dir, env, name, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runErr(dir string, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
|
||||
"GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func commit(t *testing.T, dir, text string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, dir, nil, "git", "add", "file")
|
||||
runGit(t, dir, nil, "git", "commit", "-q", "-m", text)
|
||||
}
|
||||
|
||||
// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git
|
||||
// configuration of its own.
|
||||
func sessionEnv(t *testing.T, home, managedSettings string) []string {
|
||||
t.Helper()
|
||||
var settings struct {
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"}
|
||||
for k, v := range settings.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func forgePaths(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"}
|
||||
}
|
||||
|
||||
func managedFor(p Paths) string {
|
||||
facts := Facts{Node: p.Node, Console: "http://console"}
|
||||
if ForgeCredentialHeld(p) {
|
||||
facts.ForgeCredentials = p.forgeCredentials()
|
||||
}
|
||||
return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"]
|
||||
}
|
||||
|
||||
// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and
|
||||
// fetches it again, and no credential appears in a log, a command line or the session.
|
||||
func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) {
|
||||
origin, bare := privateForge(t)
|
||||
p := forgePaths(t)
|
||||
repo := origin + "/private.git"
|
||||
|
||||
// Before the forge issued anything, the session's git has no credential: the clone is refused, as today.
|
||||
before := sessionEnv(t, p.Home, managedFor(p))
|
||||
if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil {
|
||||
t.Fatalf("a private repository was cloned with no credential:\n%s", out)
|
||||
}
|
||||
|
||||
// The vault hands this module what the forge issued for this machine's agent.
|
||||
lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" +
|
||||
"https://agent-anchor:" + agentToken + "@git.example.test\n"
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := PlaceForgeCredential(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(p.forgeCredentials())
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("the credential file: %v %v, want mode 0600", info, err)
|
||||
}
|
||||
if again, err := PlaceForgeCredential(p); err != nil || again != "" {
|
||||
t.Fatalf("placed again with nothing changed: %q %v", again, err)
|
||||
}
|
||||
managed := managedFor(p)
|
||||
env := sessionEnv(t, p.Home, managed)
|
||||
|
||||
work := t.TempDir()
|
||||
cloneOut := runGit(t, work, env, "git", "clone", repo, "clone")
|
||||
clone := filepath.Join(work, "clone")
|
||||
if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" {
|
||||
t.Fatalf("cloned %q", got)
|
||||
}
|
||||
|
||||
// The forge's repository moves on; the agent fetches it again.
|
||||
other := t.TempDir()
|
||||
runGit(t, other, nil, "git", "clone", "-q", bare, "w")
|
||||
commit(t, filepath.Join(other, "w"), "two")
|
||||
runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main")
|
||||
fetchOut := runGit(t, clone, env, "git", "fetch", "origin")
|
||||
if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") {
|
||||
t.Fatalf("fetched %q", got)
|
||||
}
|
||||
|
||||
// A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it.
|
||||
commit(t, clone, "three")
|
||||
if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil {
|
||||
t.Fatalf("a push with the agent's credential went through:\n%s", out)
|
||||
}
|
||||
|
||||
// No credential in what the session saw, its command lines, its environment or the module's words.
|
||||
remote := runGit(t, clone, env, "git", "remote", "get-url", "origin")
|
||||
for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut,
|
||||
"the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"),
|
||||
"what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} {
|
||||
if strings.Contains(text, agentToken) {
|
||||
t.Errorf("the token appears in %s", what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readOrEmpty(path string) string {
|
||||
raw, _ := os.ReadFile(path)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) {
|
||||
p := forgePaths(t)
|
||||
// A value the mesh made while the forge had not been asked: not credential lines, never placed.
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || said != "" {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
||||
t.Fatal("a value the forge did not issue was placed")
|
||||
}
|
||||
if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") {
|
||||
t.Fatal("the sessions' git is pointed at a credential that is not held")
|
||||
}
|
||||
|
||||
issued := "https://agent-anchor:" + agentToken + "@git.example.test\n"
|
||||
if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PlaceForgeCredential(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// git's store helper erased the line after a rejection, and a person's password for another host was stored.
|
||||
if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if got := readOrEmpty(p.forgeCredentials()); got != issued {
|
||||
t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "<token>"))
|
||||
}
|
||||
if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// The credential withdrawn: taken back, and the sessions' git no longer pointed at it.
|
||||
if err := os.Remove(p.forgeSecret()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) {
|
||||
t.Fatalf("%q %v", said, err)
|
||||
}
|
||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
||||
t.Fatal("a withdrawn credential stayed in the agent's home")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) {
|
||||
managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}}
|
||||
forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials")
|
||||
env := managed["env"].(map[string]any)
|
||||
if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" ||
|
||||
env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" {
|
||||
t.Fatalf("%v", env)
|
||||
}
|
||||
none := map[string]any{}
|
||||
forgeEnv(none, "")
|
||||
if _, set := none["env"]; set {
|
||||
t.Fatal("an environment was set with no credential held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
||||
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
|
||||
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
||||
if _, err := ForgeCredentialLines(bad); err == nil {
|
||||
t.Errorf("%q was taken", bad)
|
||||
} else if strings.Contains(err.Error(), "t@git") {
|
||||
t.Errorf("the refusal said the value: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,7 +585,25 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
|
||||
report()
|
||||
var last string
|
||||
// The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed
|
||||
// it; the sessions' environment names it only while one is held.
|
||||
forgeHeld := ForgeCredentialHeld(p)
|
||||
placeForge := func() {
|
||||
if line, err := PlaceForgeCredential(p); err != nil {
|
||||
say("%v", err)
|
||||
} else if line != "" {
|
||||
say("%s", line)
|
||||
}
|
||||
if held := ForgeCredentialHeld(p); held != forgeHeld {
|
||||
forgeHeld = held
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering after the forge's read credential changed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
placeForge()
|
||||
for range time.Tick(5 * time.Second) {
|
||||
placeForge()
|
||||
info, err := fsys.Stat(p.credentials())
|
||||
now := "absent"
|
||||
if err == nil {
|
||||
|
||||
@@ -216,6 +216,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
}
|
||||
if ForgeCredentialHeld(p) {
|
||||
facts.ForgeCredentials = p.forgeCredentials()
|
||||
}
|
||||
var settings Settings
|
||||
readJSON(p.Settings, &settings)
|
||||
var binding *Binding
|
||||
|
||||
@@ -34,6 +34,9 @@ const meshEntry = "mesh"
|
||||
type Facts struct {
|
||||
Node string `json:"node"`
|
||||
Console string `json:"console"`
|
||||
// ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one
|
||||
// (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh.
|
||||
ForgeCredentials string `json:"-"`
|
||||
}
|
||||
|
||||
// Settings are the operator's, for the mesh or this node.
|
||||
@@ -139,6 +142,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
forgeEnv(managed, facts.ForgeCredentials)
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@ const agentAccountFile = "/etc/claude-code/agent-account"
|
||||
|
||||
type manifestShell struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Shell []struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
} `json:"shell"`
|
||||
// The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
|
||||
// template file beside the manifest.
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func readManifestShell(t *testing.T) manifestShell {
|
||||
@@ -54,9 +55,13 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
|
||||
func zshCode(t *testing.T, m manifestShell) string {
|
||||
t.Helper()
|
||||
var code []string
|
||||
for _, s := range m.Shell {
|
||||
if s.For == "zsh" {
|
||||
code = append(code, s.Code)
|
||||
for _, it := range m.Items {
|
||||
if strings.HasPrefix(it.Collection, "shell:zsh:") {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code = append(code, string(text))
|
||||
}
|
||||
}
|
||||
if len(code) != 1 {
|
||||
@@ -71,8 +76,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
|
||||
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
|
||||
t.Fatalf("the account's file is %v", r)
|
||||
}
|
||||
if r["content"] != "${machine:agent-account}\n" {
|
||||
t.Fatalf("the account's file holds %q", r["content"])
|
||||
if text := templateText(t, r); text != "${machine:agent-account}\n" {
|
||||
t.Fatalf("the account's file holds %q", text)
|
||||
}
|
||||
if _, has := r["owner"]; has {
|
||||
t.Fatal("the account's file must be root's, as claude-agent is")
|
||||
@@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
|
||||
start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
|
||||
if !strings.Contains(start, "exec claude \"$@\"") {
|
||||
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
${machine:agent-account}
|
||||
Executable
+13
@@ -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 "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"node": "${machine:name}",
|
||||
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"role": "",
|
||||
"mcp_servers": {},
|
||||
"managed_settings": {},
|
||||
"instructions": {},
|
||||
"output_styles": {},
|
||||
"skills": {},
|
||||
"commands": {},
|
||||
"agents": {}
|
||||
}
|
||||
@@ -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=$(</etc/claude-code/agent-account)
|
||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
||||
alias claude=claude-agent
|
||||
fi
|
||||
}
|
||||
fi
|
||||
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint"
|
||||
"mcp-endpoint",
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"secrets": {
|
||||
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
@@ -85,6 +93,57 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "object",
|
||||
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"managed_settings": {
|
||||
"type": "object",
|
||||
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||
"trusted": true,
|
||||
"predefined": "agent-managed-settings",
|
||||
"default": {}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"output_styles": {
|
||||
"type": "object",
|
||||
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"commands": {
|
||||
"type": "object",
|
||||
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
@@ -102,14 +161,14 @@
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n"
|
||||
"template": "files/claude-agent"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"content": "${machine:agent-account}\n"
|
||||
"template": "files/agent-account"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
@@ -138,7 +197,7 @@
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
||||
"template": "files/facts.json"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
@@ -146,15 +205,15 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"merge": "json",
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
],
|
||||
"shell": [
|
||||
"items": [
|
||||
{
|
||||
"for": "zsh",
|
||||
"slot": "normal",
|
||||
"code": "# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(</etc/claude-code/agent-account)\n\t\tif [[ -n $agent && $agent != $USERNAME ]]; then\n\t\t\talias claude=claude-agent\n\t\tfi\n\t}\nfi\n"
|
||||
"collection": "shell:zsh:normal",
|
||||
"template": "files/zsh-normal"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -174,6 +233,11 @@
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"cadence_minutes": 240,
|
||||
"floor_minutes": 60,
|
||||
"failures_to_notify": 3,
|
||||
"cooldown_hours": 24,
|
||||
"refresh_warn_days": 3
|
||||
}
|
||||
@@ -76,6 +76,43 @@
|
||||
"reads": [
|
||||
"claude-code.holdings"
|
||||
],
|
||||
"config": {
|
||||
"cadence_minutes": {
|
||||
"type": "number",
|
||||
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 240
|
||||
},
|
||||
"floor_minutes": {
|
||||
"type": "number",
|
||||
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 60
|
||||
},
|
||||
"failures_to_notify": {
|
||||
"type": "number",
|
||||
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
},
|
||||
"cooldown_hours": {
|
||||
"type": "number",
|
||||
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 24
|
||||
},
|
||||
"refresh_warn_days": {
|
||||
"type": "number",
|
||||
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
@@ -88,15 +125,16 @@
|
||||
"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",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
||||
"merge": "json",
|
||||
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
|
||||
"template": "files/settings.json"
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
@@ -134,6 +172,11 @@
|
||||
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
||||
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
|
||||
`x11-display` on its own machine.
|
||||
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
|
||||
- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
|
||||
It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
|
||||
merged), and the session start alone is one starter.
|
||||
- Declares `clipmenud.service` as a **user unit of the operator's account**, enabled and running
|
||||
(`scope: user`, ADR 0177; issue 743), so `$mod+.` shows the history again. It is the only starter: the
|
||||
session's `xinitrc` line is gone. A drop-in (`~/.config/systemd/user/clipmenud.service.d/mesh.conf`)
|
||||
replaces `ExecStart` with the module's launcher, which finds the X display from its socket (the account's
|
||||
service manager has no `DISPLAY`) and puts the text-only `xsel` first on the `PATH`; until the display is up
|
||||
it waits (a quick exit would run into the start limit, which the drop-in also switches off, with `RestartSec=2s`). `DISPLAY` and `XAUTHORITY` come from the user manager when the session's xinitrc has handed them over (module xorg); with several X servers and no `DISPLAY` yet, it waits for them. The store is in the account's runtime directory (mode 0700),
|
||||
so no other account reads it.
|
||||
**After delivery the operator logs in again**: the running session keeps the `clipmenud` its xinitrc
|
||||
started, and the new unit waits on clipmenud's session lock until that one ends.
|
||||
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
|
||||
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
|
||||
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
|
||||
@@ -23,11 +29,11 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
|
||||
| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
|
||||
| `clipmenu_paste` | what the clipboard holds now |
|
||||
| `node-clipboard.history` | the history, newest first: count, time and length of each entry, and whether the daemon collects. **Never a text, a first line or an id** (issue 750); the text is read through clipmenu on the operator's own screen |
|
||||
| `node-clipboard.copy` | put text on the clipboard, then read it back; an error with the reason (no `DISPLAY`, `xsel` exited, other contents) when it does not hold the text. It enters the history like any copy |
|
||||
| `clipmenu_paste` | how many bytes of text the clipboard holds now, never the text |
|
||||
| `clipmenu_clear` | forget the history; the daemon's locks stay |
|
||||
| `clipmenu_delete` | forget one entry, by id or first line |
|
||||
| `clipmenu_delete` | forget one entry, by its place in the history (1 is the newest) and the time the history gave it, refused if the history changed since; not by text or id |
|
||||
|
||||
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
|
||||
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
|
||||
|
||||
@@ -15,8 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mostCopy = 1 << 20
|
||||
mostPaste = 64 << 10
|
||||
mostCopy = 1 << 20
|
||||
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
||||
majorVersion = 6
|
||||
)
|
||||
@@ -71,15 +70,15 @@ func cksum(data []byte) string {
|
||||
|
||||
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
||||
|
||||
// Entry is one clip in the history.
|
||||
// Entry is one clip in the history as a caller sees it: when and how long, never what. The text of a
|
||||
// clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the
|
||||
// operator's own screen through clipmenu (issue 750).
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Line string `json:"line"`
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
at int64
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
id string
|
||||
line string
|
||||
at int64
|
||||
}
|
||||
|
||||
// HistoryResult is what node-clipboard.history answers.
|
||||
@@ -119,14 +118,15 @@ func readStore(dir string) ([]Entry, error) {
|
||||
}
|
||||
out := make([]Entry, 0, len(latest))
|
||||
for line, ns := range latest {
|
||||
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
out = append(out, Entry{id: entryID(line), line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
||||
return out, scan.Err()
|
||||
}
|
||||
|
||||
// History is the clipboard's history, newest first.
|
||||
func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
// History is the clipboard's history, newest first: how many entries, when each was copied and how
|
||||
// long each is, and whether the daemon is collecting. It never returns a copied text.
|
||||
func History(limit int) (HistoryResult, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
@@ -140,15 +140,8 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
if i == limit {
|
||||
break
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
|
||||
if info, err := os.Stat(filepath.Join(dir, e.id)); err == nil {
|
||||
e.Bytes = int(info.Size())
|
||||
if maxBytes > 0 {
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
|
||||
if len(raw) > maxBytes {
|
||||
raw, e.Truncated = raw[:maxBytes], true
|
||||
}
|
||||
e.Text = string(raw)
|
||||
}
|
||||
}
|
||||
out.Entries = append(out.Entries, e)
|
||||
}
|
||||
@@ -159,6 +152,40 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
type CopyResult struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Unit string `json:"unit"`
|
||||
// Verified says the clipboard was read back and held exactly the text.
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times.
|
||||
var readBackWait = 200 * time.Millisecond
|
||||
|
||||
// readBack waits for the clipboard to hold text and answers why when it does not.
|
||||
func readBack(s Session, unit, text string) error {
|
||||
var last string
|
||||
for i := 0; i < 15; i++ {
|
||||
time.Sleep(readBackWait)
|
||||
r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard")
|
||||
switch {
|
||||
case err != nil:
|
||||
last = err.Error()
|
||||
case r.Code != 0:
|
||||
last = "xsel: " + strings.TrimSpace(r.Stderr)
|
||||
case r.Stdout == text:
|
||||
return nil
|
||||
default:
|
||||
last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout))
|
||||
}
|
||||
}
|
||||
why := ""
|
||||
if s.Display == "" {
|
||||
why = "; the session has no DISPLAY, so the owner could not reach the screen"
|
||||
}
|
||||
if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil {
|
||||
if tail := strings.TrimSpace(j.Stdout); tail != "" {
|
||||
why += "; the owner said: " + tail
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why)
|
||||
}
|
||||
|
||||
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
||||
@@ -194,17 +221,20 @@ func Copy(text string) (CopyResult, error) {
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
|
||||
if err := readBack(s, unit, text); err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil
|
||||
}
|
||||
|
||||
// PasteResult is what clipmenu_paste answers.
|
||||
// PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The
|
||||
// clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750).
|
||||
type PasteResult struct {
|
||||
Text string `json:"text"`
|
||||
Bytes int `json:"bytes"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text bool `json:"holds_text"`
|
||||
}
|
||||
|
||||
// Paste reads the clipboard now.
|
||||
// Paste reads the clipboard now and says how long it is.
|
||||
func Paste() (PasteResult, error) {
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
@@ -217,11 +247,7 @@ func Paste() (PasteResult, error) {
|
||||
if r.Code != 0 {
|
||||
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
|
||||
if len(out.Text) > mostPaste {
|
||||
out.Text, out.Truncated = out.Text[:mostPaste], true
|
||||
}
|
||||
return out, nil
|
||||
return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, nil
|
||||
}
|
||||
|
||||
// ChangeResult is what clear and delete answer.
|
||||
@@ -298,10 +324,12 @@ func Clear() (ChangeResult, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Delete forgets one entry, by id or by its first line.
|
||||
func Delete(id, line string) (ChangeResult, error) {
|
||||
if (id == "") == (line == "") {
|
||||
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
|
||||
// Delete forgets one entry, by its place in the history as node-clipboard.history lists it (1 is the
|
||||
// newest) together with the time it reports, so a copy arriving since the listing cannot shift the
|
||||
// place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line.
|
||||
func Delete(place int, at string) (ChangeResult, error) {
|
||||
if place < 1 {
|
||||
return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest")
|
||||
}
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
@@ -309,6 +337,17 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if place > len(entries) {
|
||||
return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries))
|
||||
}
|
||||
if entries[place-1].At != at {
|
||||
return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at)
|
||||
}
|
||||
line := entries[place-1].line
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -319,7 +358,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(l, " ")
|
||||
if text == line || (id != "" && entryID(text) == id) {
|
||||
if text == line {
|
||||
out.Removed++
|
||||
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
||||
continue
|
||||
@@ -327,7 +366,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if out.Removed == 0 {
|
||||
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
|
||||
return fmt.Errorf("no entry %d in the clipboard history", place)
|
||||
}
|
||||
content := strings.Join(kept, "\n")
|
||||
if content != "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -51,46 +52,84 @@ func store(t *testing.T, clips map[string]string, order ...string) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
|
||||
func TestTheHistoryAnswersCountTimesAndLengthsNeverContent(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "sk-ant-api03-SECRETKEY (2 lines)": "sk-ant-api03-SECRETKEY\nline two"}, "first", "sk-ant-api03-SECRETKEY (2 lines)", "first")
|
||||
fakeProcess(t, nobody, "clipmenud")
|
||||
h, err := History(10, 4096)
|
||||
h, err := History(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
|
||||
if !h.Collecting || h.Total != 2 || len(h.Entries) != 2 || h.Entries[0].Bytes != 5 || h.Entries[1].Bytes != 31 || h.Entries[0].At == "" {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
|
||||
t.Fatalf("limited and cut: %+v", h)
|
||||
raw, _ := json.Marshal(h)
|
||||
for _, leak := range []string{"first", "SECRETKEY", "line two"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Fatalf("the history answers %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
|
||||
t.Fatalf("without text: %+v", h)
|
||||
if h, _ := History(1); len(h.Entries) != 1 {
|
||||
t.Fatalf("limited: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
|
||||
// at is the time History reports for the entry at place.
|
||||
func at(t *testing.T, place int) string {
|
||||
t.Helper()
|
||||
h, err := History(100)
|
||||
if err != nil || place > len(h.Entries) {
|
||||
t.Fatalf("%+v, %v", h, err)
|
||||
}
|
||||
return h.Entries[place-1].At
|
||||
}
|
||||
|
||||
func TestADeleteWhoseListingIsStaleIsRefused(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
|
||||
listed := at(t, 1) // b
|
||||
f, _ := os.OpenFile(filepath.Join(dir, "line_cache"), os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
f.WriteString("1800000000000000000 a\n") // a copy arrives: place 1 is now a
|
||||
f.Close()
|
||||
if _, err := Delete(1, listed); err == nil || !strings.Contains(err.Error(), "list it again") {
|
||||
t.Fatalf("the wrong entry was deleted: %v", err)
|
||||
}
|
||||
if h, _ := History(10); h.Total != 2 {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByItsPlaceWithItsTextAndNeverByItsText(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
||||
r, err := Delete(entryID("a"), "")
|
||||
// newest first: a, c, b
|
||||
r, err := Delete(1, at(t, 1))
|
||||
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
||||
t.Fatalf("by id, both copies: %+v, %v", r, err)
|
||||
t.Fatalf("by place, both copies: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the text stayed")
|
||||
}
|
||||
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("by line: %+v, %v", r, err)
|
||||
if r, err := Delete(2, at(t, 2)); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("second place (b): %+v, %v", r, err)
|
||||
}
|
||||
if _, err := Delete("", "zzz"); err == nil {
|
||||
t.Fatal("a missing entry was reported deleted")
|
||||
if _, err := Delete(5, "x"); err == nil {
|
||||
t.Fatal("a missing place was reported deleted")
|
||||
}
|
||||
if _, err := Delete("x", "y"); err == nil {
|
||||
t.Fatal("both an id and a line were accepted")
|
||||
if _, err := Delete(0, "x"); err == nil {
|
||||
t.Fatal("place 0 was accepted")
|
||||
}
|
||||
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
||||
t.Fatalf("line cache: %q", cache)
|
||||
}
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "clipmenu_delete" {
|
||||
if _, ok := tool.Input["line"]; ok {
|
||||
t.Fatal("delete still takes a line")
|
||||
}
|
||||
if _, ok := tool.Input["id"]; ok {
|
||||
t.Fatal("delete still takes an id")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
@@ -112,13 +151,21 @@ func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
func copyBed(t *testing.T, xsel string) string {
|
||||
t.Helper()
|
||||
store(t, nil)
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "xsel": xsel,
|
||||
"systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
readBackWait = 0
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManagerAndReadsItBack(t *testing.T) {
|
||||
bin := copyBed(t, `cat "$LOG.text"`)
|
||||
r, err := Copy("secret-free text")
|
||||
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
if err != nil || r.Bytes != 16 || !r.Verified || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
@@ -134,7 +181,23 @@ func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
func TestCopyFailsWhenTheClipboardHoldsSomethingElse(t *testing.T) {
|
||||
copyBed(t, `printf 'old contents'`)
|
||||
_, err := Copy("new text")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not hold") || strings.Contains(err.Error(), "old contents") {
|
||||
t.Fatalf("a copy that did not land was reported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFailsSayingWhyWhenTheOwnerCannotReachTheDisplay(t *testing.T) {
|
||||
copyBed(t, `echo "xsel: Can't open display: (null)" >&2; exit 1`)
|
||||
_, err := Copy("new text")
|
||||
if err == nil || !strings.Contains(err.Error(), "Can't open display") {
|
||||
t.Fatalf("the reason was not given: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteSaysHowMuchTheClipboardHoldsNeverWhat(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatal(err)
|
||||
@@ -142,8 +205,9 @@ func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
||||
p, err := Paste()
|
||||
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
|
||||
t.Fatalf("%+v, %v", p, err)
|
||||
raw, _ := json.Marshal(p)
|
||||
if err != nil || p.Bytes != 16 || strings.Contains(string(raw), "on the clipboard") {
|
||||
t.Fatalf("%s, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +221,26 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
|
||||
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
|
||||
if h, err := History(5); err != nil || h.Total != 0 || h.Collecting {
|
||||
t.Fatalf("an empty history: %+v, %v", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud
|
||||
// (issue 860: collecting was false while the daemon ran).
|
||||
func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first"}, "first")
|
||||
fakeProcess(t, nobody, "bash")
|
||||
cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline")
|
||||
if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(10); err != nil || !h.Collecting {
|
||||
t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err)
|
||||
}
|
||||
// another bash script is not the daemon
|
||||
os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644)
|
||||
if h, _ := History(10); h.Collecting {
|
||||
t.Fatalf("a bash script that is not clipmenud counted: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,28 +22,23 @@ func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-clipboard.history",
|
||||
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
|
||||
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
|
||||
Description: "What the operator copied, newest first: how many entries, when each was copied and how long " +
|
||||
"it is, never what it says. Whether the clipboard daemon is collecting. The text is read on the operator's own screen, through clipmenu.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := whole(args, "limit", 20, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
most, err := whole(args, "max_bytes", 4096, 0, 65536)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return History(limit, most)
|
||||
return History(limit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "node-clipboard.copy",
|
||||
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
||||
"like any copy. Answers how many bytes.",
|
||||
"like any copy. Reads the clipboard back and answers an error with the reason when it does not hold the text.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -61,7 +56,7 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_paste",
|
||||
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
|
||||
Description: "How many bytes of text the operator's clipboard holds right now, never the text itself.",
|
||||
Run: func(map[string]any) (any, error) { return Paste() },
|
||||
},
|
||||
{
|
||||
@@ -71,19 +66,22 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_delete",
|
||||
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
|
||||
"its first line exactly.",
|
||||
Description: "Forget one entry of the clipboard history, by its place in node-clipboard.history's list " +
|
||||
"(1 is the newest) and its time as the history gives it; refused when the two no longer match.",
|
||||
Input: map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the entry's id"},
|
||||
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
|
||||
"place": map[string]any{"type": "integer", "description": "the entry's place in the history, 1 for the newest"},
|
||||
"at": map[string]any{"type": "string", "description": "the entry's time, as the history reports it"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := text(args, "id", false)
|
||||
place, err := whole(args, "place", 0, 0, 100000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, _ := args["line"].(string)
|
||||
return Delete(id, line)
|
||||
at, err := text(args, "at", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Delete(place, at)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Moved bool `json:"moved"`
|
||||
Items []item `json:"items"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
@@ -23,7 +25,7 @@ type manifest struct {
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||
Contributions []contribution `json:"contributions"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
@@ -46,6 +48,53 @@ type shellCode struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest.
|
||||
type item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of
|
||||
// the collections shell:<file>:<slot>, in the order they are declared.
|
||||
func (m manifest) shell(t *testing.T) []shellCode {
|
||||
t.Helper()
|
||||
var out []shellCode
|
||||
for _, it := range m.Items {
|
||||
parts := strings.Split(it.Collection, ":")
|
||||
if len(parts) != 3 || parts[0] != "shell" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// environmentOf is the variables the module sets, read from the templates of its items of the environment.
|
||||
func (m manifest) environmentOf(t *testing.T) *environment {
|
||||
t.Helper()
|
||||
var env *environment
|
||||
for _, it := range m.Items {
|
||||
if it.Collection != "environment" || it.Attributes["kind"] != "variable" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env == nil {
|
||||
env = &environment{Variables: map[string]string{}}
|
||||
}
|
||||
env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
@@ -58,6 +107,7 @@ func readManifest(t *testing.T) manifest {
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
m.Environment = m.environmentOf(t)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -85,26 +135,66 @@ func (m manifest) packages() (present, absent []string) {
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
// fromTemplate checks that a file resource takes its text from its template file in the module's
|
||||
// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the
|
||||
// template is there. The readable file in the repository is the one place the machine's text is written.
|
||||
func (m manifest) fromTemplate(t *testing.T, id, template string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
if r["template"] != template {
|
||||
t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"])
|
||||
}
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil {
|
||||
t.Fatalf("resource %s names %s: %v", id, template, err)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs
|
||||
// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func (m manifest) everyFileIsATemplate(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// text is the text a file resource places on the machine: its template file's.
|
||||
func (m manifest) text(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
name, _ := m.resource(t, id)["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %s names no template", id)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
@@ -176,31 +266,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type contribution struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
// displaySession is the collection the module's lines for the window manager are an item of.
|
||||
const displaySession = "contribution:node-display-session:config"
|
||||
|
||||
// i3Lines is what the module contributes to the window manager.
|
||||
// i3Lines is what the module contributes to the window manager: the text of its item of the collection
|
||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||
func (m manifest) i3Lines() string {
|
||||
var out string
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||
out += c.Content
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == displaySession {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
out += string(text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||
// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no
|
||||
// old-form contribution to node-display-session is left.
|
||||
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if len(m.Contributions) != 0 {
|
||||
t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession)
|
||||
}
|
||||
if m.i3Lines() != string(want) {
|
||||
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||
var templates []string
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == displaySession {
|
||||
templates = append(templates, it.Template)
|
||||
}
|
||||
}
|
||||
if len(templates) != 1 || templates[0] != source {
|
||||
t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source)
|
||||
}
|
||||
if m.i3Lines() == "" {
|
||||
t.Fatalf("the template %s holds no line", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
|
||||
@@ -27,20 +34,48 @@ func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
|
||||
func TestTheDaemonIsTheOperatorsUserUnitEnabledAndRunning(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
if len(m.shell(t)) != 0 {
|
||||
t.Fatalf("a second start from the session: %+v", m.shell(t))
|
||||
}
|
||||
code := m.Shell[0].Code
|
||||
// Started once, through the module's text-only xsel (a selection holding an image is never read).
|
||||
if strings.Count(code, "clipmenud &\n") != 1 || !strings.Contains(code, `PATH="/usr/local/lib/mesh-clipmenu:$PATH" clipmenud &`) ||
|
||||
strings.Contains(code, "greenclip") {
|
||||
t.Fatalf("%q", code)
|
||||
svc := m.resource(t, "daemon")
|
||||
want := map[string]any{"id": "daemon", "type": "service", "unit": "clipmenud.service", "scope": "user",
|
||||
"user": "${machine:account}", "state": "running", "boot": "enabled", "reload-on": nil}
|
||||
for k, v := range want {
|
||||
if k != "reload-on" && svc[k] != v {
|
||||
t.Fatalf("daemon %s = %v, want %v: %v", k, svc[k], v, svc)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" || r["type"] == "process" {
|
||||
t.Fatalf("a second start: %v", r)
|
||||
// ADR 0240 rule 8: a long-running resource says how it is ready. The launcher keeps the unit active while
|
||||
// it waits for the display and execs clipmenud, so the unit's state is the daemon's own liveness.
|
||||
if h, _ := svc["health"].(map[string]any); h["kind"] != "unit" {
|
||||
t.Fatalf("daemon health = %v, want kind unit", svc["health"])
|
||||
}
|
||||
dropin := m.resource(t, "daemon-dropin")
|
||||
content := m.text(t, "daemon-dropin")
|
||||
if dropin["path"] != "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf" ||
|
||||
dropin["owner"] != "${machine:account}" ||
|
||||
!strings.Contains(content, "ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session") ||
|
||||
strings.Contains(content, "greenclip") {
|
||||
t.Fatalf("drop-in: %v", dropin)
|
||||
}
|
||||
launcher := m.text(t, "launcher")
|
||||
if !strings.Contains(launcher, "DISPLAY") || !strings.Contains(launcher, "/tmp/.X11-unix") ||
|
||||
!strings.Contains(launcher, `PATH="/usr/local/lib/mesh-clipmenu:$PATH"`) || !strings.Contains(launcher, "exec clipmenud") {
|
||||
t.Fatalf("launcher: %q", launcher)
|
||||
}
|
||||
// Waits for the display instead of exiting into the start limit; DISPLAY from the manager wins.
|
||||
if !strings.Contains(launcher, "until [ -n \"$DISPLAY\" ]") || !strings.Contains(launcher, "sleep 1") ||
|
||||
strings.Contains(launcher, "exit 1") || strings.Contains(launcher, "exit 0") {
|
||||
t.Fatalf("the launcher must wait, not exit: %q", launcher)
|
||||
}
|
||||
if !strings.Contains(content, "StartLimitIntervalSec=0") || !strings.Contains(content, "RestartSec=2s") {
|
||||
t.Fatalf("drop-in lacks the safety net: %q", content)
|
||||
}
|
||||
for k, v := range m.environmentOf(t).Variables {
|
||||
if !strings.Contains(content, "Environment="+k+"="+v) {
|
||||
t.Fatalf("the unit lacks %s=%s", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,3 +99,131 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
readManifest(t).everyFileIsATemplate(t)
|
||||
}
|
||||
|
||||
// Run for real: DISPLAY from the manager is used at once; several sockets and no DISPLAY wait.
|
||||
func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
script := m.text(t, "launcher")
|
||||
dir := t.TempDir()
|
||||
file := dir + "/launcher"
|
||||
// clipmenud and the socket directory are faked: the script's own paths are rewritten.
|
||||
body := strings.ReplaceAll(script, "/tmp/.X11-unix", dir+"/x11")
|
||||
body = strings.Replace(body, "exec clipmenud", `echo "DISPLAY=$DISPLAY"`, 1)
|
||||
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.MkdirAll(dir+"/x11", 0o755)
|
||||
fakeBin := dir + "/bin"
|
||||
os.MkdirAll(fakeBin, 0o755)
|
||||
// the manager's environment: empty until the file appears
|
||||
os.WriteFile(fakeBin+"/systemctl", []byte("#!/bin/sh\n[ -f \""+dir+"/env\" ] && cat \""+dir+"/env\"\nexit 0\n"), 0o755)
|
||||
run := func(env ...string) (string, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", file)
|
||||
cmd.Env = append([]string{"PATH=" + fakeBin + ":" + os.Getenv("PATH"), "HOME=" + dir}, env...)
|
||||
out, _ := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), ctx.Err() == nil
|
||||
}
|
||||
if out, ok := run("DISPLAY=:7"); !ok || out != "DISPLAY=:7" {
|
||||
t.Fatalf("with DISPLAY: %q %v", out, ok)
|
||||
}
|
||||
if _, ok := run(); ok {
|
||||
t.Fatal("it exited with no X server")
|
||||
}
|
||||
for _, n := range []string{"X0", "X1"} {
|
||||
l, _ := net.Listen("unix", dir+"/x11/"+n)
|
||||
defer l.Close()
|
||||
}
|
||||
if _, ok := run(); ok {
|
||||
t.Fatal("it chose one of two servers")
|
||||
}
|
||||
// the session hands the manager its display while the launcher waits
|
||||
go func() {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
os.WriteFile(dir+"/env", []byte("DISPLAY=:1\nOTHER=x\n"), 0o644)
|
||||
}()
|
||||
if out, ok := run(); !ok || out != "DISPLAY=:1" {
|
||||
t.Fatalf("the display that arrived while waiting: %q %v", out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Run for real: while another clipmenud of the account holds the session lock the launcher waits without
|
||||
// a word, and starts clipmenud once the lock is free (issue 862).
|
||||
func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) {
|
||||
if _, err := exec.LookPath("flock"); err != nil {
|
||||
t.Skip("flock is missing here")
|
||||
}
|
||||
m := readManifest(t)
|
||||
script := m.text(t, "launcher")
|
||||
dir := t.TempDir()
|
||||
body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1)
|
||||
file := dir + "/launcher"
|
||||
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := dir + "/cm/clipmenu.6.op"
|
||||
os.MkdirAll(store, 0o700)
|
||||
lock := store + "/session_lock"
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
|
||||
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := holder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// flock's child shares the lock, so the whole group goes
|
||||
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
|
||||
defer release()
|
||||
for i := 0; i < 50; i++ {
|
||||
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
var out syncBuffer
|
||||
cmd := exec.Command("sh", file)
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"}
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("the launcher went on while the lock was held: %q", out.String())
|
||||
case <-time.After(2500 * time.Millisecond):
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("the launcher spoke while it waited: %q", out.String())
|
||||
}
|
||||
release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
t.Fatal("the launcher did not start clipmenud after the lock was freed")
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "started" {
|
||||
t.Fatalf("after the lock was freed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() }
|
||||
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
|
||||
|
||||
@@ -403,7 +403,7 @@ func processesOf(comm string) []int {
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm || runsScript(dir, comm) {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,24 @@ func processesOf(comm string) []int {
|
||||
return pids
|
||||
}
|
||||
|
||||
// runsScript is whether the process is an interpreter running the script named name: clipmenud is a
|
||||
// bash script, so its comm is "bash" and only its command line, "bash /usr/bin/clipmenud", names it.
|
||||
func runsScript(procDir, name string) bool {
|
||||
raw, err := os.ReadFile(filepath.Join(procDir, "cmdline"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
switch filepath.Base(args[0]) {
|
||||
case "bash", "sh", "dash", "zsh", "env":
|
||||
return filepath.Base(args[1]) == name
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.
|
||||
#
|
||||
# The account's service manager starts before the X server does, and has a DISPLAY only once the session's
|
||||
# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits
|
||||
# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when
|
||||
# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a
|
||||
# second server beside the session) wait for the session's own to arrive.
|
||||
# A running process does not see what the session hands the manager later, so each pass asks the manager.
|
||||
until [ -n "$DISPLAY" ]; do
|
||||
for pair in $(systemctl --user show-environment 2>/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
|
||||
Executable
+20
@@ -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 "$@"
|
||||
@@ -0,0 +1 @@
|
||||
15
|
||||
@@ -0,0 +1 @@
|
||||
500
|
||||
@@ -0,0 +1 @@
|
||||
clipboard
|
||||
@@ -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
|
||||
@@ -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,48 @@
|
||||
"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": "user-units-dir",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/systemd/user",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "daemon-dropins",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"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 +130,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -5,6 +5,20 @@
|
||||
"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",
|
||||
@@ -17,8 +31,18 @@
|
||||
"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 +64,11 @@
|
||||
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
SMTP_AUTH_USER=${secret:smtp-user}
|
||||
SMTP_AUTH_PASS=${secret:smtp-pass}
|
||||
@@ -42,7 +42,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n"
|
||||
"template": "files/server.env"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
@@ -66,5 +66,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"desktop-machines": []
|
||||
}
|
||||
@@ -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",
|
||||
@@ -66,8 +74,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 +94,11 @@
|
||||
"env": {
|
||||
"MESH_DESK_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time
|
||||
spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue
|
||||
depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue
|
||||
312). It changes nothing and declares no resources, so it can be assigned to every machine.
|
||||
312). It also reads a machine's CPU use by systemd unit and by container, from each cgroup's cpu.stat,
|
||||
so an overload outside every container can be named (novox/hq issue 741). It changes nothing and
|
||||
declares no resources, so it can be assigned to every machine.
|
||||
|
||||
## Why it exists, and why here
|
||||
|
||||
@@ -22,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
||||
|
||||
## Tools
|
||||
|
||||
All four only read.
|
||||
All five only read.
|
||||
|
||||
| tool | what | replaces |
|
||||
|---|---|---|
|
||||
@@ -30,6 +32,7 @@ All four only read.
|
||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||
|
||||
### How each number is read
|
||||
|
||||
|
||||
@@ -68,9 +68,10 @@ func (m *Machine) bottomDevices() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// unitIO is every unit's storage I/O, by its cgroup's path: the innermost .service or .scope, because
|
||||
// io.stat counts a cgroup's descendants too and an outer unit would count them twice.
|
||||
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
||||
// unitPaths is every systemd unit's cgroup path under /sys/fs/cgroup: the innermost .service or
|
||||
// .scope, because a cgroup's own stat file counts its descendants too and an outer unit would count
|
||||
// them twice. Shared by every reader that walks the cgroup tree (unitIO here, unitCPU in cpu.go).
|
||||
func (m *Machine) unitPaths() []string {
|
||||
base := m.path("/sys/fs/cgroup")
|
||||
var units []string
|
||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
@@ -89,11 +90,19 @@ func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
||||
outer[dir] = true
|
||||
}
|
||||
}
|
||||
out := map[string]cgroupIO{}
|
||||
out := make([]string, 0, len(units))
|
||||
for _, u := range units {
|
||||
if outer[u] {
|
||||
continue
|
||||
if !outer[u] {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unitIO is every unit's storage I/O, by its cgroup's path.
|
||||
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
||||
out := map[string]cgroupIO{}
|
||||
for _, u := range m.unitPaths() {
|
||||
text := m.read("/sys/fs/cgroup/" + u + "/io.stat")
|
||||
if text == "" {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cgroupCPU is a cgroup's processor time, from its cpu.stat.
|
||||
type cgroupCPU struct {
|
||||
usageUsec uint64
|
||||
}
|
||||
|
||||
// parseCPUStat reads a cgroup's cpu.stat ("usage_usec 123\nuser_usec …\nsystem_usec …"), readable by
|
||||
// every account, unlike /proc/<pid>/stat for another account's process.
|
||||
func parseCPUStat(s string) cgroupCPU {
|
||||
var c cgroupCPU
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
k, v, ok := strings.Cut(line, " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k == "usage_usec" {
|
||||
c.usageUsec, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// unitCPU is every unit's processor time, by its cgroup's path. A cgroup whose cpu.stat cannot be
|
||||
// read is left out, never an error for the whole answer.
|
||||
func (m *Machine) unitCPU() map[string]cgroupCPU {
|
||||
out := map[string]cgroupCPU{}
|
||||
for _, u := range m.unitPaths() {
|
||||
text := m.read("/sys/fs/cgroup/" + u + "/cpu.stat")
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out[u] = parseCPUStat(text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MachineCPU is the machine's CPU in use over the window, from /proc/stat's cpu line. CPUsBusy is
|
||||
// rounded only for display; raw, unexported, is the same count unrounded, for topCPU's share and
|
||||
// unaccounted math (review round 1 of novox/hq issue 741: a small busy time that rounds CPUsBusy to
|
||||
// 0.0 must not zero out every unit's share along with it).
|
||||
type MachineCPU struct {
|
||||
CPUs int `json:"cpus"`
|
||||
BusyPct float64 `json:"busy_pct"` // % of all CPUs together
|
||||
CPUsBusy float64 `json:"cpus_busy"` // as a count of CPUs, e.g. 7.5 of 12, rounded for display
|
||||
raw float64
|
||||
}
|
||||
|
||||
// machineCPUOver is the machine's CPU in use between two readings of /proc/stat: everything but idle
|
||||
// and iowait, counted against the window's total ticks over every CPU.
|
||||
func machineCPUOver(a, b cpuTimes) MachineCPU {
|
||||
total := float64(b.total() - a.total())
|
||||
if total <= 0 {
|
||||
return MachineCPU{CPUs: b.cpus}
|
||||
}
|
||||
idle := sub(b.idle, a.idle) + sub(b.iowait, a.iowait)
|
||||
busy := total - idle
|
||||
if busy < 0 {
|
||||
busy = 0
|
||||
}
|
||||
raw := busy / total * float64(b.cpus)
|
||||
return MachineCPU{CPUs: b.cpus, BusyPct: round1(busy / total * 100), CPUsBusy: round1(raw), raw: raw}
|
||||
}
|
||||
|
||||
// CPUUsage is a unit's or a container's share of CPU between two readings of its cgroup's cpu.stat.
|
||||
// Cgroup is its cgroup's path, relative to /sys/fs/cgroup, said beside Unit always (review round 1 of
|
||||
// novox/hq issue 741): two cgroups can share a leaf name (an outer "init.scope" and a user session's
|
||||
// "user.slice/user@1000.service/init.scope"), and the path is how they are told apart.
|
||||
type CPUUsage struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Cgroup string `json:"cgroup,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
|
||||
SharePct float64 `json:"share_pct"` // this unit's share of the machine's busy time
|
||||
raw float64
|
||||
}
|
||||
|
||||
// topCPU ranks the units, or the containers, with the most CPU time between two readings of
|
||||
// cpu.stat, busiest first. unaccountedPct is the machine's busy time no unit accounts for, as a
|
||||
// share of it (never below 0): the sum is always taken over every unit, whichever by ranks by, so a
|
||||
// limit on the list never changes it.
|
||||
func (m *Machine) topCPU(a, b map[string]cgroupCPU, window time.Duration, by string, limit int, cpusBusy float64) ([]CPUUsage, float64) {
|
||||
windowUsec := window.Seconds() * 1e6
|
||||
type entry struct {
|
||||
path, unit, container string
|
||||
pct float64
|
||||
}
|
||||
var entries []entry
|
||||
var names map[string]string
|
||||
var sumPct float64
|
||||
for path, end := range b {
|
||||
start, ok := a[path]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := sub(end.usageUsec, start.usageUsec)
|
||||
if d == 0 {
|
||||
continue
|
||||
}
|
||||
pct := d / windowUsec * 100
|
||||
sumPct += pct
|
||||
unit := filepath.Base(path)
|
||||
var container string
|
||||
if id := containerID.FindStringSubmatch("/" + path); id != nil {
|
||||
if names == nil {
|
||||
names = m.containerNames()
|
||||
}
|
||||
container = id[1][:12]
|
||||
if n, ok := names[id[1]]; ok {
|
||||
container = n
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry{path: path, unit: unit, container: container, pct: pct})
|
||||
}
|
||||
|
||||
// Units are keyed by their cgroup's own path, never by its leaf name (filepath.Base): two cgroups can
|
||||
// share a leaf name (an outer "init.scope" and a user session's nested one), and keying by the name
|
||||
// alone would merge them into one row (review round 1 of novox/hq issue 741). Containers stay keyed
|
||||
// by their name, as before.
|
||||
groups := map[string]*CPUUsage{}
|
||||
for _, e := range entries {
|
||||
key := e.path
|
||||
if by == "container" {
|
||||
if e.container == "" {
|
||||
continue
|
||||
}
|
||||
key = e.container
|
||||
}
|
||||
g, ok := groups[key]
|
||||
if !ok {
|
||||
g = &CPUUsage{Unit: e.unit, Cgroup: e.path, Container: e.container}
|
||||
if by == "container" {
|
||||
g.Unit, g.Cgroup = "", ""
|
||||
}
|
||||
groups[key] = g
|
||||
}
|
||||
g.raw += e.pct
|
||||
}
|
||||
out := make([]CPUUsage, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, *g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].raw != out[j].raw {
|
||||
return out[i].raw > out[j].raw
|
||||
}
|
||||
return out[i].Unit+out[i].Cgroup+out[i].Container < out[j].Unit+out[j].Cgroup+out[j].Container
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
for i := range out {
|
||||
out[i].CPUPct = round1(out[i].raw)
|
||||
if cpusBusy > 0 {
|
||||
out[i].SharePct = round1(out[i].raw / (cpusBusy * 100) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
unaccounted := cpusBusy*100 - sumPct
|
||||
if unaccounted < 0 {
|
||||
unaccounted = 0
|
||||
}
|
||||
unaccountedPct := 0.0
|
||||
if cpusBusy > 0 {
|
||||
unaccountedPct = round1(unaccounted / (cpusBusy * 100) * 100)
|
||||
}
|
||||
return out, unaccountedPct
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// aCPUHog is a machine whose CPU is 75 % busy (1.5 of 2 CPUs) over a 2 s window: a unit, another unit,
|
||||
// and a container, each with a known usage_usec delta, leaving part of the machine's busy time
|
||||
// unaccounted — the shape of issue 739, where 750 % of 12 CPUs went to processes outside containers.
|
||||
func aCPUHog(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\ncpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\nintr 1\n")
|
||||
f.then("/proc/stat", "cpu 100 0 50 50 0 0 0 0 0 0\ncpu0 50 0 25 25 0 0 0 0 0 0\ncpu1 50 0 25 25 0 0 0 0 0 0\nintr 2\n")
|
||||
|
||||
zero := "usage_usec 0\nuser_usec 0\nsystem_usec 0\n"
|
||||
cg := func(path string, usec int) {
|
||||
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", zero)
|
||||
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\nuser_usec "+itoa(usec)+"\nsystem_usec 0\n")
|
||||
}
|
||||
cg("system.slice/alpha.service", 1000000) // 50 % of one CPU over 2 s
|
||||
cg("system.slice/beta.service", 400000) // 20 %
|
||||
cg("system.slice/docker-"+store+".scope", 600000) // 30 %, named "cpu-hog" by the runtime
|
||||
f.answers["docker ps --no-trunc --format {{.ID}}\t{{.Names}}"] = store + "\tcpu-hog\n"
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCPUTopRanksUnitsAndContainersByCPUOverTheWindow(t *testing.T) {
|
||||
f := aCPUHog(t)
|
||||
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if m["by"] != "unit" || f.waited != 2*time.Second {
|
||||
t.Errorf("the default: %+v, waited %s", m, f.waited)
|
||||
}
|
||||
cpu := m["cpu"].(MachineCPU)
|
||||
if cpu.CPUs != 2 || cpu.BusyPct != 75 || cpu.CPUsBusy != 1.5 {
|
||||
t.Errorf("machine cpu %+v", cpu)
|
||||
}
|
||||
top := m["top"].([]CPUUsage)
|
||||
if len(top) != 3 {
|
||||
t.Fatalf("three units moved: %+v", top)
|
||||
}
|
||||
if top[0].Unit != "alpha.service" || top[0].CPUPct != 50 || top[0].SharePct != 33.3 {
|
||||
t.Errorf("busiest first: %+v", top[0])
|
||||
}
|
||||
if top[1].Container != "cpu-hog" || top[1].CPUPct != 30 || top[1].SharePct != 20 {
|
||||
t.Errorf("the container, named by the runtime: %+v", top[1])
|
||||
}
|
||||
if top[2].Unit != "beta.service" || top[2].CPUPct != 20 || top[2].SharePct != 13.3 {
|
||||
t.Errorf("the least busy: %+v", top[2])
|
||||
}
|
||||
if m["unaccounted_pct"] != 33.3 {
|
||||
t.Errorf("unaccounted: %+v (machine busy 150%% of one CPU, units sum to 100%%, 50%% of 150%% left over)", m["unaccounted_pct"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTopByContainerNamesOnlyContainers(t *testing.T) {
|
||||
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "container", "seconds": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||
if len(top) != 1 || top[0].Container != "cpu-hog" || top[0].Unit != "" || top[0].CPUPct != 30 {
|
||||
t.Errorf("by container: %+v", top)
|
||||
}
|
||||
if _, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "process"}); err == nil {
|
||||
t.Error("by=process was accepted; cpu_top only ranks units and containers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTopLimitsHowManyAreNamed(t *testing.T) {
|
||||
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"limit": 1.0, "seconds": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
top := m["top"].([]CPUUsage)
|
||||
if len(top) != 1 || top[0].Unit != "alpha.service" {
|
||||
t.Errorf("limit 1: %+v", top)
|
||||
}
|
||||
// The limit narrows the list, never the unaccounted share, which is taken over every unit.
|
||||
if m["unaccounted_pct"] != 33.3 {
|
||||
t.Errorf("unaccounted with limit 1: %+v", m["unaccounted_pct"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTopLeavesOutACgroupThatAppearsOrVanishesWithinTheWindow(t *testing.T) {
|
||||
f := aCPUHog(t)
|
||||
f.then("/sys/fs/cgroup/system.slice/short-lived.service/cpu.stat", "usage_usec 500000\n")
|
||||
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||
for _, u := range top {
|
||||
if u.Unit == "short-lived.service" {
|
||||
t.Errorf("a unit only in the second reading was counted: %+v", top)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(f.calls, ","), "docker ps") {
|
||||
t.Errorf("the container runtime was never asked: %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// aTinyLoad is a machine with 12 CPUs, only 0.04 of them busy over a 2 s window (busy 1 of 300 total
|
||||
// ticks): rounded to one decimal for display, CPUsBusy reads 0.0, but a unit using half of that busy
|
||||
// time still has a non-zero share — review round 1's decision that share_pct and unaccounted_pct are
|
||||
// computed from the unrounded busy time, never from the rounded CPUsBusy.
|
||||
func aTinyLoad(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
cpus := "cpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\ncpu2 0 0 0 0 0 0 0 0 0 0\ncpu3 0 0 0 0 0 0 0 0 0 0\n" +
|
||||
"cpu4 0 0 0 0 0 0 0 0 0 0\ncpu5 0 0 0 0 0 0 0 0 0 0\ncpu6 0 0 0 0 0 0 0 0 0 0\ncpu7 0 0 0 0 0 0 0 0 0 0\n" +
|
||||
"cpu8 0 0 0 0 0 0 0 0 0 0\ncpu9 0 0 0 0 0 0 0 0 0 0\ncpu10 0 0 0 0 0 0 0 0 0 0\ncpu11 0 0 0 0 0 0 0 0 0 0\n"
|
||||
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\n"+cpus)
|
||||
f.then("/proc/stat", "cpu 1 0 0 299 0 0 0 0 0 0\n"+cpus)
|
||||
|
||||
f.file("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 0\n")
|
||||
f.then("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 60000\n") // 3 % of one CPU over 2 s
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCPUTopSharesAreNonZeroEvenWhenRoundedCPUsBusyIsZero(t *testing.T) {
|
||||
out, err := tool(t, aTinyLoad(t).machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
cpu := m["cpu"].(MachineCPU)
|
||||
if cpu.CPUs != 12 || cpu.CPUsBusy != 0 {
|
||||
t.Fatalf("the machine 0.04 CPUs busy, rounded for display: %+v", cpu)
|
||||
}
|
||||
top := m["top"].([]CPUUsage)
|
||||
if len(top) != 1 || top[0].Unit != "tiny.service" || top[0].CPUPct != 3 || top[0].SharePct != 75 {
|
||||
t.Fatalf("a non-zero share from the unrounded busy time: %+v", top)
|
||||
}
|
||||
if m["unaccounted_pct"] != 25.0 {
|
||||
t.Fatalf("unaccounted from the unrounded busy time: %+v (want 25, so shares plus unaccounted add to 100)",
|
||||
m["unaccounted_pct"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows is review round 1's other decision: units are keyed
|
||||
// by their cgroup path, never by filepath.Base, so two cgroups that both end in "init.scope" — one at the
|
||||
// root, one under a user's session — are two rows, not one merged by their shared leaf name.
|
||||
func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
|
||||
f := aCPUHog(t)
|
||||
cg := func(path string, usec int) {
|
||||
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec 0\n")
|
||||
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\n")
|
||||
}
|
||||
cg("init.scope", 1000000) // the root init, 50 % of one CPU over 2 s
|
||||
cg("user.slice/user@1000.service/init.scope", 400000) // a user session's init, 20 %
|
||||
|
||||
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0, "limit": 10.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||
var inits []CPUUsage
|
||||
for _, u := range top {
|
||||
if u.Unit == "init.scope" {
|
||||
inits = append(inits, u)
|
||||
}
|
||||
}
|
||||
if len(inits) != 2 {
|
||||
t.Fatalf("two cgroups named init.scope, merged into %d row(s): %+v", len(inits), top)
|
||||
}
|
||||
byCgroup := map[string]CPUUsage{}
|
||||
for _, u := range inits {
|
||||
byCgroup[u.Cgroup] = u
|
||||
}
|
||||
if u, ok := byCgroup["init.scope"]; !ok || u.CPUPct != 50 {
|
||||
t.Errorf("the root init.scope: %+v", byCgroup)
|
||||
}
|
||||
if u, ok := byCgroup["user.slice/user@1000.service/init.scope"]; !ok || u.CPUPct != 20 {
|
||||
t.Errorf("the user session's init.scope: %+v", byCgroup)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user