printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit
This commit is contained in:
@@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
|||||||
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
API_PORT=5001
|
API_PORT=5001
|
||||||
|
# Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag,
|
||||||
|
# which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by
|
||||||
|
# the connected build template (docker-compose.yml builds from source).
|
||||||
|
IMAGE_TAG=0.7.0
|
||||||
|
|
||||||
# ---- Zabbix integration (optional, for printer supply monitoring) ----
|
# ---- Zabbix integration (optional, for printer supply monitoring) ----
|
||||||
ZABBIX_URL=
|
ZABBIX_URL=
|
||||||
|
|||||||
90
docker-compose.airgap.yml
Normal file
90
docker-compose.airgap.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# shopdb-flask AIR-GAPPED single-site stack.
|
||||||
|
#
|
||||||
|
# For a site with NO internet. Nothing is built or pulled here: the images are
|
||||||
|
# built on a connected box (scripts/build-offline-bundle.ps1), shipped as a
|
||||||
|
# tarball, and `docker load`ed at the site. This file only RUNS pre-loaded
|
||||||
|
# images. See docs/DEPLOY-AIRGAP.md for the full runbook.
|
||||||
|
#
|
||||||
|
# Differences from docker-compose.yml (the connected/build template):
|
||||||
|
# - api uses `image:` (a loaded image), never `build: .` (build needs the net).
|
||||||
|
# - NO ./plugins bind mount. The image already carries every plugin baked in;
|
||||||
|
# binding a host ./plugins (which does not exist at an image-only site) would
|
||||||
|
# mask the baked plugins with an empty dir and load ZERO plugins.
|
||||||
|
# - a one-shot `migrate` service runs db upgrade + plugin upgrade-all + seed
|
||||||
|
# BEFORE api starts, so `up -d` alone brings up a working site (no manual
|
||||||
|
# `docker compose exec ... flask db upgrade` to forget).
|
||||||
|
#
|
||||||
|
# Usage at the site:
|
||||||
|
# docker load < shopdb-stack-<version>.tar.gz
|
||||||
|
# cp .env.example .env # then edit: secrets, CORS_ORIGINS, IMAGE_TAG
|
||||||
|
# docker compose -f docker-compose.airgap.yml up -d
|
||||||
|
# docker compose -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>
|
||||||
|
|
||||||
|
# Shared application environment, reused by the migrate one-shot and the api
|
||||||
|
# service so the two never drift. A YAML anchor, not a container.
|
||||||
|
x-app-env: &app-env
|
||||||
|
FLASK_APP: wsgi.py
|
||||||
|
FLASK_ENV: production
|
||||||
|
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
||||||
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
ZABBIX_URL: ${ZABBIX_URL:-}
|
||||||
|
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||||
|
MYSQL_DATABASE: shopdb_flask
|
||||||
|
MYSQL_USER: shopdb
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# One-shot schema + seed. Runs to completion and exits; api waits for it.
|
||||||
|
# Every step is idempotent, so it is safe to run on every `up`.
|
||||||
|
migrate:
|
||||||
|
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- >
|
||||||
|
flask db upgrade &&
|
||||||
|
flask plugin upgrade-all &&
|
||||||
|
flask seed permissions &&
|
||||||
|
flask seed settings &&
|
||||||
|
flask seed reference-data
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-5001}:5001"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""apitokens.resourcescopes: pin a service token to specific resource scopes.
|
||||||
|
|
||||||
|
A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its
|
||||||
|
own manifest scope(s) and the blobs those scopes ship, not every scope by name
|
||||||
|
or every blob by hash. This nullable JSON column carries that allowlist; NULL =
|
||||||
|
unrestricted, so every existing token keeps working unchanged. Idempotent.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7d30_apitoken_resourcescopes'
|
||||||
|
down_revision = '7d29_supportteam_webhookurl'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(bind, table, column):
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
return column in {c['name'] for c in inspector.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||||
|
op.add_column('apitokens',
|
||||||
|
sa.Column('resourcescopes', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||||
|
op.drop_column('apitokens', 'resourcescopes')
|
||||||
@@ -9,15 +9,16 @@ Two audiences:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, Response, send_file
|
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes, require_permission,
|
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||||
service_token_authorized, Setting, Application,
|
authorized_service_token, Setting, Application,
|
||||||
)
|
)
|
||||||
|
|
||||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||||
@@ -50,7 +51,12 @@ def _require_service_token(scope):
|
|||||||
def wrapper(f):
|
def wrapper(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
if service_token_authorized(scope):
|
token = authorized_service_token(scope)
|
||||||
|
if token is not None:
|
||||||
|
# Stash for the route so it can honor the token's resource
|
||||||
|
# binding (token.resourcescopelist restricts which manifest
|
||||||
|
# scopes + blobs this token may pull; None = unrestricted).
|
||||||
|
g.geenforce_token = token
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||||
http_code=401)
|
http_code=401)
|
||||||
@@ -58,6 +64,14 @@ def _require_service_token(scope):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _token_resource_scopes():
|
||||||
|
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||||
|
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||||
|
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||||
|
token = getattr(g, 'geenforce_token', None)
|
||||||
|
return token.resourcescopelist if token is not None else None
|
||||||
|
|
||||||
|
|
||||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||||
|
|
||||||
@@ -79,6 +93,11 @@ def get_manifest():
|
|||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'pctype is required', http_code=400)
|
'pctype is required', http_code=400)
|
||||||
|
|
||||||
|
allowed = _token_resource_scopes()
|
||||||
|
if allowed is not None and scopename not in allowed:
|
||||||
|
return error_response(ErrorCodes.FORBIDDEN,
|
||||||
|
'token is not allowed this scope', http_code=403)
|
||||||
|
|
||||||
scope = ManifestScope.query.filter_by(
|
scope = ManifestScope.query.filter_by(
|
||||||
scopename=scopename, phase=phase).first()
|
scopename=scopename, phase=phase).first()
|
||||||
if not scope:
|
if not scope:
|
||||||
@@ -101,6 +120,59 @@ def get_manifest():
|
|||||||
|
|
||||||
# -- client payload download (share-less installer delivery) ------------------
|
# -- client payload download (share-less installer delivery) ------------------
|
||||||
|
|
||||||
|
# GET /payload hardening. This endpoint is reachable with only a read-only
|
||||||
|
# geenforce.fetch token, so a leaked display token must not be able to pull
|
||||||
|
# unbounded bytes or hammer it. Two bounds cap the blast radius:
|
||||||
|
# - a per-IP fixed-window rate limit (same shape and cache extension as the
|
||||||
|
# login limiter in shopdb.core.api.auth, so no new dependency), and
|
||||||
|
# - a served-size ceiling: refuse to stream a blob larger than the cap.
|
||||||
|
# Both are overridable via app config for a site that ships bigger installers.
|
||||||
|
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||||
|
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
|
||||||
|
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip():
|
||||||
|
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
|
||||||
|
(mirrors shopdb.core.api.auth._login_ip)."""
|
||||||
|
forwarded = request.headers.get('X-Forwarded-For')
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(',')[0].strip()
|
||||||
|
return request.remote_addr or 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_max_bytes():
|
||||||
|
return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
|
||||||
|
PAYLOAD_DOWNLOAD_MAX_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_download_ratelimited():
|
||||||
|
"""Fixed-window per-IP limiter for the payload download endpoint.
|
||||||
|
|
||||||
|
Backed by the existing cache extension (no new dependency), same shape as
|
||||||
|
the login limiter. Under the default SimpleCache the counter is per-process,
|
||||||
|
so with N gunicorn workers the effective budget is N x the configured max; a
|
||||||
|
shared cache backend (Redis/memcached) tightens it to a true global budget.
|
||||||
|
Returns True when the caller is over budget for the current window.
|
||||||
|
"""
|
||||||
|
if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
|
||||||
|
return False
|
||||||
|
window = current_app.config.get(
|
||||||
|
'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
|
||||||
|
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
|
||||||
|
maxhits = current_app.config.get(
|
||||||
|
'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
|
||||||
|
# Time bucket makes this a fixed window: the key rolls over at each window
|
||||||
|
# boundary, so a per-hit set() cannot turn it into a sliding window.
|
||||||
|
bucket = int(time.time() // window) if window > 0 else 0
|
||||||
|
key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
|
||||||
|
count = cache.get(key) or 0
|
||||||
|
if count >= maxhits:
|
||||||
|
return True
|
||||||
|
cache.set(key, count + 1, timeout=window)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
|
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
|
||||||
@require_fetch_token
|
@require_fetch_token
|
||||||
def get_payload(sha256):
|
def get_payload(sha256):
|
||||||
@@ -111,17 +183,40 @@ def get_payload(sha256):
|
|||||||
http payloads) first, then an inline DB payload with this hash. The client
|
http payloads) first, then an inline DB payload with this hash. The client
|
||||||
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
|
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
|
||||||
hash (content is immutable).
|
hash (content is immutable).
|
||||||
|
|
||||||
|
Hardened: per-IP rate limited, and a blob over the served-size ceiling is
|
||||||
|
refused (413) rather than streamed.
|
||||||
"""
|
"""
|
||||||
|
if _payload_download_ratelimited():
|
||||||
|
return error_response('RATE_LIMITED',
|
||||||
|
'Too many payload downloads. Try again later.',
|
||||||
|
http_code=429)
|
||||||
|
|
||||||
sha = (sha256 or '').strip().lower()
|
sha = (sha256 or '').strip().lower()
|
||||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||||
http_code=400)
|
http_code=400)
|
||||||
|
|
||||||
|
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||||
|
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||||
|
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||||
|
allowed = _token_resource_scopes()
|
||||||
|
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
etag = f'"{sha}"'
|
etag = f'"{sha}"'
|
||||||
if request.headers.get('If-None-Match') == etag:
|
if request.headers.get('If-None-Match') == etag:
|
||||||
return Response(status=304, headers={'ETag': etag})
|
return Response(status=304, headers={'ETag': etag})
|
||||||
|
|
||||||
|
maxbytes = _payload_max_bytes()
|
||||||
|
|
||||||
blob = db.session.get(ManifestBlob, sha)
|
blob = db.session.get(ManifestBlob, sha)
|
||||||
if blob and os.path.isfile(service.blob_path(sha)):
|
if blob and os.path.isfile(service.blob_path(sha)):
|
||||||
|
if blob.sizebytes is not None and blob.sizebytes > maxbytes:
|
||||||
|
return error_response('PAYLOAD_TOO_LARGE',
|
||||||
|
'payload exceeds the download size limit',
|
||||||
|
http_code=413)
|
||||||
response = send_file(
|
response = send_file(
|
||||||
service.blob_path(sha),
|
service.blob_path(sha),
|
||||||
mimetype=blob.contenttype or 'application/octet-stream',
|
mimetype=blob.contenttype or 'application/octet-stream',
|
||||||
@@ -131,6 +226,10 @@ def get_payload(sha256):
|
|||||||
|
|
||||||
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
|
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
|
||||||
if inline:
|
if inline:
|
||||||
|
if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
|
||||||
|
return error_response('PAYLOAD_TOO_LARGE',
|
||||||
|
'payload exceeds the download size limit',
|
||||||
|
http_code=413)
|
||||||
return Response(
|
return Response(
|
||||||
inline.payloadbytes,
|
inline.payloadbytes,
|
||||||
mimetype=inline.contenttype or 'application/octet-stream',
|
mimetype=inline.contenttype or 'application/octet-stream',
|
||||||
|
|||||||
@@ -24,6 +24,17 @@
|
|||||||
.PARAMETER ShadowMode
|
.PARAMETER ShadowMode
|
||||||
Fetch + compare + report, but install from the share (no behavior change).
|
Fetch + compare + report, but install from the share (no behavior change).
|
||||||
|
|
||||||
|
.PARAMETER IncludeCommon
|
||||||
|
Also fetch the fleet-wide CommonScope and merge it into -Scope, so the PC
|
||||||
|
enforces its own scope entries PLUS common's (on a Name conflict the -Scope
|
||||||
|
entry wins). OFF by default: a scope is enforced ALONE (self-sufficient).
|
||||||
|
Displays are self-sufficient and do NOT set this. Only a share-less non-display
|
||||||
|
PC that genuinely needs the fleet-wide common entries over HTTPS turns it on.
|
||||||
|
|
||||||
|
.PARAMETER CommonScope
|
||||||
|
Name of the fleet-wide scope merged when -IncludeCommon is set (default
|
||||||
|
'common'). Ignored without -IncludeCommon.
|
||||||
|
|
||||||
.NOTES
|
.NOTES
|
||||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||||
@@ -34,6 +45,8 @@ param(
|
|||||||
[Parameter(Mandatory)] [string]$EnginePath,
|
[Parameter(Mandatory)] [string]$EnginePath,
|
||||||
[string]$ShareManifestPath,
|
[string]$ShareManifestPath,
|
||||||
[switch]$ShadowMode,
|
[switch]$ShadowMode,
|
||||||
|
[switch]$IncludeCommon,
|
||||||
|
[string]$CommonScope = 'common',
|
||||||
[string]$BaseUrl,
|
[string]$BaseUrl,
|
||||||
[string]$ApiToken,
|
[string]$ApiToken,
|
||||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||||
@@ -46,6 +59,27 @@ function Write-Log {
|
|||||||
Write-Host $line
|
Write-Host $line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-ShopdbEventLog {
|
||||||
|
<#
|
||||||
|
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
||||||
|
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
||||||
|
observable to whoever watches the display. Best-effort: registering the
|
||||||
|
source needs admin, which the SYSTEM scheduled task has; any failure is
|
||||||
|
swallowed so it can never break the fail-safe.
|
||||||
|
#>
|
||||||
|
param([string]$Message,
|
||||||
|
[string]$EntryType = 'Warning',
|
||||||
|
[int]$EventId = 1001)
|
||||||
|
$source = 'ShopdbEnforce'
|
||||||
|
try {
|
||||||
|
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
||||||
|
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
||||||
|
}
|
||||||
|
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
||||||
|
-EventId $EventId -Message $Message -ErrorAction Stop
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||||
|
|
||||||
@@ -57,7 +91,21 @@ try {
|
|||||||
|
|
||||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||||
if (-not $sync.Path) {
|
if (-not $sync.Path) {
|
||||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
||||||
|
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
||||||
|
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
||||||
|
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
||||||
|
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
||||||
|
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
||||||
|
try {
|
||||||
|
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
||||||
|
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
||||||
|
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
||||||
|
}
|
||||||
|
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
||||||
|
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||||
@@ -75,29 +123,50 @@ try {
|
|||||||
|
|
||||||
# Which manifest the engine actually runs against.
|
# Which manifest the engine actually runs against.
|
||||||
if ($ShadowMode -and $ShareManifestPath) {
|
if ($ShadowMode -and $ShareManifestPath) {
|
||||||
# Shadow: install from the share exactly as today (no payload resolve).
|
# Shadow: install from the share exactly as today (no payload resolve,
|
||||||
|
# no common merge - the share already carries its own common scope).
|
||||||
$manifestToRun = $ShareManifestPath
|
$manifestToRun = $ShareManifestPath
|
||||||
} else {
|
} else {
|
||||||
|
# Optional common-scope inheritance (OFF by default; displays are
|
||||||
|
# self-sufficient). Only when -IncludeCommon is set do we fetch the
|
||||||
|
# fleet-wide common scope (best-effort, same fail-safe cache) and merge
|
||||||
|
# it in with the pctype winning on conflict. Skipped when this run IS
|
||||||
|
# the common scope.
|
||||||
|
$manifestToMerge = $sync.Path
|
||||||
|
if ($IncludeCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
||||||
|
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
||||||
|
if ($commonSync.Path) {
|
||||||
|
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
||||||
|
if ($manifestToMerge -ne $sync.Path) {
|
||||||
|
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Cutover: stage any http/inline payloads to local files and rewrite the
|
# Cutover: stage any http/inline payloads to local files and rewrite the
|
||||||
# manifest to point at them, so the UNCHANGED engine installs from local
|
# manifest to point at them, so the UNCHANGED engine installs from local
|
||||||
# (no SMB needed for share-less PCs).
|
# (no SMB needed for share-less PCs).
|
||||||
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $sync.Path -Config $config
|
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
||||||
if ($manifestToRun -ne $sync.Path) {
|
if ($manifestToRun -ne $manifestToMerge) {
|
||||||
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- INTEGRATION POINT ---------------------------------------------------
|
# --- INTEGRATION POINT ---------------------------------------------------
|
||||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
||||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
||||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
# Installed / Skipped / Failed / Filtered
|
||||||
# to your engine's actual return/parse; the shape below is the contract.
|
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
||||||
|
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
||||||
|
# The engine may not honor that yet: it might return $null, a bare return
|
||||||
|
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
||||||
|
# returns into a well-formed summary hashtable so the report stage always
|
||||||
|
# gets clean input (we do NOT assume the engine was fixed).
|
||||||
Write-Log "Running engine against $manifestToRun"
|
Write-Log "Running engine against $manifestToRun"
|
||||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||||
if (-not $summary) {
|
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
||||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
|
||||||
}
|
|
||||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
|
||||||
|
|
||||||
# Report the result (best-effort).
|
# Report the result (best-effort).
|
||||||
$appliedVersion = 0
|
$appliedVersion = 0
|
||||||
|
|||||||
@@ -21,6 +21,43 @@
|
|||||||
|
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
function Set-ShopdbTls {
|
||||||
|
<#
|
||||||
|
Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
|
||||||
|
5.1 (what runs as SYSTEM on the display image) does not always negotiate
|
||||||
|
TLS 1.2 by default, so every network helper calls this first. Mirrors the
|
||||||
|
pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
|
||||||
|
#>
|
||||||
|
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ShopdbProperty {
|
||||||
|
<#
|
||||||
|
Read a property/key from either a hashtable or a PSCustomObject, matching
|
||||||
|
any of the given names case-insensitively. Returns $null when absent
|
||||||
|
instead of throwing under Set-StrictMode. The engine's per-entry outcomes
|
||||||
|
and summary may arrive as either shape, so all normalization goes through
|
||||||
|
this.
|
||||||
|
#>
|
||||||
|
param($InputObject, [Parameter(Mandatory)][string[]]$Names)
|
||||||
|
if ($null -eq $InputObject) { return $null }
|
||||||
|
if ($InputObject -is [System.Collections.IDictionary]) {
|
||||||
|
foreach ($wanted in $Names) {
|
||||||
|
foreach ($key in @($InputObject.Keys)) {
|
||||||
|
if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$properties = $InputObject.PSObject.Properties
|
||||||
|
foreach ($wanted in $Names) {
|
||||||
|
foreach ($property in $properties) {
|
||||||
|
if ($property.Name -ieq $wanted) { return $property.Value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
function Get-ShopdbConfig {
|
function Get-ShopdbConfig {
|
||||||
param([string]$BaseUrl, [string]$ApiToken)
|
param([string]$BaseUrl, [string]$ApiToken)
|
||||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||||
@@ -46,6 +83,7 @@ function Sync-ShopdbManifest {
|
|||||||
[Parameter(Mandatory)] [hashtable]$Config,
|
[Parameter(Mandatory)] [hashtable]$Config,
|
||||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||||
)
|
)
|
||||||
|
Set-ShopdbTls
|
||||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||||
@@ -80,7 +118,12 @@ function Sync-ShopdbManifest {
|
|||||||
if (Test-Path $manifestPath) {
|
if (Test-Path $manifestPath) {
|
||||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||||
}
|
}
|
||||||
return @{ Path = $null; Version = $null; Source = $null }
|
# Nothing cached (e.g. a fresh display): report why so the failure is not
|
||||||
|
# silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
|
||||||
|
# non-status transport error) and the exception message.
|
||||||
|
$errText = $_.Exception.Message
|
||||||
|
if ($status) { $errText = "HTTP $status - $errText" }
|
||||||
|
return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
|
||||||
}
|
}
|
||||||
# 304 without exception (some PS versions) -> use cache.
|
# 304 without exception (some PS versions) -> use cache.
|
||||||
if (Test-Path $manifestPath) {
|
if (Test-Path $manifestPath) {
|
||||||
@@ -123,6 +166,7 @@ function Send-ShopdbReport {
|
|||||||
#>
|
#>
|
||||||
param([Parameter(Mandatory)][hashtable]$Config,
|
param([Parameter(Mandatory)][hashtable]$Config,
|
||||||
[Parameter(Mandatory)][hashtable]$Report)
|
[Parameter(Mandatory)][hashtable]$Report)
|
||||||
|
Set-ShopdbTls
|
||||||
try {
|
try {
|
||||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||||
@@ -137,14 +181,31 @@ function Send-ShopdbReport {
|
|||||||
function New-ShopdbReport {
|
function New-ShopdbReport {
|
||||||
<#
|
<#
|
||||||
Build a report payload from an engine summary. `Summary` is expected to
|
Build a report payload from an engine summary. `Summary` is expected to
|
||||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
carry Installed/Skipped/Failed/Filtered counts and a Results list whose
|
||||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
per-entry outcomes carry name/action and optionally selfhealed/exitcode/
|
||||||
per-entry outcomes into this at the call site.
|
message. The engine emits these in PascalCase (Name/Action/SelfHealed/
|
||||||
|
ExitCode/Message); the shopdb report contract is entirely lowercase, so
|
||||||
|
this function maps every per-entry key down to lowercase. Case is matched
|
||||||
|
case-insensitively, so a caller that already lowercased still works.
|
||||||
#>
|
#>
|
||||||
param([string]$Hostname = $env:COMPUTERNAME,
|
param([string]$Hostname = $env:COMPUTERNAME,
|
||||||
[Parameter(Mandatory)][string]$Scope,
|
[Parameter(Mandatory)][string]$Scope,
|
||||||
[int]$AppliedVersion,
|
[int]$AppliedVersion,
|
||||||
[Parameter(Mandatory)][hashtable]$Summary)
|
[Parameter(Mandatory)][hashtable]$Summary)
|
||||||
|
$results = @(foreach ($entry in @($Summary.Results)) {
|
||||||
|
if ($null -eq $entry) { continue }
|
||||||
|
$mapped = @{
|
||||||
|
name = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
|
||||||
|
action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
|
||||||
|
}
|
||||||
|
$selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
|
||||||
|
if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
|
||||||
|
$exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
|
||||||
|
if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
|
||||||
|
$message = Get-ShopdbProperty -InputObject $entry -Names 'message'
|
||||||
|
if ($message) { $mapped['message'] = [string]$message }
|
||||||
|
$mapped
|
||||||
|
})
|
||||||
return @{
|
return @{
|
||||||
hostname = $Hostname
|
hostname = $Hostname
|
||||||
scopename = $Scope
|
scopename = $Scope
|
||||||
@@ -156,7 +217,7 @@ function New-ShopdbReport {
|
|||||||
failed = [int]$Summary.Failed
|
failed = [int]$Summary.Failed
|
||||||
filtered = [int]$Summary.Filtered
|
filtered = [int]$Summary.Filtered
|
||||||
}
|
}
|
||||||
results = @($Summary.Results)
|
results = $results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +234,7 @@ function Get-ShopdbPayload {
|
|||||||
[string]$Filename,
|
[string]$Filename,
|
||||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||||
)
|
)
|
||||||
|
Set-ShopdbTls
|
||||||
$sha = $Sha256.Trim().ToLower()
|
$sha = $Sha256.Trim().ToLower()
|
||||||
$payloadDir = Join-Path $CacheDir 'payloads'
|
$payloadDir = Join-Path $CacheDir 'payloads'
|
||||||
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
|
if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
|
||||||
@@ -241,6 +303,125 @@ function Resolve-ShopdbPayloads {
|
|||||||
return $out
|
return $out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Merge-ShopdbManifests {
|
||||||
|
<#
|
||||||
|
Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
|
||||||
|
manifest and write the merged result to a sibling file. This mirrors how
|
||||||
|
the real GE-Enforce.ps1 applies scopes in order (common first, then the
|
||||||
|
pctype), except we produce a single merged manifest for the unchanged
|
||||||
|
engine to run once. Merge rules:
|
||||||
|
|
||||||
|
- Entries are keyed by Name (case-insensitive).
|
||||||
|
- common's unique entries come first, then all pctype entries, so
|
||||||
|
common's own apps enforce ahead of the pctype's, matching the real
|
||||||
|
script's common-then-pctype ordering.
|
||||||
|
- On a Name conflict the PCTYPE entry wins (the pctype override replaces
|
||||||
|
common's version, and keeps common's slot out of the list).
|
||||||
|
|
||||||
|
Returns the merged manifest path. If there is no common manifest, returns
|
||||||
|
the primary path unchanged. The merged manifest keeps the pctype
|
||||||
|
manifest's top-level Version (that is the version the display reports as
|
||||||
|
applied).
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$PrimaryManifestPath,
|
||||||
|
[string]$CommonManifestPath
|
||||||
|
)
|
||||||
|
if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }
|
||||||
|
|
||||||
|
$primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
|
||||||
|
$common = Get-Content -LiteralPath $CommonManifestPath -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
$primaryApps = @($primary.Applications)
|
||||||
|
$commonApps = @($common.Applications)
|
||||||
|
|
||||||
|
$primaryNames = @{}
|
||||||
|
foreach ($app in $primaryApps) {
|
||||||
|
$name = [string]$app.Name
|
||||||
|
if ($name) { $primaryNames[$name.ToLower()] = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
# common entries the pctype does not override, then all pctype entries.
|
||||||
|
$merged = @()
|
||||||
|
foreach ($app in $commonApps) {
|
||||||
|
$name = [string]$app.Name
|
||||||
|
if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
|
||||||
|
$merged += $app
|
||||||
|
}
|
||||||
|
foreach ($app in $primaryApps) { $merged += $app }
|
||||||
|
|
||||||
|
$primary.Applications = $merged
|
||||||
|
$out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
|
||||||
|
($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
|
||||||
|
return $out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-ShopdbSummary {
|
||||||
|
<#
|
||||||
|
Normalize whatever the engine returns into a well-formed summary hashtable
|
||||||
|
so New-ShopdbReport always has clean input, without assuming the engine was
|
||||||
|
fixed.
|
||||||
|
|
||||||
|
Expected engine contract (what a compliant engine returns):
|
||||||
|
@{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
|
||||||
|
EnforcerVersion=<string>;
|
||||||
|
Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }
|
||||||
|
|
||||||
|
This adapter tolerates any of:
|
||||||
|
- $null / empty -> a zeroed summary.
|
||||||
|
- a hashtable or PSCustomObject with those keys (any casing).
|
||||||
|
- an array / multiple emitted objects -> the last element that looks
|
||||||
|
like a summary (has any count or a Results list) is used.
|
||||||
|
- a bare return code (int) or unrecognized object -> a zeroed summary.
|
||||||
|
|
||||||
|
Counts are coerced to int; a missing EnforcerVersion is left for the caller
|
||||||
|
to default. Always returns a hashtable.
|
||||||
|
#>
|
||||||
|
param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')
|
||||||
|
|
||||||
|
$zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
|
||||||
|
Results = @(); EnforcerVersion = $DefaultEnforcerVersion }
|
||||||
|
|
||||||
|
$candidate = $EngineResult
|
||||||
|
if ($candidate -is [System.Array]) {
|
||||||
|
$picked = $null
|
||||||
|
foreach ($item in $candidate) {
|
||||||
|
if ($null -eq $item) { continue }
|
||||||
|
$looksLikeSummary = $false
|
||||||
|
foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
|
||||||
|
if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
|
||||||
|
}
|
||||||
|
if ($looksLikeSummary) { $picked = $item }
|
||||||
|
}
|
||||||
|
$candidate = $picked
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $candidate) { return $zero }
|
||||||
|
# A bare return code (int) or any object without the expected members reads
|
||||||
|
# as all-null through Get-ShopdbProperty below, which yields the zeroed
|
||||||
|
# summary - exactly the fail-open behavior we want for a non-compliant engine.
|
||||||
|
$results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
|
||||||
|
$enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
|
||||||
|
if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }
|
||||||
|
|
||||||
|
$toInt = {
|
||||||
|
param($value)
|
||||||
|
$parsed = 0
|
||||||
|
if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Installed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
|
||||||
|
Skipped = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
|
||||||
|
Failed = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
|
||||||
|
Filtered = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
|
||||||
|
Results = @($results)
|
||||||
|
EnforcerVersion = $enforcerVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
|
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
|
||||||
Get-ShopdbPayload, Resolve-ShopdbPayloads
|
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
|
||||||
|
ConvertTo-ShopdbSummary
|
||||||
|
|||||||
264
plugins/geenforce/seed_display_scope.py
Normal file
264
plugins/geenforce/seed_display_scope.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""Author the gea-shopfloor-display runtime scope programmatically.
|
||||||
|
|
||||||
|
Displays are Intune/Entra-joined, credential-less kiosk PCs that pull their
|
||||||
|
manifest over HTTPS (no SMB share). The kiosk engine and the kiosk browser are
|
||||||
|
BAKED INTO THE DISPLAY IMAGE, so this scope does not ship any EXE payloads; it
|
||||||
|
heals POLICY / CONFIG drift only, plus one dispatcher that points the kiosk at
|
||||||
|
the right target for the display subtype.
|
||||||
|
|
||||||
|
What this scope contains:
|
||||||
|
- Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||||
|
relaunch policies from the imaging script 09-Setup-Display.ps1 (so a display
|
||||||
|
that loses those policies self-heals on the next enforce cycle without a
|
||||||
|
keyboard or mouse on site).
|
||||||
|
- One inline PS1 dispatcher that reads C:\\Enrollment\\display-type.txt and
|
||||||
|
launches the kiosk target for the subtype. The display-type -> target map is
|
||||||
|
a data-driven table (DISPLAY_TYPE_TARGETS) so the targets are easy to edit.
|
||||||
|
|
||||||
|
Self-sufficient: the display scope carries EVERYTHING a display enforces and
|
||||||
|
does NOT inherit the fleet-wide 'common' scope. Displays run the enforcer with
|
||||||
|
common-merge off (the client default), so common's SMB-backed fleet entries
|
||||||
|
never reach a share-less display. This keeps the display path simple and needs
|
||||||
|
no common-payload repackaging.
|
||||||
|
|
||||||
|
Authoring path mirrors how every other scope is created: build a manifest dict
|
||||||
|
and hand it to service.replace_scope_draft (the same call the import-share CLI
|
||||||
|
uses), then attach the inline dispatcher payload and optionally publish. Re-
|
||||||
|
running replace_scope_draft is an idempotent draft rebuild.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
from . import service
|
||||||
|
|
||||||
|
|
||||||
|
SCOPE_NAME = 'gea-shopfloor-display'
|
||||||
|
SCOPE_PHASE = 'runtime'
|
||||||
|
SCOPE_VERSION = '2.0'
|
||||||
|
|
||||||
|
# Edge kiosk relaunch policy key. Values below mirror 09-Setup-Display.ps1
|
||||||
|
# exactly so the imaging-time state and the enforced state never disagree.
|
||||||
|
EDGE_POLICY_PATH = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||||
|
RELAUNCH_WINDOW_JSON = (
|
||||||
|
'{"entries":[{"start":{"hour":2,"minute":0},"duration_mins":120}]}')
|
||||||
|
|
||||||
|
# Data-driven display-type -> kiosk target map. The value of
|
||||||
|
# C:\Enrollment\display-type.txt selects the row; the target is a route the
|
||||||
|
# kiosk browser opens against the local kiosk base URL. Edit here to retarget a
|
||||||
|
# subtype. Keys are matched case-insensitively by the dispatcher.
|
||||||
|
#
|
||||||
|
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
|
||||||
|
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
|
||||||
|
# before this scope is published to production displays.
|
||||||
|
DISPLAY_TYPE_TARGETS = {
|
||||||
|
'Dashboard': '/shopfloor',
|
||||||
|
'Lobby': '/tv',
|
||||||
|
'3DPrintRoom': '/parts-kiosk',
|
||||||
|
}
|
||||||
|
|
||||||
|
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
|
||||||
|
|
||||||
|
|
||||||
|
def _relaunch_window_targets_comment():
|
||||||
|
"""Human note that lists the data-driven targets, for the manifest comment."""
|
||||||
|
pairs = ', '.join(f'{name}={route}'
|
||||||
|
for name, route in DISPLAY_TYPE_TARGETS.items())
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def build_dispatcher_script():
|
||||||
|
"""Return the inline dispatcher PowerShell as text.
|
||||||
|
|
||||||
|
The display-type -> target map is emitted as a hashtable at the top of the
|
||||||
|
script (generated from DISPLAY_TYPE_TARGETS) so the on-PC script and the
|
||||||
|
manifest metadata agree and both stay easy to edit.
|
||||||
|
"""
|
||||||
|
table_lines = []
|
||||||
|
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||||
|
table_lines.append(f" '{display_type}' = '{route}'")
|
||||||
|
table_body = ';\n'.join(table_lines)
|
||||||
|
|
||||||
|
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
|
||||||
|
#
|
||||||
|
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the
|
||||||
|
# route mapped for that display subtype. The kiosk browser is baked into the
|
||||||
|
# display image; this script only points it at the right target.
|
||||||
|
#
|
||||||
|
# The DisplayTypeTargets table below is the single source of truth for the
|
||||||
|
# subtype -> route map. Edit a row to retarget a subtype.
|
||||||
|
#
|
||||||
|
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
|
||||||
|
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
# --- Data-driven subtype -> kiosk route map ---
|
||||||
|
$DisplayTypeTargets = @{{
|
||||||
|
{table_body}
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Base URL the kiosk browser opens; the route from the table is appended. Edit
|
||||||
|
# to point at this site's shopdb host. Kept here so the map above stays pure.
|
||||||
|
$KioskBaseUrl = 'https://localhost'
|
||||||
|
|
||||||
|
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
|
||||||
|
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
|
||||||
|
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
|
||||||
|
return
|
||||||
|
}}
|
||||||
|
|
||||||
|
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($displayType)) {{
|
||||||
|
Write-Host 'display-type.txt is empty; nothing to launch.'
|
||||||
|
return
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
|
||||||
|
$matchedKey = $DisplayTypeTargets.Keys |
|
||||||
|
Where-Object {{ $_ -ieq $displayType }} |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $matchedKey) {{
|
||||||
|
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
|
||||||
|
return
|
||||||
|
}}
|
||||||
|
|
||||||
|
$targetRoute = $DisplayTypeTargets[$matchedKey]
|
||||||
|
$kioskUrl = "$KioskBaseUrl$targetRoute"
|
||||||
|
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
|
||||||
|
|
||||||
|
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
|
||||||
|
# so an enforce cycle does not relaunch the kiosk every run.
|
||||||
|
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
|
||||||
|
if ($alreadyRunning) {{
|
||||||
|
Write-Host 'Kiosk already running for this target; leaving it in place.'
|
||||||
|
return
|
||||||
|
}}
|
||||||
|
|
||||||
|
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
|
||||||
|
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
|
||||||
|
Write-Host 'Launched kiosk browser.'
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
|
||||||
|
"""One Type=Registry entry that writes a value and detects drift via
|
||||||
|
ValueMatches against that same path/name.
|
||||||
|
|
||||||
|
DetectionValue is stored as a string; the engine string-coerces for
|
||||||
|
ValueMatches, so a DWord value of 2 detects against '2'.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'_comment': comment,
|
||||||
|
'Name': name,
|
||||||
|
'Type': 'Registry',
|
||||||
|
'RegPath': EDGE_POLICY_PATH,
|
||||||
|
'RegName': regname,
|
||||||
|
'RegValue': regvalue,
|
||||||
|
'RegType': regtype,
|
||||||
|
'DetectionMethod': 'ValueMatches',
|
||||||
|
'DetectionPath': EDGE_POLICY_PATH,
|
||||||
|
'DetectionName': regname,
|
||||||
|
'DetectionValue': str(regvalue),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_display_manifest():
|
||||||
|
"""Return the gea-shopfloor-display manifest dict (Applications in order).
|
||||||
|
|
||||||
|
Four Edge kiosk relaunch-policy drift-heal entries, then the one dispatcher
|
||||||
|
entry. The dispatcher is declared PayloadSource=inline with no hash yet; the
|
||||||
|
seed attaches the real payload bytes (and its sha256) after the draft rows
|
||||||
|
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
|
||||||
|
into the display image, not shipped over HTTPS.
|
||||||
|
"""
|
||||||
|
applications = [
|
||||||
|
_registry_drift_heal_entry(
|
||||||
|
'Edge kiosk RelaunchNotification (Required auto-restart)',
|
||||||
|
'RelaunchNotification', 2, 'DWord',
|
||||||
|
'RelaunchNotification=2 (Required): Edge auto-restarts after the '
|
||||||
|
'notification period. Displays have no operator to dismiss the '
|
||||||
|
'update dialog, so this is the only mode that recovers unattended. '
|
||||||
|
'Heals drift of the policy set at imaging by 09-Setup-Display.ps1.'),
|
||||||
|
_registry_drift_heal_entry(
|
||||||
|
'Edge kiosk RelaunchNotificationPeriod (1 hour)',
|
||||||
|
'RelaunchNotificationPeriod', 3600000, 'DWord',
|
||||||
|
'Milliseconds before the forced auto-restart. 3600000 ms = 1 hour.'),
|
||||||
|
_registry_drift_heal_entry(
|
||||||
|
'Edge kiosk RelaunchHeadsUpPeriod (1 minute)',
|
||||||
|
'RelaunchHeadsUpPeriod', 60000, 'DWord',
|
||||||
|
'Milliseconds of final warning before auto-restart. 60000 ms = 1 '
|
||||||
|
'minute.'),
|
||||||
|
_registry_drift_heal_entry(
|
||||||
|
'Edge kiosk RelaunchWindow (02:00-04:00)',
|
||||||
|
'RelaunchWindow', RELAUNCH_WINDOW_JSON, 'String',
|
||||||
|
'Overnight forced-restart window (02:00 start, 120 minute '
|
||||||
|
'duration) so business-hour updates wait until off-hours and the '
|
||||||
|
'dialog stays invisible during the day.'),
|
||||||
|
{
|
||||||
|
'_comment': (
|
||||||
|
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
|
||||||
|
'launches the kiosk target for the subtype. Data-driven map: '
|
||||||
|
+ _relaunch_window_targets_comment()
|
||||||
|
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
|
||||||
|
'TODO-confirm the real target. Delivered inline over HTTPS '
|
||||||
|
'(share-less displays); DetectionMethod Always so it re-asserts '
|
||||||
|
'each cycle, but the script is idempotent (skips if the kiosk is '
|
||||||
|
'already serving the target URL).'),
|
||||||
|
'Name': 'Display kiosk dispatcher (display-type.txt)',
|
||||||
|
'Type': 'PS1',
|
||||||
|
'Script': DISPATCHER_FILENAME,
|
||||||
|
'PayloadSource': 'inline',
|
||||||
|
'PayloadRef': DISPATCHER_FILENAME,
|
||||||
|
'DetectionMethod': 'Always',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
'Version': SCOPE_VERSION,
|
||||||
|
'_comment': (
|
||||||
|
'gea-shopfloor-display runtime scope. Heals Edge kiosk relaunch '
|
||||||
|
'policy drift and dispatches the kiosk to the subtype target. No '
|
||||||
|
'EXE payloads: kiosk engine and browser are baked into the display '
|
||||||
|
'image. Self-sufficient: displays do NOT inherit the common '
|
||||||
|
'scope.'),
|
||||||
|
'Applications': applications,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
|
||||||
|
"""Create/refresh the gea-shopfloor-display draft scope and its entries.
|
||||||
|
|
||||||
|
Idempotent for the draft: replace_scope_draft rebuilds the draft rows, and
|
||||||
|
the inline dispatcher payload is content-addressed (a re-run stores the same
|
||||||
|
bytes to the same sha256). Set publish=True to also freeze a published
|
||||||
|
snapshot (that step is NOT idempotent: it always creates a new version).
|
||||||
|
|
||||||
|
Commits the session. Returns a summary dict:
|
||||||
|
{scopeid, entrycount, entrytypes, dispatchersha256, publishedversion}.
|
||||||
|
"""
|
||||||
|
manifest = build_display_manifest()
|
||||||
|
scope = service.replace_scope_draft(SCOPE_NAME, SCOPE_PHASE, manifest)
|
||||||
|
# Flush so the new entries get entryids before the inline payload attaches.
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
dispatcher = next(entry for entry in scope.entries
|
||||||
|
if entry.name == 'Display kiosk dispatcher (display-type.txt)')
|
||||||
|
scriptbytes = build_dispatcher_script().encode('utf-8')
|
||||||
|
payload = service.store_inline_payload(
|
||||||
|
dispatcher, DISPATCHER_FILENAME,
|
||||||
|
'text/plain; charset=utf-8', scriptbytes)
|
||||||
|
|
||||||
|
publishedversion = None
|
||||||
|
if publish:
|
||||||
|
publishedversion = service.publish_scope(
|
||||||
|
SCOPE_NAME, SCOPE_PHASE, notes=notes)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scopeid': scope.scopeid,
|
||||||
|
'entrycount': len(scope.entries),
|
||||||
|
'entrytypes': [entry.entrytype for entry in scope.entries],
|
||||||
|
'dispatchersha256': payload.payloadsha256,
|
||||||
|
'publishedversion': publishedversion,
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -288,6 +289,34 @@ def blob_path(sha256):
|
|||||||
return os.path.join(_payload_dir(), sha256)
|
return os.path.join(_payload_dir(), sha256)
|
||||||
|
|
||||||
|
|
||||||
|
def blob_referenced_by_scopes(sha256, scopenames):
|
||||||
|
"""True when any of these scopes' CURRENT published manifest references
|
||||||
|
sha256 as an entry payload.
|
||||||
|
|
||||||
|
Backs the resource-bound token check on GET /payload: a token pinned to its
|
||||||
|
own scope(s) may only pull blobs those scopes actually ship, not any blob by
|
||||||
|
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
|
||||||
|
"""
|
||||||
|
if not scopenames:
|
||||||
|
return False
|
||||||
|
rows = db.session.query(ManifestPublishedVersion).join(
|
||||||
|
ManifestScope,
|
||||||
|
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
|
||||||
|
).filter(
|
||||||
|
ManifestScope.scopename.in_(list(scopenames)),
|
||||||
|
ManifestPublishedVersion.iscurrent == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
doc = json.loads(row.manifestjson)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
for entry in doc.get('Applications', []) or []:
|
||||||
|
if entry.get('PayloadSha256') == sha256:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def store_blob(rawbytes, filename, contenttype=None):
|
def store_blob(rawbytes, filename, contenttype=None):
|
||||||
"""Store bytes in the content-addressed payload store; return the sha256.
|
"""Store bytes in the content-addressed payload store; return the sha256.
|
||||||
|
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ def _alert_team_webhook():
|
|||||||
if not team_id:
|
if not team_id:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from shopdb.core.models import SupportTeam
|
from shopdb.api import SupportTeam
|
||||||
team = db.session.get(SupportTeam, int(team_id))
|
team = db.session.get(SupportTeam, int(team_id))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export default [
|
|||||||
component: () => import('./views/ZabbixSettings.vue'),
|
component: () => import('./views/ZabbixSettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/printer-alerts',
|
||||||
|
name: 'printer-alerts',
|
||||||
|
component: () => import('./views/PrinterAlertsSettings.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'printers',
|
path: 'printers',
|
||||||
name: 'printers',
|
name: 'printers',
|
||||||
|
|||||||
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
192
plugins/printers/frontend/views/PrinterAlertsSettings.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Low-Toner Alerts</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card form-card">
|
||||||
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
|
||||||
|
<p class="field-hint">
|
||||||
|
A scheduled poll checks Zabbix toner levels. A warning email fires when
|
||||||
|
a toner crosses to the warning threshold or below, a critical email at
|
||||||
|
the critical threshold. Alerts fire once per crossing; a refill re-arms
|
||||||
|
them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Warning threshold (% remaining)</label>
|
||||||
|
<input v-model.number="values.printers_alert_warning_threshold"
|
||||||
|
type="number" min="0" max="100" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Critical threshold (% remaining)</label>
|
||||||
|
<input v-model.number="values.printers_alert_critical_threshold"
|
||||||
|
type="number" min="0" max="100" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Alert shopdb users</label>
|
||||||
|
<div class="user-picker">
|
||||||
|
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||||
|
<input type="checkbox" :value="String(candidate.userid)"
|
||||||
|
v-model="selectedUserids" />
|
||||||
|
<span>{{ candidate.username }}</span>
|
||||||
|
<span class="user-email">{{ candidate.email }}</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
Selected users receive low-toner alerts at their account email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Alert roles</label>
|
||||||
|
<div class="user-picker">
|
||||||
|
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||||
|
<input type="checkbox" :value="String(role.roleid)"
|
||||||
|
v-model="selectedRoleids" />
|
||||||
|
<span>{{ role.rolename }}</span>
|
||||||
|
<span class="user-email">{{ role.description }}</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
Every active member of a selected role receives low-toner alerts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Additional alert emails</label>
|
||||||
|
<input v-model="values.printers_alert_email" type="text"
|
||||||
|
class="form-control" placeholder="print-team@example.com, lead@example.com" />
|
||||||
|
<p class="field-hint">
|
||||||
|
Comma-separated. Empty uses the site-wide alert recipients
|
||||||
|
(Settings > System > Email).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Alert support team (Teams webhook)</label>
|
||||||
|
<select v-model="values.printers_alert_supportteamid" class="form-control">
|
||||||
|
<option value="">Site default webhook</option>
|
||||||
|
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
||||||
|
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="field-hint">
|
||||||
|
Low-toner alerts post to this team's webhook (set on Settings >
|
||||||
|
Support Teams). Empty uses the site-wide alert webhook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
'printers_alert_email',
|
||||||
|
'printers_alert_userids',
|
||||||
|
'printers_alert_roleids',
|
||||||
|
'printers_alert_supportteamid',
|
||||||
|
'printers_alert_warning_threshold',
|
||||||
|
'printers_alert_critical_threshold'
|
||||||
|
]
|
||||||
|
|
||||||
|
const values = ref({
|
||||||
|
printers_alert_email: '',
|
||||||
|
printers_alert_userids: '',
|
||||||
|
printers_alert_roleids: '',
|
||||||
|
printers_alert_supportteamid: '',
|
||||||
|
printers_alert_warning_threshold: 5,
|
||||||
|
printers_alert_critical_threshold: 0
|
||||||
|
})
|
||||||
|
const supportTeams = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const selectedUserids = ref([])
|
||||||
|
const roles = ref([])
|
||||||
|
const selectedRoleids = ref([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.list({ category: 'printers' })
|
||||||
|
const rows = response.data.data || []
|
||||||
|
for (const row of rows) {
|
||||||
|
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||||
|
}
|
||||||
|
values.value.printers_alert_warning_threshold =
|
||||||
|
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
|
||||||
|
values.value.printers_alert_critical_threshold =
|
||||||
|
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
|
||||||
|
selectedUserids.value = (values.value.printers_alert_userids || '')
|
||||||
|
.split(',').map(id => id.trim()).filter(Boolean)
|
||||||
|
const usersResponse = await usersApi.list()
|
||||||
|
users.value = (usersResponse.data.data || []).filter(
|
||||||
|
candidate => candidate.isactive && candidate.email)
|
||||||
|
selectedRoleids.value = (values.value.printers_alert_roleids || '')
|
||||||
|
.split(',').map(id => id.trim()).filter(Boolean)
|
||||||
|
const rolesResponse = await usersApi.roles.list()
|
||||||
|
roles.value = rolesResponse.data.data || []
|
||||||
|
const teamsResponse = await supportteamsApi.list()
|
||||||
|
supportTeams.value = teamsResponse.data.data || []
|
||||||
|
} catch (loadError) {
|
||||||
|
error.value = 'Could not load settings'
|
||||||
|
console.error(loadError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
message.value = ''
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
values.value.printers_alert_userids = selectedUserids.value.join(',')
|
||||||
|
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
|
||||||
|
for (const key of KEYS) {
|
||||||
|
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||||
|
}
|
||||||
|
message.value = 'Settings saved'
|
||||||
|
} catch (saveError) {
|
||||||
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||||
|
.form-row { display: flex; gap: 1rem; }
|
||||||
|
.form-row .form-group { flex: 1; }
|
||||||
|
.user-picker {
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""printers: printersupplyalerts table (toner alert crossing state).
|
||||||
|
|
||||||
|
Stores the last tier alerted per (printer, supply) so the poller fires once
|
||||||
|
on a downward crossing and re-arms after a refill. Idempotent create.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'printers0002supplyalerts'
|
||||||
|
down_revision = 'printers0001anchor'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'printersupplyalerts' in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
'printersupplyalerts',
|
||||||
|
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('printerid', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('supplykey', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('lasttier', sa.String(length=16), nullable=False,
|
||||||
|
server_default='ok'),
|
||||||
|
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.true()),
|
||||||
|
sa.ForeignKeyConstraint(['printerid'], ['printers.printerid'],
|
||||||
|
ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('printerid', 'supplykey',
|
||||||
|
name='uq_printersupplyalert_printer_key'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_printersupplyalerts_printerid',
|
||||||
|
'printersupplyalerts', ['printerid'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'printersupplyalerts' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.drop_table('printersupplyalerts')
|
||||||
@@ -8,12 +8,14 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
|||||||
SUPPLY_COLORS,
|
SUPPLY_COLORS,
|
||||||
CAPACITY_TIERS,
|
CAPACITY_TIERS,
|
||||||
)
|
)
|
||||||
|
from .supply_alert import PrinterSupplyAlert # per-printer toner alert state
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Printer',
|
'Printer',
|
||||||
'PrinterType',
|
'PrinterType',
|
||||||
'PrinterDriver',
|
'PrinterDriver',
|
||||||
'ModelSupply',
|
'ModelSupply',
|
||||||
|
'PrinterSupplyAlert',
|
||||||
'SUPPLY_TYPES',
|
'SUPPLY_TYPES',
|
||||||
'SUPPLY_COLORS',
|
'SUPPLY_COLORS',
|
||||||
'CAPACITY_TIERS',
|
'CAPACITY_TIERS',
|
||||||
|
|||||||
40
plugins/printers/models/supply_alert.py
Normal file
40
plugins/printers/models/supply_alert.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Per-printer supply alert state.
|
||||||
|
|
||||||
|
The supply poller (flask printers check-toner-alerts) is stateless: it reads
|
||||||
|
live levels from Zabbix each run. To alert once per downward crossing (and
|
||||||
|
re-arm after a refill) it needs to remember the last tier it alerted for each
|
||||||
|
printer + supply. One row per (printerid, supplykey); supplykey is the toner
|
||||||
|
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.api import db, BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterSupplyAlert(BaseModel):
|
||||||
|
"""Last-alerted tier for one printer supply. lasttier in ok/warning/critical."""
|
||||||
|
|
||||||
|
__tablename__ = 'printersupplyalerts'
|
||||||
|
|
||||||
|
printersupplyalertid = db.Column(db.Integer, primary_key=True)
|
||||||
|
printerid = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('printers.printerid', ondelete='CASCADE'),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
supplykey = db.Column(
|
||||||
|
db.String(64),
|
||||||
|
nullable=False,
|
||||||
|
comment='Toner color, or Zabbix item name when color is unknown',
|
||||||
|
)
|
||||||
|
lasttier = db.Column(
|
||||||
|
db.String(16),
|
||||||
|
nullable=False,
|
||||||
|
default='ok',
|
||||||
|
comment='Last tier alerted: ok, warning, or critical',
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('printerid', 'supplykey',
|
||||||
|
name='uq_printersupplyalert_printer_key'),
|
||||||
|
)
|
||||||
@@ -9,9 +9,11 @@ from flask import Flask, Blueprint
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||||
from shopdb.api import db, AssetType
|
from shopdb.api import db, AssetType, Setting
|
||||||
|
|
||||||
from .models import Printer, PrinterType, ModelSupply, PrinterDriver
|
from .models import (
|
||||||
|
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
||||||
|
)
|
||||||
from .api import printers_asset_bp
|
from .api import printers_asset_bp
|
||||||
from .services import ZabbixService
|
from .services import ZabbixService
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ class PrintersPlugin(BasePlugin):
|
|||||||
PrinterType, # printer type classification
|
PrinterType, # printer type classification
|
||||||
PrinterDriver, # driver links (SMB/HTTP)
|
PrinterDriver, # driver links (SMB/HTTP)
|
||||||
ModelSupply, # model -> toner/drum/waste part numbers
|
ModelSupply, # model -> toner/drum/waste part numbers
|
||||||
|
PrinterSupplyAlert, # per-printer toner alert crossing state
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_services(self) -> Dict[str, Type]:
|
def get_services(self) -> Dict[str, Type]:
|
||||||
@@ -103,8 +106,41 @@ class PrintersPlugin(BasePlugin):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
self._ensure_asset_type()
|
self._ensure_asset_type()
|
||||||
self._ensure_printer_types()
|
self._ensure_printer_types()
|
||||||
|
self._seed_settings()
|
||||||
logger.info("Printers plugin installed")
|
logger.info("Printers plugin installed")
|
||||||
|
|
||||||
|
def on_enable(self, app: Flask) -> None:
|
||||||
|
# Idempotent re-seed so settings added in later versions reach sites
|
||||||
|
# that installed earlier (enable runs on every upgrade cycle).
|
||||||
|
with app.app_context():
|
||||||
|
self._seed_settings()
|
||||||
|
|
||||||
|
def _seed_settings(self) -> None:
|
||||||
|
"""Seed low-toner alert recipient settings (idempotent)."""
|
||||||
|
defaults = [
|
||||||
|
('printers_alert_email', '', 'string',
|
||||||
|
'Comma-separated low-toner alert recipients; empty uses the '
|
||||||
|
'site alert_recipients'),
|
||||||
|
('printers_alert_userids', '', 'string',
|
||||||
|
'Comma-separated shopdb user ids whose account emails receive '
|
||||||
|
'low-toner alerts'),
|
||||||
|
('printers_alert_roleids', '', 'string',
|
||||||
|
'Comma-separated role ids; every active member of these roles '
|
||||||
|
'receives low-toner alerts'),
|
||||||
|
('printers_alert_supportteamid', '', 'string',
|
||||||
|
'Support team whose webhook receives low-toner alerts; empty '
|
||||||
|
'uses the site alert_webhook_url'),
|
||||||
|
('printers_alert_warning_threshold', '5', 'integer',
|
||||||
|
'Toner percent remaining at or below which a warning email fires'),
|
||||||
|
('printers_alert_critical_threshold', '0', 'integer',
|
||||||
|
'Toner percent remaining at or below which a critical email fires'),
|
||||||
|
]
|
||||||
|
for key, value, valuetype, description in defaults:
|
||||||
|
if Setting.get(key) is None:
|
||||||
|
Setting.set(key, value, valuetype=valuetype,
|
||||||
|
category='printers', description=description)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
def _ensure_asset_type(self) -> None:
|
def _ensure_asset_type(self) -> None:
|
||||||
"""Ensure printer asset type exists."""
|
"""Ensure printer asset type exists."""
|
||||||
existing = AssetType.query.filter_by(assettype='printer').first()
|
existing = AssetType.query.filter_by(assettype='printer').first()
|
||||||
@@ -151,6 +187,19 @@ class PrintersPlugin(BasePlugin):
|
|||||||
"""Called when plugin is uninstalled."""
|
"""Called when plugin is uninstalled."""
|
||||||
logger.info("Printers plugin uninstalled")
|
logger.info("Printers plugin uninstalled")
|
||||||
|
|
||||||
|
def get_settings_cards(self) -> List[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'group': 'Printers',
|
||||||
|
'to': '/settings/printer-alerts',
|
||||||
|
'icon': 'bell',
|
||||||
|
'title': 'Low-Toner Alerts',
|
||||||
|
'description': 'Who gets warning (5%) and critical (0%) toner '
|
||||||
|
'emails, and which support team webhook',
|
||||||
|
'position': 48,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_cli_commands(self) -> List:
|
def get_cli_commands(self) -> List:
|
||||||
"""Return CLI commands for this plugin."""
|
"""Return CLI commands for this plugin."""
|
||||||
|
|
||||||
@@ -159,6 +208,22 @@ class PrintersPlugin(BasePlugin):
|
|||||||
"""Printers plugin commands."""
|
"""Printers plugin commands."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@printerscli.command('check-toner-alerts')
|
||||||
|
def checktoneralerts():
|
||||||
|
"""Poll Zabbix for all printers and email/webhook low-toner crossings.
|
||||||
|
|
||||||
|
Run on a schedule (scheduled task / cron). Fires a warning at or
|
||||||
|
below 5 percent and a critical at 0 percent, once per crossing."""
|
||||||
|
from flask import current_app
|
||||||
|
from .services import check_supplies
|
||||||
|
|
||||||
|
with current_app.app_context():
|
||||||
|
summary = check_supplies()
|
||||||
|
click.echo(
|
||||||
|
f"Toner poll: {summary['polled']}/{summary['printers']} "
|
||||||
|
f"printers reachable, {summary['alerts']} alert(s) sent, "
|
||||||
|
f"{summary['rearmed']} re-armed.")
|
||||||
|
|
||||||
@printerscli.command('check-supplies')
|
@printerscli.command('check-supplies')
|
||||||
@click.argument('ip')
|
@click.argument('ip')
|
||||||
def checksupplies(ip):
|
def checksupplies(ip):
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from .supply_parts import (
|
|||||||
derivesupplytype,
|
derivesupplytype,
|
||||||
derivecolor,
|
derivecolor,
|
||||||
lookupsupplies,
|
lookupsupplies,
|
||||||
|
alerttier,
|
||||||
)
|
)
|
||||||
|
from .supply_alerts import check_supplies
|
||||||
from .seed_supplies import seedsupplies
|
from .seed_supplies import seedsupplies
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -15,5 +17,7 @@ __all__ = [
|
|||||||
'derivesupplytype',
|
'derivesupplytype',
|
||||||
'derivecolor',
|
'derivecolor',
|
||||||
'lookupsupplies',
|
'lookupsupplies',
|
||||||
|
'alerttier',
|
||||||
|
'check_supplies',
|
||||||
'seedsupplies',
|
'seedsupplies',
|
||||||
]
|
]
|
||||||
|
|||||||
190
plugins/printers/services/supply_alerts.py
Normal file
190
plugins/printers/services/supply_alerts.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Low-toner email/webhook alerting.
|
||||||
|
|
||||||
|
The report shows live levels; this fires an alert when a toner crosses a
|
||||||
|
threshold on its way DOWN. A warning email fires at or below 5 percent, a
|
||||||
|
critical email at 0 percent (empty). State lives in printersupplyalerts so an
|
||||||
|
alert fires once per crossing and re-arms only after a refill.
|
||||||
|
|
||||||
|
Recipients mirror the printedparts pattern: a plugin-scoped set of shopdb
|
||||||
|
users + roles + a free-text email list, falling back to the site-wide
|
||||||
|
alert_recipients; the webhook routes to a chosen support team's webhook,
|
||||||
|
falling back to the site-wide alert_webhook_url.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from shopdb.api import db, Setting
|
||||||
|
|
||||||
|
from ..models import Printer, PrinterSupplyAlert
|
||||||
|
from .supply_parts import derivesupplytype, derivecolor, alerttier, TIER_RANK
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def alert_recipients():
|
||||||
|
"""Merge selected shopdb users' account emails with the free-text list.
|
||||||
|
|
||||||
|
Empty result means fall back to the site-wide alert_recipients."""
|
||||||
|
from shopdb.api import User, Role
|
||||||
|
recipients = []
|
||||||
|
userids = (Setting.get('printers_alert_userids') or '').strip()
|
||||||
|
for rawid in userids.split(','):
|
||||||
|
rawid = rawid.strip()
|
||||||
|
if not rawid.isdigit():
|
||||||
|
continue
|
||||||
|
user = db.session.get(User, int(rawid))
|
||||||
|
if user and user.isactive and user.email:
|
||||||
|
recipients.append(user.email)
|
||||||
|
roleids = (Setting.get('printers_alert_roleids') or '').strip()
|
||||||
|
for rawid in roleids.split(','):
|
||||||
|
rawid = rawid.strip()
|
||||||
|
if not rawid.isdigit():
|
||||||
|
continue
|
||||||
|
role = db.session.get(Role, int(rawid))
|
||||||
|
if role:
|
||||||
|
for user in role.users:
|
||||||
|
if user.isactive and user.email:
|
||||||
|
recipients.append(user.email)
|
||||||
|
freetext = Setting.get('printers_alert_email') or ''
|
||||||
|
for addr in freetext.replace(';', ',').split(','):
|
||||||
|
addr = addr.strip()
|
||||||
|
if addr:
|
||||||
|
recipients.append(addr)
|
||||||
|
# de-dupe, preserve order
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for addr in recipients:
|
||||||
|
low = addr.lower()
|
||||||
|
if low not in seen:
|
||||||
|
seen.add(low)
|
||||||
|
unique.append(addr)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _threshold(key, default):
|
||||||
|
"""Read a numeric threshold setting, tolerating blank/garbage values."""
|
||||||
|
try:
|
||||||
|
raw = Setting.get(key)
|
||||||
|
return float(raw) if raw not in (None, '') else float(default)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
|
||||||
|
def site_alert_recipients():
|
||||||
|
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
|
||||||
|
raw = Setting.get('alert_recipients') or ''
|
||||||
|
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def alert_team_webhook():
|
||||||
|
"""Webhook URL of the support team chosen for toner alerts, or None.
|
||||||
|
|
||||||
|
printers_alert_supportteamid selects a SupportTeam; alerts route to that
|
||||||
|
team's webhook. None -> send_webhook uses the site-wide default."""
|
||||||
|
team_id = Setting.get('printers_alert_supportteamid')
|
||||||
|
if not team_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from shopdb.api import SupportTeam
|
||||||
|
team = db.session.get(SupportTeam, int(team_id))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return (team.webhookurl or None) if team else None
|
||||||
|
|
||||||
|
|
||||||
|
def _printer_ipaddress(printer):
|
||||||
|
"""Primary (else any) communication IP for a printer's asset, or None."""
|
||||||
|
from shopdb.api import Communication
|
||||||
|
asset = printer.asset
|
||||||
|
if not asset:
|
||||||
|
return None
|
||||||
|
comm = Communication.query.filter_by(
|
||||||
|
assetid=asset.assetid, isprimary=True).first() \
|
||||||
|
or Communication.query.filter_by(assetid=asset.assetid).first()
|
||||||
|
return comm.ipaddress if comm else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_alert(printer, color, remaining, tier):
|
||||||
|
"""Best-effort email + webhook for one toner crossing. Never raises."""
|
||||||
|
from shopdb.api import send_email, send_webhook
|
||||||
|
name = printer.asset.name or printer.asset.assetnumber if printer.asset \
|
||||||
|
else f'Printer {printer.printerid}'
|
||||||
|
label = 'CRITICAL - empty' if tier == 'critical' else 'Low'
|
||||||
|
subject = f'{label} toner: {name} ({color}) - {remaining:.0f}% remaining'
|
||||||
|
base = (Setting.get('site_base_url') or '').rstrip('/')
|
||||||
|
link = f'{base}/printers/{printer.printerid}' if base else ''
|
||||||
|
link_html = f'<p><a href="{link}">View {name}</a></p>' if link else ''
|
||||||
|
html = (f'<p><strong>{name}</strong> {color} toner is at '
|
||||||
|
f'<strong>{remaining:.0f}%</strong> ({tier}).</p>{link_html}')
|
||||||
|
webhook_text = f'{name} {color} toner at {remaining:.0f}% ({tier})'
|
||||||
|
try:
|
||||||
|
send_webhook(subject, webhook_text, url=alert_team_webhook())
|
||||||
|
recipients = alert_recipients() or site_alert_recipients()
|
||||||
|
if recipients:
|
||||||
|
send_email(recipients, subject, html)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Toner alert failed for printer %s', printer.printerid)
|
||||||
|
|
||||||
|
|
||||||
|
def check_supplies(service=None):
|
||||||
|
"""Poll Zabbix for all active printers' toner and alert on crossings.
|
||||||
|
|
||||||
|
Returns a summary dict. Intended to run on a schedule (a scheduled task or
|
||||||
|
cron calling `flask printers check-toner-alerts`)."""
|
||||||
|
from shopdb.api import Asset
|
||||||
|
from .zabbix_service import ZabbixService
|
||||||
|
|
||||||
|
service = service or ZabbixService()
|
||||||
|
summary = {'printers': 0, 'polled': 0, 'alerts': 0, 'rearmed': 0}
|
||||||
|
if not service.isconfigured:
|
||||||
|
logger.warning('Zabbix not configured; skipping toner alert poll')
|
||||||
|
return summary
|
||||||
|
|
||||||
|
warning_threshold = _threshold('printers_alert_warning_threshold', 5)
|
||||||
|
critical_threshold = _threshold('printers_alert_critical_threshold', 0)
|
||||||
|
|
||||||
|
printers = db.session.query(Printer).join(Asset).filter(
|
||||||
|
Asset.isactive == True).all() # noqa: E712
|
||||||
|
for printer in printers:
|
||||||
|
summary['printers'] += 1
|
||||||
|
ipaddress = _printer_ipaddress(printer)
|
||||||
|
if not ipaddress or ipaddress == 'USB':
|
||||||
|
continue
|
||||||
|
supplies = service.getsuppliesbyip(ipaddress)
|
||||||
|
if not supplies:
|
||||||
|
continue
|
||||||
|
summary['polled'] += 1
|
||||||
|
for supply in supplies:
|
||||||
|
supplyname = supply.get('name', '')
|
||||||
|
if derivesupplytype(supplyname) != 'toner':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
remaining = float(supply.get('level', 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
color = supply.get('color') or derivecolor(supplyname)
|
||||||
|
supplykey = color if color and color != 'none' else supplyname
|
||||||
|
supplykey = supplykey[:64]
|
||||||
|
tier = alerttier(remaining, warning_threshold, critical_threshold)
|
||||||
|
|
||||||
|
row = PrinterSupplyAlert.query.filter_by(
|
||||||
|
printerid=printer.printerid, supplykey=supplykey).first()
|
||||||
|
prior = row.lasttier if row else 'ok'
|
||||||
|
if tier == prior:
|
||||||
|
continue
|
||||||
|
if row is None:
|
||||||
|
row = PrinterSupplyAlert(
|
||||||
|
printerid=printer.printerid, supplykey=supplykey,
|
||||||
|
lasttier=tier)
|
||||||
|
db.session.add(row)
|
||||||
|
else:
|
||||||
|
row.lasttier = tier
|
||||||
|
|
||||||
|
if TIER_RANK[tier] > TIER_RANK[prior]:
|
||||||
|
_fire_alert(printer, color, remaining, tier)
|
||||||
|
summary['alerts'] += 1
|
||||||
|
else:
|
||||||
|
summary['rearmed'] += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return summary
|
||||||
@@ -10,10 +10,34 @@ and reading the matching part numbers out of the database.
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
# alert thresholds (percent remaining)
|
# display thresholds (percent remaining) for the ok/low/critical report badge
|
||||||
CRITICAL_THRESHOLD = 5
|
CRITICAL_THRESHOLD = 5
|
||||||
LOW_THRESHOLD = 10
|
LOW_THRESHOLD = 10
|
||||||
|
|
||||||
|
# toner email-alert tiers (percent remaining). Distinct from the display
|
||||||
|
# badge above: a warning email fires at or below TONER_WARNING_THRESHOLD, a
|
||||||
|
# critical email at or below TONER_CRITICAL_THRESHOLD (empty).
|
||||||
|
TONER_WARNING_THRESHOLD = 5
|
||||||
|
TONER_CRITICAL_THRESHOLD = 0
|
||||||
|
|
||||||
|
|
||||||
|
def alerttier(remaining: float, warning: float = TONER_WARNING_THRESHOLD,
|
||||||
|
critical: float = TONER_CRITICAL_THRESHOLD) -> str:
|
||||||
|
"""Map a toner percent-remaining to an email-alert tier.
|
||||||
|
|
||||||
|
critical at or below the critical threshold (default 0 = empty), warning at
|
||||||
|
or below the warning threshold (default 5), else ok. Both are configurable
|
||||||
|
(printers_alert_critical_threshold / printers_alert_warning_threshold)."""
|
||||||
|
if remaining <= critical:
|
||||||
|
return 'critical'
|
||||||
|
if remaining <= warning:
|
||||||
|
return 'warning'
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
|
||||||
|
# tier severity rank; an alert fires only when the rank increases (worsens)
|
||||||
|
TIER_RANK = {'ok': 0, 'warning': 1, 'critical': 2}
|
||||||
|
|
||||||
|
|
||||||
def derivesupplytype(name: str) -> str:
|
def derivesupplytype(name: str) -> str:
|
||||||
"""Map a Zabbix item name to a supply type."""
|
"""Map a Zabbix item name to a supply type."""
|
||||||
|
|||||||
108
scripts/build-offline-bundle.ps1
Normal file
108
scripts/build-offline-bundle.ps1
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build the shopdb-flask image on a CONNECTED box and bundle it (plus the MySQL
|
||||||
|
image) into one tarball for transfer to an air-gapped site.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Air-gapped sites cannot pip install / npm ci / docker pull. This builds the
|
||||||
|
fully self-contained application image where connectivity exists, pulls the
|
||||||
|
MySQL image the stack needs, and `docker save`s both into a single gzipped
|
||||||
|
tarball with a SHA-256 checksum. Carry the .tar.gz to the site and follow
|
||||||
|
docs/DEPLOY-AIRGAP.md (docker load + docker-compose.airgap.yml).
|
||||||
|
|
||||||
|
Run from the repo root. Requires Docker Desktop. The connected build box needs
|
||||||
|
clean access to Docker Hub + PyPI + npm. If it is behind GE Zscaler and the
|
||||||
|
build fails on pip/npm SSL (CERTIFICATE_VERIFY_FAILED), build from a box with
|
||||||
|
clean internet, or trust the corp root CA in the build - see
|
||||||
|
docs/DEPLOY-AIRGAP.md "Building behind Zscaler".
|
||||||
|
|
||||||
|
.PARAMETER Version
|
||||||
|
Image tag for shopdb-flask (default 0.7.0). This is the IMAGE_TAG the site
|
||||||
|
sets in its .env so docker-compose.airgap.yml runs the matching image.
|
||||||
|
|
||||||
|
.PARAMETER MysqlImage
|
||||||
|
MySQL image the stack runs (default mysql:8.0). Must match db.image in
|
||||||
|
docker-compose.airgap.yml.
|
||||||
|
|
||||||
|
.PARAMETER OutDir
|
||||||
|
Where to write the tarball + checksum (default the repo root).
|
||||||
|
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
Reuse an already-built shopdb-flask:<Version> image; only pull + save.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Version = '0.7.0',
|
||||||
|
[string]$MysqlImage = 'mysql:8.0',
|
||||||
|
[string]$OutDir = '.',
|
||||||
|
[switch]$SkipBuild
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Assert-LastExit([string]$What) {
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fail early if docker is missing rather than midway through a long build.
|
||||||
|
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'docker not found on PATH. Install Docker Desktop and retry.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$appImage = "shopdb-flask:$Version"
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host "==> Building $appImage" -ForegroundColor Cyan
|
||||||
|
docker build -t $appImage .
|
||||||
|
Assert-LastExit 'docker build'
|
||||||
|
} else {
|
||||||
|
Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan
|
||||||
|
docker pull $MysqlImage
|
||||||
|
Assert-LastExit 'docker pull'
|
||||||
|
|
||||||
|
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||||
|
$stem = "shopdb-stack-$Version"
|
||||||
|
$tar = Join-Path $OutDir "$stem.tar"
|
||||||
|
$gz = Join-Path $OutDir "$stem.tar.gz"
|
||||||
|
|
||||||
|
Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan
|
||||||
|
docker save -o $tar $appImage $MysqlImage
|
||||||
|
Assert-LastExit 'docker save'
|
||||||
|
|
||||||
|
Write-Host "==> Compressing to $gz" -ForegroundColor Cyan
|
||||||
|
$inStream = [System.IO.File]::OpenRead($tar)
|
||||||
|
$outStream = [System.IO.File]::Create($gz)
|
||||||
|
$gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress)
|
||||||
|
try {
|
||||||
|
$inStream.CopyTo($gzip)
|
||||||
|
} finally {
|
||||||
|
$gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose()
|
||||||
|
}
|
||||||
|
Remove-Item $tar
|
||||||
|
|
||||||
|
$hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower()
|
||||||
|
$sumFile = "$gz.sha256"
|
||||||
|
"$hash $stem.tar.gz" | Set-Content -Path $sumFile -NoNewline
|
||||||
|
$sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1)
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Bundle ready: $gz ($sizeMb MB)" -ForegroundColor Green
|
||||||
|
Write-Host "SHA-256: $hash"
|
||||||
|
Write-Host "Checksum: $sumFile"
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'At the air-gapped site (verify the checksum first):'
|
||||||
|
Write-Host " docker load -i $stem.tar.gz"
|
||||||
|
Write-Host " # set IMAGE_TAG=$Version in .env, then:"
|
||||||
|
Write-Host ' docker compose -f docker-compose.airgap.yml up -d'
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ from .plugins import plugin_manager
|
|||||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||||
# managed service token without importing core token internals. Additive name
|
# managed service token without importing core token internals. Additive name
|
||||||
# on the import surface, minor bump.
|
# on the import surface, minor bump.
|
||||||
__contract_version__ = '0.14.0'
|
__contract_version__ = '0.15.0'
|
||||||
|
|
||||||
# Product release version (see ADR-007). The product version and the
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from shopdb.core.models import (
|
|||||||
RelationshipType,
|
RelationshipType,
|
||||||
User,
|
User,
|
||||||
Role,
|
Role,
|
||||||
|
SupportTeam,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Response + pagination helpers for plugin API blueprints
|
# Response + pagination helpers for plugin API blueprints
|
||||||
@@ -63,7 +64,9 @@ from shopdb.utils.authz import require_permission, require_role
|
|||||||
# Service-token authorization for unattended plugin endpoints (collector,
|
# Service-token authorization for unattended plugin endpoints (collector,
|
||||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||||
# internals.
|
# internals.
|
||||||
from shopdb.utils.apitoken_auth import service_token_authorized
|
from shopdb.utils.apitoken_auth import (
|
||||||
|
service_token_authorized, authorized_service_token,
|
||||||
|
)
|
||||||
|
|
||||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||||
from shopdb.utils.import_mode import (
|
from shopdb.utils.import_mode import (
|
||||||
@@ -263,6 +266,8 @@ __all__ = [
|
|||||||
'require_permission',
|
'require_permission',
|
||||||
'require_role',
|
'require_role',
|
||||||
'service_token_authorized',
|
'service_token_authorized',
|
||||||
|
'authorized_service_token',
|
||||||
|
'SupportTeam',
|
||||||
# Import-mode helpers
|
# Import-mode helpers
|
||||||
'apply_import_timestamps',
|
'apply_import_timestamps',
|
||||||
'import_mode_active',
|
'import_mode_active',
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner):
|
|||||||
return scopes, None
|
return scopes, None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_resourcescopes(resourcescopes):
|
||||||
|
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
|
||||||
|
|
||||||
|
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
|
||||||
|
of resource names (today geenforce manifest scope names, e.g.
|
||||||
|
gea-shopfloor-display), not permission names, so there is no catalog to
|
||||||
|
check against - a plugin owns the meaning. None/absent = unrestricted.
|
||||||
|
"""
|
||||||
|
if resourcescopes is None:
|
||||||
|
return None, None
|
||||||
|
if not isinstance(resourcescopes, list) \
|
||||||
|
or not all(isinstance(s, str) for s in resourcescopes):
|
||||||
|
return None, error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'resourcescopes must be a list of resource-scope names')
|
||||||
|
return [s.strip() for s in resourcescopes if s.strip()], None
|
||||||
|
|
||||||
|
|
||||||
@apitokens_bp.route('', methods=['GET'])
|
@apitokens_bp.route('', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def list_apitokens():
|
def list_apitokens():
|
||||||
@@ -93,6 +111,11 @@ def create_apitoken():
|
|||||||
if scope_error is not None:
|
if scope_error is not None:
|
||||||
return scope_error
|
return scope_error
|
||||||
|
|
||||||
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||||
|
data.get('resourcescopes'))
|
||||||
|
if resource_error is not None:
|
||||||
|
return resource_error
|
||||||
|
|
||||||
secret = ApiToken.generate_secret()
|
secret = ApiToken.generate_secret()
|
||||||
token = ApiToken(
|
token = ApiToken(
|
||||||
userid=current_user.userid,
|
userid=current_user.userid,
|
||||||
@@ -102,6 +125,7 @@ def create_apitoken():
|
|||||||
expiresat=expiresat,
|
expiresat=expiresat,
|
||||||
)
|
)
|
||||||
token.scopelist = scopelist
|
token.scopelist = scopelist
|
||||||
|
token.resourcescopelist = resourcescopelist
|
||||||
db.session.add(token)
|
db.session.add(token)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
@@ -147,6 +171,12 @@ def update_apitoken(tokenid: int):
|
|||||||
if scope_error is not None:
|
if scope_error is not None:
|
||||||
return scope_error
|
return scope_error
|
||||||
token.scopelist = scopelist
|
token.scopelist = scopelist
|
||||||
|
if 'resourcescopes' in data:
|
||||||
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||||
|
data.get('resourcescopes'))
|
||||||
|
if resource_error is not None:
|
||||||
|
return resource_error
|
||||||
|
token.resourcescopelist = resourcescopelist
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(token.to_dict(), message='Token updated')
|
return success_response(token.to_dict(), message='Token updated')
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ class ApiToken(BaseModel):
|
|||||||
# authority). A scoped token grants ONLY these, intersected with what the
|
# authority). A scoped token grants ONLY these, intersected with what the
|
||||||
# owner holds, and suspends the admin bypass. See scopelist below.
|
# owner holds, and suspends the admin bypass. See scopelist below.
|
||||||
scopes = db.Column(db.Text, nullable=True)
|
scopes = db.Column(db.Text, nullable=True)
|
||||||
|
# JSON array of RESOURCE-scope names (a different axis from `scopes`, which
|
||||||
|
# is what the token may DO). Today these are geenforce manifest scope names
|
||||||
|
# (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may
|
||||||
|
# only pull those scopes' manifests and only blobs those manifests
|
||||||
|
# reference. NULL = unrestricted (any resource), for back-compat. See
|
||||||
|
# resourcescopelist below.
|
||||||
|
resourcescopes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
||||||
|
|
||||||
@@ -98,6 +105,25 @@ class ApiToken(BaseModel):
|
|||||||
else:
|
else:
|
||||||
self.scopes = json.dumps(list(names))
|
self.scopes = json.dumps(list(names))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resourcescopelist(self):
|
||||||
|
"""Parsed resource-scope names, or None when the token is unrestricted."""
|
||||||
|
if self.resourcescopes is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(self.resourcescopes)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, list) else None
|
||||||
|
|
||||||
|
@resourcescopelist.setter
|
||||||
|
def resourcescopelist(self, names):
|
||||||
|
"""Store a resource-scope list, or None to clear the restriction."""
|
||||||
|
if names is None:
|
||||||
|
self.resourcescopes = None
|
||||||
|
else:
|
||||||
|
self.resourcescopes = json.dumps(list(names))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def unknown_scope_names(names) -> list:
|
def unknown_scope_names(names) -> list:
|
||||||
"""Return the subset of names that are not in the permission catalog.
|
"""Return the subset of names that are not in the permission catalog.
|
||||||
@@ -119,6 +145,7 @@ class ApiToken(BaseModel):
|
|||||||
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
||||||
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
||||||
'scopes': self.scopelist,
|
'scopes': self.scopelist,
|
||||||
|
'resourcescopes': self.resourcescopelist,
|
||||||
'isactive': self.isactive,
|
'isactive': self.isactive,
|
||||||
'isexpired': self.is_expired,
|
'isexpired': self.is_expired,
|
||||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|||||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||||
'manifestpublishedversions', 'manifestpayloads',
|
'manifestpublishedversions', 'manifestpayloads',
|
||||||
|
'manifestblobs',
|
||||||
'manifestenforcementreports', 'manifestenforcementresults',
|
'manifestenforcementreports', 'manifestenforcementresults',
|
||||||
'pctypealiases'),
|
'pctypealiases'),
|
||||||
'knowledgebase': ('knowledgebase',),
|
'knowledgebase': ('knowledgebase',),
|
||||||
@@ -55,7 +56,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|||||||
'notifications': ('notificationtypes', 'notifications'),
|
'notifications': ('notificationtypes', 'notifications'),
|
||||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||||
'printeditemfiles'),
|
'printeditemfiles'),
|
||||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||||
|
'printersupplyalerts'),
|
||||||
'slides': ('tvslides',),
|
'slides': ('tvslides',),
|
||||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||||
'warranty': ('warranties', 'warrantyassets'),
|
'warranty': ('warranties', 'warrantyassets'),
|
||||||
|
|||||||
@@ -76,16 +76,13 @@ def touch_apitoken_lastused(token):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def service_token_authorized(scope):
|
def authorized_service_token(scope):
|
||||||
"""True when the current request carries a managed token scoped for `scope`
|
"""Return the ApiToken authorizing this request for `scope`, or None.
|
||||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
|
||||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
|
||||||
Touches lastusedat on success.
|
|
||||||
|
|
||||||
The single contract-surface entry point for unattended SERVICE tokens
|
Same checks as service_token_authorized (managed token scoped for `scope`,
|
||||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
active owner holding the permission), but hands back the token itself so a
|
||||||
token without reaching into core token internals. Returns False on any
|
caller can read its resource binding (token.resourcescopelist) without
|
||||||
miss; the caller returns its own 401.
|
reaching into core token internals. Touches lastusedat on success.
|
||||||
"""
|
"""
|
||||||
from shopdb.core.models import User
|
from shopdb.core.models import User
|
||||||
|
|
||||||
@@ -99,15 +96,29 @@ def service_token_authorized(scope):
|
|||||||
if tokenid is not None:
|
if tokenid is not None:
|
||||||
token = db.session.get(ApiToken, tokenid)
|
token = db.session.get(ApiToken, tokenid)
|
||||||
if token is None:
|
if token is None:
|
||||||
return False
|
return None
|
||||||
scopelist = token.scopelist
|
scopelist = token.scopelist
|
||||||
if not scopelist or scope not in scopelist:
|
if not scopelist or scope not in scopelist:
|
||||||
return False
|
return None
|
||||||
user = db.session.get(User, token.userid)
|
user = db.session.get(User, token.userid)
|
||||||
if user is None or not user.isactive or not user.haspermission(scope):
|
if user is None or not user.isactive or not user.haspermission(scope):
|
||||||
return False
|
return None
|
||||||
touch_apitoken_lastused(token)
|
touch_apitoken_lastused(token)
|
||||||
return True
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def service_token_authorized(scope):
|
||||||
|
"""True when the current request carries a managed token scoped for `scope`
|
||||||
|
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||||
|
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||||
|
Touches lastusedat on success.
|
||||||
|
|
||||||
|
The single contract-surface entry point for unattended SERVICE tokens
|
||||||
|
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||||
|
token without reaching into core token internals. Returns False on any
|
||||||
|
miss; the caller returns its own 401.
|
||||||
|
"""
|
||||||
|
return authorized_service_token(scope) is not None
|
||||||
|
|
||||||
|
|
||||||
def install_apitoken_auth(app):
|
def install_apitoken_auth(app):
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ CUTOVER_PLUGINS = (
|
|||||||
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
||||||
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
||||||
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline'
|
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||||
|
# baseline.
|
||||||
|
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
|
||||||
|
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
|
||||||
|
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
|
||||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||||
# rename revision on top, so its head is not the f-string default.
|
# rename revision on top, so its head is not the f-string default.
|
||||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||||
@@ -54,8 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
|||||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
|
# the per-transaction revision column.
|
||||||
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
# notifications indexes businessunitid on top of its anchor.
|
# notifications indexes businessunitid on top of its anchor.
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||||
|
|
||||||
|
|||||||
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
108
tests/test_plugins/test_geenforce_blob_parity.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""GE-Enforce manifestblobs model-vs-migration parity gate.
|
||||||
|
|
||||||
|
manifestblobs is created by the SECOND geenforce migration
|
||||||
|
(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the
|
||||||
|
baseline (0001) tables - it does not even list ManifestBlob. So the http-blob
|
||||||
|
store table has had NO parity coverage: a column added to the ManifestBlob model
|
||||||
|
but not the migration (or vice versa) would ship a broken schema silently.
|
||||||
|
|
||||||
|
This gate builds manifestblobs two ways and diffs it column-by-column:
|
||||||
|
1. the ManifestBlob model via metadata.create_all (desired shape), and
|
||||||
|
2. the 0002 migration's upgrade() run against a fresh engine (shipped shape).
|
||||||
|
Both go through the same SQLite dialect so reflected types render identically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
|
||||||
|
|
||||||
|
_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
|
||||||
|
'geenforce' / 'migrations' / 'versions' /
|
||||||
|
'0002_manifest_blobs.py')
|
||||||
|
|
||||||
|
_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes',
|
||||||
|
'createdat')
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_columns():
|
||||||
|
"""Run the 0002 migration upgrade() on a fresh engine; return the reflected
|
||||||
|
manifestblobs columns as {name: column-dict}."""
|
||||||
|
spec = importlib.util.spec_from_file_location('geenforce_blobs',
|
||||||
|
str(_BLOBS_MIGRATION))
|
||||||
|
migration = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(migration)
|
||||||
|
|
||||||
|
engine = create_engine('sqlite://')
|
||||||
|
with engine.connect() as connection:
|
||||||
|
operations = Operations(MigrationContext.configure(connection))
|
||||||
|
operations._install_proxy()
|
||||||
|
try:
|
||||||
|
migration.upgrade()
|
||||||
|
finally:
|
||||||
|
operations._remove_proxy()
|
||||||
|
connection.commit()
|
||||||
|
inspector = inspect(connection)
|
||||||
|
assert 'manifestblobs' in inspector.get_table_names(), \
|
||||||
|
'migration did not create manifestblobs'
|
||||||
|
return {column['name']: column
|
||||||
|
for column in inspector.get_columns('manifestblobs')}
|
||||||
|
|
||||||
|
|
||||||
|
def _model_columns(db):
|
||||||
|
"""Build manifestblobs from the ManifestBlob model; return {name: column}."""
|
||||||
|
from plugins.geenforce.models.manifest import ManifestBlob
|
||||||
|
|
||||||
|
engine = create_engine('sqlite://')
|
||||||
|
db.metadata.create_all(engine, tables=[ManifestBlob.__table__])
|
||||||
|
return {column['name']: column
|
||||||
|
for column in inspect(engine).get_columns('manifestblobs')}
|
||||||
|
|
||||||
|
|
||||||
|
def _colkey(column):
|
||||||
|
"""Normalize a reflected column to the facets we gate on (type, nullable)."""
|
||||||
|
return (str(column['type']), bool(column['nullable']))
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifestblob_model_matches_migration(app, db):
|
||||||
|
"""Every ManifestBlob column matches what the 0002 migration builds."""
|
||||||
|
with app.app_context():
|
||||||
|
modelcols = _model_columns(db)
|
||||||
|
migrationcols = _migration_columns()
|
||||||
|
|
||||||
|
drift = []
|
||||||
|
for name in sorted(set(modelcols) - set(migrationcols)):
|
||||||
|
drift.append(f'{name}: in model, not in migration')
|
||||||
|
for name in sorted(set(migrationcols) - set(modelcols)):
|
||||||
|
drift.append(f'{name}: in migration, not in model')
|
||||||
|
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||||
|
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||||
|
drift.append(
|
||||||
|
f'{name}: model {_colkey(modelcols[name])} != '
|
||||||
|
f'migration {_colkey(migrationcols[name])}')
|
||||||
|
|
||||||
|
assert not drift, ('manifestblobs model/migration DDL drift:\n'
|
||||||
|
+ '\n'.join(drift))
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifestblob_expected_columns_present_both_sides(app, db):
|
||||||
|
"""Lock the load-bearing columns (sha256 primary key + registry fields)
|
||||||
|
exist in BOTH the model and the migration."""
|
||||||
|
with app.app_context():
|
||||||
|
modelcols = _model_columns(db)
|
||||||
|
migrationcols = _migration_columns()
|
||||||
|
for name in _EXPECTED_COLUMNS:
|
||||||
|
assert name in modelcols, f'{name} missing from ManifestBlob model'
|
||||||
|
assert name in migrationcols, f'{name} missing from migration'
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifestblob_sha256_is_not_nullable(app, db):
|
||||||
|
"""The content hash is the primary key: it must be NOT NULL on both sides."""
|
||||||
|
with app.app_context():
|
||||||
|
modelcols = _model_columns(db)
|
||||||
|
migrationcols = _migration_columns()
|
||||||
|
assert modelcols['sha256']['nullable'] is False
|
||||||
|
assert migrationcols['sha256']['nullable'] is False
|
||||||
109
tests/test_plugins/test_geenforce_display_seed.py
Normal file
109
tests/test_plugins/test_geenforce_display_seed.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Cover the gea-shopfloor-display authoring seed.
|
||||||
|
|
||||||
|
Running seed_display_scope must produce the display scope with the expected
|
||||||
|
entry count and types: four Registry drift-heal entries (Edge kiosk relaunch
|
||||||
|
policies) plus one inline PS1 dispatcher. Also checks the dispatcher payload is
|
||||||
|
stored inline and the display-type map drives the dispatcher script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugins.geenforce.models import (
|
||||||
|
ManifestScope, ManifestEntry, ManifestPayload,
|
||||||
|
)
|
||||||
|
from plugins.geenforce.seed_display_scope import (
|
||||||
|
seed_display_scope, build_display_manifest, build_dispatcher_script,
|
||||||
|
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_creates_display_scope(db):
|
||||||
|
summary = seed_display_scope()
|
||||||
|
|
||||||
|
scope = ManifestScope.query.filter_by(
|
||||||
|
scopename=SCOPE_NAME, phase='runtime').first()
|
||||||
|
assert scope is not None
|
||||||
|
# A plain runtime scope, not the common scope. Displays are self-sufficient
|
||||||
|
# and do NOT inherit common.
|
||||||
|
assert scope.iscommon is False
|
||||||
|
|
||||||
|
# Four Registry drift-heal entries + one PS1 dispatcher, in order.
|
||||||
|
assert summary['entrycount'] == 5
|
||||||
|
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
|
||||||
|
'Registry', 'PS1']
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_entries_use_valuematches_detection(db):
|
||||||
|
seed_display_scope()
|
||||||
|
|
||||||
|
registry_entries = ManifestEntry.query.filter_by(
|
||||||
|
entrytype='Registry').order_by(ManifestEntry.sortorder).all()
|
||||||
|
assert len(registry_entries) == 4
|
||||||
|
regnames = {entry.regname for entry in registry_entries}
|
||||||
|
assert regnames == {
|
||||||
|
'RelaunchNotification', 'RelaunchNotificationPeriod',
|
||||||
|
'RelaunchHeadsUpPeriod', 'RelaunchWindow',
|
||||||
|
}
|
||||||
|
for entry in registry_entries:
|
||||||
|
assert entry.detectionmethod == 'ValueMatches'
|
||||||
|
assert entry.regpath == 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
|
||||||
|
# Detection targets the same value it writes, coerced to string.
|
||||||
|
assert entry.detectionname == entry.regname
|
||||||
|
assert entry.detectionvalue is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatcher_is_inline_and_data_driven(db):
|
||||||
|
summary = seed_display_scope()
|
||||||
|
|
||||||
|
dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one()
|
||||||
|
assert dispatcher.payloadsource == 'inline'
|
||||||
|
assert dispatcher.payloadref == DISPATCHER_FILENAME
|
||||||
|
assert dispatcher.payloadsha256 == summary['dispatchersha256']
|
||||||
|
|
||||||
|
payload = ManifestPayload.query.filter_by(
|
||||||
|
entryid=dispatcher.entryid).one()
|
||||||
|
scripttext = payload.payloadbytes.decode('utf-8')
|
||||||
|
# The data-driven map surfaces every display-type and its target route.
|
||||||
|
for display_type, route in DISPLAY_TYPE_TARGETS.items():
|
||||||
|
assert display_type in scripttext
|
||||||
|
assert route in scripttext
|
||||||
|
assert 'display-type.txt' in scripttext
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_publish_freezes_a_version(db):
|
||||||
|
summary = seed_display_scope(publish=True)
|
||||||
|
assert summary['publishedversion'] == 1
|
||||||
|
|
||||||
|
scope = ManifestScope.query.filter_by(
|
||||||
|
scopename=SCOPE_NAME, phase='runtime').first()
|
||||||
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||||
|
assert published is not None
|
||||||
|
assert published.versionnumber == 1
|
||||||
|
assert '"Version": "2.0"' in published.manifestjson
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_draft_is_idempotent(db):
|
||||||
|
first = seed_display_scope()
|
||||||
|
second = seed_display_scope()
|
||||||
|
# Re-running rebuilds the draft to the same shape (same scope, same count).
|
||||||
|
assert first['scopeid'] == second['scopeid']
|
||||||
|
assert first['entrycount'] == second['entrycount']
|
||||||
|
assert first['dispatchersha256'] == second['dispatchersha256']
|
||||||
|
|
||||||
|
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
|
||||||
|
assert len(entries) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_manifest_has_no_smb_exe_payloads(db):
|
||||||
|
manifest = build_display_manifest()
|
||||||
|
# No entry ships an EXE/MSI/File payload from a share: the only payload is
|
||||||
|
# the inline dispatcher; everything else is a Registry policy heal.
|
||||||
|
for entry in manifest['Applications']:
|
||||||
|
assert entry.get('Installer') is None
|
||||||
|
assert entry.get('Source') is None
|
||||||
|
if entry['Type'] == 'PS1':
|
||||||
|
assert entry.get('PayloadSource') == 'inline'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatcher_script_is_ascii():
|
||||||
|
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
|
||||||
|
# green and the on-PC script parses cleanly.
|
||||||
|
build_dispatcher_script().encode('ascii')
|
||||||
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
135
tests/test_plugins/test_geenforce_payload_hardening.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""GE-Enforce payload-download hardening: rate limit + served-size ceiling.
|
||||||
|
|
||||||
|
GET /api/geenforce/payload/<sha256> is reachable with only a read-only
|
||||||
|
geenforce.fetch token, so a leaked display token must not be able to hammer it
|
||||||
|
or pull unbounded bytes. These tests lock the two bounds added to the route:
|
||||||
|
a per-IP fixed-window rate limit (same cache extension as the login limiter)
|
||||||
|
and a size cap that refuses (413) a blob/inline payload over the configured max.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
from plugins.geenforce.models import ManifestBlob
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_key(client, auth_headers):
|
||||||
|
resp = client.post('/api/apitokens',
|
||||||
|
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return {'X-API-Key': resp.get_json()['data']['secret']}
|
||||||
|
|
||||||
|
|
||||||
|
def _store(app, raw, filename='setup.exe'):
|
||||||
|
with app.app_context():
|
||||||
|
sha = service.store_blob(raw, filename, 'application/octet-stream')
|
||||||
|
service.db.session.commit()
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _config(app, **overrides):
|
||||||
|
"""Temporarily override app.config keys (the app fixture is session-scoped,
|
||||||
|
so every key is restored to its prior value/absence on exit)."""
|
||||||
|
missing = object()
|
||||||
|
saved = {key: app.config.get(key, missing) for key in overrides}
|
||||||
|
app.config.update(overrides)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for key, value in saved.items():
|
||||||
|
if value is missing:
|
||||||
|
app.config.pop(key, None)
|
||||||
|
else:
|
||||||
|
app.config[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def test_blob_within_cap_is_served(client, app, auth_headers):
|
||||||
|
raw = b'x' * 64
|
||||||
|
sha = _store(app, raw, 'ok.exe')
|
||||||
|
headers = _fetch_key(client, auth_headers)
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024):
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_blob_refused_413(client, app, auth_headers):
|
||||||
|
raw = b'x' * 64
|
||||||
|
sha = _store(app, raw, 'big.exe')
|
||||||
|
headers = _fetch_key(client, auth_headers)
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16):
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||||
|
assert resp.status_code == 413
|
||||||
|
# The blob registry row still exists; the cap gates delivery, not storage.
|
||||||
|
with app.app_context():
|
||||||
|
assert service.db.session.get(ManifestBlob, sha) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_inline_payload_refused_413(client, app, db, auth_headers):
|
||||||
|
scopeid = client.post(
|
||||||
|
'/api/geenforce/scopes',
|
||||||
|
json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'},
|
||||||
|
headers=auth_headers).get_json()['data']['scopeid']
|
||||||
|
entryid = client.post(
|
||||||
|
f'/api/geenforce/scopes/{scopeid}/entries',
|
||||||
|
json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'},
|
||||||
|
headers=auth_headers).get_json()['data']['entryid']
|
||||||
|
content = b'inline config bytes here'
|
||||||
|
up = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||||
|
data={'file': (io.BytesIO(content), 'config.reg')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
assert up.status_code == 201, up.get_json()
|
||||||
|
sha = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
headers = _fetch_key(client, auth_headers)
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4):
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||||
|
assert resp.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_download_is_rate_limited(client, app, auth_headers):
|
||||||
|
sha = _store(app, b'small blob')
|
||||||
|
key = _fetch_key(client, auth_headers)
|
||||||
|
# A unique caller IP isolates this bucket from other tests' shared counter.
|
||||||
|
headers = {**key, 'X-Forwarded-For': '203.0.113.201'}
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3,
|
||||||
|
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||||
|
for _ in range(3):
|
||||||
|
ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||||
|
assert ok.status_code == 200, ok.get_json()
|
||||||
|
# The 4th request in the same window is over budget.
|
||||||
|
over = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
|
||||||
|
assert over.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_is_per_ip(client, app, auth_headers):
|
||||||
|
"""A second caller (different X-Forwarded-For) has its own budget: one IP
|
||||||
|
being throttled must not throttle everyone."""
|
||||||
|
sha = _store(app, b'per-ip blob')
|
||||||
|
key = _fetch_key(client, auth_headers)
|
||||||
|
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
|
||||||
|
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
|
||||||
|
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
|
||||||
|
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||||
|
headers=hot).status_code == 200
|
||||||
|
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||||
|
headers=hot).status_code == 429
|
||||||
|
# Fresh IP still gets its first request through.
|
||||||
|
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||||
|
headers=cool).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_can_be_disabled(client, app, auth_headers):
|
||||||
|
sha = _store(app, b'unthrottled blob')
|
||||||
|
key = _fetch_key(client, auth_headers)
|
||||||
|
headers = {**key, 'X-Forwarded-For': '203.0.113.204'}
|
||||||
|
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False,
|
||||||
|
GEENFORCE_PAYLOAD_RATELIMIT_MAX=1):
|
||||||
|
for _ in range(5):
|
||||||
|
assert client.get(f'/api/geenforce/payload/{sha}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
137
tests/test_plugins/test_geenforce_report_contract.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""GE-Enforce enforcement-report per-entry key contract.
|
||||||
|
|
||||||
|
The server reads LOWERCASE keys from each results[] item -
|
||||||
|
name / action / selfhealed / exitcode / message (see
|
||||||
|
service.record_enforcement_report) - and maps them onto the
|
||||||
|
ManifestEnforcementResult columns entryname / action / selfhealed / exitcode /
|
||||||
|
message. This is the exact contract the PowerShell client fix targets: the
|
||||||
|
client emits lowercase keys, so if the server ever silently switched to reading
|
||||||
|
uppercase keys the client's entryname/selfhealed would drop to empty/false.
|
||||||
|
|
||||||
|
These tests POST a realistic multi-entry cycle with the lowercase keys and lock
|
||||||
|
that the result columns populate, plus the negative case (uppercase keys do NOT
|
||||||
|
populate) so a future contract flip fails loudly here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
|
||||||
|
|
||||||
|
SCOPE = {
|
||||||
|
'Version': '2.6',
|
||||||
|
'Applications': [
|
||||||
|
{'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'},
|
||||||
|
{'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'},
|
||||||
|
{'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'},
|
||||||
|
{'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||||
|
with app.app_context():
|
||||||
|
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||||
|
service.publish_scope(scopename, 'runtime', notes='v')
|
||||||
|
service.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _report_token(client, auth_headers):
|
||||||
|
resp = client.post('/api/apitokens',
|
||||||
|
json={'name': 'svc', 'scopes': ['geenforce.report']},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']['secret']
|
||||||
|
|
||||||
|
|
||||||
|
def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers):
|
||||||
|
_seed_and_publish(app)
|
||||||
|
secret = _report_token(client, auth_headers)
|
||||||
|
|
||||||
|
post = client.post('/api/geenforce/report', json={
|
||||||
|
'hostname': 'WJDISPLAY07',
|
||||||
|
'scopename': 'gea-shopfloor-cmm',
|
||||||
|
'phase': 'runtime',
|
||||||
|
'appliedversion': 1,
|
||||||
|
'enforcerversion': '2.6',
|
||||||
|
'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0},
|
||||||
|
'results': [
|
||||||
|
{'name': 'VNC firewall rule', 'action': 'installed',
|
||||||
|
'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'},
|
||||||
|
{'name': 'PC-DMIS 2023.1', 'action': 'skipped',
|
||||||
|
'selfhealed': False, 'exitcode': 0, 'message': ''},
|
||||||
|
{'name': 'eDNC config', 'action': 'installed',
|
||||||
|
'selfhealed': True, 'exitcode': 0},
|
||||||
|
{'name': 'Blancco agent', 'action': 'failed',
|
||||||
|
'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'},
|
||||||
|
],
|
||||||
|
}, headers={'X-API-Key': secret})
|
||||||
|
assert post.status_code == 200, post.get_json()
|
||||||
|
reportid = post.get_json()['data']['reportid']
|
||||||
|
|
||||||
|
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
byname = {r['entryname']: r for r in detail['results']}
|
||||||
|
|
||||||
|
# Every lowercase 'name' mapped onto entryname (nothing dropped).
|
||||||
|
assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1',
|
||||||
|
'eDNC config', 'Blancco agent'}
|
||||||
|
|
||||||
|
vnc = byname['VNC firewall rule']
|
||||||
|
assert vnc['action'] == 'installed'
|
||||||
|
assert vnc['selfhealed'] is True
|
||||||
|
assert vnc['exitcode'] == 0
|
||||||
|
assert vnc['message'] == 'rule re-added'
|
||||||
|
|
||||||
|
assert byname['PC-DMIS 2023.1']['action'] == 'skipped'
|
||||||
|
assert byname['PC-DMIS 2023.1']['selfhealed'] is False
|
||||||
|
|
||||||
|
ednc = byname['eDNC config']
|
||||||
|
assert ednc['selfhealed'] is True
|
||||||
|
assert ednc['message'] is None # omitted key -> None
|
||||||
|
|
||||||
|
blancco = byname['Blancco agent']
|
||||||
|
assert blancco['action'] == 'failed'
|
||||||
|
assert blancco['exitcode'] == 1603
|
||||||
|
assert blancco['message'] == 'MSI 1603'
|
||||||
|
|
||||||
|
# A failure is present, so the cycle status is 'failed' (a self-heal without
|
||||||
|
# any failure would be 'selfhealed').
|
||||||
|
assert detail['status'] == 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
def test_selfhealed_defaults_false_when_key_omitted(client, db, app,
|
||||||
|
auth_headers):
|
||||||
|
_seed_and_publish(app)
|
||||||
|
secret = _report_token(client, auth_headers)
|
||||||
|
post = client.post('/api/geenforce/report', json={
|
||||||
|
'hostname': 'WJDISPLAY08',
|
||||||
|
'scopename': 'gea-shopfloor-cmm',
|
||||||
|
'results': [{'name': 'asset report', 'action': 'installed'}],
|
||||||
|
}, headers={'X-API-Key': secret})
|
||||||
|
reportid = post.get_json()['data']['reportid']
|
||||||
|
|
||||||
|
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
result = detail['results'][0]
|
||||||
|
assert result['entryname'] == 'asset report'
|
||||||
|
assert result['selfhealed'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_uppercase_keys_do_not_populate(client, db, app, auth_headers):
|
||||||
|
"""Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed
|
||||||
|
item does NOT populate entryname/selfhealed. Guards against a silent flip to
|
||||||
|
uppercase that would break the client's lowercase payload."""
|
||||||
|
_seed_and_publish(app)
|
||||||
|
secret = _report_token(client, auth_headers)
|
||||||
|
post = client.post('/api/geenforce/report', json={
|
||||||
|
'hostname': 'WJDISPLAY09',
|
||||||
|
'scopename': 'gea-shopfloor-cmm',
|
||||||
|
'results': [{'Name': 'wrong casing', 'Action': 'installed',
|
||||||
|
'SelfHealed': True}],
|
||||||
|
}, headers={'X-API-Key': secret})
|
||||||
|
reportid = post.get_json()['data']['reportid']
|
||||||
|
|
||||||
|
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
result = detail['results'][0]
|
||||||
|
assert result['entryname'] == '' # 'Name' not read -> default ''
|
||||||
|
assert result['selfhealed'] is False # 'SelfHealed' not read -> default
|
||||||
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Resource-scope binding on geenforce fetch tokens.
|
||||||
|
|
||||||
|
A geenforce.fetch token MAY be pinned to specific manifest scopes
|
||||||
|
(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests
|
||||||
|
and only the blobs those scopes reference; an unbound token (NULL) keeps the
|
||||||
|
original behavior. This is what lets a fleet-wide display key be handed out
|
||||||
|
without exposing every scope + every blob in the content store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
|
||||||
|
DISPLAY = 'gea-shopfloor-display'
|
||||||
|
OTHER = 'gea-shopfloor-cmm'
|
||||||
|
|
||||||
|
KIOSK_BYTES = b'kiosk-installer-payload-bytes'
|
||||||
|
KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_display_scope(app):
|
||||||
|
"""Publish a display scope whose one entry references KIOSK_SHA over http."""
|
||||||
|
with app.app_context():
|
||||||
|
service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream')
|
||||||
|
manifest = {
|
||||||
|
'Version': '2.0',
|
||||||
|
'Applications': [
|
||||||
|
{'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe',
|
||||||
|
'DetectionMethod': 'Always',
|
||||||
|
'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
service.replace_scope_draft(DISPLAY, 'runtime', manifest)
|
||||||
|
service.publish_scope(DISPLAY, 'runtime', notes='display')
|
||||||
|
# A second, unrelated scope + blob the display token must NOT reach.
|
||||||
|
service.store_blob(b'secret-other-scope-bytes', 'other.exe')
|
||||||
|
service.replace_scope_draft(OTHER, 'runtime', {
|
||||||
|
'Version': '2.6',
|
||||||
|
'Applications': [{'Name': 'Beta', 'Type': 'PS1',
|
||||||
|
'Script': 'scripts/beta.ps1',
|
||||||
|
'DetectionMethod': 'Always'}]})
|
||||||
|
service.publish_scope(OTHER, 'runtime', notes='other')
|
||||||
|
service.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _mint(client, auth_headers, resourcescopes=None):
|
||||||
|
payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']}
|
||||||
|
if resourcescopes is not None:
|
||||||
|
payload['resourcescopes'] = resourcescopes
|
||||||
|
resp = client.post('/api/apitokens', json=payload, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _key(client, auth_headers, resourcescopes=None):
|
||||||
|
return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']}
|
||||||
|
|
||||||
|
|
||||||
|
# -- manifest binding ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bound_token_gets_its_own_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_token_forbidden_other_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_token_reaches_any_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers) # no resourcescopes -> unrestricted
|
||||||
|
assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
assert client.get(f'/api/geenforce/manifest?pctype={OTHER}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# -- payload binding ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bound_token_gets_referenced_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == KIOSK_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
# A blob the display scope does NOT reference (belongs to OTHER).
|
||||||
|
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_token_gets_any_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||||
|
headers = _key(client, auth_headers)
|
||||||
|
assert client.get(f'/api/geenforce/payload/{other_sha}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# -- token model / API --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_persists_resourcescopes(client, app, db, auth_headers):
|
||||||
|
data = _mint(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
assert data['resourcescopes'] == [DISPLAY]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resourcescopes_null_by_default(client, app, db, auth_headers):
|
||||||
|
data = _mint(client, auth_headers)
|
||||||
|
assert data['resourcescopes'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_resourcescopes_rejected(client, app, db, auth_headers):
|
||||||
|
resp = client.post('/api/apitokens',
|
||||||
|
json={'name': 'x', 'scopes': ['geenforce.fetch'],
|
||||||
|
'resourcescopes': 'not-a-list'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
165
tests/test_plugins/test_printers_supply_alerts.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Low-toner alert tests.
|
||||||
|
|
||||||
|
Invariants: the alert tier boundaries (5% warning, 0% critical), the
|
||||||
|
fire-once-per-crossing state machine (re-arm on refill), toner-only scope,
|
||||||
|
and recipient routing to a support team's webhook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from shopdb.api import db
|
||||||
|
from shopdb.core.models import AssetType, Setting, SupportTeam
|
||||||
|
from plugins.printers.models import PrinterSupplyAlert
|
||||||
|
from plugins.printers.services.supply_parts import alerttier
|
||||||
|
from plugins.printers.services.supply_alerts import check_supplies
|
||||||
|
|
||||||
|
|
||||||
|
class FakeZabbix:
|
||||||
|
"""Stand-in Zabbix service: canned supplies keyed by IP."""
|
||||||
|
|
||||||
|
def __init__(self, byip):
|
||||||
|
self._byip = byip
|
||||||
|
self.isconfigured = True
|
||||||
|
|
||||||
|
def getsuppliesbyip(self, ip):
|
||||||
|
return self._byip.get(ip, [])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def printer_assettype(db):
|
||||||
|
at = AssetType(assettype='printer', pluginname='printers',
|
||||||
|
tablename='printers', description='Printers')
|
||||||
|
db.session.add(at)
|
||||||
|
db.session.commit()
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ip_comtype(db):
|
||||||
|
from shopdb.core.models import CommunicationType
|
||||||
|
row = CommunicationType.query.filter_by(comtype='IP').first()
|
||||||
|
if not row:
|
||||||
|
row = CommunicationType(comtype='IP', description='IP address')
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def network_printer(client, auth_headers, printer_assettype, ip_comtype):
|
||||||
|
"""A network printer at 10.0.0.5 with a black toner, via the real API."""
|
||||||
|
resp = client.post('/api/printers', json={
|
||||||
|
'assetnumber': 'CSF01-LaserJet',
|
||||||
|
'hostname': 'wjprn05',
|
||||||
|
'ipaddress': '10.0.0.5',
|
||||||
|
}, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return '10.0.0.5'
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerttier_boundaries():
|
||||||
|
assert alerttier(50) == 'ok'
|
||||||
|
assert alerttier(6) == 'ok'
|
||||||
|
assert alerttier(5) == 'warning'
|
||||||
|
assert alerttier(1) == 'warning'
|
||||||
|
assert alerttier(0) == 'critical'
|
||||||
|
|
||||||
|
|
||||||
|
def _toner(level, color='black'):
|
||||||
|
return {'name': f'{color} toner', 'level': level, 'color': color}
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_crossing_fires_once_and_rearms(app, db, network_printer):
|
||||||
|
ip = network_printer
|
||||||
|
with app.app_context():
|
||||||
|
# 20% -> ok, no alert
|
||||||
|
with patch('shopdb.api.send_webhook') as hook1, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(20)]}))
|
||||||
|
assert not hook1.called
|
||||||
|
|
||||||
|
# 4% -> warning, alert fires
|
||||||
|
with patch('shopdb.api.send_webhook') as hook2, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(4)]}))
|
||||||
|
assert hook2.called
|
||||||
|
|
||||||
|
# still 3% -> already warning, no repeat
|
||||||
|
with patch('shopdb.api.send_webhook') as hook3, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(3)]}))
|
||||||
|
assert not hook3.called
|
||||||
|
|
||||||
|
# refill to 80% -> re-arm (state back to ok), no alert
|
||||||
|
with patch('shopdb.api.send_webhook') as hook4, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(80)]}))
|
||||||
|
assert not hook4.called
|
||||||
|
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||||
|
assert row.lasttier == 'ok'
|
||||||
|
|
||||||
|
# drop to 2% again -> warning fires again after the re-arm
|
||||||
|
with patch('shopdb.api.send_webhook') as hook5, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(2)]}))
|
||||||
|
assert hook5.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_to_critical_fires_critical(app, db, network_printer):
|
||||||
|
ip = network_printer
|
||||||
|
with app.app_context():
|
||||||
|
with patch('shopdb.api.send_webhook') as hook, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(0)]}))
|
||||||
|
assert hook.called
|
||||||
|
subject = hook.call_args.args[0]
|
||||||
|
assert 'CRITICAL' in subject
|
||||||
|
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||||
|
assert row.lasttier == 'critical'
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerttier_respects_custom_thresholds():
|
||||||
|
# warning at <=20, critical at <=8
|
||||||
|
assert alerttier(25, warning=20, critical=8) == 'ok'
|
||||||
|
assert alerttier(20, warning=20, critical=8) == 'warning'
|
||||||
|
assert alerttier(8, warning=20, critical=8) == 'critical'
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_warning_threshold_setting_fires(app, db, network_printer):
|
||||||
|
ip = network_printer
|
||||||
|
with app.app_context():
|
||||||
|
Setting.set('printers_alert_warning_threshold', '20')
|
||||||
|
# 15% would be 'ok' under the default 5% warning, but fires at 20%
|
||||||
|
with patch('shopdb.api.send_webhook') as hook, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(15)]}))
|
||||||
|
assert hook.called
|
||||||
|
row = PrinterSupplyAlert.query.filter_by(supplykey='black').first()
|
||||||
|
assert row.lasttier == 'warning'
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_toner_supply_ignored(app, db, network_printer):
|
||||||
|
ip = network_printer
|
||||||
|
with app.app_context():
|
||||||
|
waste = {'name': 'waste toner box', 'level': 1, 'color': 'none'}
|
||||||
|
with patch('shopdb.api.send_email') as email, \
|
||||||
|
patch('shopdb.api.send_webhook'):
|
||||||
|
check_supplies(FakeZabbix({ip: [waste]}))
|
||||||
|
assert not email.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_to_support_team_webhook(app, db, network_printer):
|
||||||
|
ip = network_printer
|
||||||
|
with app.app_context():
|
||||||
|
team = SupportTeam(teamname='Print Team',
|
||||||
|
webhookurl='https://teams.example/print')
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.commit()
|
||||||
|
Setting.set('printers_alert_supportteamid', str(team.supportteamid))
|
||||||
|
|
||||||
|
with patch('shopdb.api.send_webhook') as webhook, \
|
||||||
|
patch('shopdb.api.send_email'):
|
||||||
|
check_supplies(FakeZabbix({ip: [_toner(3)]}))
|
||||||
|
assert webhook.called
|
||||||
|
assert webhook.call_args.kwargs['url'] == 'https://teams.example/print'
|
||||||
Reference in New Issue
Block a user