Add operational schema and identity roles
This commit is contained in:
1 parent
f5af75f305
commit
f181482463
11 files changed
+2068
-284
No files matched your search
@@ -15,6 +15,8 @@ imports/
|
||||
*.dump
|
||||
*.bak
|
||||
*.csv
|
||||
!sql/
|
||||
!sql/*.sql
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# MinerOps Target Architecture
|
||||
|
||||
MinerOps will move from its current working checkpoint toward a modular
|
||||
operations platform without a big-bang rewrite. Every milestone must leave the
|
||||
production application deployable and preserve verified workflows.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- The hub owns users, authorization, fleet inventory, repairs, locations,
|
||||
reporting, command policy, audit history, and long-term telemetry.
|
||||
- Site agents own network-local discovery, polling, and miner command
|
||||
execution.
|
||||
- The hub must not require a route to miner VLANs.
|
||||
- WhatsMiner protocols are adapters behind one capability-based interface.
|
||||
|
||||
## Backend
|
||||
|
||||
The target backend is organized into FastAPI routers, domain services, and
|
||||
database repositories for:
|
||||
|
||||
- identity and access
|
||||
- fleet inventory
|
||||
- telemetry and retention
|
||||
- repairs and parts
|
||||
- locations
|
||||
- agents and command queues
|
||||
- WhatsMiner protocol capabilities
|
||||
- system health
|
||||
|
||||
Alembic is the only authority for persistent schema changes. Runtime table
|
||||
creation remains temporarily for backward compatibility and will be removed
|
||||
after migrations and tests cover every current path.
|
||||
|
||||
## Frontend
|
||||
|
||||
The target frontend is a typed, responsive application with a shared design
|
||||
system. Authentication is established before protected pages render. Views
|
||||
adapt to desktop, tablet, and phone layouts and expose actions according to
|
||||
server-enforced permissions.
|
||||
|
||||
## Identity
|
||||
|
||||
MinerOps application accounts are distinct from WhatsMiner device accounts.
|
||||
The default application roles will be Admin, Manager, Technician, and Read
|
||||
Only. Authorization is permission-based and enforced by backend endpoints.
|
||||
|
||||
## Safety
|
||||
|
||||
- Scanning and real controls are disabled by default in development.
|
||||
- Site-agent commands are authenticated, durable, expiring, idempotent, and
|
||||
audited.
|
||||
- Destructive operations support dry-run behavior and explicit authorization.
|
||||
- Protocol adapters are tested with sanitized fixtures before real controls are
|
||||
enabled.
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Reproducible schema and development baseline.
|
||||
2. Accounts, roles, secure sessions, and login gate.
|
||||
3. Responsive application shell and shared UI components.
|
||||
4. Optimized dashboard loading.
|
||||
5. WhatsMiner 4028 and 4433 protocol adapters.
|
||||
6. Incremental extraction of repairs, locations, agents, commands, and health
|
||||
from the legacy application module.
|
||||
@@ -94,6 +94,16 @@ docker compose --env-file .env.dev -f compose.dev.yaml ps
|
||||
curl --fail --silent http://127.0.0.1:18000/api/health
|
||||
```
|
||||
|
||||
Run the disposable clean-database baseline:
|
||||
|
||||
```bash
|
||||
./scripts/test-dev-baseline.sh
|
||||
```
|
||||
|
||||
The baseline uses a separate Compose project and port, verifies migrations,
|
||||
schema, pages, retention rollups, and safety interlocks, then deletes only its
|
||||
own temporary volumes.
|
||||
|
||||
Stop development services without deleting their data:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""capture the operational schema baseline
|
||||
|
||||
Revision ID: 0006_operational_schema_baseline
|
||||
Revises: 0005_miner_inventory
|
||||
Create Date: 2026-07-27
|
||||
|
||||
This migration adopts tables that older MinerOps builds created at runtime.
|
||||
The IF NOT EXISTS clauses allow it to run against both an existing deployment
|
||||
and a new database.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0006_operational_schema_baseline"
|
||||
down_revision: Union[str, None] = "0005_miner_inventory"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE miners
|
||||
ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT,
|
||||
ADD COLUMN IF NOT EXISTS psu_model TEXT,
|
||||
ADD COLUMN IF NOT EXISTS customer_sn TEXT,
|
||||
ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN,
|
||||
ADD COLUMN IF NOT EXISTS api3_api_version TEXT,
|
||||
ADD COLUMN IF NOT EXISTS api3_api_switch TEXT,
|
||||
ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS api3_last_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT,
|
||||
ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT,
|
||||
ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE miner_status_snapshots
|
||||
ADD COLUMN IF NOT EXISTS ip_address INET,
|
||||
ADD COLUMN IF NOT EXISTS mac_address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS miner_maintenance_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
event_status TEXT NOT NULL DEFAULT 'completed',
|
||||
event_title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
event_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
old_mac TEXT,
|
||||
new_mac TEXT,
|
||||
old_ip INET,
|
||||
new_ip INET,
|
||||
component_type TEXT,
|
||||
component_slot TEXT,
|
||||
component_serial TEXT,
|
||||
cost_amount NUMERIC(12, 2),
|
||||
downtime_minutes INTEGER,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id
|
||||
ON miner_maintenance_events(miner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at
|
||||
ON miner_maintenance_events(event_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type
|
||||
ON miner_maintenance_events(event_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS location_positions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
rack_name TEXT NOT NULL,
|
||||
shelf_name TEXT NOT NULL,
|
||||
shelf_index INTEGER,
|
||||
position_index INTEGER NOT NULL,
|
||||
position_label TEXT,
|
||||
position_kind TEXT NOT NULL DEFAULT 'miner_slot',
|
||||
position_status TEXT NOT NULL DEFAULT 'available',
|
||||
expected_mac TEXT,
|
||||
miner_id UUID REFERENCES miners(id) ON DELETE SET NULL,
|
||||
equipment_label TEXT,
|
||||
notes TEXT,
|
||||
source_file TEXT,
|
||||
source_sheet TEXT,
|
||||
source_cell TEXT,
|
||||
raw_value TEXT,
|
||||
conflict_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE location_positions
|
||||
ADD CONSTRAINT uq_location_positions_slot
|
||||
UNIQUE (room_name, rack_name, shelf_name, position_index);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object OR duplicate_table THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_positions_room
|
||||
ON location_positions(room_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_location_positions_rack
|
||||
ON location_positions(room_name, rack_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac
|
||||
ON location_positions(expected_mac);
|
||||
CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id
|
||||
ON location_positions(miner_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_location_states (
|
||||
miner_id UUID PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE,
|
||||
asset_state TEXT NOT NULL DEFAULT 'waiting_placement',
|
||||
location_position_id UUID REFERENCES location_positions(id) ON DELETE SET NULL,
|
||||
state_notes TEXT,
|
||||
updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state
|
||||
ON miner_location_states(asset_state);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS location_import_issues (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
import_batch_id UUID NOT NULL,
|
||||
issue_type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
message TEXT NOT NULL,
|
||||
mac_address TEXT,
|
||||
room_name TEXT,
|
||||
rack_name TEXT,
|
||||
shelf_name TEXT,
|
||||
position_index INTEGER,
|
||||
source_file TEXT,
|
||||
source_cell TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch
|
||||
ON location_import_issues(import_batch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac
|
||||
ON location_import_issues(mac_address);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS miner_command_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
miner_id TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
command_status TEXT NOT NULL,
|
||||
dry_run BOOLEAN NOT NULL DEFAULT true,
|
||||
requested_by_user_id TEXT,
|
||||
requested_by_username TEXT,
|
||||
target_ip TEXT,
|
||||
target_mac TEXT,
|
||||
request_payload JSONB,
|
||||
response_payload JSONB,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created
|
||||
ON miner_command_events(miner_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_api_credentials (
|
||||
miner_id TEXT PRIMARY KEY,
|
||||
api_account TEXT NOT NULL DEFAULT 'super',
|
||||
api_password TEXT NOT NULL,
|
||||
password_source TEXT NOT NULL DEFAULT 'minerops',
|
||||
updated_by_username TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_site_agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
site_name TEXT NOT NULL,
|
||||
hostname TEXT,
|
||||
version TEXT,
|
||||
ip_address TEXT,
|
||||
last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_agent_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
site_name TEXT NOT NULL,
|
||||
snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll',
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
miner_count INTEGER NOT NULL DEFAULT 0,
|
||||
payload JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received
|
||||
ON minerops_agent_snapshots(received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_agent_command_results (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
site_name TEXT NOT NULL,
|
||||
command_id TEXT,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status TEXT NOT NULL,
|
||||
payload JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received
|
||||
ON minerops_agent_command_results(received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_agent_command_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
command_type TEXT NOT NULL,
|
||||
command_status TEXT NOT NULL DEFAULT 'pending',
|
||||
requested_by_user_id TEXT,
|
||||
requested_by_username TEXT,
|
||||
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
result_payload JSONB,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
claimed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status
|
||||
ON minerops_agent_command_queue(agent_id, command_status, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_agent_discoveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
site_name TEXT NOT NULL,
|
||||
room_name TEXT,
|
||||
ip_address INET,
|
||||
mac_address TEXT,
|
||||
model TEXT,
|
||||
hostname TEXT,
|
||||
miner_sn TEXT,
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
discovery_status TEXT NOT NULL DEFAULT 'new',
|
||||
matched_miner_id UUID,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac
|
||||
ON minerops_agent_discoveries(mac_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip
|
||||
ON minerops_agent_discoveries(ip_address);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
target_type TEXT NOT NULL,
|
||||
target_value TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'performance_mode',
|
||||
performance_mode TEXT,
|
||||
repeat_rule TEXT NOT NULL DEFAULT 'once',
|
||||
scheduled_for TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent',
|
||||
requested_by_user_id TEXT,
|
||||
requested_by_username TEXT,
|
||||
queued_command_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_queued_command_count INTEGER NOT NULL DEFAULT 0,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due
|
||||
ON minerops_scheduled_bulk_actions(status, scheduled_for);
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created
|
||||
ON minerops_scheduled_bulk_actions(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
miner_id UUID,
|
||||
ip_address INET,
|
||||
mac_address TEXT,
|
||||
model TEXT,
|
||||
pool_index INTEGER,
|
||||
url TEXT,
|
||||
worker_user TEXT,
|
||||
pool_status TEXT,
|
||||
stratum_active BOOLEAN,
|
||||
accepted DOUBLE PRECISION,
|
||||
rejected DOUBLE PRECISION,
|
||||
stale DOUBLE PRECISION,
|
||||
get_failures DOUBLE PRECISION,
|
||||
remote_failures DOUBLE PRECISION,
|
||||
pool_rejected_percent DOUBLE PRECISION,
|
||||
pool_stale_percent DOUBLE PRECISION,
|
||||
last_share_time_epoch DOUBLE PRECISION,
|
||||
raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
agent_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE miner_pool_snapshots
|
||||
ADD COLUMN IF NOT EXISTS model TEXT,
|
||||
ADD COLUMN IF NOT EXISTS pool_index INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS worker_user TEXT,
|
||||
ADD COLUMN IF NOT EXISTS pool_status TEXT,
|
||||
ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN,
|
||||
ADD COLUMN IF NOT EXISTS accepted DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS rejected DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS stale DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS get_failures DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS remote_failures DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS pool_stale_percent DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS last_share_time_epoch DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
id_type TEXT;
|
||||
BEGIN
|
||||
SELECT data_type INTO id_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miner_pool_snapshots'
|
||||
AND column_name = 'id';
|
||||
|
||||
IF id_type = 'text' THEN
|
||||
ALTER TABLE miner_pool_snapshots
|
||||
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
|
||||
ELSIF id_type = 'uuid' THEN
|
||||
ALTER TABLE miner_pool_snapshots
|
||||
ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
|
||||
ON miner_pool_snapshots(miner_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created
|
||||
ON miner_pool_snapshots(ip_address, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
miner_id UUID,
|
||||
agent_id TEXT,
|
||||
site_name TEXT,
|
||||
ip_address INET,
|
||||
mac_address TEXT,
|
||||
api_supported BOOLEAN,
|
||||
api_error TEXT,
|
||||
api_version TEXT,
|
||||
api_switch TEXT,
|
||||
model TEXT,
|
||||
miner_sn TEXT,
|
||||
power_sn TEXT,
|
||||
psu_model TEXT,
|
||||
device_info JSONB,
|
||||
miner_setting JSONB,
|
||||
miner_status JSONB,
|
||||
fan_setting JSONB,
|
||||
custom_data JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_api3_enrichment_miner_created
|
||||
ON miner_api3_enrichment_snapshots(miner_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
miner_id UUID,
|
||||
ip_address INET,
|
||||
mac_address TEXT,
|
||||
slot INTEGER,
|
||||
pcb_sn TEXT,
|
||||
hash_average_ths DOUBLE PRECISION,
|
||||
factory_hash_ths DOUBLE PRECISION,
|
||||
freq DOUBLE PRECISION,
|
||||
effective_chips INTEGER,
|
||||
chip_temp_min_c DOUBLE PRECISION,
|
||||
chip_temp_avg_c DOUBLE PRECISION,
|
||||
chip_temp_max_c DOUBLE PRECISION,
|
||||
raw_hashboard_json JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_api3_hashboards_miner_created
|
||||
ON miner_api3_hashboard_snapshots(miner_id, created_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS miner_status_hourly_rollups (
|
||||
miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
sample_count BIGINT NOT NULL,
|
||||
avg_mhs_av DOUBLE PRECISION,
|
||||
max_mhs_av DOUBLE PRECISION,
|
||||
avg_power_w DOUBLE PRECISION,
|
||||
max_chip_temp_c DOUBLE PRECISION,
|
||||
avg_chip_temp_c DOUBLE PRECISION,
|
||||
avg_pool_rejected_percent DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (miner_id, bucket_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_status_hourly_bucket
|
||||
ON miner_status_hourly_rollups(bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miner_status_daily_rollups (
|
||||
miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
|
||||
bucket_date DATE NOT NULL,
|
||||
sample_count BIGINT NOT NULL,
|
||||
avg_mhs_av DOUBLE PRECISION,
|
||||
max_mhs_av DOUBLE PRECISION,
|
||||
avg_power_w DOUBLE PRECISION,
|
||||
max_chip_temp_c DOUBLE PRECISION,
|
||||
avg_chip_temp_c DOUBLE PRECISION,
|
||||
avg_pool_rejected_percent DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (miner_id, bucket_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_status_daily_bucket
|
||||
ON miner_status_daily_rollups(bucket_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS minerops_retention_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mode TEXT NOT NULL,
|
||||
raw_status_retention_days INTEGER NOT NULL,
|
||||
hourly_rollup_rows_before BIGINT NOT NULL DEFAULT 0,
|
||||
hourly_rollup_rows_after BIGINT NOT NULL DEFAULT 0,
|
||||
daily_rollup_rows_before BIGINT NOT NULL DEFAULT 0,
|
||||
daily_rollup_rows_after BIGINT NOT NULL DEFAULT 0,
|
||||
raw_status_rows_before BIGINT NOT NULL DEFAULT 0,
|
||||
raw_status_rows_after BIGINT NOT NULL DEFAULT 0,
|
||||
raw_status_rows_eligible_delete BIGINT NOT NULL DEFAULT 0,
|
||||
raw_status_rows_deleted BIGINT NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_minerops_retention_runs_started
|
||||
ON minerops_retention_runs(started_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
"0006 adopts pre-existing operational tables and is intentionally irreversible"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""add application roles and permission matrix
|
||||
|
||||
Revision ID: 0007_identity_and_roles
|
||||
Revises: 0006_operational_schema_baseline
|
||||
Create Date: 2026-07-27
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0007_identity_and_roles"
|
||||
down_revision: Union[str, None] = "0006_operational_schema_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
("dashboard.view", "View fleet dashboards"),
|
||||
("miners.view", "View miners and telemetry"),
|
||||
("miners.update", "Update miner inventory and details"),
|
||||
("miners.control", "Run approved miner control operations"),
|
||||
("repairs.view", "View repair queues and history"),
|
||||
("repairs.manage", "Manage repairs, notes, parts, and status"),
|
||||
("locations.view", "View rooms, racks, shelves, and placements"),
|
||||
("locations.manage", "Manage layouts and miner placements"),
|
||||
("agents.view", "View site agents and command status"),
|
||||
("agents.manage", "Queue and manage site-agent work"),
|
||||
("system_health.view", "View system health and retention status"),
|
||||
("users.manage", "Create and manage MinerOps accounts"),
|
||||
("credentials.manage", "Manage protected miner credentials"),
|
||||
("scan_ranges.manage", "Manage scan ranges"),
|
||||
("scans.run", "Run discovery and polling operations"),
|
||||
("exports.create", "Export operational data"),
|
||||
("audit.view", "View audit history"),
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"Manager": (
|
||||
"dashboard.view",
|
||||
"miners.view",
|
||||
"miners.update",
|
||||
"miners.control",
|
||||
"repairs.view",
|
||||
"repairs.manage",
|
||||
"locations.view",
|
||||
"locations.manage",
|
||||
"agents.view",
|
||||
"agents.manage",
|
||||
"system_health.view",
|
||||
"scan_ranges.manage",
|
||||
"scans.run",
|
||||
"exports.create",
|
||||
"audit.view",
|
||||
),
|
||||
"Technician": (
|
||||
"dashboard.view",
|
||||
"miners.view",
|
||||
"miners.update",
|
||||
"miners.control",
|
||||
"repairs.view",
|
||||
"repairs.manage",
|
||||
"locations.view",
|
||||
"locations.manage",
|
||||
"agents.view",
|
||||
"scans.run",
|
||||
"exports.create",
|
||||
),
|
||||
"Read Only": (
|
||||
"dashboard.view",
|
||||
"miners.view",
|
||||
"repairs.view",
|
||||
"locations.view",
|
||||
"agents.view",
|
||||
"system_health.view",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
for key, description in PERMISSIONS:
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
""",
|
||||
(key, description),
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO roles (name, description)
|
||||
VALUES
|
||||
('Manager', 'Operational manager with fleet and workflow control'),
|
||||
('Read Only', 'Read-only access to operational views')
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
"""
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
UPDATE roles
|
||||
SET description = CASE name
|
||||
WHEN 'Admin' THEN 'Full MinerOps administrator'
|
||||
WHEN 'Technician' THEN 'Technician for repair and fleet operations'
|
||||
ELSE description
|
||||
END
|
||||
WHERE name IN ('Admin', 'Technician')
|
||||
"""
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
UPDATE users
|
||||
SET role_id = (SELECT id FROM roles WHERE name = 'Read Only')
|
||||
WHERE role_id IN (
|
||||
SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor')
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor'))"
|
||||
)
|
||||
connection.exec_driver_sql("DELETE FROM roles WHERE name IN ('Viewer', 'Auditor')")
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Admin', 'Manager', 'Technician', 'Read Only'))"
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
CROSS JOIN permissions
|
||||
WHERE roles.name = 'Admin'
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
for role_name, keys in ROLE_PERMISSIONS.items():
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = ANY(%s)
|
||||
WHERE roles.name = %s
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(list(keys), role_name),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("0007 changes live role assignments and is intentionally irreversible")
|
||||
File diff suppressed because it is too large.
Load diff
+60
-284
@@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.identity import (
|
||||
COOKIE_TOKEN_HINT,
|
||||
SESSION_COOKIE,
|
||||
get_current_user,
|
||||
install_identity_middleware,
|
||||
router as identity_router,
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
password_hasher = PasswordHasher()
|
||||
app.include_router(identity_router)
|
||||
install_identity_middleware(app)
|
||||
|
||||
|
||||
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
|
||||
@@ -74,83 +82,6 @@ def check_redis() -> str:
|
||||
return f"error: {exc}"
|
||||
|
||||
|
||||
class SetupAdminRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
display_name: str = Field(min_length=1, max_length=120)
|
||||
email: EmailStr | None = None
|
||||
password: str = Field(min_length=10, max_length=256)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
def create_access_token(user_id: str, username: str, role: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
def get_bearer_token(authorization: str | None = Header(default=None)) -> str:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
return authorization.split(" ", 1)[1].strip()
|
||||
|
||||
|
||||
def get_current_user(token: str = Depends(get_bearer_token)) -> dict:
|
||||
payload = decode_access_token(token)
|
||||
user_id = payload["sub"]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
users.id::text AS id,
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email,
|
||||
users.is_active,
|
||||
roles.name AS role
|
||||
FROM users
|
||||
JOIN roles ON roles.id = users.role_id
|
||||
WHERE users.id = %s;
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||
|
||||
return dict(user)
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
database_status = check_database()
|
||||
@@ -171,196 +102,6 @@ def health_check():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/auth/setup-status")
|
||||
def setup_status():
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM users;")
|
||||
user_count = cur.fetchone()[0]
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"setup_required": user_count == 0,
|
||||
"user_count": user_count,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/auth/setup-admin")
|
||||
def setup_admin(request: SetupAdminRequest):
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
conn.autocommit = False
|
||||
|
||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
||||
cur.execute("SELECT COUNT(*) AS user_count FROM users;")
|
||||
user_count = cur.fetchone()["user_count"]
|
||||
|
||||
if user_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Initial admin setup has already been completed",
|
||||
)
|
||||
|
||||
cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
|
||||
admin_role = cur.fetchone()
|
||||
if not admin_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Admin role is missing",
|
||||
)
|
||||
|
||||
password_hash = password_hasher.hash(request.password)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (username, display_name, email, password_hash, role_id)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id::text, username, display_name, email;
|
||||
""",
|
||||
(
|
||||
request.username.strip(),
|
||||
request.display_name.strip(),
|
||||
request.email,
|
||||
password_hash,
|
||||
admin_role["id"],
|
||||
),
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
||||
VALUES (%s, 'user.created', 'user', %s, %s);
|
||||
""",
|
||||
(
|
||||
user["id"],
|
||||
user["id"],
|
||||
psycopg.types.json.Jsonb(
|
||||
{
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": "Admin",
|
||||
"source": "setup-admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"user_id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(request: LoginRequest):
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
conn.autocommit = False
|
||||
|
||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
users.id::text AS id,
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email,
|
||||
users.password_hash,
|
||||
users.is_active,
|
||||
roles.name AS role
|
||||
FROM users
|
||||
JOIN roles ON roles.id = users.role_id
|
||||
WHERE lower(users.username) = lower(%s);
|
||||
""",
|
||||
(request.username.strip(),),
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
||||
|
||||
try:
|
||||
password_hasher.verify(user["password_hash"], request.password)
|
||||
except VerifyMismatchError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
||||
|
||||
access_token = create_access_token(user["id"], user["username"], user["role"])
|
||||
|
||||
cur.execute(
|
||||
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
|
||||
(user["id"],),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
||||
VALUES (%s, 'user.login', 'user', %s, %s);
|
||||
""",
|
||||
(
|
||||
user["id"],
|
||||
user["id"],
|
||||
psycopg.types.json.Jsonb({"username": user["username"]}),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.jwt_access_token_minutes * 60,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"email": user["email"],
|
||||
"role": user["role"],
|
||||
},
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
if conn is not None:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(current_user: dict = Depends(get_current_user)):
|
||||
return api_response(data=current_user)
|
||||
|
||||
class CreateRoomRequest(BaseModel):
|
||||
site_id: str | None = None
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
@@ -4303,7 +4044,7 @@ def minerops_ensure_pool_info_table():
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
miner_id UUID,
|
||||
ip_address INET,
|
||||
mac_address TEXT,
|
||||
@@ -4326,6 +4067,25 @@ def minerops_ensure_pool_info_table():
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
id_type TEXT;
|
||||
BEGIN
|
||||
SELECT data_type INTO id_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miner_pool_snapshots'
|
||||
AND column_name = 'id';
|
||||
|
||||
IF id_type = 'text' THEN
|
||||
ALTER TABLE miner_pool_snapshots
|
||||
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
|
||||
ELSIF id_type = 'uuid' THEN
|
||||
ALTER TABLE miner_pool_snapshots
|
||||
ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
|
||||
ON miner_pool_snapshots (miner_id, created_at DESC);
|
||||
|
||||
@@ -4443,7 +4203,6 @@ async def agent_pool_snapshots(request: Request):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miner_pool_snapshots (
|
||||
id,
|
||||
miner_id,
|
||||
ip_address,
|
||||
mac_address,
|
||||
@@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request):
|
||||
agent_id
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s::inet,
|
||||
%s,
|
||||
@@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request):
|
||||
);
|
||||
""",
|
||||
(
|
||||
str(_uuid.uuid4()),
|
||||
miner_id,
|
||||
ip,
|
||||
mac,
|
||||
@@ -6241,22 +5998,25 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
const token = getToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", {
|
||||
credentials: "same-origin",
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const payload = await response.json();
|
||||
const user = payload && payload.data ? payload.data : payload;
|
||||
|
||||
if (user && !getToken()) {
|
||||
setToken("cookie-session");
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
@@ -6352,9 +6112,16 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
||||
}
|
||||
}
|
||||
|
||||
function logoutFromHeader() {
|
||||
clearToken();
|
||||
window.location.reload();
|
||||
async function logoutFromHeader() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
} finally {
|
||||
clearToken();
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHeaderAuth() {
|
||||
@@ -6388,6 +6155,13 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
||||
<button type="button" id="mineropsHeaderLogout">Logout</button>
|
||||
`;
|
||||
|
||||
if (user.role === "Admin") {
|
||||
const accountsLink = document.createElement("a");
|
||||
accountsLink.href = "/accounts";
|
||||
accountsLink.textContent = "Accounts";
|
||||
authArea.prepend(accountsLink);
|
||||
}
|
||||
|
||||
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
|
||||
return;
|
||||
}
|
||||
@@ -23630,9 +23404,11 @@ async def insert_miner_command_event(
|
||||
|
||||
def minerops_command_require_bearer(request: Request):
|
||||
auth_header = request.headers.get("authorization") or ""
|
||||
cookie_token = request.cookies.get(SESSION_COOKIE) or ""
|
||||
header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else ""
|
||||
|
||||
if not auth_header.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="Login token required.")
|
||||
if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
|
||||
raise HTTPException(status_code=401, detail="Login required.")
|
||||
|
||||
|
||||
async def minerops_command_user_from_request(request: Request):
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_name="${MINEROPS_SMOKE_PROJECT:-minerops-smoke-$$}"
|
||||
dev_port="${MINEROPS_SMOKE_PORT:-18001}"
|
||||
keep_environment="${MINEROPS_SMOKE_KEEP:-false}"
|
||||
base_url="http://127.0.0.1:${dev_port}"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
tmp_prefix="/tmp/${project_name}"
|
||||
admin_cookie="${tmp_prefix}-admin.cookies"
|
||||
manager_cookie="${tmp_prefix}-manager.cookies"
|
||||
technician_cookie="${tmp_prefix}-technician.cookies"
|
||||
readonly_cookie="${tmp_prefix}-readonly.cookies"
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
|
||||
if (( status != 0 )); then
|
||||
echo
|
||||
echo "Smoke test failed. Recent service logs:"
|
||||
"${compose[@]}" logs --no-color --tail 160 || true
|
||||
fi
|
||||
|
||||
rm -f "${tmp_prefix}"-*.json "${tmp_prefix}"-*.cookies "${tmp_prefix}"-*.headers
|
||||
if [[ "${keep_environment}" != "true" ]]; then
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Keeping Compose project ${project_name} for inspection."
|
||||
fi
|
||||
|
||||
exit "${status}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
status_for() {
|
||||
local cookie_jar="$1"
|
||||
local method="$2"
|
||||
local path="$3"
|
||||
local body="${4:-}"
|
||||
local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}")
|
||||
if [[ -n "${cookie_jar}" ]]; then
|
||||
args+=(-b "${cookie_jar}")
|
||||
fi
|
||||
if [[ -n "${body}" ]]; then
|
||||
args+=(-H 'Content-Type: application/json' --data "${body}")
|
||||
fi
|
||||
curl "${args[@]}" "${base_url}${path}"
|
||||
}
|
||||
|
||||
if [[ ! -f .env.dev ]]; then
|
||||
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Validating Compose configuration..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
|
||||
|
||||
echo "Building and starting disposable development stack..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build
|
||||
|
||||
echo "Waiting for the gateway health endpoint..."
|
||||
health_file="${tmp_prefix}-health.json"
|
||||
for attempt in $(seq 1 90); do
|
||||
if curl --fail --silent "${base_url}/api/health" >"${health_file}"; then
|
||||
break
|
||||
fi
|
||||
|
||||
if (( attempt == 90 )); then
|
||||
echo "Gateway did not become healthy within 90 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
health_json="$(cat "${health_file}")"
|
||||
[[ "${health_json}" == *'"status":"ok"'* ]]
|
||||
[[ "${health_json}" == *'"environment":"development"'* ]]
|
||||
[[ "${health_json}" == *'"database":"ok"'* ]]
|
||||
[[ "${health_json}" == *'"redis":"ok"'* ]]
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0007_identity_and_roles"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
|
||||
< sql/verify-development-schema.sql
|
||||
)"
|
||||
if [[ -n "${missing_tables}" ]]; then
|
||||
echo "Missing required tables:" >&2
|
||||
echo "${missing_tables}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking the unauthenticated login gate..."
|
||||
setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
|
||||
[[ "${setup_status}" == *'"setup_required":true'* ]]
|
||||
[[ "$(status_for "" GET /login)" == "200" ]]
|
||||
for path in dashboard repairs locations system-health accounts; do
|
||||
[[ "$(status_for "" GET "/${path}")" == "303" ]]
|
||||
done
|
||||
[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
|
||||
|
||||
echo "Creating the first administrator and role test accounts..."
|
||||
setup_response="$(curl --fail --silent -c "${admin_cookie}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \
|
||||
"${base_url}/api/auth/setup-admin")"
|
||||
[[ "${setup_response}" == *'"role":"Admin"'* ]]
|
||||
[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
|
||||
[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
|
||||
|
||||
for account in \
|
||||
'manager|Operations Manager|Manager|manager@example.com' \
|
||||
'technician|Repair Technician|Technician|technician@example.com' \
|
||||
'readonly|Read Only User|Read Only|readonly@example.com'; do
|
||||
IFS='|' read -r username display_name role email <<<"${account}"
|
||||
response="$(curl --fail --silent -b "${admin_cookie}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"username\":\"${username}\",\"display_name\":\"${display_name}\",\"email\":\"${email}\",\"role\":\"${role}\",\"password\":\"MinerOpsTest!123\"}" \
|
||||
"${base_url}/api/admin/users")"
|
||||
[[ "${response}" == *"\"role\":\"${role}\""* ]]
|
||||
done
|
||||
|
||||
roles_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/roles")"
|
||||
for role in Admin Manager Technician 'Read Only'; do
|
||||
[[ "${roles_response}" == *"\"name\":\"${role}\""* ]]
|
||||
done
|
||||
users_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/users")"
|
||||
for username in admin manager technician readonly; do
|
||||
[[ "${users_response}" == *"\"username\":\"${username}\""* ]]
|
||||
done
|
||||
|
||||
echo "Logging in each role and checking authorization boundaries..."
|
||||
for login in \
|
||||
"manager|${manager_cookie}|Manager" \
|
||||
"technician|${technician_cookie}|Technician" \
|
||||
"readonly|${readonly_cookie}|Read Only"; do
|
||||
IFS='|' read -r username cookie_jar role <<<"${login}"
|
||||
login_response="$(curl --fail --silent -c "${cookie_jar}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"username\":\"${username}\",\"password\":\"MinerOpsTest!123\"}" \
|
||||
"${base_url}/api/auth/login")"
|
||||
[[ "${login_response}" == *"\"role\":\"${role}\""* ]]
|
||||
[[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
|
||||
done
|
||||
|
||||
[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
|
||||
[[ "$(status_for "${manager_cookie}" GET /accounts)" == "403" ]]
|
||||
[[ "$(status_for "${manager_cookie}" GET /api/admin/users)" == "403" ]]
|
||||
[[ "$(status_for "${technician_cookie}" GET /repairs)" == "200" ]]
|
||||
[[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]]
|
||||
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
|
||||
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
|
||||
|
||||
room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
|
||||
[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
|
||||
|
||||
me_response="$(curl --fail --silent -b "${readonly_cookie}" "${base_url}/api/auth/me")"
|
||||
[[ "${me_response}" == *'"role":"Read Only"'* ]]
|
||||
[[ "$(status_for "${readonly_cookie}" POST /api/auth/logout)" == "200" ]]
|
||||
[[ "$(status_for "${readonly_cookie}" GET /dashboard)" == "303" ]]
|
||||
|
||||
echo "Checking development safety interlocks..."
|
||||
"${compose[@]}" exec -T backend sh -ec '
|
||||
test "$SCHEDULED_SCAN_ENABLED" = "false"
|
||||
test "$MINEROPS_HUB_MODE" = "true"
|
||||
test "$MINEROPS_DISABLE_LOCAL_SCANNER" = "true"
|
||||
test "$MINEROPS_ENABLE_REAL_REBOOT" = "false"
|
||||
test "$MINEROPS_ENABLE_REAL_POWER_CONTROL" = "false"
|
||||
'
|
||||
|
||||
echo "Running retention rollups against the disposable baseline..."
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
< sql/minerops-retention-hourly.sql >/dev/null
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
< sql/minerops-retention-daily.sql >/dev/null
|
||||
|
||||
echo "Development identity baseline passed."
|
||||
@@ -0,0 +1,34 @@
|
||||
INSERT INTO miner_status_daily_rollups (
|
||||
miner_id,
|
||||
bucket_date,
|
||||
sample_count,
|
||||
avg_mhs_av,
|
||||
max_mhs_av,
|
||||
avg_power_w,
|
||||
max_chip_temp_c,
|
||||
avg_chip_temp_c,
|
||||
avg_pool_rejected_percent,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
miner_id,
|
||||
observed_at::date AS bucket_date,
|
||||
COUNT(*) AS sample_count,
|
||||
AVG(mhs_av)::double precision,
|
||||
MAX(mhs_av)::double precision,
|
||||
AVG(power_w)::double precision,
|
||||
MAX(chip_temp_max_c)::double precision,
|
||||
AVG(chip_temp_avg_c)::double precision,
|
||||
AVG(pool_rejected_percent)::double precision,
|
||||
now()
|
||||
FROM miner_status_snapshots
|
||||
GROUP BY miner_id, observed_at::date
|
||||
ON CONFLICT (miner_id, bucket_date) DO UPDATE
|
||||
SET sample_count = EXCLUDED.sample_count,
|
||||
avg_mhs_av = EXCLUDED.avg_mhs_av,
|
||||
max_mhs_av = EXCLUDED.max_mhs_av,
|
||||
avg_power_w = EXCLUDED.avg_power_w,
|
||||
max_chip_temp_c = EXCLUDED.max_chip_temp_c,
|
||||
avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
|
||||
avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
|
||||
updated_at = now();
|
||||
@@ -0,0 +1,34 @@
|
||||
INSERT INTO miner_status_hourly_rollups (
|
||||
miner_id,
|
||||
bucket_at,
|
||||
sample_count,
|
||||
avg_mhs_av,
|
||||
max_mhs_av,
|
||||
avg_power_w,
|
||||
max_chip_temp_c,
|
||||
avg_chip_temp_c,
|
||||
avg_pool_rejected_percent,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
miner_id,
|
||||
date_trunc('hour', observed_at) AS bucket_at,
|
||||
COUNT(*) AS sample_count,
|
||||
AVG(mhs_av)::double precision,
|
||||
MAX(mhs_av)::double precision,
|
||||
AVG(power_w)::double precision,
|
||||
MAX(chip_temp_max_c)::double precision,
|
||||
AVG(chip_temp_avg_c)::double precision,
|
||||
AVG(pool_rejected_percent)::double precision,
|
||||
now()
|
||||
FROM miner_status_snapshots
|
||||
GROUP BY miner_id, date_trunc('hour', observed_at)
|
||||
ON CONFLICT (miner_id, bucket_at) DO UPDATE
|
||||
SET sample_count = EXCLUDED.sample_count,
|
||||
avg_mhs_av = EXCLUDED.avg_mhs_av,
|
||||
max_mhs_av = EXCLUDED.max_mhs_av,
|
||||
avg_power_w = EXCLUDED.avg_power_w,
|
||||
max_chip_temp_c = EXCLUDED.max_chip_temp_c,
|
||||
avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
|
||||
avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
|
||||
updated_at = now();
|
||||
@@ -0,0 +1,30 @@
|
||||
WITH expected(table_name) AS (
|
||||
VALUES
|
||||
('roles'),
|
||||
('permissions'),
|
||||
('users'),
|
||||
('miners'),
|
||||
('miner_status_snapshots'),
|
||||
('miner_status_hourly_rollups'),
|
||||
('miner_status_daily_rollups'),
|
||||
('minerops_retention_runs'),
|
||||
('miner_maintenance_events'),
|
||||
('location_positions'),
|
||||
('miner_location_states'),
|
||||
('location_import_issues'),
|
||||
('miner_command_events'),
|
||||
('miner_api_credentials'),
|
||||
('miner_pool_snapshots'),
|
||||
('miner_api3_enrichment_snapshots'),
|
||||
('miner_api3_hashboard_snapshots'),
|
||||
('minerops_site_agents'),
|
||||
('minerops_agent_snapshots'),
|
||||
('minerops_agent_command_results'),
|
||||
('minerops_agent_command_queue'),
|
||||
('minerops_agent_discoveries'),
|
||||
('minerops_scheduled_bulk_actions')
|
||||
)
|
||||
SELECT table_name
|
||||
FROM expected
|
||||
WHERE to_regclass('public.' || table_name) IS NULL
|
||||
ORDER BY table_name;
|
||||
Reference in new issue
Block a user