Baseline: docker stack, provisioning tools, test-data, gateway-as-files (pre-PrimeBAT build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 11:56:30 -05:00
commit 310b1b3b9e
951 changed files with 17028 additions and 0 deletions

413
tools/provision.py Normal file
View File

@@ -0,0 +1,413 @@
#!/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()