Schema: each expression field says which values and variables it accepts
The registry knows, for every expression field, the constants it may
evaluate to and the variables its conditions may read, and enforces both.
The schema served to INBUXA Admin described every one as a bare
x:Expression, so the console could offer nothing better than free text.
tools/fork/expr-schema.py reads those contexts from the generated registry
code and writes them onto each field's type as
expression: {constants, variables}. All 124 expression fields are covered.
CI runs it with --check so the schema can't drift from the registry.
This commit is contained in:
@@ -44,6 +44,10 @@ jobs:
|
|||||||
# schema has, and name nothing that is gone.
|
# schema has, and name nothing that is gone.
|
||||||
- if: always()
|
- if: always()
|
||||||
run: python3 tools/fork/privacy-check.py
|
run: python3 tools/fork/privacy-check.py
|
||||||
|
# The admin reads each expression field's allowed values and variables
|
||||||
|
# from the schema; they're generated from the registry and must match it.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/expr-schema.py --check
|
||||||
- if: always()
|
- if: always()
|
||||||
run: python3 -m unittest discover -s tools/fork/tests
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
wDJZ1KdKs21tjHD-UBI9R_XwPEET7Iul8XEXbPlgBPE
|
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU
|
||||||
@@ -112,3 +112,18 @@ a fresh copy for each one. See `docs/spec/compat-tests.md`.
|
|||||||
tools/fork/run-compat.sh --store /srv/inbuxa-copy/rocks.db \
|
tools/fork/run-compat.sh --store /srv/inbuxa-copy/rocks.db \
|
||||||
--admin '[email protected]:PASSWORD' --recordings ~/compat
|
--admin '[email protected]:PASSWORD' --recordings ~/compat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## expr-schema.py
|
||||||
|
|
||||||
|
Writes each expression field's allowed constants and variables, read from the
|
||||||
|
generated registry code, into the schema the server serves INBUXA Admin
|
||||||
|
(`resources/schema/schema.json.gz` and its checksum). The admin uses them to
|
||||||
|
offer plain choices instead of a free-text box.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/fork/expr-schema.py # update the schema
|
||||||
|
tools/fork/expr-schema.py --check # exit 1 if it's out of date (CI)
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run it after anything that regenerates the registry, an upstream import
|
||||||
|
included.
|
||||||
|
|||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Tell INBUXA Admin what each expression field accepts.
|
||||||
|
|
||||||
|
Every expression field in the registry has a context: the constants it may
|
||||||
|
evaluate to (DKIM verification: relaxed, strict or disable) and the variables
|
||||||
|
its conditions may read (sender_domain, local_port...). The server enforces
|
||||||
|
both, but the schema it serves the admin describes every expression field as
|
||||||
|
only an `x:Expression` object, so the admin can offer nothing better than a
|
||||||
|
free-text box.
|
||||||
|
|
||||||
|
This reads those contexts from the generated registry code and writes them
|
||||||
|
into the served schema, on each expression field's type:
|
||||||
|
|
||||||
|
"type": {"type": "object", "objectName": "x:Expression",
|
||||||
|
"expression": {"constants": ["relaxed", "strict", "disable"],
|
||||||
|
"variables": ["sender", "sender_domain", ...]}}
|
||||||
|
|
||||||
|
The registry code is the source, so re-run this after anything that
|
||||||
|
regenerates it (an upstream import, a new expression field). `--check` exits 1
|
||||||
|
when the schema is out of date; CI runs it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
REGISTRY = root / 'crates' / 'registry' / 'src' / 'schema'
|
||||||
|
SCHEMA = root / 'resources' / 'schema' / 'schema.json.gz'
|
||||||
|
SCHEMA_HASH = root / 'resources' / 'schema' / 'schema.json.sha256'
|
||||||
|
|
||||||
|
|
||||||
|
def names(enum, text):
|
||||||
|
"""Variant → wire name, from the `Enum::Variant => "name"` arms."""
|
||||||
|
return dict(re.findall(rf'{enum}::(\w+) => "([^"]+)"', text))
|
||||||
|
|
||||||
|
|
||||||
|
def lists(text):
|
||||||
|
"""Every `pub static NAME: &[ExpressionConstant|Variable] = &[...]`."""
|
||||||
|
out = {}
|
||||||
|
for name, kind, body in re.findall(
|
||||||
|
r'pub static (\w+): &\[(ExpressionConstant|ExpressionVariable)\] = &\[(.*?)\];', text, re.S
|
||||||
|
):
|
||||||
|
out[name] = (kind, re.findall(rf'{kind}::(\w+)', body))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def contexts(text):
|
||||||
|
"""(struct, Property variant, variables list name, constants list name) per context."""
|
||||||
|
out = []
|
||||||
|
for block in re.finditer(r'(?m)^impl (\w+) \{(.*?)^\}', text, re.S):
|
||||||
|
struct, body = block.group(1), block.group(2)
|
||||||
|
for ctx in re.finditer(r'ExpressionContext \{(.*?)\n\s*\}\n', body, re.S):
|
||||||
|
fields = ctx.group(1)
|
||||||
|
prop = re.search(r'property: Property::(\w+),', fields)
|
||||||
|
var = re.search(r'allowed_variables: (&\[\]|\w+),', fields)
|
||||||
|
const = re.search(r'allowed_constants: (&\[\]|\w+),', fields)
|
||||||
|
if prop and var and const:
|
||||||
|
out.append((struct, prop.group(1), var.group(1), const.group(1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
enums = (REGISTRY / 'enums.rs').read_text(encoding='utf-8')
|
||||||
|
enums_impl = (REGISTRY / 'enums_impl.rs').read_text(encoding='utf-8')
|
||||||
|
props = names('Property', (REGISTRY / 'properties_impl.rs').read_text(encoding='utf-8'))
|
||||||
|
const_names = names('ExpressionConstant', enums_impl)
|
||||||
|
var_names = names('ExpressionVariable', enums_impl)
|
||||||
|
known = lists(enums)
|
||||||
|
|
||||||
|
def resolve(ref, kind, wire):
|
||||||
|
if ref == '&[]':
|
||||||
|
return []
|
||||||
|
found = known.get(ref)
|
||||||
|
if not found or found[0] != kind:
|
||||||
|
raise SystemExit(f'expr-schema: no {kind} list named {ref}')
|
||||||
|
return [wire[v] for v in found[1]]
|
||||||
|
|
||||||
|
table = {}
|
||||||
|
for struct, prop, var, const in contexts((REGISTRY / 'structs_impl.rs').read_text(encoding='utf-8')):
|
||||||
|
table[(f'x:{struct}', props[prop])] = {
|
||||||
|
'constants': resolve(const, 'ExpressionConstant', const_names),
|
||||||
|
'variables': resolve(var, 'ExpressionVariable', var_names),
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def apply(schema, table):
|
||||||
|
"""Write the table into the schema; returns the (object, field) pairs it couldn't place."""
|
||||||
|
missing = []
|
||||||
|
for (obj, field), expr in sorted(table.items()):
|
||||||
|
target = schema['fields'].get(obj, {}).get('properties', {}).get(field)
|
||||||
|
if target is None or target['type'].get('objectName') != 'x:Expression':
|
||||||
|
missing.append(f'{obj}.{field}')
|
||||||
|
continue
|
||||||
|
target['type']['expression'] = expr
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def encode(schema):
|
||||||
|
text = json.dumps(schema, ensure_ascii=False, separators=(',', ':'))
|
||||||
|
out = gzip.compress(text.encode('utf-8'), compresslevel=9, mtime=0)
|
||||||
|
digest = base64.urlsafe_b64encode(hashlib.sha256(out).digest()).decode().rstrip('=')
|
||||||
|
return out, digest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument('--check', action='store_true', help='exit 1 if the schema is out of date')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
before = SCHEMA.read_bytes()
|
||||||
|
schema = json.loads(gzip.decompress(before))
|
||||||
|
table = build()
|
||||||
|
missing = apply(schema, table)
|
||||||
|
if missing:
|
||||||
|
print('expr-schema: expression contexts with no matching schema field:', file=sys.stderr)
|
||||||
|
for m in missing:
|
||||||
|
print(f' {m}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
current = json.loads(gzip.decompress(before))
|
||||||
|
if current == schema:
|
||||||
|
print(f'expr-schema: {len(table)} expression fields, schema up to date')
|
||||||
|
return 0
|
||||||
|
if args.check:
|
||||||
|
print('expr-schema: schema is out of date; run tools/fork/expr-schema.py', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
out, digest = encode(schema)
|
||||||
|
SCHEMA.write_bytes(out)
|
||||||
|
SCHEMA_HASH.write_text(digest, encoding='utf-8')
|
||||||
|
print(f'expr-schema: wrote {len(table)} expression fields into {SCHEMA.relative_to(root)}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user