Audited all 40 docs/ against the live codebase; fixed factual staleness in 23, 14 were clean. Highlights (all verified against code): - equipment -> machines (ADR-011 rename) in INSTALL/DEPLOY-WINDOWS-IIS, PLUGIN-GUIDE, GE-ENFORCE, ROADMAP. - Versions refreshed: contract 0.10.0 -> 0.13.0, product 0.5.0 -> 0.7.0, plus plugin example core_version pins. - Bundled set corrected to the current 13 (PLUGINS.md 7 -> 13 rows; DEPLOY eleven -> thirteen). - Per-plugin Alembic chain workflow (ADR-008) replacing stale core-chain steps in PLUGIN-QUICKSTART / BACKUP-RESTORE; deploy adds plugin upgrade-all. - Frontend plugin staging (ADR-010) replacing 'no frontend plugin system yet' in PLUGIN-GUIDE; view/route paths repointed to plugins/<name>/frontend/. - Corrected file paths (MapView.vue, manifest_schema.json), CLI (shelf-list), API gating (GET /api/plugins is optional-jwt), WJF 15 -> 16 stages, and retired Collector/PC-Types settings pages (ADR-012). - ge-enforce proposal marked ACCEPTED/built.
33 KiB
Collector integration (PC auto-update)
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
classic ASP api.asp?action=updateCompleteAsset path. The generic collector
contract is defined in ADR-006; this doc is the operational reference for wiring
a real caller (the GE-Enforce fleet agent) to it.
- Server code:
shopdb/core/api/collector.py - Computers schema + upsert:
plugins/computers/plugin.py(get_collector_schema/apply_collector_payload) - Contract rationale:
docs/adr/ADR-006-collector-contract.md
1. How it works now
Auth model (header-only, env keys, fail-closed)
- The API key travels in the
X-API-Keyrequest header. Nothing else is accepted. The old?api_key=<key>querystring fallback has been removed on every collector endpoint (see the breaking-change section below). - Keys come from environment variables (never the database, never a file the app
serves):
COLLECTOR_API_KEY_<PLUGINNAME>- per-plugin override, uppercased plugin name (e.g.COLLECTOR_API_KEY_COMPUTERS).COLLECTOR_API_KEY- shared fallback used when no per-plugin key is set.- Resolution order per request: per-plugin key first, then the shared key
(
_plugin_api_keyincollector.py).
- Fail-closed: if neither variable is set server-side, the endpoint returns
HTTP 500
Collector API key not configuredand rejects every request. An unconfigured server never silently accepts unauthenticated data. - A caller that sends the wrong key (or no key) gets HTTP 401
Invalid API key. - In addition to the env keys, a managed API token scoped to
collector.ingestis accepted as a collector credential on every collector endpoint. See "Managed collector tokens" below; env keys remain the fallback.
Managed collector tokens (recommended)
Alongside the env keys, every collector endpoint (/api/collector/<plugin>,
/pc, /apps, /heartbeat, /bulk, /status) also accepts a managed API
token (PAT) scoped to the collector.ingest permission. The env keys stay
supported as a bootstrap/legacy fallback - nothing breaks - but a managed token
is the preferred credential because it can be minted, rotated, and revoked from
the UI (Settings > API Tokens) and its use shows up in lastusedat and the
audit log.
What makes a token a collector service token: it is scoped to ONLY
collector.ingest. That scope authorizes the collector ingest API and NOTHING
else. The existing scoped-token machinery contains it automatically - a scoped
token passes require_permission only for its listed permissions and is denied
on every role-gated (require_role) endpoint and on import mode, and
collector.ingest gates no normal route. So a collector token that leaks cannot
be used to read or write anything through the regular API; it can only submit
collector payloads.
Both wire transports are accepted (send whichever is convenient; GE-Enforce
sends X-API-Key today, so that stays ergonomic):
POST /api/collector/computers
X-API-Key: shopdb_pat_<40 hex>
or
POST /api/collector/computers
Authorization: Bearer shopdb_pat_<40 hex>
An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector
token and is rejected (401) - only collector.ingest in the scope list counts.
A revoked or expired token is rejected (401) on both transports.
How to mint one (admin flow)
The simplest contained flow: an admin mints the token, scoped to
collector.ingest. Because the token is scoped, the admin-role bypass is
suspended for it, so the token is contained to the collector API even though its
owner is an admin - it cannot act with admin authority anywhere.
- Settings > API Tokens > New Token.
- Check Restrict permissions, then in the permissions grid tick only
Submit collector payloads (fleet reporting) (the
collector.ingestpermission under the Collector category). Name it (e.g.wj-fleet-collector), optionally set an expiry, Create. - Copy the
shopdb_pat_...secret (shown once) and deploy it to the fleet the same way as the env key: thecollectorApiKeyfield in per-sitesite-config.json(see "Delivering the API key to clients" below). The client sends it inX-API-Keyexactly as it sends an env key today - no client code change.
Service identity (documented, not built): if you prefer a non-admin owner,
create a dedicated low-privilege user (e.g. svc-collector) whose role holds
only collector.ingest, plus apitokens.create if that user is to mint its own
token. The scope ceiling then caps any token it mints at collector.ingest.
The admin-minted route above is simpler and equally contained, so it is the
recommended default.
Rotation
Managed tokens rotate without a fleet re-image:
- Mint a new collector token (steps above).
- Deploy it via
site-config.json(collectorApiKey) - update the one per-site value. - Confirm the new token is in use: watch its
lastusedatclimb in Settings > API Tokens (and the old token'slastusedatgo stale). - Revoke the old token once traffic has moved. Revocation is immediate.
Generic endpoint contract: POST /api/collector/<plugin>
One dynamic route serves every enabled plugin that returns a collector schema.
For the PC fleet that is POST /api/collector/computers.
Request flow inside generic_collect:
- Look up the plugin's schema. Unknown / disabled plugin -> HTTP 404
No collector registered for plugin <plugin>. - Resolve and check the API key (per-plugin then shared, as above).
- Parse the JSON body. Missing or non-object body -> HTTP 400
No data provided. - Identity-field resolution: the schema names an
identityfield(hostnamefor computers). If that field is missing or blank in the payload, HTTP 400<identityfield> is required. - Idempotent upsert: the plugin's
apply_collector_payloadfinds the existing asset by identity and updates it, or inserts a new one. Same identity on a later submission updates the same row - re-imaging a PC does not duplicate it, and existing asset relationships are preserved. - Audit logging: every accepted submission writes an
AuditLogrow (action= created/updated, entityCollector,details = collector:<plugin> action=<action>), then commits.
Response body (HTTP 200), wrapped in the standard envelope
{status, data, message, meta}, with the collector result under data:
{
"status": "success",
"data": {
"status": "ok",
"action": "created",
"assetid": 12345,
"identityvalue": "WJRP2335",
"warnings": ["unknown operating system: Microsoft Windows 11 Enterprise 23H2 (build 22631)"]
},
"message": "computers collector created",
"meta": { "timestamp": "...", "requestid": "..." }
}
action is created, updated, or noop. warnings is a list of soft
problems (unmapped pc-type, unknown OS, unknown app, un-stored pcsubtype) that
did NOT fail the request - the row was still written.
Error responses
Errors use the same envelope with status: "error" and the detail under
data.error:
{ "status": "error", "data": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } }, "meta": { } }
The plugin can raise ValueError for controlled validation failures; its message
is returned verbatim (HTTP 400). Any other exception is caught, logged
server-side, the transaction is rolled back, and the caller gets a generic
HTTP 500 Internal error processing collector payload with no internal detail
(by design - check the server log to see what actually failed).
Schema discovery: GET /api/collector/_schemas
Returns the collector schema for every enabled plugin. This route is JWT-protected (an interactive/admin token), NOT API-key protected - it is for humans and tooling introspecting what payloads are accepted, not for the headless collectors themselves.
Legacy endpoints (computers-only, predate ADR-006)
These still exist for the older PowerShell callers and all require the same
X-API-Key header:
| Endpoint | Purpose |
|---|---|
POST /api/collector/pc |
Update one PC matched by hostname (lastboot, user, serial). |
POST /api/collector/apps |
Update installed apps for a PC (known apps only). |
POST /api/collector/heartbeat |
Record check-in for one or many hostnames. |
POST /api/collector/bulk |
Update many PCs in one call. |
GET /api/collector/status |
Liveness + endpoint list. |
New integrations should target POST /api/collector/computers, not these. Per
ADR-006 the legacy /pc path is deprecated and slated for removal before v1.0.
Computers plugin field mapping
Payload naming follows the project convention (lowercase concatenated). The
identity field is hostname. All other fields are optional; an omitted field
leaves the existing value untouched (patch-style), so a bare report never blanks
a column.
| Payload field | Type | Server behaviour (apply_collector_payload) |
|---|---|---|
hostname (required) |
string | Identity. Matches Computer.hostname (case-insensitive), then falls back to Asset.assetnumber. New asset created if no match. |
machinenumber |
string | Business tag -> Asset.assetnumber. The placeholder 9999 and empty string are skipped; when skipped a new PC falls back to assetnumber = hostname. On an existing PC a real value updates assetnumber. |
pctype |
string | gea-shopfloor-* imaging type -> Computer.computertypeid via the configurable pctypemap settings. Unmapped value -> warning, not error. |
pcsubtype |
string | Accepted but not stored yet -> warning. |
serialnumber |
string | Asset.serialnumber. |
loggedinuser |
string | Computer.loggedinuser. (currentuser is also accepted as a legacy alias.) |
lastboottime |
ISO-8601 datetime | Computer.lastboottime. Unparseable -> warning. |
lastcheckin |
ISO-8601 datetime | Accepted (heartbeat semantics). lastreporteddate is set server-side on every call regardless. |
ipaddress |
string | Primary Communication row (isprimary=True, type IP). Updated in place or created. |
vendorname |
string | Computer.vendorid. Vendor row auto-created if missing (free vocab). |
modelnumber |
string | Computer.modelnumberid, scoped to the vendor when known. Model row auto-created if missing. |
osname |
string | Computer.osid. Controlled vocab: looked up in operatingsystems, NOT auto-created. Unknown value -> warning (row still written, osid left unset). |
installedsoftware |
array of {name, version} |
ComputerInstalledApp rows for applications shopdb already tracks. Unknown app name -> warning, skipped. |
defaultprinter |
string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a defaultprinter relationship. Unresolved -> warning. |
printers |
array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a connectedto relationship (the default is skipped here since it already links as defaultprinter). Unresolved entries -> warning. |
Schema source of truth: get_collector_schema in plugins/computers/plugin.py.
If you change the payload, change it there and re-check this table.
PC -> printer relationship sync
When a payload carries defaultprinter and/or printers, the collector syncs
AssetRelationship rows so a PC page shows its printers and a printer page shows
the PCs that use it (both render in the shared Relationships card).
- Resolution: each identifier is matched, first hit wins, against the printer's
windowsname,hostname,sharename, its asset number/name, then any active printer communications IP. Case-insensitive except the IP (exact). An identifier that resolves to nothing adds a warning and is skipped; it never fails the whole push. - Link types: the default printer links with
defaultprinter(directional, PC is the source); every other reported printer links withconnectedto(symmetric). A printer that is both default and inprinterslinks only as the default. - Idempotent: re-reporting the same set creates no duplicate rows (an existing matching row is reactivated if it was archived, otherwise left as is).
- Stale-link archive: on every push, collector-created links to printers no
longer reported are set inactive. Collector-created rows are tagged in
assetrelationships.label = 'collector:printers'; only tagged rows are ever archived, so links you create by hand in the UI are never touched. A payload that omits BOTH printer keys leaves all existing printer links untouched (report an emptyprinters: []to clear the auto links instead). - Response: the collector response carries
printerlinkcountand aprinterlinkslist of{assetid, relationshiptype}for the links kept.
pc-type mapping (configurable per site)
pctype (e.g. gea-shopfloor-cmm) maps to a shopdb Computer Type through
pctypemap_<pxetype> settings. The "Collector PC Types" settings page is
retired (ADR-012): pc-type-to-Computer-Type handling now lives in GE-Enforce
(each imaging PC type is a manifest scope with its own computertypeid). The
built-in defaults in plugins/computers/pctypemap.py are still seeded on plugin
install and the collector still reads them, so existing enrollment keeps
working. Unmapped pc-types produce a warning, not a failure.
Classic api.asp field mapping (for porting the PowerShell reporter)
The fleet's classic-ASP reporter posts form fields to
api.asp?action=updateCompleteAsset. Map them to the collector JSON as follows:
Classic updateCompleteAsset form field |
Collector field |
|---|---|
hostname |
hostname |
machineNo |
machinenumber |
pcType |
pctype |
serialNumber |
serialnumber |
loggedInUser |
loggedinuser |
lastBootUpTime / lastBootTime |
lastboottime |
manufacturer |
vendorname |
model |
modelnumber |
osVersion |
osname |
installedApps |
installedsoftware |
Not carried over (no current home in the computers schema): warranty fields, DNC
config, multi-NIC detail beyond the single primary IP, VNC/WinRM flags. The
classic reporter posts a full networkInterfaces array; the collector accepts
only one ipaddress, so pick the corp/routable NIC (see the corp-range gate in
the PowerShell below).
2. Breaking change: querystring api_key removed
The API key must now be sent in the X-API-Key header. The ?api_key=<key>
querystring form has been removed on every collector endpoint
(/api/collector/<plugin>, /pc, /apps, /heartbeat, /bulk, /status).
Querystring keys leak into web-server access logs, proxy history, and browser
history. Header-only keeps the secret out of those logs.
Before (no longer works - the key is ignored and the request is rejected 401):
POST /api/collector/computers?api_key=SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
After (correct):
POST /api/collector/computers
X-API-Key: SECRET
Content-Type: application/json
{ "hostname": "WJRP2335", "machinenumber": "2335" }
PowerShell before/after:
# BEFORE (broken)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers?api_key=$key" `
-Method Post -Body $json -ContentType 'application/json'
# AFTER (correct)
Invoke-RestMethod -Uri "https://$SiteHost/api/collector/computers" `
-Method Post -Body $json -ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $key }
Any caller still putting api_key in the URL must move it to the header.
3. GE-Enforce implementation guide
GE-Enforce is the SYSTEM-context fleet agent that runs every enforcement cycle on each shopfloor PC (scheduled task, at-logon + periodic). It lives OUTSIDE this repo. The facts below are drawn from the real scripts so the payload matches what the PC already knows:
playbook/shopfloor-setup/common/GE-Enforce.ps1- the enforcement pass. At the end of each run it writes a status JSON to<share>\_outputs\logs\<hostname>\status.json(interim transport; the fleet dashboard reads those files). It logs viaWrite-EnforceLogtoC:\Logs\Shopfloor\enforce-YYYYMMDD.log. Its machine-number resolution is: registryHKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\GeneralthenHKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General, propertyMachineNo, skipping the9999placeholder, thenC:\Enrollment\machine-number.txtas fallback. hostname is[System.Environment]::MachineName(live NetBIOS name, not$env:COMPUTERNAME, which goes stale after a post-image rename).playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1- the existing HTTP reporter that POSTs to the classic ASPapi.asp?action=updateCompleteAsset. It runs every cycle as aType=PS1manifest entry (DetectionMethodAlways) under the SYSTEM task, logs toC:\Logs\Shopfloor\report-asset-YYYYMMDD.log, always exits 0, and reads the same machine number (reg, thencmm\cmmid.txt, thenmachine-number.txt) plus BIOS serial, OS caption, make/model, logged-in user, and corp-NIC IP from WMI. The function below is the direct-to-flask analogue of this reporter.playbook/shopfloor-setup/Shopfloor/lib/Update-MachineNumber.ps1- keeps the regMachineNoandC:\Enrollment\machine-number.txtin sync when a tech reassigns a bay, so both sources agree.
Paste-ready reporter
Drop this in as a Type=PS1 / DetectionMethod Always manifest entry (mirroring
Report-AssetToShopDB.ps1), or dot-source Send-ShopdbCollectorReport from
GE-Enforce.ps1 and call it at the end of the pass. It reads the machine number
exactly the way GE-Enforce already does, builds a payload matching the computers
collector schema, and POSTs with the X-API-Key header over TLS 1.2. Every
field name below was checked against get_collector_schema in
plugins/computers/plugin.py.
The X-API-Key value can be EITHER a COLLECTOR_API_KEY[_COMPUTERS] env key OR
a managed token scoped to collector.ingest (a shopdb_pat_... secret; see
"Managed collector tokens"). The script is identical for both - it just carries
whatever collectorApiKey the site-config supplies - so switching a site from an
env key to a managed token (and rotating it) is a config change, not a script
change.
# Send-ShopdbCollectorReport.ps1
# Reports this PC's identity to shopdb-flask via POST /api/collector/computers.
# Runs as SYSTEM from the GE-Enforce cycle. Always exits without throwing;
# failures are logged, never fatal.
function Get-ShopdbCollectorApiKey {
# NEVER hardcode the key in this script (it lives on the share). Read it
# from a per-site value the SYSTEM/machine account can already reach.
# Preferred: a field in the site-config.json GE-Enforce already parses.
# Fallback: a machine-local file staged at imaging, ACL'd to SYSTEM.
$key = ''
$siteCfg = 'C:\Enrollment\site-config.json'
if (Test-Path -LiteralPath $siteCfg) {
try {
$cfg = Get-Content -LiteralPath $siteCfg -Raw | ConvertFrom-Json
if ($cfg.collectorApiKey) { $key = "$($cfg.collectorApiKey)".Trim() }
} catch {}
}
if (-not $key) {
$keyFile = 'C:\Enrollment\collector-api-key.txt'
if (Test-Path -LiteralPath $keyFile) {
try { $key = (Get-Content -LiteralPath $keyFile -First 1 -ErrorAction Stop).Trim() } catch {}
}
}
return $key
}
function Get-ShopdbMachineNumber {
# Same resolution GE-Enforce.ps1 / Report-AssetToShopDB.ps1 use:
# eDNC registry (skip 9999 placeholder) then C:\Enrollment\machine-number.txt.
$machineNumber = ''
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
)) {
if ($machineNumber) { break }
if (Test-Path $rp) {
try {
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v -and "$v".Trim() -ne '9999') { $machineNumber = "$v".Trim() }
} catch {}
}
}
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
try {
$v = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction Stop).Trim()
if ($v -and $v -ne '9999') { $machineNumber = $v }
} catch {}
}
return $machineNumber
}
function Get-ShopdbCorpIPv4 {
# Pick the corp/AESFMA NIC IP. Same allowed-range gate as
# Report-AssetToShopDB.ps1 - update the ranges if the site re-VLANs.
$allowedRanges = @(
@{ Network = '10.134.48.0'; PrefixLen = 23 },
@{ Network = '10.48.249.0'; PrefixLen = 26 }
)
function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
try {
$ips = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ips) {
$ipInt = ConvertTo-Uint32 $ipo.IPAddress
foreach ($r in $allowedRanges) {
$netInt = ConvertTo-Uint32 $r.Network
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $ipo.IPAddress }
}
}
} catch {}
return ''
}
function Send-ShopdbCollectorReport {
param(
[string]$SiteHost = 'tsgwp00525.wjs.geaerospace.net',
[string]$ApiKey = (Get-ShopdbCollectorApiKey),
[int]$TimeoutSec = 30,
[string]$LogFile = ('C:\Logs\Shopfloor\collector-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
)
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
function Write-CollectorLog([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts $msg" | Tee-Object -FilePath $LogFile -Append | Out-Null
}
Write-CollectorLog '=== Report to shopdb-flask collector ==='
if (-not $ApiKey) {
Write-CollectorLog 'ERROR no collector API key (site-config.json / collector-api-key.txt) - skipping.'
return
}
# PowerShell 5.1 does not always negotiate TLS 1.2 by default.
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
# --- Gather identity (matches the computers collector schema) ---
$hostname = [System.Environment]::MachineName
if (-not $hostname) { $hostname = $env:COMPUTERNAME }
$machineNumber = Get-ShopdbMachineNumber
$ipAddress = Get-ShopdbCorpIPv4
$serialNumber = ''
try {
$serialNumber = "$((Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber)".Trim()
} catch { Write-CollectorLog "WARN BIOS serial read failed: $($_.Exception.Message)" }
$vendorName = ''; $modelNumber = ''; $loggedInUser = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$vendorName = "$($cs.Manufacturer)".Trim()
$modelNumber = "$($cs.Model)".Trim()
# UserName is COMPUTERNAME\user or DOMAIN\user; keep the bare username.
if ($cs.UserName) { $loggedInUser = ($cs.UserName -split '\\')[-1].Trim() }
} catch { Write-CollectorLog "WARN ComputerSystem read failed: $($_.Exception.Message)" }
$osName = ''; $lastBootTime = ''
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osName = "$($os.Caption)".Trim()
$dv = ''
try { $dv = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion } catch {}
if ($dv) { $osName += " $dv" }
if ($os.BuildNumber) { $osName += " (build $($os.BuildNumber))" }
$osName = $osName.Trim()
# ISO-8601 so the server's datetime parse accepts it.
try { $lastBootTime = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch {}
} catch { Write-CollectorLog "WARN OS read failed: $($_.Exception.Message)" }
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
try { $pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
$pcSubType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-subtype.txt') {
try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {}
}
# --- Installed printers (Win32_Printer). The Default flag marks the one
# default printer. We report each printer's port name (an IP or a queue
# host for network printers) and fall back to the share/printer name, which
# the collector resolves flexibly against printer windowsname/hostname/IP. ---
$defaultPrinter = ''
$printerIds = @()
try {
$printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop
foreach ($p in $printers) {
if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote)
# Prefer the port name (IP or queue host); fall back to ShareName,
# then the printer Name.
$identity = $p.PortName
if (-not $identity) { $identity = $p.ShareName }
if (-not $identity) { $identity = $p.Name }
if (-not $identity) { continue }
$printerIds += $identity
if ($p.Default) { $defaultPrinter = $identity }
}
$printerIds = @($printerIds | Select-Object -Unique)
} catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" }
# --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname }
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }
if ($serialNumber) { $payload['serialnumber'] = $serialNumber }
if ($loggedInUser) { $payload['loggedinuser'] = $loggedInUser }
if ($lastBootTime) { $payload['lastboottime'] = $lastBootTime }
if ($ipAddress) { $payload['ipaddress'] = $ipAddress }
if ($vendorName) { $payload['vendorname'] = $vendorName }
if ($modelNumber) { $payload['modelnumber'] = $modelNumber }
if ($osName) { $payload['osname'] = $osName }
$payload['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# installedsoftware is optional; add an array of @{ name=..; version=.. }
# here if the manifest introspection is wired to real Application names.
$uri = "https://$SiteHost/api/collector/computers"
$json = $payload | ConvertTo-Json -Depth 5
$headers = @{ 'X-API-Key' = $ApiKey }
Write-CollectorLog ("POST {0} host={1} machineNo={2} pcType={3} serial={4} ip={5}" -f `
$uri, $hostname, $machineNumber, $pcType, $serialNumber, $ipAddress)
try {
$resp = Invoke-RestMethod -Uri $uri -Method Post -Body $json `
-ContentType 'application/json' -Headers $headers `
-TimeoutSec $TimeoutSec -ErrorAction Stop
$d = $resp.data
Write-CollectorLog ("OK action={0} assetid={1} identity={2} warnings={3}" -f `
$d.action, $d.assetid, $d.identityvalue, ((@($d.warnings)) -join '; '))
} catch {
$code = $null
try { $code = [int]$_.Exception.Response.StatusCode.value__ } catch {}
Write-CollectorLog "ERROR POST failed (http=$code): $($_.Exception.Message)"
}
}
# Entry point when run as a standalone manifest PS1 entry:
Send-ShopdbCollectorReport
Where the call slots into the GE-Enforce run
Put the POST at the END of the enforcement pass, after the enforcement scopes are processed and the PC identity is settled. Two placements, pick one:
- Preferred - a sibling manifest entry, mirroring
Report-AssetToShopDB.ps1: addSend-ShopdbCollectorReport.ps1as aType=PS1, DetectionMethodAlwaysmanifest entry incommon\manifest.json. It runs every cycle under the SYSTEM task, keeps GE-Enforce.ps1 untouched, and gets its own log file. This matches the established pattern the classic reporter already uses. - Alternative - inline: dot-source the function and call
Send-ShopdbCollectorReportinside GE-Enforce.ps1's status-write-backtryblock (right afterstatus.jsonis written, near the end of the pass). Use this only if you want the HTTP push to share GE-Enforce's own log and lifecycle.
Either way, run it ALONGSIDE the existing status.json share write (and
alongside the classic Report-AssetToShopDB.ps1) during rollout - do not remove
the share write until the HTTP path is proven.
Delivering the API key to clients
Do NOT hardcode the key in the script - the script itself lives on the SFLD share, so a literal key there is effectively published to everyone with share read. Two viable options:
- Per-site value the machine account already reads. GE-Enforce already parses
C:\Enrollment\site-config.json(its local copy of the per-site config on the share). Add one field,collectorApiKey, populated per site. The script reads it with the machine/SYSTEM identity it already runs as. Rotation = update the one config value at the site, no re-image, no script redeploy. - Baked at imaging into a machine-local file (
C:\Enrollment\collector-api-key.txt) or an HKLM value, ACL'd to SYSTEM/Administrators, written by the enrollment pipeline. Tighter blast radius (the key never sits on the share at all), but rotation requires touching every PC or re-imaging.
Recommendation: use option 1 (collectorApiKey in the per-site config), matching
how GE-Enforce already sources its share paths and mirroring how SFLD credentials
are provisioned per site. It keeps the secret out of source control and off the
script, sits at the same trust boundary as everything else the SYSTEM agent
already reads, and supports rotation without a fleet touch. Get-ShopdbCollectorApiKey
above already prefers this and falls back to the imaging-baked file, so a site
can start on option 1 and tighten to option 2 later with no code change. The
server side pairs this with COLLECTOR_API_KEY_COMPUTERS (per-plugin) so the PC
fleet's key is scoped to the computers collector only.
Staged rollout
- Keep everything as-is:
status.jsonshare write + classicReport-AssetToShopDB.ps1continue running. ProvisioncollectorApiKeyper site and setCOLLECTOR_API_KEY_COMPUTERSon the shopdb-flask server. - Add
Send-ShopdbCollectorReportas a new manifest entry on a handful of pilot bays (one per pc-type: collections, cmm, keyence, waxtrace, ...). It logs toC:\Logs\Shopfloor\collector-YYYYMMDD.logand cannot break enforcement (it never throws, exits cleanly). - Verify on the server: pilot PCs appear/update in shopdb-flask,
actioniscreated/updated, warnings are understood (map any unmappedpctype, add any missing OS strings to theoperatingsystemsvocab). Cross-check the collector log'sOKlines against the audit log. - Roll the manifest entry out fleet-wide (it deploys from
common\, so every pc-type gets it). Continue running both the share write and the HTTP POST. - Once shopdb-flask is the system of record and stable across a full imaging
cycle, retire the classic
Report-AssetToShopDB.ps1and, if desired, thestatus.jsonshare write.
4. Troubleshooting
| Symptom | HTTP | Cause / fix |
|---|---|---|
Invalid API key |
401 | The X-API-Key header is missing or wrong. Confirm the client key matches COLLECTOR_API_KEY_COMPUTERS (or the shared COLLECTOR_API_KEY) on the server. Check the key is being read (Get-ShopdbCollectorApiKey returned non-empty). Remember the querystring form no longer works. |
Collector API key not configured |
500 | Fail-closed: neither COLLECTOR_API_KEY_COMPUTERS nor COLLECTOR_API_KEY is set in the server environment. Set one and restart the app. This is a SERVER config gap, not a client problem. |
No collector registered for plugin computers |
404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
hostname is required / No data provided |
400 | Empty body, non-JSON body, or missing identity field. Check -ContentType 'application/json' and that hostname is set. |
Internal error processing collector payload |
500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (Collector upsert failed for computers). Check there. |
warnings present but action is created/updated |
200 | Soft issues only; the row WAS written. Common: unmapped pctype (fix the pctypemap setting), unknown osname (add it to the operatingsystems vocab), unknown app name, pcsubtype not stored. No action needed unless the warning matters to you. |
Client-side log for the fleet reporter: C:\Logs\Shopfloor\collector-YYYYMMDD.log.
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
schema failures there).