forked from b.peck/BAT
414 lines
16 KiB
Python
414 lines
16 KiB
Python
#!/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()
|