#!/usr/bin/env bash # Case 2 row C2-13b (design 10 step 2b): a hub's install, run as # 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] # # --org ID is the same option as --business ID, and --org-file FILE the same # as --business-file FILE. They are two families: each spelling once, and a # second spelling of a family must carry the same text. # # 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 /secrets//; `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 # /.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; } # shellcheck source=scripts/selector.sh . "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; } # The selectors (rename design r2): the id family and the file family are separate scopes. A # missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a # flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before. id_refused() { case "$1" in missing) usage ;; flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;; *) echo "case2-install: $2" >&2; exit 4 ;; esac } file_refused() { case "$1" in missing) usage ;; *) echo "case2-install: $2" >&2; exit 4 ;; esac } business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; } PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}" while [ $# -gt 0 ]; do case "$1" in --business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;; --business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; 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 here. [ "$(id -u)" != 0 ] || refuse 'run-as-root: run the install as , 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}-chief@mosaic.invalid`, 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 (add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3." if [ "$PROFILE" = library ]; then say "Then Jason places the library token in $SLOT (step 4), and runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)." fi exit 0