Skip to content

Commit 82a1779

Browse files
committed
feat(config): unify all user choices into one canonical mios.toml dotfile
Single source of user truth across the entire stack ==================================================== Every script in mios.git and mios-bootstrap.git that needs a username, hostname, base image, AI endpoint, flatpak list, profile features, or free-form env var now resolves through ONE file with three layers: /usr/share/mios/mios.toml vendor defaults (this commit -- new) /etc/mios/mios.toml host overlay (bootstrap-staged) ~/.config/mios/mios.toml per-user overlay (XDG) The user's editable canonical copy lives at mios-bootstrap.git/mios.toml (repo root). Higher layers shadow lower layers field-by-field; user-set fields supersede defaults. UNIFIED SCHEMA ============== The two pre-existing schemas (lightweight [user]/[image]/[build] in ~/.config/mios/mios.toml read by userenv.sh, vs. rich [identity]/ [locale]/[auth]/[network]/[ai]/[desktop]/[image]/[bootstrap]/[quadlets] in profile.toml read by bootstrap install.sh) are folded into ONE schema -- the rich one. mios.toml now covers all of: [identity] username, fullname, hostname, shell, groups [locale] timezone, keyboard_layout, language [auth] ssh_key_action, password_policy, secrets refs [network] firewalld_default_zone, allow_ssh/cockpit/libvirt_bridge [ai] endpoint, model, embed_model, api_key, enable_ollama/localai, mcp_registry, system_prompt_file [desktop] session, color_scheme, flatpaks [image] ref, branch, base, bib, name, tag, local_tag [bootstrap] mode, mios_repo, bootstrap_repo, install_packages, reboot_on_finish [profile] role, features [quadlets.enable] per-Quadlet first-boot enable flags [env] free-form KEY = VALUE exported verbatim NEW VENDOR DEFAULTS: usr/share/mios/mios.toml ============================================= Byte-identical to mios-bootstrap.git/mios.toml; ships in the image as the lowest layer. The previous usr/share/mios/profile.toml is left in place untouched so legacy install paths keep working. UPGRADED RESOLVER: tools/lib/userenv.sh ======================================= - Reads three TOML layers via Python tomllib + deep-merge. - Expanded MIOS_* keymap (39 typed slots) covering every section.field in the unified schema, plus legacy aliases ([user]/[build]/ [flatpaks].install) so older user files keep resolving. - Free-form [env] table still exported verbatim. - Legacy split files (env.toml/images.toml/build.toml/flatpaks.list/ bare 'env') now read only when no mios.toml exists at any layer. UPDATED ENTRY POINT =================== - Justfile, install scripts, all entry-point scripts continue to source tools/lib/userenv.sh; no caller-side changes needed. DOCUMENTATION ============= - AGREEMENTS.md gains a new section 8 (single-source-of-user-truth) documenting the three-layer overlay, the canonical edit path (mios-bootstrap.git/mios.toml), and the per-user re-init workflow.
1 parent e61136b commit 82a1779

3 files changed

Lines changed: 356 additions & 75 deletions

File tree

AGREEMENTS.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,40 @@ console interaction. Operators who want a hard gate can wrap any of
154154
the entry points in `MIOS_REQUIRE_AGREEMENT_ACK=1 ./entrypoint.sh`
155155
and supply the corresponding handler in their wrapper.
156156

157-
## 8. Pointers
157+
## 8. Single canonical user-config dotfile
158+
159+
There is **one** file that holds every user choice (account, hostname,
160+
groups, locale, auth policy, network posture, AI endpoint and model,
161+
desktop session, flatpak picks, base image refs, build args, profile
162+
features, free-form env vars, per-Quadlet enable flags). That file is
163+
`mios.toml`, present in three layers:
164+
165+
| Layer | Path | Owned by | Mutability |
166+
|---|---|---|---|
167+
| Vendor | `/usr/share/mios/mios.toml` | `mios.git` | image-immutable |
168+
| Host | `/etc/mios/mios.toml` | bootstrap (staged) | admin-editable |
169+
| Per-user | `~/.config/mios/mios.toml` | per Linux user | user-editable |
170+
171+
The user's editable canonical copy in the bootstrap repository lives at
172+
`mios-bootstrap.git/mios.toml` (repo root). Bootstrap's `install.sh`
173+
stages it to `/etc/mios/mios.toml` at install time; the per-user copy
174+
is seeded from `/etc/skel/.config/mios/mios.toml` on `useradd -m`.
175+
176+
Every script that needs a value -- in `mios.git`, `mios-bootstrap.git`,
177+
the deployed image's `usr/bin/mios` CLI, the `Justfile`, the entry-point
178+
scripts -- resolves through `tools/lib/userenv.sh` (in `mios.git`),
179+
which deep-merges the three layers in order (vendor → host → user) and
180+
exports `MIOS_*` environment variables plus a verbatim `[env]` table.
181+
Higher layers shadow lower layers field-by-field; user-set fields
182+
supersede defaults.
183+
184+
To change anything globally for your deployment, edit
185+
`mios-bootstrap.git/mios.toml` once and re-run `bootstrap.sh` /
186+
`install.sh`. To change anything just for yourself on a deployed host,
187+
edit `~/.config/mios/mios.toml` and run `just init-user-space` (or
188+
re-source `tools/lib/userenv.sh` in your shell).
189+
190+
## 9. Pointers
158191

159192
- [`LICENSE`](./LICENSE) -- Apache-2.0 main license
160193
- [`LICENSES.md`](./LICENSES.md) -- bundled-component license inventory

tools/lib/userenv.sh

Lines changed: 142 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,111 @@
11
#!/usr/bin/env bash
22
# tools/lib/userenv.sh -- read the unified 'MiOS' user config and export
3-
# MIOS_* environment variables. Sourced by Justfile, /etc/profile.d, and
4-
# any tool that needs the user-overridden values.
3+
# MIOS_* environment variables. Sourced by Justfile, /etc/profile.d, every
4+
# entry-point script, and any tool that needs the user-overridden values.
55
#
6-
# Single source of user truth: ~/.config/mios/mios.toml
7-
# [user] identity
8-
# [image] base/builder/registry refs
9-
# [build] local tag
10-
# [flatpaks] array of refs
11-
# [ai] model, endpoint, key, optional system_prompt_file
12-
# [profile] role + features (replaces ~/.config/mios/profile.toml)
13-
# [env] free-form KEY = "VALUE" exports (replaces ~/.config/mios/env)
6+
# THERE IS ONE CANONICAL FILE PATH PER LAYER. Higher layers shadow lower
7+
# layers field-by-field; the user-edit copy lives in mios-bootstrap and is
8+
# staged into /etc/mios/mios.toml at install time.
149
#
15-
# Resolution order (first non-empty wins):
16-
# 1. ~/.config/mios/mios.toml (user, unified)
17-
# 2. /etc/mios/install.env (host, written by Windows installer)
18-
# 3. /etc/mios/env.d/*.env (admin drop-ins, alphabetical)
19-
# 4. /usr/share/mios/env.defaults (vendor)
10+
# 1. /usr/share/mios/mios.toml (vendor defaults; baked into image) lowest
11+
# 2. /etc/mios/mios.toml (host-local; bootstrap-staged)
12+
# 3. ~/.config/mios/mios.toml (per-user; XDG) highest
2013
#
21-
# Backwards-compat: if mios.toml is absent the legacy split files
22-
# (env.toml / images.toml / build.toml / flatpaks.list / profile.toml /
23-
# the bare `env` file) are still read. `just init-user-space` migrates them.
14+
# Schema is the same in all three layers (TOML 1.0; section names below).
15+
# Resolution mode: deep merge by section.field. The Python helper below
16+
# reads each layer in order and writes one consolidated set of MIOS_*
17+
# exports back to the calling shell.
18+
#
19+
# Section -> MIOS_* env mapping (typed slots; non-typed fields can still
20+
# be reached via the [env] table for free-form injection):
21+
#
22+
# [identity] .username/.fullname/.hostname/.shell/.groups
23+
# -> MIOS_USER, MIOS_USER_FULLNAME, MIOS_HOSTNAME,
24+
# MIOS_USER_SHELL, MIOS_USER_GROUPS (CSV)
25+
# [locale] .timezone/.keyboard_layout/.language
26+
# -> MIOS_TIMEZONE, MIOS_KEYBOARD, MIOS_LOCALE
27+
# [auth] .ssh_key_action/.password_policy
28+
# -> MIOS_SSH_KEY_ACTION, MIOS_PASSWORD_POLICY
29+
# [network] .firewalld_default_zone
30+
# -> MIOS_FIREWALLD_ZONE
31+
# [ai] .endpoint/.model/.embed_model/.api_key/.system_prompt_file/.mcp_registry
32+
# -> MIOS_AI_ENDPOINT, MIOS_AI_MODEL, MIOS_AI_EMBED_MODEL,
33+
# MIOS_AI_KEY, MIOS_SYSTEM_PROMPT_FILE, MIOS_MCP_REGISTRY
34+
# [desktop] .session/.color_scheme/.flatpaks
35+
# -> MIOS_DESKTOP_SESSION, MIOS_COLOR_SCHEME,
36+
# MIOS_FLATPAKS (CSV; consumed by Containerfile build arg)
37+
# [image] .ref/.branch/.base/.bib/.name/.tag/.local_tag
38+
# -> MIOS_IMAGE_REF, MIOS_BRANCH, MIOS_BASE_IMAGE,
39+
# MIOS_BIB_IMAGE, MIOS_IMAGE_NAME, MIOS_IMAGE_TAG,
40+
# MIOS_LOCAL_TAG
41+
# [bootstrap] .mode/.mios_repo/.bootstrap_repo
42+
# -> MIOS_BOOTSTRAP_MODE, MIOS_REPO_URL, MIOS_BOOTSTRAP_REPO_URL
43+
# [profile] .role/.features
44+
# -> MIOS_PROFILE_ROLE, MIOS_PROFILE_FEATURES (CSV)
45+
# [env] arbitrary KEY = "VALUE" pairs exported verbatim
46+
#
47+
# Backwards compat:
48+
# - The legacy lightweight schema ([user]/[build]/[flatpaks].install) is
49+
# still understood as a fallback when [identity]/[image]/[desktop] are
50+
# absent. 'just init-user-space' migrates the legacy split files.
51+
# - The legacy split files (env.toml, images.toml, build.toml,
52+
# flatpaks.list, the bare 'env' file) are still read when no
53+
# mios.toml is present in any layer.
2454
#
2555
# Usage: source ./tools/lib/userenv.sh
2656
# Note: must be sourced (not executed) to affect the calling shell.
2757

58+
MIOS_VENDOR_TOML="${MIOS_VENDOR_TOML:-/usr/share/mios/mios.toml}"
59+
MIOS_HOST_TOML="${MIOS_HOST_TOML:-/etc/mios/mios.toml}"
2860
MIOS_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mios"
29-
MIOS_UNIFIED_TOML="${MIOS_CONFIG_DIR}/mios.toml"
61+
MIOS_USER_TOML="${MIOS_CONFIG_DIR}/mios.toml"
3062
MIOS_VENDOR_DEFAULTS="${MIOS_VENDOR_DEFAULTS:-/usr/share/mios/env.defaults}"
3163

32-
# Typed-slot map: TOML "section.field" -> MIOS_* env var name.
33-
# Keep aligned with usr/share/mios/mios.toml.example.
34-
_mios_keymap=(
35-
"user.name=MIOS_USER"
36-
"user.hostname=MIOS_HOSTNAME"
37-
"image.base=MIOS_BASE_IMAGE"
38-
"image.bib=MIOS_BIB_IMAGE"
39-
"image.name=MIOS_IMAGE_NAME"
40-
"image.tag=MIOS_IMAGE_TAG"
41-
"build.local_tag=MIOS_LOCAL_TAG"
42-
"ai.model=MIOS_AI_MODEL"
43-
"ai.endpoint=MIOS_AI_ENDPOINT"
44-
"ai.key=MIOS_AI_KEY"
45-
"ai.system_prompt_file=MIOS_SYSTEM_PROMPT_FILE"
46-
"profile.role=MIOS_PROFILE_ROLE"
47-
)
48-
49-
# 1. Vendor defaults (lowest priority). Shell-format KEY="VALUE".
64+
# 0. Vendor env-defaults (lowest priority, shell-format KEY="VALUE" file).
65+
# Surfaces port numbers, paths, and infrastructure constants that don't
66+
# belong in the TOML schema.
5067
if [[ -f "$MIOS_VENDOR_DEFAULTS" ]]; then
5168
set -a
5269
# shellcheck disable=SC1090
5370
source "$MIOS_VENDOR_DEFAULTS"
5471
set +a
5572
fi
5673

57-
# 2. Unified mios.toml -- highest priority. Use python tomllib (3.11+ stdlib).
58-
_mios_load_toml() {
59-
local toml="$1"
60-
[[ -f "$toml" ]] || return 0
74+
# 1. TOML overlay (vendor -> host -> per-user). Use python tomllib (3.11+
75+
# stdlib; tomli fallback for older). The Python block prints shell-safe
76+
# 'export' lines that the surrounding shell evals.
77+
_mios_load_unified() {
78+
local layers=("$MIOS_VENDOR_TOML" "$MIOS_HOST_TOML" "$MIOS_USER_TOML")
6179
command -v python3 >/dev/null 2>&1 || return 0
6280
local exports
63-
exports=$(MIOS_TOML="$toml" python3 - "${_mios_keymap[@]}" <<'PY'
64-
import os, sys, shlex
81+
exports=$(MIOS_LAYERS="${layers[*]}" python3 - <<'PY'
82+
import os, sys, shlex, re
6583
try:
6684
import tomllib
6785
except ImportError:
6886
try:
6987
import tomli as tomllib
7088
except ImportError:
7189
sys.exit(0)
72-
path = os.environ["MIOS_TOML"]
73-
try:
74-
with open(path, "rb") as f:
75-
data = tomllib.load(f)
76-
except Exception as e:
77-
sys.stderr.write(f"userenv: failed to parse {path}: {e}\n")
78-
sys.exit(0)
90+
91+
layers = os.environ["MIOS_LAYERS"].split()
92+
93+
def deep_merge(dst, src):
94+
for k, v in src.items():
95+
if isinstance(v, dict) and isinstance(dst.get(k), dict):
96+
deep_merge(dst[k], v)
97+
else:
98+
dst[k] = v
99+
100+
merged = {}
101+
for path in layers:
102+
if not path or not os.path.isfile(path):
103+
continue
104+
try:
105+
with open(path, "rb") as f:
106+
deep_merge(merged, tomllib.load(f))
107+
except Exception as e:
108+
sys.stderr.write(f"userenv: failed to parse {path}: {e}\n")
79109
80110
def get(d, dotted):
81111
for p in dotted.split("."):
@@ -84,31 +114,68 @@ def get(d, dotted):
84114
d = d[p]
85115
return d
86116
87-
# Typed slots
88-
for arg in sys.argv[1:]:
89-
dotted, env = arg.split("=", 1)
90-
v = get(data, dotted)
117+
# Typed slot map. Pairs of "section.field=ENV_VAR".
118+
slots = [
119+
# identity
120+
("identity.username", "MIOS_USER"),
121+
("identity.fullname", "MIOS_USER_FULLNAME"),
122+
("identity.hostname", "MIOS_HOSTNAME"),
123+
("identity.shell", "MIOS_USER_SHELL"),
124+
("identity.groups", "MIOS_USER_GROUPS"),
125+
# locale
126+
("locale.timezone", "MIOS_TIMEZONE"),
127+
("locale.keyboard_layout", "MIOS_KEYBOARD"),
128+
("locale.language", "MIOS_LOCALE"),
129+
# auth
130+
("auth.ssh_key_action", "MIOS_SSH_KEY_ACTION"),
131+
("auth.password_policy", "MIOS_PASSWORD_POLICY"),
132+
# network
133+
("network.firewalld_default_zone", "MIOS_FIREWALLD_ZONE"),
134+
# ai
135+
("ai.endpoint", "MIOS_AI_ENDPOINT"),
136+
("ai.model", "MIOS_AI_MODEL"),
137+
("ai.embed_model", "MIOS_AI_EMBED_MODEL"),
138+
("ai.api_key", "MIOS_AI_KEY"),
139+
("ai.system_prompt_file", "MIOS_SYSTEM_PROMPT_FILE"),
140+
("ai.mcp_registry", "MIOS_MCP_REGISTRY"),
141+
# desktop
142+
("desktop.session", "MIOS_DESKTOP_SESSION"),
143+
("desktop.color_scheme", "MIOS_COLOR_SCHEME"),
144+
("desktop.flatpaks", "MIOS_FLATPAKS"),
145+
# image
146+
("image.ref", "MIOS_IMAGE_REF"),
147+
("image.branch", "MIOS_BRANCH"),
148+
("image.base", "MIOS_BASE_IMAGE"),
149+
("image.bib", "MIOS_BIB_IMAGE"),
150+
("image.name", "MIOS_IMAGE_NAME"),
151+
("image.tag", "MIOS_IMAGE_TAG"),
152+
("image.local_tag", "MIOS_LOCAL_TAG"),
153+
# bootstrap
154+
("bootstrap.mode", "MIOS_BOOTSTRAP_MODE"),
155+
("bootstrap.mios_repo", "MIOS_REPO_URL"),
156+
("bootstrap.bootstrap_repo","MIOS_BOOTSTRAP_REPO_URL"),
157+
# profile
158+
("profile.role", "MIOS_PROFILE_ROLE"),
159+
("profile.features", "MIOS_PROFILE_FEATURES"),
160+
# legacy/lightweight aliases (keep older mios.toml drafts working)
161+
("user.name", "MIOS_USER"),
162+
("user.hostname", "MIOS_HOSTNAME"),
163+
("build.local_tag", "MIOS_LOCAL_TAG"),
164+
("ai.key", "MIOS_AI_KEY"),
165+
("flatpaks.install", "MIOS_FLATPAKS"),
166+
]
167+
168+
for dotted, env in slots:
169+
v = get(merged, dotted)
91170
if v is None or v == "":
92171
continue
93172
if isinstance(v, list):
94173
v = ",".join(str(x) for x in v)
95174
print(f"export {env}={shlex.quote(str(v))}")
96175
97-
# Flatpaks list -> MIOS_FLATPAKS
98-
fl = get(data, "flatpaks.install")
99-
if isinstance(fl, list) and fl:
100-
print(f"export MIOS_FLATPAKS={shlex.quote(','.join(str(x) for x in fl))}")
101-
102-
# Profile features list -> MIOS_PROFILE_FEATURES (comma-joined)
103-
pf = get(data, "profile.features")
104-
if isinstance(pf, list) and pf:
105-
print(f"export MIOS_PROFILE_FEATURES={shlex.quote(','.join(str(x) for x in pf))}")
106-
107-
# [env] table -> export each key=value verbatim. Keys must match
108-
# POSIX env-var rules ([A-Za-z_][A-Za-z0-9_]*); silently skip anything else
109-
# rather than emit an export line bash will error on.
110-
import re
111-
ev = data.get("env") if isinstance(data.get("env"), dict) else {}
176+
# Free-form [env] table: arbitrary KEY=VALUE exports. POSIX-compliant
177+
# names only; silently skip otherwise so 'eval' below doesn't choke.
178+
ev = merged.get("env") if isinstance(merged.get("env"), dict) else {}
112179
for k, v in ev.items():
113180
if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', k):
114181
sys.stderr.write(f"userenv: skipping invalid [env] key: {k!r}\n")
@@ -122,10 +189,12 @@ PY
122189
)
123190
[[ -n "$exports" ]] && eval "$exports"
124191
}
125-
_mios_load_toml "$MIOS_UNIFIED_TOML"
192+
_mios_load_unified
126193

127-
# 3. Backwards compat: if mios.toml is absent, fall back to the legacy split
128-
# files. Each is shallow KEY = "VALUE" (no sections), so a regex grep works.
194+
# 2. Backwards-compat: legacy split files (per-user only). Read only when
195+
# none of the three TOML layers contain a [identity] or [user] section --
196+
# i.e., the user is on a pre-unified-schema deployment. Each is shallow
197+
# KEY="VALUE", grep-friendly.
129198
_mios_legacy_get() {
130199
local file="$1" key="$2"
131200
grep -E "^${key}\s*=" "$file" 2>/dev/null \
@@ -134,7 +203,7 @@ _mios_legacy_get() {
134203
| tr -d '"' || true
135204
}
136205

137-
if [[ ! -f "$MIOS_UNIFIED_TOML" ]]; then
206+
if [[ -z "${MIOS_USER:-}" && ! -f "$MIOS_USER_TOML" && ! -f "$MIOS_HOST_TOML" ]]; then
138207
if [[ -f "${MIOS_CONFIG_DIR}/env.toml" ]]; then
139208
f="${MIOS_CONFIG_DIR}/env.toml"
140209
for key in MIOS_USER MIOS_HOSTNAME MIOS_FLATPAKS MIOS_BASE_IMAGE MIOS_LOCAL_TAG; do
@@ -157,7 +226,6 @@ if [[ ! -f "$MIOS_UNIFIED_TOML" ]]; then
157226
flat=$(grep -vE '^\s*(#|$)' "${MIOS_CONFIG_DIR}/flatpaks.list" 2>/dev/null | paste -sd,)
158227
[[ -n "$flat" ]] && export "MIOS_FLATPAKS=$flat"
159228
fi
160-
# Legacy bare `env` file -- shell-format KEY=VALUE, source it directly.
161229
if [[ -f "${MIOS_CONFIG_DIR}/env" ]]; then
162230
set -a
163231
# shellcheck disable=SC1091

0 commit comments

Comments
 (0)