Remove the delivery test bench

The delivery test bench and its notes were scaffolding for validating the software-delivery path during development. That work is complete and covered by tests, so the bench is removed along with its route registration rather than left in the shipped UI where it would read as a supported feature.
This commit is contained in:
benjamin.baker
2026-09-07 12:39:46 -04:00
parent ea3739262b
commit 187d371453
3 changed files with 0 additions and 449 deletions

View File

@@ -1,112 +0,0 @@
# Delivery testing notes (update_app + run_script)
Temporary field notes for exercising the endpoint delivery path end to end and
for handing the UI work to Cameron. ASCII only, no em-dashes, no emojis, per
repo style. Remove the dev test bench (view, route, nav entry) once the
production delivery UI lands.
## What exists today (the contract Cameron builds against)
Server allowlist (`plugins/endpoints/commands.py`):
- `run_script` - targetkind `script`. `target` is a script NAME the agent
already holds in its local `scriptpaths` allowlist. No body or path crosses
the wire.
- `update_app` - targetkind `app`. `target` is an `appversionid` that resolves
to a ShopDB-hosted installer package. The server pins the package by SHA-256
and passes this version's `silentargs`.
Both actions require `endpoints.deploy` ON TOP OF `endpoints.command`. Queueing
one is the actual authority (audited with the requesting user). The agent claims
it with its collector credential.
Enqueue call (same route as every other endpoint command):
```
POST /api/endpoints/<assetid>/commands
{ "action": "run_script", "target": "remove-network-drives" }
{ "action": "update_app", "target": "<appversionid>" }
```
Rules the UI must respect:
- At most one pending/running command per endpoint. A second enqueue returns
409 (treat as "already in flight", not a failure).
- `update_app` is validated at enqueue: an appversionid with no installer
package or unknown id is rejected 400 before it is queued.
- A version with no `sha256` has no uploaded package yet. Do not offer it for
`update_app`; the agent's integrity check will fail closed.
Read the result back:
```
GET /api/endpoints/<assetid>/commands
```
Each row carries `status` (pending/running/succeeded/failed), `resultmessage`
(short summary) and `resultjson` (structured detail: for run_script the exit
code and script path; for update_app the installer path, exit code and digest
check).
Agent-facing package download (collector-authenticated, not user JWT):
```
GET /api/endpoints/apppackage/<appversionid> (X-API-Key collector token)
```
## The dev test bench
Route: `/endpoints/delivery-test` (sidebar "Delivery test bench", gated on
`endpoints.deploy`). It is deliberately minimal: pick an endpoint, push a script
by name or push an app version, then watch the command log pane. There is no
script-library component yet, so `run_script` takes a free-text name; the agent
runs it only if that name is in its `scriptpaths`.
## Preparing the test endpoint
1. Copy the dev build to the test box:
- `agent\dev-bin\agent\shopdbagent.exe`
- `agent\dev-bin\agent\appsettings.json`
Stage them the same way the site installer does (service exe + appsettings).
2. Provision the collector credential and base URL. Per repo policy these are
NOT in appsettings.json; set the registry values under
`HKLM\SOFTWARE\GE\ShopDB` (`CollectorKey`, `BaseUrl`). The shipped
appsettings has `baseurl` empty and `apikey` = REPLACE_WITH_COLLECTOR_KEY so
the agent fails closed until provisioned.
3. Stage the sample script where appsettings points:
`C:\ProgramData\ShopDB\EndpointAgent\scripts\remove-network-drives.ps1`
(source: `agent\dev-bin\scripts\remove-network-drives.ps1`). The
`scriptpaths` entry in appsettings.json maps the name `remove-network-drives`
to that path.
4. For an `update_app` test, create the application in the Applications module,
upload its installer package (this stamps the version's `sha256`), and note
the version's `appversionid`.
## Collecting logs
Endpoint side (the agent):
- Rolling diagnostics log: `%ProgramData%\ShopDB\EndpointAgent\logs`
(override with `logpath` in appsettings). This is where run_script and
update_app record what they resolved, the digest check, and the installer or
script exit code.
- Windows Event Log source `ShopDB Endpoint Agent` for service faults.
Web UI / ShopDB side:
- The command log pane on the bench (or `GET /api/endpoints/<assetid>/commands`)
shows `status`, `resultmessage` and `resultjson` reported by the agent.
- Ingest observability: unknown/unpersisted telemetry keys are logged by the
endpoints plugin on the API container, so incoming agent data is visible in
the app logs even before UI persistence catches up.
- Audit log records each enqueue (`created EndpointCommand`, action + target +
asset) with the requesting user.
## Notes to capture during testing (for Cameron)
- Round-trip latency from enqueue to a terminal status, at the agent cadence.
- Whether `resultmessage` alone is enough for the operator, or the UI needs to
surface `resultjson` (exit codes, digest mismatches).
- How a 409 (already in flight) should read in the real UI.
- What a failed digest check or non-zero installer exit looks like end to end,
so the production UI can present it clearly.

View File

@@ -23,16 +23,6 @@ export default [
name: 'endpoints-monitors-settings',
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production
// delivery UI lands.
{
path: 'endpoints/delivery-test',
name: 'endpoints-delivery-test',
component: () => import('./views/DeliveryTestBench.vue'),
meta: { requiresAuth: true, plugin: 'endpoints' }
}
]

View File

@@ -1,327 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>