Initial: Node-RED flow validator with conftest/Rego

- Declarative YAML flow format
- Python flow2json converter with auto-positioning
- Rego policies: 14 validation rules for flow integrity
- Conftest integration
- justfile workflow: generate → validate → deploy
This commit is contained in:
2026-06-30 19:15:45 -07:00
commit ca1c7eda26
6 changed files with 596 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Convert declarative flow YAML to Node-RED flow JSON."""
import json
import sys
import uuid
import yaml
COLUMNS = [170, 450, 770]
ROW_HEIGHT = 140
BROKER_DEFAULTS = {
"clientid": "", "autoConnect": True, "usetls": False,
"protocolVersion": "4", "keepalive": "60", "cleansession": True,
"birthTopic": "", "birthQos": "0", "birthPayload": "",
"closeTopic": "", "closeQos": "0", "closePayload": "",
"willTopic": "", "willQos": "0", "willPayload": "",
}
NODE_TEMPLATES = {
"mqtt-in": {
"nl": False, "rap": True, "rh": 0, "inputs": 0, "datatype": "auto",
},
"mqtt-out": {
"qos": "2", "retain": False, "respTopic": "", "contentType": "",
"userProps": "", "correl": "", "expiry": "",
},
"debug": {
"active": True, "tosidebar": True, "console": False,
"tostatus": False, "complete": "payload", "targetType": "msg",
"statusVal": "", "statusType": "auto",
},
"ui-text": {
"width": "6", "height": "2", "layout": "row-spread",
},
"ui-button": {
"width": "3", "height": "1", "payloadType": "json",
},
}
def new_id(prefix="n"):
return prefix + uuid.uuid4().hex[:8]
def make_broker_node(name, cfg, flow_id):
node = {"id": new_id("br"), "z": "", "type": "mqtt-broker", "name": name}
node["broker"] = cfg["host"]
node["port"] = str(cfg.get("port", 1883))
for k, v in BROKER_DEFAULTS.items():
node.setdefault(k, v)
return node
def make_node(nd, col, row, broker_map, group_map, flow_id):
ntype = nd["type"]
nid = nd["id"] if "id" in nd else new_id()
node = {"id": nid, "z": flow_id, "type": ntype, "name": nd.get("label", nd.get("name", ""))}
node.update(NODE_TEMPLATES.get(ntype, {}))
node["x"] = COLUMNS[col] if col < len(COLUMNS) else col * 300 + 170
node["y"] = row * ROW_HEIGHT + 80
if ntype in ("mqtt-in", "mqtt-out"):
node["broker"] = broker_map[nd["broker"]]
if ntype in ("ui-text", "ui-button"):
node["group"] = group_map[nd["group"]]
if ntype == "mqtt-in":
node["topic"] = nd["topic"]
node["qos"] = str(nd.get("qos", 2))
if ntype == "mqtt-out":
node["topic"] = nd.get("topic", "")
node["qos"] = str(nd.get("qos", 2))
if ntype == "ui-text":
node["format"] = nd.get("format", "{{msg.payload}}")
node["name"] = nd.get("label", nd.get("name", "Text"))
if ntype == "ui-button":
node["payload"] = json.dumps(nd.get("payload", ""))
node["topic"] = nd.get("topic", "")
node["name"] = nd.get("label", nd.get("name", "Button"))
if ntype == "debug":
node["name"] = nd.get("label", nd.get("name", "Debug"))
return node
def make_group_node(name, tab_id, order):
return {
"id": new_id("g"), "z": tab_id, "type": "ui_group",
"name": name, "tab": tab_id, "order": order,
"disp": True, "width": "6",
}
def make_tab_node(name, icon, order):
tab_id = new_id("t")
return tab_id, {
"id": tab_id, "z": "", "type": "ui_tab",
"name": name, "icon": icon, "order": order,
"disabled": False, "hidden": False,
}
def build_flow(doc, flow_def, node_positions):
nodes = []
flow_id = new_id("f")
# Collect broker references
broker_map = {}
for nd in flow_def["nodes"]:
if nd["type"] in ("mqtt-in", "mqtt-out") and "broker" in nd:
bname = nd["broker"]
if bname not in broker_map:
bcfg = doc["brokers"][bname]
bn = make_broker_node(bname, bcfg, flow_id)
broker_map[bname] = bn["id"]
nodes.append(bn)
# Collect dashboard groups/tabs
group_map = {}
if "dashboards" in doc:
for db in doc["dashboards"]:
tab_id, tn = make_tab_node(db["tab"], db.get("icon", "dashboard"), db.get("order", 1))
nodes.append(tn)
for gi, grp in enumerate(db.get("groups", [])):
gn = make_group_node(grp["name"], tab_id, gi + 1)
group_map[grp["name"]] = gn["id"]
nodes.append(gn)
# Create flow nodes with auto-positioning
for i, nd in enumerate(flow_def["nodes"]):
col, row = node_positions.get(nd["id"], (0, i))
node = make_node(nd, col, row, broker_map, group_map, flow_id)
nodes.append(node)
# Wire connections
wires = flow_def.get("wires", {})
for src_id, targets in wires.items():
src = next(n for n in nodes if n.get("id") == src_id or n.get("name", "").lower().replace(" ", "-") == src_id)
src.setdefault("wires", [])
src["wires"].append(targets)
return nodes
def auto_position(flow_def):
"""Simple auto-layout: put each node in a column determined by its role."""
positions = {}
col_order = {"mqtt-in": 0, "ui-button": 0, "inject": 0,
"debug": 2, "ui-text": 2, "mqtt-out": 2, "ui-chart": 2, "ui-gauge": 2}
for i, nd in enumerate(flow_def["nodes"]):
col = col_order.get(nd["type"], 1)
positions[nd["id"]] = (col, i // 3)
return positions
def main():
doc = yaml.safe_load(open(sys.argv[1]))
all_nodes = []
for flow_def in doc.get("flows", []):
positions = auto_position(flow_def)
all_nodes += build_flow(doc, flow_def, positions)
json.dump(all_nodes, sys.stdout, indent=2)
if __name__ == "__main__":
main()