A bay with an NTLARS/eDNC MachineNo registry is identified by it, and a CMM by cmmid.txt. Everything else - Keyence, Genspect, wax-trace, part markers, and whatever a site declares next - had no stable identity at all, and the two mechanisms that stood in for one both key off the PC: reuse looks for a prior link from THIS PC asset, and minting builds `<PC number>-<SUFFIX>` where a PC's number is its hostname. Both survive a re-image. NEITHER survives a PC SWAP. A new hostname is a new PC asset with no prior link and a predicted number that has never existed, so the same physical device gets a second record while the first keeps its config and its backup history under a dead PC's name. That is how 43 legacy MT-#### tools ended up shadowed by minted twins, three records deep in places. The metrology path learned this and gained an explicit id file; the part-marker path was modelled on the metrology path as it stood BEFORE that fix, so it inherited the defect - and its own docstring said so, describing minting "the same way it already mints a CMM". C:\Enrollment\asset-id.txt is now that identity for both, and for anything declared later through subordinatedevice_<pctype>. It holds one line, the device's assetnumber, and deliberately does NOT record what kind of device it is: the pc-type already does, so a new device type needs no new file and no client change. Resolution puts it first, ahead of everything PC-derived. NOT machine-number.txt. machinenumber answers "which bay is this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device there would silently stop every bay-gated manifest entry from matching. A part marker still files partof the operation from machinenumber; asset-id.txt changes which marker the PC controls, not which operation that marker belongs to. The wire field is `deviceid` rather than `assetid`, because assetid is already this contract's RESPONSE field for a PC's integer primary key - the two would have sat side by side meaning different things. measuringtoolid, shipped in 0.12.0, is accepted as an alias and the client still reads the older file, so a bay staged in the last day keeps reporting; deviceid wins when both arrive. Two guards, shared by both device families: a value that resolves to nothing warns instead of minting a phantom, and a value that resolves to the WRONG KIND of asset is refused with the asset named, so a machine number pasted into the file cannot be filed under a device label the collector also owns. Row creation is now one helper instead of a copy per path, because the named and minted branches both need get-or-create on the extension row and the control link. 1744 tests green, including a new file that pins the swap case both ways - with the file one device, without it two. Five of its tests failed first time because the fixtures built bare assets with no extension row and the type guard refused them, which is the guard working. VERIFIED ON WINDOWS 11 (build 26200), five cases: the new file alone; both files present, new winning; the legacy file alone; neither, sending no field; and a padded value with a trailing line.
370 lines
16 KiB
PowerShell
370 lines
16 KiB
PowerShell
# Report-AssetToShopDB.ps1
|
|
#
|
|
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
|
|
# stays current with whatever the PC actually is right now: hostname, BIOS
|
|
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
|
|
# present) and its corp/AESFMA IPv4 address.
|
|
#
|
|
# TARGET: the ADR-006 collector API.
|
|
# POST <shopdb>/api/collector/computers
|
|
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
|
# which Install-GEEnforce.ps1 provisions and the enforcement client already
|
|
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
|
|
# product code is a defect, and this script ships to every site.
|
|
# The computers plugin's apply_collector_payload upserts idempotently by
|
|
# hostname (create if missing, patch-style update if present - only the fields
|
|
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
|
|
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
|
|
#
|
|
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
|
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
|
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
|
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
|
|
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
|
|
# key into the manifest JSON on the share.
|
|
#
|
|
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
|
|
# identity with no machineNo, so no PC-to-machine link is built - by design.
|
|
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
|
|
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
|
|
# logged, never thrown.
|
|
#
|
|
# WHY ONE NIC ONLY:
|
|
# Some bays carry two NICs - a private controller NIC and the routable
|
|
# corporate NIC. Only the routable one belongs in ShopDB. A site may name its
|
|
# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
|
|
# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
|
|
# the same intent without knowing any site's addressing.
|
|
|
|
param(
|
|
# Flask collector endpoint for the computers plugin. Empty resolves from
|
|
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
|
|
[string]$ApiUrl = '',
|
|
|
|
# Comma-separated CIDRs naming this site's routable ranges, e.g.
|
|
# '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
|
|
[string]$AllowedRanges = '',
|
|
|
|
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
|
|
# store in the registry. Override with -ApiKey via Args for testing.
|
|
[string]$ApiKey = '',
|
|
|
|
[int]$TimeoutSec = 30
|
|
)
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
|
|
# protocol the site rejects; the collector POST is HTTPS.
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
|
|
$logDir = 'C:\Logs\Shopfloor'
|
|
if (-not (Test-Path $logDir)) {
|
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
|
|
|
function Log([string]$msg) {
|
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
|
}
|
|
|
|
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
|
# running this script is running the enforcement client, which cannot work
|
|
# without BaseUrl, so it is present wherever this is deployed.
|
|
if (-not $ApiUrl) {
|
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
|
try {
|
|
if (Test-Path $regPath) {
|
|
$base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
|
|
if ($base -and $base.Trim()) {
|
|
$ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
|
|
break
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
if (-not $ApiUrl) {
|
|
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
|
|
exit 0
|
|
}
|
|
|
|
# A site may name its routable ranges rather than rely on the default route.
|
|
if (-not $AllowedRanges) {
|
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
|
try {
|
|
if (Test-Path $regPath) {
|
|
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
|
|
if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
# collector key from the GE-Enforce secret store when not passed via Args.
|
|
if (-not $ApiKey) {
|
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
|
try {
|
|
if (Test-Path $regPath) {
|
|
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
|
|
if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
if (-not $ApiKey) {
|
|
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
|
exit 0
|
|
}
|
|
|
|
# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
|
|
# unconfigured site falls through to the default-route NIC below (ADR-015).
|
|
#
|
|
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
|
|
# that collides with the [string] parameter above and the array is silently
|
|
# COERCED to a string. .Count on a string is 1, so the script then believes a
|
|
# range is configured and never falls back to the default route.
|
|
$rangeList = @()
|
|
foreach ($cidr in ($AllowedRanges -split ',')) {
|
|
$cidr = $cidr.Trim()
|
|
if (-not $cidr) { continue }
|
|
$parts = $cidr -split '/'
|
|
if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
|
|
$rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
|
|
}
|
|
|
|
function ConvertTo-Uint32([string]$ip) {
|
|
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
|
|
[Array]::Reverse($bytes)
|
|
return [BitConverter]::ToUInt32($bytes, 0)
|
|
}
|
|
|
|
function Test-InAllowedRange([string]$ip) {
|
|
try {
|
|
$ipInt = ConvertTo-Uint32 $ip
|
|
foreach ($r in $rangeList) {
|
|
$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 $true }
|
|
}
|
|
} catch {}
|
|
return $false
|
|
}
|
|
|
|
Log '=== Report asset to ShopDB (collector) ==='
|
|
|
|
# hostname - the collector identity field. required.
|
|
$hostname = $env:COMPUTERNAME
|
|
|
|
# BIOS serial - optional now (collector keys on hostname). Sent when present.
|
|
$serialNumber = ''
|
|
try {
|
|
$serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
|
|
if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
|
|
} catch {
|
|
Log "WARN could not read BIOS serial: $($_.Exception.Message)"
|
|
}
|
|
|
|
# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
|
|
# server-side (9999 placeholder skipped there and here).
|
|
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
|
|
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
|
|
# 3. C:\Enrollment\machine-number.txt - imaging value.
|
|
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
|
|
# Those bays name their DEVICE instead, in asset-id.txt below.
|
|
$machineNo = ''
|
|
foreach ($regPath in @(
|
|
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
|
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
|
|
)) {
|
|
if ($machineNo) { break }
|
|
try {
|
|
if (Test-Path $regPath) {
|
|
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
|
|
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
|
|
}
|
|
} catch {
|
|
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
if (-not $machineNo) {
|
|
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
|
|
if (Test-Path -LiteralPath $cmmFile) {
|
|
try {
|
|
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
|
|
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
|
|
}
|
|
}
|
|
if (-not $machineNo) {
|
|
$mnFile = 'C:\Enrollment\machine-number.txt'
|
|
if (Test-Path -LiteralPath $mnFile) {
|
|
try {
|
|
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
|
|
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
|
|
}
|
|
}
|
|
|
|
# OS name string (caption + feature-update + build), e.g.
|
|
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
|
|
# distinct string into operatingsystems.
|
|
$osVersion = ''
|
|
$lastBootTime = ''
|
|
try {
|
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
|
$osVersion = "$($os.Caption)".Trim()
|
|
$displayVersion = ''
|
|
try {
|
|
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
|
|
} catch {}
|
|
if ($displayVersion) { $osVersion += " $displayVersion" }
|
|
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
|
|
$osVersion = $osVersion.Trim()
|
|
# ISO 8601 - collector parses via datetime.fromisoformat.
|
|
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
|
|
} catch {}
|
|
|
|
# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
|
|
# (console session owner). empty when nobody logged on -> omitted so an
|
|
# unattended bay does not blank the last-known user.
|
|
$loggedInUser = ''
|
|
try {
|
|
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
|
|
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
|
|
} catch {
|
|
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
|
}
|
|
|
|
# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
|
|
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
|
|
# nothing else: it does not say what kind of device that is, because the pc-type
|
|
# already does, so the same file serves a part marker, a Keyence, a Genspect or
|
|
# anything added later without a new file or a client change.
|
|
#
|
|
# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
|
|
# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
|
|
# there would silently stop every bay-gated manifest entry from matching.
|
|
#
|
|
# WHY A FILE AT ALL: every other identity the server can fall back to is derived
|
|
# from the PC - a prior link from this PC, or an asset number built from this
|
|
# hostname - so none of them survive a PC SWAP. The replacement box mints a
|
|
# SECOND record for the same physical device while the first keeps its history
|
|
# under a dead PC's name. This file is what survives.
|
|
#
|
|
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
|
|
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
|
|
# of inventing a phantom device.
|
|
$deviceId = ''
|
|
$idFile = 'C:\Enrollment\asset-id.txt'
|
|
if (Test-Path -LiteralPath $idFile) {
|
|
try {
|
|
$deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
|
|
} catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
|
|
}
|
|
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
|
|
# as a fallback so a bay already staged with one keeps reporting.
|
|
if (-not $deviceId) {
|
|
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
|
|
if (Test-Path -LiteralPath $mtFile) {
|
|
try {
|
|
$deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
|
|
if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
|
|
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
|
|
}
|
|
}
|
|
|
|
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
|
|
# when present; absent -> server leaves existing pctype untouched (a bare report
|
|
# never re-types a PC). Unmapped values return a warning, not an error.
|
|
$pcType = ''
|
|
$ptFile = 'C:\Enrollment\pc-type.txt'
|
|
if (Test-Path -LiteralPath $ptFile) {
|
|
try {
|
|
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
|
|
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
|
|
}
|
|
|
|
# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
|
|
# present so a WMI read failure does not blank the model.
|
|
$manufacturer = ''
|
|
$model = ''
|
|
try {
|
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
$manufacturer = "$($cs.Manufacturer)".Trim()
|
|
$model = "$($cs.Model)".Trim()
|
|
} catch {
|
|
Log "WARN could not read make/model: $($_.Exception.Message)"
|
|
}
|
|
|
|
# The routable IPv4 - a physical, connected NIC. The collector schema takes a
|
|
# single ipaddress string, so report the corporate NIC and drop any
|
|
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
|
|
# with none, the NIC carrying the default route is the routable one by
|
|
# definition, which needs no knowledge of a site's addressing.
|
|
$corpIp = ''
|
|
$defaultRouteIfIndexes = @()
|
|
if ($rangeList.Count -eq 0) {
|
|
try {
|
|
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
|
|
Sort-Object RouteMetric |
|
|
Select-Object -ExpandProperty InterfaceIndex -Unique)
|
|
} catch {
|
|
Log "WARN could not read the route table: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
try {
|
|
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
|
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
|
foreach ($ipo in $ipObjs) {
|
|
if ($corpIp) { break }
|
|
$adapter = $null
|
|
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
|
|
if (-not $adapter) { continue }
|
|
if (-not $adapter.HardwareInterface) { continue }
|
|
if ($adapter.Status -ne 'Up') { continue }
|
|
if ($rangeList.Count -gt 0) {
|
|
if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
|
|
} elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
|
|
$corpIp = $ipo.IPAddress
|
|
}
|
|
}
|
|
} catch {
|
|
Log "WARN interface enumeration failed: $($_.Exception.Message)"
|
|
}
|
|
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }
|
|
|
|
# collector schema fields (lowercase concatenated). hostname is required; the
|
|
# rest are sent only when present so a partial read never blanks a good value.
|
|
$body = @{ hostname = $hostname }
|
|
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
|
|
if ($machineNo) { $body['machinenumber'] = $machineNo }
|
|
if ($pcType) { $body['pctype'] = $pcType }
|
|
if ($deviceId) { $body['deviceid'] = $deviceId }
|
|
if ($manufacturer) { $body['vendorname'] = $manufacturer }
|
|
if ($model) { $body['modelnumber'] = $model }
|
|
if ($osVersion) { $body['osname'] = $osVersion }
|
|
if ($lastBootTime) { $body['lastboottime'] = $lastBootTime }
|
|
if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser }
|
|
if ($corpIp) { $body['ipaddress'] = $corpIp }
|
|
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
|
|
|
|
$json = $body | ConvertTo-Json -Compress -Depth 4
|
|
|
|
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
|
|
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
|
|
|
|
try {
|
|
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
|
-ContentType 'application/json' `
|
|
-Headers @{ 'X-API-Key' = $ApiKey } `
|
|
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
|
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
|
|
} catch {
|
|
Log "ERROR POST failed: $($_.Exception.Message)"
|
|
}
|
|
|
|
exit 0
|