Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -352,6 +352,8 @@ def list_scopes():
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'manifestversion': scope.manifestversion,
|
||||
'minlibversion': scope.minlibversion,
|
||||
'minlibversion': scope.minlibversion,
|
||||
'computertypeid': scope.computertypeid,
|
||||
'measuringtooltypeid': scope.measuringtooltypeid,
|
||||
'iscommon': scope.iscommon,
|
||||
@@ -462,6 +464,7 @@ def create_scope():
|
||||
scope = ManifestScope(
|
||||
scopename=scopename, phase=phase,
|
||||
manifestversion=str(payload.get('manifestversion', '1.0')),
|
||||
minlibversion=(payload.get('minlibversion') or None),
|
||||
description=payload.get('description'),
|
||||
computertypeid=payload.get('computertypeid'),
|
||||
measuringtooltypeid=payload.get('measuringtooltypeid'),
|
||||
@@ -496,6 +499,10 @@ def update_scope(scopeid):
|
||||
setattr(scope, field, payload[field])
|
||||
if 'manifestversion' in payload:
|
||||
scope.manifestversion = str(payload['manifestversion'])
|
||||
if 'minlibversion' in payload:
|
||||
# Blank clears it, which returns the scope to a derived floor rather
|
||||
# than pinning it at the empty string.
|
||||
scope.minlibversion = (payload['minlibversion'] or None)
|
||||
if 'iscommon' in payload:
|
||||
scope.iscommon = bool(payload['iscommon'])
|
||||
db.session.commit()
|
||||
@@ -764,9 +771,9 @@ def publish_preflight(scopeid):
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
hosts, floor = service.hosts_below_libversion(
|
||||
scope.scopename, scope.phase, scope.manifestversion)
|
||||
scope.scopename, scope.phase, service.required_libversion(scope))
|
||||
return success_response({
|
||||
'required': scope.manifestversion,
|
||||
'required': service.required_libversion(scope),
|
||||
'floor': floor,
|
||||
'hostsbehind': hosts,
|
||||
'canpublish': not hosts,
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
|
||||
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
|
||||
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
|
||||
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "Main Plant" `
|
||||
-ShopdbUrl https://shopdb.example.net -ShopdbToken shopdb_pat_xxx `
|
||||
-EngineSource \\server\share\dt\shopfloor\common
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
|
||||
@@ -20,10 +20,30 @@
|
||||
# Runs as SYSTEM under GE-Enforce, from an entry gated to the test bays.
|
||||
# Fail-safe: exits 0 on every path - shadowing must never stop a bay enforcing.
|
||||
|
||||
param(
|
||||
# This site's ShopDB. Optional: a bay that already carries a BaseUrl in the
|
||||
# registry keeps it, so the fleet needs this only on the first cycle or when
|
||||
# the address changes. There is deliberately NO default - see below.
|
||||
[string] $BaseUrl = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# RE-ENTRANCY GUARD. This entry lives IN the manifest the shadow run enforces,
|
||||
# so without it the thing recurses without bound: engine runs the manifest ->
|
||||
# reaches this entry -> we invoke the runner -> the runner runs the engine
|
||||
# against the SAME manifest -> reaches this entry again. Measured on the test
|
||||
# bays 2026-08-13: 347 nested cycles in 36 minutes, one every five seconds,
|
||||
# until the share unmounted. The task-based predecessor never hit this because
|
||||
# it registered a task instead of invoking the runner.
|
||||
#
|
||||
# An environment variable, because it is inherited by every child process and
|
||||
# therefore covers the nested engine and runner without a file to clean up or a
|
||||
# stale lock to age out. Set for THIS process tree only.
|
||||
if ($env:SHOPDB_SHADOW_ACTIVE -eq '1') { exit 0 }
|
||||
$env:SHOPDB_SHADOW_ACTIVE = '1'
|
||||
|
||||
$InstallDir = 'C:\Program Files\GE\Shopfloor'
|
||||
$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
|
||||
$LegacyTask = 'ShopDB GE-Enforce (shadow)'
|
||||
|
||||
function Write-ShadowLog {
|
||||
@@ -66,12 +86,30 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# BaseUrl only when it differs, so a hand-set value is not churned.
|
||||
# THE BAY'S OWN VALUE WINS. This used to hold one site's ShopDB URL as a
|
||||
# compiled-in constant and write it whenever the registry disagreed. At the
|
||||
# site it was written for that reads as "heal drift"; anywhere else it reads
|
||||
# as "overwrite this site's address on every enforce cycle", and the site
|
||||
# cannot win because the cycle repeats. A second plant could not point its
|
||||
# own bays at its own server.
|
||||
#
|
||||
# So: an explicitly passed -BaseUrl seeds the value, an existing registry
|
||||
# value is never touched, and with neither there is nothing honest to write.
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
|
||||
if ((Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl -ne $BaseUrl) {
|
||||
$current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl
|
||||
if (-not $current) {
|
||||
if (-not $BaseUrl) {
|
||||
Write-ShadowLog "no BaseUrl in $regPath and none passed - set it, or pass -BaseUrl in the manifest entry. Skipping this cycle."
|
||||
exit 0
|
||||
}
|
||||
Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl
|
||||
Write-ShadowLog "BaseUrl set to $BaseUrl"
|
||||
Write-ShadowLog "BaseUrl seeded as $BaseUrl"
|
||||
} elseif ($BaseUrl -and $BaseUrl -ne $current) {
|
||||
# Say it, do not do it. A changed address is a real event, and it should
|
||||
# be a deliberate one, not a side effect of whatever the manifest last
|
||||
# carried.
|
||||
Write-ShadowLog "BaseUrl in registry is $current; -BaseUrl passed $BaseUrl. Keeping the registry value."
|
||||
}
|
||||
|
||||
Write-ShadowLog "shadowing $scope against $shareManifest"
|
||||
@@ -83,3 +121,8 @@ catch {
|
||||
Write-ShadowLog "FAILED: $_"
|
||||
exit 0
|
||||
}
|
||||
finally {
|
||||
# Clear it so the NEXT enforce cycle shadows again. Without this the guard
|
||||
# would latch for the life of the process tree and shadow would run once.
|
||||
$env:SHOPDB_SHADOW_ACTIVE = $null
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
a PC is never left unmanaged because the web app is down.
|
||||
|
||||
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
|
||||
BaseUrl e.g. https://shopdb.example.geaerospace.net
|
||||
BaseUrl e.g. https://shopdb.example.net
|
||||
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
|
||||
provisioned the same way as SFLD creds (Azure DSC).
|
||||
#>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
class="cidr-input"
|
||||
rows="5"
|
||||
spellcheck="false"
|
||||
placeholder="10.134.48.0/23 10.48.249.0/26"
|
||||
placeholder="192.0.2.0/24 198.51.100.0/26"
|
||||
></textarea>
|
||||
<small class="input-hint">
|
||||
One CIDR (or a plain IP) per line, or comma-separated. Example:
|
||||
<code>10.134.48.0/23, 10.48.249.0/26</code>. A caller from any of these
|
||||
<code>192.0.2.0/24, 198.51.100.0/26</code>. A caller from any of these
|
||||
networks may pull manifests + payloads and post reports with no token.
|
||||
</small>
|
||||
|
||||
|
||||
@@ -19,10 +19,28 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _columns(bind):
|
||||
insp = sa.inspect(bind)
|
||||
if 'manifestenforcementreports' not in insp.get_table_names():
|
||||
return None
|
||||
return {c['name'] for c in insp.get_columns('manifestenforcementreports')}
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Guarded, like network0003prefix. On a FRESH database the table is built
|
||||
# from the SQLAlchemy models, which already declare this column, so an
|
||||
# unconditional add fails with "duplicate column name" - and this chain has
|
||||
# been re-running its head on MySQL (see the missing commit in
|
||||
# shopdb/plugins/alembic_template.py), so a redeploy hit the same wall.
|
||||
columns = _columns(op.get_bind())
|
||||
if columns is None or 'subtype' in columns:
|
||||
return
|
||||
op.add_column('manifestenforcementreports',
|
||||
sa.Column('subtype', sa.String(length=50), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
columns = _columns(op.get_bind())
|
||||
if columns is None or 'subtype' not in columns:
|
||||
return
|
||||
op.drop_column('manifestenforcementreports', 'subtype')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""A scope declares the client library it needs, separately from its own version.
|
||||
|
||||
The publish gate exists to refuse a manifest the fleet's GE-Enforce library
|
||||
cannot read correctly. It was comparing reported library versions against
|
||||
`manifestversion` - the manifest's own 'Version' field, which for a share
|
||||
imported manifest happens to share the library's numbering but for a
|
||||
code-authored scope is whatever the author chose (seed_display_scope writes
|
||||
'2.0'). Every kiosk runs something newer than that, so the gate passed on the
|
||||
one scope most in need of it.
|
||||
|
||||
`minlibversion` says the thing the gate actually wants to know. NULL means
|
||||
derive it from what the manifest uses, so existing scopes need no edit.
|
||||
|
||||
Revision ID: geenforce0004minlib
|
||||
Revises: geenforce0003subtype
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'geenforce0004minlib'
|
||||
down_revision = 'geenforce0003subtype'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE = 'manifestscopes'
|
||||
|
||||
|
||||
def _columns(bind):
|
||||
insp = sa.inspect(bind)
|
||||
if TABLE not in insp.get_table_names():
|
||||
return None
|
||||
return {c['name'] for c in insp.get_columns(TABLE)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Guarded like geenforce0003subtype and network0003prefix: on a fresh
|
||||
# database the table is built from the models, which already declare this.
|
||||
columns = _columns(op.get_bind())
|
||||
if columns is None or 'minlibversion' in columns:
|
||||
return
|
||||
op.add_column(TABLE, sa.Column('minlibversion', sa.String(length=16),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
columns = _columns(op.get_bind())
|
||||
if columns is None or 'minlibversion' not in columns:
|
||||
return
|
||||
op.drop_column(TABLE, 'minlibversion')
|
||||
@@ -49,6 +49,15 @@ class ManifestScope(BaseModel):
|
||||
computertypeid = db.Column(db.Integer, nullable=True)
|
||||
measuringtooltypeid = db.Column(db.Integer, nullable=True)
|
||||
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
|
||||
# Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
|
||||
#
|
||||
# SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
|
||||
# and is whatever its author wrote - seed_display_scope picks '2.0'. The
|
||||
# publish gate used manifestversion as the library floor, so for every
|
||||
# code-authored scope it compared the fleet against a number that says
|
||||
# nothing about library features, and passed. NULL means "derive it", see
|
||||
# service.required_libversion.
|
||||
minlibversion = db.Column(db.String(16), nullable=True)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
# The manifest-level '_comment' (documentation), preserved verbatim so
|
||||
# export-to-share round-trips it. Excluded from behavioral parity.
|
||||
|
||||
@@ -84,6 +84,20 @@ def _relaunch_window_targets_comment():
|
||||
return pairs
|
||||
|
||||
|
||||
def _site_base_url():
|
||||
"""This site's public base URL from settings, or '' when it is unset.
|
||||
|
||||
Wrapped because the script builders are also called with no application
|
||||
context (the seed tests build the text and check it compiles), and a
|
||||
settings lookup there raises rather than returning a default.
|
||||
"""
|
||||
try:
|
||||
from shopdb.api import Setting
|
||||
return (Setting.get('site_base_url', '') or '').strip().rstrip('/')
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def build_dispatcher_script():
|
||||
"""Return the inline dispatcher PowerShell as text.
|
||||
|
||||
@@ -96,6 +110,11 @@ def build_dispatcher_script():
|
||||
table_lines.append(f" '{display_type}' = '{route}'")
|
||||
table_body = ';\n'.join(table_lines)
|
||||
|
||||
# Baked in at seed time because the emitted script runs on a kiosk with no
|
||||
# route back to the database. Blank when the site has not set it, and the
|
||||
# dispatcher refuses rather than guessing - see the comment on $KioskBaseUrl.
|
||||
kiosk_base_url = _site_base_url()
|
||||
|
||||
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
|
||||
#
|
||||
# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
|
||||
@@ -122,12 +141,19 @@ $DisplayTypeTargets = @{{
|
||||
|
||||
# Base URL the kiosk browser opens; the route from the table is appended. Read
|
||||
# from HKLM (the value the GE-Enforce client is configured with) so it stays
|
||||
# site-agnostic; falls back to the West Jefferson shopdb host.
|
||||
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
|
||||
# site-agnostic. The fallback is THIS site's site_base_url setting, baked in
|
||||
# when the scope was seeded - it used to be one particular plant's host, which
|
||||
# meant a kiosk anywhere else with no registry value quietly opened a server it
|
||||
# has no business reaching.
|
||||
$KioskBaseUrl = '{kiosk_base_url}'
|
||||
try {{
|
||||
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
|
||||
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
|
||||
}} catch {{}}
|
||||
if (-not $KioskBaseUrl) {{
|
||||
Write-Host 'no ShopDB base URL: set HKLM:\\SOFTWARE\\GE\\ShopDB BaseUrl, or set site_base_url in Settings and re-publish the display scope.'
|
||||
exit 0
|
||||
}}
|
||||
|
||||
# PREFERRED: resolve this display's role from the server by its FQDN, so a change
|
||||
# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The
|
||||
@@ -136,7 +162,7 @@ try {{
|
||||
$path = $null
|
||||
$serial = ''
|
||||
try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}}
|
||||
$fqdnDomain = 'device.geaerospace.net'
|
||||
$fqdnDomain = 'device.geaerospace.net' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
|
||||
try {{
|
||||
$ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop
|
||||
if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }}
|
||||
@@ -200,8 +226,15 @@ Get-ChildItem -LiteralPath $startup -Filter '*.lnk' -ErrorAction SilentlyContinu
|
||||
# -kiosk matches both '-kiosk' and '--kiosk'. Also catch any browser
|
||||
# shortcut pointing at a shopdb kiosk URL (incl. the dead
|
||||
# shopfloor-dashboard route + --app variants).
|
||||
# The host comes from $KioskBaseUrl, not from a literal: this used
|
||||
# to name one site's server, so at any other site a legacy shortcut
|
||||
# pointing at that site's own ShopDB was not recognised and never
|
||||
# cleaned up.
|
||||
$kioskHost = ''
|
||||
try {{ $kioskHost = ([uri]$KioskBaseUrl).Host }} catch {{}}
|
||||
$kioskArgs = ($existing.Arguments -match '(?i)-kiosk') -or `
|
||||
($existing.Arguments -match '(?i)tsgwp00525|/shopdb/|shopfloor-dashboard')
|
||||
($kioskHost -and $existing.Arguments -like "*$kioskHost*") -or `
|
||||
($existing.Arguments -match '(?i)/shopdb/|shopfloor-dashboard')
|
||||
if ($isBrowser -and $kioskArgs) {{ $drop = $true }}
|
||||
}} catch {{}}
|
||||
}}
|
||||
|
||||
@@ -116,6 +116,45 @@ def hosts_below_libversion(scopename, phase, required):
|
||||
return sorted(set(behind)), floor
|
||||
|
||||
|
||||
# Manifest features and the client library that first understood them. A
|
||||
# library older than this does not merely skip the feature, it MISREADS the
|
||||
# entry: 2.6 added _CmmVersion, and an older lib treats a version-gated entry as
|
||||
# ungated and installs every PC-DMIS build it cannot detect.
|
||||
#
|
||||
# Only features that NARROW behaviour belong here. An addition an old lib
|
||||
# ignores harmlessly does not need a floor.
|
||||
LIBVERSION_FEATURES = (
|
||||
('cmmversion', '2.6'),
|
||||
)
|
||||
|
||||
|
||||
def required_libversion(scope):
|
||||
"""Lowest client library that may enforce this scope.
|
||||
|
||||
An explicit minlibversion wins. Otherwise it is derived from the features
|
||||
the draft actually uses, so a scope written before this column existed is
|
||||
still gated on what it contains rather than on manifestversion - which is
|
||||
the manifest's own 'Version' field and says nothing about the library.
|
||||
|
||||
Falls back to manifestversion when nothing else applies, preserving the
|
||||
behaviour share-imported manifests already relied on, where the two
|
||||
numbering schemes do coincide.
|
||||
"""
|
||||
declared = (scope.minlibversion or '').strip()
|
||||
if declared:
|
||||
return declared
|
||||
|
||||
floor = None
|
||||
for attribute, version in LIBVERSION_FEATURES:
|
||||
if any(getattr(entry, attribute, None) for entry in scope.entries):
|
||||
candidate = parse_libversion(version)
|
||||
if candidate and (floor is None or candidate > floor[0]):
|
||||
floor = (candidate, version)
|
||||
if floor:
|
||||
return floor[1]
|
||||
return scope.manifestversion
|
||||
|
||||
|
||||
def _installed_app_model():
|
||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||
|
||||
@@ -184,11 +223,11 @@ def publish_scope(scopename, phase, notes=None, publishedby=None, force=False):
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
|
||||
if not force:
|
||||
behind, floor = hosts_below_libversion(
|
||||
scopename, phase, scope.manifestversion)
|
||||
required = required_libversion(scope)
|
||||
behind, floor = hosts_below_libversion(scopename, phase, required)
|
||||
if behind:
|
||||
raise LibVersionTooOldError(
|
||||
scopename, scope.manifestversion, floor or 'unknown', behind)
|
||||
scopename, required, floor or 'unknown', behind)
|
||||
|
||||
text = scope_to_json(scope)
|
||||
maxversion = db.session.query(
|
||||
|
||||
Reference in New Issue
Block a user