Merge origin/main into align-wilm-main (align wilm with Cameron's latest)
Bring main's endpoint hardening, operator-report validation, launch-path policy, and theme-variable CSS into wilm. Channel model reconciled to this repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
@@ -21,16 +21,30 @@ from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_role,
|
||||
require_role, Setting,
|
||||
)
|
||||
|
||||
from ..models import EndpointMonitor
|
||||
from .routes import endpoints_bp
|
||||
|
||||
|
||||
# Shipped monitors default to this PC type until they are authored as policies.
|
||||
# Which PC type the shipped monitors attach to, as a SETTING rather than a
|
||||
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
|
||||
# type is called "Shop Floor Workstation" got no shipped monitors at all when
|
||||
# this was hardcoded, and a plant that happens to use the name for something
|
||||
# else got all of them. The default matches the seeded type so nothing changes
|
||||
# for a site that uses it; a blank setting attaches the shipped bundle to
|
||||
# nothing, which is the right neutral behaviour for a site that does not want
|
||||
# it rather than a silent mismatch.
|
||||
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
|
||||
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
||||
|
||||
|
||||
def shipped_pctype():
|
||||
"""The PC type the shipped monitor bundle attaches to at this site."""
|
||||
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
|
||||
return (configured or '').strip()
|
||||
|
||||
_VALID_KINDS = ('process', 'service')
|
||||
|
||||
|
||||
@@ -93,9 +107,6 @@ def update_monitor(monitor_id):
|
||||
if monitor is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||
http_code=404)
|
||||
if monitor.source != 'local':
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'Shipped monitors cannot be edited', http_code=403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
||||
@@ -110,8 +121,27 @@ def update_monitor(monitor_id):
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
monitor.kind = kind
|
||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
|
||||
# one site's disk layout. It used to refuse every edit, which meant a plant
|
||||
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
|
||||
# the launch path nor switch the monitor off - and then got permanent false
|
||||
# drift warnings for it. ADR-015 allows seed data precisely because a site
|
||||
# can edit it afterwards.
|
||||
#
|
||||
# So: kind and displayname are the identity 0007's policy bundle matches on
|
||||
# and stay fixed on a shipped row; the paths and the active flag, which are
|
||||
# what actually differ between plants, are editable.
|
||||
if monitor.source != 'local':
|
||||
for field in ('kind', 'displayname'):
|
||||
if field in body and (body.get(field) or '').strip() != (
|
||||
getattr(monitor, field) or ''):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'A shipped monitor keeps its kind and display name; its '
|
||||
'paths and active flag can be changed', http_code=403)
|
||||
else:
|
||||
monitor.kind = kind
|
||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
||||
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
||||
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
||||
@@ -142,13 +172,14 @@ def delete_monitor(monitor_id):
|
||||
def _resolve_pctype_monitors(pctype):
|
||||
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
||||
|
||||
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
|
||||
Shipped monitors attach to the PC type named by the shipped-pctype setting;
|
||||
policy-attached monitors come from the computers policy resolver by PC type.
|
||||
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
||||
"""
|
||||
selected = {}
|
||||
|
||||
if pctype == SHIPPED_DEFAULT_PCTYPE:
|
||||
shipped_for = shipped_pctype()
|
||||
if shipped_for and pctype == shipped_for:
|
||||
for monitor in EndpointMonitor.query.filter_by(
|
||||
source='shipped', isactive=True).all():
|
||||
selected[monitor.endpointmonitorid] = monitor
|
||||
|
||||
@@ -39,11 +39,12 @@ def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# A watched service or process reports a STATE STRING, not a boolean, and the
|
||||
# vocabulary varies by source. Both the services panel and the fleet health
|
||||
# rollup have to agree on what counts as running, so the rule lives here once -
|
||||
# reading it wrong makes every service look stopped.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
# What counts as RUNNING is defined once, by the reconciler, and imported here.
|
||||
# The panel and the reconciler read the same agent snapshot, so a disagreement
|
||||
# would paint a service green and flag it as drift on the same page. Importing
|
||||
# api -> services is the direction the plugin layout already runs in; the
|
||||
# reconciler's own import of this module is deferred, so there is no cycle.
|
||||
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
|
||||
|
||||
|
||||
def _service_running(entry):
|
||||
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||
# malformed or hostile agent cannot fill the disk with one request.
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
# The multipart envelope carries the other form fields and the part boundaries
|
||||
# too, so content_length is legitimately larger than the image. Allow for that
|
||||
# rather than rejecting a screenshot that is just under the cap.
|
||||
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
|
||||
# PNG signature. The route stores and serves the file as image/png, so this is
|
||||
# the one thing it accepts.
|
||||
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
def _operator_report_dir():
|
||||
@@ -1837,13 +1845,26 @@ def upload_operator_report():
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
# Refuse an oversized body BEFORE anything parses it. content_length is a
|
||||
# header, so it is readable without touching the body; request.form is not
|
||||
# - the first access to it makes werkzeug parse the whole multipart and
|
||||
# spool the file part to a temp file. An earlier version of this check sat
|
||||
# below that line and so ran after the thing it exists to prevent. The
|
||||
# value is caller-supplied, which is why this is an early out and not the
|
||||
# enforcement; the bounded read below is.
|
||||
declared = request.content_length or 0
|
||||
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
|
||||
hostname = (request.form.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -1864,17 +1885,24 @@ def upload_operator_report():
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
data = upload.read()
|
||||
# Read one byte past the cap: enough to know it was exceeded, without
|
||||
# taking the whole of a body that lied about its length.
|
||||
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
|
||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
if data and not data.startswith(_PNG_MAGIC):
|
||||
# The stored file is served back as image/png. Storing whatever
|
||||
# arrived under that name meant the extension and the bytes could
|
||||
# disagree; the upload headers make the document case inert, but
|
||||
# nothing is gained by keeping a file we already know is not what it
|
||||
# claims.
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot must be a PNG')
|
||||
if data:
|
||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
||||
if ext != '.png':
|
||||
ext = '.png'
|
||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
||||
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
|
||||
@@ -20,17 +20,27 @@ CRUD API refuses to edit or delete them):
|
||||
trying to START the process when a launch path is mapped - never something
|
||||
the product ships switched on for every endpoint.
|
||||
|
||||
BusinessPolicy "RPCA Shopfloor App Monitors"
|
||||
BusinessPolicy "Shopfloor App Monitors"
|
||||
the site-level wrapper, carrying the Configuration Policy as its one item.
|
||||
It was named for the originating plant when this arrived. A shipped row is
|
||||
product-owned and every adopting site sees it, so the name cannot be one
|
||||
site's designator (ADR-015). Migration 0010 renames it on installs that
|
||||
already stamped this one.
|
||||
|
||||
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
|
||||
so the bundle survives id drift across installs.
|
||||
|
||||
Lives in the endpoints chain, after 0006, because the monitor rows it references
|
||||
are seeded there. The computers policy tables are created by the core baseline
|
||||
before per-plugin chains run; this migration still guards on their presence and
|
||||
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
|
||||
names so re-running never duplicates the bundle.
|
||||
are seeded there. The policy tables belong to the COMPUTERS plugin and are
|
||||
created by its own post-cutover chain, not by the core baseline; endpoints
|
||||
declares a hard dependency on computers, which is what orders the two.
|
||||
|
||||
It guards on those tables being present and no-ops if they are not, and it is
|
||||
idempotent on the policy names so re-running never duplicates the bundle. Be
|
||||
clear about what the guard costs, though: Alembic stamps the revision either
|
||||
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
|
||||
back to finish the job. That is acceptable only because the dependency makes
|
||||
the absent case unreachable in a supported install.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
@@ -47,7 +57,7 @@ depends_on = None
|
||||
|
||||
|
||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors this bundle attaches, by identity (kind, displayname).
|
||||
_SHIPPED_MONITORS = (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Rename the shipped business policy off one site's designator.
|
||||
|
||||
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
|
||||
plant's facility designator, and the row is product-owned: source='shipped',
|
||||
seeded into every install, and the CRUD API refuses to rename it. So every
|
||||
adopting site would carry another plant's label at the top of its policy list
|
||||
with no way to change it, which is exactly what ADR-015 exists to prevent.
|
||||
|
||||
0007 now seeds the neutral name, which covers a fresh install. This migration
|
||||
covers the installs that already stamped 0007 with the old one - the
|
||||
originating site, and anyone who upgraded before this landed.
|
||||
|
||||
Renames only when the old name is present AND the new one is not, so it is
|
||||
idempotent and cannot collide with a site that already has a policy of its own
|
||||
under the new name. Downgrade is deliberately one-way: putting another site's
|
||||
designator back into a shipped row is not a state worth being able to reach.
|
||||
|
||||
Revision ID: endpoints0010renamepolicy
|
||||
Revises: endpoints0009opreports
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'endpoints0010renamepolicy'
|
||||
down_revision = 'endpoints0009opreports'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
OLD_NAME = 'RPCA Shopfloor App Monitors'
|
||||
NEW_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
|
||||
def _has_table(bind, name):
|
||||
return sa.inspect(bind).has_table(name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
# The policy tables belong to the computers plugin. Same guard as 0007: if
|
||||
# they are not there, there is nothing seeded to rename.
|
||||
if not _has_table(bind, 'businesspolicies'):
|
||||
return
|
||||
|
||||
exists = bind.execute(
|
||||
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': NEW_NAME}).scalar()
|
||||
if exists:
|
||||
return
|
||||
|
||||
bind.execute(
|
||||
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
|
||||
{'new': NEW_NAME, 'old': OLD_NAME})
|
||||
|
||||
|
||||
def downgrade():
|
||||
# One-way on purpose. See the module docstring.
|
||||
pass
|
||||
@@ -27,11 +27,16 @@ Two seams are kept deliberately narrow so this stays easy to change:
|
||||
can call it directly.
|
||||
"""
|
||||
|
||||
# The running-state vocabulary a watched process/service can report. Kept in
|
||||
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
|
||||
# panel paints green is never flagged as drift here. Both read from the same
|
||||
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
# The running-state vocabulary a watched process/service can report. A watched
|
||||
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
|
||||
# source, so what counts as running is a single rule rather than two.
|
||||
#
|
||||
# It lives here, and api/routes.py imports it, because the panel and this
|
||||
# reconciler MUST agree: a service the panel paints green that this flags as
|
||||
# drift is a contradiction shown to the same person on the same page. It was
|
||||
# two copies with a comment asking a human to keep them in lockstep, which is
|
||||
# not a mechanism.
|
||||
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
|
||||
|
||||
def monitor_expects_running(monitor):
|
||||
@@ -82,7 +87,7 @@ def _reported_running_keys(reported_services):
|
||||
else:
|
||||
name, raw, kind = str(entry), '', None
|
||||
name = name.strip().lower()
|
||||
if not name or raw not in _RUNNING_STATES:
|
||||
if not name or raw not in RUNNING_STATES:
|
||||
continue
|
||||
running.add((kind, name))
|
||||
return running
|
||||
|
||||
Reference in New Issue
Block a user