Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.
Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.
NOT READY FOR main. Open items:
- 10 new routes are absent from docs/api-inventory.json, so
test_api_inventory_parity fails. That gate SKIPS on the published mirror
(the test ships, its data file is scrubbed), which is why wilm never saw it.
- dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
- plugins/endpoints/plugin.py carries a whitespace-only re-indent.
Scope: product
205 lines
8.1 KiB
Python
205 lines
8.1 KiB
Python
"""Configuration and business policy models (RMM-style).
|
|
|
|
DT staff author reusable Configuration Policies (a single rule binding
|
|
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
|
|
them into ordered Business Policies assigned to a fleet. When an endpoint reports
|
|
in, the server folds the matching Business Policies in priority order to compute
|
|
that endpoint's effective policy (first match wins per action).
|
|
|
|
These models live in the computers plugin so policies sit next to ComputerType.
|
|
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
|
|
layer references action NAMES only. The `source` column is the seam for a future
|
|
GE-Enforce sync (local-authored rows vs imported/managed rows).
|
|
|
|
The whole surface is gated by the computers_configpolicies_enabled setting; the
|
|
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
|
|
enforcement stay inert while the flag is off, and the migration downgrade drops
|
|
every table for a clean rollback.
|
|
"""
|
|
|
|
from shopdb.api import db, BaseModel
|
|
from .jsontext import JSONText
|
|
|
|
|
|
# Provenance of a policy row.
|
|
# leaves room for `geenforce` (or another manager) to own rows later without a
|
|
# schema change - an imported row is read-only in the UI and reconciled by its
|
|
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
|
POLICY_SOURCE_LOCAL = 'local'
|
|
|
|
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
|
|
# treatment as any non-local source: the CRUD API refuses to edit or delete
|
|
# them, the seeder reconciles them. Distinct from 'local' so a site's own
|
|
# policies are never confused with what the product shipped.
|
|
POLICY_SOURCE_SHIPPED = 'shipped'
|
|
|
|
|
|
class ConfigurationPolicy(BaseModel):
|
|
"""A single reusable policy: a scope plus the actions it applies.
|
|
|
|
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
|
|
stored as a small typed JSON AST in scopejson; see services/policyscope.py
|
|
for the evaluator. Actions live in ConfigurationPolicyAction rows.
|
|
"""
|
|
__tablename__ = 'configurationpolicies'
|
|
|
|
configurationpolicyid = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
|
description = db.Column(db.Text)
|
|
|
|
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
|
|
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
|
|
# (known node types, depth cap) before it is stored - see policyscope.
|
|
scopejson = db.Column(JSONText, nullable=True)
|
|
|
|
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
|
|
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
|
|
|
# createddate, modifieddate, isactive come from BaseModel.
|
|
|
|
actions = db.relationship(
|
|
'ConfigurationPolicyAction',
|
|
back_populates='configurationpolicy',
|
|
cascade='all, delete-orphan',
|
|
)
|
|
|
|
__table_args__ = (
|
|
db.Index('idx_configpolicy_source', 'source'),
|
|
db.Index('idx_configpolicy_active', 'isactive'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ConfigurationPolicy {self.name}>"
|
|
|
|
|
|
# What a policy action does to a matched endpoint.
|
|
# desired: reconciled desired-state. desiredstate says what it should be
|
|
# (e.g. running=true for mtconnect); the server enqueues the
|
|
# drift-correcting command when telemetry disagrees.
|
|
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
|
|
# matched endpoints; it constrains what may be enqueued, it does not
|
|
# drive anything on its own.
|
|
ACTION_MODE_DESIRED = 'desired'
|
|
ACTION_MODE_GATE = 'gate'
|
|
|
|
|
|
class ConfigurationPolicyAction(BaseModel):
|
|
"""One allowlisted action carried by a ConfigurationPolicy.
|
|
|
|
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
|
|
it is validated against that allowlist at write time. `mode` selects
|
|
desired-state reconciliation vs allowlist-gating.
|
|
"""
|
|
__tablename__ = 'configurationpolicyactions'
|
|
|
|
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
|
|
configurationpolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
|
ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
|
|
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
|
|
# is code, not a table - so it is validated in the API layer.
|
|
action = db.Column(db.String(100), nullable=False)
|
|
|
|
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
|
|
|
|
# Desired-state target for mode=desired, as JSON so different actions can
|
|
# express different targets (e.g. {"running": true}). Null for gate mode.
|
|
desiredstate = db.Column(JSONText, nullable=True)
|
|
|
|
# Optional action parameters passed through to the command (JSON), and an
|
|
# optional reconcile cadence in minutes (null = reconcile on every report).
|
|
params = db.Column(JSONText, nullable=True)
|
|
intervalminutes = db.Column(db.Integer, nullable=True)
|
|
|
|
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
configurationpolicy = db.relationship(
|
|
'ConfigurationPolicy', back_populates='actions')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('configurationpolicyid', 'action',
|
|
name='uq_configpolicyaction'),
|
|
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
|
|
|
|
|
|
class BusinessPolicy(BaseModel):
|
|
"""An ordered collection of Configuration Policies assigned to a fleet.
|
|
|
|
Priority orders Business Policies against each other when several match one
|
|
endpoint: lower priority number is evaluated first, and the first policy to
|
|
speak on a given action name wins (first-match-wins per action).
|
|
"""
|
|
__tablename__ = 'businesspolicies'
|
|
|
|
businesspolicyid = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(150), unique=True, nullable=False)
|
|
description = db.Column(db.Text)
|
|
|
|
# Lower runs first. Ties broken by businesspolicyid for a stable order.
|
|
priority = db.Column(db.Integer, default=100, nullable=False)
|
|
|
|
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
|
|
|
|
# createddate, modifieddate, isactive come from BaseModel.
|
|
|
|
items = db.relationship(
|
|
'BusinessPolicyItem',
|
|
back_populates='businesspolicy',
|
|
cascade='all, delete-orphan',
|
|
order_by='BusinessPolicyItem.sortorder',
|
|
)
|
|
|
|
__table_args__ = (
|
|
db.Index('idx_businesspolicy_priority', 'priority'),
|
|
db.Index('idx_businesspolicy_active', 'isactive'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<BusinessPolicy {self.name} priority={self.priority}>"
|
|
|
|
|
|
class BusinessPolicyItem(BaseModel):
|
|
"""Membership of a Configuration Policy in a Business Policy, ordered.
|
|
|
|
sortorder sets the walk order WITHIN a Business Policy; combined with the
|
|
Business Policy priority it gives a fully deterministic fold order for the
|
|
effective-policy resolver.
|
|
"""
|
|
__tablename__ = 'businesspolicyitems'
|
|
|
|
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
|
|
businesspolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
configurationpolicyid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('configurationpolicies.configurationpolicyid',
|
|
ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
sortorder = db.Column(db.Integer, default=0, nullable=False)
|
|
|
|
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
|
|
configurationpolicy = db.relationship('ConfigurationPolicy')
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
|
|
name='uq_businesspolicyitem'),
|
|
db.Index('idx_bpitem_business', 'businesspolicyid'),
|
|
db.Index('idx_bpitem_config', 'configurationpolicyid'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
|
|
f"cp={self.configurationpolicyid}>")
|