Add GE-Enforce export-to-share + fleet-compliance UI (Milestone 1 UX)
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s

Rounds out the Milestone 1 admin experience: author + publish in shopdb, push
to the share by a button, and see what the fleet actually did.

Export to share:
- GET/PUT /api/geenforce/config stores the on-share export root (Setting
  geenforce_share_root); POST /scopes/<id>/export-share writes the current
  published JSON to <shareroot>/<scope>/manifest.json (preinstall.json for the
  preinstall phase), backing up the existing file to _meta/history first.
  geenforce.publish gated. The engine and PCs are untouched - this is the safe
  Milestone 1 push whose rollback is restoring the history backup.
- Editor: a share-root config row + an "Export to Share" button per scope.
- 3 tests (config roundtrip, export writes the file, second export backs up).

Fleet-compliance UI (Settings > Enforcement Reports):
- New page over GET /reports + /reports/<id>: latest report per PC with
  received (applied vs latest published version), status (ok/selfhealed/failed),
  and install/skip/fail counts; row detail shows per-entry outcomes with
  self-heal flags, exit codes, and messages. Hostname/PC-type filters.
- ADR-010 settings card + ADR-009 plugin-gated route.

Full suite 883 green; frontend build + naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 18:06:33 -04:00
parent 7eb6cebeb5
commit adc5b7f69e
6 changed files with 285 additions and 1 deletions

View File

@@ -0,0 +1,65 @@
"""GE-Enforce export-to-share: config the share root, publish, write to share.
This is the Milestone 1 loop - author + publish in shopdb, then export the
published JSON to the on-share manifest with a history backup, engine untouched.
"""
import json
import os
from plugins.geenforce import service
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(name, 'runtime', {
'Version': '2.6',
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
'Installer': 'apps/a.msi'}]})
service.publish_scope(name, 'runtime', notes='v1')
scope = service.ManifestScope.query.filter_by(scopename=name).first()
scopeid = scope.scopeid
service.db.session.commit()
return scopeid
def test_export_requires_config(client, db, app, auth_headers):
scopeid = _scope_with_entry(app)
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 400 # no share root configured
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
# Configure the share root.
put = client.put('/api/geenforce/config',
json={'shareroot': str(tmp_path)}, headers=auth_headers)
assert put.status_code == 200
got = client.get('/api/geenforce/config', headers=auth_headers)
assert got.get_json()['data']['shareroot'] == str(tmp_path)
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
path = resp.get_json()['data']['path']
assert os.path.isfile(path)
written = json.load(open(path))
assert [e['Name'] for e in written['Applications']] == ['Alpha']
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
headers=auth_headers)
# First export creates the file.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
# Second export backs the old file into _meta/history.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
historydir = tmp_path / '_meta' / 'history'
assert historydir.is_dir()
assert any(historydir.iterdir())