360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""Backup kind registry.
|
|
|
|
A "kind" is one category of configuration backup (NTLARS/DNC registry, part
|
|
marker, UDC, CMM ...). Each kind declares three things the rest of the plugin
|
|
needs and nothing else:
|
|
|
|
where its bytes live 'shopdb' (parsed into payloadjson) or 'share'
|
|
(opaque vendor file left on the SFLD share)
|
|
how to resolve an asset the collector reports an identifier; the kind turns
|
|
it into the assetid the revision belongs to
|
|
how to render downloads one or more output formats
|
|
|
|
Adding a kind is a class plus one REGISTRY entry. Nothing else in the plugin
|
|
knows kind names.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from . import ntlars as ntlarscodec
|
|
|
|
# Root of the opaque-backup tree on the site's file share. Connected PCs write
|
|
# here directly (they need share creds - a SYSTEM process hitting a UNC path
|
|
# without them gets an access-denied that Test-Path reports as "not found").
|
|
#
|
|
# EMPTY BY DESIGN (ADR-015). This used to ship one site's own file server
|
|
# as the default, which put one site's internal topology in a bundled plugin and
|
|
# in the public mirror, and silently pointed a second site at a server it cannot
|
|
# reach. The live value is the backups_shareroot setting; unset, the share kinds
|
|
# have no conventional location to offer and say so rather than composing a path
|
|
# from somebody else's hostname.
|
|
DEFAULTSHAREROOT = ''
|
|
|
|
|
|
def canonicalhash(projection):
|
|
"""sha256 over the SEMANTIC content only. Dedup key for 'shopdb' kinds.
|
|
|
|
Deliberately hashes just schema + keys, excluding sourcedialect. The same
|
|
settings exported through NTLARS's Save... button and through a scripted
|
|
reg export parse to identical keys but differing sourcedialect; hashing the
|
|
whole projection would record that as a change and produce a spurious
|
|
revision every time the collection route changed. Storage is
|
|
dialect-neutral, so the dedup key has to be too.
|
|
"""
|
|
subset = {
|
|
'schema': projection.get('schema'),
|
|
'keys': projection.get('keys'),
|
|
}
|
|
blob = json.dumps(subset, sort_keys=True, separators=(',', ':'))
|
|
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def byteshash(raw):
|
|
"""sha256 over raw bytes. Dedup key for 'share' kinds."""
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def markerforsource(sourcehostname):
|
|
"""Asset id of the part marker the reporting PC drives, or None.
|
|
|
|
A part-marker PC's config describes ITS marker, not the operation the
|
|
marker serves. Several markers can serve one operation - 0613, 0615 and
|
|
WJPRT each do - so filing by machine number put several devices' configs in
|
|
one history where they overwrote each other. The computers collector gives
|
|
each marker PC a marker asset (`collector:partmarker`), and that is what a
|
|
backup from that PC belongs to.
|
|
|
|
Falls through to None, and so to the machine number, whenever anything is
|
|
missing: no hostname, no computers or machines plugin on a lean build
|
|
(ADR-014), or a marker PC that has not reported to the computers collector
|
|
yet. Filing under the operation is the old behaviour and still better than
|
|
rejecting the backup.
|
|
"""
|
|
from shopdb.api import db, AssetRelationship, RelationshipType
|
|
|
|
hostname = (sourcehostname or '').strip()
|
|
if not hostname:
|
|
return None
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
|
|
except ImportError:
|
|
return None
|
|
|
|
computer = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
|
if computer is None or not computer.assetid:
|
|
return None
|
|
|
|
controls = RelationshipType.query.filter_by(
|
|
relationshiptype='controls').first()
|
|
if controls is None:
|
|
return None
|
|
|
|
link = (db.session.query(AssetRelationship)
|
|
.filter(AssetRelationship.sourceassetid == computer.assetid,
|
|
AssetRelationship.relationshiptypeid ==
|
|
controls.relationshiptypeid,
|
|
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
|
AssetRelationship.isactive.is_(True))
|
|
.first())
|
|
return link.targetassetid if link else None
|
|
|
|
|
|
class BackupKind:
|
|
"""Base class. Subclasses override what applies to them."""
|
|
|
|
key = None
|
|
displayname = None
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['*']
|
|
# Text shown when the kind has no revisions for an asset. None means the
|
|
# panel HIDES entirely instead (the generic list renderer shows a panel
|
|
# only when it has rows or declares empty text). Default to hiding: a kind
|
|
# applies to an asset TYPE, but whether a given asset ever has this kind of
|
|
# backup is a property of the individual machine. A part marker panel on
|
|
# every one of 144 machines is noise, not information.
|
|
emptytext = None
|
|
|
|
def parse(self, raw):
|
|
"""Opaque kinds return None; parseable kinds return the projection."""
|
|
return None
|
|
|
|
def formats(self):
|
|
"""Downloadable formats: [{'id','label','ext','mimetype'}]."""
|
|
return []
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
"""Return (bytes, extension, mimetype) for a 'shopdb' kind."""
|
|
raise NotImplementedError
|
|
|
|
def resolveassetid(self, payload):
|
|
"""Map a collector payload to the assetid the backup belongs to."""
|
|
raise NotImplementedError
|
|
|
|
def infopanel(self):
|
|
"""Optional 'at a glance' card built from this kind's LATEST revision.
|
|
|
|
Returns an ADR-010 panel dict, or None when the kind has no summary
|
|
worth surfacing. Declared by the kind rather than hardcoded in the
|
|
plugin so a successor technology (NTLARS/DNC is expected to give way to
|
|
Shopfloor Connect) ships its own card by adding a class, without
|
|
touching the plugin or the panel wiring.
|
|
"""
|
|
return None
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
"""Build this kind's info-card payload. Only called when infopanel()."""
|
|
return {'fields': [], 'sectioncount': 0}
|
|
|
|
def sharedir(self, machinetype, identifier, shareroot=None):
|
|
"""UNC directory a 'share' kind's files are expected under.
|
|
|
|
Advisory only - the authoritative path is the sharepath the collector
|
|
reports, since the PC is what actually wrote the file. This builds the
|
|
conventional location for display and for validating a reported path.
|
|
|
|
Returns '' when no share root is configured. A path built on an empty
|
|
root is not a lesser answer, it is a wrong one - it would render as a
|
|
relative path and validate a reported path against nothing.
|
|
"""
|
|
root = shareroot or DEFAULTSHAREROOT
|
|
if not root:
|
|
return ''
|
|
return '{}\\{}\\{}\\{}'.format(
|
|
root, machinetype or 'unknown', identifier or 'unknown', self.key)
|
|
|
|
|
|
class NtlarsKind(BackupKind):
|
|
"""NTLARS / DNC registry settings.
|
|
|
|
Collected from the CONTROLLING PC's registry but belongs to the MACHINE:
|
|
the settings describe how to talk to that machine's controller, so they
|
|
follow the machine when a PC is swapped. resolveassetid therefore keys on
|
|
the reported machine number, not the hostname.
|
|
"""
|
|
|
|
key = 'ntlars'
|
|
displayname = 'NTLARS / DNC Settings'
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['machine']
|
|
|
|
@staticmethod
|
|
def embeddedmachineno(projection):
|
|
"""The MachineNo NTLARS itself is configured with (General tab)."""
|
|
for key in (projection or {}).get('keys', []):
|
|
if (key.get('path') or '').lower() == 'general':
|
|
entry = (key.get('values') or {}).get('MachineNo') or {}
|
|
return str(entry.get('data') or '').strip()
|
|
return ''
|
|
|
|
def parse(self, raw):
|
|
"""Parse, then refuse to record an unconfigured NTLARS install.
|
|
|
|
A freshly imaged PC opens NTLARS with a blank General tab (no MachineNo,
|
|
CNC, Host or Interface type) until a tech restores the config. Storing
|
|
that would make an empty config the newest revision at exactly the
|
|
moment someone needs the last good one - so a blank MachineNo is
|
|
rejected as "nothing worth backing up" rather than accepted as a change.
|
|
Two of the 320 known-good backups on the share already have this shape.
|
|
"""
|
|
projection = ntlarscodec.parse(raw)
|
|
if not self.embeddedmachineno(projection):
|
|
raise ValueError(
|
|
'NTLARS General\\MachineNo is empty - unconfigured install, '
|
|
'refusing to record it as a revision')
|
|
return projection
|
|
|
|
def formats(self):
|
|
return [
|
|
{
|
|
'id': 'ntlars',
|
|
'label': 'NTLARS Load... (.reg)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Restore with the Load... button in the NTLARS settings dialog.',
|
|
},
|
|
{
|
|
'id': 'wow6432node',
|
|
'label': 'Direct reg import (.reg, WOW6432Node)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Use when importing outside NTLARS on a 64-bit machine.',
|
|
},
|
|
]
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
if formatid not in ('ntlars', 'wow6432node'):
|
|
raise ValueError('unknown format for ntlars kind: {}'.format(formatid))
|
|
raw = ntlarscodec.render(projection, dialect=formatid, comments=comments)
|
|
return raw, '.reg', 'application/octet-stream'
|
|
|
|
def infopanel(self):
|
|
return {
|
|
'id': 'backups-dncinfo',
|
|
'title': 'DNC Info',
|
|
'assettypes': ['machine'],
|
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
|
|
# tabs, not keyvalue: General / eFocas / Serial / NTSHR / MARK are
|
|
# one card the tech switches between, rather than a 30-row wall.
|
|
# No 'empty' key - the renderer hides a panel with no sections, so
|
|
# a machine with no NTLARS revision has no DNC Info card at all.
|
|
'render': 'tabs',
|
|
# Above the history panels: this answers the question a tech
|
|
# arrives with, while history is for the rarer restore case.
|
|
'position': 38,
|
|
}
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
from . import dncinfo
|
|
return dncinfo.build(projection, assetid,
|
|
partmarkertypes=options.get('partmarkertypes'))
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
marker = markerforsource(payload.get('sourcehostname'))
|
|
if marker is not None:
|
|
return marker, None
|
|
|
|
machinenumber = (payload.get('machinenumber') or '').strip()
|
|
if not machinenumber:
|
|
return None, 'no machinenumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == machinenumber).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(machinenumber)
|
|
return asset.assetid, None
|
|
|
|
|
|
class PartMarkerKind(BackupKind):
|
|
"""Telesis part marker configuration.
|
|
|
|
Opaque vendor filetype with no useful JSON representation, so ShopDB stores
|
|
metadata and a UNC pointer while the file itself stays on the share. The
|
|
original filename and extension are preserved because the vendor tool
|
|
rejects a renamed file.
|
|
"""
|
|
|
|
key = 'partmarker'
|
|
displayname = 'Part Marker Configuration'
|
|
storagebackend = 'share'
|
|
# Marker assets are core type 'machine', like the other 144 machines - but
|
|
# only a marker ever has a partmarker revision, and the list renderer hides
|
|
# a panel with no rows and no empty text. So declaring 'machine' shows the
|
|
# panel on markers and nowhere else, with no special-casing.
|
|
#
|
|
# This was deliberately EMPTY while backups were filed against the operation
|
|
# number, when a partmarker card would have appeared on every machine and
|
|
# said nothing. Since a marker PC's backup is filed against its own marker
|
|
# asset (see markerforsource), the panel has a home of its own.
|
|
assettypes = ['machine']
|
|
|
|
def sharedir(self, machinetype, identifier, shareroot=None):
|
|
"""<root>\\partmarker\\<marker asset>\\ - the SITE's layout, which
|
|
inverts the base class's <root>\\<machinetype>\\<identifier>\\<kind>.
|
|
|
|
Overridden rather than left to the default so what ShopDB displays and
|
|
validates matches what the collector actually writes. Still advisory:
|
|
the authoritative path is the sharepath on each revision, which is what
|
|
keeps older snapshots resolvable after a PC swap renames the asset.
|
|
|
|
Snapshots live in timestamped subdirectories BELOW this - one per
|
|
capture, so a pattern deleted from the controller stays recoverable
|
|
from the snapshot that still holds it.
|
|
"""
|
|
root = shareroot or DEFAULTSHAREROOT
|
|
if not root:
|
|
return ''
|
|
return '{}\\{}\\{}'.format(root, self.key, identifier or 'unknown')
|
|
|
|
def infopanel(self):
|
|
"""Contents of the latest snapshot, on the marker's own asset page.
|
|
|
|
A 'share' kind stores no bytes in ShopDB, so this is built from the
|
|
manifest the collector sends alongside the hash. Without it the app
|
|
server would have to mount SFLD to say what a snapshot contains, which
|
|
the read routes refuse to do on purpose.
|
|
"""
|
|
return {
|
|
'id': 'backups-partmarker-contents',
|
|
'title': 'Marker Contents',
|
|
'assettypes': ['machine'],
|
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=partmarker',
|
|
'render': 'keyvalue',
|
|
# Above the history panel: "what is on this marker" is the question
|
|
# a tech arrives with; the history is for the rarer restore.
|
|
'position': 39,
|
|
}
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
from . import partmarkerinfo
|
|
return partmarkerinfo.build(
|
|
projection, assetid,
|
|
revisionid=options.get('revisionid'),
|
|
sourcehostname=options.get('sourcehostname'))
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
marker = markerforsource(payload.get('sourcehostname'))
|
|
if marker is not None:
|
|
return marker, None
|
|
|
|
identifier = (payload.get('machinenumber')
|
|
or payload.get('assetnumber') or '').strip()
|
|
if not identifier:
|
|
return None, 'no machinenumber/assetnumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == identifier).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(identifier)
|
|
return asset.assetid, None
|
|
|
|
|
|
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
|
|
|
|
|
|
def getkind(key):
|
|
return REGISTRY.get((key or '').strip().lower())
|