Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows
This commit is contained in:
@@ -5,5 +5,7 @@ from .routes import endpoints_bp
|
||||
from . import adoption # noqa: F401
|
||||
# Attaches the site agent generator packaging routes to endpoints_bp.
|
||||
from . import siteagent # noqa: F401
|
||||
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
|
||||
from . import monitors # noqa: F401
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
|
||||
235
plugins/endpoints/api/monitors.py
Normal file
235
plugins/endpoints/api/monitors.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
|
||||
|
||||
A monitor definition is the reusable "what" an operator builds on the Settings
|
||||
page: a named process or service with a bespoke display label and optional
|
||||
launch/collector paths. Attaching a monitor to a PC type is a site-policy
|
||||
concern (see plugins/computers policies); this surface owns the definitions
|
||||
themselves plus the resolve-by-pctype read the agent uses at runtime.
|
||||
|
||||
Shipped rows (source='shipped') are product-owned: readable by everyone with
|
||||
the view permission but not editable or deletable through this API.
|
||||
|
||||
The resolve-by-pctype read returns the monitor set a deployed agent should watch
|
||||
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
|
||||
active site policies attach to that type. Shipped monitors are hardcoded to
|
||||
'Shopfloor PC' as a proof of concept until they are authored through the policy
|
||||
route like any user-built monitor.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_role, Setting,
|
||||
)
|
||||
|
||||
from ..models import EndpointMonitor
|
||||
from .routes import endpoints_bp
|
||||
|
||||
|
||||
# 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')
|
||||
|
||||
|
||||
def _validate_monitor_payload(body, kind):
|
||||
"""Return an error message for an invalid create/update body, or None."""
|
||||
displayname = (body.get('displayname') or '').strip()
|
||||
if not displayname:
|
||||
return 'displayname is required'
|
||||
if kind not in _VALID_KINDS:
|
||||
return "kind must be 'process' or 'service'"
|
||||
if kind == 'process' and not (body.get('processname') or '').strip():
|
||||
return 'processname is required for a process monitor'
|
||||
if kind == 'service' and not (body.get('servicename') or '').strip():
|
||||
return 'servicename is required for a service monitor'
|
||||
return None
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_monitors():
|
||||
"""List monitor definitions (shipped and local)."""
|
||||
monitors = EndpointMonitor.query.order_by(
|
||||
EndpointMonitor.source.desc(),
|
||||
EndpointMonitor.displayname.asc()).all()
|
||||
return success_response([monitor.to_dict() for monitor in monitors])
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def create_monitor():
|
||||
"""Create a user-defined (local) monitor definition."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or '').strip().lower()
|
||||
problem = _validate_monitor_payload(body, kind)
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
monitor = EndpointMonitor(
|
||||
kind=kind,
|
||||
displayname=(body.get('displayname') or '').strip(),
|
||||
processname=(body.get('processname') or '').strip() or None,
|
||||
servicename=(body.get('servicename') or '').strip() or None,
|
||||
launchpath=(body.get('launchpath') or '').strip() or None,
|
||||
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
|
||||
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
|
||||
source='local',
|
||||
isactive=bool(body.get('isactive', True)),
|
||||
)
|
||||
db.session.add(monitor)
|
||||
db.session.commit()
|
||||
return success_response(monitor.to_dict(), message='Monitor created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
|
||||
@require_role('admin')
|
||||
def update_monitor(monitor_id):
|
||||
"""Update a local monitor definition. Shipped rows are read-only."""
|
||||
monitor = db.session.get(EndpointMonitor, monitor_id)
|
||||
if monitor is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||
http_code=404)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
||||
problem = _validate_monitor_payload(
|
||||
{
|
||||
'displayname': body.get('displayname', monitor.displayname),
|
||||
'processname': body.get('processname', monitor.processname),
|
||||
'servicename': body.get('servicename', monitor.servicename),
|
||||
},
|
||||
kind,
|
||||
)
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
# 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
|
||||
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
|
||||
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
|
||||
if 'isactive' in body:
|
||||
monitor.isactive = bool(body.get('isactive'))
|
||||
db.session.commit()
|
||||
return success_response(monitor.to_dict(), message='Monitor updated')
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
|
||||
@require_role('admin')
|
||||
def delete_monitor(monitor_id):
|
||||
"""Delete a local monitor definition. Shipped rows are protected."""
|
||||
monitor = db.session.get(EndpointMonitor, 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 deleted', http_code=403)
|
||||
db.session.delete(monitor)
|
||||
db.session.commit()
|
||||
return success_response(None, message='Monitor deleted')
|
||||
|
||||
|
||||
def _resolve_pctype_monitors(pctype):
|
||||
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
||||
|
||||
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 = {}
|
||||
|
||||
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
|
||||
|
||||
computertypeid = _computertypeid_for(pctype)
|
||||
if computertypeid is not None:
|
||||
policy_ids = _policy_monitor_ids(computertypeid)
|
||||
if policy_ids:
|
||||
for monitor in EndpointMonitor.query.filter(
|
||||
EndpointMonitor.endpointmonitorid.in_(policy_ids),
|
||||
EndpointMonitor.isactive.is_(True)).all():
|
||||
selected[monitor.endpointmonitorid] = monitor
|
||||
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
def _computertypeid_for(pctype):
|
||||
"""Resolve a PC type name to its computertypeid, or None."""
|
||||
try:
|
||||
from plugins.computers.models import ComputerType
|
||||
except Exception:
|
||||
return None
|
||||
row = ComputerType.query.filter_by(computertype=pctype).first()
|
||||
return row.computertypeid if row else None
|
||||
|
||||
|
||||
def _policy_monitor_ids(computertypeid):
|
||||
"""Monitor ids attached to a PC type by effective policy, or empty set."""
|
||||
try:
|
||||
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
|
||||
except Exception:
|
||||
return set()
|
||||
return monitor_ids_for_pctype(computertypeid)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def resolve_monitors():
|
||||
"""Resolve the monitor set for a PC type (agent runtime fetch).
|
||||
|
||||
Query param pctype names the computer type. Returns the definitions the
|
||||
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
|
||||
active site policies attach to that type.
|
||||
"""
|
||||
pctype = (request.args.get('pctype') or '').strip()
|
||||
if not pctype:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
monitors = _resolve_pctype_monitors(pctype)
|
||||
return success_response({
|
||||
'pctype': pctype,
|
||||
'monitors': [monitor.to_dict() for monitor in monitors],
|
||||
})
|
||||
@@ -25,7 +25,10 @@ from shopdb.api import (
|
||||
Application, AppVersion, Script,
|
||||
)
|
||||
|
||||
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
|
||||
from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
OperatorReport,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
|
||||
@@ -36,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):
|
||||
@@ -87,7 +91,8 @@ def _format_uptime(seconds):
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('', methods=['GET'])
|
||||
@endpoints_bp.route('', methods=['GET'], strict_slashes=False)
|
||||
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
|
||||
@require_permission('endpoints.fleet')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
@@ -396,6 +401,24 @@ def list_endpoint_events(asset_id: int):
|
||||
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
|
||||
# =============================================================================
|
||||
|
||||
def _drift_for_state(state):
|
||||
"""Parse the cached warn-only reconciler drift list off an EndpointState.
|
||||
|
||||
driftjson is written on every check-in by the endpoints ingest: the monitors
|
||||
site policy expects running that the latest snapshot did not report running.
|
||||
Null means never computed; an empty list means computed with no drift.
|
||||
Tolerant of a malformed blob so a bad cache never breaks the panel.
|
||||
"""
|
||||
raw = getattr(state, 'driftjson', None) if state else None
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
@@ -425,6 +448,19 @@ def panel_status(asset_id: int):
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Warn-only reconciler summary: monitors site policy expects to be running
|
||||
# that the last check-in did not report as running. Surfaced as a status
|
||||
# field so an operator sees the drift on the panel; nothing is auto-started.
|
||||
drift = _drift_for_state(state)
|
||||
if drift:
|
||||
names = ', '.join(entry.get('displayname') or entry.get('name')
|
||||
for entry in drift)
|
||||
fields.append(field(
|
||||
'Needs attention',
|
||||
'{} monitor(s) expected running but not reported: {}'.format(
|
||||
len(drift), names)))
|
||||
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@@ -446,28 +482,100 @@ def panel_telemetry_meta(asset_id: int):
|
||||
return success_response({'lastreportedat': stamp})
|
||||
|
||||
|
||||
def _pctype_for_asset(asset_id):
|
||||
"""Resolve an asset's PC type name, or None.
|
||||
|
||||
Endpoints stays optional-on-computers: a missing plugin, a non-computer
|
||||
asset, or an unset type all resolve to None so the caller falls back to
|
||||
reported-only behaviour.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
except Exception:
|
||||
return None
|
||||
row = (db.session.query(ComputerType.computertype)
|
||||
.join(Computer,
|
||||
Computer.computertypeid == ComputerType.computertypeid)
|
||||
.filter(Computer.assetid == asset_id)
|
||||
.first())
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _resolved_monitors_for_asset(asset_id):
|
||||
"""Monitor definitions attached to this asset's PC type by policy/shipped.
|
||||
|
||||
Reuses the endpoints monitor resolver (shipped defaults plus policy-attached
|
||||
monitors). Returns a list of EndpointMonitor rows, or an empty list when the
|
||||
PC type is unknown or nothing resolves.
|
||||
"""
|
||||
pctype = _pctype_for_asset(asset_id)
|
||||
if not pctype:
|
||||
return []
|
||||
try:
|
||||
from .monitors import _resolve_pctype_monitors
|
||||
return _resolve_pctype_monitors(pctype) or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _row_action_dict(app, kind, name, running):
|
||||
"""Build the inline Start/Stop action map for a services/processes row."""
|
||||
targets = row_actions_for(app, kind, name)
|
||||
if not targets:
|
||||
return None
|
||||
return {
|
||||
'running': running,
|
||||
'start': targets['start'],
|
||||
'stop': targets['stop'],
|
||||
'status': targets['status'],
|
||||
'target': targets['target'],
|
||||
'targetkind': targets['targetkind'],
|
||||
}
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_services(asset_id: int):
|
||||
"""List panel: monitored services/processes with a run-state badge per row.
|
||||
|
||||
Reshapes the latest servicesjson snapshot into rows the list renderer maps
|
||||
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
|
||||
an unknown state renders as an Unknown badge rather than failing.
|
||||
to name + status badge, then MERGES the monitors that site policy (and the
|
||||
shipped defaults) attach to this asset's PC type. A policy-attached monitor
|
||||
the agent has not yet reported surfaces as an Unknown row with a Start
|
||||
control, so an operator sees the intent immediately rather than waiting for
|
||||
the next agent snapshot. Tolerant of a missing snapshot or plugin: an
|
||||
unknown state renders as an Unknown badge rather than failing.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state or not state.servicesjson:
|
||||
return success_response([])
|
||||
|
||||
maycommand = caller_holds('endpoints.command')
|
||||
|
||||
try:
|
||||
services = json.loads(state.servicesjson)
|
||||
except (TypeError, ValueError):
|
||||
return success_response([])
|
||||
services = []
|
||||
if state and state.servicesjson:
|
||||
try:
|
||||
services = json.loads(state.servicesjson) or []
|
||||
except (TypeError, ValueError):
|
||||
services = []
|
||||
|
||||
rows = []
|
||||
for entry in (services or []):
|
||||
# Reported identities, keyed (kind, lowercased name), so a policy monitor
|
||||
# the agent already reports is not duplicated as a synthetic row.
|
||||
reported = set()
|
||||
# Warn-only reconciler drift, keyed (kind, lowercased name), so a row that is
|
||||
# expected running but not reported running can be flagged for the operator.
|
||||
# kind may be None on an older snapshot, so match on name too.
|
||||
drift_keys = set()
|
||||
for entry in _drift_for_state(state):
|
||||
name = (entry.get('name') or '').strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
drift_keys.add(((entry.get('kind') or '').lower() or None, name))
|
||||
drift_keys.add((None, name))
|
||||
|
||||
def _is_drift(kind, name):
|
||||
key = (name or '').strip().lower()
|
||||
return (kind, key) in drift_keys or (None, key) in drift_keys
|
||||
|
||||
for entry in services:
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get('name') or entry.get('service') or 'Unknown'
|
||||
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
|
||||
@@ -476,9 +584,10 @@ def panel_services(asset_id: int):
|
||||
else:
|
||||
name, raw, app, kind = str(entry), '', None, None
|
||||
running = _service_running(entry)
|
||||
normkind = (kind or '').lower() or None
|
||||
row = {
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'kind': normkind,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
# Amber for paused and the four Windows transitional states. A
|
||||
# service stuck in Start Pending is failing to start on a retry
|
||||
@@ -493,17 +602,42 @@ def panel_services(asset_id: int):
|
||||
# emission never consulted the caller, so a read-only viewer was shown
|
||||
# controls on a shop-floor PC that 403 on click - wrong signal, and it
|
||||
# gets worse the moment guests can read this panel.
|
||||
targets = row_actions_for(app, kind, name) if maycommand else None
|
||||
if targets:
|
||||
row['action'] = {
|
||||
'running': running,
|
||||
'start': targets['start'],
|
||||
'stop': targets['stop'],
|
||||
'status': targets['status'],
|
||||
'target': targets['target'],
|
||||
'targetkind': targets['targetkind'],
|
||||
}
|
||||
action = _row_action_dict(app, kind, name, running) if maycommand else None
|
||||
if action:
|
||||
row['action'] = action
|
||||
if not running and _is_drift(normkind, name):
|
||||
row['drift'] = True
|
||||
rows.append(row)
|
||||
reported.add((normkind, name.strip().lower()))
|
||||
|
||||
# Merge policy/shipped monitors this PC type should watch. A monitor already
|
||||
# reported by the agent stays as its live row; one not yet seen is appended
|
||||
# as an Unknown row so the operator sees the intended coverage and can Start
|
||||
# it. The monitor's displayname is the operator label (e.g. 'msedge.exe'
|
||||
# -> 'Edge'); its process/service name is the wire target for actions.
|
||||
for monitor in _resolved_monitors_for_asset(asset_id):
|
||||
kind = (monitor.kind or '').lower() or None
|
||||
osname = (monitor.processname if kind == 'process'
|
||||
else monitor.servicename)
|
||||
osname = (osname or '').strip()
|
||||
if not osname:
|
||||
continue
|
||||
if (kind, osname.lower()) in reported:
|
||||
continue
|
||||
row = {
|
||||
'name': monitor.displayname or osname,
|
||||
'kind': kind,
|
||||
'statelabel': 'Unknown',
|
||||
'statecolor': 'gray',
|
||||
}
|
||||
action = _row_action_dict(None, kind, osname, False) if maycommand else None
|
||||
if action:
|
||||
row['action'] = action
|
||||
if _is_drift(kind, osname):
|
||||
row['drift'] = True
|
||||
rows.append(row)
|
||||
reported.add((kind, osname.lower()))
|
||||
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@@ -1335,6 +1469,64 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
|
||||
def resolved_monitors_for_agent():
|
||||
"""Agent watch list: the monitors this endpoint should report on.
|
||||
|
||||
Collector-token auth (not JWT), same as the command channel. The agent
|
||||
calls this each telemetry cycle with its hostname and receives the process
|
||||
and service monitors that site policy (and the shipped defaults) attach to
|
||||
this endpoint's PC type, so a policy-attached monitor is enumerated and
|
||||
reported without editing appsettings.json on the box. The shape mirrors the
|
||||
agent's MonitorConfig (processes[]/services[] of {name, app}) so the agent
|
||||
can bind it directly: name is the wire identity (process image or service
|
||||
key), app is the operator label. An unknown hostname or a PC type with no
|
||||
monitors returns empty lists, which the agent merges as "watch nothing extra".
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Unknown hostname: {hostname!r}', http_code=404)
|
||||
|
||||
processes = []
|
||||
services = []
|
||||
for monitor in _resolved_monitors_for_asset(asset_id):
|
||||
kind = (monitor.kind or '').lower()
|
||||
if kind == 'process':
|
||||
wirename = (monitor.processname or '').strip()
|
||||
bucket = processes
|
||||
elif kind == 'service':
|
||||
wirename = (monitor.servicename or '').strip()
|
||||
bucket = services
|
||||
else:
|
||||
continue
|
||||
if not wirename:
|
||||
continue
|
||||
entry = {
|
||||
'name': wirename,
|
||||
'app': (monitor.displayname or wirename),
|
||||
}
|
||||
# Only process monitors carry a launch path, and only when set. The
|
||||
# agent never trusts it blindly: it validates the path against an
|
||||
# approved-root allowlist before it becomes launchable (fail closed).
|
||||
if kind == 'process':
|
||||
launchpath = (monitor.launchpath or '').strip()
|
||||
if launchpath:
|
||||
entry['launchpath'] = launchpath
|
||||
bucket.append(entry)
|
||||
|
||||
return success_response({'processes': processes, 'services': services})
|
||||
|
||||
|
||||
@endpoints_bp.route('/commands/next', methods=['GET'])
|
||||
def claim_next_command():
|
||||
"""Agent long-poll: claim the next pending command for a hostname.
|
||||
@@ -1578,3 +1770,201 @@ def download_script_body(script_id: int):
|
||||
return send_upload(script_dir, script.filename, as_attachment=True)
|
||||
|
||||
|
||||
# Operator issue reports (tray screenshot + free-text notes)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retention groundwork: how many days an operator report (and its screenshot
|
||||
# file) is kept before the purge sweep removes it. Read from the Setting store
|
||||
# so Agent Configuration can expose a toggle later without a code change; the
|
||||
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
|
||||
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
|
||||
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():
|
||||
return os.path.join(current_app.instance_path, 'operatorreports')
|
||||
|
||||
|
||||
def _operator_report_retention_days():
|
||||
"""Configured retention window in whole days (default 7, <=0 disables)."""
|
||||
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
|
||||
if raw is None:
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
|
||||
|
||||
|
||||
def _purge_expired_operator_reports():
|
||||
"""Delete operator reports older than the retention window, files and all.
|
||||
|
||||
Best-effort and idempotent: called opportunistically on upload so the feed
|
||||
stays bounded without a scheduler. A retention of <=0 disables the sweep.
|
||||
"""
|
||||
days = _operator_report_retention_days()
|
||||
if days <= 0:
|
||||
return
|
||||
|
||||
cutoff = _naive_utcnow() - timedelta(days=days)
|
||||
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
|
||||
if not stale:
|
||||
return
|
||||
|
||||
report_dir = _operator_report_dir()
|
||||
for report in stale:
|
||||
if report.imagefilename:
|
||||
path = os.path.join(report_dir, report.imagefilename)
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
db.session.delete(report)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports', methods=['POST'])
|
||||
def upload_operator_report():
|
||||
"""Agent uploads one operator issue report (collector-token auth).
|
||||
|
||||
The tray captures the operator's free-text note and an optional screenshot,
|
||||
then hands them to the service, which posts here as multipart/form-data on
|
||||
the same endpoints collector token used for telemetry. The reported hostname
|
||||
resolves to an asset; an unresolved hostname is a validation error. The
|
||||
screenshot bytes are written to the operator-reports uploads dir and the row
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
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,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'could not resolve hostname to a computer: {!r}'.format(hostname))
|
||||
|
||||
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
|
||||
details = (request.form.get('details') or '').strip() or None
|
||||
|
||||
imagefilename = None
|
||||
imagemimetype = None
|
||||
imagebytesize = None
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
# 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:
|
||||
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
|
||||
handle.write(data)
|
||||
|
||||
report = OperatorReport(
|
||||
assetid=asset_id,
|
||||
reason=reason,
|
||||
details=details,
|
||||
imagefilename=imagefilename,
|
||||
imagemimetype=imagemimetype,
|
||||
imagebytesize=imagebytesize,
|
||||
reportedhostname=hostname,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
|
||||
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
|
||||
# must not fail the upload the operator already completed.
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return success_response(report.to_dict(),
|
||||
message='Operator report received', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_operator_reports(asset_id: int):
|
||||
"""Operator Reports panel: this PC's recent operator issue reports.
|
||||
|
||||
Newest first. Each row carries a hasimage flag so the viewer knows whether
|
||||
to request the screenshot from the serve route below. Runs a retention
|
||||
sweep opportunistically so a viewed PC does not accumulate stale rows.
|
||||
"""
|
||||
try:
|
||||
_purge_expired_operator_reports()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
reports = (OperatorReport.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(OperatorReport.createdat.desc())
|
||||
.all())
|
||||
return success_response({'reports': [r.to_dict() for r in reports]})
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
|
||||
methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def serve_operator_report_image(operatorreportid: int):
|
||||
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
|
||||
from shopdb.api import send_upload
|
||||
|
||||
report = db.session.get(OperatorReport, operatorreportid)
|
||||
if report is None or not report.imagefilename:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No screenshot for this report', http_code=404)
|
||||
return send_upload(_operator_report_dir(), report.imagefilename)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ from .routes import endpoints_bp
|
||||
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
||||
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
|
||||
|
||||
# The standalone uninstaller lives in the repo tree (not staged per-instance),
|
||||
# so it ships in every bundle. Resolve it relative to this file:
|
||||
# plugins/endpoints/api/siteagent.py -> repo root is three levels up.
|
||||
UNINSTALLER_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))),
|
||||
'scripts', 'uninstall-shopdbagent.ps1')
|
||||
|
||||
# Public TLS certificate the agent must trust when this deployment terminates
|
||||
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
|
||||
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
|
||||
@@ -144,6 +152,12 @@ FILES
|
||||
appsettings.json Non-secret runtime config (cadence, monitors,
|
||||
launch paths). No credentials.
|
||||
install.ps1 Provisions the credentials and installs the service.
|
||||
uninstall-shopdbagent.ps1
|
||||
Standalone uninstaller. Run elevated to reverse the
|
||||
install: stops and deletes the service, kills agent
|
||||
and tray processes, removes the tray autostart Run
|
||||
key, the Start Menu shortcut, and the credential
|
||||
hive, and deletes the install directory.
|
||||
server.crt Server TLS certificate (present only for self-signed
|
||||
or internal-CA deployments, either auto-detected or
|
||||
uploaded by the operator). install.ps1 imports it
|
||||
@@ -153,10 +167,16 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service.
|
||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||
copies the agent into Program Files, and starts the service. It also
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
SECURITY
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
||||
server after it was generated; regenerate the key in the UI to revoke it.
|
||||
@@ -248,7 +268,7 @@ Write-Host "ServiceName : $ServiceName"
|
||||
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {{
|
||||
Write-Host "[0/4] Removing existing service $ServiceName ..."
|
||||
Write-Host "[0/6] Removing existing service $ServiceName ..."
|
||||
if ($existing.Status -ne "Stopped") {{
|
||||
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
|
||||
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
|
||||
@@ -264,7 +284,7 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
|
||||
}}
|
||||
|
||||
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
||||
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
|
||||
@@ -288,7 +308,7 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
||||
}}
|
||||
|
||||
# 2. Copy agent files to the install directory.
|
||||
Write-Host "[2/4] Copying agent files to $InstallDir ..."
|
||||
Write-Host "[2/6] Copying agent files to $InstallDir ..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
|
||||
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
|
||||
@@ -306,17 +326,118 @@ if (-not $NoTray) {{
|
||||
$traySource = Join-Path $sourceDir $TrayExeName
|
||||
if (Test-Path $traySource) {{
|
||||
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
||||
$installedTray = Join-Path $InstallDir $TrayExeName
|
||||
|
||||
# Autostart the tray for every user at logon via the HKLM Run key (both
|
||||
# 64-bit and 32-bit views, matching the credential hive). This is the
|
||||
# reliable launch guarantee: a plain Start Menu shortcut does not
|
||||
# autostart, and the SYSTEM service cannot cleanly spawn into the
|
||||
# interactive session. The tray itself is single-instance, so a stray
|
||||
# second launch is harmless.
|
||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"')
|
||||
|
||||
# Export the branded icon from the tray exe so the shortcut shows the
|
||||
# ShopDB brand instead of the generic exe icon. The tray draws its icon
|
||||
# at runtime (no embedded Win32 icon), so we ask it to write an .ico into
|
||||
# the install dir and point the shortcut at it.
|
||||
$iconPath = Join-Path $InstallDir "shopdb-agent.ico"
|
||||
try {{ & $installedTray --export-icon $iconPath | Out-Null }}
|
||||
catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }}
|
||||
|
||||
# Start Menu shortcut so operators can relaunch the tray on demand. This
|
||||
# is discoverability only, not autostart. Written to the machine-wide
|
||||
# (All Users) Programs folder.
|
||||
try {{
|
||||
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
|
||||
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = $installedTray
|
||||
$shortcut.WorkingDirectory = $InstallDir
|
||||
$shortcut.Description = "ShopDB Endpoint Agent tray"
|
||||
if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }}
|
||||
$shortcut.Save()
|
||||
Write-Host " created Start Menu shortcut: $shortcutPath"
|
||||
}}
|
||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||
|
||||
# Launch the tray once now so it appears without waiting for the next
|
||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
||||
}}
|
||||
}}
|
||||
|
||||
# 3. Install and start the service. The agent supports its own service install.
|
||||
Write-Host "[3/4] Installing and starting the service ..."
|
||||
Write-Host "[3/6] Installing and starting the service ..."
|
||||
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
|
||||
$binPath = '"' + $installedAgent + '"'
|
||||
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
||||
sc.exe start $ServiceName | Out-Null
|
||||
|
||||
Write-Host "[4/4] Done." -ForegroundColor Green
|
||||
# 4. Confirm the service (and tray, when installed) actually reached a running
|
||||
# state before we destroy the installer. sc.exe start returns before the service
|
||||
# is up, and a failed start would otherwise leave a half-installed endpoint with
|
||||
# the secret-bearing install.ps1 already deleted. Poll with a short backoff and
|
||||
# retry the start a few times; only proceed to cleanup once healthy.
|
||||
Write-Host "[4/6] Verifying agent runtime ..."
|
||||
$serviceOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
Start-Sleep -Seconds 3
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }}
|
||||
Write-Host " service not running yet (attempt $attempt/5); retrying start ..."
|
||||
try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }}
|
||||
}}
|
||||
if (-not $serviceOk) {{
|
||||
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
|
||||
}}
|
||||
Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$trayOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
||||
}}
|
||||
|
||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
||||
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
|
||||
# collector key in its default parameter, so it must not linger on the endpoint.
|
||||
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
||||
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
||||
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
||||
foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{
|
||||
if ([string]::IsNullOrWhiteSpace($name)) {{ continue }}
|
||||
$src = Join-Path $sourceDir $name
|
||||
if (Test-Path $src) {{
|
||||
try {{ Copy-Item -Path $src -Destination $dataDir -Force }}
|
||||
catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }}
|
||||
}}
|
||||
}}
|
||||
$uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
|
||||
|
||||
$installSelf = Join-Path $sourceDir "install.ps1"
|
||||
if (Test-Path $installSelf) {{
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
if (Test-Path $uninstaller) {{
|
||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed."
|
||||
}}
|
||||
""".format(
|
||||
baseurl=baseurl_literal,
|
||||
collectorkey=collectorkey_literal,
|
||||
@@ -421,6 +542,9 @@ def package_siteagent():
|
||||
cadenceseconds, certfilename))
|
||||
archive.writestr('README.txt', _README)
|
||||
|
||||
if os.path.isfile(UNINSTALLER_PATH):
|
||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||
|
||||
agent = _staged_path(STAGED_AGENT_RELPATH)
|
||||
if os.path.isfile(agent):
|
||||
archive.write(agent, 'shopdbagent.exe')
|
||||
|
||||
Reference in New Issue
Block a user