Merge wilm into wj

Brings in wilm commits:
- Render script surfaces correctly in dark mode
- Stop scripts reporting success after doing nothing
- Correct two gate commands that pass without running
- Gate the script library on the grant that pushes scripts
- Let the policy list edit the rows the API accepts
- Cap plugin revision ids at the 32 chars MySQL stores
- Split shipped monitor bundle into per-monitor policies

Resolved conflict in agent ShopDbEndpointAgent.csproj by merging:
- wilm 1.14.0 script verdict and output capture features
- wj 1.13.1-1.13.5 tray coordination releases
- Bumped to 1.14.1 for coordinated agent+tray release
This commit is contained in:
Proudlock
2026-09-09 14:35:41 -04:00
32 changed files with 2661 additions and 208 deletions

View File

@@ -19,7 +19,7 @@ from shopdb.api import (db, AuditLog, success_response, error_response,
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.apppolicy import disown_if_generated
@@ -262,8 +262,12 @@ def update_configuration_policy(policy_id: int):
'Configuration policy not found', http_code=404)
# An autoapp row is generated from an Application's "Required on all PCs"
# checkbox and IS editable: editing it is precisely how an admin takes
# ownership. Genuinely external sources (shipped/managed) stay read-only.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
# ownership. A shipped row is editable for the same reason - the product
# ships a starting point, not an opinion about one site's floor - and keeps
# source='shipped' so provenance survives the edit. Genuinely external
# sources (a future managed owner) stay read-only.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
@@ -326,9 +330,11 @@ def delete_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
# As with update: a generated row is the site's to remove, an externally
# managed one is not.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
# As with update: a generated or shipped row is the site's to remove, an
# externally managed one is not. A site that does not run MTConnect should
# be able to delete its monitor policy outright rather than carry it.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)

View File

@@ -42,8 +42,8 @@
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
<button class="btn btn-secondary btn-sm" :disabled="!iseditable(p)" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
@@ -287,6 +287,18 @@ async function save() {
}
}
// Mirrors the API guard in plugins/computers/api/policy_routes.py: local,
// autoapp and shipped rows are all editable. A shipped row is a starting point
// the product supplies, not a fixed opinion, and it keeps source='shipped' so
// provenance survives the edit. Only a genuinely external managed owner is
// read-only. Disabling on source==='local' alone stranded shipped rows behind
// dead buttons while the API was accepting the write.
const EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']
function iseditable(p) {
return EDITABLE_SOURCES.includes(p.source)
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {

View File

@@ -512,6 +512,90 @@
</div>
</div>
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
from Corporate Tools above - that starts a task the endpoint already
approved, this delivers script content ShopDB holds. Separate grant
(endpoints.runscript), separate tab, so neither implies the other. -->
<div v-show="activegroup === 'runscripts'" class="detail-full">
<div class="card">
<h3>Run Scripts</h3>
<p class="corptools-intro">
Sends a script from the ShopDB script library to this computer. The
agent fetches the body by digest and runs it in the context the
script itself specifies.
</p>
<!-- Persistent, same reasoning as the Corporate Tools warning: this
is arbitrary code on a live machine, and the operator must still
be able to see that when they decide to press Run. -->
<p class="corptools-warning">
A script here runs as SYSTEM unless it is configured to run as the
logged-on user. It takes effect immediately on the live computer and
cannot be undone from here.
</p>
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
No runnable scripts. A script needs an uploaded body before it can be
sent; add one under Settings then return here.
</div>
<template v-else>
<div class="form-group">
<label for="runscript-picker">Script</label>
<select
id="runscript-picker"
v-model="selectedscriptid"
class="form-control"
>
<option value="">Select a script...</option>
<option
v-for="script in runnablescripts"
:key="script.scriptid"
:value="script.scriptid"
>{{ script.scriptname }}</option>
</select>
</div>
<!-- What the operator is about to run, stated before they run it.
Run context and working directory are authoring decisions made
in the library and are shown read-only here: overriding either
at push time would mean the script that ran is not the script
that was reviewed. -->
<div v-if="selectedscript" class="runscripts-detail">
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
{{ selectedscript.scriptdescription }}
</p>
<dl class="runscripts-facts">
<dt>Runs as</dt>
<dd>
{{ selectedscript.runcontext === 'user'
? 'The logged-on user' : 'SYSTEM' }}
</dd>
<dt>Runs in</dt>
<dd class="mono">
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
</dd>
</dl>
<p
v-if="selectedscript.workingdirectory"
class="runscripts-note"
>
This script requires that folder to exist on the computer. If it
does not, the run fails and reports the missing folder rather
than running somewhere else.
</p>
</div>
<div class="runscripts-actions">
<button
class="btn btn-warning"
:disabled="!selectedscript || firingScript"
@click="confirmRunScript"
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
</div>
</template>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -686,7 +770,7 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { computersApi, applicationsApi, endpointsApi, scriptsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -829,6 +913,16 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
// during setup, which reads canDeploy before this line would otherwise run.
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
// starts a task the endpoint already holds and has allowlisted, while
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
// the script pins a user context. Those were split out of the old
// endpoints.deploy bundle for exactly this reason (see
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
// scripts off the Corporate Tools gate would put the bundle back together in
// the UI after the backend took it apart.
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
// Operator report screenshots are an uncontrolled capture of whatever was on
// the operator's screen, so they carry their own grant rather than riding
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
@@ -868,6 +962,15 @@ const detailtabs = computed(() => {
if (canDeploy.value && liveness.value) {
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
}
// Run Scripts is its own tab rather than a card in Corporate Tools because it
// is a different grant and a different trust model - a library of arbitrary
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
// already approved. Liveness-gated for the same reason as Corporate Tools:
// the command queues against a live agent, so offering it for a machine that
// is not checking in only produces a command nothing will collect.
if (canRunScript.value && liveness.value) {
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
}
return tabs
})
@@ -1377,6 +1480,47 @@ const corporateTools = [
const firingTask = ref('')
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
// of corporateTools above) because these are site-authored scripts that change
// without an agent release.
const scriptlibrary = ref([])
const scriptsloading = ref(false)
const scriptsloaded = ref(false)
const selectedscriptid = ref('')
const firingScript = ref(false)
// Only scripts that can actually run. A row with no uploaded body is rejected
// at enqueue by _resolve_run_script, so listing it would offer a button whose
// only outcome is an error; the library page is where that gets fixed.
const runnablescripts = computed(
() => scriptlibrary.value.filter((script) => script.hasbody))
const selectedscript = computed(
() => runnablescripts.value.find(
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
// Lazy and permission-gated: an operator without endpoints.runscript never has
// the tab, and so never issues a request the server would refuse anyway.
async function loadScriptLibrary() {
if (scriptsloaded.value || scriptsloading.value) return
scriptsloading.value = true
try {
const response = await scriptsApi.list({ active: 'true' })
scriptlibrary.value = response.data.data || []
scriptsloaded.value = true
} catch (error) {
toast.error(apiError(error, 'Failed to load the script library'))
} finally {
scriptsloading.value = false
}
}
// Fetch on first view of the tab rather than on page load: most visits to a PC
// never open this, and the library is irrelevant to every other tab.
watch(activegroup, (group) => {
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
})
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.corptools regardless.
@@ -1412,6 +1556,41 @@ async function confirmRunTask(tool) {
}
}
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
// system_restart is gated", so the confirmation is TYPED rather than an OK
// button - and unlike Corporate Tools there is no non-destructive branch,
// because every script here is arbitrary code by definition. The confirm names
// the context and the folder so the operator sees what the library decided, not
// just the script's name.
async function confirmRunScript() {
const script = selectedscript.value
if (!script) return
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
const context = script.runcontext === 'user'
? 'the logged-on user'
: 'SYSTEM'
const folder = script.workingdirectory || 'the agent staging folder'
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
+ `It runs as ${context}, in ${folder}.\n`
+ 'It starts immediately and cannot be undone from here.'
+ '\n\nType RUN to confirm.')
if (typed !== 'RUN') return
firingScript.value = true
try {
await endpointsApi.enqueueCommand(
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
// "Queued", not "finished": the agent collects this on its next check-in,
// and the script's own result comes back separately. Saying "ran" here
// would claim an outcome nothing has observed yet.
toast.success(`${script.scriptname} queued for ${label}`)
} catch (error) {
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
} finally {
firingScript.value = false
}
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
@@ -1816,7 +1995,7 @@ async function confirmRetire() {
it does before the confirm gate rather than after. */
.corptools-intro {
margin: 0 0 1rem;
color: var(--text-secondary);
color: var(--text-light);
font-size: 0.875rem;
}
/* Warning weight without alert-banner styling: it is permanent, so a loud
@@ -1825,9 +2004,9 @@ async function confirmRetire() {
.corptools-warning {
margin: 0 0 1rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--warning, #d97706);
background: var(--bg-secondary);
color: var(--text-primary);
border-left: 3px solid var(--warning);
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
}
.corptools-list {
@@ -1848,7 +2027,7 @@ async function confirmRetire() {
font-weight: 600;
}
.corptools-desc {
color: var(--text-secondary);
color: var(--text-light);
font-size: 0.8125rem;
margin-top: 0.125rem;
}
@@ -1858,12 +2037,61 @@ async function confirmRetire() {
.corptools-rowwarning {
margin: 0.25rem 0 0.375rem;
padding: 0.375rem 0.625rem;
border-left: 3px solid var(--danger, #dc2626);
background: var(--bg-secondary);
color: var(--text-primary);
border-left: 3px solid var(--danger);
background: var(--bg-card);
color: var(--text);
font-size: 0.8125rem;
font-weight: 500;
}
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
picker-specific pieces are new. */
.runscripts-empty {
padding: 0.75rem 0;
color: var(--text-light);
font-size: 0.875rem;
}
.runscripts-detail {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.runscripts-desc {
margin: 0 0 0.5rem;
color: var(--text-light);
font-size: 0.8125rem;
}
/* Two columns so the label and its value stay on one line: the folder is the
fact most likely to be skimmed, and wrapping it under its own label made it
read as body copy in review. */
.runscripts-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0;
font-size: 0.8125rem;
}
.runscripts-facts dt {
color: var(--text-light);
}
.runscripts-facts dd {
margin: 0;
color: var(--text);
}
/* Defined locally: .mono is not a global utility in this build, and a path
rendered in the body font is harder to read back against the endpoint. */
.runscripts-facts dd.mono {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
word-break: break-all;
}
.runscripts-note {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.8125rem;
}
.runscripts-actions {
margin-top: 1rem;
}
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const here = dirname(fileURLToPath(import.meta.url))
const view = readFileSync(join(here, 'ConfigurationPoliciesList.vue'), 'utf8')
// The API decides what may be edited; the buttons only reflect that decision.
// These drifted once: policy_routes.py was widened to accept shipped rows while
// the buttons still disabled on source !== 'local', so every shipped policy sat
// behind a dead Edit button and the change looked like it had never rolled.
// If the API guard changes, EDITABLE_SOURCES here changes with it.
describe('the configuration policy buttons follow the API guard', () => {
it('treats shipped as editable, not read-only', () => {
expect(view).toContain("EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']")
})
it('does not gate the buttons on local alone', () => {
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
})
it('gates edit and delete on the same rule', () => {
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
expect(disabled).toHaveLength(2)
})
})

View File

@@ -0,0 +1,95 @@
/**
* Run Scripts tab on PC Details.
*
* The thing worth pinning here is the SEPARATION. run_script and
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
* (endpoints.runscript and endpoints.corptools), split out of a single
* endpoints.deploy bundle precisely so that being able to trigger a corporate
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
*
* That split lives in the backend. The UI can quietly undo it by hanging the
* script picker off the Corporate Tools tab, whose only gate is
* endpoints.corptools - and the failure would be invisible in review, because
* the page would look correct and the server would simply 403 the push. These
* tests fail if someone merges the two.
*
* Asserted against the file text, matching systemScriptsSettings.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
describe('PCDetail Run Scripts tab', () => {
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
expect(source).toMatch(
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
})
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
// The corptools block must not gain a run_script call: that would put the
// action behind endpoints.corptools in the UI while the server still
// demands endpoints.runscript.
const corptools = source.slice(
source.indexOf("activegroup === 'corptools'"),
source.indexOf("activegroup === 'runscripts'"))
expect(corptools).not.toMatch(/run_script/)
expect(source).toMatch(/activegroup === 'runscripts'/)
})
it('enqueues run_script with a script-id target', () => {
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
})
it('requires a typed confirmation before running', () => {
// Every script on this tab is arbitrary code, so unlike Corporate Tools
// there is no safe branch that gets a plain OK button.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/Type RUN to confirm/)
expect(body).toMatch(/typed !== 'RUN'/)
})
it('warns that a script runs as SYSTEM before it is sent', () => {
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
})
it('shows the run context and working directory read-only', () => {
// Both are authoring decisions made in the library. Offering an override
// here would mean the script that ran is not the script that was reviewed,
// so they must be rendered as facts and never bound with v-model.
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
expect(block).toMatch(/selectedscript\.workingdirectory/)
expect(block).toMatch(/selectedscript\.runcontext/)
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
})
it('offers only scripts that have a body', () => {
// A bodyless script is rejected at enqueue by _resolve_run_script, so
// listing it would offer a button whose only outcome is an error.
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
})
it('reports the push as queued rather than as finished', () => {
// The agent collects the command on its next check-in; claiming it ran
// would assert an outcome nothing has observed.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
})
it('styles the Run Scripts tab from defined theme tokens only', () => {
// --text-primary/--text-secondary/--bg-secondary are not defined by this
// app's theme. var() then falls through to the fallback, or to inherit,
// which reads fine in light mode and misrenders for dark-mode users - the
// failure only shows up on the theme most of the shop floor runs.
const runscripts = source.slice(source.indexOf('.corptools-intro'))
expect(runscripts).not.toMatch(/--text-primary|--text-secondary|--bg-secondary|--surface/)
expect(runscripts).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
})

View File

@@ -27,10 +27,21 @@ from .jsontext import JSONText
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
# treatment as any non-local source: the CRUD API refuses to edit or delete
# them, the seeder reconciles them. Distinct from 'local' so a site's own
# policies are never confused with what the product shipped.
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
# Distinct from 'local' so a site's own policies are never confused with what
# the product shipped, and so the UI can badge the origin.
#
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
# The product ships a starting point, not an opinion about one site's floor: a
# plant that runs no MTConnect must be able to drop that policy. source stays
# 'shipped' through an edit, so provenance is a fact about where the row came
# from rather than a claim about who controls it now.
#
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
# (they SELECT by name and return early if it exists), so an upgrade cannot
# stomp a site's edits. That property is load-bearing now that the rows are
# editable - do not replace it with a reconciling seeder without adding a
# dirty flag first.
POLICY_SOURCE_SHIPPED = 'shipped'
# Rows generated FOR US by another surface rather than authored by hand: today

View File

@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
connections with a `truncated` flag in `detail`, because an oversized payload
rejected by the server returns nothing at all and a partial answer is more
useful than a failed one. Standard reports carry listening ports only.
- `run_script` - run a reviewed PowerShell script from the script library. The
body is NOT carried on the command channel; the claim carries a descriptor and
the agent fetches the bytes separately (section 7.4).
### 7.1 Claim the next command (long-poll)
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
gated by user JWT + `endpoints.command`. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
### 7.4 `run_script` claim descriptor
A `run_script` claim carries a `script` object alongside the usual
`endpointcommandid` and `action`:
```jsonc
{
"scriptid": 12,
"scriptname": "sfld_autologon",
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
"sha256": "3f9a...", // 64 hex chars, lowercase
"runcontext": "system", // "system" | "user"
"workingdirectory": "C:\\Tools", // "" = agent staging folder
"errormode": "default" // "default" | "strict"
}
```
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
against `sha256`, and refuse to run on mismatch. The body never travels on the
command channel so a queued command cannot be used to smuggle code past review.
`workingdirectory` is the folder the script runs from. Empty means the agent's
staging directory, which is correct for a self-contained script and wrong for
one invoking a separately-installed tool by relative path. A pinned folder that
does not exist MUST fail the command rather than falling back, so the script
never runs somewhere unintended.
`errormode` selects how the run is judged:
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
the script writes the error, continues, and still exits zero.
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
error terminates the run and the exit code reflects reality.
`default` is the default precisely BECAUSE it is permissive. Every script
authored before this field existed relies on that behaviour, and some rely on it
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
when a thing is absent, looping past a bad item. Promoting `strict` globally
would make those bodies abort partway and leave endpoints half-configured, so it
is opt-in per script.
Two consequences the agent MUST honour:
- The strict prologue is applied AFTER digest verification, so the hash still
validates the bytes the server actually delivered.
- Because the prologue adds a line, an error reported on line N of a strict run
refers to line N-1 of the stored body. Surfaces showing the body should say so.
An agent that does not recognise an `errormode` value MUST treat it as
`default`. The server always sends the field explicitly, even at the default, so
an agent that received it and ignored it stays distinguishable from one that
never saw it.
#### Script output
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
It is returned to the server in `detail.output` only when the command FAILED.
Routine output stays on the machine that produced it: shipping every successful
run back would put arbitrary script output, including whatever an author printed
while debugging, into the server's command history for no diagnostic gain.
Output is truncated to 8000 characters and flagged when truncated, because an
oversized payload rejected by the server returns nothing at all.
Timed-out runs do not carry output: the script is still running and still holds
the capture file open, so anything read at that moment would be misleading.

View File

@@ -2072,9 +2072,15 @@ def _resolve_run_script(target):
"""Resolve a run_script target to a script delivery descriptor.
Accepts either "script:<scriptid>" or a bare script name. The descriptor
carries a collector-authenticated download URL, the body's SHA-256, and the
per-script run context (system or user). The agent fetches the body with
its collector credential and runs it; the body never crosses the wire.
carries a collector-authenticated download URL, the body's SHA-256, the
per-script run context (system or user), and the optional folder to run in.
The agent fetches the body with its credential and runs it; the body never
crosses the wire.
workingdirectory is sent even when empty so the field is always present in
the claim: an agent that receives it and ignores it is then distinguishable
from one that never saw it, which matters while agents of both vintages are
in the field. errormode rides the same rule, and for the same reason.
"""
kind, _sep, raw = str(target or '').partition(':')
script = None
@@ -2102,6 +2108,8 @@ def _resolve_run_script(target):
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
'sha256': script.sha256,
'runcontext': script.runcontext,
'workingdirectory': script.workingdirectory or '',
'errormode': script.errormode or 'default',
}, None

View File

@@ -23,6 +23,20 @@ export default [
name: 'endpoints-monitors-settings',
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
// non-admin operators, and the sibling pages' admin gate would lock the
// holders of the grant out of the library they are trusted to manage. The
// API enforces the same permission, so this only keeps the menu honest.
{
path: 'settings/endpoints/scripts',
name: 'endpoints-scripts-settings',
component: () => import('./views/SystemScriptsSettings.vue'),
meta: {
requiresAuth: true,
requiresPermission: 'endpoints.runscript',
plugin: 'endpoints'
}
}
]

View File

@@ -0,0 +1,412 @@
<template>
<div>
<div class="page-header">
<h2>System Scripts</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
</div>
<p class="setting-description">
PowerShell the site agent can be told to run on an endpoint. A script is
stored here once, pinned by digest, and fetched by the agent at push time
so the bytes that run are the bytes that were reviewed.
</p>
<div class="warning-banner">
<strong>Every script here runs as SYSTEM.</strong>
There is no user context and no prompt: the agent is a service, so a
script cannot see a signed-in person's desktop, mapped drives, or
credentials. Scripts are intended to TRIGGER an already registered
scheduled task on the endpoint, which is what does the real work. Anything
written to act "as the logged-on user" will not behave the way it reads.
</div>
<details class="explainer">
<summary>How does this work?</summary>
<div class="explainer-body">
<h4>Delivery</h4>
<p>
The body is stored here and hashed. At push time the agent downloads it
with its own credential and checks the digest before running anything,
so the bytes that run are the bytes that were reviewed. The body never
travels on the command channel and is deleted from the endpoint after
the run.
</p>
<h4>Runs in</h4>
<p>
<strong>System</strong> is the agent's own service account: no desktop,
no mapped drives, no signed-in person's credentials.
<strong>User</strong> runs in the interactive session instead, and only
works when somebody is actually signed in.
</p>
<h4>Run in folder</h4>
<p>
The working directory for the run. Blank means the agent's staging
folder, which is right for a self-contained script. Set it when the
script calls a tool installed separately on the endpoint by relative
path. If the folder is missing the run fails outright rather than
running somewhere unintended.
</p>
<h4>When a line fails</h4>
<p>
This is the one worth understanding. PowerShell treats most errors as
<em>non-terminating</em>: it writes the error, carries on to the next
line, and still exits zero. Because the command's verdict comes from
that exit code, <strong>Continue</strong> means a script can report
success having accomplished nothing.
</p>
<p>
<strong>Stop</strong> makes the first error end the run, so the verdict
means what you would assume it means. It is not the default because it
changes how a script behaves: a body that tolerates a failing cleanup,
probes with a cmdlet that errors when something is absent, or loops
past bad items will start aborting partway through, leaving the
endpoint half-configured. Read the script first, then switch it on.
</p>
<h4>Where the output goes</h4>
<p>
Script output is always written to the agent log on the endpoint. It is
sent back to ShopDB only when the run fails, so routine output stays on
the machine that produced it. Long output is truncated and marked as
such.
</p>
<p class="hint">
In Stop mode the agent adds one line to the top of the script, so an
error reported on line N refers to line N-1 of the body shown here.
</p>
</div>
</details>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Runs in</th>
<th>On error</th>
<th>Body</th>
<th>Digest</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in scripts" :key="s.scriptid">
<td>{{ s.scriptname }}</td>
<td class="cell-truncate" :title="s.scriptdescription">
{{ s.scriptdescription || '-' }}
</td>
<td class="cell-truncate mono" :title="s.workingdirectory">
{{ s.workingdirectory || 'Agent staging folder' }}
</td>
<td>
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
Stop on first error
</span>
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
Continue
</span>
</td>
<td>
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
<span v-else class="badge badge-warning">No body - cannot run</span>
</td>
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
</td>
</tr>
<tr v-if="scripts.length === 0">
<td colspan="8" style="text-align:center; color: var(--text-light);">
No scripts
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="warning-banner">
<strong>This script will run as SYSTEM on every endpoint it is
pushed to.</strong>
Write it to trigger a scheduled task that already exists on the
endpoint. It has no user session, so anything depending on a
person being signed in will not work.
</div>
<div class="form-group">
<label>Name *</label>
<input v-model="form.scriptname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description <span class="hint">(what it triggers, and why)</span></label>
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>
Script file <span class="hint">(.ps1, replaces the stored body)</span>
</label>
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
<p v-if="editing && editing.hasbody" class="hint">
Current: {{ editing.filename }}. Uploading replaces it and
restamps the digest.
</p>
<p v-else-if="editing" class="hint">
No body uploaded yet. A script without a body cannot be pushed.
</p>
</div>
<div class="form-group">
<label>
Run in folder
<span class="hint">(optional, endpoint-local absolute path)</span>
</label>
<input
v-model="form.workingdirectory"
type="text"
class="form-control mono"
placeholder="C:\Program Files\CorporateTools"
/>
<details class="field-help">
<summary>How this works</summary>
<p>
Leave blank to run from the agent's staging folder. Set this only
when the script calls a tool already installed on the endpoint by
relative path. The folder must exist on the endpoint or the push
fails rather than running somewhere else.
</p>
</details>
</div>
<div class="form-group">
<label>
When a line fails
<span class="hint">(how the run is judged)</span>
</label>
<select v-model="form.errormode" class="form-control">
<option value="default">Continue - report success if the script reaches the end</option>
<option value="strict">Stop on the first error and fail the command</option>
</select>
<details class="field-help">
<summary>How this works</summary>
<p>
PowerShell normally writes an error and carries on, and still
exits zero, so a script can report success having done none of
its work. Stop makes the first failure end the run and fail the
command. Leave this on Continue unless you have read the script
and know it does not rely on a step being allowed to fail.
</p>
</details>
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { scriptsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const scripts = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const pendingfile = ref(null)
const form = ref(emptyForm())
function emptyForm() {
return {
scriptname: '',
scriptdescription: '',
workingdirectory: '',
errormode: 'default',
isactive: true
}
}
function shortdigest(s) {
return s.sha256 ? s.sha256.slice(0, 12) : '-'
}
async function loadData() {
loading.value = true
try {
const response = await scriptsApi.list({ active: 'false' })
scripts.value = response.data.data || []
} catch (err) {
toast.error(apiError(err, 'Failed to load scripts'))
} finally {
loading.value = false
}
}
function pickfile(event) {
pendingfile.value = event.target.files[0] || null
}
function openModal(script = null) {
error.value = ''
pendingfile.value = null
editing.value = script
form.value = script
? {
scriptname: script.scriptname,
scriptdescription: script.scriptdescription || '',
workingdirectory: script.workingdirectory || '',
errormode: script.errormode || 'default',
isactive: script.isactive
}
: emptyForm()
showModal.value = true
}
function closeModal() {
showModal.value = false
editing.value = null
pendingfile.value = null
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
scriptname: form.value.scriptname,
scriptdescription: form.value.scriptdescription,
workingdirectory: form.value.workingdirectory,
errormode: form.value.errormode,
isactive: form.value.isactive
}
let scriptid
if (editing.value) {
await scriptsApi.update(editing.value.scriptid, payload)
scriptid = editing.value.scriptid
} else {
const created = await scriptsApi.create(payload)
scriptid = created.data.data.scriptid
}
// Second call on purpose: the body is multipart and the catalog row is
// JSON. A failure here leaves a bodyless row, which the list marks as
// unrunnable rather than pretending it is ready to push.
if (pendingfile.value) {
await scriptsApi.uploadBody(scriptid, pendingfile.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save script')
} finally {
saving.value = false
}
}
async function remove(s) {
if (!confirm(`Delete script "${s.scriptname}"?`)) return
try {
await scriptsApi.delete(s.scriptid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
onMounted(loadData)
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono { font-family: monospace; }
.hint { color: var(--text-light); font-weight: normal; }
.warning-banner {
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
.explainer {
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 1rem;
background: var(--bg-card);
color: var(--text);
}
.explainer summary {
cursor: pointer;
padding: 0.6rem 1rem;
font-weight: 600;
color: var(--text);
}
.explainer-body {
padding: 0 1rem 0.75rem;
}
.explainer-body h4 {
margin: 0.9rem 0 0.25rem;
font-size: 0.95rem;
color: var(--text);
}
.explainer-body p {
margin: 0 0 0.5rem;
color: var(--text-light);
line-height: 1.5;
}
.field-help {
margin-top: 0.4rem;
}
.field-help summary {
cursor: pointer;
color: var(--text-light);
font-size: 0.85rem;
}
.field-help p {
margin: 0.35rem 0 0;
color: var(--text-light);
font-size: 0.85rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,124 @@
/**
* System Scripts settings page.
*
* Two things are worth pinning here and nothing else is:
*
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
* agent is a service with no user session; exposing a picker would let
* someone author a script that reads as "run as the logged-on user" and
* silently does not.
* 2. The SYSTEM warning must be present on both the list and the add/edit
* form. Someone pasting a script into the modal may never have read the
* page copy above it.
*
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
describe('SystemScriptsSettings', () => {
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
const warnings = source.match(/runs? as SYSTEM/g) || []
expect(warnings.length).toBeGreaterThanOrEqual(2)
})
it('does not offer a run context picker', () => {
expect(source).not.toMatch(/runcontext/i)
})
it('does not send a runcontext in its save payload', () => {
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
})
it('flags a script with no body as unrunnable', () => {
expect(source).toMatch(/hasbody/)
expect(source).toMatch(/cannot run/i)
})
it('is gated on the standalone script permission, not admin', () => {
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
})
it('offers an optional working directory and sends it on save', () => {
expect(source).toMatch(/form\.workingdirectory/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
})
it('shows the staging default rather than an empty cell', () => {
// A blank cell reads as "unknown". The folder is the difference between a
// corporate tool being found and the script failing further in, so the
// list has to state the default out loud.
expect(source).toMatch(/Agent staging folder/)
})
it('offers an error mode and sends it on save', () => {
expect(source).toMatch(/form\.errormode/)
const payload = source.slice(source.indexOf('const payload = {'))
expect(payload.slice(0, 300)).toMatch(/errormode/)
})
it('defaults a new script to the permissive mode', () => {
// Strict changes how a body behaves partway through. Defaulting to it
// would silently alter every script authored from here on, so a new
// script has to start where the old ones already are.
const empty = source.slice(source.indexOf('function emptyForm'))
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
})
it('names both error modes by their consequence, not by their flag', () => {
// "default" and "strict" mean nothing to the person choosing. What they
// need to know is which one can report success having done nothing.
expect(source).toMatch(/Continue - report success if the script reaches the end/)
expect(source).toMatch(/Stop on the first error and fail the command/)
})
it('explains the non-terminating error trap that justifies the choice', () => {
// The whole feature exists because PowerShell exits zero after an error.
// If the page never says so, the dropdown looks like pedantry and stays
// on the setting that produces false green.
expect(source).toMatch(/non-terminating/i)
expect(source).toMatch(/exits? zero/i)
})
it('offers the explainer collapsed so it does not bury the page', () => {
expect(source).toMatch(/<details class="explainer">/)
expect(source).toMatch(/How does this work\?/)
expect(source).not.toMatch(/<details class="explainer" open>/)
})
it('collapses the long per-field prose in the form', () => {
// The modal is already tall enough to scroll. Three paragraphs of standing
// explanation between the inputs pushes Save off screen and gets skimmed
// past, which is the opposite of what the copy is for.
const helps = source.match(/<details class="field-help">/g) || []
expect(helps.length).toBeGreaterThanOrEqual(2)
expect(source).toMatch(/<summary>How this works<\/summary>/)
})
it('styles itself from defined theme tokens only', () => {
// --surface and --text-primary are not defined by this app's theme, so a
// var() fallback silently rendered a white panel on the dark background.
// A fallback colour is the bug: it looks correct in light mode and only
// shows up as a bright block for dark-mode users.
const styles = source.slice(source.indexOf('<style scoped>'))
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
it('states that output is kept locally and only returned on failure', () => {
// Operators otherwise assume either that nothing was captured or that
// every run is shipped back to the server.
const explainer = source.slice(source.indexOf('Where the output goes'))
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
})
})

View File

@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
process agent.exe -> MTConnect
service MTConnect UDC Agent -> MTConnect
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
or delete them. Launch paths mirror the agent's launchpaths block; collector and
Shipped rows (source='shipped') keep their kind and displayname - that pair is
the identity the monitor policies match on - but a site may edit their paths and
active flag. Launch paths mirror the agent's launchpaths block; collector and
log paths are left null until site paths are supplied.
Idempotent: creation checks the live schema first so a db.create_all() site

View File

@@ -1,12 +1,22 @@
"""Ship the shopfloor app monitors as a read-only Site Policy.
"""Ship the shopfloor app monitors as a Site Policy bundle.
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
sites compose their own. Read this for history; do not extend it.
Two claims below were true when written and are now false, corrected here rather
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
at the policy CRUD guards), and no seeder reconciles them - the idempotency
check at the top of upgrade() returns early if the policy exists, which is what
keeps a site's edits safe across upgrades.
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
its watch-list from the server instead.
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
stops carrying them as hardcoded appsettings.json defaults and reads its
watch-list from the server instead.
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
CRUD API refuses to edit or delete them):
Seeds two computers-plugin rows, both source='shipped':
ConfigurationPolicy "Shopfloor App Monitors"
scope {"type":"true"} (every endpoint), one action per shipped monitor,

View File

@@ -0,0 +1,320 @@
"""Split the shipped monitor bundle into one policy per monitor.
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
same name. That bundle was all-or-nothing: a site that runs eDNC but no
MTConnect could not detach the half it does not have, because the whole row was
product-owned and read-only.
This migration replaces it with one Configuration Policy per shipped monitor:
Monitor: eDNC (process)
Monitor: Universal Data Collector (process)
Monitor: MTConnect (process)
Monitor: MTConnect (service)
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
scope {"type":"true"} - no PC type attribution. The name carries the kind
because displayname alone is not unique: MTConnect ships as both a process and
a service, and configurationpolicies.name is UNIQUE.
source stays 'shipped'. Provenance is still true - the product did ship these -
and the UI keeps showing where they came from. What changes is that the CRUD
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
without losing the record of its origin.
The shipped SITE policy is REMOVED and not replaced. Composition is a site
decision: which monitors apply to which endpoints, and at what priority, is
exactly the judgement a plant makes about its own floor. Shipping one meant
every install inherited "apply all four to every endpoint" as a starting point
that could not be edited. Sites now build their own from the per-monitor pieces.
Removing it is the behaviour change to be aware of on upgrade: monitors that
were attached through that bundle stop being attached until a site policy
composes them. It is deliberate and it is the point of the split - see the
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
the per-monitor policies are listed and ready to compose.
Idempotent on the policy names, so a re-run neither duplicates the split rows
nor resurrects the bundle. Guards on the policy tables being present and no-ops
if they are not, matching 0007.
Revision ID: endpoints0019splitpolicies
Revises: endpoints0018endpointlocks
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
# id passes on SQLite and fails on strict MySQL with "Data too long".
revision = 'endpoints0019splitpolicies'
down_revision = 'endpoints0018endpointlocks'
branch_labels = None
depends_on = None
BUNDLE_NAME = 'Shopfloor App Monitors'
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
# so this survives id drift across installs exactly as 0007 does.
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
'configurationpolicyactions', 'businesspolicies',
'businesspolicyitems'}
def _tables(bind):
from sqlalchemy import inspect
return set(inspect(bind).get_table_names())
def _policy_name(kind, displayname):
return f'Monitor: {displayname} ({kind})'
def _policy_description(kind, displayname):
noun = 'process' if kind == 'process' else 'service'
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
f'Attach it to a Site Policy to apply it to endpoints.')
def _config_policy_id(bind, name):
return bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': name}).scalar()
def _drop_config_policy(bind, name):
"""Delete a configuration policy, its actions, and any items citing it."""
policyid = _config_policy_id(bind, name)
if policyid is None:
return
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
# sqlite does not enforce by default.
bind.execute(
sa.text('DELETE FROM businesspolicyitems '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicies '
'WHERE configurationpolicyid = :id'), {'id': policyid})
def _drop_business_policy(bind, name):
policyid = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name AND source = :source'),
{'name': name, 'source': 'shipped'}).scalar()
if policyid is None:
return
bind.execute(
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
{'id': policyid})
bind.execute(
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
{'id': policyid})
def _monitor_id(bind, kind, displayname):
return bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def upgrade():
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
return
now = datetime.utcnow()
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
for kind, displayname in _SHIPPED_MONITORS:
name = _policy_name(kind, displayname)
if _config_policy_id(bind, name) is not None:
continue
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is None:
# A site that never had this monitor gets no policy for it.
continue
op.bulk_insert(config_policies, [{
'name': name,
'description': _policy_description(kind, displayname),
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
op.bulk_insert(actions, [{
'configurationpolicyid': _config_policy_id(bind, name),
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
# Retire the monolith. The business policy goes first so its item rows are
# gone before the configuration policy it cited is removed.
_drop_business_policy(bind, BUNDLE_NAME)
_drop_config_policy(bind, BUNDLE_NAME)
def downgrade():
"""Reinstate the single shipped bundle and drop the per-monitor policies.
Rebuilt from the same monitor identities rather than from anything the
upgrade stashed, so it lands in the state 0007 produces regardless of what
a site did to the split rows in between.
"""
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
return
now = datetime.utcnow()
monitor_ids = []
for kind, displayname in _SHIPPED_MONITORS:
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is not None:
monitor_ids.append(monitorid)
for kind, displayname in _SHIPPED_MONITORS:
_drop_config_policy(bind, _policy_name(kind, displayname))
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
return
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(config_policies, [{
'name': BUNDLE_NAME,
'description': 'Product-shipped watch-list for the shopfloor apps '
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(actions, [{
'configurationpolicyid': config_policy_id,
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': index,
'createddate': now,
'modifieddate': now,
'isactive': True,
} for index, monitorid in enumerate(monitor_ids)])
business_policies = sa.table(
'businesspolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('priority', sa.Integer),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(business_policies, [{
'name': BUNDLE_NAME,
'description': 'Product-shipped Site Policy that applies the shopfloor '
'app monitors to every endpoint. Read-only.',
'priority': 100,
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
items = sa.table(
'businesspolicyitems',
sa.column('businesspolicyid', sa.Integer),
sa.column('configurationpolicyid', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(items, [{
'businesspolicyid': business_policy_id,
'configurationpolicyid': config_policy_id,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])

View File

@@ -320,6 +320,21 @@ class EndpointsPlugin(BasePlugin):
'agent, and configure what it reports.',
'position': 27,
},
# Its own card rather than a section of the agent key page: the
# audience differs. Key management is admin-only, while the script
# library is reachable by any endpoints.runscript holder, and
# burying it under an admin page would hide it from exactly the
# operators the grant exists for.
{
'group': 'Endpoints',
'to': '/settings/endpoints/scripts',
'icon': 'terminal',
'title': 'System Scripts',
'description': 'PowerShell the agent can run on an endpoint. '
'Runs as SYSTEM; used to trigger scheduled '
'tasks already registered on the endpoint.',
'position': 28,
},
]
def get_navigation_items(self) -> List[Dict]: