feat(W4): daily full reconciliation — user/role drift repair + ReconcileLogs audit
- w4-body.js: ONE-DIRECTIONAL sync (NocoDB status = SOT, never reverse);
drift matrix: missing user/role (provision w/ username lookup to avoid
duplicates), dangling refs (adopt/recreate), disabled<->status, canonical
base_path '/' + permission 16384, scopes vs calculateEffectiveGrants,
username drift detect+log_only; verify-after-repair on every change;
password never pushed on repair (方案 C); dry-run via ?dryRun=true
- gen_w4.js: daily 03:00 cron + webhook alist-w4-reconcile; API-import-safe
(UUID node ids, no pinData, webhookId)
- ReconcileLogs table mktsb7le6fqr2u1 (add_reconcilelogs_table.py, idempotent,
v2 API); FK column nc_jitu___Customers_id for bt-link writes (v2 ignores
link column itself)
- test_w4_reconcile.js: 34 assertions / 12 mock worlds, ALL PASSED
- live-verified against production: dry-run (3 drifts + 1 error found),
real repair (3 repaired, verify all passed, 4 log rows), idempotent rerun
- NocoDB 2026.08.x pitfalls documented (v1 col endpoints gone, row delete
via body [{Id}]); n8n API import/activation pitfalls (activeVersionId)
- docs: W4-DESIGN.md new; DATABASE_SCHEMA 8 tables; N8N_WORKFLOWS, SETUP,
PROJECT_STATE updated
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure the ReconcileLogs table exists in AlistAccess (idempotent, NON-destructive).
|
||||
|
||||
Unlike create_nocodb_schema.py (which drops + recreates the base), this script
|
||||
only creates/mends the ReconcileLogs table + columns. Safe to re-run.
|
||||
Uses v2 meta endpoints only — verified on NocoDB 2026.08.1 (v1 column GET/PATCH
|
||||
endpoints are GONE on this version; column id is fetched from the table meta).
|
||||
|
||||
Credentials from env (NOCODB_URL / NOCODB_TOKEN) — never commit real tokens.
|
||||
|
||||
Usage:
|
||||
NOCODB_TOKEN=... python scripts/add_reconcilelogs_table.py
|
||||
"""
|
||||
import json, os, urllib.request, urllib.error
|
||||
|
||||
NOCODB_URL = os.environ.get("NOCODB_URL", "https://nocodb.hoelee.com")
|
||||
NOCODB_TOKEN = os.environ.get("NOCODB_TOKEN", "CHANGE_ME_nocodb_token")
|
||||
BASE_ID = os.environ.get("NOCODB_BASE_ID", "p3lzkresswyowqg")
|
||||
CUSTOMERS_TID = os.environ.get("NOCODB_TABLE_CUSTOMERS", "maqb9zgvza2te3k")
|
||||
TABLE_TITLE = os.environ.get("RECONCILELOGS_TITLE", "ReconcileLogs")
|
||||
|
||||
DRIFT_OPTIONS = [
|
||||
"user_missing", "role_missing", "dangling_user_ref", "dangling_role_ref",
|
||||
"user_disabled_drift", "scopes_drift", "base_path_drift",
|
||||
"permission_drift", "role_binding_drift", "username_drift",
|
||||
"verify_failed", "reconcile_error",
|
||||
]
|
||||
RESULT_OPTIONS = ["repaired", "log_only", "failed", "skipped"]
|
||||
|
||||
|
||||
def req(method, path, body=None):
|
||||
r = urllib.request.Request(f"{NOCODB_URL}{path}", method=method)
|
||||
r.add_header("xc-token", NOCODB_TOKEN)
|
||||
if body is not None:
|
||||
r.add_header("Content-Type", "application/json")
|
||||
r.data = json.dumps(body).encode()
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"{method} {path} -> HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
|
||||
|
||||
def table_meta(tid):
|
||||
return req("GET", f"/api/v2/meta/tables/{tid}")
|
||||
|
||||
|
||||
def col_by_title(tid, title):
|
||||
meta = table_meta(tid)
|
||||
return next((c for c in (meta.get("columns") or []) if c.get("title") == title), None)
|
||||
|
||||
|
||||
def ensure_column(tid, title, uidt, **extra):
|
||||
c = col_by_title(tid, title)
|
||||
if not c:
|
||||
req("POST", f"/api/v2/meta/tables/{tid}/columns",
|
||||
{"title": title, "uidt": uidt, **extra})
|
||||
c = col_by_title(tid, title)
|
||||
if not c:
|
||||
raise RuntimeError(f"column {title} not found after create")
|
||||
print(f" + {title} ({uidt}) -> {c['id']}")
|
||||
return c
|
||||
|
||||
|
||||
def ensure_ss_options(tid, title, options):
|
||||
c = ensure_column(tid, title, "SingleSelect")
|
||||
cur = ((c.get("colOptions") or {}).get("options") or [])
|
||||
cur_titles = [o.get("title") for o in cur]
|
||||
if cur_titles == options:
|
||||
print(f" = {title} options OK")
|
||||
return
|
||||
req("PATCH", f"/api/v2/meta/columns/{c['id']}", {
|
||||
"colOptions": {"options": [{"title": o, "order": i + 1}
|
||||
for i, o in enumerate(options)]},
|
||||
"dtxp": ",".join(options),
|
||||
})
|
||||
print(f" ~ {title} options set")
|
||||
|
||||
|
||||
def main():
|
||||
tables = req("GET", f"/api/v2/meta/bases/{BASE_ID}/tables")["list"]
|
||||
existing = next((t for t in tables if t["title"] == TABLE_TITLE), None)
|
||||
if existing:
|
||||
tid = existing["id"]
|
||||
print(f"{TABLE_TITLE} exists: {tid}")
|
||||
else:
|
||||
t = req("POST", f"/api/v2/meta/bases/{BASE_ID}/tables", {
|
||||
"title": TABLE_TITLE, "table_name": TABLE_TITLE, "type": "table",
|
||||
"columns": [],
|
||||
})
|
||||
tid = t["id"]
|
||||
print(f"{TABLE_TITLE} created: {tid}")
|
||||
|
||||
ensure_column(tid, "run_id", "SingleLineText")
|
||||
ensure_column(tid, "details", "LongText")
|
||||
ensure_column(tid, "action", "SingleLineText")
|
||||
ensure_column(tid, "timestamp", "DateTime")
|
||||
ensure_ss_options(tid, "drift_type", DRIFT_OPTIONS)
|
||||
ensure_ss_options(tid, "result", RESULT_OPTIONS)
|
||||
|
||||
if not col_by_title(tid, "customer"):
|
||||
req("POST", f"/api/v1/db/meta/tables/{tid}/columns", {
|
||||
"title": "customer", "uidt": "LinkToAnotherRecord",
|
||||
"parentId": CUSTOMERS_TID, "childId": tid, "type": "bt",
|
||||
})
|
||||
c = col_by_title(tid, "customer")
|
||||
print(f" + customer (bt link) -> {c['id']}")
|
||||
fk = col_by_title(tid, "nc_jitu___Customers_id")
|
||||
if not fk:
|
||||
raise RuntimeError("FK column nc_jitu___Customers_id missing — bt link create failed")
|
||||
|
||||
print(f"\nDONE. table={tid} fk_column={fk['id']} ({fk['title']})")
|
||||
print("=> add to n8n env: NOCODB_TABLE_RECONCILELOGS=" + tid
|
||||
+ " NOCODB_FK_RECONCILELOG_CUSTOMER=" + fk["title"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -76,16 +76,27 @@ def main():
|
||||
body.update(extra)
|
||||
return curl("POST", f"/api/v1/db/meta/tables/{tid}/columns", body)
|
||||
|
||||
def set_options(colid, opts):
|
||||
return curl("PATCH", f"/api/v1/db/meta/columns/{colid}",
|
||||
def col_id(tid, title):
|
||||
# v1 GET /columns is removed on NocoDB 2026.08.x — read table meta instead
|
||||
t = curl("GET", f"/api/v2/meta/tables/{tid}")
|
||||
for c in jget(t, "columns", d=[]) or []:
|
||||
if c.get("title") == title:
|
||||
return c.get("id")
|
||||
return None
|
||||
|
||||
def set_options(tid, title, opts):
|
||||
cid = col_id(tid, title)
|
||||
if not cid:
|
||||
raise RuntimeError(f"column {title} not found on {tid}")
|
||||
return curl("PATCH", f"/api/v2/meta/columns/{cid}",
|
||||
{"colOptions": {"options": [{"title": o, "order": i + 1, "color": None}
|
||||
for i, o in enumerate(opts)]},
|
||||
"dtxp": ",".join(f"'{o}'" for o in opts)})
|
||||
|
||||
def add_ss(tid, title, opts):
|
||||
r = add_col(tid, title, "SingleSelect")
|
||||
cid = jget(r, "id")
|
||||
set_options(cid, opts)
|
||||
add_col(tid, title, "SingleSelect")
|
||||
cid = col_id(tid, title)
|
||||
set_options(tid, title, opts)
|
||||
return cid
|
||||
|
||||
def add_link(child_tid, title, parent_tid, type_="bt"):
|
||||
@@ -166,6 +177,19 @@ def main():
|
||||
{"title": "notes", "uidt": "LongText"},
|
||||
]); pur = jget(pur, "id")
|
||||
|
||||
rlog = create_table("ReconcileLogs", [
|
||||
{"title": "run_id", "uidt": "SingleLineText"},
|
||||
{"title": "details", "uidt": "LongText"},
|
||||
{"title": "action", "uidt": "SingleLineText"},
|
||||
{"title": "timestamp", "uidt": "DateTime"},
|
||||
]); rlog = jget(rlog, "id")
|
||||
add_ss(rlog, "drift_type",
|
||||
["user_missing", "role_missing", "dangling_user_ref", "dangling_role_ref",
|
||||
"user_disabled_drift", "scopes_drift", "base_path_drift",
|
||||
"permission_drift", "role_binding_drift", "username_drift",
|
||||
"verify_failed", "reconcile_error"])
|
||||
add_ss(rlog, "result", ["repaired", "log_only", "failed", "skipped"])
|
||||
|
||||
# ---- links ----
|
||||
add_link(cp, "customer", cust)
|
||||
add_link(cp, "product", prod)
|
||||
@@ -175,9 +199,11 @@ def main():
|
||||
add_link(al, "customer", cust)
|
||||
add_link(pur, "customer", cust)
|
||||
add_link(pur, "product", prod)
|
||||
add_link(rlog, "customer", cust)
|
||||
|
||||
ids = {"base": base_id, "Customers": cust, "Products": prod, "Resources": res,
|
||||
"CustomerProducts": cp, "AccessGrants": ag, "AccessLogs": al, "Purchases": pur}
|
||||
"CustomerProducts": cp, "AccessGrants": ag, "AccessLogs": al,
|
||||
"Purchases": pur, "ReconcileLogs": rlog}
|
||||
print("DONE. IDs:", json.dumps(ids, indent=2))
|
||||
# persist for later steps
|
||||
open("_nocodb_ids.json", "w").write(json.dumps(ids, indent=2))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Live dry-run of the W4 Code node against REAL AList + NocoDB, outside n8n.
|
||||
// Runs the exact inlined jsCode from W4-daily-reconcile.json in a vm sandbox,
|
||||
// with helpers.httpRequest backed by node fetch. ZERO writes (dryRun=true).
|
||||
// Usage: node scripts/live_dry_run_w4.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const wf = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'n8n', 'W4-daily-reconcile.json'), 'utf8'));
|
||||
const code = wf.nodes.find(n => n.name === 'Reconcile & Repair (Daily Full)').parameters.jsCode;
|
||||
|
||||
const ENV = {
|
||||
NOCODB_URL: 'https://nocodb.hoelee.com',
|
||||
NOCODB_TOKEN: process.env.NOCODB_TOKEN, // from env, never hardcoded — same for passwords below
|
||||
NOCODB_TABLE_CUSTOMERS: 'maqb9zgvza2te3k',
|
||||
NOCODB_TABLE_PRODUCTS: 'molyb3pxv81l75g',
|
||||
NOCODB_TABLE_RESOURCES: 'm6cm7tkxzd8m18c',
|
||||
NOCODB_TABLE_CUSTOMERPRODUCTS: 'mvyqbv8ao7u6bct',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'mxdx3yhfcyej3ao',
|
||||
NOCODB_TABLE_RECONCILELOGS: 'mktsb7le6fqr2u1',
|
||||
NOCODB_FK_RECONCILELOG_CUSTOMER: 'nc_jitu___Customers_id',
|
||||
NOCODB_LINK_PROD_RESOURCES: 'c4ko4x8kwi16swg',
|
||||
ALIST_URL: process.env.ALIST_URL || 'http://192.168.1.1:5244', // DSM host port (container DNS only inside bridge)
|
||||
ALIST_ADMIN_USERNAME: process.env.ALIST_ADMIN_USERNAME || 'hoelee',
|
||||
ALIST_ADMIN_PASSWORD: process.env.ALIST_ADMIN_PASSWORD, // from env
|
||||
W4_DRY_RUN: 'false',
|
||||
};
|
||||
if (!ENV.NOCODB_TOKEN || !ENV.ALIST_ADMIN_PASSWORD) { console.error('NOCODB_TOKEN + ALIST_ADMIN_PASSWORD env required'); process.exit(1); }
|
||||
|
||||
const httpRequest = async (opts) => {
|
||||
const { method = 'GET', url, headers = {}, body } = opts;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed = null;
|
||||
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
$env: ENV,
|
||||
$json: { query: { dryRun: 'true' } },
|
||||
console,
|
||||
};
|
||||
sandbox.helpers = { httpRequest };
|
||||
|
||||
(async () => {
|
||||
const fn = vm.runInNewContext(`(async function(){\n${code}\n})`, sandbox, { filename: 'w4-body-live.js' });
|
||||
const result = await fn();
|
||||
console.log(JSON.stringify(result.json, null, 1));
|
||||
})().catch(e => { console.error('LIVE RUN FAILED:', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user