Compare commits
177 Commits
main
...
pxe-webapp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9c4fdac4 | ||
|
|
54cbe6b5d6 | ||
|
|
b075939c38 | ||
|
|
6d5fee786c | ||
|
|
0e1b80b903 | ||
|
|
8907a86c42 | ||
|
|
a7cb7164d9 | ||
|
|
28efde7d76 | ||
|
|
d5cf255443 | ||
|
|
7ed30f9b85 | ||
|
|
c4aeaaaa17 | ||
|
|
54176e591f | ||
|
|
d185e2b810 | ||
|
|
68f2ce29e4 | ||
|
|
f442ff2f65 | ||
|
|
a0aceb4680 | ||
|
|
bb08392b84 | ||
|
|
9d51c0b987 | ||
|
|
68df59e117 | ||
|
|
d2200e8522 | ||
|
|
8c21282024 | ||
|
|
36be60e9ae | ||
|
|
b96ff7e970 | ||
|
|
d04865da0f | ||
|
|
2b6189f739 | ||
|
|
385acfefa7 | ||
|
|
d5f7abc19f | ||
|
|
049dd7bde6 | ||
|
|
c7b17696d0 | ||
|
|
dfa026b4ee | ||
|
|
936902dc4d | ||
|
|
27e76467a8 | ||
|
|
66c24b5d59 | ||
|
|
1a175bc4fe | ||
|
|
01f524e8b3 | ||
|
|
3880151e71 | ||
|
|
5f97e81dec | ||
|
|
626561a1fa | ||
|
|
f1d9bdf478 | ||
|
|
cc391529bd | ||
|
|
234295931e | ||
|
|
0ffdcc79ed | ||
|
|
3b63697176 | ||
|
|
0cb6b26c27 | ||
|
|
8fbae24b4d | ||
|
|
18db077475 | ||
|
|
8df30017aa | ||
|
|
b240cfea12 | ||
|
|
e7be0f5391 | ||
|
|
0c4c2a245d | ||
|
|
fdf63ab32f | ||
|
|
64e1f7e088 | ||
|
|
287ec86c12 | ||
|
|
85e7d91b1a | ||
|
|
2461804fc8 | ||
|
|
48bc609eb5 | ||
|
|
132c57ab39 | ||
|
|
99deaa6882 | ||
|
|
948d013539 | ||
|
|
6e218f3daf | ||
|
|
b5b644a360 | ||
|
|
bc89ba1cf3 | ||
|
|
1487abdba5 | ||
|
|
7af66575d0 | ||
|
|
8e11f0824a | ||
|
|
d441abd20f | ||
|
|
9145023440 | ||
|
|
51edf98e7d | ||
|
|
e3a3fa6794 | ||
|
|
a165a79f95 | ||
|
|
41cace17e8 | ||
|
|
e97e5bd049 | ||
|
|
c2538a05c5 | ||
|
|
59deaea714 | ||
|
|
f49fa0f940 | ||
|
|
c8e704b595 | ||
|
|
1d65103cc0 | ||
|
|
bfe17fe123 | ||
|
|
da380fbcd7 | ||
|
|
a935c04e1f | ||
|
|
1e17a0564f | ||
|
|
99e7679e87 | ||
|
|
1e6603d331 | ||
|
|
e844ff367c | ||
|
|
913c807142 | ||
|
|
1853db0903 | ||
|
|
7a67716fcc | ||
|
|
995909042b | ||
|
|
a351160520 | ||
|
|
a380b17112 | ||
|
|
5211861409 | ||
|
|
db6be99d43 | ||
|
|
a6fa21589b | ||
|
|
f570e92847 | ||
|
|
ea136687e0 | ||
|
|
57fae57d3b | ||
|
|
c88b2b0ab8 | ||
|
|
1a5852f7ff | ||
|
|
0b116e3ecf | ||
|
|
f6d970c08d | ||
|
|
59b1a9fb65 | ||
|
|
69a1682a7f | ||
|
|
c74148a222 | ||
|
|
191083e440 | ||
|
|
1860a92afa | ||
|
|
9ad467ba6c | ||
|
|
485fe1c7c4 | ||
|
|
9b46d0279f | ||
|
|
5e13d38512 | ||
|
|
55c1ab4814 | ||
|
|
5c3db71879 | ||
|
|
97b9e58d23 | ||
|
|
7298d433eb | ||
|
|
de7d41f5e5 | ||
|
|
f95d305cca | ||
|
|
8e1f81b942 | ||
|
|
77c917157d | ||
|
|
d0dcce5427 | ||
|
|
6602afde38 | ||
|
|
d359563a4c | ||
|
|
cb149ed8cd | ||
|
|
821e3179d1 | ||
|
|
fce6680c6f | ||
|
|
ed12988591 | ||
|
|
b8bb00e2fe | ||
|
|
a104cfdebb | ||
|
|
b57ba0fb6f | ||
|
|
54dddaa760 | ||
|
|
00d4105956 | ||
|
|
86fbc132dd | ||
|
|
4015adeb33 | ||
|
|
de3018512a | ||
|
|
7f93347f74 | ||
|
|
548d85fed5 | ||
|
|
45f39fd431 | ||
|
|
b86b830568 | ||
|
|
a22d2f0313 | ||
|
|
44554b95b0 | ||
|
|
02499cf74b | ||
|
|
27045d5e4a | ||
|
|
1f60c86ec8 | ||
|
|
44d2f0afd5 | ||
|
|
5891a1966f | ||
|
|
e1ea6b7c62 | ||
|
|
2a0b4885fe | ||
|
|
37357eee43 | ||
|
|
3aabd47571 | ||
|
|
7f097013fc | ||
|
|
9108b495c9 | ||
|
|
d8c64bef2b | ||
|
|
76a3ba513c | ||
|
|
3385bc87aa | ||
|
|
220c5db5b9 | ||
|
|
8debc4ddb3 | ||
|
|
036090348c | ||
|
|
b9f66687ac | ||
|
|
65eeead5a0 | ||
|
|
a9a7478d5a | ||
|
|
1c361e138b | ||
|
|
ca647cb690 | ||
|
|
5f322d1110 | ||
|
|
520d4aa791 | ||
|
|
894305e906 | ||
|
|
1b7e1bfee4 | ||
|
|
d5398bdd74 | ||
|
|
cdb6655e4a | ||
|
|
74ba3d1339 | ||
|
|
4599c85509 | ||
|
|
2d75935dfc | ||
|
|
3fb1d983df | ||
|
|
9beee842f1 | ||
|
|
f013aa2bff | ||
|
|
a9260ecadd | ||
|
|
ab3e1c98f6 | ||
|
|
842ef88ccb | ||
|
|
a17b3fae6a | ||
|
|
ce604adcda |
18
.gitignore
vendored
@@ -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
|
After Width: | Height: | Size: 4.9 KiB |
BIN
Binary/Binary.NewBinary10
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Binary/Binary.NewBinary11
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Binary/Binary.NewBinary12
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Binary/Binary.NewBinary13
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary14
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary15
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary16
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary17
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary18
Normal file
|
After Width: | Height: | Size: 766 B |
40
Binary/Binary.NewBinary19
Normal 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
|
After Width: | Height: | Size: 318 B |
22
Binary/Binary.NewBinary20
Normal 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
@@ -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
|
After Width: | Height: | Size: 318 B |
BIN
Binary/Binary.NewBinary4
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Binary/Binary.NewBinary5
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary/Binary.NewBinary6
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Binary/Binary.NewBinary7
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary8
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
Binary/Binary.NewBinary9
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
Binary/Binary.SetAllUsers.dll
Normal file
BIN
Icon/Icon.ARPPRODUCTICON.exe
Normal file
BIN
Icon/Icon.NewShortcut1_AB8E7834205C40DE9AD7D94845255E55.exe
Normal file
BIN
Icon/Icon.NewShortcut2_1E955C7F522448028AA8419A487CA996.exe
Normal file
BIN
Icon/Icon._052C53DD_2BAF_4769_A1A0_1519CA2B23FB
Normal file
BIN
Icon/Icon._173060F8_E06D_4B06_B289_4656D92A595E
Normal file
BIN
Icon/Icon._3336CEAD_1861_4B26_A1C1_2D2F75C36A27
Normal file
BIN
Icon/Icon._59497049_1008_4427_84DF_744B04B0F075
Normal file
BIN
Icon/Icon._5EF3CFE6_6B6A_49D5_A54E_95222360D405
Normal file
BIN
Icon/Icon._823D3F96_4702_483C_BE0E_6CF23CAA78AD
Normal file
BIN
Icon/Icon._90919B36_D3D7_437D_B050_7D5620C88056
Normal file
BIN
Icon/Icon._E7806B4E_27A4_4270_8F13_3E9E01192773
Normal file
16
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
12
SETUP.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
boot-tools/blancco/blancco-chain.ipxe
Normal 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
|
||||||
48
boot-tools/blancco/blancco-debug.ipxe
Normal 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
|
||||||
17
boot-tools/blancco/blancco.ipxe
Normal 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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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:
|
||||||
|
|||||||
695
docs/ZERO-TOUCH-IMAGING-PROPOSAL.md
Normal 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
|
||||||
|
```
|
||||||
144
docs/cyberark-cmm-doda-policy.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
47
docs/post-deploy-checklist.live.html
Normal 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>
|
||||||
37
docs/post-deploy-checklist.md
Normal 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).
|
||||||
59
docs/post-deploy-checklist.static.html
Normal 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>
|
||||||
@@ -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*
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -152,19 +152,14 @@
|
|||||||
<!-- 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>
|
|
||||||
<Path>C:\Deploy\Applications\extra\opentext\unattended.bat</Path>
|
|
||||||
<Description>Install J2SE Runtime Environment 1.6.0_22</Description>
|
|
||||||
</RunSynchronousCommand>
|
|
||||||
|
|
||||||
<!-- Adobe -->
|
<!-- Adobe -->
|
||||||
<RunSynchronousCommand wcm:action="add">
|
<RunSynchronousCommand wcm:action="add">
|
||||||
|
|||||||
@@ -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)..."
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
142
playbook/select-waxtrace-asset.ps1
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1
Normal 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
|
||||||
409
playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
Normal 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 ""
|
||||||
181
playbook/shopfloor-setup/Fetch-StagingPayload.ps1
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +331,19 @@ 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) {
|
||||||
|
if ($app.PCTypesStrict) {
|
||||||
|
foreach ($t in $allowedTypes) {
|
||||||
|
if (($pcType -ieq $t) -or ($pcProfileKey -ieq $t)) { $matchesType = $true; break }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
foreach ($t in $allowedTypes) {
|
foreach ($t in $allowedTypes) {
|
||||||
if ($myNames.Contains($t)) { $matchesType = $true; break }
|
if ($myNames.Contains($t)) { $matchesType = $true; break }
|
||||||
foreach ($g in $aliasGroups) {
|
foreach ($g in $aliasGroups) {
|
||||||
@@ -338,6 +354,7 @@ foreach ($app in $config.Applications) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (-not $matchesType) {
|
if (-not $matchesType) {
|
||||||
Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
|
Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
|
||||||
$skipped++
|
$skipped++
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
playbook/shopfloor-setup/Shopfloor/Apply-MachineNumber.ps1
Normal 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 {}
|
||||||
|
}
|
||||||
@@ -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 '; ')"
|
||||||
|
|||||||
@@ -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
|
||||||
|
# Check Machine Number task name (in case this bay was imaged
|
||||||
|
# before the split and never re-imaged).
|
||||||
|
$removed = @()
|
||||||
|
foreach ($t in @($machineNumPromptTask, $machineNumApplyTask, $machineNumLegacyTask)) {
|
||||||
try {
|
try {
|
||||||
Unregister-ScheduledTask -TaskName $machineNumTaskName -Confirm:$false -ErrorAction Stop
|
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
|
||||||
Write-Host " Machine number logon prompt: REMOVED" -ForegroundColor Yellow
|
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
|
$machineNumTaskExists = $false
|
||||||
} catch { Write-Warning " Failed to remove task: $_" }
|
|
||||||
} 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
200
playbook/shopfloor-setup/Shopfloor/Prompt-MachineNumber.ps1
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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: $_"
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
# Force the GE Report IP exe to post the new (AESFMA corp) IP
|
||||||
|
# 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 {
|
} catch {
|
||||||
Write-Warning "WiFi swap (INTERNETACCESS -> AESFMA) failed: $_"
|
Write-Warning "Force GE Report IP failed: $_"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($phase1Essential -and $script:cache.DeviceId -and -not $script:cache.DeviceIdReported) {
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# 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,9 +1382,17 @@ try {
|
|||||||
$nextRetrigger = $lastSync.AddMinutes($currentInterval)
|
$nextRetrigger = $lastSync.AddMinutes($currentInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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
|
Start-Sleep -Seconds $PollSecs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch {
|
catch {
|
||||||
# Any unhandled exception in the main loop lands here. Write the error
|
# Any unhandled exception in the main loop lands here. Write the error
|
||||||
# into the transcript and then block on a keypress so the PS window
|
# into the transcript and then block on a keypress so the PS window
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: $_"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
27
playbook/shopfloor-setup/Verify-And-Heal-Staging.bat
Normal 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
|
||||||
177
playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1
Normal 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
|
||||||
|
}
|
||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
375
playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
33
playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd
Executable 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
|
||||||
75
playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd
Executable 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%
|
||||||
62
playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1
Executable 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
|
||||||
68
playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1
Normal 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
|
||||||