177 Commits

Author SHA1 Message Date
cproudlock
0d9c4fdac4 Rename every copy of an MTConnect device name, not just the agent's
Set-MachineNumber rewrote one Devices.xml per variant and restarted the agent.
That is right for Okuma and eDNC, which keep the device name in that one file -
checked across 7 Okuma and 15 eDNC bay captures. Fanuc and Makino keep it in
several, and editing only the agent's copy leaves the adapter streaming under
the old identity.

It has already happened. Seven of thirty-four captured Fanuc bays have the
agent on the right machine number and the adapter still on the bay it was
imaged from: 4007 on a 7801 Toshulin, 3031 on a 7804, 2005 against 2006 on a
dual-spindle pair. Both captured Makino bays have Devices.xml on the machine
number and the other three files still on MAKINO-1.

Update-MTConnectVariantName renames across a declared set of files with the
owning services stopped first. Makino needs that ordering more than most: the
vendor's guide says the Adapter Manager rewrites the COMPLETE configuration
from memory as it stops, so an edit made while it runs is discarded, and that
the name must be identical in every file or the agent may not start.

It collects every name in play before rewriting rather than discovering one.
A bay half-renamed by the old code carries two at once, and converging on the
one that happened to be found leaves the other behind - which is the state this
is meant to end, not reproduce. Nothing is written if a service will not stop,
and whatever was stopped is started again.

Tested on Windows 11 against copies of the real captures: the 7801 bay (adapter
4007) converges to 7801 in two files and leaves the correct agent alone; the
7502 Makino bay converges all four; second runs report no changes. Service
ordering verified by process id, using stand-in services - sc.exe fakes named
after the real ones are not real services, so the service list is a parameter
defaulting to the production names.

Does not fix the bays already in this state; they need a run each.
2026-08-11 14:28:03 -04:00
cproudlock
54cbe6b5d6 Bring the share's common scripts under version control
Sixteen files that run on every shopfloor PC existed only on the SFLD share.
The cost showed up while debugging the NTLARS backup: the script that posts to
ShopDB could not be read, reviewed or diffed, so its behaviour was inferred
from log output for most of a day. It turned out to hold a silent fallback that
had been governing the whole fleet for months.

Imported as-is from tsgwp00525-v2, no edits:

  lib/ShopdbBackupClient.psm1        the shared backup client
  scripts/Backup-NtlarsSettings.ps1  converted to use it
  scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery
  scripts/Test-RegExport.ps1         exercises the .reg codec with mocks
  scripts/Set-EventSaver*.ps1        kiosk power / screensaver / disable
  scripts/Setup-OpenText.*           OpenText install + toolbar
  scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1,
  scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd

lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines
AHEAD of this repo and purely additive: the Add-EnforceResult reporting added
during the kiosk API cutover, done live and never committed back. Nothing was
removed.

Checked for embedded secrets before committing; there are none.
Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the
share rather than holding it, so the script is safe to track.

The share remains what actually runs. This makes it reviewable, and makes the
next drift visible as a diff rather than a surprise.
2026-08-11 12:36:38 -04:00
cproudlock
b075939c38 GE-Enforce: roll undated logs so retention can actually reach them
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.

Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.

Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.

A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.

Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
2026-08-11 09:03:28 -04:00
cproudlock
6d5fee786c Proposal: fold in what a day of fixing the current pipeline taught
New section 9a - five design rules, each from a bug fixed on 2026-08-06 and each
cheap to honour in a new design but expensive to retrofit:

  Absent and empty are different. Four scripts tested config as
  "not null AND Count > 0", so an explicitly empty list fell through to a
  hardcoded default - a kiosk configured with "desktopApps": [] was given the
  full machine-tool set. Configuring "none" produced "everything".

  Deny by default. preinstall.json had 16 of 21 entries at PCTypes ['*'] and the
  SMB manifest 18 of 25 unfiltered. Both filters worked perfectly; they just were
  not applied.

  Gate at the point of action. Call-site gates on PC type were bypassed by the
  finalization phase, silently.

  The image carries configuration. WJ Shopfloor.lnk is inside the WIM, so no
  pipeline change removes it.

  The ppkg is a hard boundary. Chrome, RealVNC, Tanium, CyberArk and the rest come
  from the package - thinning the image does not thin them.

Also corrects a premise in section 4.2: a first-boot selection UI cannot assume
the API is reachable, because ShopDB needs the AESFMA wifi SSID and the imaging
LAN has no route to it. Points at Install-ShopdbKiosk-WhenOnline.ps1 as the
reusable shape - arm a task at imaging, do nothing until the API answers, act
once, verify, self-remove.

Plus two risks, the diagnostics collector in the reference map, and a gotchas
section: HardwareDriver.json defeats ConvertFrom-Json on every PowerShell
version, Compress-Archive caps at 2 GB against an 8 GB package, and PowerShell
Core normalises backslashes on Linux so bay scripts can be tested here.
2026-08-06 17:36:17 -04:00
cproudlock
0e1b80b903 preflight: stop ConvertFrom-Json choking on the driver catalogue
The driver check - the most valuable check in this script - has never actually
run. It died on:

  Driver check failed: Cannot convert the JSON string because a dictionary that
  was converted from the string contains the duplicated keys 'FileName' and
  'FileName'

HardwareDriver.json carries both casings of the same fields - fileName and
FileName, destinationDir and DestinationDir. ConvertFrom-Json treats object keys
case-insensitively and refuses the document. -AsHashtable would handle it but
that is PowerShell 6+, and WinPE runs 5.1. Confirmed it throws on PowerShell 7
too, so no version of ConvertFrom-Json can read this file as-is.

Pulls the four needed fields out of each entry by regex instead, preferring the
lowercase key and falling back to the capitalised one, and unescaping the
backslashes in destinationDir.

Tested against the real 44-entry catalogue, all three outcomes:
  OptiPlex Micro 7020, pack present  -> OK, win11_optiplexd13mlk7020_a09.zip
  same model, pack removed           -> FAIL, names the missing path
  Surface Laptop 7                   -> FAIL, no pack matches

Worth noting the check was failing SAFE - a WARN that reads like a tooling
glitch rather than a missing driver pack. It would have stayed invisible until a
bay imaged with no NIC.
2026-08-06 17:24:04 -04:00
cproudlock
8907a86c42 Collector: decode StartupApproved 0x04 and 0x05
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
2026-08-06 17:16:57 -04:00
cproudlock
a7cb7164d9 Kiosks stop installing shopfloor apps, and empty config stops meaning everything
PREINSTALL SCOPE

Sixteen entries carried PCTypes ['*'], so a Display kiosk installed Adobe
Acrobat, OpenText HostExplorer, WJF Defect Tracker, the shopfloor serial drivers
and twelve legacy VC++ redistributables. Those are now scoped to the nine
machine-tool types.

A Display now installs 3 of 21 entries:
  PowerShell 7.5.4                    SFLD-ApplyDSCConfig runs pwsh.exe 7
  VC++ Redistributable 2015-2022 x64  modern x64 runtime Edge/WebView2/PS7 lean on
  Display Kiosk App                   already Display-scoped

Kept as wildcards deliberately - removing the x64 runtime risks breaking the
kiosk itself, and PS7 is a hard dependency of the DSC task. Everything else a
kiosk has no use for. Edited as targeted text replacement, 16 line pairs, so the
file's formatting and its long _comment blocks are untouched.

THE LAST OF THE EMPTY-MEANS-DEFAULT BUGS

Configure-PC.ps1 and 07-TaskbarLayout.ps1 had the same test as the two fixed
earlier: $null -ne $cfg -AND .Count -gt 0, so an explicitly empty list fell
through to a hardcoded shopfloor default. Configure-PC is the one that actually
bit - it runs in the finalization phase and put these in the all-users Startup
folder on 579C144, timestamped mid-image:

    Defect Tracker.lnk   16:46:03
    Plant Apps.lnk       16:46:03

Plant Apps launching msedge --new-window on a kiosk. eDNC was in the same
hardcoded list and only escaped because its Test-Path guard found no DncMain.exe.

08-EdgeDefaultBrowser.ps1 had it too, with a Plant Apps + WJ Shopfloor + Dashboard
tab fallback. Harmless today because the Display profiles configure a real tab,
but one edit away from biting.

Get-ProfileValue returns $null only when a key is absent from BOTH the profile
and site-config, so $null is the only honest "not configured" signal and an
empty array means what it says.

VERIFIED against the post-fix capture: the S: mapper Run key and the machine
number prompt task are both gone, and 'ShopDB Kiosk Bootstrap' registered
correctly with boot and time triggers.

Also: the collector's StartupApproved decoder now handles 0x04/0x05, which that
same capture surfaced as unknown.

Still baked into the WIM and unaffected by any of this: WJ Shopfloor.lnk, dated
April, sitting in the all-users Startup folder.
2026-08-06 16:58:53 -04:00
cproudlock
28efde7d76 Stop Display bays getting machine-tool shortcuts, prompts and the S: mapper
Three fixes from the 579C144 diagnostics, all the same shape: a Display kiosk
being treated as a machine-tool bay.

desktopApps: configuring "none" produced "everything"

  "Display-Dashboard": { "desktopApps": [] }

06-OrganizeDesktop.ps1 tested $null -ne $cfgApps -AND .Count -gt 0, so an
explicitly empty list fell through to the hardcoded fallback and the kiosk was
given UDC, eDNC, NTLARS, WJ Shopfloor and Defect_Tracker shortcuts.
Get-ProfileValue returns $null only when the key is ABSENT from both the profile
and site-config, so $null is the real "not configured" signal and an empty array
means what it says. startupItems carried the same test - harmless today because
its else branch has no fallback list, but commented so the two cannot drift.

Machine-number prompt on a machine with no machine number

Register-CheckMachineNumberTask.ps1 armed 'Prompt Machine Number' (AtLogOn,
BUILTIN\Users) whenever the number was the 9999 placeholder - which is always
true on a Display, because startnet only collects a real number for machine-tool
types. A logon dialog on a kiosk with no keyboard. Now skips PC types that have
no machine number by design, and clears any stale task.

S: mapper on a share-less PC

Displays are Entra-joined with local accounts and no SFLD credentials, so mapping
S: can only fail, once per logon, forever. Run-ShopfloorSetup already gated the
CALL on $noEnforceTypes, but the bay registered it anyway at 15:07:55 with no
"Skipping" line in the log - so something in the finalization phase reaches the
registrar past that gate. Rather than chase the caller, the registrar now gates
itself and removes a stale Run entry. The call-site gate stays; this makes the
outcome correct regardless of who invokes it.

That bypass is worth understanding separately - the same pattern would defeat any
call-site gate in the finalization phase.

All three parse clean and are deployed byte-identical to the share.
2026-08-06 16:23:18 -04:00
cproudlock
d5cf255443 Kiosk bootstrap: look for the installer where staging actually puts it
The bootstrap looked for C:\Enrollment\kiosk\Install-ShopdbKiosk.ps1, but
startnet copies the type-specific tree to
C:\Enrollment\shopfloor-setup\gea-shopfloor-display\ - so the payload lands
beside the bootstrap, not under C:\Enrollment.

As written it would have logged "Installer not staged" on every 15-minute cycle
and never installed anything, with no error anywhere. Exactly the silent-failure
shape this whole day has been about, and it would have looked like the API being
unreachable.

Resolves via a -KioskRoot parameter defaulting to kiosk\ next to the script, so
the bootstrap and its payload travel together regardless of where the tree is
staged. display-type.txt still comes from EnrollmentRoot, which is correct - that
one really is at C:\Enrollment.
2026-08-06 16:12:41 -04:00
cproudlock
7ed30f9b85 Display: install the ShopDB enforce client once AESFMA is reachable
A PXE-imaged display ends up with no GE-Enforce client at all. Confirmed on
579C144, 2026-08-06:

    is the client installed?            NOT FOUND
    scheduled tasks that would run it?  NONE

Not a broken configuration - nothing had ever tried. Install-ShopdbKiosk.ps1
downloads itself from {BaseUrl}/installers/kiosk over HTTPS, and ShopDB is only
reachable after the bay joins the AESFMA wifi SSID, so it cannot run during
imaging. Nothing was arranged to run it afterwards.

09-Setup-Display.ps1 now registers 'ShopDB Kiosk Bootstrap' as a SYSTEM task at
boot and every 15 minutes. Install-ShopdbKiosk-WhenOnline.ps1 does nothing until
ShopDB answers, then runs the vendor installer once, verifies BaseUrl and the
enforce task exist, deletes the staged key and unregisters itself. A bay cabled
up days later still self-configures.

It deliberately does not reimplement the installer - it waits, invokes, verifies
and cleans up, so the vendor script can be replaced wholesale without touching
this.

The key file is LABELLED (collector=, fetch=) rather than positional. The two
tokens are not interchangeable and a mix-up is silent: a fetch token in the
collector slot leaves asset reporting broken while everything looks configured.
A bare unlabelled line is ignored rather than guessed at. Missing keys are not
fatal - the fetch token is unnecessary on an IP-allowlisted subnet, and the
installer skips the asset-report task rather than failing.

Staged on the share: the bootstrap, the vendor installer under kiosk\, and the
updated display setup. The key itself is NOT staged yet - see the commit
discussion; it needs to be labelled with which scope it carries first.
2026-08-06 15:58:15 -04:00
cproudlock
c4aeaaaa17 Collector: exclude the 8 GB package, fix base-URL detection, note the AESFMA dependency
Three defects found by the first real run, on 579C144.

16 GB collection, zip failed. run-enrollment harvests
C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
provisioning package existed TWICE under the trees being copied. Compress-Archive
cannot exceed 2 GB and died with "stream was too long". Copies now exclude
*.ppkg, *.wim and *.iso and cap individual files at 100 MB.

Base-URL detection matched the wrong value. The name pattern accepted anything
containing "base", so it picked up baseVersion=2.0.2 from
HKLM:\SOFTWARE\GE\SFLD\Credentials and then probed "2.0.2/api/docs" - making
both UNREACHABLE lines meaningless. Now matches the name exactly and requires the
value to start with http.

Unreachable was reported as a bare failure. ShopDB is only reachable once the bay
has joined the AESFMA wifi SSID, so on the imaging LAN or plain wired an
unreachable result is EXPECTED. It now says so, rather than looking like a fault.

The run still answered the question it was written for: no enforce client is
installed and nothing is scheduled to call the API, so this bay was never going
to reach GE-Enforce regardless of network.
2026-08-06 15:38:42 -04:00
cproudlock
54176e591f Collector: record whether each startup item is enabled or disabled
Disabling a startup item through Task Manager or Settings does not remove the Run
key or the Startup shortcut - Windows writes a flag into StartupApproved instead.
So the collector would have listed those entries as present with no indication
they had been switched off, and a bay where someone had already tidied up would
look identical to one where nothing autostarts.

Reads all five StartupApproved keys, including Run32 for the WOW64 view, and
decodes the first byte: 02/06 enabled, 03/07 disabled.

This matters for the current investigation because turning an item off is a
per-machine workaround - the next imaged bay installs and starts it again. The
fix is not installing it, which needs the app-to-autostart mapping this section
preserves.
2026-08-06 15:28:47 -04:00
cproudlock
d185e2b810 Collect everything needed to debug an imaged bay, in one pass
Every imaging investigation so far has meant walking to a bay and hand-copying
files, and each time discovering another thing we wished we had grabbed at the
same moment. This takes the lot. Read-only; changes nothing.

Collects: computer name (active AND pending, which is how you tell whether the
package's H<serial> -> F<serial> rename has landed or is still queued for the
next reboot), dsregcmd enrollment state, the package self-checks, installed
applications, everything that starts by itself, kiosk shortcut targets, Edge
policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the
whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx.

Two things it does deliberately:

Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry
views, all Startup folders, non-Microsoft scheduled tasks, and auto-start
services outside C:\Windows. That combination is what identifies which installer
planted a given autostart entry. A 32-bit installer's Run key lands under
Wow6432Node where 64-bit tooling never looks, which is exactly how the old
Dashboard/Lobby autostart survived an earlier purge.

It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on
disk proves nothing - a client can be present, configured, and never once
succeed. It reports whether a client exists at all, whether anything is scheduled
to run it, the configured base URL, and then actually probes the endpoint. A 401
is a good result: it proves DNS, routing and TLS work and the service answered.
Only a timeout or DNS failure means unreachable. No token is sent.

Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of
every installed product, which is slow and can change the machine.

Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay
straight off the line carries applications that should not be there, because
preinstall.json entries without a PCTypes filter install everywhere. The point is
to have an exact record of what imaging really produces so the unnecessary items
can be identified and filtered. After lockdown you cannot tell whether something
is absent because lockdown removed it or because imaging never installed it.

Staged on the enrollment share alongside the other shopfloor-setup scripts.
2026-08-06 15:24:25 -04:00
cproudlock
68f2ce29e4 Proposal: full reference map, and correct the LTSC autostart assumption
REFERENCES (section 13, was six lines, now a map)
Where every referenced thing lives, so the document is usable by someone without
today's context: this repo, the PXE server share, shopdb-flask, the on-bay log
locations, and the evidence bundles each claim came from. Plus re-derivation
recipes - pulling startnet out of boot.wim, extracting customizations.xml from an
8 GB .ppkg without unpacking it, reading the provisioning evtx, and the two
validation commands - and the 2026-08-06 commit list.

LTSC AUTOSTART - CORRECTED
Section 7.1 previously argued PESetup might already auto-start: AutoStartCountDown
is 31, disableauto.json is absent, and the operator on 579C144 clicked Next at 11
seconds, so nobody had waited long enough to find out.

Per the team's operational experience that is wrong for our media. LTSC builds
cannot autostart - the SELECT OS confirmation is mandatory regardless of the
countdown, and every image we produce is Windows 11 LTSC 24H2.

So the countdown is not a route to zero-touch. That promotes re-implementing the
imaging step from a contingency to the actual decision, and the risk table and
open questions now frame it as "re-implement, or accept one click per build" -
zero-touch being the only thing that requires it. The hands-off test is kept, but
only to confirm and record the behaviour first-hand.
2026-08-06 15:05:22 -04:00
cproudlock
f442ff2f65 Propose zero-touch, ShopDB-driven imaging
Design proposal, nothing built. Two ideas in one architecture: the image becomes
thin (partition, apply, join Entra, install the enforce client - everything else
is data pulled from the API and enforced by GE-Enforce), and imaging becomes a
job rather than a menu (open a machine in ShopDB, click Create this PC, pick the
zeroed box waiting in WinPE, and it images start to finish reporting progress
onto that machine's record).

Every claim about current behaviour is sourced from 2026-08-06 evidence -
decompiled binaries, live logs off bay 579C144, and the live share - and where
something is inferred it says so.

Covers: why the current early-decision chain fails silently (five worked examples
from one day); what already exists to build on; per-machine configuration records
replacing 147 .reg files and two CSVs, referencing payloads rather than embedding
them; the dual-NIC design with the firewall work that gates it; whether PESetup
needs replacing at all; the BPRT token shelf-life constraint on pre-imaged stock;
an ownership table; six delivery phases each useful alone; risks; and seven open
questions.

Two findings in here are worth acting on before any of it:

  disableauto.json is ABSENT from the media and AutoStartCountDown is 31, while
  the operator on 579C144 clicked Next at 11 seconds. PESetup may already
  auto-start and nobody has waited long enough to find out. One bay, hands off
  the keyboard, count to 40.

  dnsmasq is already safely bound (listen-address + bind-dynamic) so DHCP will
  not leak onto a GE-side NIC - but Samba binds to all interfaces and every ufw
  rule is ALLOW IN Anywhere, including SSH on a pxe/pxe account. That work gates
  dual-homing.
2026-08-06 15:02:08 -04:00
cproudlock
a0aceb4680 startnet: define the server address and share credentials once
The PXE server address appeared 14 times and the share credentials 13, so moving
the server or rotating the account meant editing every net use in the file and
rebuilding boot.wim - with no way to tell from a diff whether one had been
missed. Now set once at the top as PXESERVER / SHAREUSER / SHAREPASS.

Exactly one literal address remains, the definition on line 6. Prose in REM
comments is left alone so the paths stay readable.

This also reaches the post-boot self-heal for free. startnet already writes
C:\Enrollment\fetch-source.txt, and both Fetch-StagingPayload.ps1 and
Verify-And-Heal-Staging.ps1 read it (line1=UNC, line2=user, line3=pass) with
their own hardcoded values only as a fallback. Confirmed on 579C144:

  [13:30:35] [INFO] fetch-source.txt: UNC=\172.16.9.1\enrollment user=pxe-upload
  [13:30:36] [INFO] Mounting \172.16.9.1\enrollment as Z: (attempt 1/5)...

so the seeded file wins and the heal follows PXESERVER automatically. My earlier
claim that the heal scripts were hardcoded was wrong - they were already
config-driven and startnet was already seeding them. The hardcoding was all in
startnet itself.

Verified: set on line 13, first use on line 20; parens balance; every goto
resolves; 1005 CRLF lines with no bare LF; the deployed copy contains exactly one
literal address. boot.wim md5 7a9b832b.
2026-08-06 14:41:40 -04:00
cproudlock
bb08392b84 download-drivers: reuse an existing directory that differs only in case
Dell and GE manifests spell the same folder inconsistently - OptiPlex vs
Optiplex. Windows does not care, but the Samba share sits on a case-sensitive
filesystem, so a blind mkdir -p created a SECOND tree and the drivers split
between them.

That is exactly how the OptiPlex Micro 7020 pack went missing: the manifest asked
for OptiPlex/D13MLK while the 3.2 GB zip sat in Optiplex/D13MLK. PESetup found no
pack, logged a warning rather than an error, and the bay imaged with no network
drivers - so DNS failed at first boot and bulk enrollment could not reach the
CDN. Symptoms three steps from the cause.

mkdir_ci walks the path one component at a time and reuses whatever is already
there whatever its case, creating only genuinely new components. Callers must use
the RETURNED path, since it may differ in case from the requested one. Falls back
to plain mkdir -p rather than skipping a download if the resolve fails.

Both spellings still exist in the live catalogues; scripts/lint-driver-catalogue.py
reports a case mismatch as an error so the pair cannot silently drift again.
2026-08-06 14:38:54 -04:00
cproudlock
9d51c0b987 Ask tenant + PC purpose at the PXE menu; add the missing 3D kiosk display option
PC PURPOSE / TENANT FOR STANDARD AND ENGINEER

The capability was already there - the media carries all six MCL packages
(PERS/SH/LOAN x GCCH/RoW) and Start-BulkEnrollOrchestrator.ps1 picks one from
whatever Select-PCConfig.ps1 returns. The problem was WHEN it asks: a GUI at
first logon, so an otherwise unattended build stops at a dialog until someone
walks over.

Now startnet asks after Standard/Engineer is chosen, while the tech who knows
what the machine is for is standing at it, and writes {Tenant}_{Purpose} to
C:\Enrollment\pc-config.txt. A shim at the path the orchestrator already calls
returns that value and falls through to the vendor dialog when it is absent or
malformed - so "ask at first logon" stays available and bays imaged before this
are unaffected. The vendor script is preserved as Select-PCConfig-vendor.ps1;
replace THAT when the vendor ships a new one.

The tenant is not cosmetic. Insert-BPRTToPPKG.ps1 matches the bulk token on
Tenant AND Purpose, so this selects which token gets injected.

ONLY THE CHOICE IS PRE-SEEDED, deliberately. Injection stays at first logon
because it fetches an encrypted token table from mcl.dwcdn.geaerospace.com,
unreachable from the isolated imaging LAN - which is also why routing MCL
packages through run-enrollment.ps1 would not work: provtool would get a package
whose token is still the Exp_XXXXXXXX placeholder and join nothing.

Standard/engineer previously skipped staging entirely (no ppkg, no PCTYPE), so
the staging gate now also admits PCCONFIG.

3D PARTS KIOSK

The display submenu offered Dashboard and Lobby only. The GE-Enforce display
scope has always had a third entry - 3DPrintRoom -> /shopdb/parts-kiosk - so a
parts kiosk could only be set by editing display-type.txt by hand after imaging.
Added as option 3. The value is a KEY into that map, so the spelling matches
exactly.

Verified: startnet parens balance, every goto resolves, 994 CRLF lines with no
bare LF; shim parses clean. Deployed - boot.wim 4d16c946, shim staged with the
vendor dialog preserved.
2026-08-06 14:37:20 -04:00
cproudlock
68df59e117 Record who owns what, and report repo-vs-share drift
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.

share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.

Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.

First run: 8 git-owned pairs all match, 4 known-unreconciled.

Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.

The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
2026-08-06 14:28:16 -04:00
cproudlock
d2200e8522 Preflight: check what PESetup fails on, before it fails on it
TWO SCRIPTS, DIFFERENT AUDIENCES

playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:

  secure boot   GatherDataSelection fails outright when SecurebootEnabled != 1
  disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
  driver match  reimplements GetDriverByModel - family filter, untrimmed
                comma-separated substring tokens, first match wins - and checks
                the pack is actually on the media
  media age     media expires 30 days after build; Media.tag's timestamp is the
                local proxy

The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.

scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.

First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.

Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
2026-08-06 14:22:01 -04:00
cproudlock
8c21282024 Verify staging in WinPE, harvest imaging logs, make enrollment run once
Three changes aimed at the same failure mode: a bay that images green and is
silently unusable, diagnosed only by walking over and copying files off by hand.

VERIFY STAGING (startnet, at :pctype_done)
Checks pc-type.txt, Run-ShopfloorSetup.ps1, shopfloor-setup/common and
shopfloor-setup/<PCTYPE> exist on the applied volume before anything depends on
them, retries the small trees once, and prints a loud banner if the retry does
not fix it. The enrollment package is checked but never blindly re-pulled - it is
8 GB, so a miss is reported instead.
Done here because a tech is still at the machine: a short copy found in WinPE
costs 30 seconds, the same copy found at first logon costs 20 minutes, and found
never costs a rebuild. Four Display bays sat green at stage 2 for weeks.

HARVEST LOGS (startnet, after PESetup exits)
Collects X:\*.log, the generated X:\Unattend.xml, PESetup's own log from the
target's Panther directory and winpe-staging.log into
\<server>\enrollment\imaging-logs\<serial>\, plus a build-context.txt naming
PCTYPE, PPKG, machine number and media. All of it was being discarded at reboot.
Runs after PESetup exits so the logs are final, which means re-mapping Y: since
cleanup already dropped it. Best-effort throughout - a bay must never fail to
reboot because a log copy failed.

W: WAIT CAP 20 -> 45 MINUTES
270 polls instead of 120. Fine on NVMe either way, but a WIM apply can exceed 20
minutes on slow media, and the failure mode is the os_not_found banner plus
nothing staged. The loop still exits the moment the SYSTEM hive appears.

RUN-ENROLLMENT RUNS ONCE
Marker at C:\Enrollment\.ppkg-applied, written on exit 0 and also on 0x800700B7
ERROR_ALREADY_EXISTS. Observed running twice on 579C144; the second pass
re-applied a pending rename over the package's own and otherwise did nothing.

Verified: startnet parens balance, every goto resolves, 899 CRLF lines with no
bare LF; run-enrollment parses clean under the PowerShell parser. Deployed -
boot.wim md5 159c2a4d, live run-enrollment dce9d50a.
2026-08-06 14:18:25 -04:00
cproudlock
36be60e9ae run-enrollment: stop renaming the computer, the PPKG owns it
The package declares <DNSComputerName>F%SERIAL%</DNSComputerName>, so bays are
meant to come up as F<serial>. This script hardcoded a rename to E<serial>,
which is a second pending rename racing the package's own.

Observed on 579C144, 2026-08-06:

  13:31:55  run-enrollment  Rename-Computer -> E579C144      (pending)
  13:32:17  ppkg            Reboot/DeviceName -> F%SERIAL%   (pending)
            Provisioning-Diagnostics: "Reboot successfully scheduled.
            Device name set successfully."
  13:36:45  run-enrollment runs AGAIN -> E579C144            (overwrites F)
  ~13:37    reboot; E579C144 wins

Last writer wins at reboot, so the hardcoded prefix silently beat the package.
The bay came up E579C144 with the ppkg reporting no errors at all - 535
Provisioning-Diagnostics records, every one of them Level 4.

Nothing in this repo ever produced an F name; grep for a prefix rule or a
namePrefix setting finds nothing. The convention only works if naming is left to
the package, so the rename is removed rather than corrected to F. That also
makes it site-agnostic: a different package can name bays differently without a
script edit.

The double execution is a separate defect and is left alone for now. With the
rename gone a second run is harmless: provtool returns 0x800700B7
ERROR_ALREADY_EXISTS and changes nothing.
2026-08-06 14:06:58 -04:00
cproudlock
b96ff7e970 Retire the shopfloor-dashboard route; point Lobby at its own page
The dashboard moved to /shopdb/shopfloor. Every reference here still pointed at
/shopdb/shopfloor-dashboard/, which no longer resolves.

Display-Lobby was also pointing at the DASHBOARD url, not the lobby one, so lobby
screens showed the shop floor dashboard. The routes now follow the authoritative
map in shopdb-flask plugins/geenforce/seed_display_scope.py:

    Dashboard    -> /shopdb/shopfloor
    Lobby        -> /shopdb/tv
    3DPrintRoom  -> /shopdb/parts-kiosk   (no imaging-time menu option yet)

Changed: urls.shopfloorDashboard, Display-Dashboard.edgeHomepage,
Display-Lobby.edgeHomepage, and the Shopfloor Dashboard fallback in
08-EdgeDefaultBrowser.ps1.

Note these values are now a backstop rather than the primary path. Displays are a
single image whose variant comes from C:\Enrollment\display-type.txt, and the
GE-Enforce display scope delivered over the shopdb API writes the kiosk Startup
shortcut itself - preferring the server-side role from Settings > Dashboard
Defaults and falling back to display-type.txt. That dispatcher also SWEEPS
shortcuts matching shopfloor-dashboard, so a stale value here would have been
deleted on the next enforce cycle rather than honoured.

Edited by line position, not string match: the two edgeHomepage values were
textually identical and belong to different display blocks. Formatting preserved
- a json.dumps round-trip reflowed 632 lines and was reverted.
2026-08-06 12:30:35 -04:00
cproudlock
d04865da0f docs: record the PESetup version actually in production; match LogonCount to the live unattend
PESETUP-INTERNALS.md was written from a decompiled 4.0.0.17. The media in
production reports 4.0.0.20 in its own log. Rather than restate the document as
4.0.0.20, which would claim a re-derivation that has not happened, it now names
both: line-level claims are 4.0.0.17, and the behaviour re-observed on bay
579C144 on 2026-08-06 is listed so a reader knows which parts are confirmed
current - media drive Z:, W: created by PrepareDisk and used for every copy
destination, the fallback Deploy\FlatUnattendW10.xml being the unattend that
loads, and driver selection by model.

FlatUnattendW10-shopfloor.xml carried LogonCount 7 while the live shopfloor
unattend has 12, and Run-ShopfloorSetup.ps1's comment about topping up the
autologon budget already said 12. The live value is the real one, so the repo
follows it.

Both files still lint clean under scripts/lint-unattend.py.
2026-08-06 12:25:30 -04:00
cproudlock
2b6189f739 startnet: stage against PESetup's own W:, and stop reporting failed copies as successes
Brings the repo back in line with what is actually running in boot.wim, validated
end to end on bay 579C144 today.

WHAT THIS FILE NOW DOES DIFFERENTLY

Applied-volume detection. The volume finder and the diskpart letter reassignment
are gone. PESetup hardcodes W: in nine places and creates it during its own disk
preparation, so there is nothing to search for, and searching actively broke
staging: a scan run before PESetup finished disking returned the PREVIOUS
install, startnet relabelled that partition W:, and PESetup's own DISKPART then
erased it mid-copy. Timestamps from that failure are in the comment so nobody
rebuilds the finder from the same premise. The wait is back on
W:\Windows\System32\config\system, which only exists once the WIM apply has
written it.

Copy reporting. Eight sites echoed "Copied ..." unconditionally, so a run where
every robocopy exited 16 and moved zero bytes still printed six success lines.
They now branch on errorlevel 8 and say FAILED, including in the staging log.
%ERRORLEVEL% is no longer printed inside parenthesised blocks - it expands at
parse time there and showed a stale value. The WaxTrace test moved above its
mkdir, which sets its own errorlevel.

Per-PCTYPE media. Maps Z: to _media\<PCTYPE> and probes
Z:\Deploy\Control\HardwareDriver.json before trusting it, falling back to the
shared media with a warning.

findstr -> find. findstr.exe is not in this WinPE image. It failed with errorlevel
9009, which "if errorlevel 1" read as true, so an SFLD package took the non-SFLD
branch and never got its BPRT-tagged name.

VERIFIED ON A REAL BAY, from C:\Enrollment\winpe-staging.log:

    PPKG=GCCH_Prod_SFLD_NoOffice_US_Exp_20260831_v4.16.ppkg
    Copied PPKG GCCH_Prod_SFLD_v4.16.ppkg as GCCH_Prod_SFLD_NoOffice_US_Exp_...
    Shopfloor 44 files / common 17 / _ntlars 147 / display 1 / preinstall 85
    FAILED: 0 across every copy
2026-08-06 12:25:18 -04:00
cproudlock
385acfefa7 Lint unattend answer files before they reach a bay
Well-formed XML is not enough. Windows Setup validates against a schema and one
bad value invalidates the WHOLE answer file for its pass, so the machine stops at
a dialog with nothing configured and the only clue is an XPath buried in
C:\Windows\Panther\setupact.log.

On 2026-08-06 every shopfloor, standard and engineer build was failing exactly
that way:

    /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
    Description = Value is invalid.   hrResult = 0x80220005   pass = specialize

An inlined "powershell.exe -Command ..." had grown to 676 characters in a field
capped at 259, and had been broken since the previous evening. Every check here
is mechanical and would have caught it before a bay was booted:

  path-too-long         RunSynchronousCommand/Path     > 259
  cmdline-too-long      SynchronousCommand/CommandLine > 1024
  description-too-long  Description                    > 256
  duplicate-element     a once-only element appearing twice
  not-well-formed       XML does not parse
  bom                   UTF-8 BOM (the live files have none, and python's
                        utf-8-sig silently ADDS one when writing them back)
  unknown-token         %token% PESetup will not substitute; %WINDIR% and the
                        other shell variables are excluded or it cries wolf

Reads over SSH via base64 so a BOM or CRLF survives the hop unchanged. Exits
non-zero on any ERROR so it can gate a deploy.

Immediately found a second live defect the manual review had missed: a 329-char
Description on gea-engineer Order 11, which would have failed the oobeSystem pass
once specialize started passing. All three live image types and both repo copies
now lint clean.
2026-08-06 12:24:59 -04:00
cproudlock
d5f7abc19f BIOS: cover the whole OptiPlex 7020 family, not just Tower Plus
An OptiPlex Micro 7020 reported "no update in catalog" at the WinPE menu. The
firmware was staged all along - OptiPlex_7020_1.22.1_SEMB.exe, 102 MB, sitting on
the share - but models.txt listed only "Tower Plus 7020". check-bios.cmd matches
with find /I, a substring test against the WMI model name, and "Tower Plus 7020"
is not a substring of "OptiPlex Micro 7020", so every Micro, SFF and Tower 7020
silently skipped its firmware update.

The 7010 family three lines above already shows the intended shape: one SEMB
package behind Micro, SFF Plus and Tower Plus entries. Dell packages 7020 the
same way, covering Micro, Micro Plus, SFF, SFF Plus, Tower and Tower Plus, so all
six now point at the one package and the stray Tower Plus line moves up to join
its family.

Verified by simulating the matcher over the manifest: all six variants resolve to
the 7020 package, no duplicate tokens, and 7010 still resolves to its own. Note
that "Small Form Factor 7020" spelled out does NOT match - the short form is
correct, since HardwareDriver.json's modelswminame field uses SFF 7020.

Deployed to the live share, where check-bios.cmd reads it directly off B:, so no
boot.wim rebuild is involved. Previous manifest kept at
/home/pxe/models-rollback-20260806.txt.
2026-08-06 11:10:13 -04:00
cproudlock
049dd7bde6 startnet: map the per-PCTYPE media view for shopfloor builds
Maps Z: to _media\<PCTYPE> instead of the shared gea-shopfloor media, so
PESetup's unfiltered CopyPackages stages only this type's payload rather than
every shopfloor payload on every bay.

The mapping is validated rather than assumed: it probes
Z:\Deploy\Control\HardwareDriver.json, which resolves only when the view exists
AND its symlinks are intact, so a dangling view cannot image silently off a
half-mapped drive. On any failure it falls back to the shared media with a
console warning - the bay still images, it just stages the union, which is
exactly today's behaviour. That fallback also covers the second WJ PXE box,
which has no _media views yet.

Z: is freed before the mapping. A leftover mapping makes net use fail with
"device already in use", and the probe would then pass against the OLD media -
a silent wrong-payload build, which is the failure this change exists to
prevent.

Views are built by scripts/build-pctype-media.py.
2026-08-06 11:03:12 -04:00
cproudlock
c7b17696d0 Build per-PCTYPE PESetup media views out of symlinks
CopyPackages copies the whole of <media>\Deploy\Applications to the target,
recursively, with no manifest and no filtering, so every bay built from the
shared gea-shopfloor media receives every shopfloor payload whatever its type.

The obvious fix - repoint a path inside the shared media as each bay picks its
type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a
path while another bay is mid-copy either hands it the wrong payload or fails its
imaging outright, intermittently and unreproducibly.

Give each type its own media directory instead, built once and never mutated.
Everything in it is a symlink, so a type costs inodes rather than gigabytes and
the shared media stays untouched while bays read it. Samba already serves this
pattern: follow symlinks and wide links are on, and every image root reaches
Sources, Operating Systems, Out-of-box Drivers and Packages the same way.

Types come from the enrollment share's menu.json, the file the WinPE picker
already renders, so the media set cannot drift from the boot menu. Deliberately
NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the
real unattend - the view is the clean set PESetup actually reads. Each type is
built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees
the old tree or the new one, never a half-built one.

Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An
SMB client sees ordinary directories and files - Sources, Control,
FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the
behaviour that actually matters, since PESetup reads this over SMB from WinPE.

Note for anyone extending this: passing the server-side script on stdin to
"echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe,
reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
2026-08-06 10:56:18 -04:00
cproudlock
dfa026b4ee Lint the driver catalogue against PESetup's real matcher
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.

Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:

  virtual-platform  one such entry flips the tool into virtual-only mode and
                    hard-fails every physical machine
  empty-token       a trailing comma yields "", and Contains("") is true for
                    every model, so that entry swallows the catalogue
  token-whitespace  Split(',') does not trim, so " OptiPlex 3010" needs the
                    space present in the model string too
  case-mismatch     the share is case-sensitive; Optiplex vs OptiPlex splits the
                    tree and the pack is never found
  missing-zip       referenced pack absent
  family-mismatch   the family filter runs first, so a token whose line
                    contradicts the family field can never match
  shadowed          first match wins, so a later entry may be unreachable
  duplicate-token   osId is not part of the match, so a win10 pack can land on a
                    win11 build purely by ordering

--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.

Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
2026-08-06 10:46:28 -04:00
cproudlock
936902dc4d Document how PESetup.exe actually works
Written from the decompiled assembly rather than from observation. Three
long-standing beliefs about this tool are wrong, and each has cost real
debugging time:

W: is not a guess. PESetup hardcodes it in nine places - every copy
destination, the DISM offline sessions, bcdboot, reagentc - and creates it
during its own disk preparation. startnet.cmd's volume finder, diskpart
reassignment and W: wait loop are machinery built around a problem that does
not exist.

The copy steps do not filter. CopyPackages copies the whole of
Deploy\Applications recursively to W:\Deploy\Applications - no manifest, no
unattend parsing, no extension or size rules. Anything dropped there reaches the
target, which is the basis for staging our own payload without the enrollment
share. It is also fail-fast: one unreadable file fails the whole step.

A missing driver match is a WARNING, not an error. GetDriverByModel does a
substring test of comma-separated tokens with first-match-wins, the family
filter knows only Latitude, OptiPlex and Precision, and a single 'virtual
platform' entry in the catalogue fails every physical machine. A miss lets
imaging finish with no drivers, so no NIC, so DNS failures at first boot -
symptoms far from the cause, and easily misattributed to the drive letter.

Includes the bundle-extraction recipe so the next person can re-derive all of
this instead of trusting this document.
2026-08-06 08:04:48 -04:00
cproudlock
27e76467a8 manifest engine: resolve installer paths without the PS provider
Join-Path routes a drive-qualified path through the PowerShell provider, so
it returns null when that drive has gone away mid-cycle, and every consumer
then bound the null straight into Test-Path -LiteralPath and crashed the
entry with a message that names neither the entry nor the path.

Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which
does plain string math, and have all six callers treat a null resolve as
"not found" and log it. Only the PS1 branch had a guard before, and it
covered a null Script value, not a null resolved path.

The helper deliberately avoids [IO.Path]::Combine: its separator and
rooted-path rules follow the host platform, so behaviour cannot be verified
off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"),
which Combine would have resolved outside the share root. Checked all 36
path values across the share manifests: none are rooted today.

Behaviour verified against the real manifest values (forward slashes,
backslashes, trailing-slash root, null, whitespace, rooted, UNC).
2026-08-03 17:23:00 -04:00
cproudlock
66c24b5d59 shopfloor: stop manifest entries hijacking GE-Enforce's drive letter
GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce
cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two
scripts that run inside that cycle, as SYSTEM in the same drive namespace,
mapped W: for their own use and deleted it on exit:

  Restore-UDCData.ps1     manifest PS1 entry, mounts the UDC backup share
  Update-MachineNumber.ps1  "Apply Machine Number" task, same backup share

Once W: is gone, the next manifest entry's Join-Path resolves against a dead
drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than
throwing, so the null lands in Test-Path -LiteralPath and the entry dies with
"Cannot bind argument to parameter 'LiteralPath' because it is null".

Observed on a collections bay: the controller-credential entry, which runs
immediately after UDC Data Restore, failed this way while the entry one line
earlier had resolved a W: path successfully one second before.

Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC
backup share. Neither letter is referenced anywhere else in the tree; V: was
not an option because Restore-EDncReg and Update-MachineNumber's NTLARS
restore already use it.

The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the
manifest loop and blames idle SMB timeout, but the whole cycle took one
second, so timeout was never the cause.
2026-08-03 17:22:50 -04:00
cproudlock
1a175bc4fe FlatUnattend: revert broken prereqs block, keep OpenText shopfloor switch
The .NET3.5/VC++ prereqs block (11 RunSynchronousCommands with ~260-char doubled Paths + a long Description) made the answer file invalid for the specialize pass - 'Windows could not parse or process unattend answer file for pass [specialize]'. Rebuilt the template from the proven-working original with only the OpenText change (Order 19 -> Setup-OpenText.cmd, drop unattended.bat, short Description). Deployed to both live gea-standard + gea-engineer. The prereqs will be re-added correctly as a single short-Path wrapper cmd (install-prereqs.cmd) once imaging is confirmed.
2026-07-23 14:50:59 -04:00
cproudlock
01f524e8b3 std/eng OpenText: use shopfloor Setup-OpenText.ps1 (fans office+shopfloor profiles to all users)
Standard + Engineer imaged OpenText via the vendor Inno exe + unattended.bat, which left per-user profiles in SYSTEM's appdata (operators never saw keymaps/menus/macros) and only copied to Default+Shared. Switched their FlatUnattend to the shopfloor Setup-OpenText.cmd, which does the install via direct msiexec (OpenTextHostExplorer15x64.msi + ShopFloorx64.mst + SP1.msp) and fans the profile content (both office and shopfloor sets - Accessories/EB, Keymap, Menu, Profile) to ProgramData\Shared + Default user + every existing user. Kept both J2SE installs (Java). Dropped unattended.bat (its crude Default+Shared copy is superseded; NOTE its stale DNS SearchList reg add - old logon.ds.ge.com/rd.ds.ge.com domains - is no longer applied). Shopfloor opentext bundle staged into winpeapps/_shared/Applications/extra/opentext/.
2026-07-23 14:15:56 -04:00
cproudlock
3880151e71 webapp: shopfloor sub-menu tab + nav rename (the edits dropped by a failed git add)
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
2026-07-23 13:58:08 -04:00
cproudlock
5f97e81dec webapp: nest shopfloor menu as a tab in the Boot Menu editor; rename nav to Boot Menu
The shopfloor sub-menu editor is now a 'Shopfloor Sub-Menu' tab inside the startnet editor (it IS a sub-menu of the boot menu), not a separate page - sf-prefixed IDs avoid clashing with the top-level Boot Menu tab. /shopfloor-menu GET + save now redirect back into that tab; standalone shopfloor_menu.html removed. Sidebar 'startnet.cmd' renamed to 'Boot Menu'.
2026-07-23 13:37:55 -04:00
cproudlock
626561a1fa shopfloor menu: data-driven from menu.json (picker + webapp editor)
Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
2026-07-23 13:27:08 -04:00
cproudlock
f1d9bdf478 startnet editor: harden _split_lines against doubled CRLF (\r\r\n)
A CRLF file re-CRLF'd (e.g. sed adding \r to already-CRLF lines) yields \r\r\n; the old _split_lines left a stray \r that split into a blank line between every line, so parse_boot_menu/settings/lint saw a garbled file and returned nothing. Now collapse any run of CR before a newline. Also re-deployed a clean-CRLF startnet.cmd into the live boot.wim (the earlier sed-based deploys had doubled the CR).
2026-07-23 11:42:51 -04:00
cproudlock
cc391529bd startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
2026-07-23 11:37:24 -04:00
cproudlock
234295931e playbook: keep the driver store owned by pxe so download-drivers.py can push
download-drivers.py rsyncs driver packs into _shared/Out-of-box Drivers over ssh as the pxe user, but the store was created root:root so new model folders failed with Permission denied. Added a task to own the store pxe:pxe 0775 (root and the webapp still write it fine). Fixed live on 172.16.9.1 too (chown -R).
2026-07-23 11:13:11 -04:00
cproudlock
0ffdcc79ed enrollment: auto-activate uploaded ppkg via ppkg.conf (no boot.wim edit)
startnet.cmd now sources SOURCE_PPKG/PPKG_VER/PPKG_EXP from \\<pxe>\enrollment\ppkg.conf (baked-in values kept as fallback if the file is missing). The webapp writes ppkg.conf on upload for standard GCCH_..._v<ver>.ppkg files: version parsed from the filename, optional expiry field (blank keeps current), so a new ppkg goes live at next boot with no startnet/boot.wim edit. Enrollment page shows the active ppkg. ppkg.conf seeded on the share (v4.16) and the ppkg.conf-aware startnet.cmd deployed into boot.wim.
2026-07-23 11:02:43 -04:00
cproudlock
3b63697176 download-drivers: compare BIOS versions numerically, not as strings
parse_bios_catalog kept the 'latest' BIOS per model with a string compare, so e.g. '1.20.1' > '1.9.0' was False and it wrongly retained the older 1.9.0. Added _ver_tuple() and compare tuples of ints so the genuinely newest firmware wins.
2026-07-23 10:54:35 -04:00
cproudlock
0cb6b26c27 webapp import: checksum-aware sync (skip unchanged, update only changed)
Image import previously full-copied everything and rmtree'd existing target dirs on every run. Replaced the shutil copy/move/copytree/rmtree with deploy.sync_tree(), which shells out to rsync -a --checksum: files whose content already matches the target are skipped, only new or changed files are written, and existing target files not in the source are left untouched (merge, not mirror). move=True uses --remove-source-files (frees the SMB upload dir) and prunes emptied source dirs. Applies to the Deploy import, the _shared redirections, and the root-level items. Big re-imports now only rewrite what actually changed.
2026-07-23 10:45:58 -04:00
cproudlock
8fbae24b4d webapp: fix a11y contrast, heading hierarchy, prefix-safe modal actions
- Accessibility: dark ink (#00003d) on warning/success buttons, badges, and success alert - white text on #ff9500/#0ad64f failed WCAG AA (~2:1); dark-on-bright now passes.
- Heading hierarchy: sidebar brand h1 -> div.brand-title (with matching CSS selector), and standardized all page-header titles to a single <h1> per page (8 templates were h2).
- Prefix-safe modal JS: dashboard/backups/reports/enrollment delete+clone actions now build their form action from url_for(...'__K__'/'__F__').replace(...) instead of hardcoded paths, so they survive a URL-prefix mount.
2026-07-23 10:14:29 -04:00
cproudlock
18db077475 webapp: reskin to shopdb-flask design system
Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
2026-07-23 10:05:09 -04:00
cproudlock
8df30017aa Playbook: reconcile live dnsmasq/samba/blancco hand-edits back into repo
The live PXE server carried working config the playbook would have clobbered on re-run. Committed back:
- dnsmasq: BIOS boot chain now the vendorclass PXEClient:Arch:00000 -> ipxe.pxe scheme (plus iPXE userclass HTTP chain), replacing the stale undionly.kpxe client-arch=0 scheme (undionly.kpxe was never staged in tftp-root). Bind by listen-address=172.16.9.1 + bind-dynamic instead of interface=+bind-interfaces.
- samba: add 'allow insecure wide links = yes' to the managed symlinks block (modern Samba silently disables wide links without it) and the [winpeapps_bios] read-only share.
- blancco iPXE menu: full BIOS/EFI arch split (direct-kernel vmlinuz-bde-linux / vmlinuz-ubuntu with ucode+kexec initrds) with a GRUB chain fallback, replacing the old single-line 'chain grubx64.efi'.
startnet.cmd was NOT a commit-back: repo is already ahead of the live boot.wim (deploy-pending).
2026-07-23 09:31:20 -04:00
cproudlock
b240cfea12 collections: add eDNC detection diagnostic scripts 2026-07-23 09:21:49 -04:00
cproudlock
e7be0f5391 gea-engineer: pre-stage .NET 3.5 + VC++ redists via FlatUnattend
Add RunSynchronous Orders 39-49: enable NetFx3 from staged sxs + install the VC++ 2005-2022 redistributables at specialize, before the first-logon engineering installer. Each command is if-exist guarded against the extra_engineering\prereqs payload, so gea-standard (which shares this template) skips them. Payloads staged on the share under gea-engineer Deploy\Applications\extra_engineering\prereqs.
2026-07-23 09:21:49 -04:00
cproudlock
0c4c2a245d Remove legacy ge-* image types (gea-only fleet)
Drop ge-standard/ge-engineer/ge-shopfloor-lockdown/ge-shopfloor-mce from image_types + standard_types, both startnet boot menus (choices + labels + net use), and the webapp IMAGE_TYPES/FRIENDLY_NAMES. Fleet is gea-* only; the empty ge-* winpeapps stub dirs were removed on the live share.
2026-07-23 09:21:49 -04:00
cproudlock
fdf63ab32f Shopfloor self-heal: bootstrap recovery scripts + Keyence/WaxTrace heal
Fixes Keyence (and CMM/WaxTrace) imaging where the target got only partial
data: the Y: SMB mount goes idle-dead during WIM apply, so WinPE staging dies
early (often just after site-config.json + ppkg). The first-logon self-heal
was meant to recover but its scripts were themselves staged past the death
point, so nothing ran.

- FlatUnattendW10-shopfloor.xml: new FirstLogonCommands Order-4 bootstrap that
  mounts the enrollment share fresh and pulls Fetch-StagingPayload /
  Verify-And-Heal-Staging / enrollment scripts into C:\Enrollment before the
  Order 5/6 heal runs. Recovery no longer depends on WinPE staging surviving.
  Keep CommandLine <=320 and Description <=252 chars: exceeding the unattend
  schema length limits makes the whole oobeSystem pass invalid (OOBE prompts,
  no autologon).

- Verify-And-Heal-Staging.ps1: add Keyence and WaxTrace heavy-payload heal
  branches (previously only CMM). Keyence re-pulls installers-post\keyence\
  <model> -> C:\KeyenceInstall\<model>; WaxTrace re-pulls the bundle (minus
  formtracepak) plus the bay-matched FORMTRACEPAK-V<ver>.iso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:12:43 -04:00
cproudlock
64e1f7e088 Report-AssetToShopDB: log OS + boot time in the POST line
Adds os= and boot= to the POST log line so report-asset-*.log shows whether
the PC actually grabbed the OS version and LastBootUpTime (uptime source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:31:12 -04:00
cproudlock
287ec86c12 Report-AssetToShopDB: fleet-wide reporting, more fields, dual NIC
- collect logged-in user (console user via Win32_ComputerSystem, bare
  username), pc-type (C:\Enrollment\pc-type.txt), make/model, OS version
  (caption + DisplayVersion + build), last boot time (for uptime)
- report BOTH corp and controller NICs (physical only), each with MAC,
  tagged IsMachineNetwork; was corp-only before
- machine-number sourcing adds C:\Enrollment\cmm\cmmid.txt (CMM bay id)
  and skips the 9999 placeholder everywhere
- intended to run from common\ (every pc-type), not collections-only;
  api.asp patch-style update keeps it from clobbering other types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:41:36 -04:00
cproudlock
85e7d91b1a CMM: imaging manifest installs goCMM 2.12.3 (was 1.1)
Future PXE images now install goCMM 2.12.3 (NSIS, /S silent, -> C:\Program Files
\goCMM, runs no-admin). Detection switched to File on goCMM.exe (new install
path). The tsgwp00525 SFLD enforce manifest is deliberately left on goCMM 1.1 so
already-deployed bays are NOT auto-upgraded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:45:25 -04:00
cproudlock
2461804fc8 CMM: Backup-CMM also captures the whole C:\Program Files\WAI tree
Adds a wai_backup_<PC>_<ts>.zip (robocopy /E of C:\Program Files\WAI + the x86
path) alongside the goCMM + PC-DMIS backups, indexed in cmm-backup-index.json.
Captures machine/controller content beyond the per-version PC-DMIS grab. Can be
multi-GB if WAI holds the full PC-DMIS 2016 install.

NOTE: this only CAPTURES it. sync-cmm-backups.sh + Restore-CMM still handle only
gocmm/pcdmis zips - staging/restoring the WAI zip needs those updated too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:01 -04:00
cproudlock
48bc609eb5 CMM/DODA: fix DODA-bay profile resolution + goCMM 2.12 DataFolder + settings converter
- Get-PCProfile: subtype-strip fallback. DODA bays set pc-subtype.txt=doda, so
  the profile key became "gea-shopfloor-cmm-doda" which matched NO profile/alias
  -> Get-PCProfile returned null -> callers fell to hardcoded defaults (no
  PC-DMIS desktop icons; Defect Tracker / WJ Shopfloor / Plant Apps force-started).
  Now an unmatched compound key falls back to the bare pc-type (-> CMM). VM-tested:
  gea-shopfloor-cmm/doda + CMM/doda resolve to CMM (7 apps, PC-DMIS present);
  non-CMM unaffected.
- 09-Setup-CMM Step 2.5c: Active Setup seed for goCMM 2.12 DataFolder. goCMM 2.12
  stores its shared-data-dir in HKCU\Software\General Electric\goCMM\DataFolder
  (decompiled: RegistrySettings uses Registry.CurrentUser - per-user). Imaging as
  SupportUser wouldn't reach the ShopFloor operator's HKCU. Active Setup runs the
  StubPath once per user at first logon -> every user gets DataFolder=C:\geaofi\.
  VM-tested: StubPath writes the value with the trailing backslash intact.
- Convert-goCMMSettings.ps1: converts legacy goCMM 1.1 ApplicationSettings.xml ->
  goCMM 2.12 goCMMSettings.xml schema. VM-tested: output byte-identical to a real
  goCMM-2.12-produced CMM10 goCMMSettings.xml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:59:08 -04:00
cproudlock
132c57ab39 CMM: create C:\GE PC-DMIS FRONT END at imaging + move regserver helper to Public desktop
- Ensure-PCDMISFrontEnd.ps1/.bat: standalone fix for already-imaged bays -
  create C:\GE PC-DMIS FRONT END + grant Users/Auth Users Modify. PCDToIGES.exe
  writes its error log there in its catch block; on a fresh bay the dir is absent
  (legacy front-end setup isn't part of imaging), so ANY PCDToIGES error becomes
  an unhandled DirectoryNotFoundException that crashes the export and masks the
  real cause (confirmed live on a CMM bay).
- 09-Setup-CMM Step 2.5b: create that dir + ACL at imaging for every CMM bay.
- 09-Setup-CMM Step 2.5: Register-PCDMIS-COM.bat now lands on the Public desktop
  (visible to operator or SupportUser) instead of SupportUser-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:56:00 -04:00
cproudlock
99deaa6882 CMM: add PC-DMIS 2026.1 desktop shortcut + taskbar pin (site-config)
The CMM pcProfile listed PC-DMIS 2016 and 2019 R2 in desktopApps/taskbarPins but
not 2026.1, so freshly imaged 2026 bays (CMM11/12) got no PC-DMIS shortcut. Add
the 2026.1 entries pointing at C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit\
PCDLRN.exe. Test-Path gated by the shortcut generator, so it is a no-op on
2016/2019 bays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:49:55 -04:00
cproudlock
948d013539 preinstall: add VC++ 2015-2022 x64 redist (PC-DMIS 2026 vcruntime140_1.dll)
The x64 redist ladder in preinstall stopped at 2013, so 64-bit PC-DMIS 2026 had
no vcruntime140_1.dll (introduced in VC++ 2019/14.20) and PCDLRN.exe failed to
launch: "vcruntime140_1.dll was not found". The patched PC-DMIS MSI bypasses
Hexagon's Burn bundle, which would otherwise have pulled the runtime in - and the
existing 2010/2012 x64 entries (added for PC-DMIS 2016/2019) never got a
2015-2022 successor.

Add VC++ 2015-2022 x64 (vc_redist.x64.exe 14.44.35211, /install /quiet
/norestart), PCTypes ["*"], File-detected on vcruntime140_1.dll so it is
version-independent. Installer staged on the share at
pre-install/installers/vcredist/2022-x64/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:43:35 -04:00
cproudlock
6e218f3daf CMM/DODA: Install-DODA does the full unattended runtime+app install
The vendor GE_DODA_build5_Installer.exe is a multi-app NSIS bundler whose /S
silent mode is broken (nested sub-install -> MSI 1619); VM-confirmed it only
works interactively. So Install-DODA now drives the bundled components directly,
all silent:
  python-2.7.16 msi /qn  -> C:\Python27
  openjdk-1.8.0.232 msi /qn -> C:\Program Files\RedHat
  gs927w64.exe /S        -> Ghostscript 9.27
  vc_redist /quiet
  doda_build*.zip        -> C:\Apps\DODA  (+ writes .settings the zip lacks)
Then sets AFDA_INSTALL_DIR + Java PATH, precompiles .py->.pyc as admin, and
grants Users+Auth Users Full on C:\Apps\DODA so the locked-down operator can run
DovetailAnalysis.exe (writes .pyc/PreProcess/$TMP). Each step detects-and-skips;
idempotent. VM-validated end to end incl a non-admin operator-paths test.

Runtime binaries (python/jdk/gs/vc msis + doda zip) are staged on the share, not
committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:54:15 -04:00
cproudlock
b5b644a360 CMM/DODA: restore on DODA bays + grant Full on whole C:\Apps\DODA
- Restore-CMM: drop the skip-on-doda gate. DODA bays now restore the
  config-version PC-DMIS + goCMM settings like any other bay (they have backups
  now; DODA itself installs separately to C:\Apps\DODA and is unaffected).
- sync-cmm-backups.sh: update the stale "do not back up DODA bays" note.
- Install-DODA.ps1: grant Users + Authenticated Users Full on the WHOLE
  C:\Apps\DODA (was PreProcess only) - DODA writes output/temp throughout the
  folder as the locked-down operator. /T covers PreProcess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:06 -04:00
cproudlock
bc89ba1cf3 CMM: add Register-PCDMIS-COM.bat desktop helper for post-license COM regserver
PC-DMIS COM must be registered for goCMM to connect, but Pcdlrn.exe /regserver
no-ops until PC-DMIS is licensed - which is a manual post-image step
(clmadmin.exe). So we cannot register at imaging time. Instead 09-Setup-CMM
drops a self-elevating one-click helper on the SupportUser desktop (a
pre-existing profile; avoids 06-OrganizeDesktop's Public-desktop sweep). The
tech runs it after activating the license; safe to re-run. Falls back to Public
Desktop if the SupportUser profile is absent. Copy happens before the Step 3
staging cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:44 -04:00
cproudlock
1487abdba5 CMM/DODA: enable on bays 4/10/11/12, fix PCTypes match, grant PreProcess ACL
Three fixes so DODA actually deploys on the four bays that need it:

- cmm-bay-config.csv: doda=yes for CMM4, CMM10, CMM11, CMM12 (was no on all
  bays). Drives doda.txt -> startnet pc-subtype.txt=doda -> the cmm-doda path.
- cmm-manifest.json: DODA entry PCTypes "cmm-doda" -> "gea-shopfloor-cmm-doda".
  The old value never matched: Test-PCTypeMatches builds the PC identity set as
  {gea-shopfloor-cmm, gea-shopfloor-cmm-doda, CMM} (the alias of the cmm type is
  bare CMM, no subtype variant), so "cmm-doda" was in no set and the entry was
  silently skipped. Must be the exact Type-SubType string.
- Install-DODA.ps1: grant Users + Authenticated Users Full on C:\Apps\DODA\
  PreProcess (icacls, SIDs, OI/CI inherit), applied every run. MergeFiles.exe
  writes there as the locked-down operator; without it the merge step fails.

Not yet pushed to the SFLD/enrollment share (server unreachable at commit time).
Not smoke-tested - no pwsh available here; logic-traced only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:35:39 -04:00
cproudlock
7af66575d0 CMM: gocmm-debug - capture the real startup NRE (PC-DMIS COM not registered)
debug.log from the bay shows the part group enumerates fine (14 .geop files
opened), then goCMM connects to PC-DMIS over COM and throws:

  System.ArgumentNullException: Value cannot be null. Parameter name: type
     at System.Activator.CreateInstance(Type type)
     at GEAE.Common.CMM.CMMInterfaces.PCDMIS.PCDMIS.<ConnectToSoftware>b__43_0()
  -> System.NullReferenceException at ConnectToSoftware(...)

Root cause: PC-DMIS automation server is not COM-registered, so
Type.GetTypeFromProgID returns null -> CreateInstance(null) throws -> the NRE
is the downstream symptom. Not part-group, not permissions, not calibration.

- PROBE 4: pull the .NET Runtime / Application Error crash stack for goCMM from
  the Application log so the next run captures the null in one shot.
- PROBE 5: (a) part-group UNC reachability; (b) PC-DMIS COM registration check
  (PCDLRN.* ProgID -> CLSID -> LocalServer32) that names the missing registration
  and the Pcdlrn.exe /regserver fix; plus install presence for context.
- .bat header documents the COM root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:31:11 -04:00
cproudlock
8e11f0824a CMM: gocmm-debug probes the startup NRE (part-group match), checks real exe
goCMM matches the registry 'Selected Part Group' against the
ApplicationSettings.xml <PartGroup FullName> entries with a case-sensitive
compare. No match -> SelectedPartGroup null -> "Object reference not set to
an instance of an object" at start. This is a different failure from the
registry SecurityException the script already probes.

- PROBE 3: read the reg value (32-bit view) + every FullName in the XML,
  Ordinal-compare, and report exact / case-only / no-match / missing-XML.
  Case-only and no-match name the d441abd canonicalization fix as the remedy.
- Version check now looks for the real goCMM.exe, not the nonexistent
  GEAOperatorFriendlyInterface.exe (was a false MISSING).
- .bat header documents both failure modes it now diagnoses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:08:00 -04:00
cproudlock
d441abd20f CMM: goCMM restore - canonicalize the SHARE segment case, not just the host
goCMM showed an empty parts list after restore though the bay reached the share.
Decompiled goCMM: PartGroupViewModel matches the registry Selected Part Group
against ApplicationSettings.xml <PartGroup FullName> with a CASE-SENSITIVE compare,
then enumerates that FullName for the parts. The host-canon rewrite fixed only the
hostname, leaving xml '\shared' (lowercase) vs registry '\SHARED' (uppercase) ->
Find null -> SelectedPartGroup null -> empty list. Fix spans the share segment too,
pinning both to \tsgwp00525.wjs.geaerospace.net\SHARED. Verified in PowerShell
(-ceq True). Runs at imaging in Restore-CMM, so all captured backups are fixed on
restore with no re-backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:12:07 -04:00
cproudlock
9145023440 heal/fetch: suppress benign net-use-delete error during imaging
The pre-mount `net use Z: /delete /y` in Fetch-StagingPayload and
Verify-And-Heal-Staging emits "The network connection could not be found" when
Z: is not yet mapped (the normal first-attempt case). PowerShell surfaces that
native stderr as a NativeCommandError (System.Management.Automation.Remote-
Exception) at the call site EVEN WITH `2>$null` - it prints a red error during
the FirstLogonCommands run, alarming the tech and able to mask a real fault.
The mount then succeeds, so it was always cosmetic.

Wrap the cleanup in cmd.exe (`cmd /c "net use $drive /delete /y >/dev/null 2>&1"`) so
net.exe's stderr is redirected to nul INSIDE cmd and never reaches PowerShell as
an error record. Verified on the win11 VM: old pattern leaves $Error.Count=4
(RemoteException); new pattern leaves $Error.Count=0. All four call sites fixed
(both scripts' Mount-Share + end-of-run unmount).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:50:27 -04:00
cproudlock
51edf98e7d heal: verify file completeness (size/timestamp), not just presence
The shallow present-check passed a file that merely existed, so a partially
transferred payload (e.g. a truncated PC-DMIS MSI) looked PRESENT and was never
re-pulled - then failed to install because it was incomplete. Replace it with a
per-item robocopy that compares size + timestamp on every file and re-pulls
anything missing OR partial, skipping ones already complete. VerifyOnly uses /L
to report INCOMPLETE without changing anything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:20:32 -04:00
cproudlock
e3a3fa6794 startnet: stage Verify-And-Heal-Staging.ps1 to C:\Enrollment
The live boot.wim startnet did not stage Fetch-StagingPayload.ps1 (rebuilt from a
stale source), so the unattend Fetch (Order 4) + Verify-And-Heal (Order 5) steps
had no script on disk and never ran - imaging lost payloads with no recovery.

Stage Verify-And-Heal-Staging.ps1 directly here (alongside the existing
Fetch-StagingPayload copy) so the Order 5 heal runs even if Fetch itself fails to
land. Requires re-injecting this startnet.cmd into boot.wim to take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:31:32 -04:00
cproudlock
a165a79f95 imaging: force shopfloor unattend deploy (was force:no -> went stale)
The shopfloor unattend deploy used force:no, so once a live copy existed the
playbook never overwrote it. That let the live gea-shopfloor unattend drift for
weeks - missing the Fetch + Verify-And-Heal staging steps - which is why imaging
lost payloads (CMM bundle/backups). Flip to force:yes so the repo stays the
source of truth, matching the standard/engineer unattend task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:12:06 -04:00
cproudlock
41cace17e8 imaging: run Verify-And-Heal-Staging at first logon before the network switch
Wire the staging self-heal into the imaging flow so a bay re-pulls any missing
payload while still on the imaging LAN (172.16.9.1), before wait-for-internet
takes it to the production network.

- FlatUnattendW10-shopfloor.xml: insert Verify-And-Heal-Staging.ps1 as
  FirstLogonCommands Order 5 (right after Fetch-StagingPayload Order 4, before
  wait-for-internet); renumber the rest 6-10. Run-ShopfloorSetup stays last and
  is NOT the heal point - it runs post-network-switch when the imaging LAN is
  gone.
- Fetch-StagingPayload.ps1: also pull the small Verify-And-Heal-Staging.ps1 to
  C:\Enrollment so the Order 5 step has it on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:01:59 -04:00
cproudlock
e97e5bd049 shopfloor: CMM PC-DMIS version gate, ShopDB reporter fixes, staging self-heal
- lib Install-FromManifest 2.5->2.6: add _CmmVersion per-entry filter (reads
  C:\Enrollment\cmm\version.txt). Lifted the version gate out of 09-Setup-CMM
  into the shared lib so imaging and GE-Enforce apply it identically and cannot
  drift (root cause of PC-DMIS 2016 installing on every CMM).
- Install-goCMMSettings: canonicalize the part-group share host to the FQDN in
  both the registry and ApplicationSettings.xml. Handles bare \\tsgwp00525\ and
  the legacy rd.ds.ge.com domain; idempotent. VM-tested.
- Report-AssetToShopDB: resolve the machine number eDNC registry first, then fall
  back to C:\Enrollment\machine-number.txt (matches the lib resolution order) so
  a freshly imaged PC still reports its number for the PC-machine relationship.
- Add Update-CMMEnforcer.ps1/.bat: update one CMM's local lib to the gated
  version and self-heal its PC-DMIS version.
- Add Debug-ShopDBReporting.ps1/.bat: one-shot reporter triage (preconditions,
  client log, live test POST, verdict).
- Add Verify-And-Heal-Staging.ps1/.bat: post-boot check that every imaging
  payload arrived and re-pull anything missing from the share, including the CMM
  bundle and the selected bay's backup (the payload that times out in WinPE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:14:54 -04:00
cproudlock
c2538a05c5 CMM: wire per-bay settings restore into the imaging flow
Restore-CMM.ps1 (new) restores a CMM's PC-DMIS + goCMM settings at imaging from
its staged backup. Self-gating: reads C:\Enrollment\cmm\{cmmid,version,doda,
partgroup}.txt, skips DODA bays and bays with no staged backup, and restores
ONLY the config-version PC-DMIS zip via the existing Install-*Settings scripts.
Same-bay restore (cmmid match) so the backed-up controller CommPort is this
bay's own value - no cross-bay clobber.

Version selection matches the VERSION FIELD of the zip name, anchored on the
trailing timestamp, so version=2026 does not false-match a 2019/2016 zip whose
backup timestamp (20260612...) merely contains "2026".

09-Setup-CMM.ps1: new Step 2.8 calls Restore-CMM after app install + first-run
init (so a restored config is not clobbered by PC-DMIS defaults) and before the
C:\CMM-Install cleanup (the backup set lives under <stagingRoot>\backups\<cmmid>).
Best-effort: Restore-CMM always exits 0, imaging never fails on a restore.

startnet.cmd: stage ONLY the picked bay's backup into C:\CMM-Install\backups\
%CMMID% (the bulk robocopy now /XD-excludes the backups tree, which holds every
bay's backup - some 240 MB each - to avoid copying GBs to every imaged CMM).
Also bump the PPKG to v4.16 (the live boot.wim was already v4.16; the repo had
drifted to v4.14).

sync-cmm-backups.sh: source the backups from pxe-images/cmm/backups (where
Backup-CMM writes via the pulled-down copies), not the old cmm-bk path.

Smoke tested on the win11 VM against CMM3's real backup: version=2019 restored
the 2019 R2 zip (not 2016.0), imported HKLM+HKCU reg, converted the part-group
S:\ path to the tsgwp00525 UNC, created C:\geaofi, exit 0; version=2026 correctly
found no matching zip (anchor works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:34:10 -04:00
cproudlock
59deaea714 CMM: Backup-CMM defaults to S: record-retention path + prompts for CMM#
Default output is now S:\2 WJ Scans Record Retention\backup\cmm\<CmmId>\ instead
of C:\Logs. If S: is not mapped/reachable it falls back to C:\Logs\CMM\cmm-backup
so the backup still runs. When -CmmId is not passed the script prompts for it
(loops until non-empty) since it names the per-bay folder.

Smoke tested on the win11 VM: S: fallback path + the Read-Host prompt (fed via
redirected stdin) both produce the correctly-named per-CMM folder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:34:10 -04:00
cproudlock
f49fa0f940 CMM: install PC-DMIS PDF converter (Amyuni) the standalone-MSI bypassed
Our patched standalone PC-DMIS MSI never installs the Amyuni Document
Converter 500 (system printer "PC-DMIS 50 Converter"). INSTALLPDFCONVERTER
is a Burn-bundle property the main MSI never reads (0 of 153 custom actions
reference it; not in the Property table), and the patched-MSI strategy
bypasses the bundle that would have chained the Amyuni install. The MSI only
lays the installer on disk at <installdir>\PDFDriverInstallFiles\
BatFileInstallPDF50.zip and nothing runs it.

Install-PCDMISPDFConverter.ps1 runs it: scans Program Files\Hexagon (and
Wai) for the laid-down zip, extracts it, parses the InstallPDF50.exe
invocation from the shipped bat (printer name + Wilcox licensee + license
code, read not hardcoded), and runs the exe directly from the extract dir
so sibling DLLs resolve. The shipped bat ends in `pause` (hangs under /qn)
so we never run it. InstallPDF50.exe creates the printer then hangs (same
trait as the bundle), so we poll for the printer and kill the stuck exe
once it appears. Idempotent: printer already present -> exit 0.

Wired as a PS1 manifest entry placed after the PC-DMIS MSIs (files must
exist on disk first), no _CmmVersion (one shared printer covers every
version), MarkerFile detection for one-shot at imaging.

Smoke tested on the win11 VM as SYSTEM: fresh install 7.2s (printer +
driver created), idempotent re-run 0.6s, both exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:54 -04:00
cproudlock
c8e704b595 CMM: add backup staging (sync-cmm-backups.sh) + write cmmid.txt at resolve
sync-cmm-backups.sh pushes per-CMM backup sets (goCMM + PC-DMIS zips produced
by Backup-CMM) from pxe-images/cmm-bk/<cmm_id>/ to the PXE share at
installers-post/cmm/backups/<cmm_id>/, atomic-swap with a timestamped prior
copy. Distinct from sync-cmm.sh (which stages the CMM installer bundle).

resolve-cmm-bay-config.ps1 now also writes cmmid.txt alongside version/doda/
partgroup, so 09-Setup-CMM can locate this bay's staged backup for
restore-by-machine-number.

The 09-Setup-CMM restore block + startnet staging line are intentionally NOT
added yet - the restore needs manual end-to-end validation on a real CMM
before auto-running at imaging (per the live-bay restore issues we hit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:47:21 -04:00
cproudlock
1d65103cc0 CMM: add PC-DMIS + combined CMM backup/restore + diagnostic scripts
Adds the PC-DMIS settings/probe backup-restore set alongside the existing
goCMM scripts, plus a single combined CMM backup and the diagnostics built
while debugging the live bays:

- Backup-PCDMISSettings / Install-PCDMISSettings: capture+restore PC-DMIS
  registry + data/probe/cal files per installed version (2016/2019/2026).
  Hardened from real-bay failures: detect install dir via Program Files
  fallback; capture compens.dat (not just comp.dat) + interfac.dll; identify
  the controller by hash-matching interfac.dll to its source DLL AND reading
  the PE OriginalFilename (covers rename-without-copy); EXCLUDE the whole
  Homepage state (Recent/Favorites/DetailsView) which null-refs PC-DMIS on
  launch via stale routine paths; restore routes HKCU into the target user's
  hive (-TargetUser ShopFloor), fails loud on a non-backup path, and applies
  the legacy->new FQDN rewrite across reg + data files incl .bas.
- Backup-CMM: one wrapper running goCMM + PC-DMIS (all versions) into one
  per-CMM folder + index, for staging on PXE and restore-by-machine-number.
- Clear-PCDMISRecent: fixes the Homepage recent-list NullReferenceException
  crash on an already-broken bay.
- pcdmis-probe-debug / Export-PCDMISCrashEvents: diagnostics for the
  custom-probe-not-showing and crash investigations.
- Modify-PCDMISRights / Grant-FullControl: grant the operator the registry +
  filesystem access PC-DMIS needs under lockdown.
- Install-goCMMSettings: add .bas to the FQDN-rewrite include list.

Not yet wired into 09-Setup-CMM auto-restore - staging + the gated restore
block come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:42:32 -04:00
cproudlock
bfe17fe123 CMM: add goCMM settings backup/restore + debug scripts
goCMM settings live in two places: 3 pointer values at
HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM, and the real content of all
7 Settings tabs (PC-DMIS, Quindos, Modus, Machine Definition, User Input,
Notifications, Part Groups) in C:\geaofi\ApplicationSettings.xml. Capture-replay
pair, mirroring the Wax/Trace Backup/Install scripts:

- Backup-goCMMSettings.ps1/.bat: on a live legacy bay (admin), zips the registry
  key + the C:\geaofi tree (minus transient LocalProgramCopies/logs) to
  gocmm_backup_<PC>_<ts>.zip.
- Install-goCMMSettings.ps1/.bat: restore at imaging (admin). Imports the key +
  lays down C:\geaofi, then grants BUILTIN\Users WriteKey on the reg key and
  Modify on C:\geaofi - goCMM's RegistrySettings.GetRegistryString opens the key
  with writable:true even to READ, so a locked-down operator throws a
  SecurityException without the grant (the post-lockdown 'registry access not
  allowed' error). Applies a built-in legacy->new FQDN rewrite
  (rd.ds.ge.com -> wjs.geaerospace.net) automatically across the registry values
  and ApplicationSettings.xml (incl PartGroup FullName); -NoDefaultRewrite skips
  it, /replace adds an extra pair, -SelectedPartGroup overrides per bay.
- gocmm-debug.ps1/.bat: run as the operator to reproduce the SecurityException
  and dump the goCMM key ACL (confirms whether lockdown stripped the grant).

All round-trip + FQDN-rewrite verified on the win11 VM. NOTE: covers goCMM only;
PC-DMIS probe calibrations / custom tip angles / machine comp are owned by
PC-DMIS (Hexagon) and not captured here. Not yet wired into 09-Setup-CMM
auto-discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:27:30 -04:00
cproudlock
da380fbcd7 Wax/Trace: prefer backup ZIP over cal ISO when both exist
Standardize the calibration source. Step 3b already restores a per-asset
backup ZIP (HKLM + data + config, incl. the probe cal tables) for any bay
that has one - which is now every migrated bay. When that ZIP is staged,
skip the redundant and fragile cal-ISO mount/vendor-Setup step so all
bays follow one flow (app ISO + backup restore), matching the bays that
never had a cal ISO. A future bay with a cal ISO but no backup still
falls through to the ISO path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:13:03 -04:00
cproudlock
a935c04e1f Wax/Trace WJF00450: set user_id, pin 6.213 app-ISO choice
Fill in user_id 0992830128 (was a MISSING_DATA placeholder pending a
dongle read). Keep ftpak_version 6.213: the bay runs FormTracePak
6.2.0.45 but no V6.204 app ISO exists, so it installs the V6.213 app and
restores the 6.2.0.45 config/cal from its per-asset backup ZIP - the same
pattern as WJF00461 (6.213 ISO / 6.2.0.51 backup). Cal .txt tables are
version-independent so they apply onto the 6.213 binaries. hw_id stays
MISSING_DATA (metadata, not consumed by the resolver). Sync the scripts/
copy so the resolver and the backup tools see identical data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:30 -04:00
cproudlock
1e17a0564f eDNC: standardize per-type fallback installers on 6.4.7
Replace per-type eDNC_6-4-5.msi with eDNC-6.4.7.msi (ProductVersion
6.4.7.0) in collections, nocollections, heattreat, and partmarker.
01-eDNC.ps1 already prefers the single shared installer at
C:\PreInstall\installers\dnc (6.4.7); aligning the per-type fallback
means an empty shared dir can no longer install a stale 6.4.5. All DNC
types now resolve to 6.4.7 on either path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:19 -04:00
cproudlock
99e7679e87 HeatTreat: bump DNC app 6.2.1 -> 6.4.9
Replace HeatTreat_6.2.1.msi with HeatTreat_6-4-9.msi (ProductVersion
6.4.9.0, ProductCode {9E603EFE-888A-4E3F-8CF5-7F03B7029919}). The install
script globs HeatTreat*.msi so no logic change; the MSI's NOT
NEWERVERSIONDETECTED LaunchCondition makes 6.2.1 -> 6.4.9 a clean
in-place upgrade. Update version references in 02-Setup-HeatTreat.ps1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:05 -04:00
cproudlock
1e6603d331 eDNC: default to single shared installer (C:\PreInstall\installers\dnc\eDNC-6.4.7.msi)
01-eDNC.ps1 (all 4 DNC types) now installs eDNC from the shared pre-install copy
staged by startnet.cmd to C:\PreInstall\installers\dnc - one source of truth
(currently 6.4.7) instead of a per-type bundled msi. Falls back to the per-type
eDNC\ folder when the shared copy is absent (older images). eMxInfo.txt, the
x86->64 mirror, and Site/MachineNo registry steps are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:45:24 -04:00
cproudlock
e844ff367c site-config: common shortcut base on all shopfloor types + per-type apps; new homepage host
- Homepage/start-menu + Edge URLs: http://tsgwp00524.logon.ds.ge.com/ ->
  https://tsgwp00525.wjs.geaerospace.net (8 refs across site-config + 08-EdgeDefaultBrowser).
- Every shopfloor working type now carries the same common base: Edge + WJ
  Shopfloor + Defect_Tracker pins/desktop, WJ Shopfloor + Plant Apps startup,
  3 web tabs. Timeclock/Genspect/Keyence/CMM upgraded to match.
- Per-type specialized apps: CMM (PC-DMIS 2016/2019 R2, CLM Admin, goCMM),
  WaxAndTrace (Formtracepak), partmarker (Mark + Backup420 only - dropped
  Telesis Backup400/470), heattreat (HeatTreat).
- eDNC/UDC/NTLARS listed only on bays that can run them; 06-OrganizeDesktop +
  07-TaskbarLayout gate every exe entry on Test-Path, so uninstalled apps are
  silently skipped (the "eDNC if it exists / UDC if it exists" behavior).
- Lab + Display-* left as kiosk/lab, not given the shopfloor base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:08:32 -04:00
cproudlock
913c807142 preinstall: OpenText + serial drivers on all PC types; Oracle on DNC bays + CMM
- Shopfloor Serial Drivers: PCTypes ["Standard"] -> ["*"]. Serial hardware
  appears across bays; a driver where the hardware is absent is harmless (sits
  in the driver store). PartMarker/HeatTreat were getting skipped.
- OpenText HostExplorer ShopFloor: PCTypes -> ["*"]. Every shopfloor PC type
  should get HostExplorer + its profiles/EB/keymaps/menus + desktop shortcuts.
- Oracle Client 11.2: scoped to the DNC-bearing types (collections,
  nocollections, partmarker, heattreat) + CMM (metrology tooling links the
  Oracle home). Dropped Genspect/Keyence/WaxAndTrace/Display/Timeclock, which
  have no DNC and don't need the Oracle client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:40:11 -04:00
cproudlock
1853db0903 preinstall: run OpenText on Part Marker bays (profiles, EB macros, desktop shortcuts)
OpenText HostExplorer was skipped on Part Marker PCs because its PCTypes
filter in preinstall.json omitted PartMarker, so Setup-OpenText.ps1 never
ran - no MSI, no per-user profile/keymap/menu/macro fan-out, no .eb macros
into ProgramData\Hummingbird\Connectivity\15.00\Shared, and no public
desktop shortcuts.

- preinstall.json: add PartMarker + gea-shopfloor-partmarker to OpenText PCTypes
- 00-PreInstall-MachineApps.ps1: add PartMarker alias group to the preinstall
  matcher (parity with Install-FromManifest.ps1, which already had it)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:49:36 -04:00
cproudlock
7a67716fcc manifest engine: null-safe PS1 dispatch (accept Script or Installer, log resolved path)
PS1 entries were crashing with a cryptic "Cannot bind LiteralPath because it is
null" when the resolved script path came back null - the per-entry try/catch
caught it (so the scope survived) but the cause was opaque. Now the PS1 branch
accepts either Script or Installer, null-guards before Join-Path/Test-Path, and
logs the resolved relative path, so a bad/empty entry is skipped with a clear
"has no Script/Installer value" line instead of a null-bind throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:38:16 -04:00
cproudlock
995909042b preinstall: install Oracle Client 11.2 on Part Marker + HeatTreat bays
The preinstall Oracle entry (run off the PXE pre-install share during shopfloor
setup, NOT the runtime enforcer) gated PCTypes to the old taxonomy only -
Standard/CMM/Genspect/Keyence/WaxAndTrace/Display - so gea-shopfloor-partmarker
and gea-shopfloor-heattreat bays were pc-filtered and never installed Oracle.
These two pctypes were added after the rename reorg and only exist as
gea-shopfloor-* names, so they were missing from the legacy-named list. Added
both. Already deployed live to the PXE enrollment share's preinstall.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:04:17 -04:00
cproudlock
a351160520 manifest engine: reg-first machine number + per-app crash isolation; add start-layout DSC
Install-FromManifest.ps1:
- Get-CurrentMachineNumber reads the eDNC/DNC registry FIRST (reassignment-
  authoritative), falling back to C:\Enrollment\machine-number.txt. The txt is
  written once at imaging and is NOT updated on reassignment, so txt-first
  gated reassigned bays on a stale number.
- Per-entry try/catch in the app loop: a single entry that throws no longer
  aborts the whole scope (skipping every later entry + the status write). It is
  logged, counted failed, and the loop continues. This was silently killing the
  collections scope at the MTConnect Makino entry, which also stopped the
  ShopDB asset reporter (a later entry) from ever running.

Deploy-ShopfloorStartLayout.ps1 (new): local-DSC port of the Intune
desktop-weblinks + Start-menu pins (copies .url/.lnk to Public Desktop +
All-Users Start Menu, writes the ConfigureStartPins JSON policy, resets
start2.bin + restarts the shell). Verified on Win11: pins render after logon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:40 -04:00
cproudlock
a380b17112 collections: report host + IP + machine number to ShopDB each enforce cycle
Adds Report-AssetToShopDB.ps1 (Type=PS1, DetectionMethod=Always manifest entry)
for collections PCs. Reads hostname, BIOS serial, eDNC MachineNo and the corp
NIC IPv4 (filtered to WJ corp ranges, controller NIC dropped) and POSTs
action=updateCompleteAsset to ShopDB api.asp, which upserts the machine, stores
the IP, and links the PC to its machine-number equipment. manifest-entry-report-asset.json
is the snippet to merge into the SFLD share collections manifest (+ stage the
script under apps/). Note: relies on the ShopDB api.asp LogToFile Err-leak fix
(separate shopdb repo commit) to create the relationship reliably.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:04:33 -04:00
cproudlock
5211861409 manifest: add PartMarker pctype alias so common entries reach Part Marker PCs
PartMarker was missing from the PCType alias map, so gea-shopfloor-partmarker
matched no alias set and common manifest entries gated by PCTypes (notably
Oracle Client 11.2) were pc-filtered out - Part Marker PCs never installed
Oracle. Adds @('PartMarker','gea-shopfloor-partmarker') to the alias groups.
The companion fix (adding partmarker+heattreat to Oracle's PCTypes list) lives
in the SFLD share common/manifest.json. Verified on the win11 VM: with PCType
gea-shopfloor-partmarker the Oracle entry is now evaluated (0 pc-filtered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:04:33 -04:00
cproudlock
db6be99d43 samba: deadtime=0 (was 5) so WIM-apply idle does not drop the staging mount
WinPE maps the enrollment share early, then idles for minutes while the WIM
applies. deadtime=5 dropped that idle session, so the post-apply staging
copies failed (bay left with only site-config.json staged). VM test against
the live share copied everything fine on a fresh mount; the only difference
was the idle. deadtime=0 disables idle auto-disconnect. Applied live to the
current box (smbd reloaded); this makes it permanent + covers the second box
on the next playbook run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:04:37 -04:00
cproudlock
a6fa21589b Imaging: defer bulk staging to first-logon Fetch (fresh mount) - Phase 1
WinPE maps Y: early then idles for minutes during the WIM apply; samba
deadtime drops the idle session, so the WinPE staging copies failed (bay
left with only site-config.json). Add Fetch-StagingPayload.ps1, run from the
unattend FirstLogonCommands at first logon on a FRESH share mount (full
Windows, no prior idle), to pull the shopfloor-setup tree + preinstall
bundle. Detailed per-item log (exit code, counts, timing, mount retries) at
C:\Logs\Fetch\ - the old WinPE staging was opaque.

- Fetch runs as Order 4, BEFORE wait-for-internet.ps1 (Order 5) which switches
  the bay to the production network and off the imaging LAN. So Fetch still
  reaches \172.16.9.1\enrollment.
- WinPE bulk staging kept as best-effort fail-fast fallback (Phase 1); the
  post-boot Fetch is now the authoritative path. Remove the WinPE bulk once
  validated. Heavy per-type payloads (CMM/Keyence/WaxTrace) stay in WinPE for
  now - Phase 2.
- startnet stages Fetch-StagingPayload.ps1 + writes fetch-source.txt
  (UNC/user/pass) for the post-boot mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:01:19 -04:00
cproudlock
f570e92847 startnet: robocopy (fail-fast) for staging copies so a stalled Y: cannot hang imaging
A Display bay staged only site-config.json then nothing - winpe-staging.log
ended at 'Copied site-config.json'. PCTYPE/PPKG were both set, so it was not
a menu bug: execution froze at the very next step, the PPKG copy
  copy /Y Y:\ppkgs\... W:\Enrollment\...
A bare 'copy' from a network drive has no timeout; when the Y: SMB handle
stalled it blocked forever, so pc-type.txt / shopfloor-setup / preinstall
never ran (Display kiosk + Common OpenText both depend on the preinstall
phase). Convert every bare 'copy /Y' in the staging path to
'robocopy /R:1 /W:1' (fail-fast retry, no overwrite/dir prompts). robocopy
exit codes are inverse of copy (0-7 ok, 8+ fail) so the PPKG check flips to
errorlevel 8, and since robocopy keeps the source name the PPKG gets a ren
to its BPRT-tagged target. A stalled share now fails one step and continues
instead of freezing the whole stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:21:39 -04:00
cproudlock
ea136687e0 site-config: add Part Marker + HeatTreat pcProfiles (DNC shortcuts/pins + apps)
Neither new PC type had a pcProfile, so they fell back to the top-level
defaults, which deliberately exclude eDNC. Result: no DNC desktop shortcut
or taskbar pin on either type. Add profiles mirroring gea-shopfloor-
nocollections (eDNC + NTLARS baseline) plus the type-specific apps:

- gea-shopfloor-partmarker: Mark (C:\Program Files (x86)\Mark\bin\Mark.exe)
  + Telesis Backup400/420/470 (C:\Program Files (x86)\Telesis\Backup4xx\).
- gea-shopfloor-heattreat: HeatTreat
  (C:\Program Files (x86)\HeatTreat\bin\HeatTreat.exe).

Deployed to the live enrollment share (config/ + shopfloor-setup/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:59:03 -04:00
cproudlock
57fae57d3b CMM/DODA: fix DODA paths + CyberArk EPM policy doc
- 09-Setup-CMM.ps1: Step 2.5 ACL list targeted C:\Program Files\DODA (a path
  that never exists), so the BUILTIN\Users write grant on DODA was silently
  skipped. Corrected to C:\Apps\DODA, where Install-DODA.ps1 actually extracts.
- Install-DODA.ps1: create C:\Apps\DODA\PreProcess after extract. The DODA
  zip unpacks flat without it; MergeFiles.exe expects it and crashed with
  DirectoryNotFoundException (MergeFiles.GetDoDAFolder) when absent.
- docs/cyberark-cmm-doda-policy.md: EPM admin reference for elevating the CMM
  report toolchain. CyberArk EPM elevation is per-process and not inherited, so
  the external tools PC-DMIS spawns (MergeFiles/PCDToIGES/RotateProbeVector/
  DovetailAnalysis) run un-elevated and fail. Doc gives the Application Group
  (by SHA-256), the Elevate policy, scope, verify steps, and the
  CREATE_PDF_FROM_RTF.BAS rework that drops Word/Reader from the elevation set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:38:34 -04:00
cproudlock
c88b2b0ab8 Wax/Trace: classify restore .reg by content root, not filename
Install-FormtracepakSettings decided per-user vs HKLM by matching the
filename against 'HKEY_USERS'. The backup names files after their source
path, so an operator pref captured from HKCU:\ lands in a file named
"HKCU_..." with content root HKEY_CURRENT_USER - which the filename match
missed entirely, dropping it from the restore.

Read each .reg once and classify by its content root(s):
  [HKEY_CURRENT_USER...  -> per-user, remap root to HKEY_USERS\<targetSid>
  [HKEY_USERS\<srcSid>... -> per-user, remap srcSid -> targetSid
  [HKEY_LOCAL_MACHINE...  -> HKLM
The temp rewrite (UTF-16-LE for reg.exe import) is only written when the
content actually changes; otherwise the file imports as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:54:00 -04:00
cproudlock
1a5852f7ff CMM: per-bay path is goCMM "Selected Part Group" UNC, not Shared Data Directory
A capture from a working CMM4 bay showed the goCMM registry holds two
distinct values under HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM:
  Shared Data Directory = C:\geaofi\                  (constant on every bay)
  Selected Part Group   = \\tsgwp00525...\SHARED\...  (the per-bay UNC path)

The prior commit (f6d970c) put the per-bay path into "Shared Data Directory",
which is wrong. Correct that:
- bay-config column shared_data_dir -> part_group
- resolve-cmm-bay-config emits partgroup.txt (was shareddatadir.txt)
- 09-Setup-CMM seeds "Shared Data Directory" to the constant C:\geaofi\ and
  "Selected Part Group" to the per-bay path, converting the friendly S:\...
  form to the \\tsgwp00525.wjs.geaerospace.net\SHARED UNC at apply time.
  Users write grant on the key is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:53:53 -04:00
cproudlock
0b116e3ecf HeatTreat: import per-machine DNC .reg by machine number
HeatTreat bays pick a machine number (6601-6604) at imaging; 02-Setup-
HeatTreat now imports the matching reg\<machine-number>.reg after the MSI,
rewritten to WOW6432Node (DNC is 32-bit; reg import does not honor /reg:32),
mirroring the Part Marker WJPRT.reg flow.

- startnet.cmd: route gea-shopfloor-heattreat to the machine-number prompt
  (was defaulting to skip), so machine-number.txt is written for the picker.
- The 6601-6604 .reg files are gitignored (they carry a DNC FtpPasswd
  credential) and deploy via the enrollment share from the working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:21:58 -04:00
cproudlock
f6d970c08d CMM: seed goCMM Shared Data Directory per bay + grant Users write
goCMM (.NET x86) stores its program-source path in HKLM\SOFTWARE\
WOW6432Node\General Electric\goCMM value 'Shared Data Directory'. Being
HKLM, a non-admin shopfloor user cannot set it via goCMM's UI (nor save a
Selected Part Group switch). 09-Setup-CMM Step 2.7 now seeds the per-bay
path (admin context at imaging) and grants BUILTIN\Users write on the key,
mirroring the existing Step 2.5 install-dir ACL grant.

- cmm-bay-config.csv: add shared_data_dir column (per-bay paths, CMM1-12).
- resolve-cmm-bay-config.ps1: write C:\Enrollment\cmm\shareddatadir.txt
  (space-safe; e.g. CMM8 'Venture CMM8').
- 09-Setup-CMM.ps1: Step 2.7 reg seed + Users ACL on the goCMM key.

Not yet deployed to the live server (held).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:59:55 -04:00
cproudlock
59b1a9fb65 Add Part Marker + HeatTreat shopfloor PC types (eDNC + vendor MSI)
New gea-shopfloor-partmarker type (startnet menu option 10) and fill the
gea-shopfloor-heattreat stub. Both follow the collections eDNC pattern:
01-eDNC.ps1 installs DNC, then a 02-Setup script installs the vendor MSI.

Part Marker (02-Setup-PartMarker.ps1):
- msiexec Mark-6.2.1.msi /qn /norestart LAUNCHNTLARS=false (the LaunchNtlars
  custom action otherwise fires under /qn and launches NTLARS mid-install,
  same as eDNC).
- After install: import WJPRT.reg rewritten to WOW6432Node (reg import does
  not honor /reg:32; DNC is 32-bit and reads the redirected hive), then copy
  the Mark overlay + eMxInfo.txt into C:\Program Files (x86)\Mark.

HeatTreat (02-Setup-HeatTreat.ps1):
- msiexec HeatTreat_6.2.1.msi /qn /norestart LAUNCHNTLARS=false. Existing
  09-Setup-Heattreat.ps1 (OpenText) still runs after. Optional .reg/file
  copy left as a marked TODO pending confirmation.

Both MSIs decompiled: WiX/GE Aviation, no forced reboot, only LaunchCondition
is NOT NEWERVERSIONDETECTED. utilpassword.txt is gitignored (secret, deployed
via the enrollment share from the working tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:59:45 -04:00
cproudlock
69a1682a7f webapp: imaging UX overhaul + image management CRUD
Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
  Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
  inferred sessions keyed by MAC for bays that have only touched the boot
  chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
  list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
  30); sidecar .log file per serial records every log_lines push uncapped
  (read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
  Client fetches /imaging/tiles (HTML partial) on hash change and swaps
  #imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
  and any LAPS input the operator is mid-pasting (swap skipped when a
  laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
  header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
  /imaging/tiles SSE refresh. Inferred bays render with yellow border +
  log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
  <serial>. Session metadata grid, stage timeline table, full sidecar log
  with truncation indicator, Copy-support-summary button. Linked from each
  client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
  Intune device-ID QRs in the swapped-in tiles.

Image management
- services/image_registry.py: JSON registry of image types at
  {SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
  config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
  mutate the file then call reload() which rewrites config.IMAGE_TYPES +
  config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
  optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
  Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
  box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
  dirs).

Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
  upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
  /images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
  drivers card-footer rebuilt as interactive list with per-row Adopt inline
  form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
  appends a packages.json entry.

Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
  DNSMASQ_SYSLOG for the log-tailer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:06 -04:00
cproudlock
c74148a222 Blancco boot configs: update IP from 10.9.100.1 to 172.16.9.1
grub-blancco.cfg was still referencing the old PXE LAN IP for kernel,
initrd, and archiso_http_srv. Also synced repo copy to match the live
BDE-kernel TFTP-based config. Updated iPXE scripts (blancco.ipxe,
blancco-debug.ipxe, blancco-chain.ipxe) for the same IP change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:14 -04:00
cproudlock
191083e440 preinstall.json: fix Oracle 11.2 detection path (add WOW6432Node)
Oracle Client 11.2 is 32-bit. On 64-bit Windows the registry key lives
under WOW6432Node but the preinstall detection was reading the native
64-bit path, which doesn't exist. Detection always failed, causing a
redundant reinstall on every re-image. The GE-Enforce manifest had the
correct path since 2026-05-01 but preinstall.json was missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:09:46 -04:00
cproudlock
1860a92afa 09-Setup-WaxAndTrace: bypass vendor cal Setup.exe for all 218-378-13 probes
The .NET cal Setup.exe crashes (0xE0434352) on 218-378-13 series ISOs
even when filenames are clean (no trailing-space bug). Previously only
bypassed when the ' _' trailing-space signature was detected. Now
detects by probe series ID in the data filenames too and always does
direct file copy for 218-378-13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:06:57 -04:00
cproudlock
9ad467ba6c 09-Setup-WaxAndTrace: fix stale $backupDir in no-backup log message
The variable was renamed to $backupDirCandidates + $bd loop but the
else-branch log message still referenced the old $backupDir, producing
a malformed path like "\.zip" in the log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:05:14 -04:00
cproudlock
485fe1c7c4 startnet.cmd: log all WinPE staging operations to winpe-staging.log
Every copy/robocopy during the W: staging phase now appends to
W:\Enrollment\winpe-staging.log (persists as C:\Enrollment\ post-boot).
robocopy gets /LOG+ to append its file list; echo lines log timestamps
for each stage. Helps diagnose missing-file issues post-imaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:49:57 -04:00
cproudlock
9b46d0279f select-waxtrace-asset: fix blank serial/probe columns in bay picker
The picker was reading unit_serial and probe_part from the old INDEX.csv
format. bay-config.csv uses different column names (ftpak_version, model,
host). Updated Select-Object and display format to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:20:37 -04:00
cproudlock
5e13d38512 CMM per-bay PC-DMIS version selection + DODA deploy
Add bay picker (same arrow-key pattern as waxtrace) that maps CMM1-12
to a PC-DMIS version (2016/2019/2026) and DODA flag via cmm-bay-config.csv.

startnet.cmd: replace Standard/DODA submenu with bay picker. Writes
CMMID (e.g. CMM4) to machine-number.txt so the existing
TargetMachineNumbers filter on the SFLD share manifest gates per-bay
entries with no lib changes.

09-Setup-CMM: reads resolved version.txt and filters cmm-manifest.json
by _CmmVersion tag at imaging time so only the matched PC-DMIS version
installs.

cmm-manifest.json: add PC-DMIS 2026.1 entry (patched MSI, product code
{81BACE1B-FB08-4DCF-8100-79911AD3EC1E}) and DODA entry (flat zip extract
to C:\Apps\DODA\). Existing 2016/2019 entries tagged with _CmmVersion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:01:27 -04:00
cproudlock
55c1ab4814 CMM first-run-as-admin, controller credential user-context fix, IE compat hash
09-Setup-CMM: add Step 2.6 that launches each installed PC-DMIS
version once as admin before the PPKG locks the machine down. Also
adds PC-DMIS 2026.1 to the ACL directory list.

Controller credential: cmdkey /add under SYSTEM stored creds in the
wrong vault. Switch to a Register script (MarkerFile detection, runs
once) that creates an AtLogOn scheduled task under BUILTIN\Users so
cmdkey runs in the ShopFloor user's session.

IE compat: update test matrix hash for the new site list that adds
wjfms3.apps.wlm.geaerospace.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:03:32 -04:00
cproudlock
5c3db71879 startnet.cmd: replace remaining xcopy calls with robocopy /MT:16
Waxtrace staging already used robocopy (8e1f81b, f95d305) but the
shopfloor-setup baseline, common, _ntlars-backups, type-specific,
pre-install, CMM, and Keyence copies still used xcopy with zero error
visibility. Switch them all to robocopy /E /MT:16 /R:1 /W:1 with
errorlevel-8 warnings so copy failures surface on the WinPE console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:44:44 -04:00
cproudlock
97b9e58d23 Configure-PC + nocollections ACL: align with two-task machine number design
- gea-shopfloor-nocollections/02-MachineNumberACLs.ps1: gut to no-op
  matching the collections variant. SYSTEM Apply task no longer needs
  per-user ACLs on the eDNC reg key or UDC ProgramData dir.

- Configure-PC.ps1 item 6 (Machine number logon prompt toggle):
  Stop duplicating Register-ScheduledTask logic inline. Call the
  shared Register-CheckMachineNumberTask.ps1 registrar so both the
  Prompt user-task and Apply SYSTEM-task are installed with matching
  SDDL config. Existence check now treats EITHER the new "Prompt
  Machine Number" task OR the legacy "Check Machine Number" task as
  "ON" so old bays still register correctly. Toggle OFF unregisters
  all three names (Prompt + Apply + legacy) for clean removal.
2026-05-24 18:36:35 -04:00
cproudlock
7298d433eb 9999 machine-number prompt: split into user-context Prompt + SYSTEM-context Apply
OLD design: a single 'Check Machine Number' scheduled task ran as the
logged-in user (BUILTIN\Users, Limited) on AtLogOn. It both showed the
InputBox AND tried to update HKLM\SOFTWARE\WOW6432Node\GE Aircraft
Engines\DNC\General + C:\ProgramData\UDC\udc_settings.json. To make
those non-admin writes possible, 02-MachineNumberACLs.ps1 pre-granted
BUILTIN\Users SetValue + Modify on those targets during imaging.

Three problems with that:
  1. SECURITY: any logged-in user could overwrite the machine-identity
     reg key.
  2. FRAGILE: ACL grants raced with eDNC install timing on some bays
     (eDNC reg key didn't exist yet when 02-MachineNumberACLs ran;
     OpenSubKey returned null, ACL silently skipped, Check-MachineNumber
     later failed with PermissionDenied).
  3. SILENT-SUCCESS BUG: Update-MachineNumber's Set-ItemProperty calls
     lacked -ErrorAction Stop. PermissionDenied is a non-terminating
     error in PS5.1, so the try/catch never fired. The script set
     $out.EdncUpdated=$true anyway and the dialog reported success
     while the reg value stayed at 9999. WJF capture log on FGY07FZ3
     shows this exact pattern.

NEW design - two scheduled tasks split by responsibility:

  - "Prompt Machine Number" : AtLogOn trigger, BUILTIN\Users (Limited).
    Reads current values (read-only). If 9999, shows InputBox. Writes
    typed number to C:\Logs\SFLD\machine-number-request.txt. Triggers
    SYSTEM Apply via schtasks /run. Polls for result JSON (60s timeout).
    Shows result MessageBox with TopMost so it isn't hidden behind
    other windows.

  - "Apply Machine Number" : on-demand, SYSTEM (Highest). Reads the
    request file, calls Update-MachineNumber (full HKLM + ProgramData
    access from SYSTEM context). Pulls per-machine NTLARS .reg + UDC
    settings JSON + UDC live data from the SFLD share if site-config
    has share paths. Writes result JSON. Removes request file.
    Unregisters the Prompt task on full success (Prompt itself can't
    self-unregister - Limited users can't delete a SYSTEM-owned task).

  - Default task SDDL only allows Admins + SYSTEM to read/run a
    SYSTEM-owned task. Added BUILTIN\Users GR+GX ACE via COM
    SetSecurityDescriptor so the Limited Prompt task can schtasks /run
    Apply on demand. They can read + execute it; not modify or delete.

  - Update-MachineNumber.ps1 writes now have -ErrorAction Stop so
    PermissionDenied actually fires the catch block instead of being
    swallowed.

  - 02-MachineNumberACLs.ps1 gutted to a no-op (left in place for
    Stage-Dispatcher discovery; no longer grants the ACLs). Old bays'
    existing grants are harmless since SYSTEM ignores them.

  - Register-CheckMachineNumberTask.ps1 now installs both tasks AND
    unregisters the legacy 'Check Machine Number' task name on
    re-imaging. Run-ShopfloorSetup.ps1's $skipInBaseline list now
    includes Prompt-MachineNumber.ps1 + Apply-MachineNumber.ps1 so
    they aren't auto-run during the baseline pass (only via the
    scheduled tasks).

Smoke tested end-to-end on win11 VM with ShopFloor (Limited) logging in
interactively: AtLogOn trigger fired Prompt, dialog rendered, tech
typed 7777, schtasks /run succeeded (the SDDL fix lets Limited users
trigger SYSTEM tasks), Apply ran as SYSTEM, eDNC reg + machine-number.txt
both updated to 7777, result MessageBox shown, Prompt task auto-
unregistered by Apply's cleanup step. No ACL grants needed on any user.

Apply also re-tested with -ErrorAction Stop confirming non-terminating
PermissionDenied now properly throws into the catch + populates Errors[]
+ flips $out.EdncUpdated to false - so any future write failures will
report honestly instead of silently claiming success.
2026-05-24 17:08:59 -04:00
cproudlock
de7d41f5e5 Wax/Trace: defer HKEY_USERS per-user prefs restore to first ShopFloor logon via SYSTEM scheduled task
Bay's ShopFloor user account exists but has never logged in at imaging
time, so its NTUSER.DAT doesn't exist yet and we can't reg-load its
hive to remap source SID -> ShopFloor SID. The in-line restore at
09-Setup Step 3b handles HKLM (controller config, device-map) + files,
but per-user prefs (LouteditS Layout, Page margins, Recent Files, ~2700
rows in a typical WJF capture) get skipped.

Fix: register a SYSTEM-context scheduled task at imaging time that
fires AtLogOn UserId=ShopFloor. When ShopFloor first logs in, Windows
loads their NTUSER.DAT automatically; task fires (running as SYSTEM
so lockdown policies on ShopFloor's user-context don't block HKLM
writes via the same Install script); SID-remap path finds the live
hive and writes prefs into HKEY_USERS\<ShopFloor-sid>. Task writes a
flag file + unregisters itself after one successful run.

Pieces:
- Install-FormtracepakSettings.ps1: new -HKEYUsersOnly switch that
  skips the HKLM .reg files + HKLM CSV rows (already restored at
  imaging time). Fallback user chain ShopFloor->SupportUser->$USERNAME.
- Schedule-WaxTracePerUserRestore.ps1: registers the task, writes
  C:\WaxTrace-Install\Run-WaxTracePerUserRestore.ps1 task action which
  invokes Install with -HKEYUsersOnly and self-cleans on success.
- 09-Setup-WaxAndTrace.ps1 Step 3b: in-line restore now uses
  -RestoreRegistry -RestoreData -RestoreConfig (HKLM + files now);
  calls Schedule-WaxTracePerUserRestore.ps1 to queue HKEY_USERS for
  first ShopFloor logon.
- sync-waxtrace.sh: pushes Schedule-WaxTracePerUserRestore.ps1 to
  PXE share alongside Install-FormtracepakSettings.ps1.

Smoke tested on win11 VM partially: task registration works, manual
trigger fires + self-unregisters cleanly, flag file lands. Real per-
user SID-remap happens at first ShopFloor logon (can't simulate from
qga without an interactive ShopFloor session).
2026-05-24 16:19:45 -04:00
cproudlock
f95d305cca startnet.cmd: robocopy /MT:16 + /J for faster waxtrace stage
Three speed bumps on the waxtrace WinPE stage:
- /MT:16 on the bundle robocopy (was single-thread). Parallelizes the
  small-files-many-of-them part (config + backups + prereqs).
- /J on the single-file FTPak ISO cherry-pick. Unbuffered I/O is
  measurably faster than xcopy on 2 GB-ish files over SMB; xcopy goes
  through the buffered I/O path which double-copies in kernel.
- Swap the FTPak ISO cherry-pick from xcopy to robocopy single-file
  syntax (source dir + dest dir + filename pattern) so we can use /J.

No behavioral change otherwise. Backup ZIP cherry-pick stays on xcopy
(it's ~1 MB, doesn't benefit from /J).
2026-05-24 14:09:06 -04:00
cproudlock
8e1f81b942 startnet.cmd: robocopy /XD instead of xcopy /EXCLUDE:NUL for waxtrace stage
WJF00159 imaging pass on 2026-05-24 logged:
  [ERROR] waxtrace-manifest.json not found at
          C:\WaxTrace-Install\waxtrace-manifest.json
even though the manifest is present on the PXE share. Root cause: the
xcopy approach copied EVERYTHING including the 12+ GB formtracepak\
directory of vendor ISOs, then deleted formtracepak\ afterward and
re-created it with just the one matched ISO. The /EXCLUDE:NUL placeholder
("pass NUL device as exclude-file") was silently ignored by xcopy in
WinPE, and the redirection >/dev/null 2>/dev/null hid any errors. The copy was
either aborting mid-stream (running out of patience / disk / connection)
or the manifest landed but got clobbered in the rmdir/recreate dance.

Switch to robocopy /XD formtracepak which excludes the dir up front so
we never even attempt the 12 GB of ISOs. Keep /NFL /NDL to suppress
per-file listing but leave overall output visible (no >/dev/null) so any
failure is debuggable from the install log. Check errorlevel >= 8
explicitly (robocopy exit codes 0-7 are all "OK").

Smoke tested on win11 VM with a representative tree (manifest +
bay-config + backups\WJF00159.zip + prereqs\hasp.exe + dummy
formtracepak\ ISOs): formtracepak\ excluded, all other files +
sub-dirs copied cleanly, exit 1 (= OK).

Pushed to boot.wim.with-ps (the correct PowerShell-bearing wim - the
one I had been editing before was missing powershell.exe) and deployed
to /var/www/html/win11/sources/boot.wim on 172.16.9.1.
2026-05-24 14:07:45 -04:00
cproudlock
77c917157d select-waxtrace-asset.ps1: read bay-config.csv (17 bays) by default
Picker was still pointed at calibrations/INDEX.csv, which only listed
14 bays that have a per-asset cal ISO ripped. Three bays we just
refreshed into bay-config.csv (WJF00450, WJF00461, WJRP0423) had no
cal-disc entry, so they fell off the menu and tech had to drop to the
free-text prompt to type the asset by hand - felt like a regression.

Two changes:
- select-waxtrace-asset.ps1: prefer bay-config.csv when -IndexPath
  points there (now the default). Auto-detect schema by checking for
  the ftpak_version column. Display columns become
  ASSET / FTPAK / MODEL / USER ID so the tech can confirm bay metadata
  at a glance before pressing Enter. Falls back to
  calibrations/INDEX.csv if bay-config.csv missing.
- startnet.cmd: invoke the picker with -IndexPath bay-config.csv.

Pushed: both boot.wim copies refreshed via wimupdate, new
select-waxtrace-asset.ps1 deployed to PXE share, new boot.wim landed
at /var/www/html/win11/sources/boot.wim on 172.16.9.1.

bay-config.csv parent-dir copy synced with scripts/bay-config.csv so
resolve-bay-config.ps1 (called from startnet.cmd in WinPE) and the
picker both see the same 17-bay set.
2026-05-24 13:12:03 -04:00
cproudlock
d0dcce5427 Wax/Trace: auto-restore captured backup ZIP during imaging
Wires the three pieces that make the per-bay backup restore happen as
part of the PXE imaging pass, no manual post-imaging step required:

1. sync-waxtrace.sh: stage per-asset backup ZIPs from
   /home/camp/pxe-images/wt/<asset>/formtracepak_backup_*.zip (newest)
   into installers-post/waxtrace/backups/<asset>.zip on the PXE share.
   Also pushes scripts/Install-FormtracepakSettings.ps1 alongside the
   bootstrap bundle so 09-Setup can call it post-vendor-install.
2. startnet.cmd: after the FTPak ISO cherry-pick, xcopy
   Y:\installers-post\waxtrace\backups\%MACHINENUM%.zip to
   W:\WaxTrace-Install\backup\%MACHINENUM%.zip. Logs INFO if no per-asset
   ZIP exists - 09-Setup will then skip the restore step.
3. 09-Setup-WaxAndTrace.ps1 Step 3b: between cal ISO and OpenText
   auto-start steps, look for C:\WaxTrace-Install\backup\<asset>.zip
   and invoke Install-FormtracepakSettings.ps1 -BackupPath ...
   -RestoreData -RestoreConfig -Force. Registry restore is intentionally
   omitted - captured HKLM is overwritten by the vendor MSI install in
   Step 2 anyway, and captured HKEY_USERS would land at the source
   bay's SID (which doesn't exist on the freshly imaged bay).

bay-config.csv refresh: 17 captured bays with full version/model/
user_id/hw_sn/hw_id/host. Versions stick to the original bay-config.csv
target values where the live binary drifted to a release we don't have
an ISO for (e.g. WJRP2035 live 5.7.0.82 -> imaging targets 6.0).
WJF00450 flagged MISSING_DATA in user_id + hw_id columns so the
imaging path aborts cleanly until the dongle is read.

Smoke tested on win11 VM with WJF00545's real capture: staged the
expected bay-side layout (C:\WaxTrace-Install\Install-FormtracepakSettings.ps1
+ C:\WaxTrace-Install\backup\WJF00545.zip), invoked the resolve +
call path from a simulated Step 3b - 17 files restored cleanly, 0
errors.
2026-05-24 12:55:37 -04:00
cproudlock
6602afde38 Backup-FormtracepakSettings: timeout-fence reg.exe + recursive walk
WJF00052 / WJF00083 / WJF00084 / WJF00159 produced 0-byte ZIPs on the
2026-05-24 capture pass. Export-FormtracepakInventory was the primary
culprit (Get-Service hang, fixed in d359563), but Backup has the same
root failure mode for the same bays: reg.exe export via Start-Process
-Wait and the recursive Get-ChildItem walk over HKLM:\SOFTWARE\
WOW6432Node\Mitutoyo (95k+ values) both lack timeouts, so a degraded
SCM / antivirus interception / WMI repository on the bay can wedge the
script and the operator kills the .bat - same outcome as the inventory
hang.

Two timeout fences:
- reg.exe export: switch from Start-Process -PassThru -Wait to
  [System.Diagnostics.Process]::Start + WaitForExit(300_000). If the
  process hasn't exited after 5 minutes, Kill() it and log + count an
  error; the .reg file for that one root is skipped but other roots
  + the CSV fallback + the file/data captures continue. Bonus: the
  ProcessStartInfo path also gives reliable $proc.ExitCode access; the
  Start-Process -PassThru object sometimes returned a stub with
  unpopulated ExitCode, producing a false "reg.exe exit  for ..."
  warning even on successful exports.
- Recursive Get-ChildItem CSV walk: move into a Start-Job + Wait-Job
  -Timeout 600 (10 min). If the walk hangs, Stop-Job, log, increment
  error count, .reg file remains authoritative for that root.

Also fixed a subtle Start-Job return-shape bug introduced in the same
edit: emitting the rows via the pipeline inside the job + Receive-Job
flattens correctly, whereas `return ,$list` wrapped the whole List in a
single-element array, so the outer foreach was treating the list-of-95k
as a single row. Net effect was Registry Values: 4 instead of 95152 in
the manifest. Verified fixed via a full real-install run on the VM:
95152 values captured cleanly in 22 s, 0 errors.

Net behaviour: the failed-bay re-runs (WJF00052/00083/00084/00159)
should now either produce real ZIPs or print a clear warning naming the
specific reg root that hung, instead of leaving an empty ZIP behind.
2026-05-24 11:43:42 -04:00
cproudlock
d359563a4c Export-FormtracepakInventory: drop Get-Service entirely (SCM hang)
WJF00052 / WJF00083 / WJF00084 / WJF00159 hung indefinitely on step [5/5]
during the 2026-05-24 capture pass and the operator killed the .bat,
leaving empty inventory CSVs. The earlier Start-Job + Wait-Job -Timeout 30
guard (commit fce6680) was insufficient: Stop-Job on a Get-Service that's
blocked on a degraded Service Control Manager can itself block in the SCM
call (Sentinel HASP driver service in particular has been observed to
wedge Get-Service for minutes), so the main script never unblocked even
after the 30s timeout fired.

Service state isn't load-bearing for identifying the install on a bay
(version + model + DeviceName come from disk + registry, not the SCM),
so the cleanest fix is to drop the Get-Service block entirely. Get-Process
is fast and stays. Step [5/5] label changed from "Checking running
processes and services" to "Checking running processes".

Smoke tested on win11 VM: full Export against a real v6.213 / AVANT
install completes in ~25 s (was 2.9 s with no install, 25 s now reflects
the legitimate ~95k registry walk that dominates the runtime - not the
service hang).
2026-05-24 11:03:20 -04:00
cproudlock
cb149ed8cd Backup-FormtracepakSettings: empirically-grounded version + model detection, HKEY_USERS sweep, manifest evidence stamp
Installed FormTracePak v6.213 on the win11 VM (picking FORMTRACER Avant
in the dialogs) and probed the resulting registry / disk layout to find
out what evidence a real FormTracePak install actually carries. Two
empirical findings:

1. ACTIVE MODEL lives at
   HKLM:\SOFTWARE\WOW6432Node\Mitutoyo\FORMPAK\Config\device map\DeviceName
   (string value). For the AVANT install the value is "FORMTRACER Avant";
   for the CV/SV/CS controllers the value contains the matching model id.
   The Surfpak\FormMes\MachineInfo\Machine\Machine* subtree lists EVERY
   supported machine and is NOT the active selection - the previous
   heuristic that scanned uninstall-entry DisplayName picked up bogus
   WinUSB driver-package entries from "Mitutoyo Corporation" instead.

2. INSTALLED BINARY VERSION is in Formtracepak.exe VersionInfo:
   FileVersion=6.2.0.51, ProductVersion=6.2.0.0. This does NOT match the
   Mitutoyo MSI release label ("6.213") that bay-config.csv uses. The
   uninstall entry's DisplayVersion is empty. So bay-config.csv stays
   canonical for the per-asset marketing version; exe FileVersion is a
   concrete cross-check.

Backup rewrites:
- Replace the previous one-shot version detection with evidence reading:
  bay-config.csv (asset->version+model), Formtracepak.exe VersionInfo,
  device-map\DeviceName. The over-broad uninstall-reg regex is gone.
- Normalize DeviceName ("FORMTRACER Avant" / "CV-4500" / "CV-3200" /
  "Contracer" / "Surftest") to bay-config notation (AVANT / CV-4500 / ...).
- Emit BayConfigMatch flag - true when bay-config-predicted model agrees
  with the device-map\DeviceName on disk. False = drift, tech rechecks
  before restoring to a new bay.
- manifest.json now stamps: AssetNumber, FormtracepakVersion, Model,
  BayConfigVersion, BayConfigModel, BayConfigSource, InstalledExeVersion,
  InstalledExePath, InstalledDeviceName, InstalledModelNormalized,
  BayConfigMatch.

HKEY_USERS sweep:
- Wax/Trace bays log in as a per-site user (lg782713sd at WJ today,
  ShopFloor post-SFLD-2.0, other accounts at other sites). The previous
  HKCU:\ scan only captured the script's running user. Sweep every
  loaded HKEY_USERS hive whose SID matches S-1-5-21-* (real user SIDs)
  for Software\Mitutoyo / FORMTRACEPAK / FORMPAK / SURFPAK subkeys,
  add them to $RegistryRoots. Username-agnostic - works at any site
  without changes.

reg.exe export now also accepts the Registry::HKEY_USERS\<sid>\... PSPath
form by stripping the "Registry::" prefix when building the reg.exe
argument (previously emitted "Invalid key name" errors on HKEY_USERS roots).

Smoke tested against a real v6.213 / FORMTRACER Avant install on win11
VM: bay-config lookup matches, exe FileVersion read, device-map
normalized to AVANT, all four .reg files (HKLM, HKLM-WOW6432Node, HKCU,
HKEY_USERS\<interactive-sid>) exported clean, 0 errors.

Restore-side SID translation (HKEY_USERS\<src-sid>\... -> target user's
SID or HKCU on the new bay) is a follow-up. HKLM tree carries the
critical device-map\DeviceName, controller config, and machine settings;
the HKEY_USERS hive captures per-user UI prefs only.
2026-05-24 09:58:24 -04:00
cproudlock
821e3179d1 Wax/Trace triad: switch to SHA256 hashes (FIPS-compliant) + separate hash-failure path from copy-failure path
Backup-FormtracepakSettings observed 17 Errors on a real shopfloor PC
(G5PRTW04ESF / WJF00159 capture) - all of the form:
  WARNING: Failed to copy ...App.ini: Exception calling ".ctor" with "0"
  argument(s): "This implementation is not part of the Windows Platform
  FIPS validated cryptographic algorithms."

Cause: Windows FIPS policy is enabled on West Jefferson shopfloor PCs.
The per-file Get-FileHash -Algorithm MD5 call throws a hard .NET exception
that bypasses -ErrorAction SilentlyContinue (the throw is from the MD5
constructor, not the cmdlet's parameter binder). That exception was caught
by the broad try/catch around both Copy-Item + manifest add, producing a
misleading "Failed to copy" message even though Copy-Item already succeeded.
Net effect: files copied fine, but manifest rows were missing for those
files (Install would fall back to its bulk-copy path).

Two fixes:
- Switch the hash algorithm from MD5 to SHA256 in both Backup (manifest
  row capture) and Install (Restore-FileItem hash-skip compare). SHA256
  is Get-FileHash's default and is FIPS-compliant. Old MD5-hashed backups
  remain restorable because Install computes hashes fresh from disk at
  restore time and does not read the Hash column from file_manifest.csv.
- Split the broad try/catch in Backup's Copy-ToStaging into two
  try/catches: the first wraps only Copy-Item (real copy failure -> Errors
  counter + skip the file), the second wraps only Get-FileHash (hash
  failure -> log warning, manifest row gets a null Hash and is still
  recorded). A hash failure no longer pretends the copy failed.
- Install's hash compare is wrapped in try/catch too so a hash exception
  falls through to overwrite-mode rather than crashing the restore.

Smoke tested on win11 VM: SHA256 round-trip works (64-char hashes in
file_manifest.csv), Backup reports 0 Errors, Install hash-skip path
correctly skips Identical files on second-run idempotency check.
2026-05-24 09:23:53 -04:00
cproudlock
fce6680c6f Wax/Trace triad: relocate backup path + harden service enum against SCM hangs
Two operator-driven fixes.

1. Backup target moves from S:\2 WJ Scans Record Retention\backup\waxtrace
   to S:\DT\Shopfloor\backup\waxandtrace per the canonical SFLD layout.
   Backup creates the per-asset folder if missing; Install reads from the
   same path by default.

2. Export-FormtracepakInventory hung on step [5/5] when run on a shopfloor
   PC. The original `Get-Service | Where-Object { DisplayName -match ... }`
   pattern materializes every service via the Service Control Manager + post
   filters in PowerShell, which can block indefinitely when any single
   service (Sentinel HASP driver, GE-Enforce agent, etc.) is in a degraded
   state. Two-part fix:
   - Switch to Get-Service -DisplayName 'Mitutoyo*','*FORMTRACEPAK*',...
     so the SCM only materializes matching services (server-side wildcard
     filter, faster + lower blast radius).
   - Wrap the enumeration in Start-Job + Wait-Job -Timeout 30 so a
     degraded SCM aborts gracefully with a warning rather than wedging
     the whole inventory pass.

Smoke tested on win11 VM: full Export run with the new code completes in
2.9 s and emits the inventory CSV correctly.
2026-05-24 08:51:30 -04:00
cproudlock
ed12988591 Wax/Trace triad: harden against empty $PSScriptRoot
Tech ran Export-FormtracepakInventory.ps1 from S:\DT\shopfloor\scripts\
waxandtrace\ and the picker fired correctly but Export-Csv failed with
'Cannot bind argument to parameter Path because it is an empty string'.
Root cause: $OutputPath defaulted to $PSScriptRoot and $PSScriptRoot came
through empty in that invocation path (suspected ISE / IEX-style host or
remote wrapper). On a [string] param, $null/empty default coerces to ''
and Join-Path then errors.

Fix in all three triad scripts: resolve a local $scriptDir via a fallback
chain ($PSScriptRoot -> $PSCommandPath -> Get-Location), and use that
instead of $PSScriptRoot for sibling lookups (Select-WaxtraceAsset.ps1,
bay-config.csv).

Export additionally:
- Drops the $OutputPath = $PSScriptRoot param default in favor of the
  same fallback chain.
- Tests / creates $OutputPath BEFORE the 90k-item registry scan so a bad
  output dir surfaces immediately instead of after a long scan.

Smoke tested on win11 VM: explicit -OutputPath '' now resolves to a
writable directory and the CSV writes successfully.
2026-05-24 08:00:00 -04:00
cproudlock
b8bb00e2fe Wax/Trace triad: arrow-key bay picker + S: backup path
Two operator-UX improvements for the Backup / Export / Install triad.

1. Backup target moves from \\tsgwp00525\...\formtracepac to S:\2 WJ Scans
   Record Retention\backup\waxtrace\<asset>\. S: is mapped at shopfloor
   imaging time and stays mapped post-categorization, so the same default
   path works whether the operator runs the backup on an old bay (manual
   pre-image capture) or a freshly imaged one. The destination directory
   is created if missing.

2. New Select-WaxtraceAsset.ps1 - arrow-key bay picker patterned after
   the WinPE select-waxtrace-asset.ps1. Reads bay-config.csv (sibling
   file), shows asset_tag + ftpak_version + model + user_id per row, and
   returns the selected asset_tag via stdout. Falls back to a manual
   entry prompt if the CSV is missing or the operator picks "Other".

   Backup / Export / Install now invoke the picker when interactive AND
   bay-config.csv is alongside the script. Non-interactive paths
   (qga / SYSTEM / scheduled task) keep silently defaulting to
   COMPUTERNAME so unattended runs are unchanged.

   Export gained an -AssetNumber parameter and stamps it into the output
   CSV filename so multiple inventories from the same host stay
   distinguishable when the operator is auditing several bays in a row.

bay-config.csv is copied into the scripts\ dir so the picker has a
source of truth that ships next to the scripts (and into pxe-images
for tech distribution).

Smoke tested on win11 VM: all four PS1 parse-clean, non-interactive
backup path still produces a valid ZIP (silent COMPUTERNAME default),
picker handles missing-CSV gracefully (manual-entry fallback). The
arrow-key UX itself is operator-verifiable only on a real terminal.
2026-05-24 07:41:25 -04:00
cproudlock
a104cfdebb Wax/Trace triad: fix registry corruption + cover v6.213 vendor install path
Three fixes in Backup / Export / Install, validated end-to-end on the win11 VM
against a seeded HKCU\SOFTWARE\Mitutoyo\Formtracepak key carrying all five
registry value types (String, DWord, ExpandString, MultiString, Binary).

1. Registry corruption on REG_BINARY / REG_MULTI_SZ restore
   Backup wrote those values to registry_values.csv via [string]$val, which
   lossily coerces a byte[] to "System.Byte[]" and a string[] to a
   space-joined scalar. Install's CSV restore loop runs AFTER the .reg file
   import (which is lossless), so the CSV pass overwrites the good values
   with corrupted strings. Two-part fix:
   - Backup: skip Binary / MultiString / None / Unknown when writing the CSV.
     Only String, ExpandString, DWord, QWord roundtrip cleanly through
     New-ItemProperty -PropertyType, so capture only those. The .reg file
     remains authoritative for the rest.
   - Install: defensive filter on the CSV restore loop that skips any row
     whose Type is not in {String, ExpandString, DWord, QWord}. This catches
     legacy CSVs already on the share that were taken before this fix.

2. v6.213 vendor install path not scanned / not restored to
   The per-bay FormTracePak install (commit 54dddaa) lands under
   C:\Program Files (x86)\MitutoyoApp\Formtracepak, but the search-path
   lists in Backup + Export only covered C:\...\Mitutoyo (no MitutoyoApp).
   Result: a backup taken on a freshly imaged v6.213 bay produced Config
   Files = 0 because the script never looked at the actual install dir.
   Added MitutoyoApp (x86 + native ProgramFiles) ahead of the legacy
   paths in all three scripts.

3. Install $DefaultAppTargets fallback didn't include MitutoyoApp either,
   so a restore from an OLDER bay (source path C:\Mitutoyo\...) onto a
   freshly imaged v6.213 bay would fall back to ProgramFiles\Mitutoyo
   (does not exist), miss the MitutoyoApp\Formtracepak tree, and write
   the restored files into the first existing legacy path. Added the
   MitutoyoApp entries at the top of the ordered fallback table.

Smoke tested on win11 VM: backup of all 5 reg types, then corrupt every
value, then Install -RestoreAll restores all 5 byte-exact (incl. REG_BINARY
DE-AD-BE-EF-CA-FE-BA-BE-01-02-03-04 and REG_MULTI_SZ alpha.smp/beta.smp/
gamma.smp). Verified legacy poison-CSV path triggers the defensive filter
and the .reg-imported values survive untouched. -DryRun confirmed
non-mutating. Idempotency confirmed via hash-skip.
2026-05-24 07:29:27 -04:00
cproudlock
b57ba0fb6f webapp: add CSRF token to imaging Clear-all form
The dashboard Clear-all button posts to /imaging/delete-all but the form
was missing the hidden _csrf_token input that the rest of the webapp's
POST forms include, so the endpoint would reject the request when CSRF
enforcement is active.
2026-05-24 07:04:20 -04:00
cproudlock
54dddaa760 Wax/Trace: per-bay FormTracePak version via bay-config.csv
Bays span 7 FormTracePak versions (5.510 - 6.213) and 3 sub-versions
(AVANT / CV-4500 / CV-3200), each with a unique licensing USER ID. Previously
all bays got v6.213 with no model/USER hint to the tech.

- bay-config.csv: 15 rows mapping asset_tag to ftpak_version + model + user_id.
- resolve-bay-config.ps1: WinPE-runnable resolver. Looks up the asset and
  writes version.txt / model.txt / userid.txt / bay-info.txt under
  W:\Enrollment\waxtrace\.
- startnet.cmd: xcopy WaxTrace bundle minus formtracepak\, invoke the
  resolver with %MACHINENUM%, then cherry-pick only the matching
  FORMTRACEPAK-V<ver>.iso (~2 GB local vs ~12 GB if all were staged).
- 09-Setup-WaxAndTrace.ps1: read the per-bay files, mount the right ISO,
  drop <asset>-FTPak-install-info.txt on SupportUser's desktop, and print
  a banner with MODEL + USER ID so the tech has them top-of-mind when
  Setup.exe dialogs come up.
- sync-waxtrace.sh: loop over all FORMTRACEPAK-V*.iso instead of hard-coding
  v6.213; also push bay-config.csv + resolve-bay-config.ps1 to the share.
2026-05-24 07:04:15 -04:00
cproudlock
00d4105956 04-SetControllerNicIP: broaden adapter enum + store-mismatch fix + netsh fallback
- Drop -Physical + MediaType filter from Get-NetAdapter; some OEM driver
  stacks report HardwareInterface=False or localize MediaType, hiding the
  Realtek controller NIC from the previous query.
- Refine corp-vs-controller classification: skip only if a gateway is set
  AND it's not 192.168.1.x, OR if the IP looks corp (10.x / 172.16-31.x).
  Keep candidates that are unconfigured, link-local, or 192.168.x.
- Disable DHCP in both PersistentStore and ActiveStore before New-NetIPAddress
  to avoid "Inconsistent parameters PolicyStore PersistentStore and Dhcp
  enabled" failures.
- Fall back to netsh interface ip set address when the PS cmdlets still
  fight each other; netsh writes both stores cleanly.
2026-05-24 07:04:02 -04:00
cproudlock
86fbc132dd GE-Enforce: backfill Keyence pc-subtype.txt from installed ProductCode
Pre-2026-05 Keyence images didn't write pc-subtype.txt via startnet.cmd. Without
a subtype the share manifest's per-model PCTypes gate falls back to the default
(VR-6000), causing the wrong model to install on VR-3000 / VR-5000 boxes.
Detect the installed VR-3000/5000/6000 by its uninstall ProductCode and
persist the subtype so subsequent GE-Enforce cycles + the share manifest gate
route correctly.
2026-05-24 07:03:54 -04:00
cproudlock
4015adeb33 utilities/waxtrace-recovery: ship cal diagnostic + repair scripts
Pair of operational tools used when a wax/trace bay's cal apply fails
(218-378-13 series cal Setup.exe crash, or any future variant). Were
living in /home/camp/pxe-images/ on the workstation; promoting to the
repo so they ship with the codebase, get version-controlled, and can
be pushed onto each PXE server's enrollment share via the standard
sync flow.

debug-waxtrace-cal.ps1 (+ .bat launcher):
- 9-section forensic walkthrough that runs on the bay as admin.
- Autodetects FormTracePak install location, dumps data/ dir contents,
  finds + mounts the per-asset cal ISO, lists its contents, checks the
  on-disk 09-Setup-WaxAndTrace.ps1 for the direct-copy bypass marker,
  greps the imaging-time log for cal lines, pulls the last 24h of
  .NET Runtime / Application Error / WER events related to Setup.exe.
- Output: C:\Logs\WaxTrace\debug-waxtrace-cal.log

fix-waxtrace-cal.ps1 (+ .bat launcher):
- Idempotent recovery: mounts the bay's cal ISO, unconditionally copies
  data\* into FormTracePak's data dir, renames any filename containing
  ' _' (space-underscore) to drop the embedded space, clears read-only,
  dismounts. Works on both 218-378-13 (broken filenames) and 218-458A
  (clean filenames) since the rename is a no-op when no space is
  present. Bypasses the buggy vendor cal Setup.exe entirely.
- Output: C:\Logs\WaxTrace\fix-waxtrace-cal.log

Both already pushed to \\172.16.9.1\enrollment\tools\ on both PXE
servers earlier today; this commit lands them in the repo as the
source of truth so future PXE server builds + ad-hoc rsyncs pick
them up automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:23 -04:00
cproudlock
de3018512a 09-Setup-WaxAndTrace: fix broken-filename detection (Get-ChildItem -Filter)
The direct-copy bypass added earlier was never firing - Get-ChildItem -Filter
uses Win32 filename filtering and does NOT honor PowerShell wildcards or
character classes. The previous detection filter '*[0-9] _*.txt' matched
literal bracket-zero-through-nine text, which never appears in any
filename. $hasBrokenFilenames was therefore always False, and every
218-378-13 series cal apply fell through to the vendor setup.exe which
crashes with System.ArgumentException (exit -532462766).

Confirmed via debug-waxtrace-cal.ps1 log on WJF00159: section 6 reports
the on-disk script has the direct-copy fix, section 7 shows the actual
runtime log line 'running cal Setup.exe' followed by exit -532462766.
The "fix" was never executing because the gate was broken.

Replace -Filter with Get-ChildItem -File + Where-Object regex match on
' _\d+\.txt$' which catches the actual buggy filename pattern (space-
underscore-digits-.txt at end of name) regardless of probe series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:09:10 -04:00
cproudlock
7f93347f74 CMM: park DODA entry under _pending_doda_entry until binary arrives
Removed the placeholder DODA entry from Applications so a bay imaged
with the 'With DODA' submenu choice today does not log a 'Installer
not found: DODA-PLACEHOLDER.exe' error per cycle. Wiring is otherwise
unchanged: startnet.cmd still offers the With-DODA submenu, pc-subtype.txt
is still written as 'doda', and 09-Setup-CMM.ps1 still passes PCSubType
through to Install-FromManifest. When the DODA installer is sourced,
move the entry from _pending_doda_entry back into Applications (engine
ignores any top-level field other than Version + Applications).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:59:05 -04:00
cproudlock
548d85fed5 CMM: subtype gating + relax exact-version + conditional cleanup + DODA placeholder
Carries over the lessons learned from wax/trace + Keyence imaging today
and threads the same pattern through the CMM path.

09-Setup-CMM.ps1:
- Pass PCType + PCSubType to Install-FromManifest so the manifest's
  per-entry PCTypes filter is honored. Without this every entry runs
  regardless of bay variant - the same bug Keyence had before per-model
  gating was added.
- Move bootstrap cleanup to a conditional that only deletes
  C:\CMM-Install once every (filter-applicable) manifest entry detects
  as installed. If a Hexagon installer forces an unplanned reboot
  mid-install, the new Run-ShopfloorSetup self-resume RunOnce fires on
  the next auto-login; the staging dir needs to still be on disk for
  the re-run to recover. Logs "retained ... not all entries installed
  yet - will retry on next self-resumed run" when partial.

cmm-manifest.json:
- Drop exact DetectionValue from PC-DMIS 2016, PC-DMIS 2019 R2, CLM
  1.8.73, and goCMM. Detection is now uninstall-key presence only, so
  a Hexagon security patch that bumps the DisplayVersion does not
  trigger a re-install loop with exit 1638 every GE-Enforce cycle.
  Bumping the installer in apps/ is the upgrade path - manifest engine
  detection should not also be a version drift catcher for vendor MSIs
  whose backward-compat is established by the vendor.
- Specific to goCMM: the installer filename version (1.1.6718.31289)
  does not match what the installer registers under its uninstall key.
  Dropping DetectionValue silences the false-mismatch loop the prior
  version would have triggered.
- Add DODA placeholder entry gated to PCTypes=["cmm-doda"]. Real
  Installer filename, args, and DetectionPath still TODO once the
  DODA binary is sourced + dropped at installers-post/cmm/.

startnet.cmd:
- Add :cmm_submenu after the user picks gea-shopfloor-cmm from the
  main menu. Two options: Standard (default PC-DMIS + CLM + goCMM
  + Protect Viewer) or With DODA. Mirrors :keyence_submenu pattern.
- Write CMMVARIANT to W:\Enrollment\pc-subtype.txt so Install-FromManifest
  on the bay can apply the PCTypes filter against gea-shopfloor-cmm-doda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:48:16 -04:00
cproudlock
45f39fd431 startnet.cmd: suppress 'System error 85' on duplicate Y: mount
The :prompt_waxtrace_asset picker block maps Y: to the enrollment share
so select-waxtrace-asset.ps1 can read INDEX.csv. The later :skip_machinenum
block also runs `net use Y: \\172.16.9.1\enrollment ...` to keep the share
mounted through the rest of imaging, but Y: is already mapped at that
point so the second net use throws "System error 85: The local device
name is already in use". Harmless (script proceeds and "the command
completed successfully" prints right after) but visible noise to the
operator during PXE imaging.

Gate the second net use behind `if exist Y:\` so we only map when Y: is
not already mounted. Same end state, no error message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:38:34 -04:00
cproudlock
b86b830568 Run-ShopfloorSetup: self-resume RunOnce + top up AutoLogonCount
Imaging chain stalled on WJF00159 after FormTracePak Setup.exe forced
a reboot: SupportUser auto-logged in fine, briefly flashed something
(an HKLM\Run logon hook), then idle - no resume of Run-ShopfloorSetup.
Confirmed via diag-dispatcher.ps1: bay had AutoLogon working, RunOnce
empty, no Stage-Dispatcher.ps1 on disk, no setup-stage.txt.

Root cause: Run-ShopfloorSetup launches once from unattend XML's
FirstLogonCommands and has no self-resume mechanism. If anything cuts
it off mid-flight (FormTracePak Setup, eDNC MSI, Oracle install with
forced reboot, etc) the chain dies and nothing brings it back.
Stage-Dispatcher.ps1 in the repo is academic infrastructure that was
never wired into the live flow - startnet.cmd does not stage it and
nothing creates setup-stage.txt.

Fix: have Run-ShopfloorSetup register ITSELF as RunOnce at the top of
the script. The script is idempotent throughout (detection checks
skip already-done work) so re-entry post-reboot picks up cleanly.
Normal completion path removes the RunOnce so it does not re-fire
after the planned end-of-script reboot.

Also top up AutoLogonCount to 10 at script start. The unattend XML's
LogonCount=7 budget gets consumed across typical imaging reboots
(Office, Oracle, FormTracePak, Run-ShopfloorSetup explicit, sync-intune)
and an unplanned FormTracePak forced reboot pushes the counter past 0,
clearing AutoAdminLogon and parking the bay at the login screen.
Restoring the budget every Run-ShopfloorSetup entry keeps SupportUser
auto-logging in across any number of forced reboots until normal
completion. Lockdown's Autologon.exe sets its own AutoAdminLogon for
the ShopFloor user when it runs, so the post-completion natural
decrement to 0 does not affect the final state.

Verified via diag-dispatcher.ps1 capture on WJF00159 today - that bay
had AutoLogonCount=4 and no Stage-Dispatcher.ps1 on disk, which both
match this root cause + fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:17:07 -04:00
cproudlock
a22d2f0313 Stage-Dispatcher: top up AutoLogonCount during shopfloor-setup stage
Unattend XML sets LogonCount=7 for SupportUser autologon. Each interactive
login decrements the counter; at 0 Windows clears AutoAdminLogon and the
bay parks at the login screen with no one to fire RunOnce -> dispatcher
never re-runs -> imaging chain stalls.

Typical imaging burns several logons: Windows OOBE first logon,
post-Office reboot, Oracle install reboot, FormTracePak Setup.exe forced
reboot, Run-ShopfloorSetup's own shutdown /r at end of script, stage
advances. The unplanned FormTracePak reboot pushes the count past 0 on
some bays - the exact failure mode the WJF00159 imaging today hit, where
Stage-Dispatcher.ps1 had the new defensive RunOnce-re-register fix
landed but the dispatcher still never re-fired because there was no
auto-logon to trigger it.

Top up AutoLogonCount to 10 every time the dispatcher hits the
'shopfloor-setup' stage. Restores the autologon budget across any
vendor-forced reboots that fire during the stage. When sync-intune
finishes the whole pipeline, AutoLogonCount is left to decrement
naturally; by then lockdown's Autologon.exe has set its own
AutoAdminLogon for the ShopFloor user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:59:22 -04:00
cproudlock
44554b95b0 gea-shopfloor-waxtrace: stage Mitutoyo Backup/Install/Export triad
User received three PowerShell scripts from a Mitutoyo source for
backing up + restoring FormTracePak settings per asset:

  Export-FormtracepakInventory.ps1 - audit: enumerate files + reg keys
  Backup-FormtracepakSettings.ps1  - capture: config + data + reg into
                                     timestamped ZIP, manifest-driven
  Install-FormtracepakSettings.ps1 - restore: replay ZIP to a new bay,
                                     hash-skip identicals, backup
                                     existing as .pre_restore_bak

Cleanup pass over the vendor-shipped versions:
- Strip Unicode box-drawing characters from banners (ASCII-only policy)
- Install: switch to [ordered]@{} for DefaultAppTargets/DefaultDataTargets
  so fallback priority is deterministic
- Install: add -AssetNumber gate that defaults to per-asset SFLD path
  \\tsgwp00525...\Shopfloor\backup\formtracepac\<AssetNumber>
- Install: timestamp the .pre_restore_bak filename so re-runs don't
  clobber the previous backup
- Install: handle BackupPath being a directory containing
  formtracepak_backup_*.zip files (picks newest)
- .bat launchers for each PS1 (bypass execution policy, double-click)

Not yet wired into 09-Setup-WaxAndTrace.ps1; pending reference-backup
capture from a known-good bay before promoting to imaging path. Today
the V6.213 vendor MSI install + per-asset cal ISO still handle the
imaging-time setup directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:39:24 -04:00
cproudlock
02499cf74b docs: post-deploy checklist + COM 2/4 PCIe serial-port flowchart update
New post-deploy-checklist.md (+ live.html + static.html siblings):
- Four-section terse coach checklist run after a freshly imaged PC
  reaches the login screen. Pairs with the existing post-deploy debug
  flowchart for failure paths.
- Sections: Common Shop Floor opens + connects, controller comms
  (ping 192.168.1.1 / NTLARS General+FMS), UDC COM port matches
  physical socket + no machine-comm error dialog + Tools > Retry
  Connection succeeds, printers ready (Genspect-specific note).

post-deploy-debug-flowchart.md:
- COM port mapping: PCIe add-in card now listed as 'COM 2 or COM 4'
  (Windows enumeration varies by hardware) instead of fixed COM 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:39:15 -04:00
cproudlock
27045d5e4a gea-shopfloor-collections: controller NIC auto-IP + credential break-glass
04-SetControllerNicIP.ps1 (imaging-time, runs once via Run-ShopfloorSetup):
- Finds the Realtek physical Ethernet adapter (controller NIC on every
  collections bay; corp LAN is Intel)
- Skips any candidate with a DHCP default gateway (that one is the corp
  LAN, not the controller)
- Skips any candidate already on 192.168.1.2
- Sets static 192.168.1.2/24, no gateway, clears DNS - matches the
  manual procedure documented in post-deploy-debug-flowchart.md section 2B
- Refuses to guess when multiple Realtek NICs remain ambiguous
- Imaging-time only, not enforced via GE-Enforce so the tech can override
  on a specific bay if needed without the drift-catcher reverting

Set-ControllerCredential.ps1 + manifest-entry-controller-credential.json:
- Break-glass cmdkey /add for the controller SMB share (\\192.168.1.1\md1
  used by DNC). Scoped to the 12 Okuma LOC650 machine numbers (3201-3212).
- Manifest entry is detection-less so it runs every enforce cycle if the
  script is armed (.ps1 extension); disarmed by default (.ps1.bak on the
  share) so a coach can rename when a bay loses its credential without
  the enforcer overwriting per-bay deviations between events.
- Smoke-tested end-to-end on win11 VM via QGA: SYSTEM context cmdkey /add
  succeeds, cmdkey /list shows the entry. DNC service runs as LocalSystem
  so SYSTEM vault is the right target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:59 -04:00
cproudlock
1f60c86ec8 Shopfloor: auto-register 9999-placeholder machine number prompt
If a bay is imaged with the 9999 placeholder (tech leaves the WinPE
prompt blank or types 9999), the lockdown+auto-login chain ends up at
the ShopFloor user with no real machine number. We had Check-MachineNumber.ps1
written - InputBox + Update-MachineNumber pulls per-machine NTLARS .reg
+ udc_settings_<N>.json from the SFLD share - but it only got registered
when a tech manually ran Configure-PC + toggled item 6. Fresh 9999 bays
never got the prompt, leaving the bay stuck on placeholder values until
someone noticed.

New Register-CheckMachineNumberTask.ps1 auto-registers the logon task
at imaging time. Gated on C:\Enrollment\machine-number.txt == 9999;
bays imaged with a real number never get the task (and any stale task
from a prior 9999-imaging on the same disk is cleaned up).

Wired into Run-ShopfloorSetup.ps1 right after the S: drive logon mapper
register. Skipped for self-contained types (display kiosks have no
machine number).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:48 -04:00
cproudlock
44d2f0afd5 Stage-Dispatcher: re-register RunOnce BEFORE calling Run-ShopfloorSetup
Setup chains we do not control (FormTracePak Setup.exe, eDNC MSI, any
vendor installer that forces an immediate reboot) cut & $script off
mid-flight. Without this, the dispatcher never returns from the call
and the post-call Register-NextRun never fires, leaving the next boot
with no RunOnce + a stalled imaging chain. Observed today on WJF00159
where the FormTracePak v6.213 Setup.exe rebooted the bay before the
dispatcher could advance the stage.

Register the dispatcher's own RunOnce defensively before invoking the
sub-script. If a reboot interrupts the call, the next boot re-fires the
same dispatcher, which re-reads the still-'shopfloor-setup' stage file,
re-runs Run-ShopfloorSetup (every step is idempotent + detects already-
installed state), and converges. The existing post-call Register-NextRun
+ stage advance still run on the happy path - cheap, idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:39 -04:00
cproudlock
5891a1966f Wax/Trace: heal 218-378-13 cal disc filename bug + VC++ 2017 + picker
09-Setup-WaxAndTrace.ps1 Step 3:
- Detect Mitutoyo's burn-time typo on 218-378-13 series cal discs
  (filenames carry a trailing space inside the probe ID component,
  e.g. "Linear_X_218-378-13 _100072210.txt"). Their own .NET Setup.exe
  calls FileSystemInfo.set_Attributes on the source path and throws
  System.ArgumentException because the path contains an embedded space
  component, crashing every cal apply on 218-378-13 bays (exit
  -532462766 = 0xE0434352, .NET unhandled exception). Confirmed via
  WER Event 1026 captured during today's WJF00159 imaging.
- When the buggy filenames are detected, bypass the broken vendor
  Setup.exe and direct-copy data\*.* into
  C:\Program Files (x86)\MitutoyoApp\Formtracepak\data\, renaming
  each file to strip ' _' (space-underscore) -> '_'. Clear read-only
  attr on each landed file. Older 218-458A discs have clean filenames
  and still use the vendor Setup.exe path.

waxtrace-manifest.json:
- Drop DetectionValue=v14.15.26706 from both VC++ 2017 redist entries.
  Windows Update routinely bumps the VS14 runtime to 14.16+ / 14.3x+,
  the older Mitutoyo redist refuses to install over the newer (exit
  1638 'Another version already installed') and the manifest engine
  marked it as failed even though the runtime was fine. Detection is
  now by registry-key+name presence, which any VC++ 2015-2022 redist
  satisfies (they are backward-compatible).

startnet.cmd:prompt_waxtrace_asset:
- Replace free-text input with select-waxtrace-asset.ps1 arrow-key
  picker driven from installers-post/waxtrace/calibrations/INDEX.csv.
- Map Y: enrollment share early so the picker can read INDEX.csv.
- Replace parens-in-parens block (echo of '(e.g. WJRP2335)' inside
  the if-paren caused 'to was unexpected at this time' parse error
  observed by tech mid-imaging) with goto-flow.
- Fall back to free-text prompt if picker unavailable or operator
  presses Esc.

select-waxtrace-asset.ps1:
- Sort bays descending by asset tag so WJRP* lands at top of menu.
- Also staged as gea-shopfloor-waxtrace/select-waxtrace-asset.ps1 so
  sync-waxtrace.sh ships it to installers-post/waxtrace/ on the share.

sync-waxtrace.sh:
- Push select-waxtrace-asset.ps1 next to INDEX.csv on the share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:19 -04:00
cproudlock
e1ea6b7c62 Wax/Trace: switch baseline to FormTracePak v6.213 vendor install
Replace the V6.0 captured-binary replay (pf-x86-MitutoyoApp.zip +
c-MitutoyoApp.zip + hklm-wow-mitutoyo.reg.gz) with a real vendor install
from FORMTRACEPAK-V6.213.iso mounted via Mount-DiskImage.

09-Setup-WaxAndTrace.ps1:
- Step 2 rewritten: Mount-DiskImage on
  C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V6.213.iso, run the VB6
  Setup.exe wrapper from the assigned drive letter (DRIVE_CDROM check
  satisfied by virtual mount, no real CD needed), then Dismount.
- Header rewritten: drop captured/ description, note legacy fallback
  remains in the repo (captured-binary/ unchanged) for manual recovery
  if the v6.213 vendor install fails on a bay.

sync-waxtrace.sh:
- Push formtracepak/FORMTRACEPAK-V6.213.iso (2.0 GB) into the bundle
  instead of captured/ payload. Override path via $FTPAK_ISO env var if
  needed (e.g. testing a v6.213 patch ISO).
- Sanity check no longer demands pf-x86-MitutoyoApp.zip; only requires
  prereqs/ + the manifest + dispatcher PS1.

playbook/utilities/convert-cal-iso.sh:
- New helper. Rebuilds a Linux-dd of a multi-session UDF cal disc into
  a clean ISO9660+UDF hybrid that Windows Mount-DiskImage reads. mkisofs
  reads the file via loop-udf, repacks single-session with the asset tag
  as volume label. Run when `file CAL-*.iso` reports "data" (multi-session
  UDF dd produced a Linux-readable but Windows-unreadable container).
  Single-session 218-458A discs from dd are already ISO9660 and don't
  need this.

Verified on win11 VM via qga: V6.213 ISO mounts, Setup.exe locatable.
14 cal ISOs all converted to ISO9660 (md5s refreshed in INDEX.csv),
re-synced to /srv/samba/enrollment/installers-post/waxtrace/calibrations/.
PXE share bundle now 2.0 GB total (V6.213 ISO + 14 cal ISOs + prereqs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:42 -04:00
cproudlock
2a0b4885fe Keyence VR-3000 G2: imaging-time FIPS opt-out for .exe.configs
Under Intune-enforced LSA FIPS policy, Profilometer / VRAnalyzer /
VRInspection apps crash at device init when MD5CryptoServiceProvider's
ctor is called to verify the probe EEPROM calibration (see keyence3000.txt
+ .png in pxe-images for the dialog + stack).

Patch each .exe.config under C:\Program Files\KEYENCE\<model>\ with
<runtime><enforceFIPSPolicy enabled="false"/></runtime>. Scope is app-CLR
only; OS-wide Lsa FIPS policy stays enforced. CMMC posture: scoped
exception, non-CUI integrity hash, documented in SSP. Each affected bay's
hostname must be on InfoSec's FIPS-exception list before imaging.

09-Setup-Keyence.ps1 gates the patch behind model=vr3000 only. vr5000 /
vr6000 bays do not auto-apply. Verified on win11 VM via qga: 29 configs
across vr5000+vr6000 layouts (vr3000 install was incomplete on VM),
patched + idempotent on re-run, existing <runtime> children preserved.
Also verified on a real PC: 27 patched + 2 skipped (Keyence pre-shipped
the element in two configs), 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:35:29 -04:00
cproudlock
37357eee43 Shopfloor images: add Wax/Trace + Keyence per-model variants
Wax/Trace (gea-shopfloor-waxtrace):
- captured/ holds master FormTracePak v6.0 state (Program Files reg dump
  gzipped, ARP entries) taken from a win11 VM where the CD-ROM-bound VB6
  wrapper was driven to completion. xcopy + reg-import replays the install
  on real bays without running the wrapper itself.
- 09-Setup-WaxAndTrace.ps1 rewrites the stub: installs prereqs via manifest
  (VC++ 2008/2017 x86+x64, Sentinel HASP), expands the captured zips into
  C:\Program Files (x86)\MitutoyoApp + C:\MitutoyoApp, imports the reg
  hive, then mounts the bay's per-machine cal ISO (matched by asset tag
  in machine-number.txt) and runs its Setup.exe.
- waxtrace-manifest.json lists the 5 prereqs with InstallShield-style
  silent flags verified on the win11 VM.
- sync-waxtrace.sh ships captured-binary/ + prereqs + cal ISOs from
  /home/camp/pxe-images/iso/mitutoyo-cal/ to
  /srv/samba/enrollment/installers-post/waxtrace/ on the PXE box.
- select-waxtrace-asset.ps1 arrow-key bay picker for WinPE (parses
  INDEX.csv from the cal share, offers "Other (new bay)" fallback).
- startnet.cmd: prompt_waxtrace_asset prompt, skip_waxtrace_stage xcopy
  block (mirrors :skip_cmm_stage), machine-number.txt write covers bay
  asset tag (WJRP*).

Keyence (gea-shopfloor-keyence) - now multi-model:
- vr3000/manifest.json + vr5000/manifest.json + vr6000/manifest.json
  (current single-model VR-6000 moved into vr6000/ subdir). Each ships
  the model's MSI silent-install + DetectionPath via ProductCode.
  Big payloads (Data1.cab, Data11.cab) gitignored, staged via
  sync-keyence.sh from /home/camp/pxe-images/iso/keyence/.
- 09-Setup-Keyence.ps1 dispatches by C:\Enrollment\keyence-model.txt
  (written by startnet.cmd in :keyence_submenu) and points
  InstallerRoot at C:\KeyenceInstall\<model>. DXSETUP probe widened
  to all three Program Files paths (VR-3000 G2, VR-5000, VR-6000).
- startnet.cmd: :keyence_submenu picks vr3000/vr5000/vr6000,
  :skip_keyence_stage xcopy block selectively stages chosen model bundle,
  pc-subtype.txt also written = drops directly into existing GE-Enforce
  PCSubType wiring (looks for gea-shopfloor-keyence-<model>\manifest.json
  on the tsgwp00525 share for ongoing enforcement, no dispatcher change
  needed).
- sync-keyence.sh mirrors sync-waxtrace.sh pattern.

Verified silent MSI install for VR-3000 G2 v2.5.0 and VR-5000 v3.3.1 on
the win11 VM 2026-05-18 with /qn /norestart ALLUSERS=1 REBOOT=ReallySuppress
TRANSFORMS=1033.mst. boot.wim on 172.16.9.1 wimupdate'd with the new
startnet.cmd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:34:20 -04:00
cproudlock
3aabd47571 imaging dashboard: add Clear all button + endpoint
New /imaging/delete_all endpoint wipes every per-bay JSON in IMAGING_DIR
via imaging_status.delete_all_sessions(). Template adds "Clear all"
outline-danger button next to the count badge, gated on sessions list
non-empty, with confirm() prompt naming the count.

Deployed via scp + systemctl restart pxe-webapp on 172.16.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:58:31 -04:00
cproudlock
7f097013fc BIOS sub-stage: switch to flag-file signaling, move push after W: copies
check-bios.cmd: drop literal `^` from BIOS_STATUS (caret survives quoted
SET so substring search for `->` never matched). Write X:\bios-fired.flag
on flash_done + staged paths so startnet.cmd can detect via if-exist.

startnet.cmd: replace `call set` substring-replace with `if exist
X:\bios-fired.flag`. Move push to after W:\Enrollment xcopy completes
(before Y: cleanup) so dashboard reflects "BIOS firmware update" stage
once file staging is done, matching user mental model of imaging order.

Tested flag-file logic in win11 VM cmd.exe: missing -> SKIPS, present
-> FIRES, removed -> SKIPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:58:25 -04:00
cproudlock
9108b495c9 startnet.cmd: fix BIOS sub-stage detection (substring-replace trick)
Original used 'echo %BIOS_STATUS% | findstr /C:"->"' to detect whether
check-bios.cmd actually applied a firmware update. The '>' inside the
value (from check-bios writing e.g. 'updated 1.5.0 -> 1.6.0') gets
parsed by cmd.exe as a redirect operator BEFORE the pipe is set up.
Result: echo wrote a file named '1.6.0' in cwd instead of piping to
findstr. findstr saw no input, returned errorlevel=1, block never
fired. Plus a stray file got created in X:\Windows\System32.

Confirmed empirically in the win11 VM with test bat:
 - echo|findstr approach: SKIPS even when '->' present  (bug)
 - substring-replace: FIRES iff '->' present  (correct)

Fix: replace echo/pipe/findstr with a substring-replace test:

  call set "BIOS_STATUS_STRIPPED=%%BIOS_STATUS:->=%%"
  if not "%BIOS_STATUS%"=="%BIOS_STATUS_STRIPPED%" ( ... )

The '>' inside %VAR:->=...% is parsed as part of the substring
substitution token, not as a redirect. Yields true diff only when
the arrow was actually in BIOS_STATUS.

xcopy region was never impacted by the bug because subsequent
if-exist blocks didn't depend on the previous errorlevel state.
But the BIOS sub-stage push to the dashboard was silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:50:12 -04:00
cproudlock
d8c64bef2b Add conditional BIOS-update sub-stage on idx=1
winpe-status-push.ps1 now accepts -CurrentStage / -StageIndex
params so callers can override the default "WinPE: PESetup / WIM
apply" string. Backwards compatible.

startnet.cmd: after the existing initial WinPE status push,
inspect $BIOS_STATUS for the "->" marker that check-bios.cmd
writes when an update was actually applied or staged. If present,
fire a second idx=1 push with stage="WinPE: BIOS firmware update -
<status>". No-op for clean "up to date" / "no update in catalog"
runs.

imaging.html: at stage_idx=1 with "bios" in current_stage, swap
friendly label to "Updating BIOS firmware" with a do-NOT-power-off
hint. Bays without firmware updates show the default "Booting from
PXE" label as before.

boot.wim startnet.cmd updated via wimupdate so live PXE clients
pick it up at next boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:26:31 -04:00
cproudlock
76a3ba513c Monitor: drop cert pre-gate + force Report IP after AESFMA connect
Two fixes for the AESFMA swap path:

1. Removed the X509Chain root-thumbprint pre-check. Bay user reported
   "claims connect not yet operational, but i was able to manually
   connect" - meaning the cert IS in LocalMachine\My but
   $chain.Build() returns a partial chain (probably missing an
   intermediate in the local trust store), so our root-thumbprint
   match returned false and Monitor never even tried the netsh
   connect. Letting netsh attempt directly - it's the source of
   truth on whether EAP-TLS auth succeeds. Rate-limited to 30s
   between attempts to avoid log spam when AESFMA truly isn't
   reachable.

2. Bumped post-connect verify sleep 8s -> 15s. WLAN auth + DHCP can
   take longer than 8s on first attempt.

3. New: once Test-AESFMAConnected returns true and INTERNETACCESS
   is deleted, force-run GE_ReportIP_3_v1.EXE /ForceUpdate=True /S
   so the webhook gets the corp-AESFMA IP immediately instead of
   waiting for the next DHCP-change trigger (which may never fire
   if AESFMA was the bay's first 10.x lease). $script:cache.
   ReportIpForced caches the one-shot fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:25:37 -04:00
cproudlock
3385bc87aa Monitor + imaging: per-phase sub-stages within idx=7
Monitor's Get-Snapshot already tracks Phase 1-5 (Intune Registration,
Device Configuration, Software Deployment, Credentials, Lockdown).
The webapp dashboard only saw a single idx=7 push for the entire
post-PPKG / pre-lockdown window, so the friendly label couldn't
reflect "where is this bay actually". Operator looking at the
dashboard had no idea whether to assign category or hit ARTS for
lockdown next.

Monitor now pushes additional idx=7 entries as it crosses Phase
boundaries:
 - On DeviceId capture: "Intune Device ID captured" (existing)
 - On Phase 2 done (SFLD policy delivered = category was assigned):
   "Phase 2 SFLD policy delivered (device configuration)"
 - On Phase 1-4 all complete: "Phases 1-4 complete - ready for
   lockdown (ARTS request)"
 - On lockdown done: idx=8 (existing)

imaging.html maps the stage_string substring to friendly labels:
 - default idx=7         -> "Registered - assign category"
 - 'sfld policy' / 'phase 2' -> "Phase 2 - device configuration"
 - 'credentials' / 'phase 4' -> "Phase 3 / 4 - DSC + credentials"
 - 'ready for lockdown' / 'request lockdown' -> "Ready - request
                              lockdown" (hint: click ARTS request)

Operator now knows exactly when to act vs when to wait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:20 -04:00
cproudlock
220c5db5b9 webapp: LAPS clear actually removes the password from session JSON
Previous /imaging/<serial>/laps clear path used update_session() to
re-feed state minus laps_password. But update_session MERGES payload
into existing state - it cannot delete a key the existing state
already has. The laps_password persisted on disk across the "clear"
POST, then came back into the page on next reload.

Fix: bypass update_session for the clear case. Read the session JSON
directly, pop laps_password, write via atomic tempfile-rename. Same
write pattern update_session uses for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:33:07 -04:00
cproudlock
8debc4ddb3 imaging: LAPS input always visible, not gated on intune_device_id
Was hiding LAPS QR section until idx=7 pushed with a DeviceId.
Operator couldn't paste a password if Monitor hadn't gotten around
to capturing the DeviceId yet. The QR encoding doesn't depend on
DeviceId - it's just the password being encoded - so the section is
useful any time the bay is past the LAPS reboot.

Drop the {% if s.intune_device_id %} gate. LAPS section now appears
in every expanded tile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:30:48 -04:00
cproudlock
036090348c imaging: persist tile expanded state across page refresh
localStorage-backed set of serials at key 'imaging-expanded'. On
DOMContentLoaded, walk each .imaging-card; if its data-serial is in
the set, set card.open=true. On every <details> toggle, update the
set. Refresh no longer collapses the tile the operator was looking
at.

Per-browser state (localStorage), no server round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:29:45 -04:00
cproudlock
b9f66687ac imaging: search now also matches stage name, stage-N, and status
Extended the data-filter attribute to include:
 - friendly stage label (e.g. "awaiting intune lockdown")
 - "stage-N" token (e.g. type "stage-7" to find idx=7 bays)
 - status string (in_progress / succeeded / failed)

Use cases:
 - Find all bays waiting on lockdown: type "lockdown"
 - Find all bays at the same stage: "stage-7"
 - Find failed bays: "failed"
 - Find succeeded bays: "succeeded"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:28:08 -04:00
cproudlock
65eeead5a0 imaging: collapsible tile + ARTS link + reword stage 7
Tile is now <details>. Always-visible summary:
  - QR (96px)
  - serial / hostname / pctype / machine# / status badge
  - friendly stage label + N/M badge + pct
  - progress bar

Click to expand. Body shows:
  - friendly stage hint
  - Intune device id row with [copy] [set category] [ARTS request]
  - metadata one-liner (started / last / MAC / raw current_stage)
  - error banner (if any)
  - LAPS password QR generator
  - log tail
  - Clear button

ARTS button links to https://arts.dw.geaerospace.net/requests/type
for kicking off a new lockdown request (Intune-side step happens
externally; this is a deep-link for convenience).

Stage 7 wording: "Awaiting Intune lockdown" (was "awaiting category /
lockdown" - confusing when category was already assigned). Hint
explicitly mentions category check for cases where it isn't yet set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:27:35 -04:00
cproudlock
a9a7478d5a imaging: organize tile metadata into deterministic rows
Previously last_updated, MAC, started, Intune device id, and the
raw current_stage string were sprinkled around the card in
hard-to-track positions. Reorganized:

Row 1 (header): serial | hostname | pctype | machine# | status badge
Row 2 (stage):  friendly label + N/M badge | pct% (right)
Row 3:          full-width progress bar
Row 4:          friendly hint (optional)
Row 5:          Intune device id + copy + set-category (optional)
Row 6:          started ... last ... MAC ... raw current_stage

Each row consistent across all cards regardless of which fields
are populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:16:34 -04:00
cproudlock
1c361e138b imaging: compact tile + search filter + stage 7 label tweak
Tile shrunk for fleet density:
 - QR: 160px -> 96px
 - Drop big h4 for serial, use fs-6 strong instead
 - DeviceId + buttons + MAC + started time consolidated into one
   small grey row instead of three separate sections
 - Progress bar 1.2rem -> 0.7rem
 - mb-4 -> mb-2 between cards
 - card-body py-2 for tighter vertical rhythm

Search:
 - Sticky search input above the card list
 - Filters live on serial, hostname, pctype, machinenumber,
   intune_device_id via lowercase substring match on a data-filter
   attribute
 - Visible-count badge updates as you type ("3/12")
 - Auto-refresh paused while query has text or while input is focused

Stage 7 label: was "assign category" only, now "awaiting category /
lockdown" to reflect that bays past category assignment are still
waiting on the Intune-driven LAPS-prompt reboot before lockdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:14:51 -04:00
cproudlock
ca647cb690 imaging: redesign tile + LAPS persist + 15s refresh
Tile redesign:
 - QR (or placeholder if not yet captured) on the left as a fixed 160px block
 - Right side: header (serial / hostname / pctype / machinenumber / status)
   then stage label as a big h4 with stage badge + % on the same row,
   then full-width progress bar, then friendly stage hint
 - Intune device id row with copy + set-category buttons consolidated
   under the progress section
 - Footer one-liner: started / last / MAC / raw current_stage (small grey)
 - LAPS QR + log tail still expandable below
 - shadow-sm for visual lift, no card-header line splitting

LAPS persist: POST password to /imaging/<serial>/laps so it survives
the dashboard refresh. Auto-renders QR on page load if the session
already has a stored password. Clear button POSTs empty string to
wipe server-side. No more 60s auto-clear - stays until cleared (or
daily server reset).

Refresh: 5s -> 15s. Reduces polling jitter + gives the eye time to
read before page flickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:11:58 -04:00
cproudlock
5f322d1110 imaging: operator-friendly stage labels per bay card
Was showing the raw push label (e.g. "Run-ShopfloorSetup: handoff to
Monitor-IntuneProgress") which only makes sense if you know the
playbook internals. Added a stage_index -> (label, hint) lookup table:

  1  Booting from PXE                    WinPE loaded
  2  Configuring Windows                 First boot baseline scripts
  3  Installing apps                     09-Setup-<pctype>
  4  Apps installed                      preparing for enrollment
  5  Enrolling in Intune                 PPKG + AAD/Intune join
  6  Waiting on first Intune sync        post-PPKG settle (~120s)
  7  Registered - assign category        idx=7 with QR + set-category btn
  8  Imaging complete                    lockdown applied

Friendly label + one-line hint shown bold, raw stage string shown
underneath in small monospace for techs who want the playbook
breadcrumb. Stage index/total folded into a badge next to the
"Current stage" header so it doesn't need its own column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:08:05 -04:00
cproudlock
520d4aa791 Monitor: fix AESFMA-connected detection + stop retrying once connected
Two bugs causing "AESFMA cert detected, connecting AESFMA..." to log
over and over even after AESFMA is already up:

1. Regex 'SSID\s*:\s*AESFMA.*?State\s*:\s*connected' required SSID
   line BEFORE State line. Actual netsh wlan show interfaces order
   on Win11 is "Name / State / SSID" - State comes FIRST. The non-
   greedy match never succeeded. Always thought AESFMA wasn't
   connected. Refactor to a Test-AESFMAConnected helper that splits
   output into per-adapter blocks and checks SSID + State independently,
   tolerating either order.

2. Added a fast-path at top of the WiFi-swap block: if AESFMA is
   already connected (no help needed from us), just delete
   INTERNETACCESS if still present and flip the cache flag to stop
   running this block. Previously the block only set the flag after a
   successful connect-then-verify-then-delete cycle; if AESFMA was
   already up at first check, the cycle "succeeded" each tick but
   the flag never flipped, producing the log spam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:06:00 -04:00
cproudlock
894305e906 Monitor: drop AESFMA-connected from Phase 1 done; webapp: LAPS endpoint
1. Phase 1 done gate was requiring 'AESFMA WLAN connected' in addition
   to the data-side signals (AAD + Intune + EmTask + baseline). If the
   bay never reached AESFMA (cert never landed, RADIUS unreachable),
   Phase 1 stayed IN PROGRESS forever even though Intune registration
   was actually complete. Reverting to the data-side-only definition.

2. New webapp endpoint POST /imaging/<serial>/laps stores a LAPS
   password in the session JSON so it survives the 5s dashboard
   auto-refresh. Empty body clears the field. Daily reset of the
   server (cron/restart) is the lifetime cap on stored passwords.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:53:05 -04:00
cproudlock
1b7e1bfee4 imaging: pause page auto-refresh while a LAPS QR is showing
meta http-equiv=refresh fires every 5s and reloads the entire page,
wiping the LAPS QR state mid-scan. Replaced the meta tag with a
JS-driven setTimeout(location.reload, 5000) so renderLapsQR() can
clearTimeout it. Reload resumes when the QR is cleared (manual or
60s auto). Multi-bay safety: only resumes if no other bay still has
a QR rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:50:24 -04:00
cproudlock
d5398bdd74 imaging: LAPS-password-to-QR generator per bay card
Per-bay <details> section with:
 - Input field for LAPS password (paste from Intune portal manually,
   since deep-link to LAPS blade needs AAD objectId we can't obtain)
 - Make QR button generates a client-side QR from the input
 - QR displayed below at 280px with 4-cell quiet zone
 - Auto-clears input + QR after 60s with live countdown
 - Manual Clear button
 - Enter key on the input also triggers QR generation

Password never POSTs to server, never logged, never persists past the
60s window. Generated using the same qrcode-generator lib already
loaded for the device-id QR. Scan with a USB barcode scanner plugged
into the bay (HID keyboard mode) -> password types into bay login
field. Faster than reading off the Intune portal letter-by-letter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:48:43 -04:00
cproudlock
cdb6655e4a imaging: drop LAPS deep-link, keep only category
LAPS retrieval blade is keyed on AAD object id, not aadDeviceId /
mdmDeviceId. We capture aadDeviceId from dsregcmd; resolving to
objectId would require a Graph API call with Device.Read.All which
we don't have at WJ. Removed the LAPS button - operator goes to
Intune portal manually for LAPS as before.

set-category button stays - aadDeviceId works for that blade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:47:46 -04:00
cproudlock
74ba3d1339 imaging: deep-link buttons for Set Category + LAPS per bay
Two buttons next to the Intune device id on each bay card:
 - "set category" -> portal.azure.us Intune device blade properties
   via aadDeviceId/{deviceId}
 - "LAPS" -> intune.microsoft.us encryptionKeys blade via
   mdmDeviceId/{deviceId}

Both use the dsregcmd DeviceId we already capture - no Graph API
lookup or objectId resolution needed. One click from the dashboard
takes the tech to the right page for category assignment or LAPS
retrieval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:20:10 -04:00
cproudlock
4599c85509 Monitor: strip ANSI escape codes from dsregcmd output before regex
Smoking gun for "Monitor's on-screen QR works but no idx=7 push lands
on the PXE dashboard". Win11's dsregcmd emits ANSI VT100 escape codes
(e.g. \x1B[7mDeviceId\x1B[0m :) around field labels. Captured output
strings then have those codes between "DeviceId" and ":". The strict
regex 'DeviceId\s*:\s*<guid>' fails because \s* doesn't match ANSI
escape chars. $script:cache.DeviceId stays null, idx=7 push never
fires.

Build-QRCodeText was unaffected because it uses Select-String 'DeviceId'
(substring match, tolerates anything in between) then splits on ':'.

Fix: strip ANSI sequences via -replace '\x1B\[[0-9;]*[A-Za-z]', '' before
running the regex. Same pattern covers all CSI sequences dsregcmd uses.
Also force Out-String to get a single string back (was an array of lines
from 2>&1; -match on arrays returns matching elements but $matches
behavior across mixed objects is fragile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:17:05 -04:00
cproudlock
2d75935dfc PostPpkg settle 60s -> 120s
Empirical: a fresh-imaged bay often hasn't finished AAD-join + first
Intune sync by 60s, so the post-PPKG-reboot Monitor instance starts
without DeviceId visible to dsregcmd yet. Doubling the settle to 120s
gives MDM more time to land baseline policies before the reboot,
which means the post-reboot Monitor sees AAD-joined + DeviceId on
first tick and fires idx=7 immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:13:26 -04:00
cproudlock
3fb1d983df Stop moving OpenText / WJ Shopfloor shortcuts into Shopfloor Tools
OpenText / Host Explorer shortcut filenames vary by installed profile
(e.g. 'WJ Shopfloor OpenText.lnk', 'WJ Shopfloor.lnk', 'HostExplorer
ShopFloor.lnk'). The taskbar-pin path in site-config.json hardcodes
'Shopfloor Tools\WJ Shopfloor.lnk' - mismatches the actual filename
so 07-TaskbarLayout silently skips pinning it.

Drop OpenText/ShopFloor/HostExplorer pattern moves from 06's
categorization regex. Shortcuts stay at the public-desktop top
level where the OpenText installer placed them. Tech sees the
icon on the desktop, no taskbar pin (the variable filename made
the pin unreliable anyway).

Other categories (UDC, eDNC, NTLARS, etc with stable filenames)
still move into Shopfloor Tools and pin correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:10:16 -04:00
cproudlock
9beee842f1 Monitor: deterministic AESFMA cert check via X509Chain root match
Walk Cert:\LocalMachine\My, build each cert's chain, look for chain
element with thumbprint 27F0C9A22B28CE7687B115A29E31BF4B3ABB180F.
That's the AESFMA.xml TrustedRootCA value = the GE Aerospace
FreeRADIUS root that AESFMA EAP-TLS validates against. A client cert
chained to that root is the SCEP-provisioned AESFMA machine cert.

Combined with the verify-before-delete connect attempt, this gives
two gates:
 1. Cert deterministically exists + chains correctly
 2. netsh wlan connect to AESFMA actually reports State=connected

Only after both pass does INTERNETACCESS get deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:48:00 -04:00
cproudlock
f013aa2bff Monitor: AESFMA verify-before-delete - keep INTERNETACCESS until cert ready
Old gate (SCEP cert in LocalMachine\My with Client Auth EKU) was both
too loose (matches non-AESFMA certs) and unable to verify the cert
chains to GE's RADIUS root. INTERNETACCESS got deleted before AESFMA
could actually authenticate, orphaning the bay.

New flow: when Phase 1 essentials (AAD + Intune + EmTask + baseline)
are complete, ATTEMPT netsh wlan connect AESFMA with INTERNETACCESS
still up as fallback. Wait 8s, parse netsh wlan show interfaces for
SSID=AESFMA + State=connected. Only delete INTERNETACCESS after
operational verification. If AESFMA connect fails (cert not provisioned
yet, RADIUS server unreachable, etc), keep INTERNETACCESS and retry
next tick. Loop runs every 5s while DeviceIdReported is false, so the
swap fires as soon as AESFMA is operationally viable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:46:19 -04:00
cproudlock
a9260ecadd Monitor: 5s tight poll while DeviceId still missing
DeviceId may not be in dsregcmd output the moment Monitor starts after
PPKG reboot - takes a few minutes for AAD-join to settle. Default 30s
PollSecs leaves wide gaps where Monitor isn't checking. Sleep 5s
instead while DeviceIdReported is still false. Once captured + idx=7
push lands, falls back to PollSecs (30s) for the rest of the loop.

Worst case for QR-on-dashboard latency: ~5 seconds after dsregcmd
starts returning a DeviceId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:08:12 -04:00
cproudlock
ab3e1c98f6 Monitor: fire idx=7 immediately on DeviceId capture (beat LAPS reboot)
User constraint: GE-issued LAPS-prompt reboot lands ~1 minute after
Report IP posts its log. Need the QR on the PXE dashboard BEFORE
that reboot or the operator has no way to look up the device for
LAPS retrieval.

Previously idx=7 was gated on Phase 1 essentials (AAD + Intune
enrolled + EmTask + baseline policies >=5). Those flips happen
later than DeviceId capture (dsregcmd shows DeviceId the instant
AAD-join completes during PPKG). Dropping the gate so idx=7
fires the moment the cache has a DeviceId. Phase 1 row on the
on-bay Monitor display still has its own AESFMA-required gate
for operational completeness; only the dashboard push is moved
earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:06:02 -04:00
cproudlock
842ef88ccb Monitor: gate WiFi swap on SCEP cert + Phase 1 done on AESFMA connected
Two related fixes for the WiFi handoff timing:

1. WiFi swap (delete INTERNETACCESS + connect AESFMA) was firing on
   Phase 1 essentials being green (AAD + Intune + EmTask + baseline
   policies >=5). That signal flips ~minutes BEFORE the Intune SCEP
   machine cert actually lands in LocalMachine\My. Without the cert,
   AESFMA EAP-TLS auth fails and the bay has no path at all (we just
   deleted INTERNETACCESS). Stuck.

   New gate: walk Cert:\LocalMachine\My for any cert with Client
   Authentication EKU (1.3.6.1.5.5.7.3.2). When that's present, SCEP
   has delivered, AESFMA EAP-TLS will succeed. Swap then fires safely.

2. Phase 1 row on the on-bay Monitor display now ALSO requires
   AESFMA to be actively connected (parsed from netsh wlan show
   interfaces: SSID=AESFMA + State=connected). Phase 1 stays IN
   PROGRESS until the bay is operationally on corp WLAN, not just
   data-side enrolled. Matches user request "not complete phase 1
   until AESFMA is ready".

idx=7 dashboard push still fires on the original Phase 1 essentials
gate so the QR appears as soon as Intune registers the device,
independent of AESFMA join timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:04:09 -04:00
cproudlock
a17b3fae6a Retire wired-disable/re-enable dance now that PXE LAN is 172.16.9.0/24
GE Report IP filters Get-NetIPAddress on StartsWith("10.") and PXE LAN
addresses are now 172.16.9.x which the filter skips naturally. The
disable-then-re-enable workaround was only needed when PXE LAN was
10.9.100.x and bays leaked that IP to the GE webhook. With the renumber
that whole flow is dead weight.

Removed:
 - playbook/shopfloor-setup/Shopfloor/lib/Disable-WiredNics.ps1 (file)
 - Run-ShopfloorSetup: Disable-WiredNics call after PPKG returns
 - Run-ShopfloorSetup: "GE Re-enable Wired NICs" SYSTEM task registration
 - Monitor-IntuneProgress: reportIpLog-gated wired re-enable + idx=7 retry
 - Monitor-IntuneProgress: reportIpDone gate on Phase 1 done check

Side benefit: stages 2-6 dashboard pushes no longer go dark mid-flow
(used to die between idx=6 and idx=7 when wired was off). Phase 1 row
on the Monitor screen now flips COMPLETE on the natural AAD + Intune
+ EmTask + baseline-policies condition instead of waiting on the
Report IP log file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:45:54 -04:00
cproudlock
ce604adcda Renumber PXE LAN from 10.9.100.0/24 to 172.16.9.0/24
Single-site bay-stuck issue at WJ: GE Intune Report IP script filters
Get-NetIPAddress on StartsWith("10.") and posts everything matching
to the GE Tines webhook. Bays at WJ get the PXE LAN 10.9.100.x IP
captured and reported -> GE backend tags bays as on a non-corp 10.x
subnet -> dynamic group eligibility for SFLD policy never matches.
Other GE sites work because their PXE LANs aren't on 10.x at all.

Renumber PXE LAN to RFC1918 172.16.9.0/24 so the GE filter naturally
skips wired PXE addresses without any disable-NIC dance.

Server-side already in flight (netplan dual-bound, dnsmasq scope +
boot URL repointed, blancco preferences + grub.cfg + iPXE GetPxeScript
all sed'd to 172.16.9.1). This commit is the playbook / scripts /
docs side: 109 hits across 35 files sed'd in one shot.

After this lands + boot.wim is rebuilt + bays renumber off DHCP,
the 10.9.100.1 binding will be dropped from netplan as the final
cleanup step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:30:32 -04:00
287 changed files with 25821 additions and 3372 deletions

18
.gitignore vendored
View File

@@ -101,6 +101,20 @@ playbook/shopfloor-setup/Shopfloor/PrinterInstallerMap.exe
# /home/camp/pxe-images/keyence/Logs/Keyence/install.log for the signature). # /home/camp/pxe-images/keyence/Logs/Keyence/install.log for the signature).
# Canonical source on the GE-Enforce SFLD share: # Canonical source on the GE-Enforce SFLD share:
# tsgwp00525\sfld$\v2\shared\dt\shopfloor\gea-shopfloor-keyence\apps\Data1.cab # tsgwp00525\sfld$\v2\shared\dt\shopfloor\gea-shopfloor-keyence\apps\Data1.cab
# Stage to playbook/shopfloor-setup/gea-shopfloor-keyence/installers/Data1.cab
# before building the USB image. # before building the USB image.
playbook/shopfloor-setup/gea-shopfloor-keyence/installers/Data1.cab playbook/shopfloor-setup/gea-shopfloor-waxtrace/captured-binary/
# Keyence per-model installer payloads - too big for git, staged via sync-keyence.sh
playbook/shopfloor-setup/gea-shopfloor-keyence/vr3000/installers/Data*.cab
playbook/shopfloor-setup/gea-shopfloor-keyence/vr3000/installers/*.msi
playbook/shopfloor-setup/gea-shopfloor-keyence/vr5000/installers/Data*.cab
playbook/shopfloor-setup/gea-shopfloor-keyence/vr5000/installers/*.msi
playbook/shopfloor-setup/gea-shopfloor-keyence/vr6000/installers/Data1.cab
# Part Marker (Telesis) utility password - secret, deployed via the enrollment
# share from the working tree, never committed.
playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.txt
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
# credentials. Deployed via the enrollment share from the working tree.
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg

BIN
Binary/Binary.NewBinary1 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
Binary/Binary.NewBinary10 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary11 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary12 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Binary/Binary.NewBinary13 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary14 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary15 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary16 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary17 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary18 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

40
Binary/Binary.NewBinary19 Normal file
View File

@@ -0,0 +1,40 @@
Option Explicit
' アップグレードコードから、製品コードを取得
'
' 第1引数 : アップグレードコード(「{」、「}」、ハイフンあり)
Function GetProductCodeFromUpgradeCode(UpgCode)
Dim listProductCode
Dim szProductCode
' アップグレードコードから、関連する製品名のリストを取得
Set listProductCode = Session.Installer.RelatedProducts(UpgCode)
' 基本、1件のみヒットするものとする
For Each szProductCode In listProductCode
GetProductCodeFromUpgradeCode = szProductCode
' 1件目を取得した段階で抜ける
Exit For
Next
End Function
' アップグレードコードから既にインストール済みのアプリケーションのインストールパスを取得する
Sub GetInstallPath()
Dim WshShell
Dim szProductCode
Dim szInstallStringKey
Set WshShell = CreateObject("WScript.Shell")
' アップグレードコードから、製品コードを取得
szProductCode = GetProductCodeFromUpgradeCode(Session.Property("UpgradeCode"))
' レジストリのInstallLocationを取得
szInstallStringKey = WshShell.RegRead("HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + szProductCode + "\InstallLocation")
Session.Property("INSTALLDIR_FOR_MAJORUPGRADE") = szInstallStringKey
Set WshShell = nothing
End Sub

BIN
Binary/Binary.NewBinary2 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

22
Binary/Binary.NewBinary20 Normal file
View File

@@ -0,0 +1,22 @@
Option Explicit
Sub CheckOSVersion
Const HKEY_LOCAL_MACHINE = &H80000002
Dim WshShell,objRegistry
Dim strComputer, strKeyPath, strValue, strValueName
Set WshShell = CreateObject("WScript.Shell")
strComputer = "."
Set objRegistry = GetObject("winmgmts:{impersonationLevel=impersonate}!\\" & strComputer & "\root\default:StdRegProv")
strKeyPath = "SOFTWARE\Microsoft\Windows NT\CurrentVersion"
strValueName = "CurrentMajorVersionNumber"
objRegistry.GetDWORDValue HKEY_LOCAL_MACHINE,strKeyPath,strValueName,strValue
If (not IsNull(strValue)) and (strValue=10) Then
Session.Property("IsWindows10")="1"
else
Session.Property("IsWindows10")="0"
End If
End Sub

13
Binary/Binary.NewBinary21 Normal file
View File

@@ -0,0 +1,13 @@
Option Explicit
'レジストリに登録する日付をプロパティに設定
Sub SetInstallDate
Session.Property("INSTALLDATE") = YYYYMMDD
End Sub
'YYYYMMDD形式の日付を返す
Function YYYYMMDD
YYYYMMDD = Year(Date) & Right("0" & Month(Date), 2) & Right("0" & Day(Date), 2)
End Function

BIN
Binary/Binary.NewBinary3 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

BIN
Binary/Binary.NewBinary4 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Binary/Binary.NewBinary5 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
Binary/Binary.NewBinary6 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary7 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary8 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary9 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -23,7 +23,7 @@ Client PXE boot (UEFI Secure Boot)
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (10.9.100.10-100, 12h lease) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
@@ -32,8 +32,8 @@ Client PXE boot (UEFI Secure Boot)
### Network ### Network
- **PXE server IP:** `10.9.100.1/24` - **PXE server IP:** `172.16.9.1/24`
- **DHCP range:** `10.9.100.10` - `10.9.100.100` - **DHCP range:** `172.16.9.10` - `172.16.9.100`
- **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009) - **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009)
## Quick Start ## Quick Start
@@ -85,12 +85,12 @@ Creates a bootable USB with two partitions:
4. After reboot, the first-boot script: 4. After reboot, the first-boot script:
- Installs all offline .deb packages - Installs all offline .deb packages
- Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp) - Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- Configures static IP `10.9.100.1/24` - Configures static IP `172.16.9.1/24`
5. Move the server's wired NIC to the isolated PXE switch 5. Move the server's wired NIC to the isolated PXE switch
### Step 5: Access the Web Interface ### Step 5: Access the Web Interface
Open `http://10.9.100.1:9009` from any machine on the isolated network. Open `http://172.16.9.1:9009` from any machine on the isolated network.
## Web Management Interface ## Web Management Interface
@@ -213,11 +213,11 @@ This creates `pxe-server-proxmox.iso` containing the Ubuntu installer, autoinsta
3. Attach the ISO as CD-ROM and start the VM 3. Attach the ISO as CD-ROM and start the VM
4. Ubuntu auto-installs with zero interaction (~10-15 minutes) 4. Ubuntu auto-installs with zero interaction (~10-15 minutes)
5. After reboot, first-boot configures all PXE services automatically 5. After reboot, first-boot configures all PXE services automatically
6. Access the web interface at `http://10.9.100.1:9009` 6. Access the web interface at `http://172.16.9.1:9009`
### Import WinPE Images ### Import WinPE Images
After the server is running, import deployment images via the web interface at `http://10.9.100.1:9009/import` or by mounting a USB drive with WinPE content. After the server is running, import deployment images via the web interface at `http://172.16.9.1:9009/import` or by mounting a USB drive with WinPE content.
## Samba Shares ## Samba Shares
@@ -235,7 +235,7 @@ All shares use guest access (no authentication) for ease of use on the isolated
Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi. Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi.
Reports are viewable and downloadable from the web interface at `http://10.9.100.1:9009/reports`. Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`.
## Notes ## Notes

View File

@@ -18,7 +18,7 @@ Client PXE boot
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (10.9.100.10-100) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
@@ -95,7 +95,7 @@ Move the server's wired NIC to the isolated switch for PXE clients.
### Step 6: Import WinPE Content (if not bundled in Step 3) ### Step 6: Import WinPE Content (if not bundled in Step 3)
**Option A:** Use the web interface at `http://10.9.100.1:9009` to import from USB. **Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB.
**Option B:** Manual copy: **Option B:** Manual copy:
```bash ```bash
@@ -107,7 +107,7 @@ sudo umount /mnt/usb2
## Web Management Interface ## Web Management Interface
Access at `http://10.9.100.1:9009` from any machine on the isolated network. Access at `http://172.16.9.1:9009` from any machine on the isolated network.
| Page | URL Path | Purpose | | Page | URL Path | Purpose |
|-------------------|-------------|-----------------------------------------------| |-------------------|-------------|-----------------------------------------------|
@@ -146,7 +146,7 @@ sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso
# Watch progress (Ctrl+] to detach) # Watch progress (Ctrl+] to detach)
sudo virsh console pxe-test sudo virsh console pxe-test
# After install: ssh pxe@10.9.100.1 / http://10.9.100.1:9009 # After install: ssh pxe@172.16.9.1 / http://172.16.9.1:9009
# Clean up # Clean up
sudo ./test-vm.sh --destroy sudo ./test-vm.sh --destroy
@@ -215,8 +215,8 @@ pxe-server/
## Network Configuration ## Network Configuration
- PXE server static IP: `10.9.100.1/24` - PXE server static IP: `172.16.9.1/24`
- DHCP range: `10.9.100.10` - `10.9.100.100` - DHCP range: `172.16.9.10` - `172.16.9.100`
- Lease time: 12 hours - Lease time: 12 hours
- DNS: `8.8.8.8` (passed to clients, not used by server) - DNS: `8.8.8.8` (passed to clients, not used by server)
- Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp - Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp

View File

@@ -17,7 +17,7 @@ autoinstall:
match: match:
name: "en*" name: "en*"
addresses: addresses:
- 10.9.100.1/24 - 172.16.9.1/24
dhcp4: false dhcp4: false
dhcp6: false dhcp6: false
optional: true optional: true

View File

@@ -0,0 +1,14 @@
#!ipxe
dhcp
echo SAN booting Blancco ISO...
sanboot http://172.16.9.1/blancco/blancco.iso || goto failed
goto end
:failed
echo
echo FAILED!
prompt Press any key for iPXE shell...
shell
:end

View File

@@ -0,0 +1,48 @@
#!ipxe
echo =============================================
echo Blancco PXE Debug Boot
echo =============================================
echo
echo [1/4] Network configuration...
dhcp || echo DHCP FAILED
echo MAC: ${net0/mac}
echo IP: ${net0/ip}
echo GW: ${net0/gateway}
echo DNS: ${net0/dns}
echo
set server 172.16.9.1
echo [2/4] Testing HTTP connectivity...
imgfetch --name test http://${server}/blancco/config.img || echo HTTP FETCH FAILED
imgfree test
echo HTTP to ${server}: OK
echo
echo [3/4] Loading kernel and initrd...
echo Fetching vmlinuz-bde-linux...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initrd-combined.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 || goto failed
echo Kernel loaded OK.
echo
echo Fetching initrd-combined.img (43MB, may take a moment)...
initrd http://${server}/blancco/initrd-combined.img || goto failed
echo Initrd loaded OK.
echo
echo [4/4] About to call boot command...
echo
echo !! Note the LAST kernel line visible before any freeze !!
echo
prompt Press any key to boot (or Ctrl-C for iPXE shell)... && goto doboot || shell
:doboot
boot || goto failed
:failed
echo
echo !! BOOT FAILED !!
echo
prompt Press any key for iPXE shell...
shell

View File

@@ -0,0 +1,17 @@
#!ipxe
dhcp
set server 172.16.9.1
echo Loading Blancco kernel...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initrd-combined.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 nomodeset || goto failed
echo Loading initrd (combined)...
initrd http://${server}/blancco/initrd-combined.img || goto failed
echo All files loaded. Booting now...
boot || goto failed
:failed
echo
echo Blancco boot FAILED.
prompt Press any key to drop to iPXE shell...
shell

View File

@@ -3,22 +3,10 @@ set timeout=0
insmod efinet insmod efinet
insmod net insmod net
insmod http
insmod tftp insmod tftp
net_bootp net_bootp
# Blancco via Ubuntu-kernel switch_root. This is the cmdline that produces
# the slim Ubuntu-kernel-chain grubx64.efi. DO NOT flip this back to
# vmlinuz-bde-linux / archiso_http_srv / copytoram=y - that was the Apr-14
# regression (commit d6776f7) that put us into Blancco's narrow-NIC-driver
# archiso path and hung on Dell Precision hardware. The Ubuntu kernel path
# with our verbose, full-drivers/net/-tree kexec-initrd.img is what works.
#
# kexec-initrd.img is built by the pxe_server_setup.yml "Build Blancco PXE
# initramfs" task (sweeps drivers/net/ + depmod). blancco-init.sh inside it
# handles the rest: modprobe all common NICs, DHCP, download airootfs.sfs,
# overlay mount, switch_root.
menuentry "Blancco Drive Eraser" { menuentry "Blancco Drive Eraser" {
linux (http,10.9.100.1)/blancco/vmlinuz-ubuntu ip=dhcp linux (tftp,172.16.9.1)/blancco/vmlinuz-bde-linux archisobasedir=arch archiso_http_srv=http://172.16.9.1/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10
initrd (http,10.9.100.1)/blancco/kexec-initrd.img initrd (tftp,172.16.9.1)/blancco/intel-ucode.img (tftp,172.16.9.1)/blancco/amd-ucode.img (tftp,172.16.9.1)/blancco/config.img (tftp,172.16.9.1)/blancco/initramfs-bde-linux.img
} }

View File

@@ -16,8 +16,8 @@ systems.
## Network layout ## Network layout
- PXE server static IP: `10.9.100.1/24` on an isolated subnet. - PXE server static IP: `172.16.9.1/24` on an isolated subnet.
- DHCP range served by dnsmasq: `10.9.100.10 - 10.9.100.100`, 12h leases. - DHCP range served by dnsmasq: `172.16.9.10 - 172.16.9.100`, 12h leases.
- Default gateway and DNS handed out via DHCP point at the PXE server itself. - Default gateway and DNS handed out via DHCP point at the PXE server itself.
- The subnet has no route to the corporate LAN. Client traffic (Blancco BMC - The subnet has no route to the corporate LAN. Client traffic (Blancco BMC
cloud, Intune enrollment) goes out via WiFi after Windows boots; PXE-time cloud, Intune enrollment) goes out via WiFi after Windows boots; PXE-time
@@ -166,7 +166,7 @@ USB installer (2 partitions: ISO + CIDATA)
Ubuntu auto-install + first-boot Ansible playbook Ubuntu auto-install + first-boot Ansible playbook
| |
v v
Configured PXE server (10.9.100.1) ----+ Configured PXE server (172.16.9.1) ----+
| |
Windows PCs running Upload-Image.ps1 --+--> Image content (SMB, webapp import) Windows PCs running Upload-Image.ps1 --+--> Image content (SMB, webapp import)
| |

95
docs/OWNERSHIP.md Normal file
View File

@@ -0,0 +1,95 @@
# Who owns what
Every bug that cost a day in this pipeline has had the same shape: two systems
both setting the same thing, and whichever ran last silently won. This file says
who owns each concern so the next person deletes the loser instead of adding a
third writer.
All four examples below are real, from 2026-08-06.
## Computer name - the PPKG owns it
The provisioning package declares:
```xml
<DNSComputerName>F%SERIAL%</DNSComputerName>
```
so a bay comes up as `F<serial>`.
`run-enrollment.ps1` used to also run `Rename-Computer -NewName "E$serial"`.
Both are pending renames; last writer wins at reboot. The script ran twice, and
its second run landed after the package had already queued `F579C144`, so the
bay came up `E579C144` with the package reporting no errors at all.
**Rule:** nothing in this repo renames a machine. If the naming convention
changes, it changes in the package.
## Drive letters - PESetup owns them
PESetup hardcodes `W:` in nine places - every copy destination, both DISM
offline sessions, `bcdboot`, `reagentc` - and creates it during its own disk
preparation.
`startnet.cmd` briefly had a volume finder that scanned for "the applied
Windows volume" and `diskpart`-assigned it to `W:`. On a re-image it found the
*previous* install, relabelled a partition PESetup was about to erase, and every
staging copy failed into a volume that no longer existed.
**Rule:** wait for `W:\Windows\System32\config\system` - the hive only exists
once the WIM apply has written it. Never run `diskpart` while PESetup is
running.
See `docs/PESETUP-INTERNALS.md`.
## Enrollment - the PPKG owns it, the orchestrator drives reboots
The SFLD package joins Entra using the BPRT token in
`0__Accounts_Azure.provxml`. A human then assigns the device category in Intune.
The vendor's `Start-BulkEnrollOrchestrator.ps1` has two branches. The normal one
aborts the package's own reboot, registers `AutoSecondReboot`, and drives the
Entra join to completion. The `-ManualFallback` one runs `sysprep /oobe /reboot`
- it is an interactive escape hatch for handing a machine back to OOBE, meant to
be triggered by a person.
The shopfloor unattend registered `-ManualFallback` as an at-logon scheduled
task. So OOBE completed, autologon fired, and four seconds later the machine
syspreped itself back to OOBE, losing the deployment chain permanently.
**Rule:** shopfloor bays enrol. Never wire `-ManualFallback` to anything
automatic.
## Kiosk URLs - GE-Enforce owns them
`plugins/geenforce/seed_display_scope.py` in shopdb-flask is authoritative:
| display-type.txt | route |
|---|---|
| `Dashboard` | `/shopdb/shopfloor` |
| `Lobby` | `/shopdb/tv` |
| `3DPrintRoom` | `/shopdb/parts-kiosk` |
Its dispatcher prefers the server-side role from Settings > Dashboard Defaults
(resolved by device IP), falls back to `C:\Enrollment\display-type.txt`, writes
the Startup shortcut itself, and **sweeps** any shortcut matching
`shopfloor-dashboard` or `/shopdb/`.
`site-config.json` also carries `edgeHomepage` per display type. Those are a
backstop for the window before the kiosk installer runs - a stale value there
gets deleted on the next enforce cycle rather than honoured.
**Rule:** if a kiosk points at the wrong page, fix the GE-Enforce scope first.
Keep `site-config.json` correct, but do not expect it to win.
## Repo vs share
The share is production; the repo is meant to describe it. Drift runs both ways -
live hand-edits nobody committed, and repo fixes never deployed.
`scripts/share-drift.py` classifies every mapped pair as `git-owned` (repo wins,
safe to push) or `unreconciled` (diverged, nobody has decided). It reports and
never writes. Run it before a build day; `scripts/preflight.py` covers the rest.
The unattends are `unreconciled` on purpose: the live copies are ~17 KB and the
repo copies ~12 KB, so pushing the repo would regress production.

278
docs/PESETUP-INTERNALS.md Normal file
View File

@@ -0,0 +1,278 @@
# PESetup.exe internals
What GE Image Setup actually does, start to finish. Written from the decompiled
assembly, not from observation, because several long-standing beliefs about this
tool turned out to be wrong and cost weeks of debugging.
**Version documented:** 4.0.0.17 (`Sources/PESetup.exe`, PE32+ native apphost
wrapping a .NET 6 single-file bundle, 468 embedded files).
**Version in production:** 4.0.0.20, as of 2026-08-06 — the media reports it in
its own log (`AppVersion: 4.0.0.20`). Everything in this document was verified
against a 4.0.0.20 run on bay 579C144 that day: the media drive resolved to `Z:\`,
`W:` was created by `PrepareDisk` and used for every copy destination, the fallback
unattend at `Deploy\FlatUnattendW10.xml` was the one loaded, and driver selection
matched `win11_optiplexd13mlk7020_a09.zip` by model. The decompiled detail below has
not been re-derived from the 4.0.0.20 binary, so treat exact line-level claims as
4.0.0.17 and the observed behaviour as current.
## How to re-derive this
The managed code is not directly readable - the outer PE has no managed
metadata, so ILSpy refuses it. Extract `PESetup.dll` from the bundle first:
```python
# .NET single-file bundle: signature is SHA-256 of ".net core bundle";
# the int64 EIGHT BYTES BEFORE it is the bundle header offset.
SIG = bytes([0x8b,0x12,0x02,0xb9,0x6a,0x61,0x20,0x38,
0x72,0x7b,0x93,0x02,0x14,0xd7,0xa0,0x32])
i = data.find(SIG)
header = struct.unpack_from('<q', data, i - 8)[0]
# header: uint32 major, uint32 minor, int32 count, 7-bit-prefixed bundle id,
# then (v2+) 4 x int64 deps/runtimeconfig, then uint64 flags.
# each entry: int64 offset, int64 size, (v6+) int64 compressedSize,
# byte type, 7-bit-prefixed path. Compressed entries are raw
# deflate (zlib.decompress(raw, -15)).
```
Then `ilspycmd -t <TypeName> PESetup.dll`.
## Paths it hardcodes
From `PESetup.Models.GlobalSettings`. All are relative to the MEDIA drive
(`Z:\`, whichever winpeapps share startnet mapped) unless stated.
```
ControlDir \Deploy\Control
ToolsDir \Deploy\Tools\GE
SoftwarePackagesDir \Deploy\Applications
HWPackagesDir \Deploy\HW_Apps
UnattendFile \Deploy\Tools\GE\XML\FlatUnattendW10.xml
UnattendFile2 \Deploy\FlatUnattendW10.xml (fallback)
DiskPartDir <exe dir>\DiskPart
ConfigSkipPackagesFile skip.json
ConfigDisableAutoStartFile disableauto.json
ExpirationDuration 30 days
AutoStartCountDown 31
MinRequiredSpaceWithoutCompression 128849018880 (120 GB)
```
**`W:` IS HARDCODED, EVERYWHERE.** Not derived, not configurable, not a
convention this project invented:
```csharp
CopyPackages destDir = "W:\\" + SoftwarePackagesDir
CopyHWPackages destDir = "W:\\" + HWPackagesDir
CopyTools destDir = "W:\\GE"
CopyDrivers destinationPath = "W:\\Drivers\\" + <zip name without extension>
ApplyImage destination = "W:\\"
CreateBCD "W:\\Windows\\System32\\bcdboot.exe", "W:\\Windows /l en-US"
ApplyPackages DismApi.OpenOfflineSession("W:\\")
CopyLogs tags -> "w:\\windows\\system32\\"
```
PESetup's own disk preparation is what makes W: exist. Any downstream script
that hunts for "the applied volume" is solving a problem this tool does not
have. `diskpart list volume` on a machine mid-image shows `Volume 0 W Windows
NTFS 237GB Healthy`.
## The five working steps
Keys as they appear in the log: `workingstep_gatherdata`, `workingstep_selectos`,
`workingstep_prepare`, `workingstep_copy`, `workingstep_apply`,
`workingstep_finalize`, `workingstep_reboot`.
### 1. GATHER DATA
Four operations, all automatic:
| Operation | What it establishes |
|---|---|
| `GatherDataMediaDrive` | `ImageInfo.MediaDrive`, e.g. `Z:\` |
| `GatherDataBootDrive` | `ImageInfo.BootDrive`, always `X:\` (WinPE RAM disk) |
| `GatherDataSelection` | BIOS version/type, serial, secure boot, OS, languages, model, **driver** |
| `GatherDataImageDisk` | picks the physical disk and logs its partitions |
`GatherDataSelection` is where most decisions are made:
1. **Secure boot is mandatory.** `SecurebootEnabled != 1` fails the step outright.
2. Reads `Control\LanguagePacks.json`.
3. `SystemHelpers.GetModel()` and `GetManufacturer()` off WMI.
4. Reads `Control\HardwareDriver.json` and `Control\hw_applications.json`.
NOTE: it reads **HardwareDriver.json**, not the `hw_drivers.json` that also
sits in that folder. Editing the wrong one changes nothing.
5. **Virtual platform check.** If ANY driver entry has a manufacturer containing
"virtual platform", the tool goes into virtual-only mode: it takes
`list[0]` as the driver and then REQUIRES the machine to look virtual
(model/serial/BIOS containing VIRTUAL, VMWARE, XEN, QEMU, VirtualBox, vmw).
On real hardware that is a hard failure. A stray "virtual platform" entry in
the catalogue therefore breaks imaging for every physical machine.
6. Otherwise `GetDriverByModel` (below).
7. If `Control\skip.json` exists, package installation is skipped.
### 2. SELECT OS
Operator-facing. Warns when the media holds an LTSC image ("99% of the time only
for ShopFloor"). Also shows days-to-expiry - the media expires 30 days after
build (`ExpirationDuration`).
### 3. PREPARE
| Operation | What it does |
|---|---|
| `PrepareDoDisking` | copies `<exedir>\DiskPart\diskpartEFI.txt` to `X:\diskpartEFI<n>.txt`, then `cmd /c DISKPART /S X:\diskpartEFI<n>.txt` |
| `PrepareUnattend` | loads the unattend, substitutes, saves to `X:\Unattend.xml` |
`PrepareUnattend` in detail:
```csharp
text = MediaDrive + UnattendFile; // \Deploy\Tools\GE\XML\FlatUnattendW10.xml
if (!File.Exists(text)) text = MediaDrive + UnattendFile2; // \Deploy\FlatUnattendW10.xml
xml.Load(text);
xml.InnerXml = xml.InnerXml.Replace("%serialnumber%", imageinfo.Serialnumber);
xml.InnerXml = xml.InnerXml.Replace("*arch*", arch);
SetPackages(arch, xml, imageinfo); // needs Control\Packages.xml
xml.Save("X:\\Unattend.xml");
```
Two tokens are substituted: `%serialnumber%` and `*arch*`. `SetPackages` merges
in servicing packages from `Control\Packages.xml` and `Control\PackageGroups.xml`.
The unattend that is USED is the one on the media at `Tools\GE\XML\` if present,
otherwise `Deploy\FlatUnattendW10.xml`. A shopfloor variant only takes effect if
it occupies one of those two paths.
### 4. COPY
Four operations. **None of them filters, and none of them reads the unattend.**
| Operation | Source | Destination | Rule |
|---|---|---|---|
| `CopyTools` | `Z:\Deploy\Tools\GE` | `W:\GE` | whole directory, recursive |
| `CopyPackages` | `Z:\Deploy\Applications` | `W:\Deploy\Applications` | whole directory, recursive |
| `CopyHWPackages` | `Z:\Deploy\HW_Apps` | `W:\Deploy\HW_Apps` | whole directory; **skipped if `IsVirtual` or `HWApps == null`** |
| `CopyDrivers` | one `.zip` chosen by model | `W:\Drivers\<zipname>` | **unzipped**, not copied |
`ExecuteInternalCopy` walks `GetDirectoriesRecursive(source, includeSubDirs:
true)` then `Directory.GetFiles(item, "*")`. Every file, every subdirectory. It
clears the read-only attribute on each copy.
**Consequences worth designing around:**
- Anything dropped into `Deploy\Applications` on the media lands on the target.
No manifest, no registration, no unattend reference required.
- The copy is FAIL-FAST. One exception on one file returns false and fails the
whole step with `copy_packages_error`. A locked or unreadable file in
`Applications` fails imaging, it does not get skipped.
- Progress is computed from total directory size up front, so bulky additions
visibly lengthen this phase.
### 5. APPLY
| Operation | What it does |
|---|---|
| `ApplyImage` | `install.wim` from `OperatingSystem.json`'s `destinationDir`, at `wimindex`, applied to `W:\` |
| `ApplyPackages` | DISM offline session on `W:\`, adds servicing packages; logs to `X:\ApplyOSPatch_<n>.log` |
| `ApplyLanguagePacks` | same pattern, `X:\ApplyLanguagePack_<n>.log` |
| `ApplyUnattend` | DISM offline session on `W:\`, applies `X:\Unattend.xml`; logs to `X:\ApplyUnattend.log` |
| `CreateBCD` | `W:\Windows\System32\bcdboot.exe W:\Windows /l en-US` |
| `CopyWinRE` | `reagentc /setreimage /path T:\Recovery\WindowsRE /target W:\Windows` |
All DISM work uses `W:\imagetemp` as scratch and deletes it afterwards.
### 6. FINALIZE
`CopyLogs`:
- `CopyTagFiles(ToolsDir)` - every `*.tag` from the tools dir to
**`w:\windows\system32\`**. This is how build/media tags reach the OS.
- `CopyLogFiles("X:\\")` and `CopyXMLFile("X:\\")` - the PESetup log and the
generated unattend are preserved.
## Driver selection, in full
This is the part most worth understanding, because a miss is nearly silent.
```csharp
// GatherDataSelection
List<HardwareDriversRootObject> list =
JSONHelpers.ReadJSON_Driver(MediaDrive + ControlDir + "\\HardwareDriver.json");
...
imageinfo.HWDriver = GetDriverByModel(list, imageinfo.Model);
if (imageinfo.HWDriver == null) {
// status = Warning, message "driver for [MODEL] not found"
return operationResult;
}
```
```csharp
private static HardwareDriversRootObject? GetDriverByModel(List<...> drivers, string model)
{
string modelFamily = "";
if (model.ToUpper().Contains("LATITUDE")) modelFamily = "Latitude";
if (model.ToUpper().Contains("OPTIPLEX")) modelFamily = "Optiplex";
if (model.ToUpper().Contains("PRECISION")) modelFamily = "Precision";
return drivers.Where(d => {
if (!string.IsNullOrEmpty(modelFamily) &&
!d.family.ToLower().Contains(modelFamily.ToLower())) return false;
foreach (string token in d.modelswminame.Split(','))
if (model.ToLower().Contains(token.ToLower())) return true; // SUBSTRING
return false;
}).ToList()?.FirstOrDefault();
}
```
Then:
```csharp
sourceFilePath = MediaDrive + HWDriver.destinationDir.Replace("*destinationdir*","")
+ "\\" + HWDriver.fileName;
destinationPath = "W:\\Drivers\\" + Path.GetFileNameWithoutExtension(sourceFilePath);
StartUnzipAsync(...)
```
### Four traps in that logic
1. **A miss is a WARNING, not a failure.** Imaging continues and the machine
comes up with no drivers - no NIC, no WiFi, so DNS fails and anything
network-dependent at first boot fails with it. Symptoms appear far from the
cause.
2. **Matching is substring, first match wins.** A token like `7020` matches any
model string containing 7020. Order in the JSON decides ties.
3. **The family filter knows only three Dell lines.** Anything else - MicroPCs,
NUCs, non-Dell - skips the filter and depends entirely on
`modelswminame` substrings being right.
4. **One "virtual platform" entry hijacks the whole catalogue** (see GATHER
DATA step 5) and fails every physical machine.
## What this means for startnet.cmd
Three beliefs encoded in `startnet.cmd` do not survive contact with the source:
- *"The applied volume might not be W:."* PESetup hardcodes W: in nine places
and creates it during disk prep. The volume finder, the diskpart
reassignment and the `W:` wait loop are machinery around a non-problem.
- *"We must copy our payload from the enrollment share after imaging."* Anything
in `Deploy\Applications` on the media is copied to `W:\Deploy\Applications`
by PESetup itself, and is readable at `C:\Deploy\Applications` at first boot.
- *"Display MicroPCs failed because the applied volume was not W:."* Worth
re-testing. A missing `HardwareDriver.json` match produces the same
end-state - no drivers, no network - via a completely different route, and
that route only logs a warning.
## Files PESetup reads from the media
```
Deploy\Control\HardwareDriver.json driver catalogue (NOT hw_drivers.json)
Deploy\Control\hw_applications.json per-model applications
Deploy\Control\OperatingSystem.json OS list, wim path, wimindex
Deploy\Control\LanguagePacks.json language packs
Deploy\Control\packages.json servicing packages
Deploy\Control\Packages.xml unattend package merge
Deploy\Control\PackageGroups.xml package grouping
Deploy\Control\Media.tag media identity
Deploy\Control\skip.json presence = skip package install
Deploy\Control\disableauto.json presence = disable autostart
Deploy\Tools\GE\XML\FlatUnattendW10.xml preferred unattend
Deploy\FlatUnattendW10.xml fallback unattend
```

View File

@@ -22,9 +22,9 @@ contribute a `config/sites/<sitename>.yaml` template back to the repo.
| Value | Default | Where it lives | | Value | Default | Where it lives |
|-------------------|----------------------|--------------------------------------------------------------------------------| |-------------------|----------------------|--------------------------------------------------------------------------------|
| PXE server IP | 10.9.100.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) | | PXE server IP | 172.16.9.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) |
| PXE subnet | 10.9.100.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) | | PXE subnet | 172.16.9.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) |
| DHCP range | 10.9.100.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) | | DHCP range | 172.16.9.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) |
| Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) | | Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) |
### Identity and credentials ### Identity and credentials
@@ -143,7 +143,7 @@ Blob storage account.
### Image-upload paths on Windows ### Image-upload paths on Windows
`scripts/Upload-Image.ps1` defaults to: `scripts/Upload-Image.ps1` defaults to:
- `\\10.9.100.1\image-upload` as the destination - `\\172.16.9.1\image-upload` as the destination
- `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source - `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source
Update both for a different site. Update both for a different site.
@@ -156,10 +156,10 @@ A site config file should drive substitution at build time. Proposed schema:
# config/sites/<sitename>.yaml # config/sites/<sitename>.yaml
site: site:
name: westjeff name: westjeff
pxe_server_ip: 10.9.100.1 pxe_server_ip: 172.16.9.1
pxe_subnet: 10.9.100.0/24 pxe_subnet: 172.16.9.0/24
dhcp_range_start: 10.9.100.10 dhcp_range_start: 172.16.9.10
dhcp_range_end: 10.9.100.100 dhcp_range_end: 172.16.9.100
hostname: pxeserver hostname: pxeserver
credentials: credentials:

View File

@@ -0,0 +1,695 @@
# Zero-touch, ShopDB-driven imaging - proposal
**Status:** proposal, nothing built
**Written:** 2026-08-06
**Author's note:** every claim about current behaviour below is from evidence
gathered on 2026-08-06 - decompiled binaries, live logs off bay 579C144, and the
live share. Where something is inferred rather than observed it says so.
---
## 1. What we want
Two ideas, one architecture.
**A. The image becomes thin.** It carries only what must happen at image time -
partition, apply the WIM, join Entra, install the enforce client. Everything else
(PC type, subtype, tool association, apps, settings) is data, pulled at first
boot from the ShopDB-Flask API and enforced continuously by GE-Enforce.
**B. Imaging becomes a job, not a menu.** Open a machine in ShopDB, click
*Create this PC*, pick the zeroed box that is sitting in WinPE waiting, and it
images start to finish with nobody touching the keyboard - reporting progress
onto that machine's record in real time.
Between them: no menus in WinPE, no per-type media, no decisions baked into text
files by whoever happened to be standing at the bay.
---
## 2. Why - what today actually costs
Today every decision is made in WinPE, written to files, and carried through a
chain where each link can fail silently:
```
startnet.cmd -> unattend (4 passes) -> FirstLogonCommands (18 orders)
-> ppkg -> DSC -> GE-Enforce
```
On 2026-08-06 alone, that chain produced:
| Failure | Root cause | How long it hid |
|---|---|---|
| Every build stopped at a dialog | one unattend `Path` at 676 chars against a 259 limit | ~1 day, and it masked the next two |
| Bays staged nothing, silently | a volume scan raced PESetup's `diskpart` and staged into a partition being erased | weeks (4 Display bays sat "green") |
| Deployment destroyed 4s after autologon | an at-logon task ran `-ManualFallback`, which is `sysprep /oobe /reboot` | until traced through Panther |
| Machines named `E<serial>` not `F<serial>` | our script re-applied a rename over the package's own | unknown |
| Six copies reported success having moved 0 bytes | unconditional `echo` after `robocopy` | weeks |
The pattern is identical in every case: **a decision made early, carried far, and
verified nowhere.** Moving decisions to a running, networked machine with an API
and a UI makes them recoverable - re-runnable, correctable, and observable.
---
## 3. What we already have to build on
This is not a greenfield project. Most of the mechanism exists.
### 3.1 ShopDB-Flask
- Assets keyed by serial; CMM bays, measuring tools, machine numbers already modelled
- Collector API: `POST /shopdb/api/collector/computers` (X-API-Key), ingests inventory by serial
- GE-Enforce plugin: scopes, manifests, **content-addressed payload store**
(`instance/geenforce/payloads/<sha256>`), `GET /api/geenforce/payload/<sha256>`
- Display scope (`plugins/geenforce/seed_display_scope.py`) already proves the pattern:
read a local subtype, prefer a **server-side role resolved by device IP**, pull
payloads over HTTPS, write the kiosk shortcut, sweep stale ones, self-heal each cycle
- Per-device role API: `GET /api/dashboarddefaults/display-role`
### 3.2 PXE server
- Per-bay state: `/var/log/pxe-imaging/<serial>.json` with stage history
- `winpe-status-push.ps1` already POSTs stage updates from WinPE
- Data-driven menus already: `menu.json` on the share, rendered by
`select-shopfloor-type.ps1`, edited by the webapp
- Imaging dashboard with per-bay tiles
- As of today: staging verification, imaging-log harvest to
`enrollment/imaging-logs/<serial>/`, `preflight.ps1` at the bay,
`preflight.py` + `lint-unattend.py` + `lint-driver-catalogue.py` +
`share-drift.py` on the server
### 3.3 Hardware
- Second NIC already present and unused: `enp0s31f6` (onboard, `e1000e`, currently down)
- PXE LAN currently on a USB adapter, `enx34c8d6b11010`
- That adapter was silently capped at USB 2.0 (327 Mb/s measured); moved to a
USB 3 port on 2026-08-06 and now runs at line-rate gigabit (**937 Mb/s measured**)
---
## 4. Target architecture
### 4.1 Three (or one) pre-imaged types
Proposed: **Displays, Inspection, Shopfloor** as pre-imaged stock, with the
subtype chosen later by the user at first boot.
**Open question worth settling early:** if the base image is identical and all
configuration comes from the API, what actually differs between the three?
Drivers are selected by PESetup per *model*, not per type. Unless disk layout or
a must-be-offline application differs, **one image** is better - three media trees
mean three things to keep in sync, and drift is what caused the day-long outage.
### 4.2 First-boot selection
A friendly PS1 runs when no PC type is recorded yet:
```
Inspection -> CMM | Genspect | Wax Trace | Keyence
└─ CMM / Keyence / WaxTrace -> which measuring tool?
(list served from ShopDB assets, not a CSV)
Displays -> Lobby | Dashboard | 3D Print Kiosk
Shopfloor -> Collections | No Collections | Common | Heattreat | Part Marker | ...
```
The tool selection is the important half: it drives PC-DMIS version, FormTracePak
version, DODA flag, NTLARS/eDNC registry, UDC settings, machine number.
**The network constraint that shapes this.** ShopDB is only reachable once the
bay has joined the **AESFMA wifi SSID** - not on the imaging LAN, and not
necessarily at first boot. So a first-boot selection UI cannot assume the API is
there. It must either work offline and sync later, or wait. Confirmed on
579C144: the bay held `172.16.9.81` and `172.24.19.142`, neither in the
production ranges, and `sync_intune` sat retrying every 30 seconds.
**The pattern that solves it, already built and worth reusing.**
`Install-ShopdbKiosk-WhenOnline.ps1` is a SYSTEM task armed at imaging that does
nothing until ShopDB answers, then acts once, verifies, and unregisters itself.
Any step in the new architecture that needs the API - selection sync, config
pull, enrollment completion - can use the same shape rather than assuming
connectivity.
**Design points:**
- **Server is the source of truth.** The selection POSTs to ShopDB keyed on
serial; the local file is a cache. A mis-selection is then fixable from a
browser, not by walking to the bay.
- **Re-runnable.** Available from the Start menu, shows what was chosen and what
was installed. People pick wrong; that must not mean re-imaging.
- **Audited.** ShopDB records who chose what and when - that is what separates
"misconfigured" from "chose wrong" six months later.
- **Do not require the LAPS password.** Handing a shop-floor user the local admin
password to run a selection dialog is a real control change, and the app runs
elevated. Prefer a SYSTEM-run UI on the logon desktop, or a kiosk shell.
### 4.3 Per-machine configuration records
Today a machine's identity is scattered:
| What | Where today | Size |
|---|---|---|
| eDNC / NTLARS registry | `_ntlars-backups/` - 147 per-bay `.reg` files | ~1.5 MB total |
| WaxTrace bay config | `bay-config.csv` - 15 bays, 7 FTPak versions, per-bay user ID | small |
| CMM bay config | `cmm-bay-config.csv` -> version.txt, doda.txt | small |
| UDC settings | `pre-install/udc-backups/`, per-bay | ~240 MB per bay |
| Machine number | `machine-number.txt` written by startnet | trivial |
Every bay gets **all** of it staged, then picks its own.
Proposed: one record per machine in ShopDB.
```
ShopDB asset (key: serial and/or machine number)
├─ pctype + subtype -> selects the GE-Enforce scope
├─ tool association -> CMM bay, Keyence model, FTPak version, DODA
├─ machine number -> UDC / eDNC naming
└─ payload refs (sha256) -> NTLARS .reg, UDC backup, per-bay settings
```
**Reference payloads, do not embed them.** NTLARS `.reg` files carry binary
values and UDC backups are hundreds of MB. GE-Enforce already has a
content-addressed blob store with an HTTP fetch endpoint - the record names the
payload, the store holds it.
This also removes a live bug class. `Restore-UDCData.ps1` and
`Update-MachineNumber.ps1` each mount SMB backup shares on their own drive
letters, which collided with GE-Enforce's `W:` and killed the manifest entry
running next (fixed in `66c24b5` by moving them to `R:` and `N:`). Payloads over
HTTPS remove drive-letter juggling entirely.
Migration is a one-time import of two CSVs and 147 `.reg` files. A script, not a
project.
---
## 5. Zero-touch: ShopDB-triggered imaging
### 5.1 Flow
```
1. Zeroed PC, boot order = IPv4 only. PXE boots into WinPE.
2. WinPE reports serial + MAC + model, then WAITS.
3. ShopDB shows it under "awaiting assignment".
4. Operator opens the target machine -> "Create this PC" -> picks the waiting box.
5. WinPE polls, receives a job:
{ pctype, subtype, tool/bay, tenant, purpose, target asset id }
and images with NO menus.
6. Progress streams to that machine's record in real time.
7. On completion the PC binds to the asset record.
```
Every menu in `startnet.cmd` becomes a field in the job. If no job exists, it
falls back to today's interactive menu and displays its own serial so one can be
created.
### 5.2 Safety - this is a remote-triggered disk wipe
Clicking a web button destroys a machine, and the target is chosen from a list.
The nightmare is picking the wrong row.
**Mitigation:** the waiting PC displays a short code on screen; the job cannot be
claimed unless that code matches what the operator confirms in ShopDB. Cheap, and
it makes wrong-machine wipes structurally hard rather than merely unlikely.
Additionally: a job may only target a machine currently in the waiting state, and
the wipe must be logged against both the operator and the asset.
### 5.3 Real-time record updates
Each phase already produces data; it simply is not joined up.
| Phase | Existing source | Lands on the record as |
|---|---|---|
| PXE boot | dnsmasq lease + WinPE check-in | serial, MAC, model, "imaging started" |
| WIM apply | `winpe-status-push.ps1` | live stage / progress |
| Staging | `winpe-staging.log` + harvested bundle | what payload actually landed |
| First boot | collector API | CPU, RAM, disk, OS build |
| Enrollment | `criticalChecks.json`, `TokenMatch.json` | Entra join state, tenant, purpose |
| Steady state | GE-Enforce report | installed apps, drift, self-heal events |
End state is one timeline per machine: *imaged 14:32 -> staged
gea-shopfloor-cmm, bay CMM4 -> enrolled GCCH_SH -> PC-DMIS 2019 R2 installed ->
enforcing clean since*.
**Two rules so it does not rot:**
1. **Push, best-effort.** If ShopDB is unreachable the bay must still image.
Queue events on the PXE server and drain them; the harvested logs are the
durable local copy.
2. **One writer per field.** Imaging status from the PXE server, inventory from
the collector, app state from GE-Enforce. Two writers on one field is the
exact bug that ate 2026-08-06.
---
## 6. Network design
### 6.1 Dual-homed PXE server
- **PXE side** - isolated `172.16.9.0/24`, dnsmasq DHCP bound to that adapter
- **GE side** - corporate, reachable from anywhere; carries ShopDB API traffic,
the job queue, status pushes, and (if wanted) the BPRT token fetch
The onboard `enp0s31f6` is free for this today.
### 6.2 Security prerequisites - these gate the whole thing
Checked 2026-08-06:
**Already safe.** dnsmasq has `listen-address=172.16.9.1` + `bind-dynamic`, so it
binds only to the interface holding that address. DHCP will **not** leak onto a
GE-side NIC. This was the failure I most expected and it is already handled.
**Not safe yet:**
```
smb.conf no `interfaces =` / `bind interfaces only` -> Samba listens on ALL interfaces
ufw every rule is ALLOW IN Anywhere, not interface-scoped:
22/tcp SSH (account is pxe/pxe)
445/tcp shares (pxe-upload, password in scripts)
69/udp TFTP
9009,9011 webapp
```
The moment a GE-side interface comes up, all of that is corporate-reachable.
**Required before dual-homing:**
1. Scope every UFW rule to the PXE interface (`ufw allow in on <pxe-if> ...`)
2. `interfaces = <pxe-if>` + `bind interfaces only = yes` in `smb.conf`
3. Expose deliberately on the GE side only what must be - realistically the
webapp, behind real authentication
4. Reconsider SSH credentials if the host becomes corporate-reachable
*(Correction to an earlier note: the shares are **not** guest-accessible -
`guest ok = no` on every share, with `map to guest = bad user`. They require the
`pxe-upload` account. That account's password is in scripts, so it is not a
control once 445 is corporate-reachable.)*
### 6.3 What dual-homing unlocks
- Job queue sync and live status without the share as intermediary
- BPRT token fetch (`mcl.dwcdn.geaerospace.com`) reachable at imaging time
- Possibly the Entra join during imaging rather than after re-cabling - see §8.2
---
## 7. The PESetup question
### 7.1 Autostart does not apply to our media
`AutoStartCountDown = 31` exists in the decompiled settings, and
`disableauto.json` - whose *presence* disables autostart - is absent from the
media. On the surface that suggests a 31-second countdown, and the observed log
is consistent with an operator simply clicking before it fired:
```
11:30:07.74 'Selection View' started (LTSC warning displayed)
11:30:18.63 Btn 'Next' pressed.
```
**However:** per the team's operational experience, **LTSC builds cannot
autostart**. The SELECT OS step warns when the media holds an LTSC image
("99% of the time only for ShopFloor. Are you sure you want 'LTSC'?") and that
confirmation is mandatory regardless of the countdown. Our media is
`Windows 11 LTSC 24H2` (`OperatingSystem.json`, build 26100, id 20), so every
image we produce hits it.
Consequence: **the countdown is not a route to zero-touch for us.** The 40-second
hands-off test is still worth doing once to confirm the behaviour first-hand and
record it - but plan on the answer being "it waits".
That promotes §7.2 from a contingency to the actual decision.
### 7.2 Re-implementation - now the likely path, and tractable
Nothing in the mechanism is proprietary - only the data, which we already own and
already parse.
| PESetup step | Equivalent |
|---|---|
| `PrepareDisk` | `diskpart /s diskpartEFI.txt` |
| `PrepareUnattend` | token substitution (`%serialnumber%`, `*arch*`) + `Packages.xml` merge |
| `CopyTools` / `CopyPackages` / `CopyHWPackages` | robocopy |
| `CopyDrivers` | select by model from `HardwareDriver.json`, unzip |
| `ApplyImage` | `DISM /Apply-Image` per `OperatingSystem.json` |
| `ApplyPackages` / `ApplyLanguagePacks` | `DISM /Add-Package` |
| `ApplyUnattend` | `DISM /Apply-Unattend` |
| `CreateBCD` / `CopyWinRE` | `bcdboot`, `reagentc` |
A few hundred lines of PowerShell. We would also get to fix the traps documented
in `docs/PESETUP-INTERNALS.md`: substring first-match-wins driver matching, a
family filter that knows only Latitude/OptiPlex/Precision, and a driver miss
being a **warning** rather than a failure.
**The cost is ownership, not effort.** `Release.json` points at MCL auto-update
URLs, so GE expects this media to self-update. Diverging means tracking their
format changes indefinitely, plus a likely compliance conversation about the
mandated imaging tool. Since the LTSC gate means autostart will not save us
(§7.1), this is a decision to take deliberately rather than a fallback.
### 7.3 What we would keep either way
Image-time preflight stays valuable regardless: secure boot (PESetup hard-fails
without it), >=120 GB disk (`MinRequiredSpaceWithoutCompression`), driver match
for the model, media age (30-day expiry). Already built as
`playbook/scripts/preflight.ps1`.
---
## 8. Enrollment
### 8.1 How it works today
- Six MCL packages on the media: `PERS` / `SH` / `LOAN` x `GCCH` / `RoW`, plus
the SFLD package for shopfloor
- `Start-BulkEnrollOrchestrator.ps1` asks tenant + purpose via
`Select-PCConfig.ps1` (a GUI), then applies the matching package
- `Insert-BPRTToPPKG.ps1` fetches an **encrypted token table** from
`mcl.dwcdn.geaerospace.com`, decrypts with a static passphrase
(PBKDF2 310k / SHA-256 / AES-CBC), matches on **Tenant AND Purpose**, injects
the token and stamps the real expiry into the filename
(`Exp_XXXXXXXX` -> `Exp_yyyyMMdd`)
- The package itself sets `<DNSComputerName>F%SERIAL%</DNSComputerName>` and
performs the Entra join
- A human then assigns the device category in Intune
As of 2026-08-06 the tenant/purpose choice can be pre-seeded at the PXE menu
(`C:\Enrollment\pc-config.txt`, consumed by a shim at the path the orchestrator
already calls). Injection deliberately stays at first logon - it needs internet.
### 8.2 The constraint that shapes pre-imaged stock
The BPRT token is stamped into the package with an expiry. A PC imaged, powered
off, and left in a cupboard for three months has a **dead token** and will never
join - and the failure looks identical to "not on the production network yet",
because `Entra ID Joined: false` right after imaging is normal.
Three options, pick deliberately:
1. **Join at imaging time** - requires the production network at the bench
(which dual-homing could provide), and pre-imaged stock is then already joined
2. **Join at first power-on** - cleaner logistically, but stock has a shelf life
bounded by token expiry
3. **Re-inject on demand** - first boot detects a dead token and fetches a fresh
one; needs the machine to reach the token blob
Whichever is chosen, the *silent* failure must be closed: a check that
distinguishes "token expired" from "no network yet".
---
## 9. Ownership model
Four times on 2026-08-06 two systems owned one thing and the last writer won
silently. A clean slate is the moment to fix that. See `docs/OWNERSHIP.md`.
| Concern | Owner | Not |
|---|---|---|
| Computer name | the provisioning package (`F%SERIAL%`) | any script in this repo |
| Drive letters during imaging | PESetup (`W:`, hardcoded 9 places) | anything running `diskpart` concurrently |
| Entra enrollment | the package + orchestrator | `run-enrollment.ps1` |
| Kiosk / display targets | GE-Enforce scope | `site-config.json` (backstop only) |
| Imaging status | PXE server | the collector |
| Inventory | collector API | the PXE server |
| Installed app state | GE-Enforce reports | anything else |
| Job queue | **ShopDB** (owns assets) | the PXE webapp (executor only) |
---
## 9a. Design rules learned the hard way (2026-08-06)
A day of fixing the current pipeline produced five rules. They are cheap to
honour in a new design and expensive to retrofit.
### Absent and empty are different
Four separate scripts tested config as
`$null -ne $cfg -and $cfg.Count -gt 0`, so an explicitly empty list fell through
to a hardcoded default. A Display kiosk configured with
```json
"desktopApps": [], "startupItems": [], "taskbarPins": []
```
was given UDC, eDNC, NTLARS, WJ Shopfloor, Defect Tracker and Plant Apps -
**configuring "none" produced "everything"**. Plant Apps launched
`msedge --new-window` on a screen with no keyboard.
Measured, not assumed: an empty JSON array round-trips as `Object[]` with
`Count = 0`, and an absent key as `$null`. They are distinguishable; the code
simply conflated them.
*Rule: in the API model, "no items" and "not configured" must be different
values on the wire, and the default for an unrecognised or missing scope is
NOTHING, not a fallback set.*
### Deny by default, always
`preinstall.json` had 16 of 21 entries at `PCTypes: ["*"]`, so a kiosk installed
Adobe, OpenText, Defect Tracker, the serial drivers and twelve legacy VC++
redistributables. The SMB manifest had 18 of 25 entries unfiltered. In both
cases the filter worked perfectly - it simply was not applied.
*Rule: every payload declares which PC types it is for. An undeclared payload
installs nowhere and fails validation, rather than installing everywhere.*
### Gate at the point of action, not the call site
`Run-ShopfloorSetup.ps1` gated both the S: mapper and the machine-number prompt
on PC type. Both registered anyway on a Display, with no "Skipping" line in the
log - something in the finalization phase reaches those registrars past the
call-site gate. The fix was to gate inside each registrar.
*Rule: authority checks belong with the thing being done. A caller-side check is
a convenience, never the control.*
### The image itself carries configuration
`WJ Shopfloor.lnk`, dated April, sits in the all-users Startup folder of every
bay - **inside the WIM**. No script change removes it, and no amount of thinning
the pipeline touches it.
*Rule: "thin image" must include auditing what is baked into the WIM, not just
what the pipeline adds afterwards.*
### The provisioning package is a hard boundary
These are installed by the ppkg, not by anything PXE controls:
```
Google Chrome (+2 updater tasks) RealVNC Tanium
CyberArk EPM .NET 3.5 Report IP Sysinternals Autologon DSC
PowerShell 7 x64 AND x86
```
*Rule: thinning the image does not thin the package. Anything that must not be
on a kiosk and comes from the ppkg needs a package change or an explicit
post-install removal - it cannot be solved by scoping our own payloads.*
---
## 10. Phased delivery
Each phase is useful standing alone. No phase requires the next.
**Phase 0 - settle the unknowns (hours)**
- Confirm the LTSC autostart gate first-hand (one bay, hands off, 40 seconds)
and record it - expected to wait, per operational experience
- Decide: re-implement the imaging step, or accept one click per build
- Decide: one image or three
- Decide: join at imaging time, at first power-on, or re-inject
**Phase 1 - visibility (small)**
- WinPE reports serial / MAC / model on boot; "awaiting assignment" list in the webapp
- Relay imaging status to ShopDB over the GE side
- *Useful immediately: you can see what is booting and what it did.*
**Phase 2 - dual-home safely (small, gating)**
- Interface-scope UFW, bind Samba, bring up `enp0s31f6`
- Nothing else proceeds safely until this is done
**Phase 3 - job queue**
- Job model + API in ShopDB (create / claim / complete), with the on-screen
confirmation code
- WinPE polls once where the menu is today; falls back to the menu if no job
**Phase 4 - first-boot selection**
- Selection UI, server-backed lists, POST the choice, re-runnable
- Retire the WinPE sub-menus for subtype and tool association
**Phase 5 - per-machine records**
- Import the CSVs and `.reg` files into ShopDB with payload refs
- Retire `bay-config.csv`, `cmm-bay-config.csv`, and staging all 147 `.reg` files
to every bay
**Phase 6 - thin the image**
- Remove per-type media (`_media/<pctype>`), the staging block, the Office ppkg
variants, the CMM bay picker, the WaxTrace ISO cherry-pick
- WinPE's job becomes: partition, apply, join, install enforce client, reboot
---
## 11. Risks
| Risk | Why it matters | Mitigation |
|---|---|---|
| Remote-triggered wipe hits the wrong machine | destructive, irreversible | on-screen confirmation code; job may only target a waiting machine; log against operator and asset |
| ShopDB becomes a single point of failure | today a bay configures from the share with ShopDB down | GE-Enforce ETag / last-known-good cache; explicit failure UI, never a silent stall |
| Dual-homing exposes the PXE server | SSH `pxe/pxe`, SMB, TFTP currently `ALLOW IN Anywhere` | Phase 2 gates everything |
| BPRT expiry on shelf stock | silent, indistinguishable from "no network yet" | pick a join strategy (§8.2) and add an explicit expiry check |
| Selection UI becomes the new single point of correctness | wrong subtype = wrong PC-DMIS, wrong bay config | re-runnable, server-corrected, audited |
| Re-implementing PESetup | ongoing MCL format tracking + a likely compliance conversation | scope it against "one click per build" - zero-touch is the only thing that needs it |
| Big payloads over the corporate network | PC-DMIS, FormTracePak ISOs (~2 GB each), Keyence | GE-Enforce supports `smb` / `http` / `inline` per entry - decide per payload |
| Three images drift apart | drift caused the 2026-08-06 outage | prefer one image; `share-drift.py` guards what remains |
| API unreachable at first boot | ShopDB needs AESFMA; the imaging LAN cannot reach it | wait-for-network task pattern (§4.2); never assume connectivity |
| ppkg-delivered apps on a kiosk | Chrome, RealVNC, Tanium etc. are outside PXE control | package change or explicit post-install removal - scoping our payloads cannot fix it |
---
## 12. Open questions
1. One image or three? What genuinely differs between them?
2. Join at imaging time, at first power-on, or re-inject on demand?
3. Is the LTSC confirmation truly unskippable? (expected yes - it decides
whether zero-touch requires re-implementing the imaging step)
4. Is replacing the GE-mandated imaging tool acceptable, if it comes to that?
5. Does the selection UI run as SYSTEM (no LAPS password), or as an admin user?
6. Which payloads stay on SMB and which move to HTTPS?
7. Who operates the job queue day to day - is "Create this PC" a technician
action, or does it need approval?
---
## 13. Where everything referenced here lives
### 13.1 This repo (`/home/camp/projects/pxe`)
| Path | What |
|---|---|
| `docs/PESETUP-INTERNALS.md` | decompiled imaging tool behaviour; the source for every PESetup claim above |
| `docs/OWNERSHIP.md` | who owns what, with the 2026-08-06 collisions |
| `playbook/startnet.cmd` | the WinPE script this proposal thins out - menus, staging, verify, log harvest |
| `playbook/scripts/preflight.ps1` | bay-side checks (secure boot, disk, driver match, media age) |
| `playbook/shopfloor-setup/run-enrollment.ps1` | applies the SFLD package via `provtool` |
| `playbook/shopfloor-setup/Run-ShopfloorSetup.ps1` | post-boot setup; the enrollment-model comment is here |
| `playbook/shopfloor-setup/Fetch-StagingPayload.ps1`, `Verify-And-Heal-Staging.ps1` | first-logon self-heal; read `C:\Enrollment\fetch-source.txt` |
| `playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1` | the pre-seed shim (vendor GUI preserved as `-vendor.ps1` on the share) |
| `playbook/shopfloor-setup/BIOS/{check-bios.cmd,models.txt}` | firmware matching |
| `scripts/preflight.py` | runs every server-side lint in one command |
| `scripts/lint-unattend.py` | schema limits: Path 259, CommandLine 1024, Description 256 |
| `scripts/lint-driver-catalogue.py` | reimplements `GetDriverByModel` |
| `scripts/share-drift.py` | repo vs live share, git-owned vs unreconciled |
| `playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1` | one-pass bay diagnostics - identity, enrollment, installed apps, all four autostart surfaces, GE-Enforce reachability |
| `scripts/build-pctype-media.py` | per-PCTYPE media views (retired by Phase 6) |
### 13.2 PXE server (`pxe@172.16.9.1`)
| Path | What |
|---|---|
| `/srv/samba/winpeapps/<image>/Deploy/Control/` | `HardwareDriver.json`, `OperatingSystem.json`, `Media.tag`, `Release.json` |
| `/srv/samba/winpeapps/<image>/Deploy/FlatUnattendW10.xml` | the answer file that actually boots machines |
| `/srv/samba/winpeapps/_shared/Applications/BPRT/` | the six MCL packages, `Insert-BPRTToPPKG.ps1`, `Start-BulkEnrollOrchestrator.ps1`, `Select-PCConfig*.ps1` |
| `/srv/samba/winpeapps/_shared/BIOS/` | firmware EXEs + `models.txt` (served as the `winpeapps_bios` share) |
| `/srv/samba/winpeapps/_media/<pctype>/` | per-PCTYPE media views |
| `/srv/samba/enrollment/scripts/` | `run-enrollment.ps1`, `preflight.ps1`, `winpe-status-push.ps1` |
| `/srv/samba/enrollment/shopfloor-setup/` | per-PC-type trees, `menu.json`, `_ntlars-backups/` (147 `.reg`) |
| `/srv/samba/enrollment/shopfloor-setup/gea-shopfloor-waxtrace/bay-config.csv` | 15 bays, FTPak versions |
| `/srv/samba/enrollment/installers-post/cmm/cmm-bay-config.csv` | CMM bay -> version, DODA |
| `/srv/samba/enrollment/pre-install/udc-backups/` | per-bay UDC settings |
| `/srv/samba/enrollment/ppkgs/` | the SFLD package (8.2 GB) |
| `/srv/samba/enrollment/imaging-logs/<serial>/` | harvested imaging logs (added 2026-08-06) |
| `/var/www/html/win11/sources/boot.wim` | the booted WinPE image |
| `/var/log/pxe-imaging/<serial>.json` | per-bay stage history |
| `/opt/pxe-webapp/` | Flask webapp (unit `pxe-webapp`, :9009) |
| `/etc/dnsmasq.conf`, `/etc/samba/smb.conf` | the binding config §6.2 depends on |
### 13.3 shopdb-flask (`/home/camp/projects/shopdb-flask`)
| Path | What |
|---|---|
| `plugins/geenforce/seed_display_scope.py` | the pattern this proposal generalises - subtype map, server role by IP, sweep |
| `plugins/geenforce/client/` | `ShopdbEnforceClient.psm1`, `Invoke-ShopdbEnforce.ps1` |
| `shopdb/core/api/` | collector endpoint, dashboard-defaults display-role |
### 13.4 Evidence behind the claims in this document
| Path | What it shows |
|---|---|
| `/home/camp/pxe-images/now/pesetuplog.txt` | PESetup 4.0.0.20 run - media drive, disking, copy, driver selection, the `Btn 'Next'` timing in §7.1 |
| `/home/camp/pxe-images/now/debug.txt` | the WinPE console for the failed volume-finder run |
| `/home/camp/pxe-images/pxe2/Panther/` | `setupact.log` / `setuperr.log` naming the unattend `Path` failure |
| `/home/camp/pxe-images/debug2/` | the autologon registry state and the sysprep-loop timeline |
| `/home/camp/pxe-images/debug3/Logs/BPRT/` | the 13 package components, `criticalChecks.json`, `packageInfo.json` |
| `/home/camp/pxe-images/debug3/Logs/PPKG/` | the package itself, `provisioning-sessions.json`, the diagnostics evtx |
| `/home/camp/pxe-images/debug3/Logs/enrollment.log` | the double run and the rename race in §9 |
| `/home/camp/pxe-images/debug3/Logs/FilteredReportIP.log` | proves the bay had no route to Entra (§8.2) |
### 13.5 On-bay locations (for anyone diagnosing one)
```
C:\Enrollment\ staged payload, pc-type.txt, display-type.txt,
pc-config.txt, fetch-source.txt, winpe-staging.log
C:\Deploy\Applications\BPRT\ the packages + orchestrator, copied by PESetup
C:\Logs\BPRT\<Component>\ per-component package logs (UTF-16)
C:\Logs\BPRT\criticalChecks.json PPKG matched / Entra joined / OOBE execution
C:\Logs\PPKG\ provisioning diagnostics + session state
C:\Logs\SFLD\ DSC deployment, sync_intune, shopfloor-setup
C:\Logs\enrollment.log run-enrollment.ps1
C:\Windows\Panther\ setupact.log, setuperr.log, unattend.xml, PESetup*.log
C:\Windows\Panther\UnattendGC\ oobeSystem pass
```
### 13.5a Gotchas any new tooling will hit
- **`HardwareDriver.json` cannot be read by `ConvertFrom-Json`.** It carries both
casings of the same fields (`fileName`/`FileName`, `destinationDir`/
`DestinationDir`) and PowerShell rejects the document - on 5.1 *and* 7.
`-AsHashtable` is PS6+ and WinPE runs 5.1. Extract fields by regex, or parse in
Python where duplicate keys are tolerated.
- **`Compress-Archive` caps at 2 GB**, and `run-enrollment` harvests the 8 GB
provisioning package into `C:\Logs\PPKG`, so any log collection must exclude
`*.ppkg`.
- **Backslash-vs-forward-slash**: PowerShell Core normalises `\` to `/` on Linux,
so `Join-Path` with Windows separators works cross-platform - handy for testing
bay scripts on the dev box.
### 13.6 How to re-derive things
```bash
# read a file off the share
sshpass -p pxe ssh -o StrictHostKeyChecking=no pxe@172.16.9.1 'cat <path>'
smbclient //172.16.9.1/enrollment -U pxe-upload%pxe -c 'ls'
# what is actually inside the booted WinPE
sudo wimextract /var/www/html/win11/sources/boot.wim 1 \
/Windows/System32/startnet.cmd --dest-dir=/tmp/x
sudo wimdir /var/www/html/win11/sources/boot.wim 1 | grep -i <name>
# a .ppkg is a WIM - pull out the spec without extracting 8 GB
7z l <pkg>.ppkg
7z e <pkg>.ppkg -o<dir> "Multivariant/0/customizations.xml" \
"Multivariant/0/Prov/RunTime/0__Accounts_Azure.provxml"
# provisioning diagnostics (python-evtx is installed)
python3 -c "from Evtx.Evtx import Evtx;
[print(r.xml()) for r in Evtx('Provisioning-Diagnostics-Admin.evtx').records()]"
# decompile PESetup - the bundle-extraction recipe is in PESETUP-INTERNALS.md
ilspycmd -t <TypeName> PESetup.dll
# validate before deploying
./scripts/preflight.py # every server-side lint
./scripts/share-drift.py --diff # repo vs live
```
### 13.7 The 2026-08-06 commits this document draws on
```
dfa026b lint-driver-catalogue c7b1769 build-pctype-media
049dd7b startnet per-PCTYPE media d5f7abc BIOS 7020 family
385acfe lint-unattend 2b6189f startnet W: wait + honest reporting
d04865d PESetup version + LogonCount b96ff7e retire shopfloor-dashboard route
36be60e run-enrollment stops renaming 8c21282 staging verify + log harvest
d2200e8 preflight (bay + server) 68df59e OWNERSHIP.md + share-drift
9d51c0b purpose menu + 3D kiosk a0aceb4 PXESERVER consolidation
bb08392 download-drivers case fix
```

View File

@@ -0,0 +1,144 @@
# CyberArk EPM - CMM / DODA elevation policy
Reference for the CyberArk EPM admin. Fixes the "PC-DMIS is elevated but the
tools it calls error that they are not running elevated" problem on CMM bays.
## Problem (root cause)
CyberArk EPM elevation is per-process and is NOT inherited by child processes.
The existing policy elevates PC-DMIS (`PCDLRN.exe`), but the external `.exe`
files the PC-DMIS routine spawns (report, geometry, and DODA tools) launch with
the standard user token (Medium integrity) and fail their own "must run
elevated" check, even though the parent PC-DMIS is elevated. The elevation dies
at the process boundary.
## Flow that breaks
The PC-DMIS routine drives in-process `.BAS` scripts that shell out to separate
executables:
| Step | Process | Separate process? |
|------|---------|-------------------|
| Merge / sort report results | `MergeFiles.exe` (.NET), reads `C:\Apps\DODA\PreProcess\` | yes |
| Geometry export | `PCDToIGES.exe`, `RotateProbeVector.exe` | yes |
| DODA calculation | `DovetailAnalysis.exe` (+ embedded JVM and python) | yes |
| RTF -> PDF, display | `winword.exe`, `AcroRd32.exe` | yes (removed by the BAS rework below) |
| Folder create / save | `MAKEDIR.BAS`, `MAKEFOLDER.bas`, `SaveAsFolder.bas` | no (in-process, uses PC-DMIS token) |
The in-process file operations inherit PC-DMIS's elevated token. The separate
`.exe` files do not. Those are what get blocked.
## Fix: one Application Group + one Elevate policy
Do NOT use "elevate all child processes of PC-DMIS". That would elevate
`cmd.exe` and anything PC-DMIS launches, which is a large hole on a locked-down
shopfloor PC. Elevate only the named toolchain.
### Application Group: CMM-DODA-Tools
These are in-house, unsigned tools, so match by SHA-256 (or by path + filename
if the install directory is admin-write-only and the confirmed path is known).
| App | SHA-256 | Spawns children? |
|-----|---------|------------------|
| `MergeFiles.exe` | `e58ce7599d3bdba816c7ecb183d4f52b32ad8be0b8e4f41813824d8eb472d723` | no |
| `PCDToIGES.exe` | `7bdc961c406f7a0f6f8a10752988a17504bdfd691469c08d20f0d5b6673974cf` | no |
| `RotateProbeVector.exe` | `f8a1b5b0025769fe0d28dc12826ef5d1fbcdba3b29383799c1eb04b955abebdc` | no |
| `DovetailAnalysis.exe` | `86dcb0898bdef4687427ce339520a9c9f5a582890c2241d784fa985019eaaec1` | yes (JVM + python) |
### Elevate policy
- Target: the `CMM-DODA-Tools` Application Group
- Action: Elevate (run with administrator rights)
- Applies to: the CMM computer set + the `ShopFloor` user
- Child processes: elevate ONLY for `DovetailAnalysis.exe` (it launches the
embedded JVM and python scripts that do the actual file work). The other
three have no children.
- Match basis: SHA-256 hash
## Explicitly NOT in the policy
`winword.exe`, `AcroRd32.exe`, `cmd.exe`. The `CREATE_PDF_FROM_RTF.BAS` rework
(Word writes the PDF to user `%TEMP%`, PC-DMIS moves it to the final path with
its own elevated in-process token, display via the default PDF handler) removes
their need for elevation. Keep Office and Reader out of the elevation set.
## What the EPM admin needs from GE
- The computer group = the CMM bays (hostname list, OU, or AD group)
- The user = `ShopFloor` (local account)
- If using path matching instead of hash: the confirmed install directory of the
four exes on a bay (`where MergeFiles.exe`)
## Verify
- Before: from elevated PC-DMIS, spawn a child `cmd.exe`, then run
`whoami /groups | findstr Label`. Medium Mandatory Level confirms children are
not elevated (the bug).
- After: the four tools run at High Mandatory Level; report generation plus
copy/move/delete to C: and S: succeed; no "not elevated" error.
## Caveats
- Hash churn: rebuilding a tool changes its hash, so the Application Group hash
must be re-stamped. Path + filename matching avoids this IF the install
directory is admin-write-only (so a same-named spoof cannot be dropped there).
- Not an ACL fix: the tools hard-check elevation and bail before touching the
filesystem, so pre-granting NTFS ACLs alone will not unblock them. The EPM
elevation is the actual lever.
- Scope tight: match by hash and scope to the CMM computer group + ShopFloor so
this elevation never applies fleet-wide.
## Related code fixes (PXE imaging side)
- `09-Setup-CMM.ps1` Step 2.5 ACL list corrected from `C:\Program Files\DODA`
(nonexistent) to `C:\Apps\DODA` (where `Install-DODA.ps1` actually extracts).
- `MergeFiles.exe` expects `C:\Apps\DODA\PreProcess\`, which the DODA zip does
not create (it extracts flat). A missing `PreProcess` directory is the likely
cause of the historical `MergeFiles.GetDoDAFolder` `DirectoryNotFoundException`
crash (see `cmm-utilities` repo `dotNET event.txt`). Have `Install-DODA.ps1`
create the `PreProcess` subdir, or have whoever deploys the toolchain own it.
- The CMM tool chain (`MergeFiles.exe`, `PCDToIGES.exe`, `RotateProbeVector.exe`,
the `.BAS` scripts) lives in the separate `cmm-utilities` repo and is NOT
deployed by PXE imaging today. Decide whether imaging should own it so the
install path, ACLs, and `PreProcess` directory are consistent.
## CREATE_PDF_FROM_RTF.BAS rework (removes Word/Reader from the elevation set)
```vb
' CREATE_PDF_FROM_RTF.BAS - rev 1.0: convert in user temp, then move with
' PC-DMIS's own token; display via default handler (no elevation needed).
Sub Main(filename As String, displayReport As String)
Dim rtfFile As String, finalPdf As String, tempPdf As String
rtfFile = filename & ".RTF"
finalPdf = filename & ".PDF"
Dim base As String
base = Mid(filename, InStrRev(filename, "\") + 1)
tempPdf = Environ$("TEMP") & "\" & base & ".PDF"
' Word (un-elevated COM) can write to %TEMP% - a user-writable path.
Dim word As Object
Set word = CreateObject("word.application")
word.Visible = False
word.Documents.Open rtfFile
word.ActiveDocument.SaveAs2 tempPdf, 17 ' 17 = wdFormatPDF
word.Quit
Set word = Nothing
' Move temp -> final using PC-DMIS's in-process token (elevated via
' CyberArk), so the protected/S: destination is written without needing
' Word itself elevated. FileCopy works across volumes; Name does not.
If Dir(finalPdf) <> "" Then Kill finalPdf
FileCopy tempPdf, finalPdf
Kill tempPdf
' Display via the default PDF handler in the user's own context.
If UCase(displayReport) = "TRUE" Then
Dim sh As Object
Set sh = CreateObject("Shell.Application")
sh.ShellExecute finalPdf, "", "", "open", 1
End If
Kill rtfFile
End Sub
```

View File

@@ -196,7 +196,7 @@ Two separate copies of overlapping content with different roles:
| Path | Source | Used by | Updated when | | Path | Source | Used by | Updated when |
|------|--------|---------|--------------| |------|--------|---------|--------------|
| `C:\Enrollment\shopfloor-setup\` | PXE imaging copy from `\\10.9.100.1\enrollment\shopfloor-setup\` | Imaging-flow scripts: `Run-ShopfloorSetup.ps1`, `Stage-Dispatcher.ps1`, `Set-MachineNumber.ps1` -> `Update-MachineNumber.ps1` | Re-image only | | `C:\Enrollment\shopfloor-setup\` | PXE imaging copy from `\\172.16.9.1\enrollment\shopfloor-setup\` | Imaging-flow scripts: `Run-ShopfloorSetup.ps1`, `Stage-Dispatcher.ps1`, `Set-MachineNumber.ps1` -> `Update-MachineNumber.ps1` | Re-image only |
| SFLD share `\<scope>\` | Direct upload | GE-Enforce.ps1 / Install-FromManifest.ps1 (every logon) | Direct file upload to share | | SFLD share `\<scope>\` | Direct upload | GE-Enforce.ps1 / Install-FromManifest.ps1 (every logon) | Direct file upload to share |
Implication for hot-fixing scripts: a fix to `Restore-UDCData.ps1` needs to Implication for hot-fixing scripts: a fix to `Restore-UDCData.ps1` needs to

View File

@@ -52,11 +52,11 @@ Add a new entry (insert before the existing `D12 OptiPlex Family / 7090` entry):
the actual driver pack from Dell's catalog by model name (`extract_model_ids` the actual driver pack from Dell's catalog by model name (`extract_model_ids`
matches "7080") and downloads the latest pack at run time. matches "7080") and downloads the latest pack at run time.
### Side artifacts already on the live PXE server (10.9.100.1) ### Side artifacts already on the live PXE server (172.16.9.1)
- `\\10.9.100.1\winpeapps\_shared\BIOS\OptiPlex_7080_1.37.0.exe` (39.8 MB, BIOS update) - `\\172.16.9.1\winpeapps\_shared\BIOS\OptiPlex_7080_1.37.0.exe` (39.8 MB, BIOS update)
- `\\10.9.100.1\image-upload\Deploy\Out-of-box Drivers\Dell_11\OptiPlex\D11 OptiPlex Family\win11_70809ntr8_a09.zip` (Win11 driver pack, 2.6 GB) - `\\172.16.9.1\image-upload\Deploy\Out-of-box Drivers\Dell_11\OptiPlex\D11 OptiPlex Family\win11_70809ntr8_a09.zip` (Win11 driver pack, 2.6 GB)
- `\\10.9.100.1\winpeapps\_shared\BIOS\models.txt` includes the 7080 line. - `\\172.16.9.1\winpeapps\_shared\BIOS\models.txt` includes the 7080 line.
These persist regardless of `geastandardpbr/` rebuilds. Only the model-registry These persist regardless of `geastandardpbr/` rebuilds. Only the model-registry
edits need to be re-applied after a USB re-import. edits need to be re-applied after a USB re-import.

View File

@@ -0,0 +1,47 @@
<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Post-Deploy Verification Checklist</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.css">
<style>
body { box-sizing: border-box; max-width: 980px; margin: 2em auto; padding: 0 2em; }
.markdown-body img { max-width: 100%; }
.markdown-body ul.task-list { list-style: none; padding-left: 0; }
.markdown-body li.task-list-item { list-style: none; }
.markdown-body li.task-list-item input[type=checkbox] { margin-right: .5em; transform: scale(1.2); }
@media print { body { max-width: none; margin: 0; padding: 1em; } }
</style>
</head><body class="markdown-body">
<h1 id="post-deploy-checklist">Post-Deploy Checklist</h1>
<p>Run after first boot. Sign off when all boxes checked. Fail any step, see <a href="post-deploy-debug-flowchart.md">post-deploy-debug-flowchart.md</a>.</p>
<h2 id="1-common-shop-floor">1. Common Shop Floor</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Shopfloor Tools</code> then <code>WJ Shopfloor</code> opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Login prompt and menu screen render</li>
</ul>
<p>Fail: check corp network, ping <code>WJFMS3.AE.GE.COM</code>.</p>
<h2 id="2-controller-skip-if-standalone-pc">2. Controller (skip if standalone PC)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>ping 192.168.1.1</code> returns 4 of 4 replies</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS General tab populated</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS FMS Host Primary set to <code>WJFMS3.AE.GE.COM</code> (FQDN)</li>
</ul>
<p>Fail ping, see <a href="post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip">2B NIC</a>.
Blank General, see <a href="post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields">2A reg load</a>.</p>
<h2 id="3-udc-com-port">3. UDC COM port</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> UDC opens with no machine-communication error dialog</li>
<li class="task-list-item"><input type="checkbox" disabled> <code>Tools</code> then <code>Retry Connection</code> succeeds (no error)</li>
<li class="task-list-item"><input type="checkbox" disabled> Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4</li>
<li class="task-list-item"><input type="checkbox" disabled> Data lines populate after reopen</li>
</ul>
<p>Fail, see <a href="post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port">Step 4</a>.</p>
<h2 id="4-printers-genspect-all-nearby">4. Printers (Genspect: all nearby)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Install Printers</code> shortcut opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Each nearby printer installed, status Ready</li>
<li class="task-list-item"><input type="checkbox" disabled> Test page prints</li>
<li class="task-list-item"><input type="checkbox" disabled> Genspect: default printer set</li>
</ul>
<p>Fail, see <a href="../printer-mapping.md">printer-mapping.md</a>.</p>
</body></html>

View File

@@ -0,0 +1,37 @@
# Post-Deploy Checklist
Run after first boot. Sign off when all boxes checked. Fail any step, see [post-deploy-debug-flowchart.md](post-deploy-debug-flowchart.md).
## 1. Common Shop Floor
- [ ] `Shopfloor Tools` then `WJ Shopfloor` opens
- [ ] Login prompt and menu screen render
Fail: check corp network, ping `WJFMS3.AE.GE.COM`.
## 2. Controller (skip if standalone PC)
- [ ] `ping 192.168.1.1` returns 4 of 4 replies
- [ ] NTLARS General tab populated
- [ ] NTLARS FMS Host Primary set to `WJFMS3.AE.GE.COM` (FQDN)
Fail ping, see [2B NIC](post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip).
Blank General, see [2A reg load](post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields).
## 3. UDC COM port
- [ ] UDC opens with no machine-communication error dialog
- [ ] `Tools` then `Retry Connection` succeeds (no error)
- [ ] Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4
- [ ] Data lines populate after reopen
Fail, see [Step 4](post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port).
## 4. Printers (Genspect: all nearby)
- [ ] `Install Printers` shortcut opens
- [ ] Each nearby printer installed, status Ready
- [ ] Test page prints
- [ ] Genspect: default printer set
Fail, see [printer-mapping.md](../printer-mapping.md).

View File

@@ -0,0 +1,59 @@
<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Post-Deploy Verification Checklist</title>
<style>
body { font-family: -apple-system, system-ui, "Segoe UI", sans-serif; max-width: 980px; margin: 2em auto; padding: 0 2em; line-height: 1.5; color: #24292f; }
h1, h2, h3 { border-bottom: 1px solid #d0d7de; padding-bottom: .3em; scroll-margin-top: 1em; }
h1 { font-size: 2em; }
code { background: #f6f8fa; padding: .2em .4em; border-radius: 6px; font-size: 85%; }
pre { background: #f6f8fa; padding: 1em; border-radius: 6px; overflow: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; }
table th, table td { border: 1px solid #d0d7de; padding: 6px 13px; }
table tr:nth-child(2n) { background: #f6f8fa; }
img { max-width: 100%; height: auto; }
blockquote { border-left: .25em solid #d0d7de; padding: 0 1em; color: #57606a; }
ul.task-list, li.task-list-item { list-style: none; }
ul.task-list { padding-left: 0; }
li.task-list-item input[type=checkbox] { margin-right: .5em; transform: scale(1.2); }
hr { border: 0; border-top: 1px solid #d0d7de; margin: 2em 0; }
@media print {
body { max-width: none; margin: 0; padding: 1em; }
input[type=checkbox] { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head><body>
<h1 id="post-deploy-checklist">Post-Deploy Checklist</h1>
<p>Run after first boot. Sign off when all boxes checked. Fail any step, see <a href="post-deploy-debug-flowchart.md">post-deploy-debug-flowchart.md</a>.</p>
<h2 id="1-common-shop-floor">1. Common Shop Floor</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Shopfloor Tools</code> then <code>WJ Shopfloor</code> opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Login prompt and menu screen render</li>
</ul>
<p>Fail: check corp network, ping <code>WJFMS3.AE.GE.COM</code>.</p>
<h2 id="2-controller-skip-if-standalone-pc">2. Controller (skip if standalone PC)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>ping 192.168.1.1</code> returns 4 of 4 replies</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS General tab populated</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS FMS Host Primary set to <code>WJFMS3.AE.GE.COM</code> (FQDN)</li>
</ul>
<p>Fail ping, see <a href="post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip">2B NIC</a>.
Blank General, see <a href="post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields">2A reg load</a>.</p>
<h2 id="3-udc-com-port">3. UDC COM port</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> UDC opens with no machine-communication error dialog</li>
<li class="task-list-item"><input type="checkbox" disabled> <code>Tools</code> then <code>Retry Connection</code> succeeds (no error)</li>
<li class="task-list-item"><input type="checkbox" disabled> Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4</li>
<li class="task-list-item"><input type="checkbox" disabled> Data lines populate after reopen</li>
</ul>
<p>Fail, see <a href="post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port">Step 4</a>.</p>
<h2 id="4-printers-genspect-all-nearby">4. Printers (Genspect: all nearby)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Install Printers</code> shortcut opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Each nearby printer installed, status Ready</li>
<li class="task-list-item"><input type="checkbox" disabled> Test page prints</li>
<li class="task-list-item"><input type="checkbox" disabled> Genspect: default printer set</li>
</ul>
<p>Fail, see <a href="../printer-mapping.md">printer-mapping.md</a>.</p>
</body></html>

View File

@@ -24,7 +24,7 @@ flowchart TD
U2 --> U3[Tools - Options - Serial tab] U2 --> U3[Tools - Options - Serial tab]
U3 --> U4{Which physical COM port is the cable in?} U3 --> U4{Which physical COM port is the cable in?}
U4 -->|Intel / onboard| U5[Set Port Name = COM 1] U4 -->|Intel / onboard| U5[Set Port Name = COM 1]
U4 -->|PCIe add-in card| U6[Set Port Name = COM 2] U4 -->|PCIe add-in card| U6[Set Port Name = COM 2 or COM 4]
U5 --> U7[Save - File - Exit - reopen UDC] U5 --> U7[Save - File - Exit - reopen UDC]
U6 --> U7 U6 --> U7
U7 --> U8([Verify data lines populate]) U7 --> U8([Verify data lines populate])
@@ -51,7 +51,7 @@ flowchart TD
click U3 "#step-3-open-options" "Step 3 - Options" click U3 "#step-3-open-options" "Step 3 - Options"
click U4 "#step-4-set-the-correct-com-port" "Step 4 - COM port" click U4 "#step-4-set-the-correct-com-port" "Step 4 - COM port"
click U5 "#step-4-set-the-correct-com-port" "Step 4 - COM 1" click U5 "#step-4-set-the-correct-com-port" "Step 4 - COM 1"
click U6 "#step-4-set-the-correct-com-port" "Step 4 - COM 2" click U6 "#step-4-set-the-correct-com-port" "Step 4 - COM 2 or COM 4"
click U7 "#step-5-exit-to-apply" "Step 5 - Exit" click U7 "#step-5-exit-to-apply" "Step 5 - Exit"
click D2 "#2a-ntlars-reg-file-never-imported-blank-general-tab-fields" "2A - Load reg backup" click D2 "#2a-ntlars-reg-file-never-imported-blank-general-tab-fields" "2A - Load reg backup"
@@ -113,7 +113,7 @@ Click the **Serial** tab on the left. Set **Port Name** to match the **physical*
![PC rear - onboard COM1 port](images/post-deploy/Figure_5_UDC_not_collecting_data_enable_admin_options_serial_intel_com1_physical.png) ![PC rear - onboard COM1 port](images/post-deploy/Figure_5_UDC_not_collecting_data_enable_admin_options_serial_intel_com1_physical.png)
- **COM 2** = PCIe add-in serial card - **COM 2 or COM 4** = PCIe add-in serial card (Windows enumeration varies by hardware - check Device Manager -> Ports if unsure)
Logical: Logical:
@@ -138,7 +138,7 @@ Reopen UDC. Data lines should start populating as the machine runs.
### If data still does not appear ### If data still does not appear
Once the COM port is correct, ~~rule out~~ check these in order: Once the COM port is correct, ~~rule out~~ check these in order:
- Wrong physical cable - cable is in COM1 socket but Port Name set to COM 2 (or vice versa). Re-check Step 4. - Wrong physical cable - cable is in COM1 socket but Port Name set to COM 2 / COM 4 (or vice versa). Re-check Step 4.
- Cable / connector damaged - swap with a known-good cable. - Cable / connector damaged - swap with a known-good cable.
- Machine controller side not transmitting - confirm at the controller HMI. - Machine controller side not transmitting - confirm at the controller HMI.

View File

@@ -6,7 +6,7 @@ Step-by-step for imaging a new (or replacement) shopfloor PC that will sit at a
- PC connected to the **PXE switch** (not the production network yet) - PC connected to the **PXE switch** (not the production network yet)
- USB mouse + keyboard connected - USB mouse + keyboard connected
- PXE server is running and reachable (verify by pinging `10.9.100.1` from another PC on the same switch) - PXE server is running and reachable (verify by pinging `172.16.9.1` from another PC on the same switch)
- **Target machine number** known (e.g., `7605`) — you can enter it at PXE time, or use `9999` as a placeholder if the PC will be configured at the bay later - **Target machine number** known (e.g., `7605`) — you can enter it at PXE time, or use `9999` as a placeholder if the PC will be configured at the bay later
- **ARTS Lockdown request submitted** for this PC (or know that you'll submit one mid-imaging) - **ARTS Lockdown request submitted** for this PC (or know that you'll submit one mid-imaging)
@@ -229,7 +229,7 @@ The script needs a desktop session. Won't run via WinRM/SSH/non-interactive. Mak
## Reference ## Reference
- **PXE server**: `10.9.100.1` - **PXE server**: `172.16.9.1`
- **SFLD share**: `\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\` - **SFLD share**: `\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`
- **Manifest engine log**: `C:\GE Aerospace\machineapps-enforce.log` - **Manifest engine log**: `C:\GE Aerospace\machineapps-enforce.log`
- **Intune sync transcript**: `C:\Logs\SFLD\sync_intune_transcript.txt` - **Intune sync transcript**: `C:\Logs\SFLD\sync_intune_transcript.txt`

View File

@@ -136,7 +136,7 @@
</Password> </Password>
<Enabled>true</Enabled> <Enabled>true</Enabled>
<Username>SupportUser</Username> <Username>SupportUser</Username>
<LogonCount>7</LogonCount> <LogonCount>12</LogonCount>
</AutoLogon> </AutoLogon>
<FirstLogonCommands> <FirstLogonCommands>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
@@ -156,21 +156,41 @@
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>4</Order> <Order>4</Order>
<CommandLine>powershell.exe -ep Bypass -Command "net use Z: \\172.16.9.1\enrollment /user:pxe-upload pxe; robocopy Z:\shopfloor-setup C:\Enrollment Fetch-StagingPayload.ps1 Verify-And-Heal-Staging.ps1 /R:2 /W:2; robocopy Z:\scripts C:\Enrollment run-enrollment.ps1 wait-for-internet.ps1 migrate-to-wifi.ps1 /R:2 /W:2"</CommandLine>
<Description>Bootstrap self-heal: pull Fetch/Verify-Heal + enrollment scripts from the PXE share so Order 5/6 run after an early WinPE staging failure.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>6</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
<Description>Verify + re-pull any missing imaging payload from the PXE share (CMM/Keyence/WaxTrace bundles + bay backup) on the imaging LAN before the production-network switch. Log C:\Logs\Fetch.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>7</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description> <Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>5</Order> <Order>8</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description> <Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>6</Order> <Order>9</Order>
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>10</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine>
<Description>Run GCCH Enrollment</Description> <Description>Run GCCH Enrollment</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>7</Order> <Order>11</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
<Description>Run shopfloor PC type setup</Description> <Description>Run shopfloor PC type setup</Description>
</SynchronousCommand> </SynchronousCommand>

View File

@@ -1,307 +1,302 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" <unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. windowsPE is intentionally empty --> <!-- 1. windowsPE is intentionally empty -->
<settings pass="windowsPE" /> <settings pass="windowsPE" />
<!-- 2. Offline servicing (drivers) --> <!-- 2. Offline servicing (drivers) -->
<settings pass="offlineServicing"> <settings pass="offlineServicing">
<component name="Microsoft-Windows-PnpCustomizationsNonWinPE" <component name="Microsoft-Windows-PnpCustomizationsNonWinPE"
processorArchitecture="amd64" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" publicKeyToken="31bf3856ad364e35"
language="neutral" language="neutral"
versionScope="nonSxS"> versionScope="nonSxS">
<DriverPaths> <DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1"> <PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>W:\Drivers</Path> <Path>W:\Drivers</Path>
</PathAndCredentials> </PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2"> <PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path> <Path>W:\Deploy\Applications\extra\printdrivers\BROTHER\UNIV-PS-01181\PS\64</Path>
</PathAndCredentials> </PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3"> <PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>W:\Deploy\Applications\extra\printdrivers\HP</Path> <Path>W:\Deploy\Applications\extra\printdrivers\HP</Path>
</PathAndCredentials> </PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4"> <PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path> <Path>W:\Deploy\Applications\extra\printdrivers\XEROX\UNIV_5.1035.2.0_PS_x64_Driver</Path>
</PathAndCredentials> </PathAndCredentials>
</DriverPaths> </DriverPaths>
</component> </component>
</settings> </settings>
<!-- 3. specialize: computer naming + RunSynchronous all in ONE component --> <!-- 3. specialize: computer naming + RunSynchronous all in ONE component -->
<settings pass="specialize"> <settings pass="specialize">
<!-- 3a. Shell-Setup for naming/owner/org --> <!-- 3a. Shell-Setup for naming/owner/org -->
<component name="Microsoft-Windows-Shell-Setup" <component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" publicKeyToken="31bf3856ad364e35"
language="neutral" language="neutral"
versionScope="nonSxS"> versionScope="nonSxS">
<ComputerName>H%serialnumber%</ComputerName> <ComputerName>H%serialnumber%</ComputerName>
<RegisteredOrganization>GE Aerospace</RegisteredOrganization> <RegisteredOrganization>GE Aerospace</RegisteredOrganization>
<RegisteredOwner>GE</RegisteredOwner> <RegisteredOwner>GE</RegisteredOwner>
<TimeZone>Eastern Standard Time</TimeZone> <TimeZone>Eastern Standard Time</TimeZone>
</component> </component>
<!-- 3b. RunSynchronous for all of your installers, copies, etc. --> <!-- 3b. RunSynchronous for all of your installers, copies, etc. -->
<component name="Microsoft-Windows-Deployment" <component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" publicKeyToken="31bf3856ad364e35"
language="neutral" language="neutral"
versionScope="nonSxS"> versionScope="nonSxS">
<RunSynchronous> <RunSynchronous>
<!-- EAP-PEAP MSI --> <!-- EAP-PEAP MSI -->
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>1</Order> <Order>1</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path> <Path>msiexec /i "C:\Deploy\Applications\extra\wireless\EAP-PEAP.msi" /quiet /norestart</Path>
<Description>Install EAP-PEAP</Description> <Description>Install EAP-PEAP</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- Wi-Fi profiles --> <!-- Wi-Fi profiles -->
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>2</Order> <Order>2</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path> <Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\BLUESSO.xml" user=all</Path>
<Description>Add BLUESSO WiFi profile</Description> <Description>Add BLUESSO WiFi profile</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>3</Order> <Order>3</Order>
<Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path> <Path>cmd /c netsh wlan add profile filename="C:\Deploy\Applications\extra\wireless\WiFi-Profile.xml" user=all</Path>
<Description>Add generic WiFi profile</Description> <Description>Add generic WiFi profile</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- Certificates --> <!-- Certificates -->
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>4</Order> <Order>4</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_External_Root_CA_2_1.cer"</Path>
<Description>Install External Root Certificate</Description> <Description>Install External Root Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>5</Order> <Order>5</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_External_Intermediate_CA_2_1.cer"</Path>
<Description>Install External Intermediate Certificate</Description> <Description>Install External Intermediate Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>6</Order> <Order>6</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Enterprise_Root_CA_2_1.cer"</Path>
<Description>Install Enterprise Root Certificate</Description> <Description>Install Enterprise Root Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>7</Order> <Order>7</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Device_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Device Issuing Certificate</Description> <Description>Install Enterprise Device Issuing Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>8</Order> <Order>8</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Server_Issuing_CA_2_1.cer"</Path>
<Description>Install Enterprise Server Issuing Certificate</Description> <Description>Install Enterprise Server Issuing Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>9</Order> <Order>9</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_Smart_Card_Issuing_CA_2_1.cer"</Path>
<Description>Install SmartCard Issuing Certificate</Description> <Description>Install SmartCard Issuing Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>10</Order> <Order>10</Order>
<Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path> <Path>cmd /c certutil -addstore CA "C:\Deploy\Applications\GE_Enterprise_User_Issuing_CA_2_1.cer"</Path>
<Description>Install User Issuing Certificate</Description> <Description>Install User Issuing Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>11</Order> <Order>11</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path> <Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\GE_Aerospace_Enterprise_Root_CA_1.cer"</Path>
<Description>Install Aerospace Enterprise Root CA</Description> <Description>Install Aerospace Enterprise Root CA</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>12</Order> <Order>12</Order>
<Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path> <Path>cmd /c certutil -addstore Root "C:\Deploy\Applications\ZscalerCommercialCertificate-2048-SHA256.crt"</Path>
<Description>Install Zscaler Certificate</Description> <Description>Install Zscaler Certificate</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- Fonts --> <!-- Fonts -->
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>13</Order> <Order>13</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path> <Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\3OF9.TTF" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy 3OF9 Font</Description> <Description>Copy 3OF9 Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>14</Order> <Order>14</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path> <Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "3 of 9 Barcode" /t REG_SZ /d "3OF9.TTF" /f</Path>
<Description>Register 3OF9 Barcode Font</Description> <Description>Register 3OF9 Barcode Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>15</Order> <Order>15</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path> <Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39AzaleaNarrow3.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Narrow Font</Description> <Description>Copy Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>16</Order> <Order>16</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path> <Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39AzaleaNarrow3" /t REG_SZ /d "Code39AzaleaNarrow3.ttf" /f</Path>
<Description>Register Code39 Azalea Narrow Font</Description> <Description>Register Code39 Azalea Narrow Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>17</Order> <Order>17</Order>
<Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path> <Path>cmd /c copy "C:\Deploy\Applications\extra\fonts\Code39Azalea.ttf" "%WINDIR%\Fonts\" /Y</Path>
<Description>Copy Code39 Azalea Font</Description> <Description>Copy Code39 Azalea Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>18</Order> <Order>18</Order>
<Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path> <Path>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Code39Azalea" /t REG_SZ /d "Code39Azalea.ttf" /f</Path>
<Description>Register Code39 Azalea Font</Description> <Description>Register Code39 Azalea Font</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<!-- OpenText installers --> <!-- OpenText installers -->
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>19</Order> <Order>19</Order>
<Path>C:\Deploy\Applications\extra\opentext\opentext_hostexplorer_sp1_15.0_v01.exe /quiet /norestart</Path> <Path>cmd /c "C:\Deploy\Applications\extra\opentext\Setup-OpenText.cmd"</Path>
<Description>Install OpenText HostExplorer SP1</Description> <Description>Install OpenText HostExplorer SP1 (shopfloor method)</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>20</Order> <Order>20</Order>
<Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path> <Path>C:\Deploy\Applications\extra\opentext\J2SE_Runtime_Environment_1.6.0_22_Static_Config_V2_Co-Exist.EXE /silent /norestart</Path>
<Description>Install J2SE Runtime Environment 1.6.0_22</Description> <Description>Install J2SE Runtime Environment 1.6.0_22</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>21</Order> <!-- Adobe -->
<Path>C:\Deploy\Applications\extra\opentext\unattended.bat</Path> <RunSynchronousCommand wcm:action="add">
<Description>Install J2SE Runtime Environment 1.6.0_22</Description> <Order>22</Order>
</RunSynchronousCommand> <Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path>
<Description>Install Adobe</Description>
<!-- Adobe --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> <RunSynchronousCommand wcm:action="add">
<Order>22</Order> <Order>23</Order>
<Path>msiexec /i "C:\Deploy\Applications\extra\adobe\AcroRead.msi" TRANSFORMS="C:\Deploy\Applications\extra\adobe\AcroRead.mst" /quiet /norestart</Path> <Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path>
<Description>Install Adobe</Description> <Description>Apply Adobe Reader Update</Description>
</RunSynchronousCommand> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>23</Order> <!-- Java JRE 8 Enterprise Installation -->
<Path>msiexec /p "C:\Deploy\Applications\extra\adobe\AcroRdrDCUpd2500120531.msp" /quiet /norestart</Path> <RunSynchronousCommand wcm:action="add">
<Description>Apply Adobe Reader Update</Description> <Order>26</Order>
</RunSynchronousCommand> <Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path>
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description>
<!-- Java JRE 8 Enterprise Installation --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>26</Order> <!-- Disable Java Auto Updates via Registry (64-bit) -->
<Path>C:\Deploy\Applications\extra\java\jre-8u441-windows-i586.exe /s INSTALLDIR="C:\Program Files (x86)\Java\jre1.8.0_441" STATIC=1 AUTO_UPDATE=0 REBOOT=0 SPONSORS=0 WEB_JAVA=0 /L "C:\Windows\Logs\java-install.log"</Path> <RunSynchronousCommand wcm:action="add">
<Description>Install Java JRE 8 Update 441 with Enterprise Settings and Logging</Description> <Order>27</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (64-bit)</Description>
<!-- Disable Java Auto Updates via Registry (64-bit) --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>27</Order> <!-- Disable Java Auto Updates via Registry (32-bit) -->
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Java Auto Updates (64-bit)</Description> <Order>28</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Updates (32-bit)</Description>
<!-- Disable Java Auto Updates via Registry (32-bit) --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>28</Order> <!-- Disable Java Auto Update Check (64-bit) -->
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableJavaUpdate /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Java Auto Updates (32-bit)</Description> <Order>29</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (64-bit)</Description>
<!-- Disable Java Auto Update Check (64-bit) --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>29</Order> <!-- Disable Java Auto Update Check (32-bit) -->
<Path>cmd /c reg add "HKLM\SOFTWARE\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Java Auto Update Check (64-bit)</Description> <Order>30</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path>
<Description>Disable Java Auto Update Check (32-bit)</Description>
<!-- Disable Java Auto Update Check (32-bit) --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>30</Order> <!-- Disable Cortana -->
<Path>cmd /c reg add "HKLM\SOFTWARE\Wow6432Node\JavaSoft\Java Update\Policy" /v EnableAutoUpdateCheck /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Java Auto Update Check (32-bit)</Description> <Order>31</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana</Description>
<!-- Disable Cortana --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>31</Order> <!-- Disable Web Search in Start Menu -->
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Cortana</Description> <Order>33</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path>
<Description>Disable Web Search in Start Menu</Description>
<!-- Disable Web Search in Start Menu --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>33</Order> <!-- Disable Bing / web search suggestions in Start menu.
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v DisableWebSearch /t REG_DWORD /d 1 /f</Path> BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search
<Description>Disable Web Search in Start Menu</Description> (not HKLM), so the old HKLM policy placement was a no-op. The current documented
</RunSynchronousCommand> suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer.
Writing it into the Default User hive so every newly-created account inherits it. -->
<!-- Disable Bing / web search suggestions in Start menu. <RunSynchronousCommand wcm:action="add">
BingSearchEnabled is documented at HKCU\Software\Microsoft\Windows\CurrentVersion\Search <Order>34</Order>
(not HKLM), so the old HKLM policy placement was a no-op. The current documented <Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path>
suppression is DisableSearchBoxSuggestions=1 at Software\Policies\Microsoft\Windows\Explorer. <Description>Disable Bing/web search suggestions (Default User hive)</Description>
Writing it into the Default User hive so every newly-created account inherits it. --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>34</Order> <!-- Disable Search Web when searching Windows -->
<Path>cmd /c reg load HKU\TempDU C:\Users\Default\NTUSER.DAT &amp; reg add "HKU\TempDU\Software\Policies\Microsoft\Windows\Explorer" /v DisableSearchBoxSuggestions /t REG_DWORD /d 1 /f &amp; reg unload HKU\TempDU</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Bing/web search suggestions (Default User hive)</Description> <Order>35</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path>
<Description>Disable Connected Search Use Web</Description>
<!-- Disable Search Web when searching Windows --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>35</Order> <!-- Disable Cortana Speech Recognition -->
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v ConnectedSearchUseWeb /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Connected Search Use Web</Description> <Order>36</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path>
<Description>Disable Cortana Speech Recognition</Description>
<!-- Disable Cortana Speech Recognition --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>36</Order> <!-- Disable Search Highlights (news/trending) -->
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\InputPersonalization" /v AllowInputPersonalization /t REG_DWORD /d 0 /f</Path> <RunSynchronousCommand wcm:action="add">
<Description>Disable Cortana Speech Recognition</Description> <Order>38</Order>
</RunSynchronousCommand> <Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path>
<Description>Disable Search Highlights</Description>
<!-- Disable Search Highlights (news/trending) --> </RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add"> </RunSynchronous>
<Order>38</Order> </component>
<Path>cmd /c reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v EnableDynamicContentInWSB /t REG_DWORD /d 0 /f</Path> </settings>
<Description>Disable Search Highlights</Description>
</RunSynchronousCommand> <!-- 4. oobeSystem: hide OEM/EULA screens -->
</RunSynchronous> <settings pass="oobeSystem">
</component> <component name="Microsoft-Windows-Shell-Setup"
</settings> processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
<!-- 4. oobeSystem: hide OEM/EULA screens --> language="neutral"
<settings pass="oobeSystem"> versionScope="nonSxS">
<component name="Microsoft-Windows-Shell-Setup" <OOBE>
processorArchitecture="amd64" <HideEULAPage>true</HideEULAPage>
publicKeyToken="31bf3856ad364e35" <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
language="neutral" <HideOnlineAccountScreens>false</HideOnlineAccountScreens>
versionScope="nonSxS"> <HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
<OOBE> <HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideEULAPage>true</HideEULAPage> <NetworkLocation>Work</NetworkLocation>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen> <ProtectYourPC>3</ProtectYourPC>
<HideOnlineAccountScreens>false</HideOnlineAccountScreens> <SkipUserOOBE>false</SkipUserOOBE>
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE> <SkipMachineOOBE>false</SkipMachineOOBE>
<HideLocalAccountScreen>true</HideLocalAccountScreen> </OOBE>
<NetworkLocation>Work</NetworkLocation> <FirstLogonCommands>
<ProtectYourPC>3</ProtectYourPC> <SynchronousCommand wcm:action="add">
<SkipUserOOBE>false</SkipUserOOBE> <Order>1</Order>
<SkipMachineOOBE>false</SkipMachineOOBE> <CommandLine>shutdown -a</CommandLine>
</OOBE> <Description>Cancel any scheduled shutdown from Office installation</Description>
<FirstLogonCommands> </SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>1</Order> <!-- Install Zscaler -->
<CommandLine>shutdown -a</CommandLine> <SynchronousCommand wcm:action="add">
<Description>Cancel any scheduled shutdown from Office installation</Description> <Order>2</Order>
</SynchronousCommand> <CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine>
<Description>Install Zscaler Client Connector</Description>
<!-- Install Zscaler --> </SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>2</Order> <!-- Install Microsoft Office -->
<CommandLine>C:\Deploy\Applications\extra\zscaler\zscaler.bat</CommandLine> <SynchronousCommand wcm:action="add">
<Description>Install Zscaler Client Connector</Description> <Order>3</Order>
</SynchronousCommand> <CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ &amp;&amp; install.bat"</CommandLine>
<Description>Install Microsoft Office</Description>
<!-- Install Microsoft Office --> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> </FirstLogonCommands>
<Order>3</Order> </component>
<CommandLine>cmd /c "cd /d C:\Deploy\Applications\extra\office\ &amp;&amp; install.bat"</CommandLine> </settings>
<Description>Install Microsoft Office</Description>
</SynchronousCommand> </unattend>
</FirstLogonCommands>
</component>
</settings>
</unattend>

View File

@@ -80,10 +80,10 @@ echo " IFACE=$IFACE, bringing up..."
ip link set "$IFACE" up || ifconfig "$IFACE" up ip link set "$IFACE" up || ifconfig "$IFACE" up
sleep 2 sleep 2
SERVER=10.9.100.1 SERVER=172.16.9.1
ifconfig "$IFACE" 10.9.100.250 netmask 255.255.255.0 up ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up
sleep 1 sleep 1
echo " IP: 10.9.100.250 SERVER: $SERVER" echo " IP: 172.16.9.250 SERVER: $SERVER"
ip addr ip addr
echo "[3/5] Downloading airootfs.sfs (~756 MB)..." echo "[3/5] Downloading airootfs.sfs (~756 MB)..."

View File

@@ -176,7 +176,7 @@
<username encrypted="false">blancco</username> <username encrypted="false">blancco</username>
<password encrypted="false">blancco</password> <password encrypted="false">blancco</password>
<domain/> <domain/>
<hostname>10.9.100.1</hostname> <hostname>172.16.9.1</hostname>
<path>blancco-reports</path> <path>blancco-reports</path>
<protocols key="protocol" type="array"> <protocols key="protocol" type="array">
<protocol selected="true">smb</protocol> <protocol selected="true">smb</protocol>

View File

@@ -3,16 +3,16 @@
# Previously this disabled all wired NICs at first logon to keep PPKG / # Previously this disabled all wired NICs at first logon to keep PPKG /
# Intune enrollment routing internet traffic via WiFi. The wired NIC was # Intune enrollment routing internet traffic via WiFi. The wired NIC was
# preferred by Windows because the PXE dnsmasq was handing out a default # preferred by Windows because the PXE dnsmasq was handing out a default
# gateway (dhcp-option=3,10.9.100.1) which Windows installed as a default # gateway (dhcp-option=3,172.16.9.1) which Windows installed as a default
# route, and the lower interface metric of wired beat WiFi. Internet-bound # route, and the lower interface metric of wired beat WiFi. Internet-bound
# traffic then black-holed at 10.9.100.1 (the PXE server, which doesn't # traffic then black-holed at 172.16.9.1 (the PXE server, which doesn't
# forward). # forward).
# #
# That root cause was fixed by removing the dhcp-option=3 and =6 lines # That root cause was fixed by removing the dhcp-option=3 and =6 lines
# from /etc/dnsmasq.conf on the PXE server. Without an advertised gateway # from /etc/dnsmasq.conf on the PXE server. Without an advertised gateway
# on the PXE side, Windows can't add a default route via wired, so all # on the PXE side, Windows can't add a default route via wired, so all
# internet traffic uses WiFi by default and the wired NIC stays harmless # internet traffic uses WiFi by default and the wired NIC stays harmless
# for same-subnet PXE/SMB traffic to 10.9.100.1. # for same-subnet PXE/SMB traffic to 172.16.9.1.
# #
# Side effect of the original behavior was an eDNC race: eDNC autostart # Side effect of the original behavior was an eDNC race: eDNC autostart
# would fire while the wired NIC was still disabled and hit WSAEINVAL # would fire while the wired NIC was still disabled and hit WSAEINVAL

View File

@@ -3,17 +3,28 @@
"Site": "West Jefferson", "Site": "West Jefferson",
"Applications": [ "Applications": [
{ {
"_comment": "Oracle Client 11.2 Administrator - installed first because downstream apps (eDNC/NTLARS/UDC and CMM tooling) link against the Oracle home and fail cold if it's missing. Installer is a .cmd wrapper (Type=EXE is the preinstall runner's shim for non-MSI launchers, same pattern as OpenText Setup-OpenText.cmd). The wrapper expects Oracle_OracleDatabase_11r2_V03.zip (686 MB) staged next to it, unpacks to %TEMP%, runs Oracle Universal Installer silently with ge_client_install.rsp, then cleans up the staging dir. OUI exit 3 is treated as success (warnings-but-ok). Detection via the registered home key; downstream upgrades or version pins are handled by the runtime enforcer's Oracle Client 11.2 manifest entry in common/manifest.json.", "_comment": "PowerShell 7.5.4 - installed BEFORE PPKG via FlatUnattendW10-shopfloor.xml FirstLogonCommand Order 6 (race fix: Intune SetupCredentials Win32App install command starts with pwsh.exe; if PS7 not yet installed when that Win32App fires, it errors with FILE_NOT_FOUND 0x80070002 and IME's GRS retry never re-fires under V3Processor). This entry is a backstop - no-op via ProductCode detection if unattend Order 6 already installed it. PreEnrollment flag is informational; runner does not currently filter on it.",
"Name": "PowerShell 7.5.4",
"Installer": "powershell7\\PowerShell-7.5.4-win-x64.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{E8159677-ACF8-4D64-9D36-5C36B8BBEA39}",
"PreEnrollment": true,
"PCTypes": ["*"]
},
{
"_comment": "Oracle Client 11.2 Administrator - installed first because downstream apps (eDNC/NTLARS/UDC and CMM tooling) link against the Oracle home and fail cold if it's missing. Installer is a .cmd wrapper (Type=EXE is the preinstall runner's shim for non-MSI launchers, same pattern as OpenText Setup-OpenText.cmd). The wrapper expects Oracle_OracleDatabase_11r2_V03.zip (686 MB) staged next to it, unpacks to %TEMP%, runs Oracle Universal Installer silently with ge_client_install.rsp, then cleans up the staging dir. OUI exit 3 is treated as success (warnings-but-ok). Detection via the registered home key; downstream upgrades or version pins are handled by the runtime enforcer's Oracle Client 11.2 manifest entry in common/manifest.json. Scoped to the DNC-bearing PC types (collections, nocollections, partmarker, heattreat) plus CMM, whose metrology tooling links the Oracle home; non-DNC types (Genspect, Keyence, WaxAndTrace, Display, Timeclock, Lab) do not get it.",
"Name": "Oracle Client 11.2", "Name": "Oracle Client 11.2",
"Installer": "oracle\\Install-Oracle11r2.cmd", "Installer": "oracle\\Install-Oracle11r2.cmd",
"Type": "EXE", "Type": "EXE",
"InstallArgs": "", "InstallArgs": "",
"LogFile": "C:\\Logs\\OracleClient\\install.log", "LogFile": "C:\\Logs\\OracleClient\\install.log",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Oracle\\KEY_OraClient11g_home1", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Oracle\\KEY_OraClient11g_home1",
"DetectionName": "ORACLE_HOME_NAME", "DetectionName": "ORACLE_HOME_NAME",
"DetectionValue": "OraClient11g_home1", "DetectionValue": "OraClient11g_home1",
"PCTypes": ["Standard", "CMM", "Genspect", "Keyence", "WaxAndTrace", "Display"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-partmarker", "gea-shopfloor-heattreat", "gea-shopfloor-cmm"]
}, },
{ {
"_comment": "VC++ 2008 SP1 x86 - the bootstrapper (vcredist2008_x86.exe) ignores /norestart and triggers an immediate Windows reboot when files are in use (per Aaron Stebner's MSDN docs). Fix: install the extracted vc_red.msi directly with REBOOT=ReallySuppress, which IS hard-honored by Windows Installer. msiexec may return 3010 (would-have-rebooted-but-suppressed) but won't actually reboot. cab name 'vc_red.cab' is hardcoded in the MSI's Media table - do not rename.", "_comment": "VC++ 2008 SP1 x86 - the bootstrapper (vcredist2008_x86.exe) ignores /norestart and triggers an immediate Windows reboot when files are in use (per Aaron Stebner's MSDN docs). Fix: install the extracted vc_red.msi directly with REBOOT=ReallySuppress, which IS hard-honored by Windows Installer. msiexec may return 3010 (would-have-rebooted-but-suppressed) but won't actually reboot. cab name 'vc_red.cab' is hardcoded in the MSI's Media table - do not rename.",
@@ -23,7 +34,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9BE518E6-ECC6-35A9-88E4-87755C07200F}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{9BE518E6-ECC6-35A9-88E4-87755C07200F}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2010 x86 - same fix as 2008. Bootstrapper ignores /norestart; extracted MSI with REBOOT=ReallySuppress does not.", "_comment": "VC++ 2010 x86 - same fix as 2008. Bootstrapper ignores /norestart; extracted MSI with REBOOT=ReallySuppress does not.",
@@ -33,7 +44,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F0C3E5D1-1ADE-321E-8167-68EF0DE699A5}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F0C3E5D1-1ADE-321E-8167-68EF0DE699A5}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2012 x86 Minimum Runtime - extracted from vcredist2012_x86.exe Burn bundle. Same REBOOT=ReallySuppress fix.", "_comment": "VC++ 2012 x86 Minimum Runtime - extracted from vcredist2012_x86.exe Burn bundle. Same REBOOT=ReallySuppress fix.",
@@ -43,7 +54,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BD95A8CD-1D9F-35AD-981A-3E7925026EBB}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BD95A8CD-1D9F-35AD-981A-3E7925026EBB}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"Name": "VC++ Redistributable 2012 x86 (Additional)", "Name": "VC++ Redistributable 2012 x86 (Additional)",
@@ -52,7 +63,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B175520C-86A2-35A7-8619-86DC379688B9}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B175520C-86A2-35A7-8619-86DC379688B9}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2013 x86 Minimum Runtime - extracted from vcredist2013_x86.exe Burn bundle.", "_comment": "VC++ 2013 x86 Minimum Runtime - extracted from vcredist2013_x86.exe Burn bundle.",
@@ -62,7 +73,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{13A4EE12-23EA-3371-91EE-EFB36DDFFF3E}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{13A4EE12-23EA-3371-91EE-EFB36DDFFF3E}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"Name": "VC++ Redistributable 2013 x86 (Additional)", "Name": "VC++ Redistributable 2013 x86 (Additional)",
@@ -71,7 +82,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F8CFEB22-A2E7-3971-9EDA-4B11EDEFC185}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{F8CFEB22-A2E7-3971-9EDA-4B11EDEFC185}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2013 x64 Minimum Runtime - required by Keyence VR-6000 Series Software. Extracted from the Keyence installer's Windows Installer cache. Same REBOOT=ReallySuppress pattern as the x86 variants.", "_comment": "VC++ 2013 x64 Minimum Runtime - required by Keyence VR-6000 Series Software. Extracted from the Keyence installer's Windows Installer cache. Same REBOOT=ReallySuppress pattern as the x86 variants.",
@@ -81,7 +92,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{A749D8E6-B613-3BE3-8F5F-045C84EBA29B}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{A749D8E6-B613-3BE3-8F5F-045C84EBA29B}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2013 x64 Additional Runtime - required by Keyence VR-6000 Series Software. Pairs with the Minimum Runtime above.", "_comment": "VC++ 2013 x64 Additional Runtime - required by Keyence VR-6000 Series Software. Pairs with the Minimum Runtime above.",
@@ -91,7 +102,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{929FBD26-9020-399B-9A7A-751D61F0B942}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{929FBD26-9020-399B-9A7A-751D61F0B942}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2010 x64 - required by PC-DMIS 2016/2019 R2 on CMM PCs. PCDLRN.exe links against msvcr100.dll and the VS 2010 MFC DLLs which are only provided by this redistributable. Extracted from the PC-DMIS 2016 bundle's attached container (a1 payload). Silent install: /q /norestart. Detection: Uninstall key under the native x64 hive with fixed product GUID.", "_comment": "VC++ 2010 x64 - required by PC-DMIS 2016/2019 R2 on CMM PCs. PCDLRN.exe links against msvcr100.dll and the VS 2010 MFC DLLs which are only provided by this redistributable. Extracted from the PC-DMIS 2016 bundle's attached container (a1 payload). Silent install: /q /norestart. Detection: Uninstall key under the native x64 hive with fixed product GUID.",
@@ -103,7 +114,7 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1D8E6291-B0D5-35EC-8441-6616F567A0F7}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{1D8E6291-B0D5-35EC-8441-6616F567A0F7}",
"DetectionName": "DisplayVersion", "DetectionName": "DisplayVersion",
"DetectionValue": "10.0.40219", "DetectionValue": "10.0.40219",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2012 x64 - required by PC-DMIS 2016/2019 R2. Same rationale as 2010 x64; PC-DMIS links against msvcr110.dll / MFC110. Extracted from the PC-DMIS 2016 bundle's attached container (a2 payload). Detection on the Minimum Runtime GUID (the main redist wrapper installs both Minimum and Additional sub-packages).", "_comment": "VC++ 2012 x64 - required by PC-DMIS 2016/2019 R2. Same rationale as 2010 x64; PC-DMIS links against msvcr110.dll / MFC110. Extracted from the PC-DMIS 2016 bundle's attached container (a2 payload). Detection on the Minimum Runtime GUID (the main redist wrapper installs both Minimum and Additional sub-packages).",
@@ -115,7 +126,7 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5AF4E09F-5C9B-3AAF-B731-544D3DC821DD}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5AF4E09F-5C9B-3AAF-B731-544D3DC821DD}",
"DetectionName": "DisplayVersion", "DetectionName": "DisplayVersion",
"DetectionValue": "11.0.51106", "DetectionValue": "11.0.51106",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "VC++ 2015-2022 x86 - extracted from vcredist2015_2017_2019_2022_x86.exe Burn bundle. The bundle contains 2022 14.44.35211 plus 8 chained KB updates for older 2015/2017/2019 releases. We install only the 2022 Min+Add MSIs - the CRT v140 ABI is shared across 2015/2017/2019/2022, so the latest pair covers all four versions on Windows 10/11.", "_comment": "VC++ 2015-2022 x86 - extracted from vcredist2015_2017_2019_2022_x86.exe Burn bundle. The bundle contains 2022 14.44.35211 plus 8 chained KB updates for older 2015/2017/2019 releases. We install only the 2022 Min+Add MSIs - the CRT v140 ABI is shared across 2015/2017/2019/2022, so the latest pair covers all four versions on Windows 10/11.",
@@ -125,7 +136,7 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{922480B5-CAEB-4B1B-AAA4-9716EFDCE26B}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{922480B5-CAEB-4B1B-AAA4-9716EFDCE26B}",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"Name": "VC++ Redistributable 2022 x86 (Additional)", "Name": "VC++ Redistributable 2022 x86 (Additional)",
@@ -134,6 +145,16 @@
"InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1", "InstallArgs": "/qn /norestart REBOOT=ReallySuppress NOVSUI=1 USING_EXUIH_SILENT=1",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{C18FB403-1E88-43C8-AD8A-CED50F23DE8B}", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{C18FB403-1E88-43C8-AD8A-CED50F23DE8B}",
"PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
},
{
"_comment": "VC++ 2015-2022 x64 - required by PC-DMIS 2026 (PCDLRN.exe links vcruntime140_1.dll, added in VC++ 2019 / v14.20). The x64 redist line above previously stopped at 2013, so 64-bit PC-DMIS 2026 was missing its runtime and PCDLRN.exe failed to start with 'vcruntime140_1.dll was not found'. The patched PC-DMIS MSI bypasses Hexagon's Burn bundle, which is what would otherwise have pulled this in. Full vc_redist.x64.exe (14.44.35211, 2015-2022 shared CRT). Modern bootstrapper honors /norestart. Detect on the DLL itself so it is version-independent.",
"Name": "VC++ Redistributable 2015-2022 x64",
"Installer": "vcredist/2022-x64/vc_redist.x64.exe",
"Type": "EXE",
"InstallArgs": "/install /quiet /norestart",
"DetectionMethod": "File",
"DetectionPath": "C:\\Windows\\System32\\vcruntime140_1.dll",
"PCTypes": ["*"] "PCTypes": ["*"]
}, },
{ {
@@ -144,7 +165,7 @@
"InstallArgs": "", "InstallArgs": "",
"DetectionMethod": "File", "DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe", "DetectionPath": "C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "OpenText HostExplorer 15 SP1 ShopFloor - replaced the WJDT-built OpenText.exe Inno wrapper with our own Setup-OpenText.ps1 that does the same install steps (msiexec base + ShopFloor.mst transform + SP1 .msp patch) PLUS fans the per-user profile/keymap/menu/macro content out to Default User and every existing user profile. The Inno wrapper deployed per-user content to {userappdata} which resolves to whichever user is running the installer (SYSTEM under DSC, single user under PreInstall) so Azure-AD users never saw the profiles. Setup-OpenText.cmd is a tiny launcher that hands off to Setup-OpenText.ps1 because the runner only handles MSI/EXE types. All bundled files live in vcredist-style subtree at dependencies/opentext/ and get xcopied through WinPE staging. NO DetectionMethod here on purpose - Setup-OpenText.ps1 owns its own version check by reading version.txt next to itself and comparing to HKLM:\\SOFTWARE\\GE\\OpenText\\Installed; the runner always invokes the wrapper (cost: ~1s of PowerShell launch when up-to-date) so the version constant lives in exactly one place: dependencies/opentext/version.txt.", "_comment": "OpenText HostExplorer 15 SP1 ShopFloor - replaced the WJDT-built OpenText.exe Inno wrapper with our own Setup-OpenText.ps1 that does the same install steps (msiexec base + ShopFloor.mst transform + SP1 .msp patch) PLUS fans the per-user profile/keymap/menu/macro content out to Default User and every existing user profile. The Inno wrapper deployed per-user content to {userappdata} which resolves to whichever user is running the installer (SYSTEM under DSC, single user under PreInstall) so Azure-AD users never saw the profiles. Setup-OpenText.cmd is a tiny launcher that hands off to Setup-OpenText.ps1 because the runner only handles MSI/EXE types. All bundled files live in vcredist-style subtree at dependencies/opentext/ and get xcopied through WinPE staging. NO DetectionMethod here on purpose - Setup-OpenText.ps1 owns its own version check by reading version.txt next to itself and comparing to HKLM:\\SOFTWARE\\GE\\OpenText\\Installed; the runner always invokes the wrapper (cost: ~1s of PowerShell launch when up-to-date) so the version constant lives in exactly one place: dependencies/opentext/version.txt.",
@@ -153,7 +174,7 @@
"Type": "EXE", "Type": "EXE",
"InstallArgs": "", "InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log", "LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log",
"PCTypes": ["Standard", "CMM", "Keyence", "Genspect", "WaxAndTrace", "Lab"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).", "_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).",
@@ -164,7 +185,9 @@
"KillAfterDetection": true, "KillAfterDetection": true,
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC",
"PCTypes": ["Standard-Machine"] "PCTypes": ["gea-shopfloor-collections"],
"PCTypesStrict": true,
"_pcTypesNote": "UDC = the C in 'collections'. nocollections does NOT collect data so MUST NOT install UDC. PCTypesStrict bypasses the alias-expansion matcher so a nocollections PC's myNames (which transitively contains gea-shopfloor-collections via the Standard group) still won't match this entry."
}, },
{ {
"_comment": "Display kiosk app (Lobby Display or Dashboard). Install-KioskApp.cmd wrapper reads C:\\Enrollment\\display-type.txt to determine which installer to run. Both GEAerospaceLobbyDisplaySetup.exe and GEAerospaceDashboardSetup.exe must be staged in the display\\ subtree alongside the wrapper. Inno Setup /VERYSILENT is idempotent so no detection needed.", "_comment": "Display kiosk app (Lobby Display or Dashboard). Install-KioskApp.cmd wrapper reads C:\\Enrollment\\display-type.txt to determine which installer to run. Both GEAerospaceLobbyDisplaySetup.exe and GEAerospaceDashboardSetup.exe must be staged in the display\\ subtree alongside the wrapper. Inno Setup /VERYSILENT is idempotent so no detection needed.",
@@ -183,10 +206,10 @@
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress TARGETDIR=\"C:\\Program Files (x86)\\WJF_Defect_Tracker\"", "InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress TARGETDIR=\"C:\\Program Files (x86)\\WJF_Defect_Tracker\"",
"DetectionMethod": "File", "DetectionMethod": "File",
"DetectionPath": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe", "DetectionPath": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe",
"PCTypes": ["*"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
}, },
{ {
"_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Scoped to Standard PCs (both Machine + Timeclock) because the PCTypes filter is type-level only; installing a serial driver on a Timeclock without the hardware is harmless - it just sits in the driver store.", "_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Installed on every PC type (PCTypes ['*']) because serial hardware turns up across bays; a serial driver on a PC without the hardware is harmless - it just sits in the driver store until matching hardware is plugged in.",
"Name": "Shopfloor Serial Drivers", "Name": "Shopfloor Serial Drivers",
"Installer": "drivers\\Install-Drivers.cmd", "Installer": "drivers\\Install-Drivers.cmd",
"Type": "EXE", "Type": "EXE",
@@ -194,7 +217,7 @@
"LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log", "LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log",
"DetectionMethod": "File", "DetectionMethod": "File",
"DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker", "DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker",
"PCTypes": ["Standard"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-common", "gea-shopfloor-cmm", "gea-shopfloor-genspect", "gea-shopfloor-heattreat", "gea-shopfloor-keyence", "gea-shopfloor-partmarker", "gea-shopfloor-waxtrace"]
} }
] ]
} }

View File

@@ -3,7 +3,7 @@
# pxe-dhcp-hook.sh - dnsmasq dhcp-script hook. # pxe-dhcp-hook.sh - dnsmasq dhcp-script hook.
# #
# Runs every time a PXE client gets/changes/releases a DHCP lease on # Runs every time a PXE client gets/changes/releases a DHCP lease on
# 10.9.100.0/24. Flushes conntrack entries and drops any lingering # 172.16.9.0/24. Flushes conntrack entries and drops any lingering
# TCP sockets for that client IP. Prevents stale server-side state from # TCP sockets for that client IP. Prevents stale server-side state from
# causing "System error 53 - network path not found" when a WinPE client # causing "System error 53 - network path not found" when a WinPE client
# re-images the same machine without a clean SMB session teardown. # re-images the same machine without a clean SMB session teardown.

View File

@@ -14,7 +14,7 @@
# Step 2: restart nmbd (NetBIOS daemon - separate from smbd) # Step 2: restart nmbd (NetBIOS daemon - separate from smbd)
# Step 3: restart smbd (full smbd restart, kills all child sessions) # Step 3: restart smbd (full smbd restart, kills all child sessions)
# Step 4: kill any leftover smbd child processes that survived restart # Step 4: kill any leftover smbd child processes that survived restart
# Step 5: flush conntrack for 10.9.100.0/24 (kernel connection tracking) # Step 5: flush conntrack for 172.16.9.0/24 (kernel connection tracking)
# Step 6: flush ARP / neighbour cache on br-pxe # Step 6: flush ARP / neighbour cache on br-pxe
# Step 7: drop TCP sockets on port 445 via ss -K # Step 7: drop TCP sockets on port 445 via ss -K
# Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot) # Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot)
@@ -56,10 +56,10 @@ sleep 1
systemctl start smbd 2>&1 systemctl start smbd 2>&1
pause "Step 4 done" pause "Step 4 done"
echo "=== Step 5/8: flush conntrack entries for 10.9.100.0/24 ===" echo "=== Step 5/8: flush conntrack entries for 172.16.9.0/24 ==="
if command -v conntrack >/dev/null 2>&1; then if command -v conntrack >/dev/null 2>&1; then
conntrack -D -s 10.9.100.0/24 2>&1 || true conntrack -D -s 172.16.9.0/24 2>&1 || true
conntrack -D -d 10.9.100.0/24 2>&1 || true conntrack -D -d 172.16.9.0/24 2>&1 || true
else else
echo " conntrack tool not installed - skipping (apt install conntrack)" echo " conntrack tool not installed - skipping (apt install conntrack)"
fi fi

View File

@@ -38,10 +38,6 @@
- gea-standard - gea-standard
- gea-engineer - gea-engineer
- gea-shopfloor - gea-shopfloor
- ge-standard
- ge-engineer
- ge-shopfloor-lockdown
- ge-shopfloor-mce
shopfloor_types: shopfloor_types:
- gea-shopfloor - gea-shopfloor
# Image variants that share the generic Win10/Win11 unattend # Image variants that share the generic Win10/Win11 unattend
@@ -50,8 +46,6 @@
standard_types: standard_types:
- gea-standard - gea-standard
- gea-engineer - gea-engineer
- ge-standard
- ge-engineer
deploy_subdirs: deploy_subdirs:
- Applications - Applications
- Control - Control
@@ -72,7 +66,7 @@
loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}" loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
ignore_errors: yes ignore_errors: yes
- name: "Find interface with 10.9.100.1 already configured" - name: "Find interface with 172.16.9.1 already configured"
set_fact: set_fact:
preconfigured_iface: >- preconfigured_iface: >-
{{ ansible_interfaces {{ ansible_interfaces
@@ -80,7 +74,7 @@
| map('regex_replace','^(.*)$','ansible_\1') | map('regex_replace','^(.*)$','ansible_\1')
| map('extract', hostvars[inventory_hostname]) | map('extract', hostvars[inventory_hostname])
| selectattr('ipv4','defined') | selectattr('ipv4','defined')
| selectattr('ipv4.address','equalto','10.9.100.1') | selectattr('ipv4.address','equalto','172.16.9.1')
| map(attribute='device') | map(attribute='device')
| list | list
| first | first
@@ -145,13 +139,13 @@
backup: yes backup: yes
content: | content: |
port=0 port=0
interface={{ pxe_iface }} listen-address=172.16.9.1
bind-interfaces bind-dynamic
dhcp-range=10.9.100.10,10.9.100.100,12h dhcp-range=172.16.9.10,172.16.9.100,12h
# No default gateway (option 3) and no DNS (option 6) handed out: # No default gateway (option 3) and no DNS (option 6) handed out:
# the PXE network is isolated and the PXE server does not forward # the PXE network is isolated and the PXE server does not forward
# internet traffic. Previously we set both, which made imaged PCs # internet traffic. Previously we set both, which made imaged PCs
# add a default route via 10.9.100.1 and prefer it over WiFi (lower # add a default route via 172.16.9.1 and prefer it over WiFi (lower
# interface metric). PPKG / Intune enrollment then black-holed # interface metric). PPKG / Intune enrollment then black-holed
# internet-bound traffic. The fix used to be migrate-to-wifi.ps1 # internet-bound traffic. The fix used to be migrate-to-wifi.ps1
# disabling the wired NIC during first-logon, which created an # disabling the wired NIC during first-logon, which created an
@@ -163,20 +157,24 @@
# Important: dnsmasq DEFAULTS to sending its own listening address as # Important: dnsmasq DEFAULTS to sending its own listening address as
# both router and DNS when these options are unset. Commenting them # both router and DNS when these options are unset. Commenting them
# out is NOT the same as disabling - imaged PCs (and Blancco PXE # out is NOT the same as disabling - imaged PCs (and Blancco PXE
# clients) end up with 10.9.100.1 as gateway. The empty-value form # clients) end up with 172.16.9.1 as gateway. The empty-value form
# below explicitly suppresses both options. # below explicitly suppresses both options.
dhcp-option=3 dhcp-option=3
dhcp-option=6 dhcp-option=6
enable-tftp enable-tftp
tftp-root={{ tftp_dir }} tftp-root={{ tftp_dir }}
# Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run # Arch-aware NBP for Blancco/WinPE PXE clients:
# the EFI iPXE binary and report "NBP is too big to fit in free # - legacy BIOS (vendorclass PXEClient:Arch:00000) -> ipxe.pxe, a
# base memory" because ipxe.efi (~675KB) exceeds the BIOS PXE # BIOS full-feature iPXE build (boot.ipxe.org) with an HTTP/TCP
# NBP cap. Serve undionly.kpxe (~70KB, BIOS-mode iPXE) to them # stack. The old undionly.kpxe client-arch=0 scheme was stale -
# instead. Everything else (UEFI x86_64 = arch 7 or 9, plus any # undionly.kpxe was never staged in tftp-root so it broke BIOS
# future arches) keeps getting ipxe.efi - default-safe. # boot; this vendorclass+ipxe.pxe scheme is what runs on the box.
dhcp-match=set:bios,option:client-arch,0 # - iPXE-running clients (userclass "iPXE") -> chain to the HTTP menu
dhcp-boot=tag:bios,undionly.kpxe # - everything else (UEFI x86_64) -> ipxe.efi
dhcp-vendorclass=set:bios,PXEClient:Arch:00000
dhcp-userclass=set:ipxe,iPXE
dhcp-boot=tag:bios,tag:!ipxe,ipxe.pxe
dhcp-boot=tag:ipxe,http://172.16.9.1:4433/Altiris/iPXE/GetPxeScript.aspx
dhcp-boot=tag:!bios,ipxe.efi dhcp-boot=tag:!bios,ipxe.efi
log-dhcp log-dhcp
# Per-lease state cleanup: flush conntrack + port-445 sockets for # Per-lease state cleanup: flush conntrack + port-445 sockets for
@@ -227,7 +225,7 @@
content: | content: |
#!ipxe #!ipxe
set server 10.9.100.1 set server 172.16.9.1
:menu :menu
menu GE Aerospace PXE Boot Menu menu GE Aerospace PXE Boot Menu
@@ -265,6 +263,30 @@
boot boot
:blancco :blancco
iseq ${platform} pcbios && goto blancco_bios || goto blancco_efi
:blancco_bios
echo Loading Blancco for legacy BIOS (canonical)...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initramfs-bde-linux.img,config.img,intel-ucode.img,amd-ucode.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 systemd.battery-check=0 || goto blancco_bios_failed
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_bios_failed
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_bios_failed
initrd --name config.img http://${server}/blancco/config.img || goto blancco_bios_failed
initrd --name initramfs-bde-linux.img http://${server}/blancco/initramfs-bde-linux.img || goto blancco_bios_failed
boot
:blancco_bios_failed
echo
echo Blancco BIOS boot FAILED.
prompt Press any key to return to menu... && goto menu
:blancco_efi
echo Loading Blancco UEFI (direct kernel)...
kernel http://${server}/blancco/vmlinuz-ubuntu initrd=kexec-initrd.img ip=dhcp nomodeset vga=normal console=tty0 i915.modeset=0 nouveau.modeset=0 modprobe.blacklist=nouveau,nvidiafb,nvidia,nvidia_drm,mei_me,btusb,dcdbas libata.allow_tpm=1 rd.udev.timeout=10 efi=noruntime usbcore.autosuspend=-1 pcie_aspm=off e1000e.IntMode=0 e1000e.eee_enable=0 consoleblank=0 systemd.battery-check=0 systemd.mask=suspend.target,sleep.target,hibernate.target,hybrid-sleep.target no_console_suspend mem_sleep_default=s2idle || goto blancco_efi_grub
initrd --name intel-ucode.img http://${server}/blancco/intel-ucode.img || goto blancco_efi_grub
initrd --name amd-ucode.img http://${server}/blancco/amd-ucode.img || goto blancco_efi_grub
initrd --name kexec-initrd.img http://${server}/blancco/kexec-initrd.img || goto blancco_efi_grub
boot
:blancco_efi_grub
echo Direct kernel boot failed - trying GRUB chain fallback...
chain http://${server}/blancco/grubx64.efi || goto secureboot_warn chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
:memtest :memtest
@@ -503,9 +525,17 @@
state: directory state: directory
mode: '0755' mode: '0755'
- name: "Ensure driver store is owned by pxe (download-drivers.py pushes here over ssh as the pxe user; root/webapp still write it fine)"
file:
path: "/srv/samba/winpeapps/_shared/Out-of-box Drivers"
state: directory
owner: pxe
group: pxe
mode: '0775'
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/" - name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
# Path matches what startnet.cmd reads at WinPE boot: # Path matches what startnet.cmd reads at WinPE boot:
# net use B: \\10.9.100.1\winpeapps\_shared # net use B: \\172.16.9.1\winpeapps\_shared
# if exist B:\BIOS\check-bios.cmd ... # if exist B:\BIOS\check-bios.cmd ...
# Earlier deploy targeted enrollment/pre-install/bios/ (different share) # Earlier deploy targeted enrollment/pre-install/bios/ (different share)
# which startnet.cmd never read, so BIOS_STATUS perma-stuck on # which startnet.cmd never read, so BIOS_STATUS perma-stuck on
@@ -555,6 +585,10 @@
follow symlinks = yes follow symlinks = yes
wide links = yes wide links = yes
unix extensions = no unix extensions = no
# Modern Samba silently disables wide links WITHOUT this. The shared
# image dirs are served via symlinks into _shared, so it is required
# or WinPE clients cannot follow them.
allow insecure wide links = yes
- name: "Samba SMB session handling for WinPE re-image robustness" - name: "Samba SMB session handling for WinPE re-image robustness"
blockinfile: blockinfile:
@@ -571,7 +605,11 @@
# the short-lived flows that PXE imaging produces. # the short-lived flows that PXE imaging produces.
socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY
keepalive = 30 keepalive = 30
deadtime = 5 # deadtime=0 (disabled): WinPE maps the enrollment share early then
# idles for minutes during the WIM apply. A non-zero deadtime drops
# that idle session, so the post-apply staging copies failed (bay
# left with only site-config.json). 0 = never auto-disconnect idle.
deadtime = 0
- name: "Configure Samba shares" - name: "Configure Samba shares"
blockinfile: blockinfile:
@@ -626,6 +664,14 @@
level2 oplocks = no level2 oplocks = no
strict sync = yes strict sync = yes
[winpeapps_bios]
path = /srv/samba/winpeapps/_shared/BIOS
browseable = no
read only = yes
guest ok = no
valid users = pxe-upload
comment = Dell BIOS update bundle (read-only, served to WinPE)
- name: "Create Samba users (pxe-upload and blancco)" - name: "Create Samba users (pxe-upload and blancco)"
shell: | shell: |
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
@@ -681,7 +727,11 @@
src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml" src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml" dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
mode: '0644' mode: '0644'
force: no # force: yes - repo is source of truth. force: no let the live shopfloor
# unattend go stale (missing the Fetch + Verify-And-Heal staging steps),
# and a playbook run never repaired it. Keep it in sync like the standard
# /engineer unattend below.
force: yes
loop: "{{ shopfloor_types }}" loop: "{{ shopfloor_types }}"
ignore_errors: yes ignore_errors: yes
@@ -899,7 +949,7 @@
shell: | shell: |
set -e set -e
python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")' python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")'
grep -q '<hostname>10.9.100.1</hostname>' "{{ web_root }}/blancco/preferences.xml" grep -q '<hostname>172.16.9.1</hostname>' "{{ web_root }}/blancco/preferences.xml"
grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml" grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml"
changed_when: false changed_when: false
@@ -1089,7 +1139,7 @@
# Single-NIC fresh-deploy default. Boxes that need higher throughput # Single-NIC fresh-deploy default. Boxes that need higher throughput
# (e.g. WJF prod uses a USB-C 5 Gbps NIC) override this with a bridge # (e.g. WJF prod uses a USB-C 5 Gbps NIC) override this with a bridge
# config bonding the USB NIC + onboard NIC into br-pxe. Live override # config bonding the USB NIC + onboard NIC into br-pxe. Live override
# currently deployed on 10.9.100.1 (do NOT re-run this task there # currently deployed on 172.16.9.1 (do NOT re-run this task there
# without first reviewing /etc/netplan/50-cloud-init.yaml.pre-gold-swap): # without first reviewing /etc/netplan/50-cloud-init.yaml.pre-gold-swap):
# #
# network: # network:
@@ -1101,7 +1151,7 @@
# bridges: # bridges:
# br-pxe: # br-pxe:
# interfaces: [enp128s31f6, enx34c8d6b11010] # interfaces: [enp128s31f6, enx34c8d6b11010]
# addresses: [10.9.100.1/24] # addresses: [172.16.9.1/24]
# parameters: # parameters:
# stp: false # stp: false
# #
@@ -1120,7 +1170,7 @@
ethernets: ethernets:
{{ pxe_iface }}: {{ pxe_iface }}:
dhcp4: no dhcp4: no
addresses: [10.9.100.1/24] addresses: [172.16.9.1/24]
notify: "Apply netplan" notify: "Apply netplan"
handlers: handlers:

170
playbook/scripts/preflight.ps1 Executable file
View File

@@ -0,0 +1,170 @@
# preflight.ps1 - check the things PESetup fails on, before it fails on them.
#
# Called by startnet.cmd once the media is mapped. Prints a short report a tech
# can read at the bay and exits non-zero if a check is fatal.
#
# WHY EACH CHECK IS HERE - all four come from PESetup's own behaviour
# (docs/PESETUP-INTERNALS.md, decompiled 4.0.0.17, observed on 4.0.0.20):
#
# Secure boot GatherDataSelection fails the step outright when
# SecurebootEnabled != 1. Hard failure, minutes into a build.
# Disk size MinRequiredSpaceWithoutCompression is 128849018880 (120 GB).
# Driver match GetDriverByModel returns null on a miss and PESetup logs a
# WARNING and keeps going. The bay images with no drivers, so no
# NIC, so DNS fails at first boot and enrollment cannot reach the
# CDN. The symptom appears far from the cause - this is the check
# that earns the script.
# Media age The media expires 30 days after build. PESetup shows days-left
# on a screen nobody reads.
#
# Lives on the enrollment share so it can be fixed without rebuilding boot.wim.
[CmdletBinding()]
param(
[string]$MediaDrive = 'Z:',
[int]$MinDiskGB = 120,
[int]$MediaWarnDays = 25
)
$ErrorActionPreference = 'Continue'
$fatal = 0
$warn = 0
function Ok { param($m) Write-Host (" [ OK ] " + $m) }
function Warn { param($m) Write-Host (" [WARN] " + $m); $script:warn++ }
function Fail { param($m) Write-Host (" [FAIL] " + $m); $script:fatal++ }
Write-Host ""
Write-Host "======== Pre-imaging checks ========"
# --- 1. Secure boot ------------------------------------------------------
# Confirm-SecureBootUEFI is not always present in WinPE; read the state the
# firmware exposes instead.
try {
$sb = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' `
-Name UEFISecureBootEnabled -ErrorAction Stop
if ($sb.UEFISecureBootEnabled -eq 1) {
Ok "Secure boot enabled"
} else {
Fail "Secure boot is OFF. PESetup will fail at GatherData. Enable it in BIOS."
}
} catch {
Warn "Could not read secure boot state - if this is a legacy/CSM boot, PESetup will fail."
}
# --- 2. Disk size --------------------------------------------------------
try {
$disk = Get-CimInstance Win32_DiskDrive -ErrorAction Stop |
Where-Object { $_.MediaType -like '*Fixed*' } |
Sort-Object Index | Select-Object -First 1
if ($disk) {
$gb = [math]::Round($disk.Size / 1GB, 1)
if ($gb -ge $MinDiskGB) {
Ok ("Disk 0 is {0} GB ({1})" -f $gb, $disk.Model)
} else {
Fail ("Disk 0 is only {0} GB; PESetup needs {1} GB. ({2})" -f $gb, $MinDiskGB, $disk.Model)
}
} else {
Fail "No fixed disk found. PESetup has nothing to image."
}
} catch {
Warn "Could not enumerate disks: $_"
}
# --- 3. Driver match for THIS model --------------------------------------
# Reimplements GetDriverByModel: family filter first (it knows only Latitude,
# OptiPlex and Precision), then a substring test of comma-separated tokens,
# first match wins. Tokens are NOT trimmed, matching the C#.
try {
$model = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).Model
$catalogue = Join-Path $MediaDrive 'Deploy\Control\HardwareDriver.json'
if (-not (Test-Path $catalogue)) {
Warn "HardwareDriver.json not found at $catalogue - cannot check drivers."
} else {
# NOT ConvertFrom-Json. HardwareDriver.json carries both casings of the
# same fields - "fileName" and "FileName", "destinationDir" and
# "DestinationDir". Windows PowerShell 5.1 (which is what WinPE runs)
# treats object keys case-insensitively and throws:
# "cannot convert the JSON string because a dictionary that was
# converted from the string contains the duplicated keys 'FileName'
# and 'FileName'"
# -AsHashtable would handle it but that is PowerShell 6+. So pull the
# four fields we need out of each entry by text instead. Prefers the
# lowercase key, falls back to the capitalised one.
$raw = Get-Content $catalogue -Raw
$entries = @()
foreach ($chunk in ([regex]::Split($raw, '\}\s*,\s*\{'))) {
$get = {
param($names)
foreach ($n in $names) {
$m = [regex]::Match($chunk, '"' + $n + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
if ($m.Success) { return $m.Groups[1].Value -replace '\\\\', '\' }
}
return ''
}
$e = [pscustomobject]@{
modelswminame = (& $get @('modelswminame','models'))
family = (& $get @('family'))
fileName = (& $get @('fileName','FileName'))
destinationDir = (& $get @('destinationDir','DestinationDir'))
}
if ($e.modelswminame) { $entries += $e }
}
if (-not $entries.Count) {
Warn "Could not extract any entries from $catalogue - driver check skipped."
}
$family = ''
if ($model.ToUpper().Contains('LATITUDE')) { $family = 'Latitude' }
if ($model.ToUpper().Contains('OPTIPLEX')) { $family = 'Optiplex' }
if ($model.ToUpper().Contains('PRECISION')) { $family = 'Precision' }
$hit = $null
foreach ($e in $entries) {
if ($family -and ($e.family -notlike "*$family*")) { continue }
foreach ($tok in ([string]$e.modelswminame).Split(',')) {
if ($tok -and $model.ToLower().Contains($tok.ToLower())) { $hit = $e; break }
}
if ($hit) { break }
}
if ($hit) {
$zip = Join-Path $MediaDrive (([string]$hit.destinationDir) -replace '\*destinationdir\*\\?','')
$zip = Join-Path $zip $hit.fileName
if (Test-Path $zip) {
Ok ("Driver pack for '{0}': {1}" -f $model, $hit.fileName)
} else {
Fail ("Driver pack for '{0}' is listed but MISSING on the media: {1}" -f $model, $hit.fileName)
}
} else {
Fail ("NO driver pack matches '{0}'. PESetup logs this as a warning only - the bay will image with NO network drivers." -f $model)
}
}
} catch {
Warn "Driver check failed: $_"
}
# --- 4. Media age --------------------------------------------------------
# Approximate: PESetup expires media 30 days after build and Media.tag is
# rewritten when the media is rebuilt, so its timestamp is the best local proxy.
try {
$tag = Join-Path $MediaDrive 'Deploy\Control\Media.tag'
if (Test-Path $tag) {
$age = [int]((Get-Date) - (Get-Item $tag).LastWriteTime).TotalDays
if ($age -ge $MediaWarnDays) {
Warn ("Media is about {0} days old; it expires at 30. Rebuild it soon." -f $age)
} else {
Ok ("Media is about {0} days old" -f $age)
}
}
} catch { }
Write-Host "===================================="
if ($fatal -gt 0) {
Write-Host ""
Write-Host " $fatal BLOCKING problem(s) found. Imaging this bay will not work."
Write-Host ""
exit 1
}
if ($warn -gt 0) { Write-Host " $warn warning(s), no blockers." }
else { Write-Host " All checks passed." }
Write-Host ""
exit 0

View File

@@ -0,0 +1,142 @@
# select-waxtrace-asset.ps1 - Arrow-key bay picker for wax/trace imaging.
#
# Reads bay-config.csv on the PXE share to build the menu of known bays.
# Falls back to INDEX.csv (cal-disc index) if bay-config.csv is missing.
# Operator picks with Up/Down arrows + Enter. Always appends an
# "Other (new bay)" option at the end for unlisted bays - selecting it
# falls back to a free-text prompt.
#
# Writes the chosen asset tag to $OutFile (one line, no trailing newline).
# startnet.cmd reads that file back into the MACHINENUM batch var.
#
# Runs in WinPE PowerShell. Win10/11 WinPE ships powershell.exe with
# System.Console.ReadKey support. Tested 2026-05-18.
#
# Exit codes:
# 0 = asset tag written to $OutFile
# 1 = user cancelled (Esc) - $OutFile not written
# 2 = no readable bay source AND no fallback entered
param(
[string]$IndexPath = 'Y:\installers-post\waxtrace\bay-config.csv',
[Parameter(Mandatory=$true)][string]$OutFile
)
$ErrorActionPreference = 'Continue'
function Read-BayList {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return @() }
try {
$rows = @(Import-Csv -LiteralPath $Path)
# bay-config.csv has asset_tag,ftpak_version,model,user_id,hw_sn,hw_id,host,notes
# INDEX.csv (legacy) has asset_tag,unit_serial,probe_part,...
$isBayCfg = $rows.Count -gt 0 -and ($rows[0].PSObject.Properties.Name -contains 'ftpak_version')
return $rows | Sort-Object -Property asset_tag | ForEach-Object {
if ($isBayCfg) {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.ftpak_version
col2 = $_.model
col3 = $_.user_id
schema = 'bay-config'
}
} else {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.unit_serial
col2 = $_.probe_part
col3 = ''
schema = 'index'
}
}
}
} catch {
return @()
}
}
function Show-Menu {
param([object[]]$Items, [int]$Selected, [string]$Title, [string]$Schema)
Clear-Host
Write-Host ""
Write-Host " ============================================================"
Write-Host " $Title"
Write-Host " ============================================================"
Write-Host ""
Write-Host " Up / Down arrows = navigate, Enter = select, Esc = cancel"
Write-Host ""
if ($Schema -eq 'bay-config') {
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f 'ASSET','FTPAK','MODEL','USER ID')
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f '-----','-----','-----','-------')
} else {
Write-Host (" {0,-10} {1,-14} {2}" -f 'ASSET','SERIAL','PROBE')
Write-Host (" {0,-10} {1,-14} {2}" -f '-----','------','-----')
}
for ($i = 0; $i -lt $Items.Count; $i++) {
$item = $Items[$i]
if ($item -is [string]) {
$line = $item
} elseif ($Schema -eq 'bay-config') {
$line = "{0,-10} {1,-8} {2,-10} {3}" -f $item.asset_tag, $item.col1, $item.col2, $item.col3
} else {
$line = "{0,-10} {1,-14} {2}" -f $item.asset_tag, $item.col1, $item.col2
}
if ($i -eq $Selected) {
Write-Host (" > " + $line) -ForegroundColor Black -BackgroundColor White
} else {
Write-Host (" " + $line)
}
}
Write-Host ""
}
# Try bay-config.csv first; fall back to INDEX.csv if missing OR if the
# explicit -IndexPath argument points to INDEX.csv (legacy callers).
$bays = @(Read-BayList -Path $IndexPath)
if ($bays.Count -eq 0 -and $IndexPath -notmatch 'INDEX\.csv$') {
$fallback = 'Y:\installers-post\waxtrace\calibrations\INDEX.csv'
if (Test-Path -LiteralPath $fallback) {
Write-Host " (no bay-config.csv at $IndexPath - falling back to $fallback)"
$bays = @(Read-BayList -Path $fallback)
}
}
$schema = if ($bays.Count -gt 0) { $bays[0].schema } else { 'bay-config' }
$menuItems = @()
foreach ($b in $bays) { $menuItems += $b }
$menuItems += '** Other (new bay - enter asset tag manually) **'
$sel = 0
while ($true) {
Show-Menu -Items $menuItems -Selected $sel -Title 'Wax/Trace Asset Tag' -Schema $schema
$key = [System.Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' { if ($sel -gt 0) { $sel-- } }
'DownArrow' { if ($sel -lt ($menuItems.Count - 1)) { $sel++ } }
'Enter' {
if ($sel -eq ($menuItems.Count - 1)) {
Write-Host ""
$manual = Read-Host " Enter asset tag (e.g. WJRP9999) or blank to abort"
if ($manual) {
$manual = $manual.Trim().ToUpper()
Set-Content -LiteralPath $OutFile -Value $manual -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Saved asset tag: $manual"
Start-Sleep -Seconds 1
exit 0
} else {
exit 1
}
} else {
$pick = $bays[$sel].asset_tag
Set-Content -LiteralPath $OutFile -Value $pick -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Selected: $pick"
Start-Sleep -Seconds 1
exit 0
}
}
'Escape' { exit 1 }
}
}

View File

@@ -110,7 +110,8 @@ exit /b 0
:flash_done :flash_done
echo BIOS update complete. echo BIOS update complete.
set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% -^> %TARGETVER%" set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% to %TARGETVER%"
echo flash_done %SYSMODEL% %BIOSVER% to %TARGETVER%> X:\bios-fired.flag
exit /b 0 exit /b 0
:staged :staged
@@ -121,7 +122,8 @@ echo It will flash during POST after the
echo post-imaging reboot. echo post-imaging reboot.
echo ======================================== echo ========================================
echo. echo.
set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% -^> %TARGETVER% (flashes on reboot)" set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% to %TARGETVER% (flashes on reboot)"
echo staged %SYSMODEL% %BIOSVER% to %TARGETVER%> X:\bios-fired.flag
exit /b 0 exit /b 0
:compare_versions :compare_versions

View File

@@ -28,6 +28,12 @@ Latitude 7450|OptiPlex_7450_1.34.0.exe|1.34.0
Micro 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0 Micro 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
SFF Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0 SFF Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Tower Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0 Tower Plus 7010|OptiPlex_7010_1.34.0_SEMB.exe|1.34.0
Micro 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
SFF Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2 Micro QCM1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
OptiPlex 3000|OptiPlex_3000_1.38.0.exe|1.38.0 OptiPlex 3000|OptiPlex_3000_1.38.0.exe|1.38.0
OptiPlex 7000|OptiPlex_7000_1.38.0.exe|1.38.0 OptiPlex 7000|OptiPlex_7000_1.38.0.exe|1.38.0
@@ -47,5 +53,4 @@ Precision 7820 Tower|Precision_7820_7920_2.50.0.exe|2.50.0
Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1 Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3 Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1 Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2 Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2

View File

@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Return the tenant + PC purpose, pre-seeded if WinPE already asked.
.DESCRIPTION
Start-BulkEnrollOrchestrator.ps1 calls this at first logon and captures
stdout:
$pcType = powershell.exe -File 'C:\Deploy\Applications\BPRT\Select-PCConfig.ps1' -LogoPath '...'
It uses the answer twice - to pick which MCL package to apply, and to select
which bulk token Insert-BPRTToPPKG.ps1 injects (it matches on Tenant AND
Purpose). The vendor's version asks with a GUI, which means an otherwise
unattended build stops at a dialog until somebody walks over.
startnet.cmd now asks the same question at the PXE menu, while the tech who
knows what the machine is for is standing at it, and writes the answer to
C:\Enrollment\pc-config.txt. This shim returns that value when present and
falls through to the vendor dialog when it is not - so nothing is lost if the
tech picks "ask at first logon", or on a bay imaged before this existed.
ONLY the choice is pre-seeded. Token injection still happens at first logon:
Insert-BPRTToPPKG.ps1 fetches an encrypted token table from
mcl.dwcdn.geaerospace.com, which is unreachable from the isolated imaging
LAN.
.OUTPUTS
"{Tenant}_{PCType}" - Tenant GCCH|RoW, PCType PERS|SH|LOAN.
Identical contract to the vendor script.
.NOTES
The vendor script is kept alongside as Select-PCConfig-vendor.ps1. Replace
THAT file when the vendor ships a new one; this shim only needs changing if
the return contract changes.
#>
param(
[Parameter(Mandatory = $false)]
[string]$LogoPath
)
$seedFile = 'C:\Enrollment\pc-config.txt'
$vendor = Join-Path $PSScriptRoot 'Select-PCConfig-vendor.ps1'
$logDir = 'C:\Logs\BPRT\Orchestrator'
$shimLog = Join-Path $logDir 'Select-PCConfig-shim.log'
function Log {
param([string]$Message)
try {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
Add-Content -Path $shimLog -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
} catch { }
}
# Valid values, so a typo in the seed file falls back to the dialog instead of
# handing the orchestrator a string that matches no token and no package.
$validTenants = @('GCCH', 'RoW')
$validPurposes = @('PERS', 'SH', 'LOAN')
if (Test-Path $seedFile) {
$seed = (Get-Content $seedFile -First 1 -ErrorAction SilentlyContinue)
if ($seed) { $seed = $seed.Trim() }
$parts = if ($seed) { $seed.Split('_') } else { @() }
if ($parts.Count -eq 2 -and $validTenants -contains $parts[0] -and $validPurposes -contains $parts[1]) {
Log "Pre-seeded value '$seed' from $seedFile - dialog skipped."
Write-Output $seed
exit 0
}
Log "Ignoring malformed pre-seed '$seed' in $seedFile; falling back to the dialog."
}
if (Test-Path $vendor) {
Log "No usable pre-seed; showing the vendor dialog."
if ($LogoPath) { & $vendor -LogoPath $LogoPath }
else { & $vendor }
exit $LASTEXITCODE
}
# Neither a seed nor the vendor dialog. Say so in the log rather than returning
# something invented - the orchestrator would inject the wrong token.
Log "ERROR: no pre-seed at $seedFile and no vendor script at $vendor."
exit 1

View File

@@ -0,0 +1,409 @@
<#
.SYNOPSIS
Collect everything needed to diagnose an imaged bay, into one zip.
.DESCRIPTION
Read-only. Changes nothing on the machine.
Written because every imaging investigation so far has meant walking to a bay
and hand-copying files, and each time we discovered another thing we wished
we had grabbed at the same moment. This takes the lot in one pass.
Run it on a freshly imaged bay BEFORE lockdown.
This captures KNOWN-CURRENT, not known-good. A bay straight off the line has
applications on it that should not be there - preinstall.json entries without
a PCTypes filter install everywhere, so a Display bay picks up Adobe,
OpenText and Defect Tracker. The point of collecting is to have an exact
record of what imaging really produces, so the unnecessary items can be
identified and filtered out.
Before lockdown specifically, because afterwards you cannot tell whether
something is absent because lockdown removed it or because imaging never
installed it.
.PARAMETER Destination
Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect
centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs
(only reachable while the bay is still on the imaging LAN).
.PARAMETER Label
Optional tag folded into the filename, e.g. -Label pre-lockdown.
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs
#>
[CmdletBinding()]
param(
[string]$Destination = 'C:\Logs',
[string]$Label = ''
)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" }
$work = Join-Path $env:TEMP "imgdiag-$name"
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Section { param([string]$File, [scriptblock]$Body)
$p = Join-Path $work $File
try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 }
catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 }
Write-Host (" {0}" -f $File)
}
function CopyTree { param([string]$Src, [string]$Dst)
# /XF *.ppkg and /MAX are not optional. run-enrollment harvests
# C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
# provisioning package exists TWICE under the trees we copy. Without these
# the collection is 16 GB and Compress-Archive dies with "stream was too
# long" - it cannot exceed 2 GB.
if (Test-Path $Src) {
$d = Join-Path $work $Dst
New-Item -ItemType Directory -Path $d -Force | Out-Null
robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS /XF *.ppkg *.wim *.iso /MAX:104857600 | Out-Null
Write-Host (" {0}\ <- {1}" -f $Dst, $Src)
}
}
Write-Host ""
Write-Host "Collecting imaging diagnostics for $serial ..."
Write-Host ""
# --- 1. Identity ---------------------------------------------------------
# ActiveComputerName is the name in use; ComputerName is the PENDING one. They
# differ when a rename is queued for the next reboot - which is exactly how the
# H<serial> -> F<serial> transition works, so capturing both tells you whether
# the package's rename landed or is still waiting.
Section 'identity.txt' {
'== computer names =='
'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName
'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName
'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname'
''
'== hardware =='
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List
Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List
''
'== uptime / install =='
'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate
}
# --- 2. Enrollment state -------------------------------------------------
# dsregcmd is the authority on whether the Entra join actually happened.
# criticalChecks.json is the package's own self-assessment and the two can
# disagree - "Entra ID Joined: false" right after imaging is normal because the
# bay has not reached the production network yet.
Section 'enrollment.txt' {
'== dsregcmd /status =='
& dsregcmd /status
''
'== package self-checks =='
foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json',
'C:\Logs\BPRT\Orchestrator\TokenMatch.json') {
if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw }
}
''
'== staged selections =='
foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt',
'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt',
'C:\Enrollment\fetch-source.txt') {
if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','<redacted>') }
}
}
# --- 3. Installed applications ------------------------------------------
# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying
# it triggers an MSI reconfigure of every installed product, which is slow and
# can actually change the machine.
Section 'installed-apps.txt' {
$paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty $paths -EA SilentlyContinue |
Where-Object { $_.DisplayName } |
Sort-Object DisplayName |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
Format-Table -AutoSize
}
# --- 4. Everything that starts by itself --------------------------------
# THE section for "why did this app launch". Installers plant their own startup
# entries, so an app being installed IS an app being started - there is no
# separate switch. Capturing all four surfaces shows which app planted what.
# Note both registry views: a 32-bit installer's Run key lands under
# Wow6432Node and 64-bit tooling never sees it.
Section 'autostart.txt' {
'== HKLM Run / RunOnce (native + WOW64) =='
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== HKCU Run / RunOnce (current user) =='
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== Startup folders =='
foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
"$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize }
}
''
'== per-user Startup folders (all profiles) =='
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object {
$d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize }
}
''
'== enabled/disabled state (StartupApproved) =='
# Disabling a startup item via Task Manager or Settings does NOT remove the
# Run key or the Startup shortcut - it writes a flag here. So an entry can
# appear above and still be switched off. First byte 02/06 = enabled,
# 03/07 = disabled. Capturing this is what tells "imaging installed it and
# it runs" apart from "imaging installed it and somebody turned it off",
# which matters because the fix is not to install it at all.
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
if (Test-Path $k) {
"--- $k ---"
$props = Get-Item $k
foreach ($n in $props.Property) {
$v = (Get-ItemProperty $k -Name $n).$n
$state = if ($v -is [byte[]] -and $v.Length -ge 1) {
switch ($v[0]) { 2 {'ENABLED'} 4 {'ENABLED'} 6 {'ENABLED'} 3 {'disabled'} 5 {'disabled'} 7 {'disabled'} default {"unknown(0x{0:X2})" -f $v[0]} }
} else { 'unknown' }
"{0,-10} {1}" -f $state, $n
}
}
}
''
'== scheduled tasks (non-Microsoft) =='
Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
Select-Object TaskPath, TaskName, State,
@{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }},
@{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} |
Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap
''
'== services set to auto-start (non-Microsoft paths) =='
Get-CimInstance Win32_Service -EA SilentlyContinue |
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } |
Select-Object Name, DisplayName, State, StartMode, PathName |
Sort-Object Name | Format-Table -AutoSize -Wrap
}
# --- 5. Kiosk / display specifics ---------------------------------------
Section 'display-kiosk.txt' {
'== kiosk shortcuts anywhere in Startup =='
Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue |
ForEach-Object {
$sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName)
"{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments
}
''
'== Edge policies =='
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge',
'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
}
# --- 6. GE-Enforce -------------------------------------------------------
Section 'ge-enforce.txt' {
'== registry config =='
foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC',
'HKLM:\SOFTWARE\GE\ShopDB') {
# values only - do not dump anything that looks like a secret
if (Test-Path $k) {
"--- $k ---"
Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object {
$v = (Get-ItemProperty $k -Name $_).$_
if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = <redacted, length $($v.ToString().Length)>" }
else { "$_ = $v" }
}
}
}
''
'== enforce client / DSC on disk =='
foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? -----------
# Config on disk proves nothing - the client can be present, configured, and
# never once succeed. This answers the actual question: is there a client, is it
# scheduled, has it run, and can this bay reach the endpoint right now.
#
# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing
# and TLS all work and the service answered. Only a timeout or DNS failure means
# genuinely unreachable. No token is sent, so nothing here can enrol or change
# anything.
Section 'geenforce-api.txt' {
'== is the client installed? =='
$clientPaths = @(
'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB',
'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1',
'C:\Enrollment\ShopdbEnforceClient.psm1'
)
$found = $false
foreach ($p in $clientPaths) {
if (Test-Path $p) { $found = $true; "FOUND: $p"
Get-ChildItem $p -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' }
''
'== scheduled tasks that would run it =='
$tasks = Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' }
if ($tasks) {
foreach ($t in $tasks) {
$i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue
"{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f `
$t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime
($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" })
}
} else { 'NONE - nothing scheduled to call the API.' }
''
'== configured base URL =='
$base = $null
foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') {
if (Test-Path $k) {
$p = Get-ItemProperty $k
foreach ($n in $p.PSObject.Properties.Name) {
# Match the NAME tightly and validate the VALUE looks like a URL.
# A loose "base" match picked up baseVersion=2.0.2 and the probe
# then tried to fetch "2.0.2/api/docs".
if ($n -match '(?i)^(baseurl|serverurl|shopdburl|endpoint|url)$') {
"$k\$n = $($p.$n)"
if (-not $base -and "$($p.$n)" -match '^https?://') { $base = $p.$n }
}
}
}
}
if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' }
if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' }
''
"== can this bay reach it right now? (base: $base) =="
$targets = @(
@{ Name = 'API docs'; Url = "$base/api/docs" },
@{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" }
)
foreach ($t in $targets) {
try {
$sw = [Diagnostics.Stopwatch]::StartNew()
$r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop
$sw.Stop()
"{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds
} catch {
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
if ($code) {
"{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code
} else {
"{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message
' NOTE: ShopDB is only reachable once the bay has joined the AESFMA wifi SSID.'
' On the imaging LAN or plain wired, unreachable here is EXPECTED, not a fault.'
}
}
}
''
'== name resolution / route =='
try {
$h = ([uri]$base).Host
"host: $h"
Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize
Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue |
Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List
} catch { "DNS/route check failed: $_" }
''
'== client logs, if any =='
foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 7. Drivers, disk, network ------------------------------------------
Section 'drivers-disk-network.txt' {
'== third-party drivers =='
& pnputil /enum-drivers
''
'== disk layout =='
Get-Disk -EA SilentlyContinue | Format-Table -AutoSize
Get-Partition -EA SilentlyContinue | Format-Table -AutoSize
Get-Volume -EA SilentlyContinue | Format-Table -AutoSize
''
'== network =='
Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize
Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize
Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize
}
# --- 8. Provisioning session state --------------------------------------
Section 'provisioning-sessions.txt' {
'== sessions =='
$k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions'
if (Test-Path $k) {
Get-ChildItem $k | ForEach-Object {
$p = Get-ItemProperty $_.PSPath
"{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount
}
} else { '(no provisioning sessions key)' }
}
# --- 9. Log trees --------------------------------------------------------
CopyTree 'C:\Logs' 'Logs'
CopyTree 'C:\Windows\Panther' 'Panther'
CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData'
foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') {
if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue }
}
# Enrollment dir listing only - it holds an 8 GB package we do not want.
Section 'enrollment-dir-listing.txt' {
if (Test-Path 'C:\Enrollment') {
Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize
}
}
# --- 10. Provisioning event log -----------------------------------------
try {
$evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx'
& wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null
if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' }
} catch { }
# --- zip -----------------------------------------------------------------
New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null
$zip = Join-Path $Destination "imgdiag-$name.zip"
try {
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop
Remove-Item $work -Recurse -Force -EA SilentlyContinue
Write-Host ""
Write-Host "Wrote $zip"
Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB))
} catch {
Write-Host ""
Write-Host "Could not zip to $Destination : $_"
Write-Host "Raw collection left at: $work"
}
Write-Host ""

View File

@@ -0,0 +1,181 @@
# Fetch-StagingPayload.ps1 - post-boot bulk staging fetch (first-logon).
#
# WHY THIS EXISTS
# WinPE used to stage the whole shopfloor-setup tree + preinstall bundle to
# the target disk DURING the WinPE phase. But WinPE maps the enrollment share
# (Y:) early, then idles for many minutes while the full Windows image applies.
# Samba's `deadtime` drops idle sessions, so by the time WinPE reached the
# copies the Y: mount was dead and most copies failed (symptom: a bay with
# only site-config.json staged, then nothing). Doing the bulk copy here - at
# first logon, in full Windows, on a FRESH share mount with no prior idle -
# sidesteps that entirely.
#
# WHEN IT RUNS
# The unattend FirstLogonCommands runs this BEFORE the PowerShell 7 MSI install
# (which needs C:\PreInstall\installers\powershell7\) and before
# Run-ShopfloorSetup.ps1 (which needs C:\Enrollment\shopfloor-setup\). So this
# must populate both trees before those steps fire.
#
# WHAT IT FETCHES (generic bulk - Phase 1)
# \\<server>\enrollment\shopfloor-setup\Run-ShopfloorSetup.ps1 -> C:\Enrollment\
# \\<server>\enrollment\shopfloor-setup\{backup_lockdown.bat,Shopfloor,common,
# _ntlars-backups,gea-shopfloor-<pctype>} -> C:\Enrollment\shopfloor-setup\
# \\<server>\enrollment\pre-install\{preinstall.json,installers,udc-backups}
# -> C:\PreInstall\
# (Heavy per-type payloads - CMM/Keyence/WaxTrace - are still staged in WinPE
# for now; Phase 2 moves those here too.)
#
# LOGGING
# Verbose transcript + a per-item table to C:\Logs\Fetch\. Every robocopy logs
# its exit code, file/dir counts, byte total, and elapsed time, so a failed
# fetch is fully diagnosable (unlike the old opaque WinPE staging).
#
# Always exits 0 - a fetch failure must not abort the FirstLogonCommands chain;
# the log carries the truth and Run-ShopfloorSetup surfaces missing pieces.
$ErrorActionPreference = 'Continue'
# --- Logging setup ---
$logDir = 'C:\Logs\Fetch'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$logFile = Join-Path $logDir "fetch-staging-$stamp.log"
try { Start-Transcript -Path $logFile -Append -Force | Out-Null } catch {}
function Log {
param([string]$Message, [string]$Level = 'INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "[$ts] [$Level] $Message"
}
Log "================================================================"
Log "=== Fetch-StagingPayload start (PID $PID) ==="
Log "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Log "Host: $env:COMPUTERNAME"
Log "================================================================"
# --- Resolve the share source + creds (written by startnet to fetch-source.txt;
# falls back to the historical defaults if absent) ---
$shareUnc = '\\172.16.9.1\enrollment'
$shareUser = 'pxe-upload'
$sharePass = 'pxe'
$srcFile = 'C:\Enrollment\fetch-source.txt'
if (Test-Path -LiteralPath $srcFile) {
# Format: line1=UNC, line2=user, line3=pass
$lines = @(Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)
if ($lines.Count -ge 1 -and $lines[0].Trim()) { $shareUnc = $lines[0].Trim() }
if ($lines.Count -ge 2 -and $lines[1].Trim()) { $shareUser = $lines[1].Trim() }
if ($lines.Count -ge 3 -and $lines[2].Trim()) { $sharePass = $lines[2].Trim() }
Log "fetch-source.txt: UNC=$shareUnc user=$shareUser"
} else {
Log "fetch-source.txt absent - using defaults: UNC=$shareUnc user=$shareUser"
}
# --- pc-type (drives which gea-shopfloor-<type> dir to fetch) ---
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA 0).Trim()
}
Log "PC type: $(if ($pcType) { $pcType } else { '(none)' })"
# --- Status push (best-effort) ---
$pxeStatusLib = 'C:\Enrollment\shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1'
# (lib not fetched yet on first run; ignore if absent)
if (Test-Path $pxeStatusLib) { try { . $pxeStatusLib; Send-PxeStatus -Stage 'Fetch-StagingPayload: starting' -StageIndex 1 -StageTotal 8 } catch {} }
# --- Mount the share fresh (use Z:; retry to ride out a brief blip) ---
$drive = 'Z:'
function Mount-Share {
# Pre-clear any stale Z: mapping. Wrap in cmd.exe (output to nul INSIDE cmd)
# so net.exe's "network connection could not be found" stderr - emitted when
# Z: is not mapped (the normal first-attempt case) - never reaches PowerShell
# as a NativeCommandError. PS 2>$null does not reliably suppress that.
cmd /c "net use $drive /delete /y >nul 2>&1"
$r = & net use $drive $shareUnc /user:$shareUser $sharePass /persistent:no 2>&1
return ($LASTEXITCODE -eq 0)
}
$mounted = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
Log "Mounting $shareUnc as $drive (attempt $attempt/5)..."
if (Mount-Share) { $mounted = $true; Log "Mounted OK"; break }
Log "Mount failed (exit $LASTEXITCODE) - waiting 10s" 'WARN'
Start-Sleep -Seconds 10
}
if (-not $mounted) {
Log "Could not mount $shareUnc after 5 attempts - ABORTING fetch. Bay will be under-provisioned; re-run this script once the share is reachable." 'ERROR'
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Fetch helper: robocopy one item, log exit + counts + timing ---
$results = @()
function Fetch-Item {
param(
[string]$Label,
[string]$SrcDir, # under $drive
[string]$DstDir,
[string[]]$Files, # named files for a flat copy; empty = whole-dir /E
[switch]$Recurse # /E whole directory
)
$src = Join-Path $drive $SrcDir
if (-not (Test-Path -LiteralPath $src)) {
Log "[SKIP] $Label - source not on share: $src" 'WARN'
$script:results += [pscustomobject]@{ Item=$Label; Exit='n/a'; Result='SOURCE-MISSING' }
return
}
if (-not (Test-Path -LiteralPath $DstDir)) { New-Item -ItemType Directory -Path $DstDir -Force | Out-Null }
$args = @($src, $DstDir)
if ($Recurse) { $args += '/E' } else { $args += $Files }
$args += @('/R:2','/W:3','/NFL','/NDL','/NP')
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Log "[COPY] $Label : robocopy $src -> $DstDir $(if ($Recurse){'/E'}else{$Files -join ','})"
$out = & robocopy @args 2>&1
$rc = $LASTEXITCODE
$sw.Stop()
# robocopy 0-7 = success, 8+ = failure
$ok = ($rc -lt 8)
# pull the summary counts robocopy prints
$summary = ($out | Select-String -Pattern 'Files :|Dirs :|Bytes :' ) -join ' | '
Log "[$(if($ok){'OK'}else{'FAIL'})] $Label exit=$rc time=$([math]::Round($sw.Elapsed.TotalSeconds,1))s $summary"
$script:results += [pscustomobject]@{ Item=$Label; Exit=$rc; Result=$(if($ok){'OK'}else{'FAIL'}) }
}
# --- Generic bulk fetch ---
$ENR = 'C:\Enrollment'
$SFD = 'C:\Enrollment\shopfloor-setup'
$PIN = 'C:\PreInstall'
Fetch-Item -Label 'Run-ShopfloorSetup.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Run-ShopfloorSetup.ps1')
# Verify-And-Heal-Staging runs as its own unattend step (right after this Fetch,
# before the production-network switch) to re-pull anything that did not arrive -
# including the heavy CMM payload Fetch does not carry. Pull the small script
# itself here so it is on disk for that step.
Fetch-Item -Label 'Verify-And-Heal-Staging.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Verify-And-Heal-Staging.ps1')
Fetch-Item -Label 'backup_lockdown.bat' -SrcDir 'shopfloor-setup' -DstDir $SFD -Files @('backup_lockdown.bat')
Fetch-Item -Label 'Shopfloor baseline' -SrcDir 'shopfloor-setup\Shopfloor' -DstDir (Join-Path $SFD 'Shopfloor') -Recurse
Fetch-Item -Label 'common' -SrcDir 'shopfloor-setup\common' -DstDir (Join-Path $SFD 'common') -Recurse
Fetch-Item -Label '_ntlars-backups' -SrcDir 'shopfloor-setup\_ntlars-backups' -DstDir (Join-Path $SFD '_ntlars-backups') -Recurse
if ($pcType) {
Fetch-Item -Label "type:$pcType" -SrcDir "shopfloor-setup\$pcType" -DstDir (Join-Path $SFD $pcType) -Recurse
}
# preinstall bundle
Fetch-Item -Label 'preinstall.json' -SrcDir 'pre-install' -DstDir $PIN -Files @('preinstall.json')
Fetch-Item -Label 'preinstall installers' -SrcDir 'pre-install\installers' -DstDir (Join-Path $PIN 'installers') -Recurse
Fetch-Item -Label 'udc-backups' -SrcDir 'pre-install\udc-backups' -DstDir (Join-Path $PIN 'udc-backups') -Recurse
# --- Unmount ---
cmd /c "net use $drive /delete /y >nul 2>&1"
# --- Summary table ---
Log "================================================================"
Log "FETCH SUMMARY:"
foreach ($r in $results) { Log (" {0,-28} exit={1,-4} {2}" -f $r.Item, $r.Exit, $r.Result) }
$failed = @($results | Where-Object { $_.Result -eq 'FAIL' })
if ($failed.Count -gt 0) {
Log "$($failed.Count) item(s) FAILED: $(( $failed | ForEach-Object { $_.Item }) -join ', ')" 'ERROR'
} else {
Log "All fetched items OK." 'INFO'
}
Log "=== Fetch-StagingPayload complete ==="
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -27,7 +27,7 @@ Write-Host "================================================================"
Write-Host "" Write-Host ""
# Imaging-progress reporter. Posts coarse stage updates to the PXE webapp # Imaging-progress reporter. Posts coarse stage updates to the PXE webapp
# at http://10.9.100.1:9009/imaging/status so the operator can watch # at http://172.16.9.1:9009/imaging/status so the operator can watch
# progress in a browser. Best-effort: failures never block imaging. # progress in a browser. Best-effort: failures never block imaging.
$pxeStatusLib = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1' $pxeStatusLib = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1'
if (Test-Path $pxeStatusLib) { if (Test-Path $pxeStatusLib) {
@@ -50,6 +50,35 @@ Report-Stage -Stage 'Run-ShopfloorSetup: starting' -Index 2
# Cancel any pending reboot so it doesn't interrupt setup # Cancel any pending reboot so it doesn't interrupt setup
cmd /c "shutdown /a 2>nul" *>$null cmd /c "shutdown /a 2>nul" *>$null
# Self-resume: register this script as a RunOnce so a vendor-installer-
# forced reboot mid-flight (FormTracePak Setup.exe, eDNC MSI, etc) auto-
# resumes the chain after the next SupportUser auto-login. RunOnce is
# single-shot - if we complete normally we remove this key at end of
# script. If we're killed mid-flight by a forced reboot, the key
# survives and fires after reboot.
#
# Idempotent design throughout this script: every step checks detection
# before installing, so a forced-reboot re-entry just skips the already-
# done work and continues from where it left off.
#
# Also top up AutoLogonCount so the SupportUser autologon budget
# (LogonCount=12 from unattend XML) survives extra unplanned reboots.
$selfResumeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
$selfResumeName = 'ResumeRunShopfloorSetup'
$selfResumeCmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '"'
try {
Set-ItemProperty -Path $selfResumeKey -Name $selfResumeName -Value $selfResumeCmd -Type String -Force -ErrorAction Stop
Write-Host "Self-resume RunOnce registered: will re-fire $PSCommandPath if interrupted"
} catch {
Write-Warning "Failed to register self-resume RunOnce: $_"
}
try {
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -Name 'AutoLogonCount' -Value 10 -Type DWord -Force -ErrorAction Stop
Write-Host "AutoLogonCount topped up to 10 (vendor-forced reboot resilience)"
} catch {
Write-Warning "Failed to top up AutoLogonCount: $_"
}
# Wired NIC state handling moved to sync_intune (Monitor-IntuneProgress.ps1). # Wired NIC state handling moved to sync_intune (Monitor-IntuneProgress.ps1).
# Previously this script prompted the tech to unplug the PXE cable and # Previously this script prompted the tech to unplug the PXE cable and
# then re-enabled wired adapters interactively - that blocked the whole # then re-enabled wired adapters interactively - that blocked the whole
@@ -65,9 +94,51 @@ $enrollDir = "C:\Enrollment"
$typeFile = Join-Path $enrollDir "pc-type.txt" $typeFile = Join-Path $enrollDir "pc-type.txt"
$setupDir = Join-Path $enrollDir "shopfloor-setup" $setupDir = Join-Path $enrollDir "shopfloor-setup"
# NOTE: there is deliberately NO wait for an Entra join here - but NOT because
# shopfloor bays skip enrollment. They do enrol: the SFLD provisioning package
# joins Entra using the BPRT token it carries, and a human then assigns the
# device category in Intune.
#
# The wait is absent because the join cannot happen yet. At this point the bay is
# still on the isolated PXE LAN with no route to Entra - observed on 579C144
# 2026-08-06, holding 172.16.9.81 and 172.24.19.142, neither in the production
# ranges. sync_intune retries every 30 seconds until the tech re-cables to
# production, which is the right place to wait. An earlier version blocked here
# for 45 minutes and then warned about a failure that had not happened.
# "Entra ID Joined: false" in C:\Logs\BPRT\criticalChecks.json straight after
# imaging is therefore NORMAL, not a fault.
#
# CORRECTION (2026-08-06): this comment previously claimed shopfloor PCs are
# "vanilla by design" and that the orchestrator runs with -ManualFallback to skip
# BPRT injection and the package entirely. That was wrong and dangerous.
# -ManualFallback runs sysprep /oobe /reboot, so wiring it to an at-logon task
# syspreped finished machines seconds after autologon and destroyed the
# deployment chain. See docs/OWNERSHIP.md.
if (-not (Test-Path $typeFile)) { if (-not (Test-Path $typeFile)) {
Write-Host "No pc-type.txt found - skipping shopfloor setup." # A missing pc-type.txt means one of two very different things, and the old
exit 0 # blanket "skip + exit 0" hid the bad one for weeks: four Display bays sat
# at imaging stage 2 with a green exit code and nobody noticed.
# - no C:\Enrollment at all -> this machine was never staged by WinPE
# (pre-imaging, or the staging block never ran). That is a FAILURE on a
# machine that has clearly just been imaged, so say so loudly.
# - C:\Enrollment exists but no pc-type.txt -> staging ran and the write
# failed. Also a failure.
$stagingLog = Join-Path $enrollDir 'winpe-staging.log'
$detail = if (Test-Path $enrollDir) {
"C:\Enrollment exists but pc-type.txt is missing - WinPE staging ran but did not write it. Check $stagingLog."
} else {
"C:\Enrollment does not exist - WinPE staging never ran. The Windows volume was probably not found in startnet.cmd, so pc-type.txt, the enrollment package and shopfloor-setup were ALL skipped."
}
Write-Host ""
Write-Host "================================================================"
Write-Host " FAILED: no pc-type.txt at $typeFile"
Write-Host " $detail"
Write-Host " Shopfloor setup cannot run. This PC is imaged but NOT configured."
Write-Host "================================================================"
Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: FAILED - no pc-type.txt' -Index 2 -Status 'failed' -Error_ $detail
exit 1
} }
$pcType = (Get-Content $typeFile -First 1).Trim() $pcType = (Get-Content $typeFile -First 1).Trim()
@@ -102,6 +173,12 @@ $skipInBaseline = @(
'06-OrganizeDesktop.ps1', '06-OrganizeDesktop.ps1',
'07-TaskbarLayout.ps1', '07-TaskbarLayout.ps1',
'08-EdgeDefaultBrowser.ps1', '08-EdgeDefaultBrowser.ps1',
# Machine number flow: split into two scripts registered as scheduled
# tasks by Register-CheckMachineNumberTask.ps1. Prompt runs as the
# logged-in user (GUI), Apply runs as SYSTEM (privileged writes).
# Neither should run in baseline pass.
'Prompt-MachineNumber.ps1',
'Apply-MachineNumber.ps1',
'Check-MachineNumber.ps1', 'Check-MachineNumber.ps1',
'Configure-PC.ps1' 'Configure-PC.ps1'
) )
@@ -299,63 +376,10 @@ if (Test-Path -LiteralPath $monitorScript) {
# These run on every logon regardless of PC type, mounting the SFLD share # These run on every logon regardless of PC type, mounting the SFLD share
# for version-pinned app enforcement. Initial install already handled by # for version-pinned app enforcement. Initial install already handled by
# preinstall flow; enforcers only kick in when detection fails. # preinstall flow; enforcers only kick in when detection fails.
# --- Re-enable wired NICs once lockdown completes (Phase 6) --- # Wired-disable / re-enable dance retired after PXE LAN renumber to
# migrate-to-wifi.ps1 disables wired NICs so the PPKG runs over WiFi. # 172.16.9.0/24. GE Report IP filters Get-NetIPAddress on StartsWith("10.")
# Keep them disabled through the entire Intune sync + DSC + lockdown # so PXE LAN addresses are no longer caught - wired NIC can stay up
# chain so nothing interrupts the WiFi-based enrollment. Only re-enable # through the whole imaging chain without leaking to the GE webhook.
# after lockdown lands (Autologon_Remediation.log confirms ShopFloor
# autologon set). Monitor-IntuneProgress runs as Limited and can't call
# Enable-NetAdapter (needs admin). This SYSTEM task fires at logon,
# polls for lockdown completion, re-enables wired NICs, and self-deletes.
$reEnableTask = 'GE Re-enable Wired NICs'
try {
$script = @'
# Poll for the GE Report-IP Proactive Remediation log file. Its appearance
# means the Report IP script has fired with WiFi-only IPs (because we
# disabled wired post-PPKG) - which is the exact moment we want to bring
# wired back up so Monitor-IntuneProgress can push idx=7 with the
# DeviceId / QR code before the Intune-triggered LAPS-prompt reboot lands.
# Extension is .LOG (not .txt) observed in field; match any extension.
$ip = Get-ChildItem 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $ip) { exit 0 }
# Vendor-agnostic wired-NIC re-enable. NetAdapter "Name" varies wildly
# ("Ethernet", "Ethernet 2", "Network", per-vendor names like "Realtek
# Gaming GbE", "Intel(R) Ethernet Connection (10) I219-V") so filtering
# by Name is unreliable. Filter by PhysicalMediaType instead, with a
# keyword-negative guard for drivers that mis-report PhysicalMediaType.
# Captures Realtek, Intel, Broadcom, Marvell, Aquantia, etc.
Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object {
$_.HardwareInterface -eq $true -and
$_.PhysicalMediaType -ne 'Native 802.11' -and
$_.PhysicalMediaType -ne 'Wireless WAN' -and
$_.PhysicalMediaType -ne 'BlueTooth' -and
$_.InterfaceDescription -notmatch '(?i)Wi-?Fi|Wireless|WLAN|802\.11|Bluetooth'
} |
Enable-NetAdapter -Confirm:$false -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName 'GE Re-enable Wired NICs' -Confirm:$false -ErrorAction SilentlyContinue
'@
$scriptPath = 'C:\Program Files\GE\ReEnableNIC.ps1'
if (-not (Test-Path 'C:\Program Files\GE')) {
New-Item -Path 'C:\Program Files\GE' -ItemType Directory -Force | Out-Null
}
Set-Content -Path $scriptPath -Value $script -Force
$reEnableAction = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
$reEnableTrigger = New-ScheduledTaskTrigger -AtLogOn
$reEnableTrigger.Repetition = (New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 5)).Repetition
$reEnablePrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$reEnableSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $reEnableTask -Action $reEnableAction -Trigger $reEnableTrigger `
-Principal $reEnablePrincipal -Settings $reEnableSettings -Force -ErrorAction Stop | Out-Null
Write-Host "Registered '$reEnableTask' task (waits for SFLD creds, then re-enables wired NICs)."
} catch {
Write-Warning "Failed to register NIC re-enable task: $_"
}
$commonSetupDir = Join-Path $setupDir 'common' $commonSetupDir = Join-Path $setupDir 'common'
@@ -413,6 +437,26 @@ if ($noEnforceTypes -contains $pcType) {
Write-Host "Register-MapSfldShare.ps1 not found (optional) - skipping" Write-Host "Register-MapSfldShare.ps1 not found (optional) - skipping"
} }
# --- Check Machine Number logon prompt ---
# Auto-register the "Check Machine Number" scheduled task. Bays imaged with
# the 9999 placeholder will prompt the first ShopFloor end-user logon to
# enter the real machine number; on success Update-MachineNumber.ps1 pulls
# the per-machine NTLARS .reg + UDC settings JSON + UDC data backup from
# SFLD and the task self-unregisters. Self-disables once the number is
# real, so safe to always register here.
# Skipped for self-contained types (Display) that have no machine number.
$registerCheckMN = Join-Path $setupDir 'Shopfloor\Register-CheckMachineNumberTask.ps1'
if ($noEnforceTypes -contains $pcType) {
Write-Host ""
Write-Host "=== Skipping Check Machine Number task ($pcType has no machine number) ==="
} elseif (Test-Path -LiteralPath $registerCheckMN) {
Write-Host ""
Write-Host "=== Registering Check Machine Number logon task ==="
try { & $registerCheckMN } catch { Write-Warning "Check-MachineNumber registration failed: $_" }
} else {
Write-Host "Register-CheckMachineNumberTask.ps1 not found (optional) - skipping"
}
# --- Run enrollment (PPKG install) --- # --- Run enrollment (PPKG install) ---
# Enrollment is the LAST thing we do. Install-ProvisioningPackage triggers # Enrollment is the LAST thing we do. Install-ProvisioningPackage triggers
# an immediate reboot -- everything after this call is unlikely to execute. # an immediate reboot -- everything after this call is unlikely to execute.
@@ -474,31 +518,19 @@ if (Test-Path -LiteralPath $enrollScript) {
Write-Host "=== Running enrollment (PPKG install) ===" Write-Host "=== Running enrollment (PPKG install) ==="
Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel" Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel"
Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which" Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which"
Write-Host " runs a 60s settle (giving MDM time to push baseline" Write-Host " runs a 120s settle (giving MDM time to push baseline"
Write-Host " policy) and then performs a clean reboot." Write-Host " policy) and then performs a clean reboot."
try { Stop-Transcript | Out-Null } catch {} try { Stop-Transcript | Out-Null } catch {}
& $enrollScript & $enrollScript
# idx=6 push happens BEFORE wired disable so the dashboard captures
# the handoff stage. Disable-WiredNics comes right after - kills wired
# before PostPpkg settle's Schedule #3 hammer hits Intune endpoints,
# before the PPKG-driven reboot, and before IME starts firing the
# Report IP script. Goal: GE's Report IP webhook only ever sees the
# corp-WiFi IP, never PXE LAN (10.9.100.x). Monitor-IntuneProgress
# re-enables wired once C:\Logs\GE_Report_IP_Address*.txt shows up
# (proof of clean Report IP fire) and then pushes idx=7.
Write-Host "" Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 6 Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 6
$disableWiredScript = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Disable-WiredNics.ps1'
if (Test-Path -LiteralPath $disableWiredScript) {
try { & $disableWiredScript } catch { Write-Warning "Disable-WiredNics threw: $_" }
} else {
Write-Warning "Disable-WiredNics.ps1 not found at $disableWiredScript - wired stays up (Report IP leak risk)"
}
Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ===" Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ==="
cmd /c "shutdown /a 2>nul" | Out-Null cmd /c "shutdown /a 2>nul" | Out-Null
# Made it past all the reboot-prone vendor installers. Clear the
# self-resume RunOnce so a normal completion + reboot does not re-fire
# this script post-PPKG (PPKG install owns the reboot chain from here).
try { Remove-ItemProperty -Path $selfResumeKey -Name $selfResumeName -ErrorAction SilentlyContinue } catch {}
$monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1' $monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
if (Test-Path -LiteralPath $monitor) { if (Test-Path -LiteralPath $monitor) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitor -PostPpkg & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitor -PostPpkg
@@ -512,6 +544,7 @@ if (Test-Path -LiteralPath $enrollScript) {
Write-Host "================================================================" Write-Host "================================================================"
Write-Host "=== Run-ShopfloorSetup.ps1 complete $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" Write-Host "=== Run-ShopfloorSetup.ps1 complete $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "================================================================" Write-Host "================================================================"
try { Remove-ItemProperty -Path $selfResumeKey -Name $selfResumeName -ErrorAction SilentlyContinue } catch {}
try { Stop-Transcript | Out-Null } catch {} try { Stop-Transcript | Out-Null } catch {}
Write-Host "Rebooting in 10 seconds..." Write-Host "Rebooting in 10 seconds..."
shutdown /r /t 10 shutdown /r /t 10

View File

@@ -165,8 +165,12 @@ if (Test-Path -LiteralPath $machineNumFile) {
# before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true) # before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true)
# would overwrite it IF the share were reachable, but since it isn't, our # would overwrite it IF the share were reachable, but since it isn't, our
# pre-staged file survives and UDC launches with correct settings. # pre-staged file survives and UDC launches with correct settings.
# UDC payload (settings backups + webserver settings) lives only in the
# collections per-pc-type dir - UDC is the "C" of "collections". On nocoll
# bays the dir doesn't exist; Test-Path skips silently.
$udcCollDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'gea-shopfloor-collections'
if ($machineNum -and $machineNum -ne '9999') { if ($machineNum -and $machineNum -ne '9999') {
$udcBackupDir = 'C:\Enrollment\shopfloor-setup\Standard\udc-backups' $udcBackupDir = Join-Path $udcCollDir 'udc-backups'
$udcBackup = Join-Path $udcBackupDir "udc_settings_$machineNum.json" $udcBackup = Join-Path $udcBackupDir "udc_settings_$machineNum.json"
$udcTarget = 'C:\ProgramData\UDC\udc_settings.json' $udcTarget = 'C:\ProgramData\UDC\udc_settings.json'
if (Test-Path -LiteralPath $udcBackup) { if (Test-Path -LiteralPath $udcBackup) {
@@ -176,11 +180,11 @@ if ($machineNum -and $machineNum -ne '9999') {
Copy-Item -Path $udcBackup -Destination $udcTarget -Force Copy-Item -Path $udcBackup -Destination $udcTarget -Force
Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget" Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget"
} else { } else {
Write-PreInstallLog "No UDC settings backup for machine $machineNum in $udcBackupDir" Write-PreInstallLog "No UDC settings backup for machine $machineNum at $udcBackup (skipping - normal for nocoll bays)"
} }
} }
$udcWebSrc = 'C:\Enrollment\shopfloor-setup\Standard\udc_webserver_settings.json' $udcWebSrc = Join-Path $udcCollDir 'udc_webserver_settings.json'
$udcWebDst = 'C:\ProgramData\UDC\udc_webserver_settings.json' $udcWebDst = 'C:\ProgramData\UDC\udc_webserver_settings.json'
if (Test-Path -LiteralPath $udcWebSrc) { if (Test-Path -LiteralPath $udcWebSrc) {
if (-not (Test-Path 'C:\ProgramData\UDC')) { if (-not (Test-Path 'C:\ProgramData\UDC')) {
@@ -189,7 +193,7 @@ if (Test-Path -LiteralPath $udcWebSrc) {
Copy-Item -Path $udcWebSrc -Destination $udcWebDst -Force Copy-Item -Path $udcWebSrc -Destination $udcWebDst -Force
Write-PreInstallLog "Pre-staged UDC webserver settings from $udcWebSrc -> $udcWebDst" Write-PreInstallLog "Pre-staged UDC webserver settings from $udcWebSrc -> $udcWebDst"
} else { } else {
Write-PreInstallLog "No UDC webserver settings file at $udcWebSrc" "WARN" Write-PreInstallLog "No UDC webserver settings file at $udcWebSrc (skipping - normal for nocoll bays)"
} }
# --- Suppress Windows Defender Firewall "Allow access" prompts globally for # --- Suppress Windows Defender Firewall "Allow access" prompts globally for
@@ -317,7 +321,8 @@ foreach ($app in $config.Applications) {
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
) )
$myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase) $myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)
foreach ($n in @($pcType, $pcProfileKey) | Where-Object { $_ }) { foreach ($n in @($pcType, $pcProfileKey) | Where-Object { $_ }) {
@@ -326,15 +331,27 @@ foreach ($app in $config.Applications) {
if ($g -icontains $n) { foreach ($x in $g) { [void]$myNames.Add($x) } } if ($g -icontains $n) { foreach ($x in $g) { [void]$myNames.Add($x) } }
} }
} }
# PCTypesStrict=true bypasses the alias-expansion matcher and requires
# the actual pcType (or composite pcProfileKey) to literally equal one
# of the allowedTypes entries. Used by UDC because the alias graph
# transitively connects gea-shopfloor-collections <-> nocollections via
# the legacy 'Standard' group, which would otherwise cause UDC to install
# on nocoll bays even with PCTypes=['gea-shopfloor-collections'].
$matchesType = ($allowedTypes -contains '*') $matchesType = ($allowedTypes -contains '*')
if (-not $matchesType) { if (-not $matchesType) {
foreach ($t in $allowedTypes) { if ($app.PCTypesStrict) {
if ($myNames.Contains($t)) { $matchesType = $true; break } foreach ($t in $allowedTypes) {
foreach ($g in $aliasGroups) { if (($pcType -ieq $t) -or ($pcProfileKey -ieq $t)) { $matchesType = $true; break }
if ($g -icontains $t) { }
foreach ($x in $g) { if ($myNames.Contains($x)) { $matchesType = $true; break } } } else {
foreach ($t in $allowedTypes) {
if ($myNames.Contains($t)) { $matchesType = $true; break }
foreach ($g in $aliasGroups) {
if ($g -icontains $t) {
foreach ($x in $g) { if ($myNames.Contains($x)) { $matchesType = $true; break } }
}
if ($matchesType) { break }
} }
if ($matchesType) { break }
} }
} }
} }

View File

@@ -132,8 +132,12 @@ function Invoke-DesktopSweep {
Name = @( Name = @(
'^UDC', '^UDC',
'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS', 'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS',
'Host\s*Explorer', 'ShopFloor', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250', 'Host\s*Explorer', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250',
'OpenText', # OpenText / 'WJ Shopfloor' / 'ShopFloor' shortcuts left on
# the desktop intentionally. The actual filename varies by
# OpenText profile (e.g. 'WJ Shopfloor OpenText.lnk') so the
# taskbar pin path mismatch silently skipped these. Leaving
# them at the public desktop top level instead.
'Defect[_\s-]?Tracker', 'Defect[_\s-]?Tracker',
'MarkZebra', 'Zebra', 'MarkZebra', 'Zebra',
'PC-?DMIS', 'PC-?DMIS',
@@ -326,7 +330,16 @@ function Add-ShopfloorToolsApps {
# Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk # Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk
$cfgApps = Get-ProfileValue 'desktopApps' $cfgApps = Get-ProfileValue 'desktopApps'
if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) { # An EMPTY configured list means "no desktop apps" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from both the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, which meant "desktopApps": []
# fell through to the hardcoded shopfloor list below - so a Display kiosk,
# which explicitly configures an empty list, was given UDC, eDNC, NTLARS,
# WJ Shopfloor and Defect_Tracker shortcuts. Configuring "none" produced
# "everything". Observed on 579C144, 2026-08-06.
if ($null -ne $cfgApps) {
$apps = @($cfgApps | ForEach-Object { $apps = @($cfgApps | ForEach-Object {
$entry = @{ Name = $_.name; Kind = $_.kind } $entry = @{ Name = $_.name; Kind = $_.kind }
if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath } if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath }
@@ -552,6 +565,9 @@ if (Test-Path -LiteralPath $overridesPath) {
$cfgStartup = Get-ProfileValue 'startupItems' $cfgStartup = Get-ProfileValue 'startupItems'
# Same rule as desktopApps: $null = not configured, empty array = configured as
# none. Harmless here today because the else branch has no hardcoded fallback,
# but kept consistent so the two keys cannot drift apart again.
if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) { if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) {
if (-not (Test-Path $startupDir)) { if (-not (Test-Path $startupDir)) {
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null New-Item -ItemType Directory -Path $startupDir -Force | Out-Null

View File

@@ -47,7 +47,15 @@ $layoutXmlPath = Join-Path $defaultUserShell 'LayoutModification.xml'
# ============================================================================ # ============================================================================
$cfgPins = Get-ProfileValue 'taskbarPins' $cfgPins = Get-ProfileValue 'taskbarPins'
if ($null -ne $cfgPins -and $cfgPins.Count -gt 0) { # An EMPTY configured list means "no pins" and must be honoured. Get-ProfileValue
# returns $null only when the key is absent from BOTH the profile and
# site-config, so $null is the real "not configured" signal. Requiring
# .Count -gt 0 as well makes "taskbarPins": [] fall through to the hardcoded
# shopfloor list - configuring "none" producing "everything", the same bug that
# put Plant Apps and Defect Tracker in a Display kiosk's Startup folder
# (Configure-PC.ps1) and machine-tool shortcuts on its desktop
# (06-OrganizeDesktop.ps1).
if ($null -ne $cfgPins) {
$pinSpec = @($cfgPins | ForEach-Object { $pinSpec = @($cfgPins | ForEach-Object {
@{ @{
Name = $_.name Name = $_.name

View File

@@ -203,7 +203,13 @@ $cfgTabs = Get-ProfileValue 'edgeStartupTabs'
$startupTabs = @() $startupTabs = @()
if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) { # Empty configured list = "no startup tabs", and must be honoured. $null (key
# absent from both profile and site-config) is the only "not configured" signal.
# Requiring .Count -gt 0 would make "edgeStartupTabs": [] fall through to the
# Plant Apps + WJ Shopfloor + Dashboard fallback below - the same
# configuring-none-gives-everything bug fixed in Configure-PC.ps1,
# 06-OrganizeDesktop.ps1 and 07-TaskbarLayout.ps1.
if ($null -ne $cfgTabs) {
foreach ($tab in $cfgTabs) { foreach ($tab in $cfgTabs) {
$fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' } $fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' }
$url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback $url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback
@@ -213,10 +219,10 @@ if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
$plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications' $plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
if ($plantApps) { $startupTabs += $plantApps } if ($plantApps) { $startupTabs += $plantApps }
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'http://tsgwp00524.logon.ds.ge.com/' $shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
if ($shopFloorHome) { $startupTabs += $shopFloorHome } if ($shopFloorHome) { $startupTabs += $shopFloorHome }
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/' $dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor'
if ($dashboard) { $startupTabs += $dashboard } if ($dashboard) { $startupTabs += $dashboard }
} }

View File

@@ -0,0 +1,115 @@
# Apply-MachineNumber.ps1 - SYSTEM-context worker for the two-task machine
# number flow. Triggered on-demand by Prompt-MachineNumber.ps1 via
# `schtasks /run /tn "WT-Apply-MachineNumber"`. Reads the requested number
# from a file the GUI script wrote, invokes Update-MachineNumber as SYSTEM
# (full HKLM + ProgramData access), writes a result JSON for the GUI to
# display, then cleans up.
#
# Why SYSTEM:
# The eDNC reg key (HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\
# General\MachineNo) and UDC settings JSON live in HKLM + ProgramData
# respectively - both require admin to write. The OLD design granted
# BUILTIN\Users SetValue + Modify via 02-MachineNumberACLs.ps1, but that
# was fragile (timing race with eDNC install, ACL silently failed on
# some bays) AND a security hole (any user could mess with the machine
# identity). Two-task design: GUI gathers input as logged-in user, SYSTEM
# does the actual write.
#
# Files:
# C:\Logs\SFLD\machine-number-request.txt - input, single line, new number
# C:\Logs\SFLD\machine-number-result.json - output, status fields for GUI
# C:\Logs\SFLD\Apply-MachineNumber.log - transcript
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Apply-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
function Write-Result {
param([hashtable]$Body)
$Body | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $resultFile -Encoding ascii -Force
}
Write-Host "Apply-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
try {
if (-not (Test-Path -LiteralPath $requestFile)) {
Write-Warning "No request file at $requestFile - nothing to apply."
Write-Result @{ Status = 'NoRequest'; Errors = @("request file missing: $requestFile") }
exit 0
}
$newNumber = (Get-Content -LiteralPath $requestFile -First 1 -ErrorAction Stop).Trim()
Write-Host "Requested new machine number: $newNumber"
if ($newNumber -notmatch '^\d+$') {
Write-Warning "Request is not digits-only: '$newNumber'"
Write-Result @{ Status = 'BadInput'; Requested = $newNumber; Errors = @("Not digits only: '$newNumber'") }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
exit 1
}
# Dot-source the shared helper. Update-MachineNumber.ps1 now has
# -ErrorAction Stop on the writes so failures actually throw.
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
$mnResult = Update-MachineNumber -NewNumber $newNumber -Site $site
$resultBody = @{
Status = if ($mnResult.Errors.Count -eq 0) { 'OK' } else { 'PartialErrors' }
Requested = $newNumber
Site = $site
UdcUpdated = [bool]$mnResult.UdcUpdated
EdncUpdated = [bool]$mnResult.EdncUpdated
OldUdc = $mnResult.OldUdc
OldEdnc = $mnResult.OldEdnc
UdcSettingsRestored = [bool]$mnResult.UdcSettingsRestored
UdcRestored = [bool]$mnResult.UdcRestored
MTConnectUpdated = $mnResult.MTConnectUpdated
MachineNumberTxtUpdated = [bool]$mnResult.MachineNumberTxtUpdated
Errors = $mnResult.Errors
AppliedAt = (Get-Date -Format 'o')
AppliedAs = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
}
Write-Result -Body $resultBody
Write-Host "Update-MachineNumber result:"
Write-Host " UdcUpdated = $($mnResult.UdcUpdated)"
Write-Host " EdncUpdated = $($mnResult.EdncUpdated)"
Write-Host " Errors = $($mnResult.Errors.Count)"
if ($mnResult.Errors) { $mnResult.Errors | ForEach-Object { Write-Host " FAILED: $_" } }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
# On clean success, also unregister the Prompt logon task. Prompt itself
# tries to self-unregister but it runs as a Limited user (BUILTIN\Users)
# and silently fails on Unregister-ScheduledTask (no delete right on a
# SYSTEM-registered task). We're running as SYSTEM here, so we can.
# Idempotent if Prompt already unregistered itself somehow.
if ($mnResult.Errors.Count -eq 0 -and $mnResult.EdncUpdated) {
try {
if (Get-ScheduledTask -TaskName 'Prompt Machine Number' -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName 'Prompt Machine Number' -Confirm:$false -ErrorAction Stop
Write-Host "Unregistered 'Prompt Machine Number' task (SYSTEM cleanup)."
}
} catch {
Write-Host "Could not unregister 'Prompt Machine Number': $_"
}
}
Write-Host "Apply-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
} catch {
Write-Warning "Apply threw: $_"
Write-Result @{ Status = 'Exception'; Errors = @("$_") }
} finally {
try { Stop-Transcript | Out-Null } catch {}
}

View File

@@ -93,13 +93,24 @@ if ($mnResult.EdncUpdated) { $results += "eDNC updated to $new" }
foreach ($err in $mnResult.Errors) { $results += $err -replace '^', 'FAILED: ' } foreach ($err in $mnResult.Errors) { $results += $err -replace '^', 'FAILED: ' }
# --- Show result --- # --- Show result ---
$summary = ($results -join "`n") + "`n`nTo apply eDNC changes, restart any running DncMain.exe." $summary = ($results -join "`n") + "`n`nTo apply eDNC changes, restart any running DncMain.exe.`n`nFull log: C:\Logs\SFLD\Check-MachineNumber.log"
# Force the MessageBox to topmost + take focus so it isn't hidden behind
# other windows. Without this, the result dialog can render off-screen or
# behind the FormTracePak / DNC windows and the tech misses it.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
[System.Windows.Forms.MessageBox]::Show( [System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary, $summary,
"Machine Number Updated", "Machine Number Updated",
[System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information [System.Windows.Forms.MessageBoxIcon]::Information
) | Out-Null ) | Out-Null
$tmpForm.Close()
# --- Unregister task on success --- # --- Unregister task on success ---
Write-Host "Results: $($results -join '; ')" Write-Host "Results: $($results -join '; ')"

View File

@@ -143,7 +143,21 @@ $edgePath = @(
$cfgItems = Get-ProfileValue 'startupItems' $cfgItems = Get-ProfileValue 'startupItems'
if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) { # An EMPTY configured list means "no startup items" and must be honoured.
# Get-ProfileValue returns $null only when the key is absent from BOTH the
# profile and site-config, so $null is the real "not configured" signal.
#
# The old test also required .Count -gt 0, so "startupItems": [] fell through to
# the hardcoded list below and a Display kiosk was given UDC, eDNC, Defect
# Tracker, WJ Shopfloor and Plant Apps startup shortcuts - Plant Apps launching
# Edge in a new window on a kiosk. Confirmed on 579C144 2026-08-06:
#
# C:\ProgramData\...\StartUp\Defect Tracker.lnk 16:46:03
# C:\ProgramData\...\StartUp\Plant Apps.lnk 16:46:03
#
# Configuring "none" produced "everything". Same bug as desktopApps in
# 06-OrganizeDesktop.ps1 and taskbarPins in 07-TaskbarLayout.ps1.
if ($null -ne $cfgItems) {
$items = @() $items = @()
$num = 0 $num = 0
foreach ($si in $cfgItems) { foreach ($si in $cfgItems) {
@@ -285,8 +299,13 @@ if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) {
) )
} }
# Machine-number logon task is item 6 # Machine-number logon tasks (item 6 toggle controls both)
$machineNumTaskName = 'Check Machine Number' # 2026-05-24: split into user-context Prompt + SYSTEM-context Apply.
# 'Check Machine Number' is the legacy single-task name kept for
# backward-detection on bays imaged before the split.
$machineNumPromptTask = 'Prompt Machine Number'
$machineNumApplyTask = 'Apply Machine Number'
$machineNumLegacyTask = 'Check Machine Number'
# ============================================================================ # ============================================================================
# Interactive UI # Interactive UI
@@ -354,8 +373,12 @@ foreach ($item in $items) {
Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail" Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail"
} }
# Item 6: machine number logon prompt # Item 6: machine number logon prompt. "ON" if EITHER the new Prompt task OR
$machineNumTaskExists = [bool](Get-ScheduledTask -TaskName $machineNumTaskName -ErrorAction SilentlyContinue) # the legacy Check Machine Number task is registered.
$machineNumTaskExists = [bool](
(Get-ScheduledTask -TaskName $machineNumPromptTask -ErrorAction SilentlyContinue) -or
(Get-ScheduledTask -TaskName $machineNumLegacyTask -ErrorAction SilentlyContinue)
)
$mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' } $mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' }
Write-Host " 6. $mnOn Prompt standard user for machine number if 9999" Write-Host " 6. $mnOn Prompt standard user for machine number if 9999"
@@ -419,62 +442,47 @@ if ($selection) {
# Process item 6: machine number logon task # Process item 6: machine number logon task
if ($selected -contains 6) { if ($selected -contains 6) {
if ($machineNumTaskExists) { if ($machineNumTaskExists) {
# Toggle OFF # Toggle OFF - remove Prompt + Apply (new design) AND the legacy
try { # Check Machine Number task name (in case this bay was imaged
Unregister-ScheduledTask -TaskName $machineNumTaskName -Confirm:$false -ErrorAction Stop # before the split and never re-imaged).
Write-Host " Machine number logon prompt: REMOVED" -ForegroundColor Yellow $removed = @()
$machineNumTaskExists = $false foreach ($t in @($machineNumPromptTask, $machineNumApplyTask, $machineNumLegacyTask)) {
} catch { Write-Warning " Failed to remove task: $_" } try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
$removed += $t
}
} catch { Write-Warning " Failed to remove '$t': $_" }
}
if ($removed) {
Write-Host " Machine number logon prompt: REMOVED ($($removed -join ', '))" -ForegroundColor Yellow
}
$machineNumTaskExists = $false
} else { } else {
# Toggle ON - register logon task # Toggle ON - register logon task
# The task needs to run as the logged-in user (for GUI), but # Defer task registration to the shared registrar so this code
# writing to HKLM + ProgramData requires the ACLs we pre-grant # path always matches the imaging-time path. Registrar installs
# during imaging (see task 7 / ACL pre-grant script). # BOTH the user-context "Prompt Machine Number" task and the
# SYSTEM-context "Apply Machine Number" task, sets the SDDL on
# Apply so Limited users can schtasks /run it, and cleans up
# any legacy "Check Machine Number" task name.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$checkScript = Join-Path $scriptDir 'Check-MachineNumber.ps1' $registrar = Join-Path $scriptDir 'Register-CheckMachineNumberTask.ps1'
if (-not (Test-Path -LiteralPath $registrar)) {
if (-not (Test-Path -LiteralPath $checkScript)) { $registrar = 'C:\Enrollment\shopfloor-setup\Shopfloor\Register-CheckMachineNumberTask.ps1'
# Fallback: check enrollment staging dir
$checkScript = 'C:\Enrollment\shopfloor-setup\Shopfloor\Check-MachineNumber.ps1'
} }
if (Test-Path -LiteralPath $registrar) {
if (Test-Path -LiteralPath $checkScript) {
try { try {
$action = New-ScheduledTaskAction ` & $registrar
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$checkScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as the logged-in user (needs GUI for InputBox), NOT
# SYSTEM (SYSTEM can't show UI to the user's desktop).
$principal = New-ScheduledTaskPrincipal `
-GroupId 'S-1-5-32-545' `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask `
-TaskName $machineNumTaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green
Write-Host " (will auto-disable after machine number is set)" -ForegroundColor DarkGray Write-Host " (Prompt user-task + Apply SYSTEM-task registered;" -ForegroundColor DarkGray
Write-Host " will auto-disable after machine number is set)" -ForegroundColor DarkGray
$machineNumTaskExists = $true $machineNumTaskExists = $true
} catch { } catch {
Write-Warning " Failed to register task: $_" Write-Warning " Register-CheckMachineNumberTask failed: $_"
} }
} else { } else {
Write-Warning " Check-MachineNumber.ps1 not found at $checkScript" Write-Warning " Register-CheckMachineNumberTask.ps1 not found at $registrar"
} }
} }
} }

View File

@@ -0,0 +1,200 @@
# Prompt-MachineNumber.ps1 - User-context GUI script for the two-task
# machine number flow. Triggered AtLogOn for any BUILTIN\Users member.
#
# Flow:
# 1. Read current UDC + eDNC values (read-only - no privileges needed).
# 2. If neither is 9999, unregister self and exit (this PC is set up).
# 3. Show InputBox for new machine number.
# 4. Write number to C:\Logs\SFLD\machine-number-request.txt.
# 5. Trigger the SYSTEM-context Apply-MachineNumber task via
# schtasks /run. SYSTEM has full HKLM + ProgramData access so the
# actual write happens with proper privileges - the prompted user
# never needs HKLM write rights (security improvement over the old
# 02-MachineNumberACLs.ps1 ACL-grant hack).
# 6. Poll for C:\Logs\SFLD\machine-number-result.json (30s timeout).
# 7. Show result MessageBox. Unregister self on success.
#
# Why this script doesn't do the writes itself: GUI is required (InputBox),
# but GUI requires user-context (SYSTEM can't render to user desktop on
# modern Windows). The user-context dialog gathers input; the SYSTEM task
# does privileged writes.
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Prompt-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
Write-Host "Prompt-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
Add-Type -AssemblyName Microsoft.VisualBasic
Add-Type -AssemblyName System.Windows.Forms
$taskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
# --- Read current values (read-only, no perms needed) ---
$currentMN = Get-CurrentMachineNumber
$currentUdc = $currentMN.Udc
$currentEdnc = $currentMN.Ednc
Write-Host "UDC machine number: $(if ($currentUdc) { $currentUdc } else { '(not found)' })"
Write-Host "eDNC machine number: $(if ($currentEdnc) { $currentEdnc } else { '(not found)' })"
if ($currentUdc -ne '9999' -and $currentEdnc -ne '9999') {
Write-Host "Machine number is set (not 9999). Unregistering Prompt task and exiting."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
Write-Host "Placeholder 9999 detected - showing prompt."
# --- Show prompt ---
$promptLines = @()
$promptLines += "The machine number on this PC is still set to the"
$promptLines += "placeholder value (9999). Please enter the correct"
$promptLines += "machine number for this workstation."
$promptLines += ""
if ($currentUdc) { $promptLines += "Current UDC: $currentUdc" }
if ($currentEdnc) { $promptLines += "Current eDNC: $currentEdnc" }
$promptLines += ""
$promptLines += "Enter the new Machine Number:"
$prompt = $promptLines -join "`n"
$new = [Microsoft.VisualBasic.Interaction]::InputBox($prompt, "Set Machine Number", "")
if ([string]::IsNullOrWhiteSpace($new)) {
Write-Host "User cancelled. Will prompt again next logon."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$new = $new.Trim()
if ($new -notmatch '^\d+$') {
Write-Host "Invalid input: '$new' (not digits only). Showing error and re-prompting next logon."
[System.Windows.Forms.MessageBox]::Show(
"Machine number must be digits only.`n`nYou entered: '$new'`n`nThe prompt will appear again at next logon.",
"Invalid Machine Number",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Hand off to SYSTEM task ---
# Clean any stale request / result files first so we read fresh ones.
Remove-Item -LiteralPath $requestFile, $resultFile -Force -ErrorAction SilentlyContinue
try {
Set-Content -LiteralPath $requestFile -Value $new -Encoding ascii -Force -ErrorAction Stop
} catch {
[System.Windows.Forms.MessageBox]::Show(
"Could not write request file at $requestFile`n`n$_`n`nThe prompt will appear again at next logon.",
"Machine Number Request Failed",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
Write-Host "Wrote $requestFile with '$new'. Triggering SYSTEM apply task..."
& schtasks.exe /run /tn $applyTaskName 2>&1 | ForEach-Object { Write-Host " schtasks: $_" }
# --- Wait for result ---
$deadline = (Get-Date).AddSeconds(60)
$result = $null
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $resultFile) {
try {
$result = Get-Content -LiteralPath $resultFile -Raw -ErrorAction Stop | ConvertFrom-Json
break
} catch { Start-Sleep -Milliseconds 200 }
}
Start-Sleep -Milliseconds 500
}
# Make the result MessageBox topmost so it shows above the FormTracePak /
# DNC windows and isn't missed.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
if (-not $result) {
Write-Host "Timed out waiting for SYSTEM apply task to produce result file ($resultFile)."
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
"Timed out waiting for the SYSTEM update task to complete.`n`nCheck:`n C:\Logs\SFLD\Apply-MachineNumber.log`n C:\Logs\SFLD\Prompt-MachineNumber.log`n`nThe prompt will appear again at next logon.",
"Machine Number Update Timed Out",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
) | Out-Null
$tmpForm.Close()
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
# Build summary from result JSON
$lines = @()
$lines += "Requested: $($result.Requested)"
$lines += ""
if ($result.UdcUpdated) { $lines += "UDC updated to $($result.Requested)" } else { $lines += "UDC: not updated (UDC may not be installed)" }
if ($result.EdncUpdated) { $lines += "eDNC updated to $($result.Requested)" } else { $lines += "eDNC: not updated" }
if ($result.UdcSettingsRestored) { $lines += "UDC settings restored from SFLD" }
if ($result.UdcRestored) { $lines += "UDC live data restored from SFLD" }
if ($result.MachineNumberTxtUpdated) { $lines += "machine-number.txt updated" }
if ($result.MTConnectUpdated -and $result.MTConnectUpdated.Count -gt 0) {
$lines += ""
$lines += "MTConnect Devices.xml updates:"
$result.MTConnectUpdated | ForEach-Object { $lines += " - $_" }
}
if ($result.Errors -and $result.Errors.Count -gt 0) {
$lines += ""
$lines += "FAILURES:"
$result.Errors | ForEach-Object { $lines += " - $_" }
}
$lines += ""
$lines += "Status: $($result.Status)"
$lines += "Logs: C:\Logs\SFLD\Apply-MachineNumber.log"
$lines += " C:\Logs\SFLD\Prompt-MachineNumber.log"
$lines += ""
$lines += "To apply eDNC changes, restart any running DncMain.exe."
$summary = $lines -join "`n"
$icon = if ($result.Status -eq 'OK') { [System.Windows.Forms.MessageBoxIcon]::Information } else { [System.Windows.Forms.MessageBoxIcon]::Warning }
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary,
"Machine Number Update Result",
[System.Windows.Forms.MessageBoxButtons]::OK,
$icon
) | Out-Null
$tmpForm.Close()
# Clean up result file for the next round.
Remove-Item -LiteralPath $resultFile -Force -ErrorAction SilentlyContinue
# Only unregister the Prompt task on full success (no errors AND eDNC
# updated to the requested value). If anything failed, leave it registered
# for next logon retry.
if ($result.Status -eq 'OK' -and $result.EdncUpdated) {
Write-Host "All updates succeeded. Unregistering Prompt task."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
} else {
Write-Host "Some updates failed or skipped. Prompt task stays registered for next logon retry."
}
Write-Host "Prompt-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,179 @@
# Register-CheckMachineNumberTask.ps1 - Register the two-task machine
# number flow at imaging time:
#
# 1. "Prompt Machine Number" - AtLogOn, BUILTIN\Users, Limited.
# Shows InputBox + writes new number to a request file, then triggers
# the SYSTEM task via schtasks /run.
#
# 2. "Apply Machine Number" - on-demand only (no trigger), SYSTEM,
# RunLevel Highest. Reads the request file, calls Update-MachineNumber
# with full HKLM + ProgramData access, writes a result JSON, removes
# the request file. No GUI - the Prompt task polls the result file
# and displays the dialog.
#
# Replaces the old single-task design that ran as the logged-in user with
# pre-granted BUILTIN\Users HKLM ACLs (02-MachineNumberACLs.ps1). That
# approach was fragile (timing race with eDNC install, silent ACL skip)
# and a security hole (any user could write to the machine-identity reg
# key). With SYSTEM doing the actual writes, no ACL grants needed.
#
# Idempotent: safe to re-run. Existing tasks are overwritten.
#
# File kept named Register-CheckMachineNumberTask.ps1 (rather than
# Register-MachineNumberTasks.ps1) so Run-ShopfloorSetup's existing
# discovery doesn't need editing.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
$logFile = Join-Path $logDir 'register-checkmn.log'
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-RegLog '=== Register-CheckMachineNumberTask start ==='
$promptTaskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$oldTaskName = 'Check Machine Number' # legacy, removed below
# Clean up the legacy single-task name from prior imaging cycles.
try {
if (Get-ScheduledTask -TaskName $oldTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $oldTaskName -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered legacy task '$oldTaskName'"
}
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
# PC types that have no machine number by design. Arming the prompt on these
# guarantees a dialog nobody can answer: startnet only collects a machine number
# for the machine-tool types, so everything else is left on the 9999 placeholder
# and the check below would otherwise fire on every one of them.
#
# Observed on 579C144 (a Display) 2026-08-06: PreInstall logged
# "Machine number: 9999 (default placeholder)" and the logon prompt armed itself
# on a kiosk with no keyboard.
$noMachineNumberTypes = @(
'gea-shopfloor-display'
)
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($noMachineNumberTypes -contains $pcType)) {
Write-RegLog "PC type '$pcType' has no machine number by design. Not registering the prompt."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
return
}
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
$mnFile = 'C:\Enrollment\machine-number.txt'
$mnAtImaging = '9999'
if (Test-Path -LiteralPath $mnFile) {
$raw = (Get-Content -LiteralPath $mnFile -First 1 -ErrorAction SilentlyContinue)
if ($raw) { $mnAtImaging = $raw.Trim() }
}
Write-RegLog "Imaging-time machine-number.txt = '$mnAtImaging'"
if ($mnAtImaging -ne '9999') {
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering tasks."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
Write-RegLog '=== Register-CheckMachineNumberTask end (no-op) ==='
exit 0
}
# Resolve script paths. Prefer the staged shopfloor-setup tree on C:
# (where Run-ShopfloorSetup ran from); fall back to the same dir as this
# Register script if invoked standalone.
function Resolve-Script {
param([string]$LeafName)
$p = Join-Path $PSScriptRoot $LeafName
if (Test-Path -LiteralPath $p) { return $p }
$p = "C:\Enrollment\shopfloor-setup\Shopfloor\$LeafName"
if (Test-Path -LiteralPath $p) { return $p }
return $null
}
$promptScript = Resolve-Script 'Prompt-MachineNumber.ps1'
$applyScript = Resolve-Script 'Apply-MachineNumber.ps1'
if (-not $promptScript) { Write-RegLog "Prompt-MachineNumber.ps1 not found - cannot register"; exit 1 }
if (-not $applyScript) { Write-RegLog "Apply-MachineNumber.ps1 not found - cannot register"; exit 1 }
Write-RegLog "Prompt script: $promptScript"
Write-RegLog "Apply script: $applyScript"
# --- Prompt task (user-context, GUI) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$promptScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Group SID S-1-5-32-545 = BUILTIN\Users (catches ShopFloor + support/admin
# users that log in interactively). RunLevel Limited - no elevation; the
# actual writes happen in the SYSTEM Apply task below.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName $promptTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$promptTaskName' (AtLogOn, BUILTIN\Users, Limited)"
} catch {
Write-RegLog "FAILED to register '$promptTaskName': $_"
exit 1
}
# --- Apply task (SYSTEM, on-demand) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$applyScript`""
# No trigger - the Prompt task starts this via schtasks /run /tn.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName $applyTaskName -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$applyTaskName' (on-demand, SYSTEM, Highest)"
# Default SDDL on a SYSTEM-owned task only grants Admins + SYSTEM
# FullAccess - BUILTIN\Users can't see or run it via schtasks /run.
# Add an ACE granting BUILTIN\Users GenericRead + GenericExecute so the
# user-context Prompt task can trigger this Apply task on demand. They
# still can't modify/delete it - only read+execute.
try {
$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$taskObj = $svc.GetFolder('\').GetTask($applyTaskName)
# GenericRead = 0x80000000 (GR), GenericExecute = 0x20000000 (GX)
# BU = BUILTIN\Users
$newSd = 'O:BAG:BAD:(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)'
# SetSecurityDescriptor flag 0 = default, persists DACL change.
$taskObj.SetSecurityDescriptor($newSd, 0)
Write-RegLog "Granted BUILTIN\Users GR+GX on '$applyTaskName' (so Limited users can schtasks /run)"
} catch {
Write-RegLog "FAILED to set task SDDL on '$applyTaskName': $_ (Limited users may not be able to trigger Apply)"
}
} catch {
Write-RegLog "FAILED to register '$applyTaskName': $_"
exit 1
}
Write-RegLog '=== Register-CheckMachineNumberTask end ==='
exit 0

View File

@@ -16,6 +16,42 @@
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
# PC types that are self-contained and must NOT map S:. A Display kiosk is
# Entra-joined with local accounts and no SFLD credentials, so the mapping can
# only ever fail - once per logon, forever.
#
# Run-ShopfloorSetup.ps1 already gates the CALL to this script on the same list,
# but on 579C144 (2026-08-06) the Run entry was registered on a Display anyway:
#
# [15:07:55] Set HKLM:\...\Run\GE Map SFLD Share = ...Map-SfldShare.ps1
#
# with no "Skipping S: drive logon mapper" in the log, so something in the
# finalization phase reaches this script past the call-site gate. Gating here as
# well makes the outcome correct regardless of who invokes it.
$selfContainedTypes = @('Display', 'gea-shopfloor-display')
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcType = ''
if (Test-Path -LiteralPath $pcTypeFile) {
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue)
if ($pcType) { $pcType = $pcType.Trim() }
}
if ($pcType -and ($selfContainedTypes -contains $pcType)) {
$d = 'C:\Logs\SFLD'
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force -EA SilentlyContinue | Out-Null }
Add-Content -Path (Join-Path $d 'register-mapshare.log') -EA SilentlyContinue `
-Value ("[{0}] [INFO] PC type '{1}' is self-contained - not registering the S: mapper." -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $pcType)
Write-Host "PC type '$pcType' is self-contained - skipping S: drive mapper."
# Remove a stale entry from an earlier image or an earlier code path.
try {
$runKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
if ((Get-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -EA SilentlyContinue)) {
Remove-ItemProperty -Path $runKey -Name 'GE Map SFLD Share' -Force -EA Stop
Write-Host " removed stale 'GE Map SFLD Share' Run entry."
}
} catch { }
return
}
$installRoot = 'C:\Program Files\GE\SfldShare' $installRoot = 'C:\Program Files\GE\SfldShare'
$mapScript = Join-Path $installRoot 'Map-SfldShare.ps1' $mapScript = Join-Path $installRoot 'Map-SfldShare.ps1'
$logDir = 'C:\Logs\SFLD' $logDir = 'C:\Logs\SFLD'

View File

@@ -1,45 +0,0 @@
# Disable-WiredNics.ps1
# Disables every Up wired (MediaType 802.3) NIC and records their names to
# C:\Enrollment\disabled-wired-nics.txt so Monitor-IntuneProgress can
# re-enable them once Report IP has run on WiFi-only.
#
# Reason: GE's Intune Proactive-Remediation "Report IP" script enumerates
# Get-NetIPAddress and POSTs every IP it finds to a GE webhook. When a
# shopfloor bay is still cabled to the air-gapped PXE LAN (10.9.100.0/24),
# the webhook sees 10.9.100.x as one of the device's IPs and tags the bay
# "not on corp net". A dynamic group / assignment-filter at GE then excludes
# the bay from receiving the SFLD ConfigurationProfile (Function + SasToken
# OMA-URI) -> Phase 2 "Device Configuration" never closes.
#
# Killing the wired NIC after stage 2 reports + before AAD-join makes the
# bay's first Report IP fire see corp-WiFi IP only. The bay is tagged
# clean, dynamic group eligibility flips, SFLD policy delivers normally.
# Monitor-IntuneProgress re-enables the NIC once Report IP's log file
# appears at C:\Logs\GE_Report_IP_Address*.txt.
$ErrorActionPreference = 'Continue'
$stateFile = 'C:\Enrollment\disabled-wired-nics.txt'
try {
$wired = Get-NetAdapter -ErrorAction Stop |
Where-Object {
$_.Status -eq 'Up' -and
$_.MediaType -eq '802.3' -and
$_.HardwareInterface -eq $true
}
if (-not $wired) {
Write-Host "Disable-WiredNics: no Up wired NICs found - nothing to disable."
return
}
$names = $wired | ForEach-Object { $_.Name }
$names | Out-File -FilePath $stateFile -Encoding ASCII -Force
Write-Host ("Disable-WiredNics: persisted {0} NIC name(s) -> {1}" -f $names.Count, $stateFile)
foreach ($n in $names) { Write-Host " - $n" }
$wired | Disable-NetAdapter -Confirm:$false -ErrorAction Continue
Write-Host "Disable-WiredNics: NICs disabled. Re-enable triggered by Monitor when GE_Report_IP_Address log appears."
} catch {
Write-Warning "Disable-WiredNics: failed: $_"
}

View File

@@ -66,6 +66,15 @@ if (Test-Path -LiteralPath $subtypeFile) {
$pcSubtype = (Get-Content -LiteralPath $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim() $pcSubtype = (Get-Content -LiteralPath $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
} }
# Display sub-type fallback: if pc-subtype.txt is absent (post-rename-reorg
# default) but display-type.txt exists, use it as the subtype. Lets the
# Display-Lobby / Display-Dashboard / gea-shopfloor-display-{lobby,dashboard}
# profile keys resolve correctly for Display PCs.
$displayTypeFile = 'C:\Enrollment\display-type.txt'
if (-not $pcSubtype -and ($pcType -ieq 'gea-shopfloor-display' -or $pcType -ieq 'Display') -and (Test-Path -LiteralPath $displayTypeFile)) {
$pcSubtype = (Get-Content -LiteralPath $displayTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Build the profile key: "Standard-Machine", "CMM", "Display-Lobby", etc. # Build the profile key: "Standard-Machine", "CMM", "Display-Lobby", etc.
$profileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType } $profileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
@@ -82,6 +91,8 @@ $pcProfileAliasGroups = @(
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Display-Lobby', 'gea-shopfloor-display-Lobby', 'gea-shopfloor-display-lobby'),
@('Display-Dashboard', 'gea-shopfloor-display-Dashboard', 'gea-shopfloor-display-dashboard'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat')
) )
@@ -102,6 +113,28 @@ if ($siteConfig -and $siteConfig.pcProfiles -and $profileKey) {
} }
} }
} }
# Subtype-strip fallback: a compound key like "gea-shopfloor-cmm-doda" (DODA
# CMM bays set pc-subtype.txt=doda) has no profile of its own. Fall back to
# the BARE pc-type ("gea-shopfloor-cmm" -> "CMM"). Without this the profile
# resolves to $null and callers silently use hardcoded defaults (wrong
# desktop icons + unwanted startup items).
if (-not $pcProfile -and $pcSubtype) {
$bare = $pcType
$pcProfile = $siteConfig.pcProfiles.$bare
if (-not $pcProfile) {
foreach ($g in $pcProfileAliasGroups) {
if ($g -icontains $bare) {
foreach ($alias in $g) {
if ($alias -ieq $bare) { continue }
$candidate = $siteConfig.pcProfiles.$alias
if ($candidate) { $pcProfile = $candidate; break }
}
break
}
}
}
if ($pcProfile) { $profileKey = $bare }
}
} }
if ($pcProfile) { if ($pcProfile) {

View File

@@ -80,11 +80,11 @@ param(
# The persistent @logon sync_intune task takes over after reboot. # The persistent @logon sync_intune task takes over after reboot.
[switch]$PostPpkg, [switch]$PostPpkg,
# -PostPpkgSettleSec: how long to wait before the clean reboot when # -PostPpkgSettleSec: how long to wait before the clean reboot when
# in -PostPpkg mode. 60s empirically gives MDM enough time to push # in -PostPpkg mode. 120s empirically gives MDM enough time to push
# the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs # the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs
# see sync_intune resume after reboot, the readiness signals are # see sync_intune resume after reboot, the readiness signals are
# already meaningful instead of "policy still pulling". # already meaningful instead of "policy still pulling".
[int]$PostPpkgSettleSec = 60 [int]$PostPpkgSettleSec = 120
) )
# ============================================================================ # ============================================================================
@@ -186,7 +186,10 @@ $script:cache = @{
EnrollmentId = $null EnrollmentId = $null
DeviceId = $null DeviceId = $null
DeviceIdReported = $false DeviceIdReported = $false
SfldPolicyPushed = $false
CredsReadyPushed = $false
LockdownCompletePushed = $false LockdownCompletePushed = $false
ReportIpForced = $false
InternetAccessDeleted = $false InternetAccessDeleted = $false
} }
@@ -213,7 +216,12 @@ function Get-Phase1 {
# on-screen QR works but the dashboard QR did not. # on-screen QR works but the dashboard QR did not.
if (-not $script:cache.AzureAdJoined -or -not $script:cache.DeviceId) { if (-not $script:cache.AzureAdJoined -or -not $script:cache.DeviceId) {
try { try {
$dsreg = dsregcmd /status 2>&1 # dsregcmd on Win11 emits ANSI escape codes (\x1B[7m...\x1B[0m)
# around field names when its output is treated as a terminal.
# Captured output then contains those codes between e.g.
# "DeviceId" and ":", breaking a tight regex like
# 'DeviceId\s*:\s*<value>'. Strip ANSI sequences before matching.
$dsreg = (dsregcmd /status 2>&1 | Out-String) -replace '\x1B\[[0-9;]*[A-Za-z]', ''
if (-not $script:cache.AzureAdJoined -and $dsreg -match 'AzureAdJoined\s*:\s*YES') { if (-not $script:cache.AzureAdJoined -and $dsreg -match 'AzureAdJoined\s*:\s*YES') {
$script:cache.AzureAdJoined = $true $script:cache.AzureAdJoined = $true
} }
@@ -223,61 +231,12 @@ function Get-Phase1 {
} catch {} } catch {}
} }
# Report IP log presence drives two independent actions that USED to be # idx=7 push happens later in Get-Phase1 when Intune-registration
# bundled inside the DeviceId-push gate. Splitting them so re-enable # essentials are all green (see WiFi-swap block). The legacy
# fires even if DeviceId hasn't been captured yet (e.g. AAD join lag, # wired-NIC re-enable + reportIpLog-gated idx=7 retry was retired
# dsregcmd parse miss): # after the PXE LAN renumber to 172.16.9.0/24 - PXE LAN addresses
# # no longer pass GE Report IP's StartsWith("10.") filter, so the
# 1. Re-enable wired NICs as soon as the log lands + state file exists. # wired-disable / re-enable dance is unnecessary.
# 2. Push idx=7 once DeviceId is captured AND the log exists.
$reportIpLog = Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue |
Select-Object -First 1
$nicListFile = 'C:\Enrollment\disabled-wired-nics.txt'
$justReEnabled = $false
if ($reportIpLog -and (Test-Path $nicListFile)) {
try {
$nicNames = Get-Content $nicListFile -ErrorAction Stop
foreach ($n in $nicNames) {
if ([string]::IsNullOrWhiteSpace($n)) { continue }
try { Enable-NetAdapter -Name $n -Confirm:$false -ErrorAction Stop }
catch { Write-Warning "Enable-NetAdapter '$n' failed: $_" }
}
# Wait for DHCP renewal + route table update + reachability to
# PXE server. 1 second wasn't enough in field testing - the
# subsequent idx=7 push fired into the void before the wired
# NIC was carrying traffic.
Start-Sleep -Seconds 5
Remove-Item $nicListFile -Force -ErrorAction SilentlyContinue
$justReEnabled = $true
} catch {
Write-Warning "Re-enable wired NICs failed: $_"
}
}
# Push DeviceId / idx=7 once, when both DeviceId is captured and the
# Report IP log has landed (dashboard QR renders from DeviceId).
# Retry up to 6x with backoff because the imminent LAPS-prompt reboot
# gives us only seconds and the wired NIC may still be settling.
if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported -and $reportIpLog) {
Ensure-SendPxeStatus
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
$attempts = if ($justReEnabled) { 6 } else { 1 }
for ($i = 0; $i -lt $attempts; $i++) {
$err = $null
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
$script:cache.DeviceIdReported = $true
break
} catch { $err = $_ }
if ($i -lt $attempts - 1) { Start-Sleep -Seconds 2 }
}
if (-not $script:cache.DeviceIdReported -and $err) {
Write-Warning "idx=7 push failed after $attempts attempts: $err"
}
}
}
# Lockdown-applied auto-completion. Fleet-wide reality: bays use a LOCAL # Lockdown-applied auto-completion. Fleet-wide reality: bays use a LOCAL
# ShopFloor account, so AzureAdPrt stays NO and user-scoped Intune policies # ShopFloor account, so AzureAdPrt stays NO and user-scoped Intune policies
@@ -358,35 +317,99 @@ function Get-Phase1 {
} catch {} } catch {}
# Once Intune registration is fully landed (AAD-joined + Intune-enrolled # Once Intune registration is fully landed (AAD-joined + Intune-enrolled
# + EnterpriseMgmt task present + baseline policies arrived), three # + EnterpriseMgmt task present + baseline policies arrived):
# things must happen together: # - Push idx=7 to PXE dashboard with the DeviceId / QR.
# 1. Delete INTERNETACCESS WiFi profile (gets bay off 172.16.x) # The INTERNETACCESS -> AESFMA WiFi swap uses a VERIFY-BEFORE-DELETE
# 2. Connect AESFMA (gets bay onto corp 10.x via EAP-TLS - cert is # pattern so the bay never ends up with no path:
# already in LocalMachine\My thanks to Intune SCEP) # 1. Phase 1 essentials must be COMPLETE (Intune registration done).
# 3. Push idx=7 to the PXE dashboard with the captured DeviceId so # 2. Attempt netsh wlan connect AESFMA while INTERNETACCESS still up.
# the dashboard card shows the QR for the Intune device id. # 3. Wait ~8s, parse netsh wlan show interfaces for SSID=AESFMA +
# All three fire in one shot per Monitor lifetime via cache flags. # State=connected.
# 4. ONLY after operationally connected to AESFMA, delete INTERNETACCESS.
# 5. If connect fails (cert not provisioned yet, etc), keep
# INTERNETACCESS, retry next tick.
$phase1Essential = ($script:cache.AzureAdJoined -and $phase1Essential = ($script:cache.AzureAdJoined -and
$script:cache.IntuneEnrolled -and $script:cache.IntuneEnrolled -and
$script:cache.EmTaskExists -and $script:cache.EmTaskExists -and
$policiesBaselineReady) $policiesBaselineReady)
if ($phase1Essential -and -not $script:cache.InternetAccessDeleted) { if ($phase1Essential -and -not $script:cache.InternetAccessDeleted) {
try { # Helper: split netsh wlan show interfaces output into one block
Write-Host "Intune registration complete - deleting INTERNETACCESS profile + reconnecting to AESFMA..." # per adapter (delimited by lines starting with "Name :"), then
$delOut = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String # check whether any block contains SSID=AESFMA AND State=connected
Write-Host $delOut # in either order.
Start-Sleep -Seconds 2 function Test-AESFMAConnected {
$conOut = netsh wlan connect name="AESFMA" ssid="AESFMA" 2>&1 | Out-String $out = netsh wlan show interfaces 2>$null | Out-String
Write-Host $conOut if (-not $out) { return $false }
$blocks = ($out -split '(?ms)(?=^\s*Name\s*:\s*)')
foreach ($b in $blocks) {
if (($b -match 'SSID\s*:\s*AESFMA\b') -and ($b -match 'State\s*:\s*connected\b')) {
return $true
}
}
return $false
}
if (Test-AESFMAConnected) {
# Already connected (either via WLAN auto-join, prior tick's
# attempt, or an operator manual connect). Clean up
# INTERNETACCESS, force a Report IP push from the AESFMA-attached
# corp address, and stop trying.
Write-Host "AESFMA connected - cleaning up INTERNETACCESS..."
$null = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String
$script:cache.InternetAccessDeleted = $true $script:cache.InternetAccessDeleted = $true
} catch { # Force the GE Report IP exe to post the new (AESFMA corp) IP
Write-Warning "WiFi swap (INTERNETACCESS -> AESFMA) failed: $_" # to the Tines webhook immediately - default trigger is on
# DHCP event + slow interval, this skips the wait.
if (-not $script:cache.ReportIpForced) {
$rip = 'C:\ProgramData\ReportIP\GE_ReportIP_3_v1.EXE'
if (Test-Path $rip) {
try {
Start-Process -FilePath $rip -ArgumentList '/ForceUpdate=True','/S' -WindowStyle Hidden -ErrorAction Stop
Write-Host "Forced GE Report IP push (corp-AESFMA IP)."
$script:cache.ReportIpForced = $true
} catch {
Write-Warning "Force GE Report IP failed: $_"
}
}
}
} else {
# Not connected. Try without pre-gating on a cert chain check -
# the X509Chain.Build can return a partial chain (e.g. missing
# intermediate) which made the strict root-thumbprint match
# false even when EAP-TLS would actually succeed. Let netsh
# itself be the source of truth via the connect attempt.
# Rate-limit: at most one attempt every 30 seconds to avoid
# spam when AESFMA isn't actually reachable.
$now = Get-Date
if (-not $script:cache.AesfmaNextAttempt -or $now -ge $script:cache.AesfmaNextAttempt) {
try {
Write-Host "Attempting AESFMA connect (INTERNETACCESS stays up as fallback)..."
$null = netsh wlan connect name="AESFMA" ssid="AESFMA" 2>&1 | Out-String
Start-Sleep -Seconds 15
if (Test-AESFMAConnected) {
Write-Host "AESFMA connected. Deleting INTERNETACCESS profile..."
$null = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String
$script:cache.InternetAccessDeleted = $true
} else {
Write-Host "AESFMA connect not yet operational - will retry in 30s."
$script:cache.AesfmaNextAttempt = $now.AddSeconds(30)
}
} catch {
Write-Warning "AESFMA connect/swap attempt failed: $_"
$script:cache.AesfmaNextAttempt = $now.AddSeconds(30)
}
}
} }
} }
if ($phase1Essential -and $script:cache.DeviceId -and -not $script:cache.DeviceIdReported) { # idx=7 push fires AS SOON AS DeviceId is captured. We want the QR
# to render on the PXE dashboard BEFORE the Intune-driven LAPS-prompt
# reboot lands (~1 min after GE Report IP posts its log). Phase 1
# essentials, SCEP cert delivery, and AESFMA connection all take
# longer than DeviceId capture, so don't gate on any of those.
if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) { if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try { try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune registration complete' ` Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
-StageIndex 7 -StageTotal 8 ` -StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop -IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
$script:cache.DeviceIdReported = $true $script:cache.DeviceIdReported = $true
@@ -863,21 +886,13 @@ function Format-Snapshot {
# not just "arriving". Stops the category prompt firing pre-first-reboot # not just "arriving". Stops the category prompt firing pre-first-reboot
# when only ~4 subkeys are present (we tested this empirically; clicking # when only ~4 subkeys are present (we tested this empirically; clicking
# "assign category" at 4 subkeys = imaging stalls + re-image required). # "assign category" at 4 subkeys = imaging stalls + re-image required).
# Report IP log presence is part of Phase 1 completion. Without that log
# we know GE's Proactive-Remediation script hasn't fired on WiFi-only
# yet, which means the SFLD ConfigurationProfile assignment filter still
# sees a leaked 10.9.100.x IP and Phase 2 won't unblock. Don't call
# registration "done" until Report IP has cleared.
$reportIpDone = [bool](Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue | Select-Object -First 1)
$p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and $p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and
$Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady -and $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady)
$reportIpDone)
$p1Status = Get-PhaseStatus @( $p1Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false }, @{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false },
@{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false }, @{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false },
@{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false }, @{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false },
@{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false }, @{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false }
@{ Ok = $reportIpDone; Failed = $false }
) )
# Phase 6 / Lockdown (shared by both flows, rendered last). # Phase 6 / Lockdown (shared by both flows, rendered last).
@@ -1235,6 +1250,35 @@ try {
while ($true) { while ($true) {
$snap = Get-Snapshot $snap = Get-Snapshot
# Push sub-stage transitions to PXE dashboard so the operator sees
# whether the bay is waiting on category assignment, or has
# progressed past it. idx stays 7 across all three; the stage
# string drives the friendly label in imaging.html.
if (-not $script:cache.SfldPolicyPushed -and
$snap.Phase2.SfldRoot -and $snap.Phase2.FunctionOk -and $snap.Phase2.SasTokenOk) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Phase 2 SFLD policy delivered (device configuration)' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction SilentlyContinue
$script:cache.SfldPolicyPushed = $true
} catch {}
}
}
if (-not $script:cache.CredsReadyPushed -and
$snap.Phase4.CredsPopulated -and
$snap.Phase3.InstallComplete -and
$snap.Phase2.SfldRoot -and $snap.Phase2.FunctionOk -and $snap.Phase2.SasTokenOk) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Phases 1-4 complete - ready for lockdown (ARTS request)' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction SilentlyContinue
$script:cache.CredsReadyPushed = $true
} catch {}
}
}
# Retry QR code every cycle until it actually renders. dsregcmd # Retry QR code every cycle until it actually renders. dsregcmd
# may report AzureAdJoined=YES before DeviceId is populated, so # may report AzureAdJoined=YES before DeviceId is populated, so
# a single-shot refresh misses the window. # a single-shot refresh misses the window.
@@ -1338,7 +1382,15 @@ try {
$nextRetrigger = $lastSync.AddMinutes($currentInterval) $nextRetrigger = $lastSync.AddMinutes($currentInterval)
} }
Start-Sleep -Seconds $PollSecs # Tight poll while DeviceId still missing - it may take a few
# minutes after PPKG for dsregcmd to return a DeviceId, and we
# need to catch it ASAP to push idx=7 before the LAPS reboot.
# Once captured + reported, fall back to the normal cadence.
if (-not $script:cache.DeviceIdReported) {
Start-Sleep -Seconds 5
} else {
Start-Sleep -Seconds $PollSecs
}
} }
} }
catch { catch {

View File

@@ -19,7 +19,7 @@ function Send-PxeStatus {
# Only available post-AAD-join; pass it from Monitor-IntuneProgress # Only available post-AAD-join; pass it from Monitor-IntuneProgress
# once captured. The dashboard renders a QR of this value. # once captured. The dashboard renders a QR of this value.
[string]$IntuneDeviceId = '', [string]$IntuneDeviceId = '',
[string]$PxeServer = '10.9.100.1', [string]$PxeServer = '172.16.9.1',
[int]$Port = 9009, [int]$Port = 9009,
[int]$TimeoutSec = 5 [int]$TimeoutSec = 5
) )

View File

@@ -0,0 +1,44 @@
# Set-OpenTextAutoStart.ps1 - place WJ Shopfloor.lnk in the All Users
# Startup folder so HostExplorer's "WJ Shopfloor" session launches at
# every login. Idempotent: re-running is a no-op when the .lnk already
# exists at the same path.
#
# Used by per-pc-type 09-Setup scripts for shopfloor types whose only
# business app is OpenText (common, waxtrace, genspect, heattreat).
# collections + nocollections do NOT auto-start OpenText - their techs
# pick which apps via Configure-PC.ps1.
#
# Source .lnk is created by the OpenText preinstall (Setup-OpenText.ps1)
# on the public desktop. If the .lnk is missing, log a warning and exit
# 0 - imaging chain still continues; auto-start can be re-attempted on a
# subsequent login by re-running this script.
$ErrorActionPreference = 'Continue'
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp'
$publicDesktop = 'C:\Users\Public\Desktop'
$candidates = @(
Join-Path $publicDesktop 'WJ Shopfloor.lnk'
Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'WJ Shopfloor.lnk'
)
$src = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $src) {
Write-Warning "WJ Shopfloor.lnk not found on public desktop - OpenText auto-start NOT configured."
Write-Warning " Searched: $($candidates -join ' ; ')"
Write-Warning " Setup-OpenText.ps1 should create it during preinstall - check OpenText install state."
return
}
if (-not (Test-Path -LiteralPath $startupDir)) {
New-Item -Path $startupDir -ItemType Directory -Force | Out-Null
}
$dst = Join-Path $startupDir 'WJ Shopfloor.lnk'
try {
Copy-Item -LiteralPath $src -Destination $dst -Force
Write-Host "OpenText auto-start enabled: $dst (source: $src)"
} catch {
Write-Warning "Failed to copy WJ Shopfloor.lnk to startup: $_"
}

View File

@@ -41,6 +41,135 @@ function Get-CurrentMachineNumber {
return $result return $result
} }
function Update-MTConnectVariantName {
<#
.SYNOPSIS
Renames an MTConnect device across every file of a variant that carries it.
.DESCRIPTION
Makino and Fanuc do not fit the one-file-one-service shape the other
variants do.
Per the vendor's "Installation Configuration and Trouble Shooting" guide,
the device name lives in several files that must be identical or the agent
may not start, and the Adapter Manager service rewrites the COMPLETE
configuration when it stops - so an edit made while it runs is thrown away.
Makino, from a real bay capture:
Agent\Devices.xml <Device name="7502" uuid="7502" id="MC41">
Agent\Devices.EDM.xml <Device name="MAKINO-1" uuid="MAKINO-1" id="EDM41">
Data\AdapterDataFile.xml <Name>MAKINO-1</Name>
Agent\Agent.cfg Adapters { MAKINO-1 { Host=localhost ... } }
Fanuc, likewise:
Agent\devices.xml <Device id="3101" name="3101" uuid="3101">
Adapter\devices.xml the adapter's own generated copy
Adapter\adapter.xml <DeviceName>/<DeviceID>/<DeviceUUID>3101
Editing only the agent's copy leaves the adapter streaming under the old
identity. Seven of thirty-four captured Fanuc bays are in that state -
agent on the right machine number, adapter still on the bay it was
imaged from (4007 on a 7801 Toshulin, 3031 on a 7804).
The vendor's own path is ConfigurationManager.exe, a GUI tool, which a
remote renumber cannot drive. This does what it does, in the required
order: stop the manager and the agent, rewrite, start again.
Anything that cannot be renamed is reported rather than half-applied -
a bay whose files disagree is worse than one still on its old name.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$NewNumber,
[Parameter(Mandatory)][hashtable[]]$Targets,
# Overridable so the rename can be exercised against a captured bay.
[Parameter(Mandatory)][string]$Root,
# Whatever owns the files goes down first - for Makino the Adapter
# Manager rewrites its configuration from memory as it stops, and for
# Fanuc the adapter holds its own copy. Overridable only so a test can
# drive the stop/start ordering with real services.
[string[]]$Services = @()
)
$out = @{ Updated = @(); Errors = @() }
$present = @($Targets |
ForEach-Object { @{ Path = (Join-Path $Root $_.Path); Kind = $_.Kind } } |
Where-Object { Test-Path -LiteralPath $_.Path })
if (-not $present) { return $out }
# Collect EVERY name in play, not just one. A bay half-renamed by the old
# code carries two at once - Devices.xml already on the machine number while
# the adapter files still say MAKINO-n - and replacing only the name we
# happened to discover leaves the other behind, which is the very state this
# is meant to end. Gather them all, then converge every file on the target.
$names = New-Object System.Collections.Generic.HashSet[string]
foreach ($t in $present) {
$raw = Get-Content -LiteralPath $t.Path -Raw -ErrorAction SilentlyContinue
if (-not $raw) { continue }
switch ($t.Kind) {
'attr' { if ($raw -match '<Device[^>]+name="([^"]+)"') { [void]$names.Add($matches[1]) } }
'name' { if ($raw -match '<Name>\s*([^<]+?)\s*</Name>') { [void]$names.Add($matches[1]) } }
'cfg' {
# The adapter block's key is the bare line inside Adapters { }.
if ($raw -match '(?ms)Adapters\s*\{\s*\r?\n\s*([^\s{]+)') { [void]$names.Add($matches[1]) }
}
}
}
[void]$names.Remove($NewNumber)
if ($names.Count -eq 0) { return $out } # every file already on the target
# Agent is stopped too, so it re-reads Agent.cfg on the way back up.
$stopped = @()
foreach ($name in $Services) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if (-not $svc) { continue }
if ($svc.Status -ne 'Stopped') {
try {
Stop-Service -Name $name -Force -ErrorAction Stop
$stopped += $name
} catch {
$out.Errors += "MTConnect: could not stop '$name' ($_); nothing was changed"
foreach ($back in $stopped) {
try { Start-Service -Name $back -ErrorAction Stop } catch {}
}
return $out
}
}
}
foreach ($t in $present) {
try {
$content = Get-Content -LiteralPath $t.Path -Raw -ErrorAction Stop
$updated = $content
$from = @()
foreach ($old in $names) {
$escaped = [regex]::Escape($old)
switch ($t.Kind) {
'attr' { $next = $updated -replace ('"' + $escaped + '"'), ('"' + $NewNumber + '"') }
'name' { $next = $updated -replace ('<Name>\s*' + $escaped + '\s*</Name>'), ("<Name>$NewNumber</Name>") }
# The adapter block is keyed by a bare name on its own line.
'cfg' { $next = $updated -replace ('(?m)^(\s*)' + $escaped + '(\s*)$'), ("`${1}$NewNumber`${2}") }
# <DeviceName>/<DeviceID>/<DeviceUUID> in the Fanuc adapter config
'elem' { $next = $updated -replace ('(<Device(?:Name|ID|UUID)>)\s*' + $escaped + '\s*(</Device(?:Name|ID|UUID)>)'), ("`${1}$NewNumber`${2}") }
}
if ($next -ne $updated) { $from += $old; $updated = $next }
}
if ($updated -eq $content) { continue } # this file was already done
Set-Content -LiteralPath $t.Path -Value $updated -NoNewline -ErrorAction Stop
$out.Updated += "$($t.Path) ($($from -join ', ') -> $NewNumber)"
} catch {
$out.Errors += "MTConnect: rewrite failed for $($t.Path): $_"
}
}
foreach ($name in $stopped) {
try { Start-Service -Name $name -ErrorAction Stop }
catch { $out.Errors += "MTConnect: restart of '$name' failed: $_" }
}
return $out
}
function Update-MachineNumber { function Update-MachineNumber {
<# <#
.SYNOPSIS .SYNOPSIS
@@ -163,10 +292,13 @@ function Update-MachineNumber {
} }
if ($udcSharePath) { if ($udcSharePath) {
try { try {
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'W:' # N:, never W:. GE-Enforce owns W: for its whole cycle, and this
# runs as SYSTEM in the same drive namespace - taking W: here kills
# the share out from under an in-flight enforce cycle.
$mountedUdc = Mount-SFLDShare -SharePath $udcSharePath -DriveLetter 'N:'
if ($mountedUdc) { if ($mountedUdc) {
try { try {
$bayDir = Join-Path 'W:\' $NewNumber $bayDir = Join-Path 'N:\' $NewNumber
$srcCur = Join-Path $bayDir 'CurrentData.json' $srcCur = Join-Path $bayDir 'CurrentData.json'
$srcArc = Join-Path $bayDir 'ArchivedData' $srcArc = Join-Path $bayDir 'ArchivedData'
if (Test-Path -LiteralPath $srcCur) { if (Test-Path -LiteralPath $srcCur) {
@@ -226,7 +358,7 @@ function Update-MachineNumber {
Write-Host " Update-MachineNumber: no UDC backup at $bayDir (fresh PC, no prior data)" Write-Host " Update-MachineNumber: no UDC backup at $bayDir (fresh PC, no prior data)"
} }
} finally { } finally {
& net use W: /delete /y 2>$null | Out-Null & net use N: /delete /y 2>$null | Out-Null
} }
} else { } else {
Write-Host " Update-MachineNumber: UDC backup share unreachable - skipping UDC restore." Write-Host " Update-MachineNumber: UDC backup share unreachable - skipping UDC restore."
@@ -244,11 +376,16 @@ function Update-MachineNumber {
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
# --- Update UDC settings JSON --- # --- Update UDC settings JSON ---
# -ErrorAction Stop on the WRITE so PermissionDenied / IO errors become
# terminating and actually hit the catch block. Without this, the cmdlet
# writes a non-terminating error (visible in transcript) but flow
# continues + $out.UdcUpdated is set to $true, leading the dialog to
# report "UDC updated" when the file write actually failed.
if (Test-Path $script:UdcSettingsPath) { if (Test-Path $script:UdcSettingsPath) {
try { try {
$json = Get-Content $script:UdcSettingsPath -Raw | ConvertFrom-Json $json = Get-Content $script:UdcSettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json
$json.GeneralSettings.MachineNumber = $NewNumber $json.GeneralSettings.MachineNumber = $NewNumber
$json | ConvertTo-Json -Depth 99 | Set-Content -Path $script:UdcSettingsPath -Encoding UTF8 $json | ConvertTo-Json -Depth 99 | Set-Content -Path $script:UdcSettingsPath -Encoding UTF8 -ErrorAction Stop
$out.UdcUpdated = $true $out.UdcUpdated = $true
} catch { } catch {
$out.Errors += "UDC update failed: $_" $out.Errors += "UDC update failed: $_"
@@ -256,9 +393,15 @@ function Update-MachineNumber {
} }
# --- Update eDNC registry --- # --- Update eDNC registry ---
# Same -ErrorAction Stop reasoning as above. Set-ItemProperty's
# PermissionDenied is non-terminating by default; without -ErrorAction
# Stop, the catch block never fires and $out.EdncUpdated=$true gets set
# despite the write failing. This is the bug that made the 13:35:39
# tech run on FGY07FZ3 report "eDNC updated to 3005 / All updates
# succeeded" while the actual reg value stayed at 9999.
if (Test-Path $script:EdncRegPath) { if (Test-Path $script:EdncRegPath) {
try { try {
Set-ItemProperty -Path $script:EdncRegPath -Name MachineNo -Value $NewNumber -Type String -Force Set-ItemProperty -Path $script:EdncRegPath -Name MachineNo -Value $NewNumber -Type String -Force -ErrorAction Stop
$out.EdncUpdated = $true $out.EdncUpdated = $true
} catch { } catch {
$out.Errors += "eDNC update failed: $_" $out.Errors += "eDNC update failed: $_"
@@ -293,11 +436,12 @@ function Update-MachineNumber {
# devices.xml / Devices.xml entries collapse to the same file). Without # devices.xml / Devices.xml entries collapse to the same file). Without
# this filter, the Okuma branch on an Okuma PC sees the file already # this filter, the Okuma branch on an Okuma PC sees the file already
# rewritten by the (no-op) Fanuc branch and skips the service restart. # rewritten by the (no-op) Fanuc branch and skips the service restart.
# Okuma and eDNC keep the name in one file only - verified across 7 Okuma
# and 15 eDNC bay captures - so the simple edit is right for them. Fanuc and
# Makino are handled below instead.
$mtcVariants = @( $mtcVariants = @(
@{ Service='MTConnect Agent Fanuc'; Path='C:\MTConnect\Agent\devices.xml' }, @{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' },
@{ Service='MTConnect Agent Okuma'; Path='C:\MTConnect\Agent\Devices.xml' }, @{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' }
@{ Service='MTConnect eDNC Agent'; Path='C:\MTConnect_eDNC\Agent\Devices.xml' },
@{ Service='Makino MTConnect Agent'; Path='C:\Makino-MTConnect\Agent\Devices.xml' }
) )
foreach ($v in $mtcVariants) { foreach ($v in $mtcVariants) {
$svc = Get-Service -Name $v.Service -ErrorAction SilentlyContinue $svc = Get-Service -Name $v.Service -ErrorAction SilentlyContinue
@@ -332,6 +476,43 @@ function Update-MachineNumber {
} }
} }
# --- Multi-file variants: rename every copy, services stopped first ---
# Fanuc and Makino each keep the device name in more than one file, and the
# agent's copy alone is not enough - the adapter goes on streaming under the
# old identity, which is how seven captured Fanuc bays ended up with the
# agent on the right machine number and the adapter on the bay it was
# imaged from.
$multiFileVariants = @(
@{
Probe = 'MTConnect Agent Fanuc'
Root = 'C:\MTConnect'
Services = @('MTConnect Adapter Fanuc', 'MTConnect Agent Fanuc')
Targets = @(
@{ Path = 'Agent\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\devices.xml'; Kind = 'attr' },
@{ Path = 'Adapter\adapter.xml'; Kind = 'elem' }
)
},
@{
Probe = 'Makino MTConnect Agent'
Root = 'C:\Makino-MTConnect'
Services = @('Makino Adapter Manager', 'Makino MTConnect Agent')
Targets = @(
@{ Path = 'Agent\Devices.xml'; Kind = 'attr' },
@{ Path = 'Agent\Devices.EDM.xml'; Kind = 'attr' },
@{ Path = 'Data\AdapterDataFile.xml'; Kind = 'name' },
@{ Path = 'Agent\Agent.cfg'; Kind = 'cfg' }
)
}
)
foreach ($variant in $multiFileVariants) {
if (-not (Get-Service -Name $variant.Probe -ErrorAction SilentlyContinue)) { continue }
$renamed = Update-MTConnectVariantName -NewNumber $NewNumber `
-Root $variant.Root -Targets $variant.Targets -Services $variant.Services
$out.MTConnectUpdated += $renamed.Updated
$out.Errors += $renamed.Errors
}
# Keep C:\Enrollment\machine-number.txt in sync. Post-imaging GE-Enforce # Keep C:\Enrollment\machine-number.txt in sync. Post-imaging GE-Enforce
# prefers eDNC reg, but imaging-time scripts (Install-FromManifest # prefers eDNC reg, but imaging-time scripts (Install-FromManifest
# TargetMachineNumbers filter, 01-eDNC.ps1, 03-RestoreEDncConfig.ps1) # TargetMachineNumbers filter, 01-eDNC.ps1, 03-RestoreEDncConfig.ps1)

View File

@@ -86,6 +86,39 @@ switch ($stage) {
break break
} }
# Defensive: top up AutoLogonCount so SupportUser keeps auto-logging
# in across any vendor-installer-forced reboots during this stage.
# The unattend XML sets LogonCount=7 at install; typical imaging burns
# through several reboots (Office, Oracle, FormTracePak forced reboot,
# Run-ShopfloorSetup explicit reboot, stage advances) and the unplanned
# FormTracePak reboot can push the counter past 0 - clearing
# AutoAdminLogon and leaving the bay parked at the login screen with
# the dispatcher unable to fire. Set the counter to 10 every time this
# stage runs so the budget is restored. When sync-intune finishes the
# whole pipeline, AutoAdminLogon is left to decrement to 0 naturally;
# by then lockdown's own Autologon.exe has taken over for ShopFloor.
try {
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' `
-Name 'AutoLogonCount' -Value 10 -Type DWord -ErrorAction Stop
Write-Host "Topped up AutoLogonCount to 10 for SupportUser autologon resilience."
} catch {
Write-Warning "Failed to top up AutoLogonCount: $_"
}
# Defensive: re-register RunOnce BEFORE calling Run-ShopfloorSetup.
# Setup chains we don't control (FormTracePak Setup.exe, eDNC MSI,
# any vendor installer that forces an immediate reboot) can cut
# the script off mid-flight. Without this, the dispatcher never
# returns from & $script and the post-call Register-NextRun never
# fires, leaving the next boot with no RunOnce + a stalled image.
# With this defensive register the next boot re-fires the same
# dispatcher, which re-reads the still-'shopfloor-setup' stage
# file, re-runs Run-ShopfloorSetup (every step is idempotent +
# detects already-installed state), and converges. Once
# Run-ShopfloorSetup returns normally we re-register again below
# before advancing to the next stage - cheap, idempotent.
Register-NextRun
# -FromDispatcher bypasses the stage-file gate at the top of # -FromDispatcher bypasses the stage-file gate at the top of
# Run-ShopfloorSetup (which would otherwise see the stage file # Run-ShopfloorSetup (which would otherwise see the stage file
# and exit immediately thinking it should defer to us). # and exit immediately thinking it should defer to us).

View File

@@ -0,0 +1,27 @@
@echo off
REM ==========================================================================
REM Verify-And-Heal-Staging.bat - check every imaging payload arrived on this PC
REM and re-pull whatever is missing from the enrollment share.
REM
REM Usage (run on the PC):
REM Verify-And-Heal-Staging.bat verify + heal anything missing
REM Verify-And-Heal-Staging.bat /verifyonly report only, do not pull
REM ==========================================================================
setlocal EnableDelayedExpansion
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting administrator elevation...
powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'"
exit /b
)
set "PS=%~dp0Verify-And-Heal-Staging.ps1"
set "ARGS="
if /I "%~1"=="/verifyonly" set "ARGS=-VerifyOnly"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" %ARGS%
echo.
echo Exit code: %errorlevel% (0=all present/healed, 1=still missing)
pause
endlocal

View File

@@ -0,0 +1,177 @@
<#
Verify-And-Heal-Staging.ps1
Post-boot check that every payload the imaging flow is supposed to stage onto a
shopfloor PC actually arrived - and re-pull (heal) anything missing from the
enrollment share. Runs in full Windows (reliable network), so it is immune to the
WinPE samba-idle-drop that loses copies during the WIM apply.
Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND
the heavy per-type payloads that Fetch-StagingPayload does NOT pull today:
- CMM C:\CMM-Install (+ selected bay's backup C:\CMM-Install\backups\<cmmid>)
- Keyence C:\KeyenceInstall\<model> (MSI + Data*.cab)
- WaxTrace C:\WaxTrace-Install (bundle + bay-matched FormTracePak ISO)
These are the ones that silently go missing when WinPE staging runs out of time
(idle-dead Y: mount) before reboot.
Designed to be:
- run manually on a problem PC (Verify-And-Heal-Staging.bat), or
- called from the pre-install phase before 00-PreInstall-MachineApps so a bay is
never left under-provisioned.
Idempotent. Uses robocopy per item, which compares size + timestamp on every
file, so it re-pulls anything MISSING or PARTIAL (e.g. a truncated MSI that
"exists" but is incomplete and would fail to install) and skips files already
complete. Heals use /R:3 /W:5 (resilient), not the WinPE fail-fast /R:1 /W:1.
Share + creds: read from C:\Enrollment\fetch-source.txt (line1=UNC, line2=user,
line3=pass) - same file Fetch-StagingPayload uses - else the defaults below.
Run as administrator. Exit 0 = everything present or healed; 1 = something still
missing after heal attempts (read the table).
#>
param(
[string]$ShareUnc,
[string]$ShareUser,
[string]$SharePass,
[switch]$VerifyOnly # report only, do not heal
)
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$logDir = 'C:\Logs\Fetch'
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $logDir "verify-heal-$ts.log"
function Log($m,$lvl='INFO'){ $line="[$(Get-Date -Format 'HH:mm:ss')] [$lvl] $m"; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
# --- share + creds (mirror Fetch-StagingPayload) ---
$defUnc='\\172.16.9.1\enrollment'; $defUser='pxe-upload'; $defPass='pxe'
$srcFile='C:\Enrollment\fetch-source.txt'
if ((-not $ShareUnc) -and (Test-Path -LiteralPath $srcFile)) {
$l=@(Get-Content -LiteralPath $srcFile -EA SilentlyContinue)
if ($l.Count -ge 1 -and $l[0].Trim()) { $ShareUnc=$l[0].Trim() }
if ($l.Count -ge 2 -and $l[1].Trim()) { $ShareUser=$l[1].Trim() }
if ($l.Count -ge 3 -and $l[2].Trim()) { $SharePass=$l[2].Trim() }
}
if (-not $ShareUnc) { $ShareUnc=$defUnc }
if (-not $ShareUser) { $ShareUser=$defUser }
if (-not $SharePass) { $SharePass=$defPass }
# --- identity ---
function ReadTxt($p){ if (Test-Path -LiteralPath $p) { (Get-Content -LiteralPath $p -First 1 -EA 0).Trim() } else { '' } }
$pcType = ReadTxt 'C:\Enrollment\pc-type.txt'
$cmmid = ReadTxt 'C:\Enrollment\cmm\cmmid.txt'
Log "=== Verify-And-Heal-Staging ==="
Log "share=$ShareUnc user=$ShareUser pcType=$(if($pcType){$pcType}else{'(none)'}) cmmid=$(if($cmmid){$cmmid}else{'(none)'}) verifyOnly=$VerifyOnly"
# --- expected payload manifest -------------------------------------------------
# Each: Label, Src (under share), Dst, Mode (File|Dir), Verify (path that must
# exist to count as present), Optional (missing-and-no-source is not a failure),
# Files (for Mode=File), Xd (robocopy /XD dirs to exclude on heal).
$items = New-Object System.Collections.Generic.List[object]
function Add-Item($Label,$Src,$Dst,$Mode,$Verify,$Files=$null,$Optional=$false,$Xd=$null){
$items.Add([pscustomobject]@{Label=$Label;Src=$Src;Dst=$Dst;Mode=$Mode;Verify=$Verify;Files=$Files;Optional=$Optional;Xd=$Xd})
}
$ENR='C:\Enrollment'; $SFD='C:\Enrollment\shopfloor-setup'; $PIN='C:\PreInstall'
Add-Item 'Run-ShopfloorSetup.ps1' 'shopfloor-setup' $ENR 'File' (Join-Path $ENR 'Run-ShopfloorSetup.ps1') @('Run-ShopfloorSetup.ps1')
Add-Item 'Shopfloor baseline' 'shopfloor-setup\Shopfloor' (Join-Path $SFD 'Shopfloor') 'Dir' (Join-Path $SFD 'Shopfloor')
Add-Item 'common' 'shopfloor-setup\common' (Join-Path $SFD 'common') 'Dir' (Join-Path $SFD 'common')
Add-Item '_ntlars-backups' 'shopfloor-setup\_ntlars-backups' (Join-Path $SFD '_ntlars-backups') 'Dir' (Join-Path $SFD '_ntlars-backups') $null $true
if ($pcType) {
Add-Item "type:$pcType" "shopfloor-setup\$pcType" (Join-Path $SFD $pcType) 'Dir' (Join-Path $SFD $pcType)
}
Add-Item 'preinstall.json' 'pre-install' $PIN 'File' (Join-Path $PIN 'preinstall.json') @('preinstall.json')
Add-Item 'preinstall installers' 'pre-install\installers' (Join-Path $PIN 'installers') 'Dir' (Join-Path $PIN 'installers')
Add-Item 'udc-backups' 'pre-install\udc-backups' (Join-Path $PIN 'udc-backups') 'Dir' (Join-Path $PIN 'udc-backups') $null $true
# --- heavy CMM payload (the gap) ---
if ($pcType -eq 'gea-shopfloor-cmm') {
Add-Item 'CMM bundle' 'installers-post\cmm' 'C:\CMM-Install' 'Dir' 'C:\CMM-Install\cmm-manifest.json' $null $false 'backups'
if ($cmmid) {
Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true
}
}
# --- heavy Keyence payload (same gap as CMM: WinPE-only staged, never re-pulled
# by Fetch-StagingPayload). Only the selected model bundle lands under
# C:\KeyenceInstall\<model>. Verify on the model manifest so a missing/partial
# Data1.cab (the 700 MB - 2 GB payload msiexec SECREPAIR-hashes) gets re-pulled. ---
if ($pcType -eq 'gea-shopfloor-keyence') {
$kmodel = ReadTxt 'C:\Enrollment\keyence-model.txt'
if (-not $kmodel) { $kmodel = 'vr6000' }
Add-Item "Keyence bundle ($kmodel)" "installers-post\keyence\$kmodel" "C:\KeyenceInstall\$kmodel" 'Dir' "C:\KeyenceInstall\$kmodel\manifest.json"
}
# --- heavy WaxTrace payload (same gap as CMM/Keyence). Two parts, mirroring the
# three-step WinPE stage: (1) the bundle minus the formtracepak\ ISO dir, and
# (2) ONLY the bay's matched FORMTRACEPAK-V<ver>.iso, keyed on the version
# resolve-bay-config wrote to C:\Enrollment\waxtrace\version.txt during WinPE.
# If version.txt is missing (mount died before the resolver ran) the ISO cannot
# be re-pulled here - the bundle+resolver still heal, and resolve-bay-config can
# be re-run manually to regenerate version.txt then re-run this heal. ---
if ($pcType -eq 'gea-shopfloor-waxtrace') {
Add-Item 'WaxTrace bundle' 'installers-post\waxtrace' 'C:\WaxTrace-Install' 'Dir' 'C:\WaxTrace-Install\waxtrace-manifest.json' $null $false 'formtracepak'
$wtver = ReadTxt 'C:\Enrollment\waxtrace\version.txt'
if ($wtver) {
Add-Item "WaxTrace FTPak V$wtver" 'installers-post\waxtrace\formtracepak' 'C:\WaxTrace-Install\formtracepak' 'File' "C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V$wtver.iso" @("FORMTRACEPAK-V$wtver.iso")
} else {
Log 'WaxTrace: version.txt absent - cannot heal the bay-specific FormTracePak ISO (re-run resolve-bay-config then re-run heal)' 'WARN'
}
}
# --- robocopy-based verify/heal -----------------------------------------------
# Presence alone is NOT trusted: a partially transferred file (e.g. a truncated
# MSI) exists but is incomplete and breaks install. Instead robocopy runs per
# item and compares size + timestamp on EVERY file, re-pulling any that are
# missing OR differ (partial/truncated) and skipping ones already complete (a
# cheap metadata scan). So it scans all files, not just checks a folder is
# non-empty. VerifyOnly adds /L (list-only): it reports what WOULD be re-pulled
# without changing anything.
$drive='Z:'; $mounted=$false
function Mount-Share { cmd /c "net use $drive /delete /y >nul 2>&1"; & net use $drive $ShareUnc /user:$ShareUser $SharePass /persistent:no 2>&1 | Out-Null; return ($LASTEXITCODE -eq 0) }
$report = New-Object System.Collections.Generic.List[object]
for ($a=1; $a -le 5 -and -not $mounted; $a++){ if (Mount-Share){$mounted=$true;Log "Mounted $ShareUnc as $drive"} else {Log "mount attempt $a/5 failed - 10s" 'WARN'; Start-Sleep 10} }
if (-not $mounted) {
Log "Could not mount $ShareUnc after 5 attempts - cannot verify/heal. Bay may be under-provisioned; re-run once the share is reachable." 'ERROR'
foreach ($it in $items) { $report.Add([pscustomobject]@{Item=$it.Label;Status='NO-MOUNT'}) }
} else {
foreach ($it in $items) {
$src = Join-Path $drive $it.Src
if (-not (Test-Path -LiteralPath $src)) {
$report.Add([pscustomobject]@{Item=$it.Label;Status=$(if($it.Optional){'ABSENT(opt)'}else{'NO-SOURCE'})})
Log "[$($it.Label)] source not on share ($src)$(if($it.Optional){' - optional'})" $(if($it.Optional){'INFO'}else{'WARN'})
continue
}
if (-not (Test-Path -LiteralPath $it.Dst)) { New-Item -ItemType Directory -Path $it.Dst -Force | Out-Null }
$args=@($src,$it.Dst)
if ($it.Mode -eq 'Dir') { $args+='/E' } else { $args+=$it.Files }
if ($it.Xd) { $args+=@('/XD',(Join-Path $src $it.Xd)) }
$args+=@('/R:3','/W:5','/NFL','/NDL','/NP')
if ($VerifyOnly) { $args+='/L' } # list-only: detect missing/partial, change nothing
$out = & robocopy @args 2>&1
$rc = $LASTEXITCODE
# robocopy exit bits: 1=copied, 2=extra, 4=mismatch, 8+=failure (<8 success).
$copied = (($rc -band 1) -ne 0) -or (($rc -band 4) -ne 0)
$files = ($out | Select-String -Pattern '^\s*Files :' | Select-Object -First 1)
if ($rc -ge 8) { $status='HEAL-FAIL' }
elseif (-not $copied) { $status='COMPLETE' } # in sync, nothing to do
elseif ($VerifyOnly) { $status='INCOMPLETE' } # would re-pull (missing/partial)
else { $status='HEALED' } # actually re-pulled missing/partial
$report.Add([pscustomobject]@{Item=$it.Label;Status=$status})
Log "[$($it.Label)] robocopy rc=$rc -> $status $(("$files").Trim())"
}
cmd /c "net use $drive /delete /y >nul 2>&1"
}
# --- report --------------------------------------------------------------------
Log '================ STAGING VERIFY/HEAL REPORT ================'
foreach ($r in $report) { Log (" {0,-26} {1}" -f $r.Item, $r.Status) }
$bad = @($report | Where-Object { $_.Status -in @('NO-SOURCE','HEAL-FAIL','NO-MOUNT','INCOMPLETE') })
if ($bad.Count -gt 0) {
Log "RESULT: $($bad.Count) item(s) need attention: $(($bad|ForEach-Object{$_.Item+'='+$_.Status}) -join ', ')" 'ERROR'
Log "Log: $log"
exit 1
} else {
Log 'RESULT: all required payloads complete (or healed).'
Log "Log: $log"
exit 0
}

View File

@@ -46,13 +46,76 @@ Write-EnforceLog '==============================================================
Write-EnforceLog "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ===" Write-EnforceLog "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ==="
Write-EnforceLog '================================================================' Write-EnforceLog '================================================================'
$logRoots = @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')
# --- Roll undated logs so they can age ---
# The prune below drops a *.log by LastWriteTime. A log written to a FIXED
# filename is appended every cycle, so its LastWriteTime is always now and it
# can never be older than any cutoff - it grows forever. ntlars-backup.log,
# eventsaver.log and shopdb-collector-key.log all do this; eventsaver.log had
# reached 11,000 lines on a single PC.
#
# Rolling one to <name>-YYYYMMDD.log stops it being written to, so the prune
# takes it 30 days later, and the script that owns it needs no change: they all
# append with Add-Content or Tee-Object, which recreate a missing file on the
# next write. A file already carrying a date stamp is left alone.
#
# Rolled under the date of its FIRST LINE, so the stamp matches the lines
# inside. That timestamp is also the only trustworthy signal available:
# CreationTime is not, because of NTFS file tunneling - rename a file away and
# let the owner recreate it within 15 seconds, and the new file INHERITS the
# old creation time. Keyed on that, a busy log would look stale the moment it
# was rolled and would roll again every cycle, forever.
$rolledCount = 0
$today = (Get-Date).Date
foreach ($root in $logRoots) {
if (-not (Test-Path $root)) { continue }
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -notmatch '\d{8}$' -and $_.Length -gt 0 } |
ForEach-Object {
$firstline = Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue
# MUST be pre-typed. TryParse takes [ref][datetime], and passing a
# [ref] to an untyped $null throws "cannot find an overload", which
# would make this whole roll a silent no-op.
$started = [datetime]::MinValue
if ($firstline -match '(\d{4}-\d{2}-\d{2})') {
[void][datetime]::TryParse($matches[1], [ref]$started)
}
# No parseable date: fall back to size so a log with a format we do
# not recognise still cannot grow without limit.
if ($started -eq [datetime]::MinValue) {
if ($_.Length -lt 5MB) { return }
$started = $today.AddDays(-1)
}
if ($started.Date -ge $today) { return }
$stamp = $started.ToString('yyyyMMdd')
$target = Join-Path $_.DirectoryName ('{0}-{1}.log' -f $_.BaseName, $stamp)
# An existing target means the roll already ran for that day; append
# to it rather than losing either side.
try {
if (Test-Path -LiteralPath $target) {
Get-Content -LiteralPath $_.FullName -ErrorAction Stop |
Add-Content -LiteralPath $target -ErrorAction Stop
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
} else {
Move-Item -LiteralPath $_.FullName -Destination $target -ErrorAction Stop
}
$rolledCount++
} catch {}
}
}
if ($rolledCount -gt 0) {
Write-EnforceLog "Rolled $rolledCount undated log file(s) so retention can age them"
}
# --- Log retention prune --- # --- Log retention prune ---
# Drops *.log files older than $retentionDays from the shopfloor log roots. # Drops *.log files older than $retentionDays from the shopfloor log roots.
# Cheap (flat dir scan, no recursion). Runs every cycle. Today's # Cheap (flat dir scan, no recursion). Runs every cycle. Today's
# enforce-YYYYMMDD.log is never touched (LastWriteTime = now). # enforce-YYYYMMDD.log is never touched (LastWriteTime = now).
$retentionDays = 30 $retentionDays = 30
$prunedCount = 0 $prunedCount = 0
foreach ($root in @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')) { foreach ($root in $logRoots) {
if (-not (Test-Path $root)) { continue } if (-not (Test-Path $root)) { continue }
$cutoff = (Get-Date).AddDays(-$retentionDays) $cutoff = (Get-Date).AddDays(-$retentionDays)
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue | Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
@@ -76,6 +139,40 @@ $pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction Silentl
$pcSubType = if (Test-Path $pcSubTypeFile) { $pcSubType = if (Test-Path $pcSubTypeFile) {
(Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim() (Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
} else { '' } } else { '' }
# Backfill pc-subtype.txt on Keyence PCs imaged before 2026-05 (startnet.cmd
# didn't write pc-subtype.txt for Keyence then). Without a subtype, the share
# manifest's per-model PCTypes gate falls back to installing the default model
# (VR-6000) on top of VR-3000 / VR-5000 boxes. Detect the installed model from
# its uninstall ProductCode and persist the subtype so subsequent GE-Enforce
# cycles + the share manifest gate route correctly.
if ($pcType -ieq 'keyence' -and -not $pcSubType) {
$keyenceProducts = @(
@{ Subtype = 'vr3000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{9CC9A062-2A93-4D3B-AECA-F70C691A46F2}' },
@{ Subtype = 'vr5000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{AF7E8B93-DBEB-4DB1-91CB-4DA592D8E222}' },
@{ Subtype = 'vr6000'; Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{058E7194-BDF8-4FA2-9D69-978BB0F25214}' }
)
foreach ($p in $keyenceProducts) {
if (Test-Path -LiteralPath $p.Path) {
$pcSubType = $p.Subtype
try {
$enrollDir = Split-Path -Parent $pcSubTypeFile
if (-not (Test-Path -LiteralPath $enrollDir)) {
New-Item -Path $enrollDir -ItemType Directory -Force | Out-Null
}
Set-Content -LiteralPath $pcSubTypeFile -Value $pcSubType -Encoding ascii -Force
Write-EnforceLog "Backfilled pc-subtype.txt = $pcSubType from installed product code"
} catch {
Write-EnforceLog "pc-subtype.txt backfill write failed: $_" 'WARN'
}
break
}
}
if (-not $pcSubType) {
Write-EnforceLog "Keyence PC with no pc-subtype.txt and no recognized VR product installed - skipping model-gated apps until imaging populates subtype" 'WARN'
}
}
Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })" Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })"
# --- site-config --- # --- site-config ---

View File

@@ -38,6 +38,12 @@ $ErrorActionPreference = 'Continue'
# logged; manifests tagged with a newer MINOR are fine. # logged; manifests tagged with a newer MINOR are fine.
# #
# Changelog: # Changelog:
# 2.6 - added _CmmVersion filter. Entry tagged _CmmVersion only applies when
# it equals C:\Enrollment\cmm\version.txt (the bay's resolved PC-DMIS
# version, written at imaging from cmm-bay-config.csv). Untagged entries
# always pass; missing/empty version file is a no-op (legacy install-all
# + non-CMM scopes unaffected). Lifted out of 09-Setup-CMM so the gate
# lives in one place both the imaging and enforce paths share.
# 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest # 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest
# entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI # entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI
# successfully but the wrapper process never exits (waits on a # successfully but the wrapper process never exits (waits on a
@@ -58,7 +64,7 @@ $ErrorActionPreference = 'Continue'
# 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types, # 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
# Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter # Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
$LIB_MANIFEST_MAJOR = 2 $LIB_MANIFEST_MAJOR = 2
$LIB_MANIFEST_MINOR = 5 $LIB_MANIFEST_MINOR = 6
$logDir = Split-Path -Parent $LogFile $logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) { if (-not (Test-Path $logDir)) {
@@ -235,6 +241,24 @@ function Test-AppInstalled {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Action dispatch # Action dispatch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
function Join-InstallerPath {
# Plain string math, NOT Join-Path. Join-Path resolves the drive qualifier
# through the PS provider and emits NOTHING (null) if that drive vanished
# mid-cycle - the null then binds into Test-Path -LiteralPath and throws
# "Cannot bind argument to parameter 'LiteralPath' because it is null".
# [IO.Path]::Combine is no good either: its separator and rooted-path rules
# follow the host platform, so it cannot be validated off-Windows.
# Returns $null when the entry is unusable; every caller treats null as
# "not found" and logs, instead of crashing the entry.
param([string]$Root, [string]$Rel)
if ([string]::IsNullOrWhiteSpace($Root) -or [string]::IsNullOrWhiteSpace($Rel)) { return $null }
$clean = ($Rel -replace '/', '\').Trim()
# Reject rooted values ('\x', 'D:\x', '\\server\share'): a manifest entry
# must resolve UNDER the share root, never escape it.
if ($clean -match '^(\\|[A-Za-z]:)') { return $null }
return ($Root.TrimEnd('\') + '\' + $clean)
}
function Invoke-InstallerAction { function Invoke-InstallerAction {
param($App) param($App)
@@ -245,8 +269,8 @@ function Invoke-InstallerAction {
switch ($App.Type) { switch ($App.Type) {
'MSI' { 'MSI' {
$installerPath = Join-Path $InstallerRoot $App.Installer $installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) { if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " MSI not found: $installerPath" 'ERROR' Write-InstallLog " MSI not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
@@ -264,8 +288,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog } return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
} }
'EXE' { 'EXE' {
$installerPath = Join-Path $InstallerRoot $App.Installer $installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) { if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " EXE not found: $installerPath" 'ERROR' Write-InstallLog " EXE not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
@@ -340,8 +364,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile } return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile }
} }
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } { { $_ -eq 'CMD' -or $_ -eq 'BAT' } {
$installerPath = Join-Path $InstallerRoot $App.Installer $installerPath = Join-InstallerPath $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) { if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR' Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
@@ -354,9 +378,18 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile } return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
} }
'PS1' { 'PS1' {
$scriptPath = Join-Path $InstallerRoot ($App.Script) # Accept either Script or Installer as the relative path, and never
if (-not (Test-Path -LiteralPath $scriptPath)) { # feed a null into Join-Path/Test-Path (that throws a cryptic
Write-InstallLog " PS1 not found: $scriptPath" 'ERROR' # 'LiteralPath is null'). Log the resolved value so a bad/empty
# entry is obvious in the log instead of crashing the entry.
$rel = if ($App.Script) { $App.Script } elseif ($App.Installer) { $App.Installer } else { $null }
if ([string]::IsNullOrWhiteSpace([string]$rel)) {
Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$scriptPath = Join-InstallerPath $InstallerRoot $rel
if ([string]::IsNullOrWhiteSpace($scriptPath) -or -not (Test-Path -LiteralPath $scriptPath)) {
Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
$psi.FileName = 'powershell.exe' $psi.FileName = 'powershell.exe'
@@ -369,8 +402,8 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null } return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
} }
'INF' { 'INF' {
$infPath = Join-Path $InstallerRoot $App.Installer $infPath = Join-InstallerPath $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $infPath)) { if ([string]::IsNullOrWhiteSpace($infPath) -or -not (Test-Path -LiteralPath $infPath)) {
Write-InstallLog " INF not found: $infPath" 'ERROR' Write-InstallLog " INF not found: $infPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
@@ -382,8 +415,8 @@ function Invoke-InstallerAction {
} }
'File' { 'File' {
# Copy a file from the share (configs/*) to an absolute on-PC path. # Copy a file from the share (configs/*) to an absolute on-PC path.
$source = Join-Path $InstallerRoot $App.Source $source = Join-InstallerPath $InstallerRoot $App.Source
if (-not (Test-Path -LiteralPath $source)) { if ([string]::IsNullOrWhiteSpace($source) -or -not (Test-Path -LiteralPath $source)) {
Write-InstallLog " File source not found: $source" 'ERROR' Write-InstallLog " File source not found: $source" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
@@ -455,7 +488,8 @@ $script:_pcTypeAliasGroups = @(
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
) )
# Returns every alias set (each itself a string array) that contains $name. # Returns every alias set (each itself a string array) that contains $name.
@@ -519,29 +553,35 @@ function Test-HostnameMatches {
} }
# Machine-number filter. Stable identifier tied to the bay; survives PC # Machine-number filter. Stable identifier tied to the bay; survives PC
# replacement at the same machine. Source of truth = the value the tech # replacement at the same machine.
# entered at the PXE menu, persisted to C:\Enrollment\machine-number.txt #
# by startnet.cmd. Falls back to the DNC registry if that file is missing # Source of truth = the eDNC/DNC registry MachineNo. That is what the
# (covers PCs that pre-date this filter being introduced). # reassignment flow (Set-MachineNumber -> Update-MachineNumber) actually
# rewrites when a bay is re-numbered (e.g. 9999 placeholder -> 7501). The
# imaging-time C:\Enrollment\machine-number.txt is written ONCE by startnet.cmd
# at the PXE menu and is NOT updated on reassignment, so it goes stale. Read
# the registry FIRST so TargetMachineNumbers gating follows reassignment; fall
# back to the txt only when the registry has no value (covers non-DNC PCs or a
# bay where eDNC has not populated MachineNo yet).
$script:_cachedMachineNumber = $null $script:_cachedMachineNumber = $null
function Get-CurrentMachineNumber { function Get-CurrentMachineNumber {
if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber } if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber }
$candidates = @(
'C:\Enrollment\machine-number.txt'
)
foreach ($p in $candidates) {
if (Test-Path -LiteralPath $p) {
$v = (Get-Content -LiteralPath $p -ErrorAction SilentlyContinue | Select-Object -First 1)
if ($v) { $script:_cachedMachineNumber = $v.Trim(); return $script:_cachedMachineNumber }
}
}
foreach ($r in @( foreach ($r in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) { )) {
if (Test-Path $r) { if (Test-Path $r) {
$p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue $p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue
if ($p.MachineNo) { $script:_cachedMachineNumber = [string]$p.MachineNo; return $script:_cachedMachineNumber } if ($p.MachineNo) {
$v = ([string]$p.MachineNo).Trim()
if ($v) { $script:_cachedMachineNumber = $v; return $script:_cachedMachineNumber }
}
}
}
foreach ($p in @('C:\Enrollment\machine-number.txt')) {
if (Test-Path -LiteralPath $p) {
$v = (Get-Content -LiteralPath $p -ErrorAction SilentlyContinue | Select-Object -First 1)
if ($v) { $script:_cachedMachineNumber = $v.Trim(); return $script:_cachedMachineNumber }
} }
} }
$script:_cachedMachineNumber = '' $script:_cachedMachineNumber = ''
@@ -559,6 +599,42 @@ function Test-MachineNumberMatches {
return $false return $false
} }
# CMM PC-DMIS version filter. The bay's PC-DMIS version (2016/2019/2026) is
# resolved at imaging by resolve-cmm-bay-config.ps1 from cmm-bay-config.csv (the
# single bay -> version map) and persisted to C:\Enrollment\cmm\version.txt. An
# entry tagged _CmmVersion applies only when it equals that file; untagged
# entries (CLM, goCMM, Protect Viewer, DODA, the PDF converter) always pass.
# When the file is absent/empty - a bay imaged before the picker, or any
# non-CMM PC running a different scope - the filter is a no-op so every tagged
# entry passes. That preserves the legacy "install all versions" behavior for
# pre-picker bays and leaves non-CMM scopes untouched.
#
# This is the SINGLE place the version gate lives. Both the imaging path
# (09-Setup-CMM) and the runtime path (GE-Enforce) call this lib, so the gate
# cannot apply in one path and not the other. The 2016-installed-on-a-2019-bay
# bug was exactly that drift: the imaging path filtered by _CmmVersion but the
# enforce path did not, so enforce reinstalled every version it did not detect.
$script:_cachedCmmVersion = $null
$script:_cmmVersionRead = $false
function Get-CurrentCmmVersion {
if ($script:_cmmVersionRead) { return $script:_cachedCmmVersion }
$script:_cmmVersionRead = $true
$f = 'C:\Enrollment\cmm\version.txt'
if (Test-Path -LiteralPath $f) {
$v = (Get-Content -LiteralPath $f -First 1 -ErrorAction SilentlyContinue)
if ($v) { $script:_cachedCmmVersion = $v.Trim() }
}
return $script:_cachedCmmVersion
}
function Test-CmmVersionMatches {
param($App)
if (-not $App._CmmVersion) { return $true } # untagged entry always applies
$myVer = Get-CurrentCmmVersion
if (-not $myVer) { return $true } # no resolved version -> legacy install-all
return ([string]$App._CmmVersion -ieq $myVer)
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main loop # Main loop
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -567,6 +643,18 @@ $skipped = 0
$failed = 0 $failed = 0
$pcFiltered = 0 $pcFiltered = 0
# Per-entry outcomes for the caller's report (the API enforce runner captures
# the object emitted at the end). SMB/GE-Enforce.ps1 ignores stdout + reads the
# exit code, so this is additive and does not change the share path.
$script:enforceResults = [System.Collections.Generic.List[object]]::new()
function Add-EnforceResult {
param([string]$Name, [string]$Action, [int]$ExitCode = 0,
[bool]$SelfHealed = $false, [string]$Message = '')
$script:enforceResults.Add([pscustomobject]@{
Name = $Name; Action = $Action; ExitCode = $ExitCode
SelfHealed = $SelfHealed; Message = $Message })
}
foreach ($app in $config.Applications) { foreach ($app in $config.Applications) {
# Cancel any reboot that a prior MSI queued, so the enforcer never # Cancel any reboot that a prior MSI queued, so the enforcer never
# triggers an unexpected restart on a shopfloor PC. # triggers an unexpected restart on a shopfloor PC.
@@ -574,15 +662,22 @@ foreach ($app in $config.Applications) {
Write-InstallLog "==> $($app.Name)" Write-InstallLog "==> $($app.Name)"
# Per-entry guard: a single entry that throws must NOT abort the whole
# scope (and silently skip every later entry + the status write). Catch,
# log, count as failed, move on.
try {
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) { if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping" Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++ $pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'PCTypes filter'
continue continue
} }
if (-not (Test-HostnameMatches -App $app)) { if (-not (Test-HostnameMatches -App $app)) {
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping" Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
$pcFiltered++ $pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetHostnames filter'
continue continue
} }
@@ -590,12 +685,22 @@ foreach ($app in $config.Applications) {
$myNum = Get-CurrentMachineNumber $myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping" Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
$pcFiltered++ $pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetMachineNumbers filter'
continue
}
if (-not (Test-CmmVersionMatches -App $app)) {
$myVer = Get-CurrentCmmVersion
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message '_CmmVersion filter'
continue continue
} }
if (Test-AppInstalled -App $app) { if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping' Write-InstallLog ' Already installed at expected version - skipping'
$skipped++ $skipped++
Add-EnforceResult -Name $app.Name -Action 'skipped' -Message 'already installed'
continue continue
} }
@@ -635,6 +740,12 @@ foreach ($app in $config.Applications) {
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" } if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' } if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
$installed++ $installed++
# SelfHealed = a real drift correction (a detected-missing entry we
# re-installed). Always/no-detection entries install every cycle by
# design and are not self-heals, so the report stays 'ok' for them.
Add-EnforceResult -Name $app.Name -Action 'installed' -ExitCode $rc `
-SelfHealed ([bool]($app.DetectionMethod -and $app.DetectionMethod -ne 'Always')) `
-Message "Exit $rc"
# Auto-write marker file for MarkerFile-detected entries that just # Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running # completed successfully. Keeps one-shot PS1 scripts from running
@@ -679,6 +790,13 @@ foreach ($app in $config.Applications) {
} }
$failed++ $failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -ExitCode $rc -Message "Exit $rc - FAILED"
}
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -Message $_.Exception.Message
} }
} }
@@ -688,5 +806,17 @@ Write-InstallLog '============================================'
cmd /c 'shutdown /a 2>nul' *>$null cmd /c 'shutdown /a 2>nul' *>$null
# Emit the summary object for the API enforce runner to report. Write-Host log
# lines above go to the host stream, so this is the only value on the success
# stream that '& $EnginePath' captures. The exit code is unchanged (SMB path).
Write-Output ([pscustomobject]@{
Installed = $installed
Skipped = $skipped
Failed = $failed
Filtered = $pcFiltered
EnforcerVersion = "$LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR"
Results = $script:enforceResults.ToArray()
})
if ($failed -gt 0) { exit 1 } if ($failed -gt 0) { exit 1 }
exit 0 exit 0

View File

@@ -0,0 +1,375 @@
# ShopdbBackupClient - the client half of the ShopDB asset-backup contract.
#
# WHY THIS EXISTS
#
# ShopDB's backups plugin is a pluggable registry: a "kind" declares how a
# config is parsed, rendered and resolved to an asset, and in return gets
# revision history, content dedup, retention, diffs and an asset panel. The
# server half is a clean contract. The client half was not: exactly one script
# on this share posted backups (Backup-NtlarsSettings), and everything around
# the post - finding the collector key, reading the interval, throttling,
# logging - lived inside it, bespoke.
#
# The cost showed up the day someone looked. The interval came from a public
# settings key the plugin never declared public, so the read silently fell back
# to a hardcoded 24 hours and the setting did nothing for months. The log wrote
# three lines every five minutes whatever happened, reaching 3,234 lines of
# which 3,217 were the same "Throttled" line. Seven other Backup-*.ps1 scripts
# on this share capture device configs and post NONE of them, so nothing about
# a CMM, a PC-DMIS bay or an MTConnect box has any history in ShopDB.
#
# Wiring those seven the old way would have produced seven more copies of all
# of the above. So the shared parts live here, once, and a per-device script
# becomes: capture the config, call Send-ShopdbBackup.
#
# USAGE
#
# Import-Module "$PSScriptRoot\..\lib\ShopdbBackupClient.psm1" -Force
# $ctx = Initialize-ShopdbBackup -Kind 'gocmm'
# if (-not $ctx.Proceed) { exit 0 } # quiet exit, already logged
# $bytes = [IO.File]::ReadAllBytes($configPath)
# Send-ShopdbBackup -Context $ctx -Bytes $bytes -SourceFileName 'settings.xml'
#
# Initialize-ShopdbBackup does every check that can say "nothing to do today":
# machine number, collector key, base URL, throttle. Each of those is logged
# ONCE and then stays quiet while it holds, so a PC with no NTLARS does not
# write the same line 288 times a day.
#
# PER-KIND STATE, deliberately. Marker, state and log files are all named from
# the kind, so a bay running two backup kinds does not have them fighting over
# one marker file - which is what a single fixed name would have caused the
# first time a second kind shipped.
Set-StrictMode -Version Latest
$script:LOGDIR = 'C:\Logs\Shopfloor'
$script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
function Get-ShopdbLogPath {
param([Parameter(Mandatory)][string]$Kind)
# Date-stamped at source. An append-only name can never age out of the
# GE-Enforce retention sweep, which drops by LastWriteTime - a file written
# every cycle is always "recent" and grows forever.
Join-Path $script:LOGDIR ('{0}-backup-{1}.log' -f $Kind, (Get-Date -Format 'yyyyMMdd'))
}
function Write-ShopdbBackupLog {
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Message)
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path (Get-ShopdbLogPath -Kind $Kind) -Value $line -EA SilentlyContinue
Write-Host $line
}
function Write-ShopdbQuietState {
<#
Log a no-change outcome once, then stay silent while it holds.
Returns nothing; the caller exits on a quiet outcome.
GE-Enforce runs every 5 minutes. An outcome that does not change is
worth saying once, not 288 times a day. Any real event clears the state
so the next quiet spell announces itself - otherwise a PC that stopped
working would look exactly like one still posting.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$State,
[Parameter(Mandatory)][string]$Message)
$statefile = Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)
$previous = ''
if (Test-Path $statefile) {
try { $previous = (Get-Content -LiteralPath $statefile -First 1 -EA Stop).Trim() } catch { }
}
if ($previous -ne $State) {
Write-ShopdbBackupLog -Kind $Kind -Message $Message
Set-Content -Path $statefile -Value $State -EA SilentlyContinue
}
}
function Clear-ShopdbQuietState {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbRegValue {
<#
One value from HKLM:\SOFTWARE\GE\ShopDB.
The SAME contract the display kiosks use: Install-ShopdbKiosk writes
BaseUrl / ApiToken / CollectorKey there and ACLs it to SYSTEM and
Administrators. Machine bays read the same place rather than inventing
a second home for one secret, so one delivery mechanism serves the whole
fleet and the diagnostics collector already knows to redact it.
#>
param([Parameter(Mandatory)][string]$Name)
try {
$props = Get-ItemProperty -Path $script:SHOPDBREG -EA Stop
if ($props.PSObject.Properties.Name -contains $Name) {
$val = [string]$props.$Name
if ($val) { return $val.Trim() }
}
} catch { }
return ''
}
function Get-ShopdbCollectorKey {
# Registry first (how the fleet is provisioned), then the enrollment file
# for a bay staged before that existed.
$key = Get-ShopdbRegValue -Name 'CollectorKey'
if ($key) { return $key }
if (Test-Path $script:KEYFILE) {
foreach ($line in (Get-Content -LiteralPath $script:KEYFILE -EA SilentlyContinue)) {
if ($line -match '^\s*collector\s*=\s*(.+)$') { return $matches[1].Trim() }
}
}
return ''
}
function Get-ShopdbMachineNumber {
<#
The machine number this PC reports, from pc-config.txt.
A device-specific fallback can be supplied when the application itself
knows the number - NTLARS keeps one in its own registry key. Callers
without one just get '' and exit quietly.
#>
param([scriptblock]$Fallback)
if (Test-Path $script:PCCONFIG) {
foreach ($line in (Get-Content -LiteralPath $script:PCCONFIG -EA SilentlyContinue)) {
if ($line -match '^\s*machine(number|no)?\s*=\s*(.+)$') {
$val = $matches[2].Trim()
# 9999 is the imaging-time placeholder, not a real bay.
if ($val -and $val -ne '9999') { return $val }
}
}
}
if ($Fallback) {
try {
$val = & $Fallback
if ($val) { return ([string]$val).Trim() }
} catch { }
}
return ''
}
function Get-ShopdbIntervalHours {
<#
Minimum hours between attempts, from the backups_intervalhours setting.
Read UNAUTHENTICATED from /api/settings/public, because this runs before
any credential is needed. That endpoint serves an allowlist, and the key
must be declared public by the plugin - it was not, for months, so this
read returned nothing and the fallback below silently governed the whole
fleet. The fallback stays (an unreachable server must not mean a hot
loop) but a miss is now LOGGED rather than swallowed, so the same
failure cannot hide again.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$BaseUrl,
[int]$Default = 24)
try {
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/settings/public" -Method Get `
-TimeoutSec 10 -EA Stop
$val = $null
if ($resp -and $resp.PSObject.Properties.Name -contains 'data') {
if ($resp.data.PSObject.Properties.Name -contains 'backups_intervalhours') {
$val = $resp.data.backups_intervalhours
}
}
if ($val) { return [int]$val }
Write-ShopdbQuietState -Kind $Kind -State 'interval-not-public' -Message (
'backups_intervalhours is not readable from /api/settings/public; ' +
"using the built-in ${Default}h. The plugin must declare the key public.")
} catch {
Write-ShopdbQuietState -Kind $Kind -State 'interval-unreachable' -Message (
"Could not read settings from $BaseUrl ($($_.Exception.Message)); using ${Default}h.")
}
return $Default
}
function Initialize-ShopdbBackup {
<#
Every check that can say "nothing to do", in one call.
Returns a context object. .Proceed is $false when the run should stop -
the reason has already been logged, once. On $true the context carries
Kind, BaseUrl, CollectorKey, MachineNumber and IntervalHours, and the
marker has NOT yet been stamped (Send-ShopdbBackup does that).
#>
param([Parameter(Mandatory)][string]$Kind,
[string]$BaseUrl,
[scriptblock]$MachineNumberFallback,
[switch]$Force)
$ctx = [pscustomobject]@{
Kind = $Kind
Proceed = $false
BaseUrl = ''
CollectorKey = ''
MachineNumber = ''
IntervalHours = 24
}
$machineNumber = Get-ShopdbMachineNumber -Fallback $MachineNumberFallback
if (-not $machineNumber) {
Write-ShopdbQuietState -Kind $Kind -State 'no-machine-number' -Message (
'No machine number in pc-config.txt and no device fallback. ' +
'A backup cannot be filed against an asset - skipping.')
return $ctx
}
$collectorKey = Get-ShopdbCollectorKey
if (-not $collectorKey) {
Write-ShopdbQuietState -Kind $Kind -State 'no-collector-key' -Message (
"No collector key in $script:SHOPDBREG\CollectorKey or $script:KEYFILE. " +
'The collector endpoint has no IP-allowlist path, unlike the GE-Enforce ' +
'manifest fetch, so it always needs a collector-scoped token. Skipping.')
return $ctx
}
if (-not $BaseUrl) { $BaseUrl = Get-ShopdbRegValue -Name 'BaseUrl' }
if (-not $BaseUrl) {
Write-ShopdbQuietState -Kind $Kind -State 'no-base-url' -Message (
"No ShopDB BaseUrl in $script:SHOPDBREG and none passed. Skipping.")
return $ctx
}
$BaseUrl = $BaseUrl.TrimEnd('/')
$intervalHours = Get-ShopdbIntervalHours -Kind $Kind -BaseUrl $BaseUrl
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $Kind)
if (-not $Force -and (Test-Path $markerfile)) {
try {
$last = (Get-Item $markerfile).LastWriteTime
if (((Get-Date) - $last).TotalHours -lt $intervalHours) {
$due = $last.AddHours($intervalHours).ToString('yyyy-MM-dd HH:mm')
Write-ShopdbQuietState -Kind $Kind -State ("throttled-$due") -Message (
"Throttled: posted within the last ${intervalHours}h, next attempt after $due. " +
'Use -Force to override.')
return $ctx
}
} catch { }
}
$ctx.Proceed = $true
$ctx.BaseUrl = $BaseUrl
$ctx.CollectorKey = $collectorKey
$ctx.MachineNumber = $machineNumber
$ctx.IntervalHours = $intervalHours
Clear-ShopdbQuietState -Kind $Kind
Write-ShopdbBackupLog -Kind $Kind -Message (
"=== $Kind backup start === machine $machineNumber, $BaseUrl, interval ${intervalHours}h")
return $ctx
}
function Send-ShopdbBackup {
<#
POST one captured config to the ShopDB collector.
-Bytes for a kind ShopDB parses and stores (storagebackend 'shopdb').
-ContentHash with -SharePath for a kind whose bytes stay on the share
(storagebackend 'share'), where ShopDB keeps metadata and a pointer.
Returns $true when ShopDB accepted it, whether that produced a new
revision or a no-op; an unchanged config is a no-op by design and is
the expected outcome most cycles.
#>
param([Parameter(Mandatory)][pscustomobject]$Context,
[byte[]]$Bytes,
[string]$ContentHash,
[string]$SharePath,
[string]$SourceFileName,
[int]$TimeoutSec = 30)
$kind = $Context.Kind
# Marker BEFORE the post, deliberately. If ShopDB is unreachable we do not
# want every cycle for the rest of the day retrying; the next window picks
# it up.
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $kind)
Set-Content -Path $markerfile -Value (Get-Date -Format 'o') -EA SilentlyContinue
# sourcehostname is load-bearing now, not just informational: ShopDB
# resolves a part-marker PC's backup to ITS marker through this field, and
# dedup keys a revision chain on it. An empty value silently files the
# backup against the operation instead and merges two devices' histories,
# so fall back to the DNS name rather than posting a blank.
$sourcehost = $env:COMPUTERNAME
if (-not $sourcehost) {
try { $sourcehost = [System.Net.Dns]::GetHostName() } catch { $sourcehost = '' }
}
$payload = @{
machinenumber = $Context.MachineNumber
backupkind = $kind
sourcehostname = $sourcehost
collectedat = (Get-Date).ToUniversalTime().ToString('o')
}
if ($SourceFileName) { $payload['sourcefilename'] = $SourceFileName }
if ($Bytes) {
$payload['contentbase64'] = [Convert]::ToBase64String($Bytes)
$payload['bytesize'] = $Bytes.Length
}
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
if ($SharePath) { $payload['sharepath'] = $SharePath }
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
Write-ShopdbBackupLog -Kind $kind -Message (
'Posting {0} for machine {1}' -f $size, $Context.MachineNumber)
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$resp = Invoke-RestMethod -Uri $uri -Method Post `
-Body ($payload | ConvertTo-Json -Compress) `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
-TimeoutSec $TimeoutSec -EA Stop
switch ("$($resp.data.action)") {
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
'noop' { Write-ShopdbBackupLog -Kind $kind -Message 'Settings unchanged - no new revision (expected most cycles).' }
default { Write-ShopdbBackupLog -Kind $kind -Message (
"ShopDB returned action '{0}'." -f $resp.data.action) }
}
foreach ($warning in @($resp.data.warnings)) {
if ($warning) { Write-ShopdbBackupLog -Kind $kind -Message " WARNING: $warning" }
}
return $true
} catch {
# A 400 here is usually meaningful rather than transient: an
# unconfigured device, or a machine number ShopDB does not know. Log the
# server's own message so the cause is visible at the bay.
$detail = $_.Exception.Message
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if ($body) { $detail = $body }
} catch { }
Write-ShopdbBackupLog -Kind $kind -Message "Post failed: $detail"
return $false
}
}
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
Get-ShopdbIntervalHours, Get-ShopdbLogPath

View File

@@ -0,0 +1,203 @@
# Backup-NtlarsSettings.ps1
#
# Captures this PC's LIVE NTLARS/DNC registry settings and posts them to ShopDB,
# which files them against the MACHINE (not this PC) and keeps a revision
# history. A tech can then re-download the .reg from the machine's page instead
# of hunting for a per-machine file on the share.
#
# Runs from the SFLD share every GE-Enforce cycle as a Type=PS1 manifest entry
# with DetectionMethod=Always. Updating this file on the share changes fleet
# behaviour on the next cycle - there is no local copy to heal.
#
# WHY WOW6432Node IS EXPLICIT:
# NTLARS is a 32-bit app, so its settings physically live under
# HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC. GE-Enforce runs this
# script in 64-bit PowerShell, where HKLM:\SOFTWARE\GE Aircraft Engines\DNC
# is a DIFFERENT (usually absent) key. Reading the unredirected path would
# find nothing and back up an empty config - silently, every cycle. The path
# below is therefore spelled out and never abbreviated.
#
# Note the asymmetry with what NTLARS itself writes: its Save... button
# exports WITHOUT the WOW6432Node segment. ShopDB accepts either dialect and
# stores a dialect-neutral projection, so this script does not need to care.
#
# THROTTLE, LOGGING, CREDENTIALS:
# All handled by common\lib\ShopdbBackupClient.psm1, which every backup kind
# shares. GE-Enforce fires this every cycle, so the module holds the marker
# file, exits early until backups_intervalhours has elapsed, and logs a
# no-change outcome ONCE rather than every five minutes. The interval comes
# from ShopDB, so cadence is changed centrally rather than by editing this
# file on the share.
#
# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are
# logged, never thrown.
param(
[string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb',
[int]$TimeoutSec = 30,
# Force a post regardless of the throttle. For a tech capturing a
# known-good config on demand.
[switch]$Force
)
$ErrorActionPreference = 'Continue'
# The only path this script still owns. Log, marker, state, collector key,
# enrollment and base URL all moved to ShopdbBackupClient, which names its files
# per KIND so two backup kinds on one bay cannot collide.
$DNCKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
function Convert-RegTypeName {
param($Kind)
switch ("$Kind") {
'String' { 'REG_SZ' }
'ExpandString' { 'REG_EXPAND_SZ' }
'DWord' { 'REG_DWORD' }
'QWord' { 'REG_QWORD' }
'MultiString' { 'REG_MULTI_SZ' }
'Binary' { 'REG_BINARY' }
default { 'REG_SZ' }
}
}
function Get-DncKeys {
<#
The DNC key and every subkey, root first. Split out from
Export-DncToReg so the formatting logic can be exercised against mock
keys on a machine with no registry (see Test-RegExport.ps1).
#>
$keys = @(Get-Item -Path $DNCKEY -EA Stop)
$keys += @(Get-ChildItem -Path $DNCKEY -Recurse -EA SilentlyContinue)
return $keys
}
function Export-DncToReg {
<#
Emits .reg text in the WOW6432Node dialect for the supplied keys.
Built by hand rather than shelling out to `reg export` because reg.exe
writes UTF-16 to a temp file we would then have to read back, and
because this keeps the value types explicit instead of reparsing them.
Takes the key list as a parameter so it can be tested with mocks; the
escaping and dword formatting here are the part that would corrupt a
backup silently and only surface at restore time.
#>
param([Parameter(Mandatory)]$Keys)
$lines = @('Windows Registry Editor Version 5.00', '')
$lines += "; NTLARS DNC Registry Backup"
$lines += "; Computer: $env:COMPUTERNAME"
$lines += "; Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$lines += ''
foreach ($key in $Keys) {
# PSPath -> the literal HKEY_LOCAL_MACHINE\... form a .reg file needs.
$path = $key.Name -replace '^HKEY_LOCAL_MACHINE', 'HKEY_LOCAL_MACHINE'
$lines += "[$path]"
foreach ($name in $key.GetValueNames()) {
$kind = Convert-RegTypeName $key.GetValueKind($name)
$data = $key.GetValue($name)
$lhs = if ($name -eq '') { '@' } else {
# .NET replacement strings do NOT process backslash escapes, so
# the replacement is the literal output: '\\' emits two
# backslashes, which is what .reg escaping wants. Writing
# '\\\\' here emits FOUR and silently corrupts every path-valued
# setting - verified on Windows before this was fixed.
'"{0}"' -f ($name -replace '\\', '\\' -replace '"', '\"')
}
switch ($kind) {
'REG_DWORD' {
$lines += ('{0}=dword:{1:x8}' -f $lhs, [uint32]$data)
}
'REG_BINARY' {
$hex = ($data | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex:{1}' -f $lhs, $hex)
}
'REG_QWORD' {
$bytes = [BitConverter]::GetBytes([uint64]$data)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(b):{1}' -f $lhs, $hex)
}
'REG_MULTI_SZ' {
$joined = (($data -join "`0") + "`0`0")
$bytes = [Text.Encoding]::Unicode.GetBytes($joined)
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(7):{1}' -f $lhs, $hex)
}
'REG_EXPAND_SZ' {
$bytes = [Text.Encoding]::Unicode.GetBytes(("$data" + "`0"))
$hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ','
$lines += ('{0}=hex(2):{1}' -f $lhs, $hex)
}
default {
$escaped = ("$data" -replace '\\', '\\' -replace '"', '\"')
$lines += ('{0}="{1}"' -f $lhs, $escaped)
}
}
}
$lines += ''
}
return ($lines -join "`r`n") + "`r`n"
}
# =============================================================================
# Main
#
# Guarded so the file can be DOT-SOURCED to get the functions without running a
# backup. Test-RegExport.ps1 relies on this to exercise Export-DncToReg against
# mock keys on a machine with no registry.
# =============================================================================
if ($MyInvocation.InvocationName -eq '.') { return }
# The plumbing - key lookup, base URL, interval, throttle, logging, the POST and
# its response handling - lives in ShopdbBackupClient. It used to live here, and
# being the only implementation meant every defect in it was invisible: the
# interval read fell back to a hardcoded 24h for months because nobody had a
# second copy to compare against, and the log wrote three lines every five
# minutes whatever happened. Seven other Backup-*.ps1 scripts on this share
# capture device configs and post none of them; they can now do so without
# inheriting a copy of all that.
#
# What stays here is the part that is actually about NTLARS: where its registry
# lives, and how to turn it into .reg text.
Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force
if (-not (Test-Path $DNCKEY)) {
# Not an error: plenty of PC types have no NTLARS at all. On those PCs this
# is the permanent state, so it is said once rather than 288 times a day.
Write-ShopdbQuietState -Kind 'ntlars' -State 'no-dnc-key' -Message (
"No DNC key at $DNCKEY - NTLARS is not installed on this PC. Nothing to do.")
exit 0
}
# NTLARS keeps its own MachineNo, used only when pc-config.txt has none. ShopDB
# compares the two and warns on a mismatch rather than silently trusting one.
$ctx = Initialize-ShopdbBackup -Kind 'ntlars' -Force:$Force `
-BaseUrl $(if ($PSBoundParameters.ContainsKey('BaseUrl')) { $BaseUrl } else { '' }) `
-MachineNumberFallback {
try {
$general = Get-ItemProperty -Path (Join-Path $DNCKEY 'General') -EA Stop
if ($general.MachineNo) { return ([string]$general.MachineNo).Trim() }
} catch { }
return ''
}
if (-not $ctx.Proceed) { exit 0 }
try {
$regText = Export-DncToReg -Keys (Get-DncKeys)
} catch {
Write-ShopdbBackupLog -Kind 'ntlars' -Message "Failed to read the DNC key: $_"
exit 0
}
# UTF-16LE + BOM, matching what regedit and NTLARS emit. ShopDB sniffs the BOM,
# so this is belt-and-braces rather than strictly required.
$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($regText)
[void](Send-ShopdbBackup -Context $ctx -Bytes $bytes `
-SourceFileName ("{0}.reg" -f $ctx.MachineNumber) -TimeoutSec $TimeoutSec)
Write-ShopdbBackupLog -Kind 'ntlars' -Message '=== Backup-NtlarsSettings end ==='
exit 0

View File

@@ -0,0 +1,127 @@
# Deploy-ShopfloorStartLayout.ps1
#
# Local-DSC port of the Intune SFLD desktop/Start-menu deployment. Creates the
# Public Desktop weblinks (.url) + app/folder shortcuts (.lnk) AND pins them to
# the Windows 11 Start menu - using the exact same mechanism Simple-Install.ps1
# uses: shortcuts in the All-Users Start Menu, a ConfigureStartPins JSON policy
# in the registry, and a StartMenuExperienceHost reset so it applies on next
# logon. Nothing here needs Intune/MDM - it is all file + registry-policy.
#
# Designed to run from the GE-Enforce manifest engine as a Type=PS1 entry
# (DetectionMethod=Always, or Hash on the pins.json). Idempotent.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File Deploy-ShopfloorStartLayout.ps1
# -AssetsDir <globalassets dir on the share>
#
# AssetsDir holds the prebuilt .url/.lnk (the "globalassets" folder). The pin
# list + order below mirrors device-config.yaml StartMenuPins; entries with a
# Target are created on the fly (app/folder pins), the rest are copied from
# AssetsDir.
param(
[string]$AssetsDir = (Join-Path $PSScriptRoot 'globalassets'),
[string]$DesktopDir = 'C:\Users\Public\Desktop',
[switch]$NoShellRestart
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$log = Join-Path $logDir ('start-layout-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log($m){ "$([DateTime]::Now.ToString('s')) $m" | Tee-Object -FilePath $log -Append | Out-Null }
# Ordered pin set - mirrors device-config.yaml StartMenuPins. Name = the file
# in the All-Users Start Menu (and AssetsDir for prebuilt ones). Target set =>
# create the shortcut; Target empty => copy the prebuilt file from AssetsDir.
$Pins = @(
@{ Name = 'Shopfloor Dashboard.url' }
@{ Name = 'PN & SN Label Printing.url' }
@{ Name = 'WJ Shop Floor Homepage.url' }
@{ Name = 'WJ Web Reports.url' }
@{ Name = 'Blueprint PDF Viewer.url' }
@{ Name = 'Central CSF Web Reports.url' }
@{ Name = 'Plant Apps.url' }
@{ Name = 'Safety Good Catch Form.url' }
@{ Name = 'WJ IT Help Desk.url' }
@{ Name = 'OneIDM.url' }
@{ Name = 'M365 Webmail.url' }
@{ Name = 'HR Central.url' }
@{ Name = 'Defect_Tracker.lnk' }
@{ Name = 'Calculator.lnk' }
@{ Name = 'Notepad.lnk' }
@{ Name = 'eDNC.lnk'; Target = 'C:\Program Files\eDNC\eDNC.exe' }
@{ Name = 'NTLARS.lnk'; Target = 'C:\Program Files (x86)\NTLARS\NTLARS.exe' }
@{ Name = 'Shopfloor Tools.lnk'; Target = 'C:\Users\Public\Desktop\Shopfloor Tools' }
)
$startMenuDir = Join-Path $env:ALLUSERSPROFILE 'Microsoft\Windows\Start Menu\Programs'
function New-UrlShortcut([string]$Path,[string]$Url){
@('[InternetShortcut]', "URL=$Url") | Set-Content -LiteralPath $Path -Encoding ASCII
}
function New-LnkShortcut([string]$Path,[string]$Target,[string]$Args,[string]$Icon){
$sh = New-Object -ComObject WScript.Shell
$sc = $sh.CreateShortcut($Path)
$sc.TargetPath = $Target
if ($Args) { $sc.Arguments = $Args }
# working dir: parent of target for files, the folder itself for folder pins
$sc.WorkingDirectory = if (Test-Path -LiteralPath $Target -PathType Container) { $Target } else { Split-Path -Parent $Target }
if ($Icon) { $sc.IconLocation = $Icon }
$sc.Save()
}
Log "=== Deploy shopfloor start layout (assets: $AssetsDir) ==="
New-Item -ItemType Directory -Path $startMenuDir -Force -EA SilentlyContinue | Out-Null
New-Item -ItemType Directory -Path $DesktopDir -Force -EA SilentlyContinue | Out-Null
$pinnedList = @()
foreach ($pin in $Pins) {
$leaf = $pin.Name
$dst = Join-Path $startMenuDir $leaf
try {
if ($pin.Target) {
# create app/folder shortcut from Target
New-LnkShortcut -Path $dst -Target $pin.Target -Args $pin.Arguments -Icon $pin.IconLocation
Log "created (target) $leaf -> $($pin.Target)"
} else {
# copy prebuilt asset (.url/.lnk) from globalassets
$src = Join-Path $AssetsDir $leaf
if (-not (Test-Path -LiteralPath $src)) { Log "MISSING asset, skipping pin: $src"; continue }
Copy-Item -LiteralPath $src -Destination $dst -Force
# also drop on the Public Desktop
Copy-Item -LiteralPath $src -Destination (Join-Path $DesktopDir $leaf) -Force
Log "copied $leaf (start menu + desktop)"
}
$pinnedList += @{ desktopAppLink = "%ALLUSERSPROFILE%\Microsoft\Windows\Start Menu\Programs\$leaf" }
} catch {
Log "ERROR pin ${leaf}: $($_.Exception.Message)"
}
}
# ConfigureStartPins JSON -> HKLM policy (same shape Simple-Install.ps1 writes)
$jsonDir = 'C:\ProgramData\SFLD\StartMenu'
New-Item -ItemType Directory -Path $jsonDir -Force -EA SilentlyContinue | Out-Null
$jsonPath = Join-Path $jsonDir 'pins.json'
([ordered]@{ applyOnce = $false; pinnedList = $pinnedList } | ConvertTo-Json -Depth 6) |
Set-Content -LiteralPath $jsonPath -Encoding UTF8
$reg = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'
if (-not (Test-Path $reg)) { New-Item -Path $reg -Force | Out-Null }
New-ItemProperty -Path $reg -Name 'ConfigureStartPins' -PropertyType String `
-Value (Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8) -Force | Out-Null
Log "ConfigureStartPins policy written ($($pinnedList.Count) pins) -> $reg"
# Apply now: clear each real user's cached start layout + restart the shell.
if (-not $NoShellRestart) {
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue |
Where-Object { $_.Name -notin @('Public','Default','Default User','All Users') } |
ForEach-Object {
$sb = Join-Path $_.FullName 'AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin'
if (Test-Path -LiteralPath $sb) { Remove-Item -LiteralPath $sb -Force -EA SilentlyContinue; Log "cleared start2.bin: $($_.Name)" }
}
Get-Process -Name 'StartMenuExperienceHost' -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue
Log 'StartMenuExperienceHost restarted (pins apply on next shell load)'
}
Log '=== done ==='
exit 0

View File

@@ -0,0 +1,33 @@
@echo off
REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch
REM
REM Two-step install: base MSI with enterprise transform, then DC update patch.
REM Script lives in <type>/scripts/; MSI + MST + MSP + CAB live in sibling
REM <type>/apps/. pushd into apps\ so the MSI's Media-table CAB reference
REM (Data1.cab) resolves against its sibling, and msiexec doesn't choke on
REM a mapped-drive path with ..\ normalization (was returning 1619).
setlocal
pushd "%~dp0..\apps"
echo Installing Adobe Acrobat Reader DC...
msiexec /i "AcroRead.msi" TRANSFORMS="AcroRead.mst" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader MSI failed with exit code %RC%
popd
exit /b %RC%
)
echo Applying Adobe Reader DC update patch...
msiexec /p "AcroRdrDCUpd2500120531.msp" /quiet /norestart
set RC=%errorlevel%
if %RC% neq 0 if %RC% neq 3010 (
echo Acrobat Reader patch failed with exit code %RC%
popd
exit /b %RC%
)
popd
echo Adobe Acrobat Reader DC installed successfully.
exit /b 0

View File

@@ -0,0 +1,75 @@
@echo off
REM Install-Oracle11r2.cmd
REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and
REM runs Oracle Universal Installer silently with the GE-customized
REM response file.
REM
REM Expected layout on the SFLD share (relative to this .cmd):
REM ..\apps\Oracle_OracleDatabase_11r2_V03.zip (686 MB)
REM
REM Called by Install-FromManifest.ps1 (Type=CMD). Exit codes surface back
REM to the enforcer.
REM
REM Oracle 11.2 OUI exit codes worth knowing:
REM 0 = success
REM 3 = success but with warnings
REM 1 = general failure
REM 6 = silent install requested but missing / bad response file
setlocal enabledelayedexpansion
set "LOG=C:\Logs\OracleClient\install.log"
if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient"
REM Emit a datestamp
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I
set "STAMP=!LDT:~0,14!"
echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%"
set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip"
set "STAGING=%TEMP%\oracle-11r2-install"
set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client"
set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp"
if not exist "%SRC_ZIP%" (
echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%"
echo ERROR: zip not found at %SRC_ZIP%
exit /b 2
)
echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%"
if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1
mkdir "%STAGING%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^
"try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^
>> "%LOG%" 2>&1
if not exist "%CLIENT_DIR%\setup.exe" (
echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%"
exit /b 3
)
if not exist "%RSP%" (
echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%"
exit /b 4
)
echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%"
"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^
-ignoreSysPrereqs ^
-responseFile "%RSP%" >> "%LOG%" 2>&1
set RC=%ERRORLEVEL%
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME
echo [%STAMP%] Cleaning up staging dir >> "%LOG%"
rmdir /s /q "%STAGING%" >nul 2>&1
REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK.
if %RC%==3 (
echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%"
exit /b 0
)
exit /b %RC%

View File

@@ -0,0 +1,62 @@
# Migrate-PCType.ps1 - One-shot in-place rename of legacy pc-type.txt
# values to the gea-shopfloor-* taxonomy.
#
# Idempotent + safe: no-op if pc-type.txt already starts with
# 'gea-shopfloor-'. Decides collections vs nocollections from UDC's
# Uninstall reg presence (collections has UDC, nocollections doesn't).
# Standard-Timeclock + Lab map to gea-shopfloor-common.
#
# Runs every cycle (DetectionMethod=Always in manifest). Cheap because
# the no-op fast path is just one Get-Content + StartsWith check.
$ErrorActionPreference = 'Continue'
$typeFile = 'C:\Enrollment\pc-type.txt'
$subTypeFile = 'C:\Enrollment\pc-subtype.txt'
if (-not (Test-Path $typeFile)) { exit 0 }
$current = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim()
if (-not $current) { exit 0 }
# Already on new taxonomy
if ($current.StartsWith('gea-shopfloor-')) { exit 0 }
$subType = ''
if (Test-Path $subTypeFile) {
$subType = (Get-Content -LiteralPath $subTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Map legacy -> new
$newType = $null
switch -Regex ($current) {
'^Standard$' {
if ($subType -ieq 'Machine') {
$udcReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\UDC'
$newType = if ($udcReg) { 'gea-shopfloor-collections' } else { 'gea-shopfloor-nocollections' }
} elseif ($subType -ieq 'Timeclock') {
$newType = 'gea-shopfloor-common'
} else {
# Standard with no subtype - default to collections (most common)
$newType = 'gea-shopfloor-collections'
}
}
'^CMM$' { $newType = 'gea-shopfloor-cmm' }
'^Keyence$' { $newType = 'gea-shopfloor-keyence' }
'^Lab$' { $newType = 'gea-shopfloor-common' }
'^WaxAndTrace$' { $newType = 'gea-shopfloor-waxtrace' }
'^Genspect$' { $newType = 'gea-shopfloor-genspect' }
'^Display$' { $newType = 'gea-shopfloor-display' }
'^Heattreat$' { $newType = 'gea-shopfloor-heattreat' }
default { Write-Host "Migrate-PCType: unmapped legacy value '$current' - leaving alone"; exit 0 }
}
Write-Host "Migrate-PCType: '$current' (subType='$subType') -> '$newType'"
Set-Content -LiteralPath $typeFile -Value $newType -Encoding ascii -ErrorAction Stop
# Drop pc-subtype.txt - new taxonomy is single-string
if (Test-Path $subTypeFile) {
try { Remove-Item -LiteralPath $subTypeFile -Force -ErrorAction Stop; Write-Host " removed pc-subtype.txt" } catch {}
}
exit 0

View File

@@ -0,0 +1,68 @@
# Select-KioskType.ps1
#
# Imaging-time picker that records which kind of kiosk this PC is, so GE-Enforce
# (share or Flask API) enforces the right scope. Writes the scope name to
# C:\Enrollment\pc-type.txt - the same file every other pc-type reads, and the
# value the Flask client passes as -Scope.
#
# Three kiosk subtypes (split out of the old generic gea-shopfloor-display):
# 1 lobbydisplay -> gea-shopfloor-lobbydisplay (lobby TV, dt\tv\slides)
# 2 dashboard -> gea-shopfloor-dashboard (shopfloor dashboard)
# 3 printerkiosk -> gea-shopfloor-printerkiosk (3D-printer kiosk)
#
# Usage:
# interactive (imaging operator picks): .\Select-KioskType.ps1
# unattended (automation / task seq): .\Select-KioskType.ps1 -Type dashboard
# already-deployed kiosk (one-time set): .\Select-KioskType.ps1 -Type lobbydisplay
#
# Idempotent: rewrites pc-type.txt to the chosen scope. Always exits 0.
param(
[ValidateSet('lobbydisplay', 'dashboard', 'printerkiosk')]
[string]$Type,
[string]$EnrollmentFile = 'C:\Enrollment\pc-type.txt'
)
$ErrorActionPreference = 'Continue'
$map = [ordered]@{
'1' = @{ Key = 'lobbydisplay'; Scope = 'gea-shopfloor-lobbydisplay'; Desc = 'Lobby display (lobby TV / slides)' }
'2' = @{ Key = 'dashboard'; Scope = 'gea-shopfloor-dashboard'; Desc = 'Shopfloor dashboard' }
'3' = @{ Key = 'printerkiosk'; Scope = 'gea-shopfloor-printerkiosk'; Desc = '3D-printer kiosk' }
}
function Resolve-ScopeFromType([string]$t) {
foreach ($k in $map.Keys) { if ($map[$k].Key -eq $t) { return $map[$k].Scope } }
return $null
}
$scope = $null
if ($Type) {
$scope = Resolve-ScopeFromType $Type
} else {
Write-Host ''
Write-Host 'Select this PC kiosk type:' -ForegroundColor Cyan
foreach ($k in $map.Keys) { Write-Host (" {0}) {1}" -f $k, $map[$k].Desc) }
Write-Host ''
do {
$choice = Read-Host 'Enter 1, 2, or 3'
} while (-not $map.Contains($choice))
$scope = $map[$choice].Scope
}
if (-not $scope) {
Write-Host "ERROR could not resolve a kiosk scope (Type='$Type')." -ForegroundColor Red
exit 0
}
try {
$dir = Split-Path -Parent $EnrollmentFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$scope | Set-Content -NoNewline -LiteralPath $EnrollmentFile -ErrorAction Stop
Write-Host "wrote $EnrollmentFile = $scope" -ForegroundColor Green
} catch {
Write-Host "ERROR writing ${EnrollmentFile}: $($_.Exception.Message)" -ForegroundColor Red
}
exit 0

Some files were not shown because too many files have changed in this diff Show More