236 lines
14 KiB
Bash
Executable File
236 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Case 2 row C2-13b (design 10 step 2b): a hub's install, run as <user>
|
|
# after root's 2a and before root's 2c renders the units.
|
|
#
|
|
# case2-install.sh --business ID --business-file FILE --commit SHA
|
|
# [--profile factory|library] [--personas DIR] [--home DIR]
|
|
#
|
|
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
|
|
# SHA, whose npm ci installs the pinned harness; for library, the persona
|
|
# import's install from the staging DIR; the business file at 0600; for
|
|
# library, the empty 0700 slot dir <configDir>/secrets/<id>/; `mosaic
|
|
# business validate`; then the next steps. It runs no release package or
|
|
# activate, starts no unit, writes no unit file and nothing under /etc, and
|
|
# never writes the library token, which is Jason's step 4.
|
|
#
|
|
# FILE is the business document written for the host from the go. For a library,
|
|
# the script adds chief identity/publish and roles.bean.vars and roles.chet.vars persona.name and
|
|
# persona.sha256, from the staging manifest, and checks the input against
|
|
# the precheck's profile rules first: library needs a library project,
|
|
# bus.tester off and bean and chet as companion roles with no persona var;
|
|
# factory needs no library project and bus.tester on.
|
|
#
|
|
# Every refusal it can see before writing comes first, so a refused run
|
|
# writes nothing. A re-run passes when the business file and personas on
|
|
# disk match what it would write, and refuses business-file-differs or
|
|
# persona-differs when they don't, touching neither. If validate refuses,
|
|
# the business file goes only when this run created it.
|
|
#
|
|
# --home must be $HOME. The config dir is dirname of $MOSAIC_CONFIG, or
|
|
# <home>/.config/mosaic-dev; the install makes it 0700 when absent and
|
|
# refuses config-dir-mode when it has a group or other bit.
|
|
#
|
|
# Test seam, for packages/cli/tests/case2-install.test.mjs only:
|
|
# CASE2_STACK_COPY the copy script, default scripts/stack-copy.sh
|
|
#
|
|
# Exit 0 ok, 1 refused, 4 usage.
|
|
set -uo pipefail
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
|
|
|
|
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
|
|
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
|
|
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
|
|
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
|
|
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
|
|
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
|
|
--home) [ $# -ge 2 ] || usage; HOME_ARG="$2"; shift 2 ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
[ -n "$BUSINESS" ] && [ -n "$BFILE" ] && [ -n "$COMMIT" ] || usage
|
|
case "$PROFILE" in factory|library) ;; *) echo "case2-install: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
|
|
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || { echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4; }
|
|
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "case2-install: --commit must be a full 40-character commit id: $COMMIT" >&2; exit 4; }
|
|
if [ "$PROFILE" = library ] && [ -z "$PERSONAS" ]; then echo 'case2-install: the library profile needs --personas DIR' >&2; exit 4; fi
|
|
if [ "$PROFILE" = factory ] && [ -n "$PERSONAS" ]; then echo 'case2-install: the factory profile takes no --personas' >&2; exit 4; fi
|
|
|
|
say() { echo "case2-install: $1"; }
|
|
refuse() { echo "case2-install: refused: $1" >&2; exit 1; }
|
|
mode_of() { stat -c '%a' -- "$1"; }
|
|
# An existing directory, not a link, owned by this uid, with no group or other bit.
|
|
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
|
|
# An absent path, or a private directory; anything else refuses CODE.
|
|
absent_or_private() {
|
|
if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi
|
|
private_dir "$1" || refuse "$2: $1 must be $(id -un)'s directory with no group or other bit"
|
|
}
|
|
|
|
# 1. Never as root: root owns nothing under <home> here.
|
|
[ "$(id -u)" != 0 ] || refuse 'run-as-root: run the install as <user>, not root'
|
|
[ -n "${HOME:-}" ] && [ "$HOME_ARG" = "$HOME" ] || refuse "home-mismatch: --home $HOME_ARG is not \$HOME ${HOME:-unset}"
|
|
CONFIG="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}"
|
|
case "$CONFIG" in /*) ;; *) refuse "config-path: MOSAIC_CONFIG must be an absolute path: $CONFIG" ;; esac
|
|
CONFIG_DIR="$(dirname -- "$CONFIG")"
|
|
SECRETS="$CONFIG_DIR/secrets"
|
|
SLOT="$SECRETS/$BUSINESS"
|
|
COPIES="$HOME/.local/share/mosaic-stack/copies"
|
|
absent_or_private "$CONFIG_DIR" config-dir-mode
|
|
if [ "$PROFILE" = library ]; then
|
|
absent_or_private "$SECRETS" secrets-mode
|
|
absent_or_private "$SLOT" slot-mode
|
|
fi
|
|
|
|
# The plan and the write share one helper. plan checks the input, the
|
|
# staging manifest and what is on disk, and prints one decision per line;
|
|
# write makes the business file with O_EXCL. Neither prints persona text.
|
|
HELPER='
|
|
import { closeSync, constants, fchmodSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
const [verb, id, input, profile, staging, configDir] = process.argv.slice(1);
|
|
const say = (...f) => console.log(f.join("\t"));
|
|
const stop = (code, message) => { say("REFUSE", `${code}: ${message}`); process.exit(0); };
|
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
let doc;
|
|
try { doc = JSON.parse(readFileSync(input, "utf8")); } catch (e) { stop("business-file-input", `cannot read ${input} as JSON (${e.code ?? "parse"})`); }
|
|
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) stop("business-file-input", `${input} is not a JSON object`);
|
|
if (doc.id !== id) stop("business-id-mismatch", `${input} has id ${JSON.stringify(doc.id)}, not ${id}`);
|
|
const projects = doc.projects && typeof doc.projects === "object" ? doc.projects : {};
|
|
const library = Object.keys(projects).filter((k) => projects[k]?.kind === "library");
|
|
const tester = doc.vars?.["bus.tester"] === true;
|
|
let personas = [];
|
|
if (profile === "factory") {
|
|
if (library.length) stop("profile-library-business", `the factory profile refuses library projects ${library.join(",")}`);
|
|
if (!tester) stop("bus-tester-off", "the factory profile needs vars bus.tester true");
|
|
} else {
|
|
if (!library.length) stop("no-library-project", `${input} has no library project`);
|
|
if (tester) stop("bus-tester-on", "the library profile needs bus.tester off");
|
|
// C2-21: the chief mints its own read grant after install; no token or grant is created here.
|
|
const chief = Object.values(doc.roles ?? {}).find((r) => r?.definition === "chief-of-staff");
|
|
if (!chief) stop("chief-missing", "the library profile needs a chief-of-staff instance");
|
|
const project = projects.books?.kind === "library" ? "books" : library[0];
|
|
chief.identity ??= { name: "Chief of staff", email: `${id}[email protected]`,
|
|
gitea: { url: new URL(projects[project].remote).origin, user: `${id}-chief` } };
|
|
chief.publish = [project];
|
|
for (const instance of ["bean", "chet"]) {
|
|
const role = doc.roles?.[instance];
|
|
if (role?.definition !== "companion") stop("companion-roles", `roles.${instance} must have definition companion`);
|
|
if (Object.keys(role.vars ?? {}).some((k) => k.startsWith("persona."))) stop("persona-var-input", `roles.${instance}.vars sets a persona var; the install adds them`);
|
|
}
|
|
let manifest;
|
|
try { manifest = JSON.parse(readFileSync(join(staging, "manifest.json"), "utf8")); } catch (e) { stop("persona-manifest", `cannot read ${join(staging, "manifest.json")} (${e.code ?? "parse"})`); }
|
|
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : [];
|
|
const want = (instance) => entries.filter((m) => m?.instance === instance);
|
|
for (const instance of ["bean", "chet"]) {
|
|
const m = want(instance);
|
|
if (entries.length !== 2 || m.length !== 1 || m[0].name !== instance[0].toUpperCase() + instance.slice(1) || !/^[0-9a-f]{64}$/.test(m[0].sha256 ?? ""))
|
|
stop("persona-manifest", "the staging manifest does not list Bean and Chet once each with a sha256");
|
|
personas.push(m[0]);
|
|
}
|
|
for (const m of personas) doc.roles[m.instance].vars = { ...(doc.roles[m.instance].vars ?? {}), "persona.name": m.name, "persona.sha256": m.sha256 };
|
|
}
|
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
const file = join(configDir, "businesses", `${id}.json`);
|
|
if (verb === "write") {
|
|
let fd;
|
|
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
catch (e) { stop("business-file-raced", `${file} could not be made new (${e.code})`); }
|
|
fchmodSync(fd, 0o600);
|
|
writeSync(fd, text);
|
|
closeSync(fd);
|
|
say("WROTE", file);
|
|
process.exit(0);
|
|
}
|
|
// plan: the personas on disk, then the business file on disk.
|
|
if (profile === "library") {
|
|
const dir = join(configDir, "personas", id);
|
|
const seen = personas.map((m) => {
|
|
const p = join(dir, `${m.instance}.md`);
|
|
const s = lstatSync(p, { throwIfNoEntry: false });
|
|
if (!s) return "absent";
|
|
return s.isFile() && sha256(readFileSync(p)) === m.sha256 ? "same" : "differs";
|
|
});
|
|
if (seen.every((s) => s === "absent")) say("PERSONAS", "install");
|
|
else if (seen.every((s) => s === "same")) say("PERSONAS", "present");
|
|
else stop("persona-differs", `the persona files under ${dir} are not the staged pair; nothing written`);
|
|
for (const m of personas) say("PERSONA", m.instance, m.name, m.sha256);
|
|
}
|
|
const s = lstatSync(file, { throwIfNoEntry: false });
|
|
if (!s) say("BUSINESS", "new", file);
|
|
else if (s.isFile() && !s.isSymbolicLink() && readFileSync(file, "utf8") === text) say("BUSINESS", "same", file);
|
|
else stop("business-file-differs", `${file} exists and differs from the one this install writes; nothing written`);
|
|
'
|
|
helper() { (cd "$REPO" && node --input-type=module -e "$HELPER" "$1" "$BUSINESS" "$BFILE" "$PROFILE" "$PERSONAS" "$CONFIG_DIR" 2>&1); }
|
|
field() { printf '%s\n' "$plan" | awk -F'\t' -v k="$1" '$1 == k { print $2 }'; }
|
|
|
|
plan="$(helper plan)" || refuse "plan: the check failed: $(printf '%s' "$plan" | head -n 1)"
|
|
r="$(printf '%s\n' "$plan" | awk -F'\t' '$1 == "REFUSE" { print $2; exit }')"
|
|
[ -z "$r" ] || refuse "$r"
|
|
BUSINESS_STATE="$(field BUSINESS)"
|
|
[ -n "$BUSINESS_STATE" ] || refuse "plan: no decision: $(printf '%s' "$plan" | head -n 1)"
|
|
PERSONA_STATE="$(field PERSONAS)"
|
|
|
|
# 2. bootstrap: idempotent, and it never rewrites an existing config.
|
|
if [ ! -e "$CONFIG_DIR" ]; then
|
|
mkdir -p -- "$(dirname -- "$CONFIG_DIR")" && mkdir -m 700 -- "$CONFIG_DIR" || refuse "config-dir: cannot make $CONFIG_DIR"
|
|
fi
|
|
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
|
|
say "config $CONFIG"
|
|
|
|
# 3. The stack copy row 195's units run from.
|
|
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
|
|
[ -f "$COPIES/$COMMIT/scripts/mosaic" ] || refuse "stack-copy: no copy $COMMIT in $COPIES after the copy step"
|
|
say "copy $COPIES/$COMMIT"
|
|
|
|
# 4. library: the personas, from the staging dir.
|
|
if [ "$PROFILE" = library ]; then
|
|
if [ "$PERSONA_STATE" = install ]; then
|
|
out="$(node "$COPIES/$COMMIT/scripts/case2-persona-import.mjs" install --staging "$PERSONAS" --config-dir "$CONFIG_DIR" --business "$BUSINESS" 2>&1)" \
|
|
|| refuse "persona-import: $(printf '%s\n' "$out" | awk '/^[[:alnum:]_]*Error|^ERR_/ { print; found=1; exit } { last=$0 } END { if (!found) print last }')"
|
|
while IFS=$'\t' read -r _ inst name sum; do
|
|
printf '%s\n' "$out" | grep -qxF "instance $inst: persona.name $name, persona.sha256 $sum" \
|
|
|| refuse "persona-import: the import did not report $inst with the manifest's sha256"
|
|
done < <(printf '%s\n' "$plan" | awk -F'\t' '$1 == "PERSONA"')
|
|
say "personas installed under $CONFIG_DIR/personas/$BUSINESS"
|
|
else
|
|
say "personas already installed under $CONFIG_DIR/personas/$BUSINESS"
|
|
fi
|
|
fi
|
|
|
|
# 5. The business file, at 0600 and new, unless an equal one is there.
|
|
CREATED=0
|
|
if [ "$BUSINESS_STATE" = new ]; then
|
|
mkdir -p -m 700 -- "$CONFIG_DIR/businesses" || refuse "business-file: cannot make $CONFIG_DIR/businesses"
|
|
w="$(helper write)" || refuse "business-file: $(printf '%s' "$w" | head -n 1)"
|
|
case "$w" in WROTE*) CREATED=1 ;; REFUSE*) refuse "${w#REFUSE }" ;; *) refuse "business-file: $(printf '%s' "$w" | head -n 1)" ;; esac
|
|
say "business file $CONFIG_DIR/businesses/$BUSINESS.json written"
|
|
else
|
|
say "business file $CONFIG_DIR/businesses/$BUSINESS.json already matches"
|
|
fi
|
|
|
|
# 6. library: the empty slot dir. The token is Jason's, design 10 step 4.
|
|
if [ "$PROFILE" = library ]; then
|
|
[ -e "$SECRETS" ] || mkdir -m 700 -- "$SECRETS" || refuse "slot: cannot make $SECRETS"
|
|
[ -e "$SLOT" ] || mkdir -m 700 -- "$SLOT" || refuse "slot: cannot make $SLOT"
|
|
private_dir "$SLOT" || refuse "slot-mode: $SLOT must be a 0700 directory"
|
|
say "slot dir $SLOT"
|
|
fi
|
|
|
|
# 7. validate, the business package's own schema.
|
|
if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
|
|
if [ "$CREATED" = 1 ]; then rm -f -- "$CONFIG_DIR/businesses/$BUSINESS.json"; fi
|
|
refuse "validate: $(printf '%s' "$out" | tail -n 1)$( [ "$CREATED" = 1 ] && echo '; the business file this run wrote is removed')"
|
|
fi
|
|
say "business $BUSINESS validates"
|
|
|
|
# 8. What comes next. Root's steps run from root's own checkout.
|
|
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (design 10 step 2c), then 2d and 3."
|
|
if [ "$PROFILE" = library ]; then
|
|
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
|
|
fi
|
|
exit 0
|