#!/usr/bin/env python3 """provision.py — Build-a-Thon gateway provisioning (Ignition 8.3, gateway-as-files). The gateway's durable state is bind-mounted FROM THIS REPO (docker-compose.yml, primebench/TestingPlatform pattern): ignition/gateway/commission/commissioning.json -> data/commissioning.json ignition/gateway/config/ -> data/config/ ignition/gateway/projects/ -> data/projects/ so gateway config, tags, timers, and Perspective views are ordinary files here — edit them directly, then tell the gateway to reload: python3 tools/provision.py scan-config # after editing config resources/tags python3 tools/provision.py scan-projects # after editing project files Subcommands: mint-token Create/refresh the REST API token as file drops under ignition/gateway/config/ (api-token resource + Authenticated>API security levels + read/write permission patches), update IGN_API in .env, and restart the gateway if it is running. Requires the gateway to have booted at least once (the security config files must exist to be patched). provision Idempotently create via the REST API: the MariaDB password secret file, the 'local' file secret provider, the 'Buildathon_DB' database connection, and the 'Journal' alarm journal writing to PrimeControls_alarm_events / PrimeControls_alarm_event_data. Then import test-data/simulation_tags.json (--skip-tags to omit). import-tags POST tag JSON file(s) to the [default] provider root (collisionPolicy=Overwrite). Imported tags are persisted by the gateway as files under ignition/gateway/config/ — ready to diff. Default file: test-data/simulation_tags.json scan-config POST /data/api/v1/scan/config — reload config files edited here. scan-projects POST /data/api/v1/scan/projects — reload project files edited here. wait-health Poll /StatusPing until RUNNING. Config comes from .env next to this repo's docker-compose.yml: GATEWAY_URL (default http://localhost:8088), IGN_API (Name:secret). Gotcha captured here so nobody re-hits it: when creating an alarm-journal via the REST API, profile.queryOnly MUST be sent explicitly — the gateway does not apply the schema default and the journal dies on startup with an NPE. """ import argparse import base64 import hashlib import json import os import subprocess import sys import time import urllib.error import urllib.request import uuid ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) COMPOSE = ["docker", "compose", "-f", os.path.join(ROOT, "docker-compose.yml")] CONFIG = os.path.join(ROOT, "ignition", "gateway", "config") CONFIG_CORE = os.path.join(CONFIG, "resources", "core", "ignition") HOST_SECRET_FILE = os.path.join(CONFIG, "secrets", "mariadb_password") CONTAINER_SECRET_FILE = ("/usr/local/bin/ignition/data/config/secrets/" "mariadb_password") TOKEN_NAME = "Buildathon" DB_NAME = "Buildathon_DB" JOURNAL_NAME = "Journal" TABLE_PREFIX = "PrimeControls_" DEFAULT_TAG_FILE = os.path.join(ROOT, "test-data", "simulation_tags.json") # --- .env --------------------------------------------------------------------- def read_dotenv(): env = {} path = os.path.join(ROOT, ".env") if os.path.exists(path): with open(path) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) env[k.strip()] = v.strip() env.update({k: v for k, v in os.environ.items() if k in ("GATEWAY_URL", "IGN_API")}) return env def write_dotenv_key(key, value): path = os.path.join(ROOT, ".env") lines = [] if os.path.exists(path): with open(path) as f: lines = [l for l in f.read().splitlines() if not l.startswith(key + "=")] lines.append("%s=%s" % (key, value)) with open(path, "w") as f: f.write("\n".join(lines) + "\n") def gateway_url(): return read_dotenv().get("GATEWAY_URL", "http://localhost:8088").rstrip("/") def api_token(): token = read_dotenv().get("IGN_API", "") if ":" not in token: sys.exit("IGN_API missing/malformed in env or .env " "(expected Name:secret — run mint-token)") return token # --- HTTP --------------------------------------------------------------------- def request(method, path, body=None, content_type="application/json"): url = gateway_url() + path data = None if body is not None: data = body if isinstance(body, bytes) else json.dumps(body).encode() req = urllib.request.Request(url, data=data, method=method, headers={ "X-Ignition-API-Token": api_token(), "Content-Type": content_type}) try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") def resource_exists(rtype, name): status, _ = request("GET", "/data/api/v1/resources/find/ignition/%s/%s" % (rtype, name)) return status == 200 def create_resource(rtype, payload): status, text = request( "POST", "/data/api/v1/resources/ignition/%s" % rtype, [payload]) ok = status < 300 print(" %s %r -> HTTP %d%s" % (rtype, payload["name"], status, "" if ok else " " + text[:300])) if not ok: sys.exit(2) # --- mint-token (file drops under ignition/gateway/config/) -------------------- def _load_json(path): with open(path) as f: return json.load(f) def _dump_json(path, data): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(data, f, indent=2) f.write("\n") def _ensure_api_level(levels): for level in levels: if level.get("name") == "Authenticated": children = level.setdefault("children", []) for child in children: if child.get("name") == "API": names = {g.get("name") for g in child.get("children", [])} for need in ("Access", "Read", "Write"): if need not in names: child.setdefault("children", []).append( {"children": [], "name": need}) return True children.append({"children": [ {"children": [], "name": "Access"}, {"children": [], "name": "Read"}, {"children": [], "name": "Write"}], "name": "API"}) return True return False def _ensure_permission(perm, api_child): for level in perm.setdefault("securityLevels", []): if level.get("name") == "Authenticated": for child in level.setdefault("children", []): if child.get("name") == "API": names = {g.get("name") for g in child.get("children", [])} if api_child not in names: child.setdefault("children", []).append( {"children": [], "name": api_child}) return level["children"].append( {"children": [{"children": [], "name": api_child}], "name": "API"}) return perm["securityLevels"].append( {"children": [{"children": [{"children": [], "name": api_child}], "name": "API"}], "name": "Authenticated"}) def gateway_running(): try: with urllib.request.urlopen(gateway_url() + "/StatusPing", timeout=3) as resp: return "RUNNING" in resp.read().decode("utf-8", "replace") except (urllib.error.URLError, OSError): return False def cmd_mint_token(args): levels_path = os.path.join(CONFIG_CORE, "security-levels", "config.json") props_path = os.path.join(CONFIG_CORE, "security-properties", "config.json") if not (os.path.exists(levels_path) and os.path.exists(props_path)): sys.exit("Gateway security config not found under %s — boot the " "gateway once first (docker compose up -d), then re-run." % CONFIG_CORE) raw = os.urandom(32) secret = base64.urlsafe_b64encode(raw).rstrip(b"=").decode() token_hash = base64.urlsafe_b64encode( hashlib.sha256(raw).digest()).rstrip(b"=").decode() token_dir = os.path.join(CONFIG_CORE, "api-token", args.name) _dump_json(os.path.join(token_dir, "config.json"), { "profile": { "secureChannelRequired": False, "securityLevels": [{ "children": [{ "children": [ {"children": [], "name": "Access"}, {"children": [], "name": "Read"}, {"children": [], "name": "Write"}], "name": "API"}], "description": "Represents a user who has been authenticated" " by the system.", "name": "Authenticated"}], "timestamp": int(time.time() * 1000), "type": "basic-token"}, "settings": {"tokenHash": token_hash}}) _dump_json(os.path.join(token_dir, "resource.json"), { "scope": "A", "description": "", "version": 1, "restricted": False, "overridable": True, "files": ["config.json"], "attributes": {"uuid": str(uuid.uuid4()), "enabled": True}}) levels = _load_json(levels_path) if not _ensure_api_level(levels.get("securityLevels", [])): sys.exit("security-levels: no Authenticated level found") _dump_json(levels_path, levels) props = _load_json(props_path) _ensure_permission(props["readPermissions"], "Read") _ensure_permission(props["writePermissions"], "Write") _dump_json(props_path, props) write_dotenv_key("IGN_API", "%s:%s" % (args.name, secret)) print("API token %r minted under ignition/gateway/config/; " "IGN_API updated in .env" % args.name) if gateway_running(): print("Restarting gateway to load the token...") subprocess.run(COMPOSE + ["restart", "ignition"], check=True) cmd_wait_health(args) else: print("Gateway not running — token loads on next boot.") # --- provision ------------------------------------------------------------------ def cmd_provision(args): print("Provisioning gateway at %s" % gateway_url()) os.makedirs(os.path.dirname(HOST_SECRET_FILE), exist_ok=True) with open(HOST_SECRET_FILE, "w") as f: f.write("ignition") os.chmod(HOST_SECRET_FILE, 0o600) print(" %s written" % os.path.relpath(HOST_SECRET_FILE, ROOT)) if resource_exists("secret-provider", "local"): print(" secret-provider 'local' already exists — skipping") else: create_resource("secret-provider", { "name": "local", "enabled": True, "description": "File-based secrets under data/config/secrets/", "config": { "profile": {"type": "file"}, "settings": {"files": { "mariadb-password": { "description": "MariaDB password for the ignition user", "filePath": CONTAINER_SECRET_FILE, "fileType": "CLEARTEXT"}}}}}) if resource_exists("database-connection", DB_NAME): print(" database-connection %r already exists — skipping" % DB_NAME) else: create_resource("database-connection", { "name": DB_NAME, "enabled": True, "description": "Build-a-Thon MariaDB (alarm journal + dashboards)", "config": { "driver": "MariaDB", "translator": "MYSQL", "connectURL": "jdbc:mariadb://db:3306/ignition", "username": "ignition", "password": {"type": "Referenced", "data": {"providerName": "local", "secretName": "mariadb-password"}}, "validationQuery": "SELECT 1"}}) if resource_exists("alarm-journal", JOURNAL_NAME): print(" alarm-journal %r already exists — skipping" % JOURNAL_NAME) else: create_resource("alarm-journal", { "name": JOURNAL_NAME, "enabled": True, "description": "Alarm journal -> %s, %s table prefix" % (DB_NAME, TABLE_PREFIX), "config": { # queryOnly must be explicit: the gateway does not apply the # schema default and the journal NPEs on startup without it. "profile": {"type": "DATASOURCE", "queryOnly": False}, "settings": { "datasource": DB_NAME, "events": {"minPriority": "Diagnostic"}, "advanced": { "tableName": TABLE_PREFIX + "alarm_events", "dataTableName": TABLE_PREFIX + "alarm_event_data"}}}}) if not args.skip_tags: cmd_import_tags(argparse.Namespace(files=[DEFAULT_TAG_FILE])) print("Done. Remaining manual steps: create the 'Buildathon' Perspective " "project and its default database (see README), install the " "alarmsim timer script (see test-data/README.md).") def cmd_import_tags(args): for path in args.files: with open(path, "rb") as f: body = f.read() status, text = request( "POST", "/data/api/v1/tags/import" "?provider=default&path=&type=json&collisionPolicy=Overwrite", body, content_type="application/octet-stream") print(" import %s -> HTTP %d %s" % (os.path.basename(path), status, text[:300])) if status >= 300: sys.exit(2) def _cmd_scan(what): status, text = request("POST", "/data/api/v1/scan/" + what, body=b"") print("scan/%s -> HTTP %d %s" % (what, status, text[:300])) if status >= 300: sys.exit(2) def cmd_wait_health(args): url = gateway_url() + "/StatusPing" deadline = time.time() + args.timeout last = "" while time.time() < deadline: try: with urllib.request.urlopen(url, timeout=5) as resp: last = resp.read().decode("utf-8", "replace") if "RUNNING" in last: print("Gateway RUNNING (%s)" % url) return except (urllib.error.URLError, OSError) as e: last = str(e) time.sleep(2) sys.exit("Gateway not RUNNING after %ss (last: %s)" % (args.timeout, last)) def main(): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("mint-token", help="file-drop API token into repo config, update .env") p.add_argument("--name", default=TOKEN_NAME) p.add_argument("--timeout", type=int, default=300) p.set_defaults(fn=cmd_mint_token) p = sub.add_parser("provision", help="secret + DB connection + journal + sim tags") p.add_argument("--skip-tags", action="store_true") p.set_defaults(fn=cmd_provision) p = sub.add_parser("import-tags", help="REST-import tag JSON files") p.add_argument("files", nargs="*", default=[DEFAULT_TAG_FILE]) p.set_defaults(fn=cmd_import_tags) p = sub.add_parser("scan-config", help="reload config files after editing them here") p.set_defaults(fn=lambda a: _cmd_scan("config")) p = sub.add_parser("scan-projects", help="reload project files after editing them here") p.set_defaults(fn=lambda a: _cmd_scan("projects")) p = sub.add_parser("wait-health", help="poll /StatusPing for RUNNING") p.add_argument("--timeout", type=int, default=300) p.set_defaults(fn=cmd_wait_health) args = ap.parse_args() args.fn(args) if __name__ == "__main__": main()