mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
An audit found 102 FK columns with no index — every join, per-parent listing, and the delete cascades from _delete_cascade_policy scanned whole tables on them (deployments.app_id, audit_logs.user_id, dns_records.zone_id, webhook_logs.webhook_id, ...). All FK columns now declare index=True; migration 096 creates the same indexes on existing installs, deriving the list from the live schema at upgrade time (so it is idempotent and cannot drift from what an install actually has). Ratchet: test_every_fk_column_is_indexed pins the rule with no allowlist — a new FK column is born indexed or the build fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
721 lines
31 KiB
Python
721 lines
31 KiB
Python
import uuid
|
|
import secrets
|
|
from datetime import datetime
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
from app import db
|
|
from app.models.mixins import EncryptedSecret, uuid_pk, TimestampMixin, SerializableMixin
|
|
from app.models.json_column_mixin import JsonColumnMixin
|
|
|
|
|
|
class ServerGroup(TimestampMixin, db.Model):
|
|
"""Group servers for organization"""
|
|
__tablename__ = 'server_groups'
|
|
|
|
id = uuid_pk()
|
|
name = db.Column(db.String(100), nullable=False)
|
|
description = db.Column(db.Text)
|
|
color = db.Column(db.String(7), default='#6366f1') # Hex color for UI
|
|
icon = db.Column(db.String(50), default='server') # Icon name
|
|
parent_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True, index=True)
|
|
|
|
# Fleet Management
|
|
auto_upgrade = db.Column(db.Boolean, default=False)
|
|
upgrade_channel = db.Column(db.String(20), default='stable') # stable, beta
|
|
|
|
|
|
# Relationships
|
|
# Use 'subquery' to eagerly load servers in a single query, avoiding N+1
|
|
servers = db.relationship('Server', back_populates='group', lazy='subquery')
|
|
children = db.relationship('ServerGroup', backref=db.backref('parent', remote_side=[id]))
|
|
|
|
def to_dict(self, include_servers=False):
|
|
result = {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'description': self.description,
|
|
'color': self.color,
|
|
'icon': self.icon,
|
|
'parent_id': self.parent_id,
|
|
'auto_upgrade': self.auto_upgrade,
|
|
'upgrade_channel': self.upgrade_channel,
|
|
'server_count': len(self.servers),
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if include_servers:
|
|
result['servers'] = [s.to_dict() for s in self.servers]
|
|
return result
|
|
|
|
def __repr__(self):
|
|
return f'<ServerGroup {self.name}>'
|
|
|
|
|
|
class Server(TimestampMixin, JsonColumnMixin, db.Model):
|
|
"""Represents a remote server managed by ServerKit"""
|
|
__tablename__ = 'servers'
|
|
|
|
DOCKER_READ_ACTIONS = {
|
|
'container': {'list', 'inspect', 'logs', 'stats'},
|
|
'image': {'list'},
|
|
'volume': {'list'},
|
|
'network': {'list'},
|
|
'compose': {'list', 'ps', 'logs'},
|
|
}
|
|
SYSTEM_READ_ACTIONS = {'metrics', 'info', 'processes'}
|
|
|
|
# Control-plane actions that aren't gated by per-server permissions:
|
|
# the panel issues these to discover state or refresh metadata, not
|
|
# to mutate the host. Without this list, an agent with the
|
|
# 'docker_manager' profile (no 'agent:*' grant) would 403 on the
|
|
# Refresh button in the System Status card. Authentication still
|
|
# happens at the JWT layer; this only short-circuits the per-server
|
|
# ACL.
|
|
CONTROL_PLANE_ACTIONS = {
|
|
'agent:recapabilities',
|
|
'agent:update',
|
|
}
|
|
|
|
id = uuid_pk()
|
|
|
|
# Basic Info
|
|
name = db.Column(db.String(100), nullable=False)
|
|
description = db.Column(db.Text)
|
|
hostname = db.Column(db.String(255)) # Reported by agent
|
|
ip_address = db.Column(db.String(45)) # IPv4 or IPv6
|
|
|
|
# Organization
|
|
group_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True, index=True)
|
|
# Workspace scoping (#33). Nullable: existing rows are backfilled to a default
|
|
# workspace by migration 015; new rows are stamped on create.
|
|
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=True, index=True)
|
|
tags = db.Column(db.JSON, default=list) # ["production", "us-east", "docker"]
|
|
|
|
# Status
|
|
status = db.Column(db.String(20), default='pending', index=True)
|
|
# pending, connecting, online, offline, error, maintenance
|
|
last_seen = db.Column(db.DateTime)
|
|
last_error = db.Column(db.Text)
|
|
|
|
# Onboarding lifecycle (separate from the flat `status` above). Tracks the
|
|
# provisioning state machine: pending -> validating ->
|
|
# installing_prerequisites -> installing_docker -> pairing_agent -> ready
|
|
# (or failed). `onboarding_progress` mirrors the latest log rows as JSON for
|
|
# cheap reads; the authoritative history is in server_onboarding_logs.
|
|
onboarding_state = db.Column(db.String(40), nullable=True, default='pending')
|
|
onboarding_progress = db.Column(db.Text, nullable=True) # JSON snapshot of recent steps
|
|
onboarding_updated_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
# Agent Info
|
|
agent_version = db.Column(db.String(20))
|
|
agent_id = db.Column(db.String(36), unique=True, index=True) # Agent's UUID
|
|
auto_upgrade = db.Column(db.Boolean, default=False)
|
|
upgrade_channel = db.Column(db.String(20), default='stable') # stable, beta
|
|
|
|
# Managed vs observed (plan 27 D1, plan 31 #10). Migrations 065/068 have
|
|
# always carried these columns; the model fields and MANAGEMENT_MODES were
|
|
# lost in the 2026-07 data loss, which left the survey management-mode
|
|
# routes raising AttributeError (restored via plan 82's migration↔model
|
|
# drift gate). Observed boxes refuse mutating agent commands;
|
|
# allow_agent_update_observed is the explicit break-glass that still
|
|
# permits agent:update on an observed box.
|
|
MANAGEMENT_MODES = ('managed', 'observed')
|
|
management_mode = db.Column(db.String(16), nullable=False,
|
|
default='managed', server_default='managed')
|
|
allow_agent_update_observed = db.Column(db.Boolean, nullable=False,
|
|
default=False, server_default='0')
|
|
|
|
@property
|
|
def is_managed(self):
|
|
"""False only for observed boxes (plan 27 D1)."""
|
|
return (self.management_mode or 'managed') != 'observed'
|
|
|
|
# System Info (reported by agent)
|
|
os_type = db.Column(db.String(20)) # linux, windows, darwin
|
|
os_version = db.Column(db.String(100))
|
|
platform = db.Column(db.String(100))
|
|
architecture = db.Column(db.String(20)) # amd64, arm64
|
|
cpu_cores = db.Column(db.Integer)
|
|
cpu_model = db.Column(db.String(200))
|
|
total_memory = db.Column(db.BigInteger) # bytes
|
|
total_disk = db.Column(db.BigInteger) # bytes
|
|
docker_version = db.Column(db.String(50))
|
|
# Agent's self-reported footprint (binary dir / config dir) — preferred
|
|
# over installer-default conventions by target-aware UIs (File Manager).
|
|
agent_install_dir = db.Column(db.String(255))
|
|
agent_config_dir = db.Column(db.String(255))
|
|
|
|
# Security
|
|
api_key_hash = db.Column(db.String(256)) # bcrypt hash
|
|
api_key_prefix = db.Column(db.String(12)) # For identification: "sk_abc123"
|
|
api_secret_encrypted = db.Column(db.Text) # Fernet-encrypted api_secret for signature verification
|
|
permissions = db.Column(db.JSON, default=list) # List of permission scopes
|
|
allowed_ips = db.Column(db.JSON, default=list) # IP whitelist (empty = all)
|
|
|
|
# API Key Rotation
|
|
api_key_pending_hash = db.Column(db.String(256)) # Hash of pending new API key
|
|
api_key_pending_prefix = db.Column(db.String(12)) # Prefix of pending new API key
|
|
api_secret_pending_encrypted = db.Column(db.Text) # Encrypted pending new API secret
|
|
|
|
# One crypto path (plan 77 C1): descriptor-backed accessors over the
|
|
# *_encrypted columns. Encrypt failures RAISE (the old accessors printed
|
|
# and silently stored nothing — a data-loss path).
|
|
api_secret = EncryptedSecret('api_secret_encrypted')
|
|
api_secret_pending = EncryptedSecret('api_secret_pending_encrypted')
|
|
api_key_rotation_expires = db.Column(db.DateTime) # When pending key rotation expires
|
|
api_key_rotation_id = db.Column(db.String(36)) # Unique ID for current rotation
|
|
api_key_last_rotated = db.Column(db.DateTime) # Last successful rotation timestamp
|
|
|
|
# Registration
|
|
registration_token_hash = db.Column(db.String(256))
|
|
registration_token_expires = db.Column(db.DateTime)
|
|
registered_at = db.Column(db.DateTime)
|
|
registered_by = db.Column(db.Integer, db.ForeignKey('users.id'), index=True)
|
|
|
|
# Cached capability snapshot — written every time the agent reports
|
|
# a Capabilities message, so the panel can render the full Overview
|
|
# tab even when the agent is offline. Old agents that pre-date these
|
|
# fields just have NULL here; the UI falls back gracefully.
|
|
cached_capabilities = db.Column(db.JSON) # {docker:bool, files:bool, ...}
|
|
cached_runtimes = db.Column(db.JSON) # {python:"3.11.4", node:"20.10.0", ...}
|
|
cached_runtime_managers = db.Column(db.JSON) # {python:"pyenv-win"|"pyenv"|""}
|
|
cached_allowed_paths = db.Column(db.JSON) # ["/var/www", ...]
|
|
cached_sudo = db.Column(db.String(20)) # "root"|"passwordless"|"unavailable"
|
|
cached_systemd_json = db.Column(db.Boolean) # systemctl --output=json supported?
|
|
capabilities_at = db.Column(db.DateTime) # when the snapshot was last refreshed
|
|
|
|
# Timestamps
|
|
|
|
# Relationships
|
|
group = db.relationship('ServerGroup', back_populates='servers')
|
|
metrics = db.relationship('ServerMetrics', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
|
|
commands = db.relationship('ServerCommand', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
|
|
sessions = db.relationship('AgentSession', back_populates='server', lazy='dynamic', cascade='all, delete-orphan')
|
|
|
|
@staticmethod
|
|
def generate_registration_token():
|
|
"""Generate a secure registration token"""
|
|
return f"sk_reg_{secrets.token_urlsafe(32)}"
|
|
|
|
@staticmethod
|
|
def generate_api_credentials():
|
|
"""Generate API key and secret"""
|
|
api_key = f"sk_{secrets.token_urlsafe(16)}"
|
|
api_secret = secrets.token_urlsafe(32)
|
|
return api_key, api_secret
|
|
|
|
def set_registration_token(self, token):
|
|
"""Hash and store registration token"""
|
|
self.registration_token_hash = generate_password_hash(token)
|
|
|
|
def verify_registration_token(self, token):
|
|
"""Verify a registration token"""
|
|
if not self.registration_token_hash:
|
|
return False
|
|
if self.registration_token_expires and datetime.utcnow() > self.registration_token_expires:
|
|
return False
|
|
return check_password_hash(self.registration_token_hash, token)
|
|
|
|
def set_api_key(self, api_key):
|
|
"""Hash and store API key"""
|
|
self.api_key_hash = generate_password_hash(api_key)
|
|
self.api_key_prefix = api_key[:12] if len(api_key) >= 12 else api_key
|
|
|
|
def verify_api_key(self, api_key):
|
|
"""Verify an API key"""
|
|
if not self.api_key_hash:
|
|
return False
|
|
return check_password_hash(self.api_key_hash, api_key)
|
|
|
|
def set_api_secret_encrypted(self, api_secret):
|
|
"""Encrypt and store the API secret for signature verification.
|
|
|
|
Raises if encryption fails — silently storing nothing would leave an
|
|
agent "paired" but unable to ever verify a signature.
|
|
"""
|
|
self.api_secret = api_secret
|
|
|
|
def get_api_secret(self):
|
|
"""Decrypt and return the API secret (None if unset/undecryptable)."""
|
|
return self.api_secret
|
|
|
|
def get_pending_api_secret(self):
|
|
"""Decrypt and return the pending API secret used during key rotation.
|
|
|
|
Mirrors get_api_secret() for the api_secret_pending_encrypted column so
|
|
an agent that authenticates with the pending key during a rotation
|
|
window can have its signature verified against the right secret.
|
|
Returns None when there is no pending secret or it can't be decrypted.
|
|
"""
|
|
return self.api_secret_pending
|
|
|
|
def start_key_rotation(self):
|
|
"""
|
|
Start API key rotation process.
|
|
|
|
Returns tuple: (new_api_key, new_api_secret, rotation_id)
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
new_api_key, new_api_secret = self.generate_api_credentials()
|
|
rotation_id = str(uuid.uuid4())
|
|
|
|
# Store pending credentials
|
|
self.api_key_pending_hash = generate_password_hash(new_api_key)
|
|
self.api_key_pending_prefix = new_api_key[:12] if len(new_api_key) >= 12 else new_api_key
|
|
|
|
self.api_secret_pending = new_api_secret
|
|
|
|
self.api_key_rotation_id = rotation_id
|
|
self.api_key_rotation_expires = datetime.utcnow() + timedelta(minutes=5)
|
|
|
|
return new_api_key, new_api_secret, rotation_id
|
|
|
|
def complete_key_rotation(self, rotation_id):
|
|
"""
|
|
Complete API key rotation by activating pending credentials.
|
|
|
|
Returns: True if successful, False otherwise
|
|
"""
|
|
if self.api_key_rotation_id != rotation_id:
|
|
return False
|
|
|
|
if self.api_key_rotation_expires and datetime.utcnow() > self.api_key_rotation_expires:
|
|
self.cancel_key_rotation()
|
|
return False
|
|
|
|
# Move pending to active
|
|
self.api_key_hash = self.api_key_pending_hash
|
|
self.api_key_prefix = self.api_key_pending_prefix
|
|
self.api_secret_encrypted = self.api_secret_pending_encrypted
|
|
|
|
# Clear pending and record rotation
|
|
self.api_key_pending_hash = None
|
|
self.api_key_pending_prefix = None
|
|
self.api_secret_pending_encrypted = None
|
|
self.api_key_rotation_id = None
|
|
self.api_key_rotation_expires = None
|
|
self.api_key_last_rotated = datetime.utcnow()
|
|
|
|
return True
|
|
|
|
def cancel_key_rotation(self):
|
|
"""Cancel a pending key rotation"""
|
|
self.api_key_pending_hash = None
|
|
self.api_key_pending_prefix = None
|
|
self.api_secret_pending_encrypted = None
|
|
self.api_key_rotation_id = None
|
|
self.api_key_rotation_expires = None
|
|
|
|
def verify_pending_api_key(self, api_key):
|
|
"""Verify against pending API key during rotation"""
|
|
if not self.api_key_pending_hash:
|
|
return False
|
|
if self.api_key_rotation_expires and datetime.utcnow() > self.api_key_rotation_expires:
|
|
return False
|
|
return check_password_hash(self.api_key_pending_hash, api_key)
|
|
|
|
def has_permission(self, scope):
|
|
"""Check if server/agent has a specific permission scope"""
|
|
# Control-plane actions (agent:recapabilities, agent:update) are
|
|
# not gated — they're issued by the panel to discover state and
|
|
# refresh metadata. Authentication is enforced at the JWT layer.
|
|
if scope in self.CONTROL_PLANE_ACTIONS:
|
|
return True
|
|
if not self.permissions:
|
|
return False
|
|
if '*' in self.permissions:
|
|
return True
|
|
|
|
candidate_scopes = self._expand_permission_scope(scope)
|
|
for perm in self.permissions:
|
|
for candidate in candidate_scopes:
|
|
if self._permission_matches(perm, candidate):
|
|
return True
|
|
return False
|
|
|
|
# Verb tokens that mean "read this thing, don't change it." When the
|
|
# last component of an action matches one of these, the expander
|
|
# adds a `<namespace>:read` candidate so a permissions list with
|
|
# only generic read grants doesn't 403 on a specific list/inspect
|
|
# call.
|
|
READ_ACTION_VERBS = {
|
|
'list', 'list_installed', 'list_units',
|
|
'read', 'get', 'inspect', 'status', 'info',
|
|
'search', 'logs', 'stats', 'available',
|
|
'installed', 'current', 'show',
|
|
# Fleet doctor / survey probes are Observed-safe reads (plan 28 #7):
|
|
# they only discover host state, so they classify as reads and expand
|
|
# to `<ns>:read` rather than `<ns>:write`.
|
|
'probe', 'survey',
|
|
}
|
|
|
|
@classmethod
|
|
def is_read_action(cls, action):
|
|
"""Return True when an agent action is a non-mutating read.
|
|
|
|
Classified purely by the leaf verb: ``doctor:probe`` / ``systemd:status``
|
|
are reads (Observed-safe), while ``cron:update`` / ``systemd:restart``
|
|
are mutating. Used by the fleet doctor to decide whether a command may
|
|
run under the Observed (read-only) trust level.
|
|
"""
|
|
parts = action.split(':')
|
|
return bool(parts) and parts[-1] in cls.READ_ACTION_VERBS
|
|
|
|
@classmethod
|
|
def _expand_permission_scope(cls, scope):
|
|
"""Add profile and legacy aliases for an agent command scope."""
|
|
candidate_scopes = {scope}
|
|
scope_parts = scope.split(':')
|
|
|
|
if len(scope_parts) >= 3 and scope_parts[0] == 'docker':
|
|
resource = scope_parts[1]
|
|
action = scope_parts[2]
|
|
read_actions = cls.DOCKER_READ_ACTIONS.get(resource, set())
|
|
|
|
if action in read_actions:
|
|
candidate_scopes.add(f'docker:{resource}:read')
|
|
candidate_scopes.add('docker:read')
|
|
else:
|
|
candidate_scopes.add(f'docker:{resource}:write')
|
|
candidate_scopes.add('docker:write')
|
|
|
|
if len(scope_parts) >= 2 and scope_parts[0] == 'system':
|
|
action = scope_parts[1]
|
|
if action in cls.SYSTEM_READ_ACTIONS:
|
|
candidate_scopes.add(f'system:{action}:read')
|
|
candidate_scopes.add('system:metrics:read')
|
|
candidate_scopes.add('system:read')
|
|
|
|
# Generic read-action expansion for the namespaces we added in
|
|
# later phases (packages, systemd, runtimes, cloudflared, file,
|
|
# cron). When the leaf verb is a read (list/info/status/etc.),
|
|
# accept either an exact match, a `<ns>:read`, a `<ns>:*`, or
|
|
# any of the legacy `<ns>:<sub>:read` variants. Mutating verbs
|
|
# fall through to require explicit grants.
|
|
if scope_parts:
|
|
namespace = scope_parts[0]
|
|
tail = scope_parts[-1]
|
|
if tail in cls.READ_ACTION_VERBS:
|
|
candidate_scopes.add(f'{namespace}:read')
|
|
if len(scope_parts) >= 2:
|
|
candidate_scopes.add(f'{namespace}:{scope_parts[1]}:read')
|
|
else:
|
|
candidate_scopes.add(f'{namespace}:write')
|
|
|
|
return candidate_scopes
|
|
|
|
@staticmethod
|
|
def _permission_matches(permission, scope):
|
|
"""Check exact and wildcard patterns like docker:* or docker:container:*."""
|
|
if permission == scope:
|
|
return True
|
|
|
|
scope_parts = scope.split(':')
|
|
perm_parts = permission.split(':')
|
|
|
|
if len(perm_parts) > len(scope_parts):
|
|
return False
|
|
|
|
for i, part in enumerate(perm_parts):
|
|
if part == '*':
|
|
return True
|
|
if i >= len(scope_parts) or part != scope_parts[i]:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _onboarding_progress_list(self):
|
|
"""Decode the cached onboarding_progress JSON snapshot to a list.
|
|
|
|
Returns an empty list when unset or malformed so the API/UI never
|
|
crash on legacy/partial data.
|
|
"""
|
|
return self._json_read('onboarding_progress', [], expect=list)
|
|
|
|
# Emitted by to_dict but not mapped columns: each costs a relationship load
|
|
# or a JSON walk, so `$select` has to know they exist in order to decline
|
|
# them. Kept next to to_dict so the two cannot drift.
|
|
DERIVED_FIELDS = ('group_name', 'onboarding_progress', 'latest_metrics')
|
|
|
|
def to_dict(self, include_metrics=False, fields=None):
|
|
"""Serialize the server.
|
|
|
|
`fields` (normally from ``$select``) narrows the payload AND skips the
|
|
derived work. `onboarding_progress` alone is ~29% of a row and is read
|
|
by exactly one screen; `group_name` is a relationship load. A list that
|
|
only needs {id, name} should pay for neither.
|
|
"""
|
|
want = fields.__contains__ if fields is not None else (lambda _key: True)
|
|
result = {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'description': self.description,
|
|
'hostname': self.hostname,
|
|
'ip_address': self.ip_address,
|
|
'group_id': self.group_id,
|
|
'workspace_id': self.workspace_id,
|
|
'tags': self.tags or [],
|
|
'status': self.status,
|
|
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
|
'last_error': self.last_error,
|
|
'management_mode': self.management_mode or 'managed',
|
|
'allow_agent_update_observed': bool(self.allow_agent_update_observed),
|
|
'onboarding_state': self.onboarding_state,
|
|
'onboarding_updated_at': self.onboarding_updated_at.isoformat() if self.onboarding_updated_at else None,
|
|
'agent_version': self.agent_version,
|
|
'agent_id': self.agent_id,
|
|
'os_type': self.os_type,
|
|
'os_version': self.os_version,
|
|
'platform': self.platform,
|
|
'architecture': self.architecture,
|
|
'cpu_cores': self.cpu_cores,
|
|
'cpu_model': self.cpu_model,
|
|
'total_memory': self.total_memory,
|
|
'total_disk': self.total_disk,
|
|
'docker_version': self.docker_version,
|
|
'agent_install_dir': self.agent_install_dir,
|
|
'agent_config_dir': self.agent_config_dir,
|
|
'permissions': self.permissions or [],
|
|
'allowed_ips': self.allowed_ips or [],
|
|
'api_key_last_rotated': self.api_key_last_rotated.isoformat() if self.api_key_last_rotated else None,
|
|
'registered_at': self.registered_at.isoformat() if self.registered_at else None,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if fields is not None:
|
|
result = {key: value for key, value in result.items() if key in fields}
|
|
|
|
# The derived three, each skippable.
|
|
if want('group_name'):
|
|
result['group_name'] = self.group.name if self.group else None
|
|
if want('onboarding_progress'):
|
|
result['onboarding_progress'] = self._onboarding_progress_list()
|
|
if include_metrics and want('latest_metrics'):
|
|
# `metrics` is lazy='dynamic', so this is a query per server — the
|
|
# list route batches it instead and never passes include_metrics.
|
|
latest = self.metrics.order_by(ServerMetrics.timestamp.desc()).first()
|
|
if latest:
|
|
result['latest_metrics'] = latest.to_dict()
|
|
return result
|
|
|
|
def __repr__(self):
|
|
return f'<Server {self.name}>'
|
|
|
|
|
|
class ServerMetrics(SerializableMixin, db.Model):
|
|
"""Historical metrics from servers"""
|
|
__tablename__ = 'server_metrics'
|
|
|
|
# SQLite only autoincrements when the column type is exactly INTEGER
|
|
# PRIMARY KEY (rowid alias). With BigInteger every insert failed with
|
|
# "NOT NULL constraint failed: server_metrics.id" and no metrics ever
|
|
# got stored — exactly the silent dashboard-zeroes symptom we just
|
|
# tracked down. Integer caps at ~2.1B rows which at 1 sample/sec is
|
|
# ~68 years per server, plenty.
|
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
|
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
|
|
|
|
timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
|
|
|
# System Metrics
|
|
cpu_percent = db.Column(db.Float)
|
|
memory_percent = db.Column(db.Float)
|
|
memory_used = db.Column(db.BigInteger)
|
|
disk_percent = db.Column(db.Float)
|
|
disk_used = db.Column(db.BigInteger)
|
|
network_rx = db.Column(db.BigInteger) # Bytes received (total)
|
|
network_tx = db.Column(db.BigInteger) # Bytes transmitted (total)
|
|
network_rx_rate = db.Column(db.Float) # Bytes/sec
|
|
network_tx_rate = db.Column(db.Float) # Bytes/sec
|
|
|
|
# Docker Metrics
|
|
container_count = db.Column(db.Integer)
|
|
container_running = db.Column(db.Integer)
|
|
|
|
# Additional data as JSON
|
|
extra = db.Column(db.JSON)
|
|
|
|
server = db.relationship('Server', back_populates='metrics')
|
|
|
|
__table_args__ = (
|
|
db.Index('ix_server_metrics_server_time', 'server_id', 'timestamp'),
|
|
)
|
|
|
|
|
|
|
|
class ServerCommand(SerializableMixin, db.Model):
|
|
"""Audit log of commands executed on servers"""
|
|
__tablename__ = 'server_commands'
|
|
|
|
id = uuid_pk()
|
|
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True)
|
|
|
|
# Command details
|
|
command_type = db.Column(db.String(50)) # docker:container:start, system:exec, etc.
|
|
command_data = db.Column(db.JSON) # The actual command/parameters
|
|
|
|
# Execution
|
|
status = db.Column(db.String(20), default='pending') # pending, running, completed, failed, timeout
|
|
started_at = db.Column(db.DateTime)
|
|
completed_at = db.Column(db.DateTime)
|
|
|
|
# Result
|
|
result = db.Column(db.JSON)
|
|
error = db.Column(db.Text)
|
|
exit_code = db.Column(db.Integer)
|
|
|
|
# Retry / offline queue
|
|
retry_count = db.Column(db.Integer, default=0)
|
|
max_retries = db.Column(db.Integer, default=3)
|
|
next_retry_at = db.Column(db.DateTime)
|
|
backoff_seconds = db.Column(db.Integer, default=30) # initial backoff, doubles each retry
|
|
queued = db.Column(db.Boolean, default=False) # True when agent was offline at send time
|
|
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
server = db.relationship('Server', back_populates='commands')
|
|
|
|
# Serialization comes from SerializableMixin; these columns stay out
|
|
# of API payloads (parity with the deleted hand-written to_dict).
|
|
__serialize_exclude__ = ('backoff_seconds', 'next_retry_at')
|
|
|
|
|
|
class AgentSession(SerializableMixin, db.Model):
|
|
"""Active agent WebSocket sessions"""
|
|
__tablename__ = 'agent_sessions'
|
|
|
|
id = uuid_pk()
|
|
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True)
|
|
|
|
session_token = db.Column(db.String(256)) # Current session token
|
|
socket_id = db.Column(db.String(100)) # SocketIO session ID
|
|
connected_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
last_heartbeat = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
ip_address = db.Column(db.String(45))
|
|
user_agent = db.Column(db.String(255)) # Agent version info
|
|
|
|
# Latency tracking
|
|
heartbeat_latency_ms = db.Column(db.Float) # Latest heartbeat round-trip latency
|
|
avg_latency_ms = db.Column(db.Float) # Running average latency
|
|
latency_samples = db.Column(db.Integer, default=0) # Number of samples in average
|
|
|
|
is_active = db.Column(db.Boolean, default=True, index=True)
|
|
disconnected_at = db.Column(db.DateTime)
|
|
disconnect_reason = db.Column(db.String(100))
|
|
|
|
server = db.relationship('Server', back_populates='sessions')
|
|
|
|
def update_latency(self, latency_ms):
|
|
"""Update latency with exponential moving average"""
|
|
self.heartbeat_latency_ms = latency_ms
|
|
if self.avg_latency_ms is None or self.latency_samples == 0:
|
|
self.avg_latency_ms = latency_ms
|
|
self.latency_samples = 1
|
|
else:
|
|
# EMA with alpha = 0.2 for smoothing
|
|
alpha = 0.2
|
|
self.avg_latency_ms = alpha * latency_ms + (1 - alpha) * self.avg_latency_ms
|
|
self.latency_samples += 1
|
|
|
|
# Serialization comes from SerializableMixin; these columns stay out
|
|
# of API payloads (parity with the deleted hand-written to_dict).
|
|
__serialize_exclude__ = ('latency_samples', 'session_token', 'socket_id', 'user_agent')
|
|
|
|
|
|
class AgentRollout(TimestampMixin, db.Model):
|
|
"""Tracks staged rollout progress"""
|
|
__tablename__ = 'agent_rollouts'
|
|
|
|
id = uuid_pk()
|
|
version_id = db.Column(db.String(36), db.ForeignKey('agent_versions.id'), nullable=False, index=True)
|
|
group_id = db.Column(db.String(36), db.ForeignKey('server_groups.id'), nullable=True, index=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True)
|
|
|
|
# Configuration
|
|
batch_size = db.Column(db.Integer, default=5)
|
|
delay_minutes = db.Column(db.Integer, default=10)
|
|
strategy = db.Column(db.String(20), default='staged') # staged, all, canary
|
|
|
|
# Progress
|
|
status = db.Column(db.String(20), default='pending') # pending, running, paused, completed, failed, cancelled
|
|
total_servers = db.Column(db.Integer, default=0)
|
|
processed_servers = db.Column(db.Integer, default=0)
|
|
failed_servers = db.Column(db.Integer, default=0)
|
|
current_wave = db.Column(db.Integer, default=0)
|
|
|
|
# Results per server
|
|
server_results = db.Column(db.JSON, default=list) # [{server_id, status, error, wave}]
|
|
|
|
error = db.Column(db.Text)
|
|
|
|
started_at = db.Column(db.DateTime)
|
|
completed_at = db.Column(db.DateTime)
|
|
|
|
# Parent-side backref so the delete-cascade policy covers the NOT NULL FK.
|
|
version = db.relationship('AgentVersion',
|
|
backref=db.backref('rollouts', lazy='dynamic'))
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'version_id': self.version_id,
|
|
'version': self.version.version if self.version else None,
|
|
'group_id': self.group_id,
|
|
'user_id': self.user_id,
|
|
'batch_size': self.batch_size,
|
|
'delay_minutes': self.delay_minutes,
|
|
'strategy': self.strategy,
|
|
'status': self.status,
|
|
'total_servers': self.total_servers,
|
|
'processed_servers': self.processed_servers,
|
|
'failed_servers': self.failed_servers,
|
|
'current_wave': self.current_wave,
|
|
'server_results': self.server_results or [],
|
|
'error': self.error,
|
|
'started_at': self.started_at.isoformat() if self.started_at else None,
|
|
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class AgentVersion(TimestampMixin, db.Model):
|
|
"""Available agent versions and compatibility matrix"""
|
|
__tablename__ = 'agent_versions'
|
|
|
|
id = uuid_pk()
|
|
version = db.Column(db.String(20), nullable=False, unique=True)
|
|
channel = db.Column(db.String(20), default='stable') # stable, beta
|
|
|
|
# Compatibility
|
|
min_panel_version = db.Column(db.String(20))
|
|
max_panel_version = db.Column(db.String(20))
|
|
|
|
# Metadata
|
|
release_notes = db.Column(db.Text)
|
|
is_active = db.Column(db.Boolean, default=True)
|
|
published_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
|
|
# Assets (mapped by platform: linux-amd64, windows-amd64, etc.)
|
|
assets = db.Column(db.JSON) # {"linux-amd64": "url", "checksums": "url"}
|
|
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'version': self.version,
|
|
'channel': self.channel,
|
|
'min_panel_version': self.min_panel_version,
|
|
'max_panel_version': self.max_panel_version,
|
|
'release_notes': self.release_notes,
|
|
'is_active': self.is_active,
|
|
'published_at': self.published_at.isoformat() if self.published_at else None,
|
|
'assets': self.assets or {},
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
def __repr__(self):
|
|
return f'<AgentVersion {self.version}>'
|