stack: several Mosaic Stack instances on one host (row 190, #1688)

--instance NAME on the shell entry points through scripts/instance.sh;
per-instance config, data root and marker, units, wrapper and ports;
the claim checks in mosaic-config.mjs; the guard protects every
instance's config and data root. Brief blob 4e8f1e0528, design
agents/darkwing/work/queue-190/DESIGN.md rev 3 (Filbert approved).
This commit is contained in:
jason.woltje committed 2026-10-20 16:30:34 -05:00
1 parent 176a6bf4b9
commit ab1d2ee9e6
24 files changed
+1490 -212

No files matched your search

+3 -1
View File
@@ -3,9 +3,11 @@
#
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
# absent. An existing configuration is validated, never modified.
# `--instance NAME` (row 190) creates ~/.config/mosaic-NAME/config.json
# instead, with the data root ~/.mosaic-NAME claimed by its instance.json.
# Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap:
# missing configuration is an error there, not something to invent.
set -euo pipefail
cd "$(dirname "$0")/.."
exec node scripts/mosaic-config.mjs bootstrap
exec node scripts/mosaic-config.mjs bootstrap "$@"
+31 -14
View File
@@ -19,12 +19,24 @@
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
# kept as given, so a symlink that tracks updates keeps tracking them.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is [email protected], it sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, and its session env file sits in that
# config directory. Busy and drop-in checks then cover only NAME's bus units
# and the prefix directories systemd applies to them. See scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$BUS_TEMPLATE"
USAGE="usage: scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -76,7 +88,7 @@ render() {
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local claude
claude="$(claude_line)" || exit 1
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -99,9 +111,10 @@ parse_flags() {
# Any failed read refuses before a write, including --no-reload installations.
install_guard() {
local out unit file
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
# shellcheck disable=SC2053
[[ "$unit" == $BUS_GLOB ]] || continue
case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac
done <<< "$out"
# systemctl show rejects a template name. The lookup-path scan also
@@ -114,8 +127,12 @@ install_guard() {
case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
# * includes the empty instance, i.e. [email protected].
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
# * includes the empty instance, i.e. [email protected] (or
# mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh.
local glob dropdirs=()
# shellcheck disable=SC2206
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
for file in "$dropdir"/*.conf; do
@@ -161,10 +178,10 @@ next, for one business (one per data root):
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
systemctl --user enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
systemctl --user status ${BUS_TEMPLATE%.service}<business>
journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
systemctl --user stop ${BUS_TEMPLATE%.service}<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -174,7 +191,7 @@ uninstall_unit() {
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
local out active
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
if [ -f "$target" ]; then
@@ -193,7 +210,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local business="$1"
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
local unit="mosaic-bus@$business.service"
local unit="${BUS_TEMPLATE%.service}$business.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
+20 -10
View File
@@ -14,12 +14,21 @@
# remove the unit; refuses while an instance is active
# status <binding> unit state, STOP and run.lock for one binding
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# Every verb takes --instance NAME (row 190, scripts/instance.sh): the unit is
# then [email protected], it sets NAME's MOSAIC_CONFIG, and status
# reads NAME's config. Without it, everything is as before.
#
# Exit codes: 0 ok, 1 operation failed, 3 instance claim refused, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$DISCORD_TEMPLATE"
UNIT_PREFIX="${DISCORD_TEMPLATE%.service}"
USAGE="usage: scripts/discord-service.sh [--instance NAME] render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
die() { echo "discord-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -48,7 +57,7 @@ render() {
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local unit
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "${INSTANCE_SED[@]}" "$TEMPLATE")" || return 1
if [ -n "$root" ]; then
local node
node="$(command -v node)"
@@ -101,10 +110,10 @@ install_unit() {
fi
cat <<MSG
next, for one binding:
systemctl --user enable --now mosaic-discord@<binding> start now and at login
systemctl --user status mosaic-discord@<binding>
journalctl --user -u mosaic-discord@<binding> -f the connector's log
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
systemctl --user status $UNIT_PREFIX<binding>
journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
systemctl --user stop $UNIT_PREFIX<binding> SIGTERM; restartable
scripts/discord.sh stop <binding> the brake: writes STOP, the unit stays down until STOP is removed
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
@@ -115,7 +124,7 @@ uninstall_unit() {
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
active="$(systemctl --user list-units --no-legend --plain "$UNIT_PREFIX*" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
if [ -f "$target" ]; then
@@ -134,7 +143,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local binding="$1"
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
local unit="mosaic-discord@$binding.service"
local unit="$UNIT_PREFIX$binding.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
@@ -151,6 +160,7 @@ status_unit() {
echo " checkout: $working"
fi
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG"
local data_root
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
if [ -z "$data_root" ]; then
+140
View File
@@ -0,0 +1,140 @@
# Sourced, not run: stack instance selection for the shell entry points (row 190).
#
# instance_args "$@" take `--instance NAME` out of the arguments. Sets
# INSTANCE_FLAG (NAME, or empty) and INSTANCE_ARGS (the
# rest, in order). A second --instance or a missing
# NAME exits 4.
# instance_select run `mosaic-config.mjs instance [--instance NAME]`,
# which maps NAME to ~/.config/mosaic-NAME/config.json
# and checks the data root's instance.json claim, then
# set the names below. A refusal exits with the
# loader's code (3 claim or missing config, 4 name or
# mismatch) before the caller reads or writes anything.
#
# With no flag, every name is today's and MOSAIC_CONFIG is left as the caller
# had it. With --instance NAME (other than dev), MOSAIC_CONFIG is exported as
# NAME's config, so everything the entry point runs reads that config.
#
# BUS_TEMPLATE [email protected] | [email protected]
# BUS_GLOB 'mosaic-bus@*.service' | 'mosaic-bus-NAME@*.service'
# BUS_DROPIN_GLOBS unit-path globs whose .conf files can override the bus
# ExecStart: mosaic-bus@*.service.d for the default;
# mosaic-bus-NAME@*.service.d and the shared prefix
# mosaic-bus-.service.d for NAME; then mosaic-.service.d
# and service.d for both
# CONSOLE_UNIT mosaic-webui.service | mosaic-webui-NAME.service
# DISCORD_TEMPLATE [email protected] | [email protected]
# WRAPPER_NAME mosaic | mosaic-NAME
# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env
# | <config dir>/secrets/claude-session.env
# INSTANCE_FLAGS () or (--instance NAME), for the entry points this one runs
# INSTANCE_RESERVED ports a named instance's Console never takes: 7330 (the
# operator's bare Console), 7332 (the tunnel) and 7333
# (the default's tester); empty for the default
# INSTANCE_SED sed arguments that fill @CONFIG_ENV@ and @SESSION_ENV@
# in a unit template: the default deletes the
# @CONFIG_ENV@ line, so its render is byte-identical to
# the template before row 190; NAME gets a comment and
# Environment=MOSAIC_CONFIG=<config>, and " [NAME]" after
# the Description
#
# Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG,
# MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag
# was given and the config is missing or invalid; the caller's own load
# refuses that as before) and MOSAIC_UNIT_SUFFIX (empty or -NAME).
INSTANCE_FLAG=""
INSTANCE_ARGS=()
INSTANCE_FLAGS=()
INSTANCE_RESERVED=()
instance_args() {
local seen=0
INSTANCE_FLAG=""
INSTANCE_ARGS=()
while [ $# -gt 0 ]; do
if [ "$1" = "--instance" ]; then
if [ "$seen" = 1 ] || [ $# -lt 2 ]; then
echo "instance: usage: --instance NAME, given once" >&2
exit 4
fi
INSTANCE_FLAG="$2"
seen=1
shift 2
else
INSTANCE_ARGS+=("$1")
shift
fi
done
}
instance_select() {
local here out
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "$INSTANCE_FLAG" ]; then
out="$(node "$here/mosaic-config.mjs" instance --instance "$INSTANCE_FLAG")" || exit $?
else
out="$(node "$here/mosaic-config.mjs" instance)" || exit $?
fi
eval "$out"
local s="$MOSAIC_UNIT_SUFFIX"
BUS_TEMPLATE="mosaic-bus$s@.service"
BUS_GLOB="mosaic-bus$s@*.service"
CONSOLE_UNIT="mosaic-webui$s.service"
DISCORD_TEMPLATE="mosaic-discord$s@.service"
WRAPPER_NAME="mosaic$s"
if [ -z "$s" ]; then
BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g")
return 0
fi
# systemd expands % and splits Environment= on spaces, and the sed
# expressions use |, so a named instance's paths keep to a plain set.
case "$MOSAIC_INSTANCE_CONFIG" in
*[!A-Za-z0-9/._+-]*)
echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2
exit 4 ;;
esac
export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}"
INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE")
INSTANCE_RESERVED=(7330 7332 7333)
BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env"
INSTANCE_SED=(
-e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|"
-e "s|@SESSION_ENV@|$SESSION_ENV|g"
-e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|"
)
}
# instance_port_holder DIR PORT: the installed Console unit in DIR, other than
# this instance's, whose ExecStart takes PORT as --port or --tester-port.
# A unit with no --port holds 7332, serve's default. Prints the unit and
# returns 0, or prints nothing and returns 1. Unit files are host
# configuration, so reading another instance's is not reading its state.
instance_port_holder() {
local dir="$1" want="$2" file unit line tok next port
want="$((10#$want))"
for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do
[ -f "$file" ] || continue
unit="${file##*/}"
[ "$unit" != "$CONSOLE_UNIT" ] || continue
line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)"
port=7332 next=""
local -a ports=() toks=()
read -r -a toks <<< "$line"
for tok in ${toks[@]+"${toks[@]}"}; do
case "$next" in
--port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;;
--tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;;
esac
next="$tok"
done
ports+=("$port")
for port in "${ports[@]}"; do
if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi
done
done
return 1
}
+290 -65
View File
@@ -8,13 +8,30 @@
* validate Load and strictly validate; print resolved config on stdout.
* env Print shell-safe exports for launcher scripts:
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
* instance Print shell-safe exports naming the selected stack instance
* (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG,
* MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and
* MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them.
*
* Config location: $MOSAIC_CONFIG or ~/.config/mosaic-dev/config.json
* Each operation takes an optional `--instance NAME`. NAME matches
* ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json;
* `dev` is the default instance, so `--instance dev` is the same as no flag.
* Without the flag the config is $MOSAIC_CONFIG or
* ~/.config/mosaic-dev/config.json, as before.
*
* A named instance's data root carries <dataRoot>/instance.json, written by
* `bootstrap --instance NAME`, naming the instance. With the flag every
* operation refuses a root whose marker is missing or names another
* instance; `instance` without the flag refuses a root that a named
* instance claims.
*
* Exit codes:
* 0 success
* 2 configuration exists but is invalid (never modified by this tool)
* 3 configuration is missing for a read operation (validate/env)
* 3 configuration is missing for a read operation (validate/env/instance),
* or the data root's instance marker refuses the selection
* 4 usage: a bad instance name (instance-name-invalid), or --instance and
* $MOSAIC_CONFIG naming different files (instance-config-mismatch)
*
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
* - Existing configuration is never overwritten.
@@ -33,16 +50,112 @@ const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
const SUPPORTED_BACKENDS = new Set(["docker"]);
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
const DEFAULT_INSTANCE = "dev";
const MARKER = "instance.json";
function fail(exitCode, message) {
process.stderr.write(`mosaic-config: ${message}\n`);
process.exit(exitCode);
class Failure extends Error {
constructor(exitCode, message) {
super(message);
this.exitCode = exitCode;
}
}
function configPath() {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: path.join(os.homedir(), ".config", "mosaic-dev", "config.json");
function fail(exitCode, message) {
throw new Failure(exitCode, message);
}
function instancePath(name) {
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
}
function realOrNull(file) {
try {
return fs.realpathSync(file);
} catch {
return null;
}
}
// The config file for a selection. Without --instance, as before. With it,
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
function configPath(instance) {
if (instance === null) {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: instancePath(DEFAULT_INSTANCE);
}
const named = instancePath(instance);
if (process.env.MOSAIC_CONFIG) {
const given = path.resolve(process.env.MOSAIC_CONFIG);
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
if (!same) {
fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`);
}
}
return named;
}
// Operation arguments: nothing, or `--instance NAME` once.
function parseArgs(args) {
if (args.length === 0) return null;
if (args.length !== 2 || args[0] !== "--instance") {
fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`);
}
const name = args[1];
if (!INSTANCE_PATTERN.test(name)) {
fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`);
}
return name;
}
function isNamed(instance) {
return instance !== null && instance !== DEFAULT_INSTANCE;
}
// The marker in a data root: null when absent, else its name. Anything else
// at the path refuses.
function readMarker(dataRoot) {
const file = path.join(dataRoot, MARKER);
let stat;
try {
stat = fs.lstatSync(file);
} catch (error) {
if (error.code === "ENOENT") return null;
fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`);
}
let marker;
try {
marker = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
}
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
}
return marker.name;
}
// The claim check (row 190 D2). A named instance needs its own marker; the
// default needs none, or one naming dev.
function checkClaim(dataRoot, instance) {
const owner = readMarker(dataRoot);
if (isNamed(instance)) {
if (owner === null) {
fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
}
if (owner !== instance) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
}
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`);
}
}
function readRaw(file) {
@@ -182,10 +295,11 @@ async function validate(document, file) {
};
}
async function load(file) {
async function load(file, instance = null) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
}
let document;
try {
@@ -200,67 +314,178 @@ function shellQuote(value) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
const DEFAULT_CONFIG = {
configVersion: 1,
environment: "development",
dataRoot: path.join(os.homedir(), ".mosaic-dev"),
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
function defaultConfig(dataRoot) {
return {
configVersion: 1,
environment: "development",
dataRoot,
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
}
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
// 'wx': creation is exclusive; an existing file is never overwritten, and a
// symbolic link at the path is never followed.
function createExclusive(file, text, label = file) {
let fd;
try {
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, text);
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
fail(1, `unable to create ${label}: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
async function bootstrapDefault(file) {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) {
await load(file);
return;
}
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
}
// Row 190 D2: NAME's config dir (0700), its config, then the claimed data
// root. The root is checked before anything is written, so a refusal
// writes nothing.
async function bootstrapNamed(file, instance) {
if (fs.existsSync(file)) {
const resolved = await load(file, instance); // validate only; never rewrite
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`);
let entries = null;
try {
const stat = fs.lstatSync(dataRoot);
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`);
entries = fs.readdirSync(dataRoot);
} catch (error) {
if (error instanceof Failure) throw error;
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`);
}
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
}
const configDir = path.dirname(file);
fs.mkdirSync(path.dirname(configDir), { recursive: true });
try {
fs.mkdirSync(configDir, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`);
}
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
const resolved = await load(file, instance);
checkClaim(resolved.dataRoot, instance);
return;
}
const resolved = await load(file, instance);
try {
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`);
}
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
}
// Without --instance, a missing or invalid config is left to the caller's
// own load, as before; only a loaded config's root is checked.
async function selected(instance) {
const file = configPath(instance);
let dataRoot = "";
if (isNamed(instance)) {
dataRoot = (await load(file, instance)).dataRoot;
checkClaim(dataRoot, instance);
} else {
let resolved = null;
try {
resolved = readRaw(file) === null ? null : await load(file);
} catch (error) {
if (!(error instanceof Failure)) throw error;
}
if (resolved !== null) {
dataRoot = resolved.dataRoot;
checkClaim(dataRoot, instance);
}
}
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
return [
`MOSAIC_INSTANCE=${shellQuote(name)}`,
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
"",
].join("\n");
}
async function loadSelected(instance) {
const file = configPath(instance);
const resolved = await load(file, instance);
if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
return resolved;
}
const operation = process.argv[2];
const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
try {
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`);
}
const instance = parseArgs(process.argv.slice(3));
switch (operation) {
case "bootstrap": {
const file = configPath(instance);
if (isNamed(instance)) await bootstrapNamed(file, instance);
else await bootstrapDefault(file);
process.exit(0);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
let fd;
try {
// 'wx': creation is exclusive; an existing file is never overwritten.
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
await load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
case "validate": {
const resolved = await loadSelected(instance);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = await load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await loadSelected(instance);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
}
case "env": {
const resolved = await load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
case "instance": {
process.stdout.write(await selected(instance));
process.exit(0);
}
}
default:
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
} catch (error) {
if (!(error instanceof Failure)) throw error;
process.stderr.write(`mosaic-config: ${error.message}\n`);
process.exit(error.exitCode);
}
+34 -9
View File
@@ -1,20 +1,22 @@
#!/usr/bin/env bash
# One immutable pushed tree for the bus, Console and human CLI (#1579).
# copy creates files only; wrapper is a separately Mos-gated live step.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
BUSINESS=mosaic-stack
BUSINESS=mosaic-stack BUSINESS_SET=0
ALLOW_WORKTREE=0
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--copies DIR] [--bin DIR] | check [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; }
@@ -64,6 +66,7 @@ copy_commit() {
echo "copied: $target"
}
wrapper() {
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
plain_path "$BIN" 'bin directory'
local root file lease tmp
root="$(copy_root)";file="$BIN/mosaic"
@@ -77,6 +80,22 @@ wrapper() {
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another.
# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is.
wrapper_instance() {
plain_path "$BIN" 'bin directory'
local root file tmp
root="$(copy_root)";file="$BIN/$WRAPPER_NAME"
if [ -e "$file" ] || [ -L "$file" ]; then
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file"
[ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file"
fi
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$root" > "$tmp"
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
unit_root() {
local out load root exec_start
out="$(systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
@@ -84,17 +103,18 @@ unit_root() {
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
case "$1" in
mosaic-bus@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-bus@*|mosaic-bus-*@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
esac
echo "$root"
}
check() {
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
local bus console cli=absent line path
bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)"
if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then
line="$(tail -n 1 "$BIN/mosaic")"
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
line="$(tail -n 1 "$wfile")"
path="${line#exec \"}";path="${path%\" \"\$@\"}"
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
fi
@@ -103,10 +123,15 @@ check() {
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
CMD="$1";shift
case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac
INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)"
case "$CMD" in
copy|wrapper) [ $# -gt 0 ] || usage;ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@";if [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
check) parse "$@";check;;
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
-h|--help) usage 0;;
*) usage;;esac
+67 -31
View File
@@ -24,11 +24,11 @@ set -euo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source scripts/common.sh
source scripts/instance.sh
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
@@ -41,7 +41,9 @@ usage: stack-release.sh adopt BUSINESS
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
common options: --copies DIR, --bin DIR
common options: --copies DIR, --bin DIR, --instance NAME
--instance NAME (row 190) runs on stack instance NAME: its config, data root, units and the wrapper
mosaic-NAME. Without it, the default instance, as before.
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
USAGE
@@ -69,7 +71,10 @@ refuse() {
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
valid_tester_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ]
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] || return 1
# Row 190: a named instance's Console never takes 7330, 7332 or 7333.
local r
for r in "${INSTANCE_RESERVED[@]}"; do [ "$((10#$1))" -ne "$r" ] || return 1; done
}
parse_opts() {
@@ -416,16 +421,17 @@ capture() {
mkdir -m 0700 "$RS" 2>/dev/null || return 1
STEPS="$RS/steps.log"
local u d
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; }
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
done
for d in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
# This instance's own directories only; the shared prefix ones are never captured (row 190).
for d in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1
done
[ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; }
cp -p "$BIN/mosaic" "$RS/wrapper" || return 1
stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || return 1
[ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; }
cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1
stat -c %a "$BIN/$WRAPPER_NAME" > "$RS/WRAPPER_MODE" || return 1
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done
sums "$RS" > "$RS/SHA256SUMS" || return 1
say "captured the rollback set: $RS"
@@ -458,9 +464,9 @@ install_stack() {
local id="$1" pm="$2" claude="$3"; shift 3
local -a pmflag=()
[ "$pm" = 0 ] || pmflag=(--pm)
scripts/bus-service.sh install --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
say "installed $id"
}
@@ -486,7 +492,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" ||
node -e '
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$(console_port)" || return 1
GATE=copy-check
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
# check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3).
out="$(sed -n 's/^bus: //p' <<< "$out")"
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
@@ -533,10 +539,10 @@ gate() {
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
put_back() {
local u f rel tmp
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1
done
for u in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
for u in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ -d "$RS/$u" ] || continue
while IFS= read -r -d '' f; do
rel="${f#"$RS/$u/"}"
@@ -545,12 +551,12 @@ put_back() {
tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1
done < <(find "$RS/$u" -type f -print0)
done
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1
for u in [email protected] "$CONSOLE_UNIT"; do
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" || return 1
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
done
[ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
systemctl --user daemon-reload || return 1
say "put back the unit files and the wrapper from $RS"
}
@@ -607,9 +613,9 @@ lock() {
instance_busy() {
local out unit load state rest
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
[[ "$unit" == $BUS_GLOB ]] || continue
if [ "$unit" = "$BUS_UNIT" ]; then
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
else
@@ -621,14 +627,17 @@ instance_busy() {
# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read
# refuses here, as install_guard would refuse it after the stop (row 166 item 1).
drop_in_override() {
local paths directory dropdir file content
local paths directory dropdir file content glob
local -a dropdirs
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown"
while IFS= read -r directory; do
[ -n "$directory" ] || continue
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory"
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
dropdirs=()
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
for file in "$dropdir"/*.conf; do
@@ -684,6 +693,7 @@ console_for_target() {
[ "$PLAN" = 1 ] || usage
local other=""
[ "$main" = 7332 ] || other=" and the Console's --port $main"
[ "${#INSTANCE_RESERVED[@]}" = 0 ] || other+=" and instance $MOSAIC_INSTANCE's reserved ${INSTANCE_RESERVED[*]}"
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
@@ -813,10 +823,30 @@ proof_file_problem() {
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
# Row 190: the target Console's --port (7332 when absent) and --tester-port against every other
# installed Console unit in UNIT_DIR, the default's and each named instance's.
ports_unclaimed() {
local main=7332 i holder
local -a ports=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--port) main="${RUN_CONSOLE[i + 1]:-7332}" ;;
--tester-port) ports+=("${RUN_CONSOLE[i + 1]:-}") ;;
esac
done
for i in "$main" "${ports[@]}"; do
[[ "$i" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$i")"; then
check_refuse port-claimed "port $i is taken by $UNIT_DIR/$holder"
fi
done
}
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
precheck() {
check_target
console_for_target
ports_unclaimed
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -857,7 +887,7 @@ do_adopt() {
lock
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
local out
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
TO="$(sed -n 's/^bus: //p' <<< "$out")"
TO="${TO#"$COPIES/"}"
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -970,12 +1000,13 @@ plan() {
plan_commits
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
local flags="${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
@@ -1047,7 +1078,7 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
new_activation
@@ -1059,7 +1090,7 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
@@ -1153,7 +1184,7 @@ do_pm_on() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
trap 'pm_on_fail signal' INT TERM HUP
stop_bus || pm_on_fail stop-timeout
scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
say "installed $BUS_UNIT for $P_COPY with --pm"
start_bus || pm_on_fail start
gate "$P_COPY" 1 || pm_on_fail "$GATE"
@@ -1173,7 +1204,7 @@ do_status() {
open="$(interrupted | paste -sd' ' -)"
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
echo "check:"
if scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
if scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
[ -n "$P_COPY" ] || return 0
echo "bus status:"
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
@@ -1437,6 +1468,7 @@ do_rehearse() {
local -a reserved=(7332 7333 "$(console_port)")
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
if i="$(instance_port_holder "$UNIT_DIR" "$R_PORT")"; then r_abort port-reserved "--port $R_PORT is $UNIT_DIR/$i's Console port"; fi
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
r_pass port "127.0.0.1:$R_PORT is free"
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
@@ -1489,6 +1521,9 @@ do_rehearse() {
# --- Dispatch ---
# Row 190: --instance NAME may come anywhere; instance.sh takes it out and sets the names.
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
OP="$1"; shift
ID=""
@@ -1501,13 +1536,14 @@ esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
parse_opts "$@"
COPIES_DIR="$COPIES"
instance_select
load_config
DATA="$MOSAIC_DATA_ROOT"
STATE="$DATA/state"
POINTER="$STATE/stack-active.json"
LOG="$STATE/stack-activation-log.jsonl"
STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
+5
View File
@@ -170,6 +170,10 @@ check "scripts/discord.sh reload without a binding exits 4" $?
# --- the service unit: rendered from the template, never touching systemd here ---
UNITS="$SANDBOX/units"
# Row 190: discord-service.sh reads the selected config first, so these runs name a scratch one
# that does not exist (the default's names, no data root) instead of ~/.config/mosaic-dev.
CALLER_MOSAIC_CONFIG="${MOSAIC_CONFIG-}"
export MOSAIC_CONFIG="$SANDBOX/absent/config.json"
scripts/discord-service.sh >/dev/null 2>&1
[ $? -eq 4 ]
check "scripts/discord-service.sh without a command exits 4" $?
@@ -246,6 +250,7 @@ HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake-
check "status names the copy from fake systemctl effective cwd" $?
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
check "checkout install explicitly recommends --copy" $?
if [ -n "$CALLER_MOSAIC_CONFIG" ]; then export MOSAIC_CONFIG="$CALLER_MOSAIC_CONFIG"; else unset MOSAIC_CONFIG; fi
# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
node - "$SANDBOX" <<'EOF_OFFLINE'
+62 -17
View File
@@ -65,12 +65,27 @@
# --no-reload: a unit rewritten under a running Console would take the new
# ExecStart at its next Restart=on-failure, with nobody starting it.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@,
# and render and install need --port, which may not be 7330, 7332 or 7333
# (exit 4, port-reserved). For every instance, install refuses a --port or
# --tester-port that another installed Console unit in the unit directory
# already takes (port-claimed). See scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh [--instance NAME] copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -86,6 +101,7 @@ ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
BUSINESS=""
@@ -108,7 +124,7 @@ parse_flags() {
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
@@ -127,6 +143,31 @@ parse_flags() {
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
# Row 190: a named instance's Console names its own port, outside the
# default's ports. Called by render and install only.
instance_ports_ok() {
[ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0
[ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; }
local p r
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
for r in "${INSTANCE_RESERVED[@]}"; do
[ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; }
done
done
}
# Row 190: refuse a port another installed Console unit already takes.
ports_unclaimed() {
local p holder
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then
die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed"
fi
done
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
@@ -259,7 +300,7 @@ render() {
local flags
flags="$(serve_flags)" || exit 1
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
# The unit's active state from list-units, printed when it is anything but
@@ -279,16 +320,17 @@ running() {
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. Like the state read, a failed call
# has loaded, and none loaded refuses. A named instance reads its own
# mosaic-bus-NAME@ units only. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
units="$BUS_UNIT_PREFIX$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')"
[ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
@@ -308,13 +350,15 @@ install_unit() {
parse_flags "$@"
canonical
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
instance_ports_ok
local unit
unit="$(render)" || exit 1
ports_unclaimed
bus_on_copy "$COPIES/$COPY_ID"
# N1: a running unit would take the new ExecStart at its next restart.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service}), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -336,11 +380,11 @@ install_unit() {
this script starts nothing: the Console holds the human's capability. Jason
starts and restarts it, or Mos under lead decision 102 (sends then enter the
trail as Jason); no other agent does:
systemctl --user enable --now mosaic-webui start now and at login
systemctl --user status mosaic-webui
journalctl --user -u mosaic-webui -f the Console's log
systemctl --user stop mosaic-webui SIGTERM; restartable
scripts/webui-service.sh status unit state and /healthz
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
systemctl --user status ${UNIT_NAME%.service}
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }status unit state and /healthz
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -352,7 +396,7 @@ uninstall_unit() {
# N3: checked with --no-reload too, which skips only daemon-reload.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service})"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -439,6 +483,7 @@ case "$cmd" in
parse_flags "$@"
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
instance_ports_ok
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;