cf-node-bootstrap/lib/cf-apps.functions
pyr0ball e33d6bb1ad feat: record install-time state, show CircuitForge node info in quickinfo
- lib/cf-apps.functions: cf-provision-node/install-cf-app/
  provision-orchard-node now write to ~/.config/cf-node-bootstrap/state.conf
  (record-cf-node-state) - provisioning profile, each installed app
  (name:install_type:clone_dir), and orchard role/coordinator URL. The
  orchard role/coordinator is read back from circuitforge-orch's own
  generated ~/.config/circuitforge/cf-orch.env rather than tracked
  separately, since that's the actual source of truth for what its
  interactive installer decided.
- 11-quickinfo.bashrc: new "CircuitForge Node" section (show_cf_node_info,
  default true) displaying the provisioning profile, orchard role/
  coordinator if joined, and each installed app with a best-effort
  running/stopped status. Docker-compose apps are checked by compose
  project from their clone dir rather than by container name, since
  compose's generated container names don't match the app name directly.
  Section is entirely absent if the state file doesn't exist yet (i.e.
  the CF Apps menu has never been run on this node).
2026-07-17 21:16:07 -07:00

290 lines
10 KiB
Bash

#!/bin/bash
# CF Apps registry + interactive install dispatcher for cf-node-bootstrap.
#
# Manifests live in cf-apps/*.manifest (see cf-apps/README.md for the
# schema). Design rationale, including the oem/collaborator/orchard
# provisioning-profile split, is in:
# circuitforge-plans/cf-node-bootstrap/superpowers/plans/2026-07-17-cf-apps-install-menu.md
cf_apps_manifest_dir="${cf_apps_manifest_dir:-${rundir}/cf-apps}"
# State file recording what this installer has set up on this node, so
# lib/skel/.bashrc.d/11-quickinfo.bashrc can report on it. Plain
# sourceable key=value pairs (values %q-escaped), one write-or-replace
# per key - not a general config format, just enough for quickinfo to read.
cf_node_state_file="${cf_node_state_file:-${HOME}/.config/cf-node-bootstrap/state.conf}"
record-cf-node-state() {
local key="$1" value="$2"
mkdir -p "$(dirname "$cf_node_state_file")"
touch "$cf_node_state_file"
if [ -s "$cf_node_state_file" ]; then
grep -v "^${key}=" "$cf_node_state_file" > "${cf_node_state_file}.tmp"
mv "${cf_node_state_file}.tmp" "$cf_node_state_file"
fi
printf '%s=%q\n' "$key" "$value" >> "$cf_node_state_file"
}
# Resets the app_* manifest variables/functions so sourcing one manifest
# can't leak stale values into the next.
_cf-apps-reset-manifest-vars() {
unset app_name app_desc app_repo_url_github app_repo_url_forgejo \
app_available_profiles app_install_types app_conda_env \
app_env_template app_needs_core
unset -f app_setup_bare_metal app_setup_docker app_setup_podman 2>/dev/null
}
# Prompts for a base directory to clone CF app repos into; sets $cf_apps_dir.
prompt-cf-apps-dir() {
local default_dir="${HOME}/CircuitForge"
boxborder "${lyl}Where should CircuitForge app repos be cloned?${dfl} ${gry}(default: ${default_dir})${dfl}"
local input_dir
read -r input_dir
cf_apps_dir="${input_dir:-$default_dir}"
}
# Yes/no gate + entry point, mirroring install.sh's extras-menu pattern.
# Call this from install.sh's userinstall/globalinstall alongside extras-menu.
cf-provision-menu() {
boxborder "${lyl}Set up this node for CircuitForge apps?${dfl} ${gry}(product installs or joining an orchard)${dfl}"
local cfapps_menu=(
"$(boxline "${green_check} Yes")"
"$(boxline "${red_x} No")"
)
case `select_opt "${cfapps_menu[@]}"` in
0) prompt-cf-apps-dir
cf-provision-node
;;
1) boxline "Skipping CircuitForge app provisioning" ;;
esac
}
# Populates parallel arrays describing every available app manifest.
# Call before cf-apps-menu.
load-cf-apps() {
cf_app_names=()
cf_app_descs=()
cf_app_profiles=()
local manifest
for manifest in "${cf_apps_manifest_dir}"/*.manifest; do
[ -f "$manifest" ] || continue
_cf-apps-reset-manifest-vars
source "$manifest"
cf_app_names+=("$app_name")
cf_app_descs+=("$app_desc")
cf_app_profiles+=("${app_available_profiles[*]}")
done
}
# Returns success if $1 (a space-joined profile list) contains $2
_cf-apps-profile-allows() {
local profiles=" $1 " want="$2"
[[ "$profiles" == *" $want "* ]]
}
# Prompts for what kind of node this is; sets $cf_provision_profile to
# oem|collaborator|orchard. Gates both which apps are offered and which
# remote (public GitHub vs private Forgejo) they're cloned from.
select-provision-profile() {
boxborder "${lyl}${unl}What kind of node is this?${dfl}"
profile_menu=(
"$(boxline "OEM / customer node ${gry}(public GitHub only, licensed products)${dfl}")"
"$(boxline "Collaborator node ${gry}(private Forgejo, full app list)${dfl}")"
"$(boxline "Orchard-join node ${gry}(cf-orch agent only)${dfl}")"
)
case `select_opt "${profile_menu[@]}"` in
0) cf_provision_profile="oem" ;;
1) cf_provision_profile="collaborator" ;;
2) cf_provision_profile="orchard" ;;
esac
}
# Top-level entry point: prompts for a profile (unless already set), then
# either runs the orchard-join flow or offers the app multiselect menu.
cf-provision-node() {
if [ -z "$cf_provision_profile" ]; then
select-provision-profile
fi
record-cf-node-state "cf_node_profile" "$cf_provision_profile"
if [[ "$cf_provision_profile" == "orchard" ]]; then
provision-orchard-node
return
fi
cf-apps-menu
}
cf-apps-menu() {
load-cf-apps
local menu_display=() menu_app_idx=() preselect=()
local i
for ((i=0; i<${#cf_app_names[@]}; i++)); do
if _cf-apps-profile-allows "${cf_app_profiles[$i]}" "$cf_provision_profile"; then
menu_display+=("${cf_app_names[$i]} ${gry}- ${cf_app_descs[$i]}${dfl}")
menu_app_idx+=("$i")
preselect+=("false")
fi
done
if [ "${#menu_display[@]}" -eq 0 ]; then
warn "No CF apps are available for the '${cf_provision_profile}' profile."
return
fi
boxborder "${lyl}${unl}Select CircuitForge apps to install:${dfl}"
multiselect result menu_display preselect
local idx=0 selected_i
for selected_i in "${menu_app_idx[@]}"; do
if [[ "${result[idx]}" == "true" ]]; then
install-cf-app "${cf_app_names[$selected_i]}"
fi
((idx++))
done
}
# Installs circuitforge-core as an editable dependency, once per run.
install-cf-core-dependency() {
if [[ "$cf_core_installed" == true ]]; then
return
fi
install-cf-app "circuitforge-core"
cf_core_installed=true
}
# Clones (if needed) and runs setup for a single app by name, honoring
# $cf_provision_profile for which remote to clone from.
install-cf-app() {
local name="$1"
local manifest="${cf_apps_manifest_dir}/${name}.manifest"
if [ ! -f "$manifest" ]; then
warn "No manifest found for '$name'"
return 1
fi
_cf-apps-reset-manifest-vars
source "$manifest"
if [[ "$app_needs_core" == true ]]; then
install-cf-core-dependency
fi
local repo_url
if [[ "$cf_provision_profile" == "oem" ]]; then
repo_url="$app_repo_url_github"
else
repo_url="$app_repo_url_forgejo"
fi
local apps_base_dir="${cf_apps_dir:?cf_apps_dir must be set before calling install-cf-app}"
local clone_dir="${apps_base_dir}/${app_name}"
if [ -d "$clone_dir" ]; then
warn "${clone_dir} already exists, skipping clone (will not overwrite)"
else
boxline "Cloning ${app_name} from ${repo_url}"
run "git clone --recurse-submodules \"${repo_url}\" \"${clone_dir}\""
if [[ $dry_run != true ]]; then
mkdir -p "$apps_base_dir"
git clone --recurse-submodules "$repo_url" "$clone_dir"
fi
fi
if [ -n "$app_env_template" ] && [ -f "${clone_dir}/${app_env_template}" ] && [ ! -f "${clone_dir}/.env" ]; then
cp "${clone_dir}/${app_env_template}" "${clone_dir}/.env"
boxline "Created ${app_name}/.env from ${app_env_template}"
fi
local install_type
if [ "${#app_install_types[@]}" -gt 1 ]; then
boxborder "${lyl}Install type for ${app_name}?${dfl}"
local type_menu=() t
for t in "${app_install_types[@]}"; do
type_menu+=("$(boxline "$t")")
done
install_type="${app_install_types[$(select_opt "${type_menu[@]}")]}"
else
install_type="${app_install_types[0]}"
fi
case "$install_type" in
bare-metal)
if declare -F app_setup_bare_metal >/dev/null; then
app_setup_bare_metal "$clone_dir"
else
warn "${app_name}: no bare-metal setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
fi
;;
docker)
if declare -F app_setup_docker >/dev/null; then
app_setup_docker "$clone_dir"
else
warn "${app_name}: no docker setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
fi
;;
podman)
if declare -F app_setup_podman >/dev/null; then
app_setup_podman "$clone_dir"
else
warn "${app_name}: no podman setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
fi
;;
esac
record-cf-node-state "cf_app_${app_name//-/_}" "${app_name}:${install_type}:${clone_dir}"
}
# Narrow, single-purpose flow for adding a GPU worker node to an existing
# cf-orch coordinator. circuitforge-orch has no public GitHub mirror (it's
# CircuitForge's internal coordinator/agent product), so this always clones
# from Forgejo regardless of provisioning profile.
#
# circuitforge-orch's own install.sh has no --topology/--coordinator-url
# flags - topology and coordinator URL are gathered via its own interactive
# prompts (read -rp). So this hands off to it interactively rather than
# trying to script around those prompts.
#
# NOTE: circuitforge-orch's installer and README have no model-sync/
# model-cache-sync mechanism today (checked both, no hits). This wrapper
# does not invent one - if a node needs that, raise it with circuitforge-orch
# directly rather than expecting it here.
provision-orchard-node() {
boxborder "${lyl}${unl}Join an existing CircuitForge orchard${dfl}"
boxline "This will clone circuitforge-orch and hand off to its own"
boxline "installer. When prompted for topology, choose 'agent' and have"
boxline "your coordinator's URL ready."
local apps_base_dir="${cf_apps_dir:?cf_apps_dir must be set before calling provision-orchard-node}"
local clone_dir="${apps_base_dir}/circuitforge-orch"
local repo_url="https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-orch.git"
if [ -d "$clone_dir" ]; then
warn "${clone_dir} already exists, skipping clone (will not overwrite)"
else
boxline "Cloning circuitforge-orch from Forgejo"
run "git clone \"${repo_url}\" \"${clone_dir}\""
if [[ $dry_run != true ]]; then
mkdir -p "$apps_base_dir"
git clone "$repo_url" "$clone_dir"
fi
fi
boxline "Handing off to circuitforge-orch's own install.sh..."
(cd "$clone_dir" && ./install.sh)
# circuitforge-orch's installer writes its own resulting config to
# ~/.config/circuitforge/cf-orch.env (CFORCH_MODE, CFORCH_COORDINATOR_URL);
# read that back rather than trying to track which topology the user
# picked inside its interactive prompts.
local orch_env_file="${HOME}/.config/circuitforge/cf-orch.env"
if [ -f "$orch_env_file" ]; then
local cforch_mode cforch_coordinator_url
cforch_mode=$(grep -m1 '^CFORCH_MODE=' "$orch_env_file" | cut -d= -f2-)
cforch_coordinator_url=$(grep -m1 '^CFORCH_COORDINATOR_URL=' "$orch_env_file" | cut -d= -f2-)
record-cf-node-state "cf_orchard_role" "${cforch_mode:-unknown}"
record-cf-node-state "cf_orchard_coordinator_url" "$cforch_coordinator_url"
record-cf-node-state "cf_orchard_dir" "$clone_dir"
else
warn "Couldn't find ${orch_env_file} after install - orchard role won't show in quickinfo until it exists"
fi
}