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:
1 parent
176a6bf4b9
commit
ab1d2ee9e6
24 files changed
+1490
-212
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=()
|
||||
|
||||
@@ -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
@@ -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 "$@" ;;
|
||||
|
||||
Reference in new issue
Block a user