Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import

Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
cproudlock
2026-08-28 16:17:17 -04:00
parent 93ca7587ce
commit 784964c907
149 changed files with 17545 additions and 627 deletions

View File

@@ -0,0 +1,239 @@
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
existing Computer assets so the fleet list, the PC-detail asset panels
(status / services / recent events), and the dashboard quiet + stability cards
have something to render before a real agent exists.
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
be listed and cleared without touching real agent data.
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
production image (see devnotes/live-container-and-testing-workflow.md). Do not
import it from application code.
Usage (in the API container):
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
"""
import argparse
import json
import os
import sys
from datetime import datetime, timedelta, timezone
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
SAMPLE_MARKER = '0.0.0-sample'
def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _services(*running_then_stopped):
"""Build a services snapshot. Positional pairs are (name, state)."""
return [{'name': name, 'state': state} for name, state in running_then_stopped]
def _ports(*pairs):
return [{'port': port, 'process': process} for port, process in pairs]
def _interfaces(ip, mac):
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
# the dashboard quiet card has rows. Everything is offline-plausible because
# there is no live agent.
SAMPLE_PROFILES = [
{
'loggedinuser': 'CORP\\jmartin',
'agentversion': SAMPLE_MARKER,
'minutesago': 3,
'uptimeseconds': 4 * 86400 + 6 * 3600,
'idleseconds': 12 * 60,
'cpupercent': 14.0,
'memorypercent': 46.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
'events': [(4624, -8), (4672, -8), (4698, -120)],
},
{
'loggedinuser': 'CORP\\operator2',
'agentversion': SAMPLE_MARKER,
'minutesago': 9,
'uptimeseconds': 2 * 3600 + 40 * 60,
'idleseconds': 3 * 60,
'cpupercent': 61.0,
'memorypercent': 72.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Stopped')),
'ports': _ports((3389, 'svchost'), (445, 'System')),
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
# Recent unexpected shutdown -> shows on the stability card.
'events': [(41, -50), (1001, -30), (4625, -12)],
},
{
'loggedinuser': None,
'agentversion': SAMPLE_MARKER,
'minutesago': 22,
'uptimeseconds': 11 * 86400,
'idleseconds': 9 * 3600,
'cpupercent': 3.0,
'memorypercent': 38.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
'events': [(4624, -600), (7011, -180)],
},
{
'loggedinuser': 'CORP\\nightshift',
'agentversion': SAMPLE_MARKER,
# Quiet: no report in over a day.
'minutesago': 1500,
'uptimeseconds': 30 * 60,
'idleseconds': 30 * 60,
'cpupercent': 1.0,
'memorypercent': 29.0,
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
'ports': _ports((445, 'System'),),
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
'events': [(55, -1600), (41, -1580)],
},
{
'loggedinuser': 'CORP\\qa_kiosk',
'agentversion': SAMPLE_MARKER,
'minutesago': 6,
'uptimeseconds': 6 * 3600,
'idleseconds': 45 * 60,
'cpupercent': 22.0,
'memorypercent': 55.0,
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
('WinRM', 'Running'), ('BITS', 'Running')),
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
'events': [(4624, -20), (10016, -90), (4697, -240)],
},
]
def _clear(EndpointState, EndpointEvent):
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
asset_ids = [s.assetid for s in sample_states]
events_removed = 0
if asset_ids:
events_removed = (EndpointEvent.query
.filter(EndpointEvent.assetid.in_(asset_ids))
.delete(synchronize_session=False))
states_removed = (EndpointState.query
.filter_by(agentversion=SAMPLE_MARKER)
.delete(synchronize_session=False))
db.session.commit()
print('Cleared {} sample state(s) and {} sample event(s).'
.format(states_removed, events_removed))
def _seed(limit):
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState, EndpointEvent
from plugins.endpoints.eventcatalog import EVENT_CATALOG
computers = (Computer.query
.filter(Computer.assetid.isnot(None))
.order_by(Computer.computerid.asc())
.limit(limit)
.all())
if not computers:
print('No Computer assets found to attach sample endpoints to. '
'Seed the computers plugin first.')
return
now = _naive_utcnow()
created, updated, events_added = 0, 0, 0
for index, computer in enumerate(computers):
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
reported = now - timedelta(minutes=profile['minutesago'])
boot = reported - timedelta(seconds=profile['uptimeseconds'])
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
if state and state.agentversion not in (None, SAMPLE_MARKER):
# Never stomp a real agent's snapshot.
continue
if not state:
state = EndpointState(assetid=computer.assetid, createdat=now)
db.session.add(state)
created += 1
else:
updated += 1
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
state.loggedinuser = profile['loggedinuser']
state.agentversion = SAMPLE_MARKER
state.lastboottime = boot
state.uptimeseconds = profile['uptimeseconds']
state.idleseconds = profile['idleseconds']
state.cpupercent = profile['cpupercent']
state.memorypercent = profile['memorypercent']
state.servicesjson = json.dumps(profile['services'])
state.portsjson = json.dumps(profile['ports'])
state.interfacesjson = json.dumps(profile['interfaces'])
state.scheduledtasksjson = json.dumps([])
state.lastreportedat = reported
# Refresh this asset's sample events so re-running stays idempotent.
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
synchronize_session=False)
for eventid, offset_minutes in profile['events']:
title, severity, channel = EVENT_CATALOG.get(
eventid, ('Event {}'.format(eventid), 'info', 'System'))
event = EndpointEvent(
assetid=computer.assetid,
eventid=eventid,
eventchannel=channel,
eventsource='sample-seed',
eventtime=reported + timedelta(minutes=offset_minutes),
eventmessage='{} (sample)'.format(title),
payloadjson=json.dumps({'sample': True, 'severity': severity}),
recordedat=now,
)
db.session.add(event)
events_added += 1
db.session.commit()
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
'across {} computer(s).'
.format(created, updated, events_added, len(computers)))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', action='store_true',
help='Remove sample-marked endpoint states and their events.')
parser.add_argument('--limit', type=int, default=5,
help='Max number of computers to attach sample endpoints to.')
args = parser.parse_args()
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
from plugins.endpoints.models import EndpointState, EndpointEvent
if args.clear:
_clear(EndpointState, EndpointEvent)
else:
_seed(args.limit)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,102 @@
"""Seed the org's shared shopfloor applications (DEV ONLY).
Inserts the applications that ship as common across the organization so the
Configuration Policy builder has real apps to scope against and the software
inventory has canonical rows to match collected installs to. Each app records
its executable and (where relevant) its Windows service name in
applicationnotes, since the Application model has no dedicated process/service
column - these identifiers are what a generic service_*/process_* policy action
targets.
Shipped set:
* UDC - UDC.exe
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
* eDNC - DncMain.exe process
Idempotent: matches on appname and updates in place, so re-running does not
duplicate. Marked installable so they appear in installable-app pickers.
DEV ONLY: lives under scripts/dev/ and must never be imported by application
code. Run inside the API container.
Usage (in the API container):
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
"""
import argparse
import os
import sys
# Repo root on sys.path so `shopdb` imports when run as a script.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
# appname -> seed fields. 'notes' captures the process image and service name so
# a generic policy action (params.target) has a canonical identifier to bind to.
SHARED_APPS = [
{
'appname': 'UDC',
'appdescription': 'Universal Data Collector',
'notes': 'process=UDC.exe',
},
{
'appname': 'MTConnect',
'appdescription': 'MTConnect UDC agent',
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
},
{
'appname': 'eDNC',
'appdescription': 'eDNC distributed NC file transfer',
'notes': 'process=DncMain.exe',
},
]
def _seed(Application):
created = 0
updated = 0
for spec in SHARED_APPS:
app_row = Application.query.filter_by(appname=spec['appname']).first()
if app_row is None:
app_row = Application(appname=spec['appname'])
db.session.add(app_row)
created += 1
else:
updated += 1
app_row.appdescription = spec['appdescription']
app_row.applicationnotes = spec['notes']
app_row.isinstallable = True
db.session.commit()
print('Seeded shared applications: {} created, {} updated.'
.format(created, updated))
def _clear(Application):
names = [spec['appname'] for spec in SHARED_APPS]
removed = (Application.query
.filter(Application.appname.in_(names))
.delete(synchronize_session=False))
db.session.commit()
print('Removed {} shared application row(s).'.format(removed))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', action='store_true',
help='Remove the seeded shared applications.')
args = parser.parse_args()
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
from shopdb.api import Application
if args.clear:
_clear(Application)
else:
_seed(Application)
if __name__ == '__main__':
main()

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Generate docs/PROJECT-MAP.md: the facts about this repo that go stale.
"""Generate the PROJECT-MAP wiki page: the facts about this repo that go stale.
Versions, the plugin inventory, every Alembic chain head, the ADR index and the
endpoint count were all written by hand, in several places. Written facts about
@@ -12,7 +12,7 @@ restate them point at the result. Anything in this file that cannot be read from
the repository does not belong in it.
Usage:
venv/bin/python scripts/gen_project_map.py # write docs/PROJECT-MAP.md
venv/bin/python scripts/gen_project_map.py # write the PROJECT-MAP wiki page
venv/bin/python scripts/gen_project_map.py --check # fail if it is out of date
venv/bin/python scripts/gen_project_map.py --stdout # print, write nothing
@@ -230,10 +230,10 @@ def main():
current = _read(OUTPUT) if OUTPUT.is_file() else ''
if current != content:
sys.stderr.write(
'docs/PROJECT-MAP.md is out of date.\n'
'the PROJECT-MAP wiki page is out of date.\n'
'Regenerate: venv/bin/python scripts/gen_project_map.py\n')
return 1
print('docs/PROJECT-MAP.md is current')
print('the PROJECT-MAP wiki page is current')
return 0
OUTPUT.write_text(content, encoding='utf-8')
print('wrote %s' % OUTPUT.relative_to(REPO))

View File

@@ -10,13 +10,13 @@ columns that no longer exist) and were actively misleading.
## Use the import API + a site loader instead
Legacy data is imported through the maintained, schema-agnostic contract in
[`docs/IMPORT-API.md`](../../docs/IMPORT-API.md) (create through real endpoints
the IMPORT-API page in the project wiki (create through real endpoints
with an admin PAT + `X-Import-Mode`). Each adopting site writes a thin loader
that reads its own source database and drives that API.
- Reference implementation: [`scripts/site_imports/wjf/`](../site_imports/wjf/)
(West Jefferson classic-ASP -> flask).
- Adoption playbook: [`docs/IMPORT-ADOPTION.md`](../../docs/IMPORT-ADOPTION.md).
- Adoption playbook: the IMPORT-ADOPTION page in the project wiki.
## What's kept here

View File

@@ -0,0 +1,156 @@
"""Generate reconciliation SQL for the locations table.
Reads a TSV export (locationid, locationname, description, locationtypeid,
parentlocationid) and emits a single transaction-wrapped SQL script that:
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
canonical identity for a cell.
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
suffix text into the canonical row's description (never the name).
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
("NDE"); named areas are kept as-is and never forced into coordinates.
- Repoints assets.locationid, subnets.locationid and child
locations.parentlocationid onto the canonical row, then deletes duplicates.
Bare coordinates with no variants are left untouched. The trailing W (west
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
G2W is kept distinct from a hypothetical G2.
"""
import re
import sys
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
def esc(s):
return s.replace("'", "''")
def load(path):
rows = []
with open(path, encoding='utf-8-sig') as fh:
for line in fh:
line = line.rstrip('\n').rstrip('\r')
if not line:
continue
parts = line.split('\t')
if len(parts) < 5:
parts += [''] * (5 - len(parts))
lid, name, desc, typeid, parentid = parts[:5]
rows.append({
'id': int(lid),
'name': name,
'desc': desc,
'typeid': typeid,
'parentid': parentid,
})
return rows
def classify(name):
"""Return (canonical, suffix). canonical is the bare coordinate or named
parent; suffix is the trailing detail (may be '')."""
m = COORD.match(name)
if m:
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
canonical = f"{prefix}{digits}{west}"
suffix = name[m.end():].strip()
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
return canonical, suffix, 'coord'
# named area: fold on ' - ' separator only
if ' - ' in name:
base, _, rest = name.partition(' - ')
return base.strip(), rest.strip(), 'named'
return name.strip(), '', 'named'
def main(tsv, out):
rows = load(tsv)
# Build canonical groups. Key is casefolded canonical for grouping, but we
# preserve a display canonical (prefer an existing exact bare/standalone).
groups = {}
for r in rows:
canonical, suffix, kind = classify(r['name'])
key = (kind, canonical.casefold())
g = groups.setdefault(key, {
'canonical': canonical,
'kind': kind,
'members': [],
'exact_id': None,
})
r['_suffix'] = suffix
r['_canonical'] = canonical
g['members'].append(r)
# exact row: name equals canonical (no suffix)
if suffix == '' and r['name'].strip() == canonical:
g['exact_id'] = r['id']
g['canonical'] = r['name'].strip()
lines = []
lines.append('-- Auto-generated locations reconciliation. Review before running.')
lines.append('START TRANSACTION;')
touched = 0
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
members = sorted(g['members'], key=lambda r: r['id'])
# nothing to do: single member that is already the exact canonical
if len(members) == 1 and g['exact_id'] == members[0]['id']:
continue
touched += 1
canonical = g['canonical']
# choose target id: prefer exact bare row, else promote smallest id
if g['exact_id'] is not None:
target = g['exact_id']
promote = None
else:
target = members[0]['id']
promote = members[0]
# accumulate suffix/description text from all members (dedup, ordered)
details = []
for m in members:
for chunk in (m['_suffix'], m['desc']):
chunk = (chunk or '').strip()
if chunk and chunk not in details:
details.append(chunk)
desc_val = '; '.join(details)
lines.append('')
lines.append(f"-- {canonical} (target locationid {target}) <= "
+ ', '.join(str(m['id']) for m in members))
if promote is not None:
lines.append(
f"UPDATE locations SET locationname='{esc(canonical)}' "
f"WHERE locationid={target};")
if desc_val:
lines.append(
f"UPDATE locations SET description='{esc(desc_val)}' "
f"WHERE locationid={target};")
others = [m['id'] for m in members if m['id'] != target]
if others:
ids = ','.join(str(i) for i in others)
lines.append(
f"UPDATE assets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE subnets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE locations SET parentlocationid={target} "
f"WHERE parentlocationid IN ({ids});")
lines.append(
f"DELETE FROM locations WHERE locationid IN ({ids});")
lines.append('')
lines.append('COMMIT;')
lines.append(f'-- groups touched: {touched}')
with open(out, 'w', encoding='utf-8') as fh:
fh.write('\n'.join(lines) + '\n')
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])

View File

@@ -0,0 +1,594 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
# below. See the CONFIG block for the easy place to set this permanently.
[Parameter(Mandatory = $false)]
[string]$OutputRoot,
# Optional network share to mirror the store into after a run. Overrides the
# in-script CONFIG block below.
[Parameter(Mandatory = $false)]
[string]$PublishRoot,
# Business identifier for this PC (workstation number). Defaults to hostname.
[Parameter(Mandatory = $false)]
[string]$AssetNumber,
# Value written into assets.assettypeid for the PC (resolved by name).
[Parameter(Mandatory = $false)]
[string]$AssetType = 'Computer',
# Value written into computers.computertypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$ComputerType,
# Value written into machines.machinetypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$MachineType,
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
# locations are searched.
[Parameter(Mandatory = $false)]
[string]$RegPath,
[Parameter(Mandatory = $false)]
[string]$JsonPath,
# Skip switches for partial runs.
[switch]$SkipMachine,
[switch]$SkipApps
)
<#
.SYNOPSIS
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
importable CSVs so a site can build its inventory PC by PC.
.DESCRIPTION
Accumulating store under OutputRoot:
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
computers.csv, machines.csv
Rows are merged field-by-field on each table's natural key (a later run fills
gaps and refreshes values without wiping data an earlier run captured).
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
computer_<assetnumber>.json (interfaces, installed apps)
machine_<machinenumber>.json (PC<->Machine link, connection detail)
Read-only against the machine. Local reads may run elevated; publishing to a
share runs in the current user context only.
.NOTES
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# =============================================================================
# CONFIG - SET YOUR CSV DESTINATIONS HERE
# -----------------------------------------------------------------------------
# Two-stage flow so you can collect locally (possibly elevated) and then append
# the captured rows to a CSV store that lives on a network share:
#
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
# and merged. Collection can run elevated here.
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
# that you later point 'flask csv import --dir' at. Leave
# blank ('') to keep everything local. When set, the merged
# store is copied here at the end of the run. Network
# writes must happen as a STANDARD user - do NOT run the
# publish step elevated.
#
# Example network path: '\\fileserver\shopdb$\intake'
#
# Command-line -OutputRoot / -PublishRoot still override these values.
# =============================================================================
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
# Ship blank so a site that has not configured its share keeps everything local
# instead of publishing to another site's path. Set this to your own share (or
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
# fills this in from asset_import_csv_location when the site has set it.
$NetworkStoreRoot = ''
# Resolve effective destinations (command-line params win over CONFIG above).
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
# --- Store definitions (header order + natural key per importable table) -------
$script:Stores = [ordered]@{
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
}
# --- Shared helpers -----------------------------------------------------------
function ConvertTo-CsvField {
param([AllowNull()]$Value)
if ($null -eq $Value) { return '' }
$text = [string]$Value
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
return $text
}
function Get-Prop {
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
if ($null -eq $Object) { return $null }
$prop = $Object.PSObject.Properties[$Name]
if ($null -eq $prop) { return $null }
return $prop.Value
}
function Get-NonEmpty {
param([AllowNull()]$Value)
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
return ([string]$Value).Trim()
}
return $null
}
function Get-FirstNonEmpty {
param([object[]]$Values)
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
return $null
}
function Format-ShopDbDate {
param([AllowNull()]$Value)
if ($null -eq $Value) { return $null }
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
}
function Write-Utf8NoBom {
param([string]$Path, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
# values are kept when the incoming cell is empty, so a partial run never blanks
# out data an earlier run captured.
function Merge-CsvStore {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string[]]$Header,
[Parameter(Mandatory = $true)][string]$Key,
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
)
$index = [ordered]@{}
if (Test-Path -LiteralPath $Path) {
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
if (-not $keyVal) { continue }
$index[$keyVal.ToLowerInvariant()] = $existing
}
}
$changed = 0
foreach ($row in $IncomingRows) {
if ($null -eq $row) { continue }
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
if (-not $keyVal) { continue }
$lc = $keyVal.ToLowerInvariant()
if ($index.Contains($lc)) {
$target = $index[$lc]
foreach ($col in $Header) {
$incoming = Get-NonEmpty (Get-Prop $row $col)
if ($incoming) {
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
}
}
} else {
$fresh = [ordered]@{}
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
$index[$lc] = [pscustomobject]$fresh
}
$changed++
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add(($Header -join ','))
foreach ($stored in $index.Values) {
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
$lines.Add(($cells -join ','))
}
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
return $changed
}
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
function Resolve-ControllerVendor {
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
if ($ct -eq 'SIEMENS') { return 'Siemens' }
if ($ct -eq 'OKUMA') { return 'Okuma' }
return $null
}
function Read-RegExport {
param([Parameter(Mandatory = $true)][string]$Path)
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
}
$data = @{}
$currentKey = $null
foreach ($raw in $lines) {
$line = $raw.Trim()
if ([string]::IsNullOrWhiteSpace($line)) { continue }
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
if ($line -match '^\s*;') { continue }
if ($line -match '^\[(?<key>[^\]]+)\]$') {
$currentKey = $Matches['key']
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
continue
}
if (-not $currentKey) { continue }
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
$name = $Matches['name']
$rhs = $Matches['rhs'].Trim()
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
else { $val = $rhs }
$data[$currentKey][$name] = $val
}
}
return $data
}
function Get-RegExportValue {
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
foreach ($key in $Reg.Keys) {
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
}
}
return $null
}
function ConvertFrom-RegExport {
param([hashtable]$Reg)
[ordered]@{
source = 'reg'
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
}
}
function Get-RegistryValue {
param([string]$Path, [string]$Name)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
}
function ConvertFrom-LiveRegistry {
param([string]$Base)
if (-not (Test-Path -LiteralPath $Base)) { return $null }
$general = Join-Path $Base 'General'
$efocas = Join-Path $Base 'eFocas'
$ppdcs = Join-Path $Base 'PPDCS'
[ordered]@{
source = 'registry'
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
}
}
function ConvertFrom-UdcJson {
param([string]$Path)
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
$general = Get-Prop $obj 'GeneralSettings'
$ethernet = Get-Prop $obj 'EthernetSettings'
$serial = Get-Prop $obj 'SerialSettings'
[ordered]@{
source = 'json'
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
site = Get-NonEmpty (Get-Prop $general 'Site')
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
ncif = $null
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
}
}
function Find-FirstFile {
param([string[]]$Roots, [string]$Filter)
foreach ($root in $Roots) {
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($hit) { return $hit.FullName }
}
return $null
}
function Resolve-DncSource {
param([string]$RegPath, [string]$JsonPath)
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $JsonPath
}
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
}
foreach ($base in @(
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
$fromReg = ConvertFrom-LiveRegistry -Base $base
if ($fromReg -and $fromReg.machinenumber) {
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
return $fromReg
}
}
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
if ($localReg) {
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
}
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
if ($localJson) {
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $localJson
}
return $null
}
# --- PC collection (read-only) ------------------------------------------------
function Get-PcFacts {
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
$interfaces = @()
try {
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
$interfaces += [ordered]@{
interfacealias = [string]$cfg.InterfaceAlias
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
macaddress = [string](Get-Prop $adapter 'MacAddress')
}
}
} catch {}
$installedApps = @()
if (-not $SkipApps) {
foreach ($keyPath in @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
if (-not $displayName) { continue }
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
$installedApps += [ordered]@{
appname = $displayName
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
}
}
}
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
}
[ordered]@{
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
interfaces = $interfaces
installedapps = $installedApps
}
}
# --- Main ---------------------------------------------------------------------
Write-Host ''
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
$sidecarDir = Join-Path $OutputRoot 'sidecars'
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
$pc = Get-PcFacts
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
# Row sets keyed to the shared stores.
$vendorRows = @()
$modelRows = @()
$osRows = @()
$assetRows = @()
$computerRows = @()
$machineRows = @()
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
$assetRows += [pscustomobject]@{
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null
notes = 'PC collected ' + $collectedAt
}
$computerRows += [pscustomobject]@{
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
}
# Machine (best-effort).
$machine = $null
if (-not $SkipMachine) {
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
}
if ($machine -and $machine.machinenumber) {
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
$noteBits = @("DNC=$($machine.dnctype)")
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
$noteBits += "transport=$transport"
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
$assetRows += [pscustomobject]@{
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null; notes = $machineNote
}
$machineRows += [pscustomobject]@{
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
controllervendorid = $controllerVendor; controllermodelid = $null
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
}
$link = [ordered]@{
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
site = $machine.site; source = $machine.source; collectedat = $collectedAt
connection = [ordered]@{
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
comport = $machine.comport; baudrate = $machine.baudrate
}
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
} elseif (-not $SkipMachine) {
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
}
# --- Upsert into the shared store --------------------------------------------
$rowsByStore = @{
'vendors.csv' = $vendorRows
'models.csv' = $modelRows
'operatingsystems.csv' = $osRows
'assets.csv' = $assetRows
'computers.csv' = $computerRows
'machines.csv' = $machineRows
}
Write-Host ''
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
foreach ($fileName in $script:Stores.Keys) {
$spec = $script:Stores[$fileName]
$rows = @($rowsByStore[$fileName])
if ($rows.Count -eq 0) { continue }
$path = Join-Path $OutputRoot $fileName
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
}
# PC phase-2 sidecar.
$pcSidecar = [ordered]@{
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
interfaces = $pc.interfaces; installedapps = $pc.installedapps
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
# --- Publish (standard-user network mirror, optional) -------------------------
if ($PublishRoot) {
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host ''
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
}
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
# Merge THIS PC's rows into the shared store rather than overwriting it. A
# blind copy would erase every other PC's rows (issue: the store is meant to
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
# PC's run adds its rows and refreshes shared ones without dropping the rest.
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
# write the same store CSV at once and lose each other's rows.
$lockPath = Join-Path $PublishRoot '.publish.lock'
$lockStream = $null
for ($attempt = 0; $attempt -lt 60; $attempt++) {
try {
$lockStream = [System.IO.File]::Open(
$lockPath, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
break
} catch {
Start-Sleep -Milliseconds 500
}
}
if ($null -eq $lockStream) {
Write-Host ''
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
} else {
try {
foreach ($fileName in $script:Stores.Keys) {
$spec = $script:Stores[$fileName]
$localPath = Join-Path $OutputRoot $fileName
if (-not (Test-Path -LiteralPath $localPath)) { continue }
$localRows = @(Import-Csv -LiteralPath $localPath)
if ($localRows.Count -eq 0) { continue }
$sharePath = Join-Path $PublishRoot $fileName
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
}
# Sidecars are uniquely named per asset, so a straight copy is safe and
# nothing is overwritten across PCs.
$localSidecars = Join-Path $OutputRoot 'sidecars'
if (Test-Path -LiteralPath $localSidecars) {
$shareSidecars = Join-Path $PublishRoot 'sidecars'
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
}
} finally {
$lockStream.Close()
$lockStream.Dispose()
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
}
Write-Host ''
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
}
}
Write-Host ''
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
Write-Host ''

View File

@@ -0,0 +1,91 @@
# Collect-ShopDB.ps1 Overview
## Overview
- **Purpose**: A unified, read-only "site adoption" collector that runs on any
Windows PC, gathers facts about that PC plus any attached eDNC/DNC/UDC machine,
and merges them into a shared set of importable CSVs so a site can build its
ShopDB inventory one PC at a time.
- **Compatibility**: PowerShell 5.1+; CSV headers are aligned to the ShopDB
importer (`flask csv import --dir`).
## Configuration and destinations
- **Two-stage flow**: a fast local stage folder (`%ProgramData%\ShopDB\intake`)
where CSVs are built/merged, and an optional network store (a file share) that
the whole site accumulates into.
- Command-line params `-OutputRoot` and `-PublishRoot` override the in-script
CONFIG block.
- Other params: `-AssetNumber` (defaults to hostname),
`-AssetType`/`-ComputerType`/`-MachineType` (resolved by name), explicit
`-RegPath`/`-JsonPath` machine source overrides, and `-SkipMachine`/`-SkipApps`
switches for partial runs.
## Data model (the stores)
- Six importable CSVs, each with a defined header order and natural key:
`vendors.csv`, `models.csv`, `operatingsystems.csv`, `assets.csv`,
`computers.csv`, `machines.csv`.
- Column names follow ShopDB conventions (e.g., `assetid`, `modelnumberid`,
`controllervendorid`, `lastreporteddate`).
## PC collection (`Get-PcFacts`, read-only)
- Uses CIM/WMI (`Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`)
to gather hostname, vendor/manufacturer, model, OS caption, serial number,
last boot time, and logged-in user.
- Enumerates **network interfaces** (hardware adapters only: alias, IPv4, MAC).
- Enumerates **installed applications** from the registry Uninstall keys (name,
version, publisher), skipping system components - unless `-SkipApps` is set.
## Machine (eDNC/DNC/UDC) collection
- `Resolve-DncSource` searches in priority order: explicit JSON path -> explicit
.reg path -> live registry (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg`
export files -> local `udc_settings_*.json` files.
- Three parsers normalize the different source formats into one shape:
`ConvertFrom-RegExport` (parses exported `.reg` files including dword decoding),
`ConvertFrom-LiveRegistry`, and `ConvertFrom-UdcJson`.
- Extracts machine number, site, control type, DNC type, and connection detail
(TCP address/port for ethernet, COM port/baud for serial).
- `Resolve-ControllerVendor` maps control type / NcIF to a controller vendor
(e.g., eFOCAS -> FANUC, Siemens, Okuma).
## Merge / upsert logic (`Merge-CsvStore`)
- Field-level UPSERT keyed on each table's natural key: existing rows are matched
case-insensitively, and empty incoming cells never overwrite previously captured
values - so partial or repeated runs fill gaps without wiping earlier data.
- CSV writing is UTF-8 without BOM, with proper quoting/escaping.
## Sidecar files (phase-2 data CSV can't carry)
- Per-PC `sidecars\computer_<assetnumber>.json`: interfaces + installed apps.
- Per-machine `sidecars\machine_<machinenumber>.json`: PC-to-machine link
(relationship "controls"), DNC/control details, and connection transport info.
## Publishing and output
- Optionally mirrors the whole local store to the network share
(`Copy-Item -Recurse -Force`), with a warning if run elevated (network writes
should run as standard user).
- Prints a summary and the follow-up dry-run command:
`flask csv import --dir "<OutputRoot>"`.
---
## Relevance to an "Asset Collector" package
This script is a solid template for one collector in a broader package. Notable
patterns worth generalizing across other collectors (existing databases,
Intune/Azure exports, etc.):
- **Normalized intermediate schema** (the six CSV stores + JSON sidecars) that
all sources converge on, decoupling collection from import.
- **Idempotent field-level merge** so multiple heterogeneous sources can
contribute to the same asset without clobbering each other.
- **Source-priority resolution** and per-format parsers - the same shape could
wrap an Intune/Azure `Get-MgDevice` export or a SQL query.
- **Machines, PCs, Applications** are already modeled here; an Intune/Azure
source would map naturally onto `computers.csv` + the installed-apps sidecar,
while a MES/CNC database could feed `machines.csv`.

View File

@@ -0,0 +1,5 @@
assetnumber,name,serialnumber,assettypeid,statusid,locationid,businessunitid,notes
SAMPLE-PC-01,Shopfloor PC 01,DELLSN0001,computer,In Use,A1,Gage Lab,Sample collected device
SAMPLE-PC-02,CMM PC 02,DELLSN0002,computer,In Use,A1,Gage Lab,Sample collected device
SAMPLE-MC-01,Zeiss CMM 01,ZEISSSN0001,machine,In Use,A1,Gage Lab,Sample collected device attached to SAMPLE-PC-02
SAMPLE-MC-02,Haas VF-2SS 02,HAASSN0002,machine,In Use,A1,Gage Lab,Sample collected standalone CNC
1 assetnumber name serialnumber assettypeid statusid locationid businessunitid notes
2 SAMPLE-PC-01 Shopfloor PC 01 DELLSN0001 computer In Use A1 Gage Lab Sample collected device
3 SAMPLE-PC-02 CMM PC 02 DELLSN0002 computer In Use A1 Gage Lab Sample collected device
4 SAMPLE-MC-01 Zeiss CMM 01 ZEISSSN0001 machine In Use A1 Gage Lab Sample collected device attached to SAMPLE-PC-02
5 SAMPLE-MC-02 Haas VF-2SS 02 HAASSN0002 machine In Use A1 Gage Lab Sample collected standalone CNC

View File

@@ -0,0 +1,3 @@
assetid,computertypeid,hostname,osid,vendorid,modelnumberid,loggedinuser
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,EXAMPLE\operator1
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,EXAMPLE\operator2
1 assetid computertypeid hostname osid vendorid modelnumberid loggedinuser
2 SAMPLE-PC-01 Shopfloor PC WKS-SAMPLE01 Windows 10 Enterprise Dell OptiPlex 7090 EXAMPLE\operator1
3 SAMPLE-PC-02 CMM PC WKS-SAMPLE02 Windows 11 Enterprise Dell Precision 3660 EXAMPLE\operator2

View File

@@ -0,0 +1,3 @@
assetid,machinetypeid,vendorid,modelnumberid,requiresmanualconfig,islocationonly
SAMPLE-MC-01,CMM,Zeiss,CONTURA G2,0,0
SAMPLE-MC-02,CNC,Haas Automation,VF-2SS,0,0
1 assetid machinetypeid vendorid modelnumberid requiresmanualconfig islocationonly
2 SAMPLE-MC-01 CMM Zeiss CONTURA G2 0 0
3 SAMPLE-MC-02 CNC Haas Automation VF-2SS 0 0

View File

@@ -0,0 +1,5 @@
modelnumber,vendorid
OptiPlex 7090,Dell
Precision 3660,Dell
VF-2SS,Haas Automation
CONTURA G2,Zeiss
1 modelnumber vendorid
2 OptiPlex 7090 Dell
3 Precision 3660 Dell
4 VF-2SS Haas Automation
5 CONTURA G2 Zeiss

View File

@@ -0,0 +1,3 @@
osname
Windows 10 Enterprise
Windows 11 Enterprise
1 osname
2 Windows 10 Enterprise
3 Windows 11 Enterprise

View File

@@ -0,0 +1,4 @@
vendor
Dell
Haas Automation
Zeiss
1 vendor
2 Dell
3 Haas Automation
4 Zeiss

View File

@@ -2,14 +2,14 @@
This is **site-specific reference glue**, not product code. It maps West
Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the
maintained, schema-agnostic import contract in `docs/IMPORT-API.md`.
maintained, schema-agnostic import contract in the IMPORT-API wiki page.
**Every adopting site has its own source database.** Nobody else runs this
loader. Instead, copy the pattern:
1. Point the harness at your source DB(s) (edit `harness.Source`).
2. Write per-entity stages that read your tables and POST to the same
`docs/IMPORT-API.md` endpoints with `Authorization: Bearer <admin PAT>` and
the IMPORT-API wiki page endpoints with `Authorization: Bearer <admin PAT>` and
`X-Import-Mode: true`.
3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages
resolve foreign keys and a crashed run resumes.

View File

@@ -2,7 +2,7 @@
This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's
classic schema onto the maintained, schema-agnostic import contract in
docs/IMPORT-API.md. Other sites copy this pattern against their own source DB;
the IMPORT-API wiki page. Other sites copy this pattern against their own source DB;
they do not run this loader.
The harness builds the flask app against the current DATABASE_URL (point it at a
@@ -143,7 +143,7 @@ class Harness:
def _mint_admin_pat(self):
"""Create (or reuse) an admin user and an unscoped admin PAT. Unscoped
because import mode + role gates require full owner authority
(docs/IMPORT-API.md). Returns the clear secret."""
(the IMPORT-API wiki page). Returns the clear secret."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, Role, ApiToken
admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first()

View File

@@ -22,7 +22,7 @@
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
# FRAMEWORK_URL framework git URL for CI mode.
# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
# Default: https://github.com/ge-aero/shopdb-flask.git
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
#
# Examples:
@@ -37,13 +37,13 @@
# import-surface contract test - the scanner resolves each plugin dir before
# walking, since bare Path.rglob does not descend symlinks. Optionally keep an
# equivalent import-surface assertion in your own tests/ so violations fail
# in your repo's CI too (see docs/PLUGIN-EXTERNAL-REPO.md).
# in your repo's CI too (see the PLUGIN-EXTERNAL-REPO wiki page).
set -eu
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
FRAMEWORK_URL="${FRAMEWORK_URL:-https://github.com/ge-aero/shopdb-flask.git}"
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
if [ -z "$PLUGIN_DIR" ]; then