Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption import, a versioned agent binary store with upload and rollback, remote-BuildKit CI, tray version reporting, and UI fixes. Applied as a patch again - the published mirror rewrites hashes, so the branches have unrelated histories and git refuses to merge them. Reviewed by three agents before merging. Four things fixed here. THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in publish-images.yml sits at column 0 instead of indented under steps:, so the whole file is invalid YAML and GitHub would refuse it. Their entire GHCR distribution channel - which Start-ShopDBDev.ps1 and the compose files consume - was broken at the source and clearly never run. Indented; all five workflows now parse. THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the approval packet from the same upload and then asked the writer whether they matched, so packet_matches_plan compared a hash against itself. The documented contract - "a human approves a specific, frozen plan, and the writer can prove nothing drifted" - did not exist in the web path: an admin could preview file A and commit file B, gated only by the needs-create group NAMES they ticked. The preview's planhash is now required at commit and checked against a freshly rebuilt plan. Their tests committed without previewing, which is what let this through; they go through the real flow now, and three new tests pin it - all confirmed to fail with the check stubbed out. endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a ZIP"; with the upload routes it also means "choose the executable every future package installs and runs as SYSTEM". Before this, staging needed filesystem access to the server. The permission description still said only "generate and download a package", so an admin granting it could not see what they were granting. Reworded. NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size and timestamp; re-uploading a version replaces the bytes in place under the same label. A sha256 of the stored file is now recorded, and the upload is audit-logged with it - after an incident there was previously no answer to who put that binary on the fleet. Also documented the nine new routes in docs/api-inventory.json (that gate cannot run on the mirror, so it falls to us at promotion time again), regenerated the spec and the project map, and kept our own docs where they conflicted: docs/ is scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file rather than our 737-line canonical one. Their journal section is appended to ours. Contract version comes across as 0.30.0 for a genuinely additive surface. Suite 2369, frontend 324, gate green. Scope: product
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""Shared, import-agnostic journal + undo for reversible bulk imports.
|
|
|
|
IMPORT-AGNOSTIC. Nothing here knows about WINC, Maximo, machines, or any one
|
|
source. An import writer records the primitive mutations it makes - "set column
|
|
X on row Y of table T from old to new" (update) or "insert row Y into table T"
|
|
(insert) - each tied to a batch id, and a generic undo replays them in reverse.
|
|
That is the whole mechanism: importers emit primitives; undo reverses primitives.
|
|
|
|
Identity: one value threads approval, write, and undo. The approval packet's
|
|
`batchid` becomes the importbatches primary key, becomes the journal foreign key,
|
|
becomes the argument the "Undo import" button passes. Nothing to reconcile.
|
|
|
|
Contract (extends the approval packet's 6-step writer contract):
|
|
open_batch() -> after the packet passes, write the importbatches row.
|
|
record_update()/record_insert() -> append a journal entry in the SAME
|
|
transaction as the mutation it describes (ledger rule: the
|
|
entry and the change it records commit together).
|
|
undo_batch() -> reverse-replay this batch's entries, conservatively.
|
|
|
|
Undo is deliberately conservative and never destructive beyond what the import
|
|
owns:
|
|
- update reverts a cell ONLY if it still holds the value we wrote; a value a
|
|
human changed since is left as-is and reported as a conflict (never clobbered).
|
|
- insert hard-deletes ONLY the row this batch created, and only when nothing
|
|
outside the batch still references it; otherwise it is left and reported.
|
|
|
|
All access goes through a SQLAlchemy connection executing parameterised text, so
|
|
this reuses the same connection an importer already opened and pulls in no ORM
|
|
models - keeping it a true platform primitive any importer can share.
|
|
"""
|
|
|
|
from collections import namedtuple
|
|
from datetime import datetime, timezone
|
|
|
|
BATCH_APPLIED = 'applied'
|
|
BATCH_UNDONE = 'undone'
|
|
|
|
OP_UPDATE = 'update'
|
|
OP_INSERT = 'insert'
|
|
|
|
# Result of an undo pass, for a report and a UI.
|
|
UndoResult = namedtuple('UndoResult', ('batchid', 'reverted', 'conflicts'))
|
|
|
|
# A single thing undo could not safely reverse.
|
|
Conflict = namedtuple('Conflict', ('operation', 'table', 'targetid',
|
|
'column', 'reason'))
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _as_text(value):
|
|
"""Store every journalled value as text so a pre/post image compares the
|
|
same way it will read back, regardless of the column's declared type."""
|
|
return None if value is None else str(value)
|
|
|
|
|
|
def open_batch(connection, packet, createdby=None):
|
|
"""Write the importbatches row for an approved packet.
|
|
|
|
Call once, after the packet has been verified against the current plan and
|
|
acknowledged. Returns the batchid so the caller stamps it on every journal
|
|
entry it then records. Import-agnostic: the packet's own fields carry all
|
|
the identity this needs.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
connection.execute(text(
|
|
'INSERT INTO importbatches '
|
|
'(batchid, kind, planhash, rowcount, createdat, createdby, status) '
|
|
'VALUES (:batchid, :kind, :planhash, :rowcount, :createdat, '
|
|
':createdby, :status)'),
|
|
{
|
|
'batchid': packet['batchid'],
|
|
'kind': packet.get('kind'),
|
|
'planhash': packet.get('planhash'),
|
|
'rowcount': packet.get('rowcount', 0),
|
|
'createdat': _utcnow(),
|
|
'createdby': createdby,
|
|
'status': BATCH_APPLIED,
|
|
})
|
|
return packet['batchid']
|
|
|
|
|
|
def record_update(connection, batchid, sequence, table, targetid, column,
|
|
oldvalue, newvalue):
|
|
"""Append an 'update' journal entry describing one cell change.
|
|
|
|
Record the pre-image (oldvalue) you read BEFORE applying the change and the
|
|
post-image (newvalue) you are about to write, in the same transaction as the
|
|
UPDATE itself.
|
|
"""
|
|
_insert_entry(connection, batchid, sequence, OP_UPDATE, table, targetid,
|
|
column, oldvalue, newvalue)
|
|
|
|
|
|
def record_insert(connection, batchid, sequence, table, targetid):
|
|
"""Append an 'insert' journal entry for a row this batch created.
|
|
|
|
Only ever emitted for a needs-create row the writer itself inserted, so undo
|
|
can hard-delete strictly what the import created.
|
|
"""
|
|
_insert_entry(connection, batchid, sequence, OP_INSERT, table, targetid,
|
|
None, None, None)
|
|
|
|
|
|
def _insert_entry(connection, batchid, sequence, operation, table, targetid,
|
|
column, oldvalue, newvalue):
|
|
from sqlalchemy import text
|
|
|
|
connection.execute(text(
|
|
'INSERT INTO importjournalentries '
|
|
'(batchid, sequence, operation, targettable, targetid, columnname, '
|
|
'oldvalue, newvalue, isreverted) '
|
|
'VALUES (:batchid, :sequence, :operation, :targettable, :targetid, '
|
|
':columnname, :oldvalue, :newvalue, 0)'),
|
|
{
|
|
'batchid': batchid,
|
|
'sequence': sequence,
|
|
'operation': operation,
|
|
'targettable': table,
|
|
'targetid': targetid,
|
|
'columnname': column,
|
|
'oldvalue': _as_text(oldvalue),
|
|
'newvalue': _as_text(newvalue),
|
|
})
|
|
|
|
|
|
def undo_batch(connection, batchid, references=None):
|
|
"""Reverse-replay an applied batch's journal. Returns an UndoResult.
|
|
|
|
references (optional): { table -> [(reftable, refcolumn), ...] } describing
|
|
which columns in other tables point at a created row's id. Used to decide
|
|
whether an inserted row is safe to hard-delete: if any reference OUTSIDE
|
|
this batch's own updates still points at it, the row is left and reported as
|
|
a conflict rather than deleted. Omit it to require that the batch's own
|
|
update-reverts have already cleared every reference (the common case, since a
|
|
created businessunit is only ever pointed at by the same batch's asset
|
|
updates).
|
|
|
|
Idempotent: a batch already 'undone' is a no-op. Entries are walked in
|
|
reverse sequence so inserts are removed only after the updates that pointed
|
|
at them are reverted.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
batch = connection.execute(text(
|
|
'SELECT status FROM importbatches WHERE batchid = :batchid'),
|
|
{'batchid': batchid}).fetchone()
|
|
if batch is None:
|
|
raise ValueError('unknown batchid: %s' % batchid)
|
|
if batch[0] == BATCH_UNDONE:
|
|
return UndoResult(batchid, 0, [])
|
|
|
|
entries = connection.execute(text(
|
|
'SELECT entryid, sequence, operation, targettable, targetid, '
|
|
'columnname, oldvalue, newvalue FROM importjournalentries '
|
|
'WHERE batchid = :batchid AND isreverted = 0 '
|
|
'ORDER BY sequence DESC, entryid DESC'),
|
|
{'batchid': batchid}).fetchall()
|
|
|
|
reverted = 0
|
|
conflicts = []
|
|
for row in entries:
|
|
(entryid, _sequence, operation, table, targetid, column,
|
|
oldvalue, newvalue) = row
|
|
|
|
if operation == OP_UPDATE:
|
|
conflict = _revert_update(connection, table, targetid, column,
|
|
oldvalue, newvalue)
|
|
elif operation == OP_INSERT:
|
|
conflict = _revert_insert(connection, batchid, table, targetid,
|
|
references)
|
|
else:
|
|
conflict = Conflict(operation, table, targetid, column,
|
|
'unknown operation')
|
|
|
|
if conflict is None:
|
|
connection.execute(text(
|
|
'UPDATE importjournalentries SET isreverted = 1 '
|
|
'WHERE entryid = :entryid'), {'entryid': entryid})
|
|
reverted += 1
|
|
else:
|
|
conflicts.append(conflict)
|
|
|
|
connection.execute(text(
|
|
'UPDATE importbatches SET status = :status, undoneat = :undoneat '
|
|
'WHERE batchid = :batchid'),
|
|
{'status': BATCH_UNDONE, 'undoneat': _utcnow(), 'batchid': batchid})
|
|
|
|
return UndoResult(batchid, reverted, conflicts)
|
|
|
|
|
|
def _revert_update(connection, table, targetid, column, oldvalue, newvalue):
|
|
"""Revert one cell to its pre-image, but only if it still holds what we
|
|
wrote. Returns a Conflict if a human changed it since, else None."""
|
|
from sqlalchemy import text
|
|
|
|
pk = _primary_key(connection, table)
|
|
current = connection.execute(text(
|
|
'SELECT %s FROM %s WHERE %s = :targetid' % (column, table, pk)),
|
|
{'targetid': targetid}).fetchone()
|
|
if current is None:
|
|
return Conflict(OP_UPDATE, table, targetid, column, 'row missing')
|
|
|
|
if _as_text(current[0]) != _as_text(newvalue):
|
|
return Conflict(OP_UPDATE, table, targetid, column,
|
|
'value changed since import')
|
|
|
|
connection.execute(text(
|
|
'UPDATE %s SET %s = :oldvalue WHERE %s = :targetid'
|
|
% (table, column, pk)),
|
|
{'oldvalue': oldvalue, 'targetid': targetid})
|
|
return None
|
|
|
|
|
|
def _revert_insert(connection, batchid, table, targetid, references):
|
|
"""Hard-delete a row this batch created, only if nothing outside the batch
|
|
still references it. Returns a Conflict when it is not safe, else None."""
|
|
from sqlalchemy import text
|
|
|
|
pk = _primary_key(connection, table)
|
|
for reftable, refcolumn in (references or {}).get(table, []):
|
|
referencing = connection.execute(text(
|
|
'SELECT COUNT(*) FROM %s WHERE %s = :targetid'
|
|
% (reftable, refcolumn)), {'targetid': targetid}).fetchone()[0]
|
|
if referencing:
|
|
return Conflict(OP_INSERT, table, targetid, None,
|
|
'still referenced by %s.%s' % (reftable, refcolumn))
|
|
|
|
connection.execute(text(
|
|
'DELETE FROM %s WHERE %s = :targetid' % (table, pk)),
|
|
{'targetid': targetid})
|
|
return None
|
|
|
|
|
|
def _primary_key(connection, table):
|
|
"""Single-column primary key name for a table, via the live schema."""
|
|
from sqlalchemy import inspect
|
|
|
|
pk = inspect(connection).get_pk_constraint(table)
|
|
columns = pk.get('constrained_columns') or []
|
|
if len(columns) != 1:
|
|
raise ValueError(
|
|
'journal undo needs a single-column primary key on %s' % table)
|
|
return columns[0]
|
|
|
|
|
|
def format_undo_result(result):
|
|
"""Human-readable rendering of an UndoResult for the console or a UI panel."""
|
|
lines = []
|
|
lines.append('UNDO batch %s' % result.batchid)
|
|
lines.append(' reverted : %d entries' % result.reverted)
|
|
if result.conflicts:
|
|
lines.append(' conflicts: %d (left as-is, not reverted)'
|
|
% len(result.conflicts))
|
|
for conflict in result.conflicts:
|
|
target = conflict.table
|
|
if conflict.column:
|
|
target = '%s.%s' % (conflict.table, conflict.column)
|
|
lines.append(' ! %-8s %s #%s - %s' % (
|
|
conflict.operation, target, conflict.targetid,
|
|
conflict.reason))
|
|
else:
|
|
lines.append(' conflicts: none')
|
|
return '\n'.join(lines)
|