"""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 # Default root of the opaque-backup tree on the SFLD share. Connected PCs write # here directly (they need SFLD creds - a SYSTEM process hitting a UNC path # without them gets an access-denied that Test-Path reports as "not found"). # # This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the # backups_shareroot setting, because a bundled plugin in a multi-site product # must not hardcode one site's file server. DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups' 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() 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. """ return '{}\\{}\\{}\\{}'.format( shareroot or DEFAULTSHAREROOT, 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 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' # NO asset panel. The marker's DNC settings are a tab on the single DNC Info # card, and a separate card that is empty on 145 of 147 machines earns # nobody anything. The kind is still fully live - it stores, dedupes and # serves revisions, which are listed on the backup history page. assettypes = [] def resolveassetid(self, payload): from shopdb.api import db, Asset 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())