mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 19:21:58 -06:00
Add Engine SQLite infrastructure
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
- 3.2 Stub out blueprint/Socket.IO registration hooks that mirror names from legacy code (no logic yet).
|
- 3.2 Stub out blueprint/Socket.IO registration hooks that mirror names from legacy code (no logic yet).
|
||||||
- 3.3 Smoke-test app startup via `python Data/Engine/bootstrapper.py` (or Flask CLI) to ensure no regressions.
|
- 3.3 Smoke-test app startup via `python Data/Engine/bootstrapper.py` (or Flask CLI) to ensure no regressions.
|
||||||
|
|
||||||
4. Establish SQLite infrastructure
|
[COMPLETED] 4. Establish SQLite infrastructure
|
||||||
- 4.1 Copy `_db_conn` logic into `repositories/sqlite/connection.py`, parameterized by database path (`<root>/database.db`).
|
- 4.1 Copy `_db_conn` logic into `repositories/sqlite/connection.py`, parameterized by database path (`<root>/database.db`).
|
||||||
- 4.2 Port migration helpers into `repositories/sqlite/migrations.py`; expose an `apply_all()` callable.
|
- 4.2 Port migration helpers into `repositories/sqlite/migrations.py`; expose an `apply_all()` callable.
|
||||||
- 4.3 Wire migrations to run during Engine bootstrap (behind a flag) and confirm tables initialize in a sandbox DB.
|
- 4.3 Wire migrations to run during Engine bootstrap (behind a flag) and confirm tables initialize in a sandbox DB.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The Engine mirrors the legacy defaults so it can boot without additional configu
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `BOREALIS_ROOT` | Overrides automatic project root detection. Useful when running from a packaged location. | Directory two levels above `Data/Engine/` |
|
| `BOREALIS_ROOT` | Overrides automatic project root detection. Useful when running from a packaged location. | Directory two levels above `Data/Engine/` |
|
||||||
| `BOREALIS_DATABASE_PATH` | Path to the SQLite database. | `<project_root>/database.db` |
|
| `BOREALIS_DATABASE_PATH` | Path to the SQLite database. | `<project_root>/database.db` |
|
||||||
|
| `BOREALIS_ENGINE_AUTO_MIGRATE` | Run Engine-managed schema migrations during bootstrap (`true`/`false`). | `true` |
|
||||||
| `BOREALIS_STATIC_ROOT` | Directory that serves static assets for the SPA. | First existing path among `Data/Server/web-interface/build`, `Data/Server/WebUI/build`, `Data/WebUI/build` |
|
| `BOREALIS_STATIC_ROOT` | Directory that serves static assets for the SPA. | First existing path among `Data/Server/web-interface/build`, `Data/Server/WebUI/build`, `Data/WebUI/build` |
|
||||||
| `BOREALIS_CORS_ALLOWED_ORIGINS` | Comma-delimited list of origins granted CORS access. Use `*` for all origins. | `*` |
|
| `BOREALIS_CORS_ALLOWED_ORIGINS` | Comma-delimited list of origins granted CORS access. Use `*` for all origins. | `*` |
|
||||||
| `BOREALIS_FLASK_SECRET_KEY` | Secret key for Flask session signing. | `change-me` |
|
| `BOREALIS_FLASK_SECRET_KEY` | Secret key for Flask session signing. | `change-me` |
|
||||||
@@ -23,9 +24,9 @@ The Engine mirrors the legacy defaults so it can boot without additional configu
|
|||||||
|
|
||||||
## Bootstrapping flow
|
## Bootstrapping flow
|
||||||
|
|
||||||
1. `Data/Engine/bootstrapper.py` loads the environment, configures logging, and builds the Flask application via `Data/Engine/server.py`.
|
1. `Data/Engine/bootstrapper.py` loads the environment, configures logging, prepares the SQLite connection factory, optionally applies schema migrations, and builds the Flask application via `Data/Engine/server.py`.
|
||||||
2. Placeholder HTTP and Socket.IO registration hooks run so the Engine can start without any migrated routes yet.
|
2. Placeholder HTTP and Socket.IO registration hooks run so the Engine can start without any migrated routes yet.
|
||||||
3. The resulting runtime object exposes the Flask app, resolved settings, and optional Socket.IO server. `bootstrapper.main()` runs the appropriate server based on whether Socket.IO is present.
|
3. The resulting runtime object exposes the Flask app, resolved settings, optional Socket.IO server, and the configured database connection factory. `bootstrapper.main()` runs the appropriate server based on whether Socket.IO is present.
|
||||||
|
|
||||||
As migration continues, services, repositories, interfaces, and integrations will live under their respective subpackages while maintaining isolation from the legacy server.
|
As migration continues, services, repositories, interfaces, and integrations will live under their respective subpackages while maintaining isolation from the legacy server.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from .interfaces import (
|
|||||||
register_http_interfaces,
|
register_http_interfaces,
|
||||||
register_ws_interfaces,
|
register_ws_interfaces,
|
||||||
)
|
)
|
||||||
|
from .repositories.sqlite import connection as sqlite_connection
|
||||||
|
from .repositories.sqlite import migrations as sqlite_migrations
|
||||||
from .server import create_app
|
from .server import create_app
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ class EngineRuntime:
|
|||||||
app: Flask
|
app: Flask
|
||||||
settings: EngineSettings
|
settings: EngineSettings
|
||||||
socketio: Optional[object]
|
socketio: Optional[object]
|
||||||
|
db_factory: sqlite_connection.SQLiteConnectionFactory
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> EngineRuntime:
|
def bootstrap() -> EngineRuntime:
|
||||||
@@ -31,12 +34,22 @@ def bootstrap() -> EngineRuntime:
|
|||||||
settings = load_environment()
|
settings = load_environment()
|
||||||
logger = configure_logging(settings)
|
logger = configure_logging(settings)
|
||||||
logger.info("bootstrap-started")
|
logger.info("bootstrap-started")
|
||||||
app = create_app(settings)
|
|
||||||
|
db_factory = sqlite_connection.connection_factory(settings.database_path)
|
||||||
|
if settings.apply_migrations:
|
||||||
|
logger.info("migrations-start")
|
||||||
|
with sqlite_connection.connection_scope(settings.database_path) as conn:
|
||||||
|
sqlite_migrations.apply_all(conn)
|
||||||
|
logger.info("migrations-complete")
|
||||||
|
else:
|
||||||
|
logger.info("migrations-skipped")
|
||||||
|
|
||||||
|
app = create_app(settings, db_factory=db_factory)
|
||||||
register_http_interfaces(app)
|
register_http_interfaces(app)
|
||||||
socketio = create_socket_server(app, settings.socketio)
|
socketio = create_socket_server(app, settings.socketio)
|
||||||
register_ws_interfaces(socketio)
|
register_ws_interfaces(socketio)
|
||||||
logger.info("bootstrap-complete")
|
logger.info("bootstrap-complete")
|
||||||
return EngineRuntime(app=app, settings=settings, socketio=socketio)
|
return EngineRuntime(app=app, settings=settings, socketio=socketio, db_factory=db_factory)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class DatabaseSettings:
|
|||||||
"""SQLite database configuration for the Engine."""
|
"""SQLite database configuration for the Engine."""
|
||||||
|
|
||||||
path: Path
|
path: Path
|
||||||
|
apply_migrations: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -62,6 +63,12 @@ class EngineSettings:
|
|||||||
|
|
||||||
return self.database.path
|
return self.database.path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apply_migrations(self) -> bool:
|
||||||
|
"""Return whether schema migrations should run at bootstrap."""
|
||||||
|
|
||||||
|
return self.database.apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _resolve_project_root() -> Path:
|
def _resolve_project_root() -> Path:
|
||||||
candidate = os.getenv("BOREALIS_ROOT")
|
candidate = os.getenv("BOREALIS_ROOT")
|
||||||
@@ -77,6 +84,11 @@ def _resolve_database_path(project_root: Path) -> Path:
|
|||||||
return (project_root / "database.db").resolve()
|
return (project_root / "database.db").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _should_apply_migrations() -> bool:
|
||||||
|
raw = os.getenv("BOREALIS_ENGINE_AUTO_MIGRATE", "true")
|
||||||
|
return raw.lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_root(project_root: Path) -> Path:
|
def _resolve_static_root(project_root: Path) -> Path:
|
||||||
candidate = os.getenv("BOREALIS_STATIC_ROOT")
|
candidate = os.getenv("BOREALIS_STATIC_ROOT")
|
||||||
if candidate:
|
if candidate:
|
||||||
@@ -110,7 +122,10 @@ def load_environment() -> EngineSettings:
|
|||||||
"""Load Engine settings from environment variables and filesystem hints."""
|
"""Load Engine settings from environment variables and filesystem hints."""
|
||||||
|
|
||||||
project_root = _resolve_project_root()
|
project_root = _resolve_project_root()
|
||||||
database = DatabaseSettings(path=_resolve_database_path(project_root))
|
database = DatabaseSettings(
|
||||||
|
path=_resolve_database_path(project_root),
|
||||||
|
apply_migrations=_should_apply_migrations(),
|
||||||
|
)
|
||||||
cors_allowed_origins = _parse_origins(os.getenv("BOREALIS_CORS_ALLOWED_ORIGINS"))
|
cors_allowed_origins = _parse_origins(os.getenv("BOREALIS_CORS_ALLOWED_ORIGINS"))
|
||||||
flask_settings = FlaskSettings(
|
flask_settings = FlaskSettings(
|
||||||
secret_key=os.getenv("BOREALIS_FLASK_SECRET_KEY", "change-me"),
|
secret_key=os.getenv("BOREALIS_FLASK_SECRET_KEY", "change-me"),
|
||||||
|
|||||||
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__all__: list[str] = []
|
from . import sqlite
|
||||||
|
|
||||||
|
__all__ = ["sqlite"]
|
||||||
|
|||||||
21
Data/Engine/repositories/sqlite/__init__.py
Normal file
21
Data/Engine/repositories/sqlite/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""SQLite persistence helpers for the Borealis Engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .connection import (
|
||||||
|
SQLiteConnectionFactory,
|
||||||
|
configure_connection,
|
||||||
|
connect,
|
||||||
|
connection_factory,
|
||||||
|
connection_scope,
|
||||||
|
)
|
||||||
|
from .migrations import apply_all
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SQLiteConnectionFactory",
|
||||||
|
"configure_connection",
|
||||||
|
"connect",
|
||||||
|
"connection_factory",
|
||||||
|
"connection_scope",
|
||||||
|
"apply_all",
|
||||||
|
]
|
||||||
67
Data/Engine/repositories/sqlite/connection.py
Normal file
67
Data/Engine/repositories/sqlite/connection.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""SQLite connection utilities for the Borealis Engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, Protocol
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SQLiteConnectionFactory",
|
||||||
|
"configure_connection",
|
||||||
|
"connect",
|
||||||
|
"connection_factory",
|
||||||
|
"connection_scope",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteConnectionFactory(Protocol):
|
||||||
|
"""Callable protocol for obtaining configured SQLite connections."""
|
||||||
|
|
||||||
|
def __call__(self) -> sqlite3.Connection:
|
||||||
|
"""Return a new :class:`sqlite3.Connection`."""
|
||||||
|
|
||||||
|
|
||||||
|
def configure_connection(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Apply the Borealis-standard pragmas to *conn*."""
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cur.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
# Pragmas are best-effort; failing to apply them should not block startup.
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path: Path, *, timeout: float = 15.0) -> sqlite3.Connection:
|
||||||
|
"""Create a new SQLite connection to *path* with Engine pragmas applied."""
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(path), timeout=timeout)
|
||||||
|
configure_connection(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def connection_factory(path: Path, *, timeout: float = 15.0) -> SQLiteConnectionFactory:
|
||||||
|
"""Return a factory that opens connections to *path* when invoked."""
|
||||||
|
|
||||||
|
def factory() -> sqlite3.Connection:
|
||||||
|
return connect(path, timeout=timeout)
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection_scope(path: Path, *, timeout: float = 15.0) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Context manager yielding a configured connection to *path*."""
|
||||||
|
|
||||||
|
conn = connect(path, timeout=timeout)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
402
Data/Engine/repositories/sqlite/migrations.py
Normal file
402
Data/Engine/repositories/sqlite/migrations.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""SQLite schema migrations for the Borealis Engine.
|
||||||
|
|
||||||
|
This module centralises schema evolution so the Engine and its interfaces can stay
|
||||||
|
focused on request handling. The migration functions are intentionally
|
||||||
|
idempotent — they can run repeatedly without changing state once the schema
|
||||||
|
matches the desired shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_TABLE = "devices"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_all(conn: sqlite3.Connection) -> None:
|
||||||
|
"""
|
||||||
|
Run all known schema migrations against the provided sqlite3 connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ensure_devices_table(conn)
|
||||||
|
_ensure_device_aux_tables(conn)
|
||||||
|
_ensure_refresh_token_table(conn)
|
||||||
|
_ensure_install_code_table(conn)
|
||||||
|
_ensure_device_approval_table(conn)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_devices_table(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
if not _table_exists(cur, DEVICE_TABLE):
|
||||||
|
_create_devices_table(cur)
|
||||||
|
return
|
||||||
|
|
||||||
|
column_info = _table_info(cur, DEVICE_TABLE)
|
||||||
|
col_names = [c[1] for c in column_info]
|
||||||
|
pk_cols = [c[1] for c in column_info if c[5]]
|
||||||
|
|
||||||
|
needs_rebuild = pk_cols != ["guid"]
|
||||||
|
required_columns = {
|
||||||
|
"guid": "TEXT",
|
||||||
|
"hostname": "TEXT",
|
||||||
|
"description": "TEXT",
|
||||||
|
"created_at": "INTEGER",
|
||||||
|
"agent_hash": "TEXT",
|
||||||
|
"memory": "TEXT",
|
||||||
|
"network": "TEXT",
|
||||||
|
"software": "TEXT",
|
||||||
|
"storage": "TEXT",
|
||||||
|
"cpu": "TEXT",
|
||||||
|
"device_type": "TEXT",
|
||||||
|
"domain": "TEXT",
|
||||||
|
"external_ip": "TEXT",
|
||||||
|
"internal_ip": "TEXT",
|
||||||
|
"last_reboot": "TEXT",
|
||||||
|
"last_seen": "INTEGER",
|
||||||
|
"last_user": "TEXT",
|
||||||
|
"operating_system": "TEXT",
|
||||||
|
"uptime": "INTEGER",
|
||||||
|
"agent_id": "TEXT",
|
||||||
|
"ansible_ee_ver": "TEXT",
|
||||||
|
"connection_type": "TEXT",
|
||||||
|
"connection_endpoint": "TEXT",
|
||||||
|
"ssl_key_fingerprint": "TEXT",
|
||||||
|
"token_version": "INTEGER",
|
||||||
|
"status": "TEXT",
|
||||||
|
"key_added_at": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_columns = [col for col in required_columns if col not in col_names]
|
||||||
|
if missing_columns:
|
||||||
|
needs_rebuild = True
|
||||||
|
|
||||||
|
if needs_rebuild:
|
||||||
|
_rebuild_devices_table(conn, column_info)
|
||||||
|
else:
|
||||||
|
_ensure_column_defaults(cur)
|
||||||
|
|
||||||
|
_ensure_device_indexes(cur)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_device_aux_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS device_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
ssl_key_fingerprint TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
retired_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_keys_guid_fingerprint
|
||||||
|
ON device_keys(guid, ssl_key_fingerprint)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_keys_guid
|
||||||
|
ON device_keys(guid)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_refresh_token_table(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
dpop_jkt TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_used_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_guid
|
||||||
|
ON refresh_tokens(guid)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at
|
||||||
|
ON refresh_tokens(expires_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_install_code_table(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS enrollment_install_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_by_user_id TEXT,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by_guid TEXT,
|
||||||
|
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eic_expires_at
|
||||||
|
ON enrollment_install_codes(expires_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = {row[1] for row in _table_info(cur, "enrollment_install_codes")}
|
||||||
|
if "max_uses" not in columns:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE enrollment_install_codes
|
||||||
|
ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if "use_count" not in columns:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE enrollment_install_codes
|
||||||
|
ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if "last_used_at" not in columns:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE enrollment_install_codes
|
||||||
|
ADD COLUMN last_used_at TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_device_approval_table(conn: sqlite3.Connection) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS device_approvals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
approval_reference TEXT NOT NULL UNIQUE,
|
||||||
|
guid TEXT,
|
||||||
|
hostname_claimed TEXT NOT NULL,
|
||||||
|
ssl_key_fingerprint_claimed TEXT NOT NULL,
|
||||||
|
enrollment_code_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
client_nonce TEXT NOT NULL,
|
||||||
|
server_nonce TEXT NOT NULL,
|
||||||
|
agent_pubkey_der BLOB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
approved_by_user_id TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_da_status
|
||||||
|
ON device_approvals(status)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_da_fp_status
|
||||||
|
ON device_approvals(ssl_key_fingerprint_claimed, status)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_devices_table(cur: sqlite3.Cursor) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE devices (
|
||||||
|
guid TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
agent_hash TEXT,
|
||||||
|
memory TEXT,
|
||||||
|
network TEXT,
|
||||||
|
software TEXT,
|
||||||
|
storage TEXT,
|
||||||
|
cpu TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
domain TEXT,
|
||||||
|
external_ip TEXT,
|
||||||
|
internal_ip TEXT,
|
||||||
|
last_reboot TEXT,
|
||||||
|
last_seen INTEGER,
|
||||||
|
last_user TEXT,
|
||||||
|
operating_system TEXT,
|
||||||
|
uptime INTEGER,
|
||||||
|
agent_id TEXT,
|
||||||
|
ansible_ee_ver TEXT,
|
||||||
|
connection_type TEXT,
|
||||||
|
connection_endpoint TEXT,
|
||||||
|
ssl_key_fingerprint TEXT,
|
||||||
|
token_version INTEGER DEFAULT 1,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
key_added_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_ensure_device_indexes(cur)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_device_indexes(cur: sqlite3.Cursor) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_devices_hostname
|
||||||
|
ON devices(hostname)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_ssl_key
|
||||||
|
ON devices(ssl_key_fingerprint)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_status
|
||||||
|
ON devices(status)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column_defaults(cur: sqlite3.Cursor) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE devices
|
||||||
|
SET token_version = COALESCE(token_version, 1)
|
||||||
|
WHERE token_version IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE devices
|
||||||
|
SET status = COALESCE(status, 'active')
|
||||||
|
WHERE status IS NULL OR status = ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple]) -> None:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
cur.execute("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
cur.execute("ALTER TABLE devices RENAME TO devices_legacy")
|
||||||
|
_create_devices_table(cur)
|
||||||
|
|
||||||
|
legacy_columns = [c[1] for c in column_info]
|
||||||
|
cur.execute(f"SELECT {', '.join(legacy_columns)} FROM devices_legacy")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
insert_sql = (
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO devices (
|
||||||
|
guid, hostname, description, created_at, agent_hash, memory,
|
||||||
|
network, software, storage, cpu, device_type, domain, external_ip,
|
||||||
|
internal_ip, last_reboot, last_seen, last_user, operating_system,
|
||||||
|
uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint,
|
||||||
|
ssl_key_fingerprint, token_version, status, key_added_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
record = dict(zip(legacy_columns, row))
|
||||||
|
guid = _normalized_guid(record.get("guid"))
|
||||||
|
if not guid:
|
||||||
|
guid = str(uuid.uuid4())
|
||||||
|
hostname = record.get("hostname")
|
||||||
|
created_at = record.get("created_at")
|
||||||
|
key_added_at = record.get("key_added_at")
|
||||||
|
if key_added_at is None:
|
||||||
|
key_added_at = _default_key_added_at(created_at)
|
||||||
|
|
||||||
|
params: Tuple = (
|
||||||
|
guid,
|
||||||
|
hostname,
|
||||||
|
record.get("description"),
|
||||||
|
created_at,
|
||||||
|
record.get("agent_hash"),
|
||||||
|
record.get("memory"),
|
||||||
|
record.get("network"),
|
||||||
|
record.get("software"),
|
||||||
|
record.get("storage"),
|
||||||
|
record.get("cpu"),
|
||||||
|
record.get("device_type"),
|
||||||
|
record.get("domain"),
|
||||||
|
record.get("external_ip"),
|
||||||
|
record.get("internal_ip"),
|
||||||
|
record.get("last_reboot"),
|
||||||
|
record.get("last_seen"),
|
||||||
|
record.get("last_user"),
|
||||||
|
record.get("operating_system"),
|
||||||
|
record.get("uptime"),
|
||||||
|
record.get("agent_id"),
|
||||||
|
record.get("ansible_ee_ver"),
|
||||||
|
record.get("connection_type"),
|
||||||
|
record.get("connection_endpoint"),
|
||||||
|
record.get("ssl_key_fingerprint"),
|
||||||
|
record.get("token_version") or 1,
|
||||||
|
record.get("status") or "active",
|
||||||
|
key_added_at,
|
||||||
|
)
|
||||||
|
cur.execute(insert_sql, params)
|
||||||
|
|
||||||
|
cur.execute("DROP TABLE devices_legacy")
|
||||||
|
cur.execute("COMMIT")
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_key_added_at(created_at: Optional[int]) -> Optional[str]:
|
||||||
|
if created_at:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(int(created_at), tz=timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return datetime.now(tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(cur: sqlite3.Cursor, name: str) -> bool:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_info(cur: sqlite3.Cursor, name: str) -> List[Tuple]:
|
||||||
|
cur.execute(f"PRAGMA table_info({name})")
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_guid(value: Optional[str]) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
__all__ = ["apply_all"]
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from flask import Flask, request, send_from_directory
|
from flask import Flask, request, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
@@ -12,6 +13,12 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|||||||
from .config import EngineSettings
|
from .config import EngineSettings
|
||||||
|
|
||||||
|
|
||||||
|
from .repositories.sqlite.connection import (
|
||||||
|
SQLiteConnectionFactory,
|
||||||
|
connection_factory as create_sqlite_connection_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_folder(static_root: Path) -> tuple[str, str]:
|
def _resolve_static_folder(static_root: Path) -> tuple[str, str]:
|
||||||
return str(static_root), ""
|
return str(static_root), ""
|
||||||
|
|
||||||
@@ -51,9 +58,16 @@ def _register_spa_routes(app: Flask, assets_root: Path) -> None:
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: EngineSettings) -> Flask:
|
def create_app(
|
||||||
|
settings: EngineSettings,
|
||||||
|
*,
|
||||||
|
db_factory: Optional[SQLiteConnectionFactory] = None,
|
||||||
|
) -> Flask:
|
||||||
"""Create the Flask application instance for the Engine."""
|
"""Create the Flask application instance for the Engine."""
|
||||||
|
|
||||||
|
if db_factory is None:
|
||||||
|
db_factory = create_sqlite_connection_factory(settings.database_path)
|
||||||
|
|
||||||
static_folder, static_url_path = _resolve_static_folder(settings.flask.static_root)
|
static_folder, static_url_path = _resolve_static_folder(settings.flask.static_root)
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
@@ -68,6 +82,7 @@ def create_app(settings: EngineSettings) -> Flask:
|
|||||||
SESSION_COOKIE_SECURE=not settings.debug,
|
SESSION_COOKIE_SECURE=not settings.debug,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
ENGINE_DATABASE_PATH=str(settings.database_path),
|
ENGINE_DATABASE_PATH=str(settings.database_path),
|
||||||
|
ENGINE_DB_CONN_FACTORY=db_factory,
|
||||||
)
|
)
|
||||||
app.config.setdefault("PREFERRED_URL_SCHEME", "https")
|
app.config.setdefault("PREFERRED_URL_SCHEME", "https")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user