Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e898f283ab | ||
|
|
2d89e80410 | ||
|
|
97a0139e3d | ||
|
|
3cfb0d1abe | ||
|
|
abf5654a25 | ||
|
|
b9b5e5e36f | ||
|
|
d9f0da694b | ||
|
|
82309db79a | ||
|
|
d8cd7ad058 | ||
|
|
f5965af914 | ||
|
|
6cd876b4b3 | ||
|
|
c3501b987e | ||
|
|
0dd26a3f24 | ||
|
|
4d360fac42 | ||
|
|
19fc29c72c | ||
|
|
b8b741ccee | ||
|
|
4a1959ecd6 | ||
|
|
786a1ab910 | ||
|
|
c6d01df31c | ||
|
|
06ed8248f5 | ||
|
|
d06ee7bcc6 | ||
|
|
60a831950c | ||
|
|
548051899e | ||
|
|
b741fd799b | ||
|
|
eeb3c7634e | ||
|
|
4656714837 | ||
|
|
39832fcf80 | ||
|
|
eab9646960 | ||
|
|
6e550c1c13 | ||
|
|
73f98685ce | ||
|
|
bcf1b4e3cf | ||
|
|
62c91a2c3e | ||
|
|
f2b736b08e | ||
|
|
72ad19a071 | ||
|
|
36a0caecd8 | ||
|
|
f6a2512e23 | ||
|
|
d738b8e27e | ||
|
|
a87ab2709f | ||
|
|
51aec1b404 | ||
|
|
979885bbdd | ||
|
|
93e034ea07 | ||
|
|
7280f1dfea | ||
|
|
fbf0f81df1 | ||
|
|
1cae60ab46 | ||
|
|
577226cb4e |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# MinerOps repository-root Docker build context
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.dump
|
||||
**/*.sql.gz
|
||||
backend/app/main.py.bak*
|
||||
@@ -1,122 +1,322 @@
|
||||
# MinerOps
|
||||
<p align="center">
|
||||
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
|
||||
</p>
|
||||
|
||||
MinerOps is a self-hosted mining fleet operations console for tracking, repairing, locating, and controlling WhatsMiner ASICs.
|
||||
<h1 align="center">MinerOps</h1>
|
||||
|
||||
## Current deployment
|
||||
<p align="center">
|
||||
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
||||
</p>
|
||||
|
||||
- Hub CT: 306
|
||||
- Public URL: https://plexus.lussier.cc
|
||||
- Repository: https://gitea.lussier.cc/lussierj/minerops
|
||||
- Backend container: minerops-backend
|
||||
- Database container: minerops-postgres
|
||||
- Redis container: minerops-redis
|
||||
<p align="center">
|
||||
Know every miner • Track every repair • Control every rack
|
||||
</p>
|
||||
|
||||
## Main pages
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/status-active%20development-orange" />
|
||||
<img src="https://img.shields.io/badge/python-3.13-blue" />
|
||||
<img src="https://img.shields.io/badge/license-TBD-lightgrey" />
|
||||
</p>
|
||||
|
||||
- /dashboard - fleet dashboard and repair summary
|
||||
- /repairs - repair queue, notes, parts used, history, CSV export
|
||||
- /locations - room/rack/shelf location map
|
||||
- /system-health - app, database, table size, route, and retention health
|
||||
---
|
||||
|
||||
## Repair workflow
|
||||
## Overview
|
||||
|
||||
1. Open /repairs.
|
||||
2. Find the miner by MAC, IP, model, serial, or problem.
|
||||
3. Add repair notes.
|
||||
4. Select parts used and quantity.
|
||||
5. Update repair status.
|
||||
6. Use History to edit or delete mistaken entries.
|
||||
7. Mark Ready to Shelf or use Move to Shelf.
|
||||
8. Return the miner to an open shelf position on /locations.
|
||||
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
|
||||
|
||||
## Repair statuses
|
||||
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
|
||||
|
||||
- Open
|
||||
- Diagnosing
|
||||
- Waiting Parts
|
||||
- In Progress
|
||||
MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
|
||||
|
||||
Originally developed for WhatsMiner fleets, the platform is being built around a modular architecture that will support additional miner platforms through protocol adapters in future releases.
|
||||
|
||||
---
|
||||
|
||||
# Vision
|
||||
|
||||
Running a mining operation requires far more than knowing whether a miner is online.
|
||||
|
||||
MinerOps is designed to become the operational hub for an entire mining facility by combining:
|
||||
|
||||
- Asset Inventory
|
||||
- Physical Location Management
|
||||
- Repair & Maintenance Tracking
|
||||
- Live Fleet Monitoring
|
||||
- Historical Reporting
|
||||
- Secure Remote Management
|
||||
- Operational Analytics
|
||||
- Fleet Automation
|
||||
|
||||
into one cohesive platform.
|
||||
|
||||
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
|
||||
|
||||
---
|
||||
|
||||
# Current Status
|
||||
|
||||
⚠️ **MinerOps is under active development.**
|
||||
|
||||
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
|
||||
|
||||
## Completed
|
||||
|
||||
- Docker-based development environment
|
||||
- PostgreSQL + Redis backend
|
||||
- Alembic migration framework
|
||||
- Authentication
|
||||
- Role-based access control
|
||||
- Manual inventory schema
|
||||
- Inventory domain architecture
|
||||
- Automated migration testing
|
||||
- Disposable development environment
|
||||
|
||||
## Currently In Progress
|
||||
|
||||
- Inventory API
|
||||
- Inventory user interface
|
||||
- Domain-driven architecture migration
|
||||
|
||||
## Planned
|
||||
|
||||
- Asset identity & MAC history
|
||||
- Physical location management
|
||||
- Repair & work order management
|
||||
- Parts inventory
|
||||
- Fleet monitoring
|
||||
- Site agents
|
||||
- Multi-version WhatsMiner API support
|
||||
- Alerting & notifications
|
||||
- Bulk operations
|
||||
- Scheduled automation
|
||||
- Reporting & analytics
|
||||
- Production deployment tools
|
||||
|
||||
---
|
||||
|
||||
# Core Features
|
||||
|
||||
## 📦 Asset Inventory
|
||||
|
||||
Maintain a permanent identity for every miner independent of its network configuration.
|
||||
|
||||
Planned capabilities include:
|
||||
|
||||
- Asset IDs
|
||||
- MAC history
|
||||
- Control board tracking
|
||||
- PSU serial tracking
|
||||
- Firmware history
|
||||
- Lifecycle management
|
||||
- Tags
|
||||
- Notes
|
||||
- Asset movement history
|
||||
|
||||
---
|
||||
|
||||
## 📍 Physical Location Tracking
|
||||
|
||||
Track exactly where every miner is installed.
|
||||
|
||||
Supports:
|
||||
|
||||
- Sites
|
||||
- Rooms
|
||||
- Rows
|
||||
- Racks
|
||||
- Shelves
|
||||
- Positions
|
||||
|
||||
with complete movement history.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Repair Management
|
||||
|
||||
Track every repair from diagnosis through return to production.
|
||||
|
||||
Features include:
|
||||
|
||||
- Work orders
|
||||
- Technician notes
|
||||
- Parts used
|
||||
- Repair history
|
||||
- Testing
|
||||
- Ready to Shelf
|
||||
- Needs Follow-up
|
||||
- Completed
|
||||
- Failed
|
||||
- Cancelled
|
||||
- Attachments
|
||||
- Maintenance mode
|
||||
|
||||
## Retention
|
||||
---
|
||||
|
||||
Retention is currently scheduled in dry-run mode.
|
||||
## 📊 Fleet Monitoring
|
||||
|
||||
Current intended policy:
|
||||
Monitor operational health across the fleet.
|
||||
|
||||
- Raw status snapshots: 30 days
|
||||
- Hourly rollups: 1 year or longer
|
||||
- Daily rollups: long term
|
||||
- Repair, location, and MAC history: permanent
|
||||
Planned monitoring includes:
|
||||
|
||||
Verify /system-health before switching retention to apply mode.
|
||||
- Hashrate
|
||||
- Temperature
|
||||
- Fan status
|
||||
- Hashboard health
|
||||
- PSU status
|
||||
- Pool connectivity
|
||||
- Worker information
|
||||
- Firmware
|
||||
- Alerts
|
||||
|
||||
## Common commands
|
||||
---
|
||||
|
||||
Health check:
|
||||
## 🌐 Network Discovery
|
||||
|
||||
pct exec 306 -- bash -lc "curl -s http://127.0.0.1:8000/api/health"
|
||||
Automatically discover miners while maintaining stable asset identities.
|
||||
|
||||
Rebuild backend:
|
||||
Planned capabilities include:
|
||||
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose build backend || docker-compose build backend"
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose up -d --force-recreate backend || docker-compose up -d --force-recreate backend"
|
||||
- Configurable scan ranges
|
||||
- Duplicate detection
|
||||
- MAC history
|
||||
- IP history
|
||||
- Capability detection
|
||||
- Discovery review workflow
|
||||
|
||||
Check logs:
|
||||
---
|
||||
|
||||
pct exec 306 -- bash -lc "docker logs minerops-backend --since 30m"
|
||||
## 🎛 Secure Remote Control
|
||||
|
||||
Git status:
|
||||
Safely execute commands against supported miners.
|
||||
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && git status --short"
|
||||
Examples include:
|
||||
|
||||
## Safe local development
|
||||
- Reboot
|
||||
- Shutdown
|
||||
- Performance mode
|
||||
- Pool configuration
|
||||
- Firmware operations
|
||||
|
||||
Development uses a separate Compose project, database volume, Redis volume, and
|
||||
localhost-only backend port. It does not reuse the production `.env` file.
|
||||
Scheduled scanning and real miner power/reboot controls are disabled.
|
||||
All commands will be permission-aware, audited, and designed for safe execution.
|
||||
|
||||
First-time setup:
|
||||
---
|
||||
|
||||
```bash
|
||||
./scripts/dev-env-init.sh
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml up -d --build
|
||||
## 🛰 Site Agents
|
||||
|
||||
Future releases will include lightweight Site Agents responsible for:
|
||||
|
||||
- Network discovery
|
||||
- Miner polling
|
||||
- Local command execution
|
||||
- Secure communication with the MinerOps server
|
||||
|
||||
This architecture allows mining networks to remain isolated while still enabling centralized fleet management.
|
||||
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
MinerOps follows a layered architecture that separates business logic from persistence.
|
||||
|
||||
```
|
||||
FastAPI Routers
|
||||
│
|
||||
▼
|
||||
Service Layer
|
||||
│
|
||||
▼
|
||||
Repository Layer
|
||||
│
|
||||
▼
|
||||
PostgreSQL
|
||||
```
|
||||
|
||||
Verify the environment:
|
||||
Business rules belong in Services.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml ps
|
||||
curl --fail --silent http://127.0.0.1:18000/api/health
|
||||
```
|
||||
Database access belongs in Repositories.
|
||||
|
||||
Run the disposable clean-database baseline:
|
||||
Schema changes are managed exclusively through Alembic migrations.
|
||||
|
||||
```bash
|
||||
./scripts/test-dev-baseline.sh
|
||||
```
|
||||
This architecture keeps MinerOps modular, testable, and easy to extend.
|
||||
|
||||
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:
|
||||
# Technology Stack
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml down
|
||||
```
|
||||
Current
|
||||
|
||||
The generated `.env.dev` file is mode `0600` and ignored by Git. To use a
|
||||
different local port, change `MINEROPS_DEV_PORT` in `.env.dev`.
|
||||
- Python
|
||||
- FastAPI
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Alembic
|
||||
- Docker Compose
|
||||
- Pydantic
|
||||
|
||||
## Safety notes
|
||||
Planned
|
||||
|
||||
- Do not commit .env.
|
||||
- Do not commit backups, logs, database dumps, imports, or runtime data.
|
||||
- Do not enable scanning or real miner controls in the development environment.
|
||||
- Check /system-health after major changes.
|
||||
- Commit and push after each stable working change.
|
||||
- React
|
||||
- TypeScript
|
||||
- Site Agents
|
||||
- Protocol adapters
|
||||
- Background task processing
|
||||
|
||||
---
|
||||
|
||||
# Development Philosophy
|
||||
|
||||
MinerOps is built around a few guiding principles.
|
||||
|
||||
- Self-hosted first
|
||||
- Reliability over unnecessary complexity
|
||||
- Small, reviewable commits
|
||||
- Test before commit
|
||||
- Database changes managed through Alembic
|
||||
- Business logic separated from persistence
|
||||
- Secure by default
|
||||
- Role-based authorization
|
||||
- Comprehensive audit logging
|
||||
- Incremental development over large rewrites
|
||||
|
||||
---
|
||||
|
||||
# Roadmap
|
||||
|
||||
| Version | Focus |
|
||||
|----------|---------------------------|
|
||||
| ✅ v0.2 | Foundation |
|
||||
| 🚧 v0.3 | Inventory |
|
||||
| ⏳ v0.4 | Asset Identity |
|
||||
| ⏳ v0.5 | Operations |
|
||||
| ⏳ v0.6 | Monitoring |
|
||||
| ⏳ v0.7 | Automation |
|
||||
| 🎯 v1.0 | Production Release |
|
||||
|
||||
Detailed planning will be maintained in **docs/ROADMAP.md**.
|
||||
|
||||
---
|
||||
|
||||
# Documentation
|
||||
|
||||
Project documentation is maintained in the `docs/` directory.
|
||||
|
||||
Documentation includes:
|
||||
|
||||
- Architecture
|
||||
- Roadmap
|
||||
- Architecture Decisions
|
||||
- Development Guide
|
||||
- Coding Standards
|
||||
- Database Design
|
||||
- API Documentation
|
||||
- Deployment Guide
|
||||
- Testing Guide
|
||||
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
MinerOps is currently under active development.
|
||||
|
||||
Contributions, ideas, bug reports, and feature requests are welcome as the project continues to evolve.
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
License to be determined.
|
||||
@@ -0,0 +1,4 @@
|
||||
*.ps1 -text
|
||||
|
||||
|
||||
*.ps1 whitespace=cr-at-eol
|
||||
@@ -0,0 +1,8 @@
|
||||
agent-config.json
|
||||
agent-state.json
|
||||
logs/
|
||||
*.bak
|
||||
*.bak.*
|
||||
*-probe-*.json
|
||||
rollback/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1
|
||||
ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1
|
||||
51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1
|
||||
bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1
|
||||
c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1
|
||||
90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1
|
||||
18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IpAddress,
|
||||
|
||||
[string]$MinerId = ""
|
||||
)
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
|
||||
function Read-AgentConfig {
|
||||
if (!(Test-Path $ConfigPath)) {
|
||||
throw "Agent config not found: $ConfigPath"
|
||||
}
|
||||
|
||||
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Invoke-MinerCommandRaw {
|
||||
param(
|
||||
[string]$IpAddress,
|
||||
[string]$Command,
|
||||
[int]$Port = 4028,
|
||||
[int]$TimeoutMs = 2500
|
||||
)
|
||||
|
||||
# This transport accepts only the exact queries used by this enrichment script.
|
||||
# Validate before creating a socket; callers cannot reinterpret a write as a read.
|
||||
$readCommands = @("summary", "devs", "edevs", "stats", "pools", "devdetails",
|
||||
"version", "get_version", "get_miner_info")
|
||||
# PowerShell containment operators use culture comparisons, even with -c.
|
||||
# Match ordinally and serialize the canonical literal; never trim/normalize.
|
||||
$approvedCommand = $null
|
||||
foreach ($candidate in $readCommands) {
|
||||
if ([string]::Equals($candidate, $Command, [StringComparison]::Ordinal)) {
|
||||
$approvedCommand = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($Port -ne 4028 -or $null -eq $approvedCommand) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
$requestJson = @{ cmd = $approvedCommand } | ConvertTo-Json -Compress
|
||||
|
||||
$client = $null
|
||||
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
|
||||
|
||||
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$client.EndConnect($async)
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($requestJson + "`n")
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
|
||||
try {
|
||||
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
|
||||
} catch {
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] 262144
|
||||
$chunks = New-Object System.Collections.ArrayList
|
||||
$started = Get-Date
|
||||
|
||||
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
|
||||
if (-not $stream.DataAvailable) {
|
||||
Start-Sleep -Milliseconds 20
|
||||
continue
|
||||
}
|
||||
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -le 0) {
|
||||
break
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt $read; $i++) {
|
||||
[void]$chunks.Add([byte]$buffer[$i])
|
||||
}
|
||||
|
||||
if ($chunks.Count -gt 2097152) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
|
||||
} catch {
|
||||
return ""
|
||||
} finally {
|
||||
if ($client -ne $null) {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Try-ParseJson {
|
||||
param([string]$Raw)
|
||||
|
||||
if (!$Raw -or $Raw.Trim().Length -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return $Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstValue {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string[]]$Names
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$json = $Object | ConvertTo-Json -Depth 80 -Compress
|
||||
|
||||
foreach ($name in $Names) {
|
||||
$escaped = [regex]::Escape($name)
|
||||
|
||||
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
|
||||
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if ($m.Success) {
|
||||
return $m.Groups[1].Value
|
||||
}
|
||||
|
||||
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
|
||||
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if ($m2.Success) {
|
||||
return $m2.Groups[1].Value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Find-MacAddress {
|
||||
param([string]$Text)
|
||||
|
||||
if (!$Text) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
|
||||
if ($m.Success) {
|
||||
return $m.Value.Replace("-", ":").ToUpper()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
$config = Read-AgentConfig
|
||||
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||
$secret = [string]$config.agent_secret
|
||||
$agentId = [string]$config.agent_id
|
||||
|
||||
$commands = @(
|
||||
"summary",
|
||||
"devs",
|
||||
"edevs",
|
||||
"stats",
|
||||
"pools",
|
||||
"devdetails",
|
||||
"version",
|
||||
"get_version",
|
||||
"get_miner_info"
|
||||
)
|
||||
|
||||
$raws = [ordered]@{}
|
||||
$parsed = [ordered]@{}
|
||||
|
||||
foreach ($cmd in $commands) {
|
||||
Write-Host "Querying $IpAddress command: $cmd"
|
||||
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
|
||||
$raws[$cmd] = $raw
|
||||
$parsed[$cmd] = Try-ParseJson -Raw $raw
|
||||
Write-Host " bytes: $($raw.Length)"
|
||||
}
|
||||
|
||||
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
|
||||
$allParsed = [ordered]@{
|
||||
summary = $parsed["summary"]
|
||||
devs = $parsed["devs"]
|
||||
edevs = $parsed["edevs"]
|
||||
stats = $parsed["stats"]
|
||||
pools = $parsed["pools"]
|
||||
devdetails = $parsed["devdetails"]
|
||||
version = $parsed["version"]
|
||||
get_version = $parsed["get_version"]
|
||||
get_miner_info = $parsed["get_miner_info"]
|
||||
}
|
||||
|
||||
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
|
||||
if (!$mac) {
|
||||
$mac = Find-MacAddress -Text $allText
|
||||
}
|
||||
|
||||
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
|
||||
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
|
||||
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
|
||||
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
|
||||
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
|
||||
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
|
||||
|
||||
if (!$model) {
|
||||
$model = "WhatsMiner"
|
||||
}
|
||||
|
||||
$payload = [ordered]@{
|
||||
ip_address = $IpAddress
|
||||
miner_id = $MinerId
|
||||
mac_address = $mac
|
||||
hostname = $hostname
|
||||
miner_sn = $minerSn
|
||||
model = $model
|
||||
miner_type = $model
|
||||
api_version = $apiVersion
|
||||
firmware_version = $firmware
|
||||
platform = $platform
|
||||
raw_version = @{
|
||||
version = $raws["version"]
|
||||
get_version = $raws["get_version"]
|
||||
}
|
||||
raw_miner_info = @{
|
||||
devs = $raws["devs"]
|
||||
edevs = $raws["edevs"]
|
||||
stats = $raws["stats"]
|
||||
pools = $raws["pools"]
|
||||
devdetails = $raws["devdetails"]
|
||||
get_miner_info = $raws["get_miner_info"]
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Detected:"
|
||||
Write-Host " MAC: $mac"
|
||||
Write-Host " Hostname: $hostname"
|
||||
Write-Host " SN: $minerSn"
|
||||
Write-Host " Model: $model"
|
||||
Write-Host " Firmware: $firmware"
|
||||
Write-Host " API: $apiVersion"
|
||||
Write-Host " Platform: $platform"
|
||||
Write-Host ""
|
||||
|
||||
$response = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
|
||||
-Headers @{
|
||||
"X-MinerOps-Agent-ID" = $agentId
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
} `
|
||||
-ContentType "application/json" `
|
||||
-Body ($payload | ConvertTo-Json -Depth 80)
|
||||
|
||||
$response | ConvertTo-Json -Depth 20
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,228 @@
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$BaseDir = "C:\MinerOpsAgent"
|
||||
$ConfigPath = Join-Path $BaseDir "agent-config.json"
|
||||
$StatePath = Join-Path $BaseDir "agent-state.json"
|
||||
$LogPath = Join-Path $BaseDir "logs\agent.log"
|
||||
$TaskName = "MinerOpsAgent"
|
||||
|
||||
function Read-Config {
|
||||
if (Test-Path $ConfigPath) {
|
||||
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-ConfigText {
|
||||
if (Test-Path $ConfigPath) {
|
||||
return Get-Content $ConfigPath -Raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function Save-ConfigObject {
|
||||
param([object]$Config)
|
||||
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Save-ConfigText {
|
||||
param([string]$Text)
|
||||
$Text | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-AgentTaskText {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if (!$task) { return "Scheduled task not installed" }
|
||||
|
||||
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)"
|
||||
}
|
||||
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "MinerOps Agent"
|
||||
$form.Size = New-Object System.Drawing.Size(950, 780)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
|
||||
$statusLabel = New-Object System.Windows.Forms.Label
|
||||
$statusLabel.Location = New-Object System.Drawing.Point(15, 15)
|
||||
$statusLabel.Size = New-Object System.Drawing.Size(900, 24)
|
||||
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||
$form.Controls.Add($statusLabel)
|
||||
|
||||
$startButton = New-Object System.Windows.Forms.Button
|
||||
$startButton.Text = "Start"
|
||||
$startButton.Location = New-Object System.Drawing.Point(15, 45)
|
||||
$startButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($startButton)
|
||||
|
||||
$stopButton = New-Object System.Windows.Forms.Button
|
||||
$stopButton.Text = "Stop"
|
||||
$stopButton.Location = New-Object System.Drawing.Point(125, 45)
|
||||
$stopButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($stopButton)
|
||||
|
||||
$restartButton = New-Object System.Windows.Forms.Button
|
||||
$restartButton.Text = "Restart"
|
||||
$restartButton.Location = New-Object System.Drawing.Point(235, 45)
|
||||
$restartButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($restartButton)
|
||||
|
||||
$testButton = New-Object System.Windows.Forms.Button
|
||||
$testButton.Text = "Test Hub"
|
||||
$testButton.Location = New-Object System.Drawing.Point(345, 45)
|
||||
$testButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($testButton)
|
||||
|
||||
$saveButton = New-Object System.Windows.Forms.Button
|
||||
$saveButton.Text = "Save Config"
|
||||
$saveButton.Location = New-Object System.Drawing.Point(455, 45)
|
||||
$saveButton.Size = New-Object System.Drawing.Size(120, 32)
|
||||
$form.Controls.Add($saveButton)
|
||||
|
||||
$secretLabel = New-Object System.Windows.Forms.Label
|
||||
$secretLabel.Text = "Agent Secret:"
|
||||
$secretLabel.Location = New-Object System.Drawing.Point(15, 90)
|
||||
$secretLabel.Size = New-Object System.Drawing.Size(100, 22)
|
||||
$form.Controls.Add($secretLabel)
|
||||
|
||||
$secretBox = New-Object System.Windows.Forms.TextBox
|
||||
$secretBox.Location = New-Object System.Drawing.Point(115, 87)
|
||||
$secretBox.Size = New-Object System.Drawing.Size(570, 24)
|
||||
$secretBox.UseSystemPasswordChar = $true
|
||||
$form.Controls.Add($secretBox)
|
||||
|
||||
$showSecretCheck = New-Object System.Windows.Forms.CheckBox
|
||||
$showSecretCheck.Text = "Show"
|
||||
$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88)
|
||||
$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24)
|
||||
$form.Controls.Add($showSecretCheck)
|
||||
|
||||
$saveSecretButton = New-Object System.Windows.Forms.Button
|
||||
$saveSecretButton.Text = "Save Secret"
|
||||
$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84)
|
||||
$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30)
|
||||
$form.Controls.Add($saveSecretButton)
|
||||
|
||||
$configBox = New-Object System.Windows.Forms.TextBox
|
||||
$configBox.Location = New-Object System.Drawing.Point(15, 125)
|
||||
$configBox.Size = New-Object System.Drawing.Size(900, 350)
|
||||
$configBox.Multiline = $true
|
||||
$configBox.ScrollBars = "Both"
|
||||
$configBox.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||
$configBox.Text = Read-ConfigText
|
||||
$form.Controls.Add($configBox)
|
||||
|
||||
$stateBox = New-Object System.Windows.Forms.TextBox
|
||||
$stateBox.Location = New-Object System.Drawing.Point(15, 490)
|
||||
$stateBox.Size = New-Object System.Drawing.Size(900, 85)
|
||||
$stateBox.Multiline = $true
|
||||
$stateBox.ScrollBars = "Vertical"
|
||||
$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$form.Controls.Add($stateBox)
|
||||
|
||||
$logBox = New-Object System.Windows.Forms.TextBox
|
||||
$logBox.Location = New-Object System.Drawing.Point(15, 590)
|
||||
$logBox.Size = New-Object System.Drawing.Size(900, 130)
|
||||
$logBox.Multiline = $true
|
||||
$logBox.ScrollBars = "Vertical"
|
||||
$logBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$form.Controls.Add($logBox)
|
||||
|
||||
function Refresh-Display {
|
||||
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||
|
||||
$config = Read-Config
|
||||
if ($config -and $secretBox.Text.Length -eq 0) {
|
||||
$secretBox.Text = [string]$config.agent_secret
|
||||
}
|
||||
|
||||
if (Test-Path $StatePath) {
|
||||
$stateBox.Text = Get-Content $StatePath -Raw
|
||||
} else {
|
||||
$stateBox.Text = "No state file yet."
|
||||
}
|
||||
|
||||
if (Test-Path $LogPath) {
|
||||
$logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine
|
||||
} else {
|
||||
$logBox.Text = "No log file yet."
|
||||
}
|
||||
}
|
||||
|
||||
$showSecretCheck.Add_CheckedChanged({
|
||||
$secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked
|
||||
})
|
||||
|
||||
$saveSecretButton.Add_Click({
|
||||
try {
|
||||
$config = $configBox.Text | ConvertFrom-Json
|
||||
$config.agent_secret = $secretBox.Text
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
$configBox.Text = Get-Content $ConfigPath -Raw
|
||||
[System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.")
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$startButton.Add_Click({
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$stopButton.Add_Click({
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$restartButton.Add_Click({
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$saveButton.Add_Click({
|
||||
try {
|
||||
$null = $configBox.Text | ConvertFrom-Json
|
||||
Save-ConfigText -Text $configBox.Text
|
||||
[System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.")
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$testButton.Add_Click({
|
||||
try {
|
||||
$config = $configBox.Text | ConvertFrom-Json
|
||||
if ($secretBox.Text.Length -gt 0) {
|
||||
$config.agent_secret = $secretBox.Text
|
||||
}
|
||||
|
||||
$hub = [string]$config.hub_url
|
||||
$agentId = [string]$config.agent_id
|
||||
$secret = [string]$config.agent_secret
|
||||
|
||||
$headers = @{
|
||||
"X-MinerOps-Agent-ID" = $agentId
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
}
|
||||
|
||||
$result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20
|
||||
[System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name)
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 3000
|
||||
$timer.Add_Tick({ Refresh-Display })
|
||||
$timer.Start()
|
||||
|
||||
Refresh-Display
|
||||
[void]$form.ShowDialog()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,108 @@
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$TaskName = "MinerOpsAgent"
|
||||
$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1"
|
||||
$LogPath = "C:\MinerOpsAgent\logs\agent.log"
|
||||
$DashboardUrl = "https://plexus.lussier.cc/dashboard"
|
||||
|
||||
function Start-Agent {
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Stop-Agent {
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Restart-Agent {
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Open-Gui {
|
||||
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`""
|
||||
}
|
||||
|
||||
function Open-Dashboard {
|
||||
Start-Process $DashboardUrl
|
||||
}
|
||||
|
||||
function Open-Log {
|
||||
if (Test-Path $LogPath) {
|
||||
Start-Process notepad.exe $LogPath
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AgentStatusText {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
|
||||
if (!$task) {
|
||||
return "MinerOps Agent: task not installed"
|
||||
}
|
||||
|
||||
return "MinerOps Agent: $($task.State)"
|
||||
}
|
||||
|
||||
$notify = New-Object System.Windows.Forms.NotifyIcon
|
||||
$notify.Icon = [System.Drawing.SystemIcons]::Application
|
||||
$notify.Text = Get-AgentStatusText
|
||||
$notify.Visible = $true
|
||||
|
||||
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
||||
|
||||
$itemOpen = $menu.Items.Add("Open Agent GUI")
|
||||
$itemOpen.Add_Click({ Open-Gui })
|
||||
|
||||
$itemDashboard = $menu.Items.Add("Open Plexus Dashboard")
|
||||
$itemDashboard.Add_Click({ Open-Dashboard })
|
||||
|
||||
$menu.Items.Add("-") | Out-Null
|
||||
|
||||
$itemStart = $menu.Items.Add("Start Agent")
|
||||
$itemStart.Add_Click({
|
||||
Start-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$itemStop = $menu.Items.Add("Stop Agent")
|
||||
$itemStop.Add_Click({
|
||||
Stop-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$itemRestart = $menu.Items.Add("Restart Agent")
|
||||
$itemRestart.Add_Click({
|
||||
Restart-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$menu.Items.Add("-") | Out-Null
|
||||
|
||||
$itemLog = $menu.Items.Add("Open Log")
|
||||
$itemLog.Add_Click({ Open-Log })
|
||||
|
||||
$itemExit = $menu.Items.Add("Exit Tray Icon")
|
||||
$itemExit.Add_Click({
|
||||
$notify.Visible = $false
|
||||
$notify.Dispose()
|
||||
[System.Windows.Forms.Application]::Exit()
|
||||
})
|
||||
|
||||
$notify.ContextMenuStrip = $menu
|
||||
|
||||
$notify.Add_DoubleClick({
|
||||
Open-Gui
|
||||
})
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 5000
|
||||
$timer.Add_Tick({
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
$timer.Start()
|
||||
|
||||
[System.Windows.Forms.Application]::Run()
|
||||
@@ -0,0 +1,293 @@
|
||||
param(
|
||||
[int] $Limit = 25,
|
||||
[int] $TimeoutMs = 2500
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
|
||||
if (-not (Test-Path $ConfigPath)) {
|
||||
throw "Missing config file: $ConfigPath"
|
||||
}
|
||||
|
||||
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
$HubUrl = [string]$Config.hub_url
|
||||
$HubUrl = $HubUrl.TrimEnd("/")
|
||||
$AgentId = [string]$Config.agent_id
|
||||
$SiteName = [string]$Config.site_name
|
||||
$Secret = [string]$Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $Secret
|
||||
}
|
||||
|
||||
function Invoke-WhatsMinerApi3Read {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||
[Parameter(Mandatory=$true)] [hashtable] $Request,
|
||||
[int] $TimeoutMs = 2500
|
||||
)
|
||||
|
||||
# Exact operation and parameter allowlist at the shared transport boundary.
|
||||
# No extra keys (including credentials, command aliases, or parameter overrides)
|
||||
# reach the wire, even when this function is called outside the poller loop.
|
||||
$readCommands = @("get.device.info", "get.miner.setting", "get.miner.status",
|
||||
"get.fan.setting", "get.device.custom_data")
|
||||
$command = $Request["cmd"]
|
||||
$approvedCommand = $null
|
||||
if ($command -is [string]) {
|
||||
foreach ($candidate in $readCommands) {
|
||||
if ([string]::Equals($candidate, $command, [StringComparison]::Ordinal)) {
|
||||
$approvedCommand = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($null -eq $approvedCommand) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
$expectedKeys = @("cmd")
|
||||
$wireRequest = [ordered]@{ cmd = $approvedCommand }
|
||||
if ([string]::Equals($approvedCommand, "get.miner.status", [StringComparison]::Ordinal)) {
|
||||
$expectedKeys += "param"
|
||||
if ($Request["param"] -isnot [string] -or
|
||||
-not [string]::Equals($Request["param"], "summary+pools+edevs", [StringComparison]::Ordinal)) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
$wireRequest["param"] = "summary+pools+edevs"
|
||||
}
|
||||
if ($Request.Count -ne $expectedKeys.Count) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
foreach ($key in $Request.Keys) {
|
||||
$approvedKey = $false
|
||||
if ($key -is [string]) {
|
||||
foreach ($expectedKey in $expectedKeys) {
|
||||
if ([string]::Equals($expectedKey, $key, [StringComparison]::Ordinal)) {
|
||||
$approvedKey = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $approvedKey) {
|
||||
throw "unsupported_diagnostic_operation_stage_a"
|
||||
}
|
||||
}
|
||||
|
||||
$client = $null
|
||||
$stream = $null
|
||||
|
||||
try {
|
||||
$json = ($wireRequest | ConvertTo-Json -Depth 20 -Compress)
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$connect = $client.BeginConnect($Ip, 4433, $null, $null)
|
||||
|
||||
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||
try { $client.Close() } catch {}
|
||||
throw "Timed out connecting to $Ip`:4433"
|
||||
}
|
||||
|
||||
$client.EndConnect($connect)
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$stream.Write($length, 0, 4)
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
$stream.Flush()
|
||||
|
||||
$lenBuf = New-Object byte[] 4
|
||||
$offset = 0
|
||||
|
||||
while ($offset -lt 4) {
|
||||
$read = $stream.Read($lenBuf, $offset, 4 - $offset)
|
||||
|
||||
if ($read -le 0) {
|
||||
throw "No response length received."
|
||||
}
|
||||
|
||||
$offset += $read
|
||||
}
|
||||
|
||||
$responseLength = [BitConverter]::ToInt32($lenBuf, 0)
|
||||
|
||||
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
|
||||
throw "Invalid response length: $responseLength"
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] $responseLength
|
||||
$offset = 0
|
||||
|
||||
while ($offset -lt $responseLength) {
|
||||
$read = $stream.Read($buffer, $offset, $responseLength - $offset)
|
||||
|
||||
if ($read -le 0) {
|
||||
throw "Response ended early at $offset of $responseLength bytes."
|
||||
}
|
||||
|
||||
$offset += $read
|
||||
}
|
||||
|
||||
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength)
|
||||
$parsed = $raw | ConvertFrom-Json
|
||||
|
||||
return [pscustomobject]@{
|
||||
ok = $true
|
||||
parsed = $parsed
|
||||
error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [pscustomobject]@{
|
||||
ok = $false
|
||||
parsed = $null
|
||||
error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($stream) { try { $stream.Close() } catch {} }
|
||||
if ($client) { try { $client.Close() } catch {} }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Api3OkCommand {
|
||||
param(
|
||||
[string] $Ip,
|
||||
[hashtable] $Request
|
||||
)
|
||||
|
||||
$result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs
|
||||
|
||||
if (-not $result.ok) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($result.parsed.code -ne 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $result.parsed
|
||||
}
|
||||
|
||||
Write-Host "Fetching known miners from $HubUrl"
|
||||
|
||||
$known = Invoke-RestMethod `
|
||||
-Uri "$HubUrl/api/agent/known-miners" `
|
||||
-Headers $Headers `
|
||||
-Method Get
|
||||
|
||||
$miners = @($known.miners)
|
||||
|
||||
if ($Limit -gt 0) {
|
||||
$miners = @($miners | Select-Object -First $Limit)
|
||||
}
|
||||
|
||||
Write-Host "API3 enrichment check count: $($miners.Count)"
|
||||
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
$checked = 0
|
||||
$api3Ok = 0
|
||||
$api3Failed = 0
|
||||
|
||||
foreach ($miner in $miners) {
|
||||
$checked += 1
|
||||
$ip = [string]$miner.ip_address
|
||||
$mac = [string]$miner.mac_address
|
||||
|
||||
if (-not $ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "[$checked/$($miners.Count)] $ip $mac"
|
||||
|
||||
$deviceInfoResult = Invoke-WhatsMinerApi3Read `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.device.info" } `
|
||||
-TimeoutMs $TimeoutMs
|
||||
|
||||
if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) {
|
||||
$api3Failed += 1
|
||||
|
||||
$err = $deviceInfoResult.error
|
||||
|
||||
if ($deviceInfoResult.ok) {
|
||||
$err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)"
|
||||
}
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $false
|
||||
api3_error = $err
|
||||
device_info = $null
|
||||
miner_setting = $null
|
||||
miner_status = $null
|
||||
fan_setting = $null
|
||||
custom_data = $null
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
$api3Ok += 1
|
||||
|
||||
$minerSetting = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.miner.setting" }
|
||||
|
||||
$minerStatus = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" }
|
||||
|
||||
$fanSetting = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.fan.setting" }
|
||||
|
||||
$customData = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.device.custom_data" }
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $true
|
||||
api3_error = $null
|
||||
device_info = $deviceInfoResult.parsed
|
||||
miner_setting = $minerSetting
|
||||
miner_status = $minerStatus
|
||||
fan_setting = $fanSetting
|
||||
custom_data = $customData
|
||||
})
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
agent_id = $AgentId
|
||||
site_name = $SiteName
|
||||
items = @($items.ToArray())
|
||||
}
|
||||
|
||||
$json = $payload | ConvertTo-Json -Depth 80
|
||||
|
||||
Write-Host "Posting API3 enrichment results to hub..."
|
||||
$result = Invoke-RestMethod `
|
||||
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
||||
-Method Post `
|
||||
-Headers $Headers `
|
||||
-ContentType "application/json" `
|
||||
-Body $json
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local checked: $checked"
|
||||
Write-Host "API3 OK: $api3Ok"
|
||||
Write-Host "API3 failed: $api3Failed"
|
||||
Write-Host ""
|
||||
$result | ConvertTo-Json -Depth 20
|
||||
@@ -0,0 +1,100 @@
|
||||
# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1.
|
||||
|
||||
function Get-MinerOpsAgentVersion {
|
||||
$versionPath = Join-Path $PSScriptRoot "VERSION"
|
||||
if (Test-Path $versionPath) {
|
||||
return ([string](Get-Content $versionPath -Raw)).Trim()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function Get-MinerOpsControlCredentials {
|
||||
$config = Read-AgentConfig
|
||||
$account = ([string]$config.api3_account).Trim()
|
||||
$password = [string]$config.api3_password
|
||||
$port = 4433
|
||||
if ($config.api3_port) {
|
||||
try { $port = [int]$config.api3_port } catch { $port = 4433 }
|
||||
}
|
||||
return @{
|
||||
available = (-not [string]::IsNullOrWhiteSpace($account)) -and
|
||||
(-not [string]::IsNullOrWhiteSpace($password))
|
||||
account = $account
|
||||
password = $password
|
||||
port = $port
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsControlApi3Command {
|
||||
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||
throw "physical_control_disabled_stage_a"
|
||||
}
|
||||
|
||||
function New-MinerOpsControlResult {
|
||||
param(
|
||||
[bool]$Accepted,
|
||||
[bool]$VerificationAttempted,
|
||||
[string]$VerificationStatus,
|
||||
[string]$ObservedMode = "",
|
||||
[string]$FailureCode = "",
|
||||
[string]$QueueStatus = "completed"
|
||||
)
|
||||
return [ordered]@{
|
||||
schema_version = 1
|
||||
command_accepted = $Accepted
|
||||
verification_attempted = $VerificationAttempted
|
||||
verification_status = $VerificationStatus
|
||||
observed_service_state = $null
|
||||
observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null }
|
||||
failure_code = if ($FailureCode) { $FailureCode } else { $null }
|
||||
_queue_status = $QueueStatus
|
||||
}
|
||||
}
|
||||
|
||||
function Run-MinerControlCommand {
|
||||
param([Parameter(Mandatory=$true)][object]$Payload)
|
||||
# No current request schema binds authorization to physical identity.
|
||||
# Do not parse, normalize, reinterpret, or transmit an old queued payload.
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "physical_control_disabled_stage_a" -QueueStatus "failed"
|
||||
}
|
||||
|
||||
function Send-AgentCommandResult {
|
||||
param(
|
||||
[string]$CommandId,
|
||||
[string]$Status,
|
||||
[object]$ResultPayload = $null,
|
||||
[string]$ErrorMessage = $null
|
||||
)
|
||||
if ($ResultPayload -and $ResultPayload._queue_status) {
|
||||
$Status = [string]$ResultPayload._queue_status
|
||||
$ResultPayload.Remove("_queue_status")
|
||||
$ErrorMessage = if ($ResultPayload.failure_code) {
|
||||
[string]$ResultPayload.failure_code
|
||||
} else { $null }
|
||||
}
|
||||
$body = [ordered]@{
|
||||
command_id = $CommandId
|
||||
status = $Status
|
||||
result_payload = $ResultPayload
|
||||
error_message = $ErrorMessage
|
||||
}
|
||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body
|
||||
}
|
||||
|
||||
function Send-AgentCheckin {
|
||||
$config = Read-AgentConfig
|
||||
$body = [ordered]@{
|
||||
agent_id = [string]$config.agent_id
|
||||
site_id = [string]$config.site_id
|
||||
site_name = [string]$config.site_name
|
||||
hostname = $env:COMPUTERNAME
|
||||
version = Get-MinerOpsAgentVersion
|
||||
ip_address = ""
|
||||
control_available = $false
|
||||
control_unavailable_reason = "physical_control_disabled_stage_a"
|
||||
local_time = (Get-Date).ToString("o")
|
||||
}
|
||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json
|
||||
|
||||
$HubUrl = ($Config.hub_url + "").TrimEnd("/")
|
||||
$AgentId = $Config.agent_id
|
||||
$AgentSecret = $Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $AgentSecret
|
||||
}
|
||||
|
||||
function Normalize-MinerOpsIp {
|
||||
param([string] $Ip)
|
||||
|
||||
if (-not $Ip) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ($Ip -replace "/32$", "").Trim()
|
||||
}
|
||||
|
||||
function Invoke-MinerOps4028PoolsSimple {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||
[int] $Port = 4028,
|
||||
[int] $TimeoutMs = 5000
|
||||
)
|
||||
|
||||
$client = $null
|
||||
$stream = $null
|
||||
|
||||
try {
|
||||
$json = '{"command":"pools"}'
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
$client.Connect($Ip, $Port)
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$buffer = New-Object byte[] 131072
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim()
|
||||
|
||||
if (-not $raw) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parsed = $raw | ConvertFrom-Json
|
||||
|
||||
if (-not $parsed.POOLS) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return @($parsed.POOLS)
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
finally {
|
||||
if ($stream) {
|
||||
try { $stream.Close() } catch {}
|
||||
}
|
||||
|
||||
if ($client) {
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60
|
||||
|
||||
$knownMiners = @()
|
||||
|
||||
if ($response.miners) {
|
||||
$knownMiners = @($response.miners)
|
||||
} elseif ($response.data -and $response.data.miners) {
|
||||
$knownMiners = @($response.data.miners)
|
||||
} elseif ($response.data -and $response.data.known_miners) {
|
||||
$knownMiners = @($response.data.known_miners)
|
||||
}
|
||||
|
||||
Write-Host "Known miners: $($knownMiners.Count)"
|
||||
|
||||
$poolSnapshots = New-Object System.Collections.Generic.List[object]
|
||||
$count = 0
|
||||
|
||||
foreach ($miner in $knownMiners) {
|
||||
$count++
|
||||
|
||||
$ip = Normalize-MinerOpsIp -Ip $miner.ip_address
|
||||
|
||||
if (-not $ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (($count % 25) -eq 0) {
|
||||
Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)."
|
||||
}
|
||||
|
||||
$pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip)
|
||||
|
||||
if ($pools.Count -gt 0) {
|
||||
$poolSnapshots.Add([pscustomobject]@{
|
||||
miner_id = $miner.miner_id
|
||||
ip_address = $ip
|
||||
mac_address = $miner.mac_address
|
||||
model = $miner.model
|
||||
pools = @($pools)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||
|
||||
$body = @{
|
||||
agent_id = $AgentId
|
||||
pool_snapshots = $poolSnapshots
|
||||
} | ConvertTo-Json -Depth 20
|
||||
|
||||
$result = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||
-Headers $Headers `
|
||||
-Body $body `
|
||||
-ContentType "application/json" `
|
||||
-TimeoutSec 180
|
||||
|
||||
$result | ConvertTo-Json -Depth 10
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
$LogDir = "C:\MinerOpsAgent\logs"
|
||||
$LogFile = Join-Path $LogDir "pool-poller.log"
|
||||
|
||||
if (-not (Test-Path $LogDir)) {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
try {
|
||||
Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller."
|
||||
|
||||
$output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1
|
||||
|
||||
foreach ($line in $output) {
|
||||
Add-Content -Path $LogFile -Value "$line"
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
Add-Content -Path $LogFile -Value "$stamp Pool poller finished."
|
||||
Add-Content -Path $LogFile -Value ""
|
||||
}
|
||||
catch {
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)"
|
||||
Add-Content -Path $LogFile -Value ""
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
# MinerOps Windows Agent
|
||||
|
||||
## Stage A physical-control containment
|
||||
|
||||
Physical miner control is disabled in this version, including suspend, resume,
|
||||
performance-mode changes, reboot, M30 tests, and miner-side password changes.
|
||||
No environment flag, configured API3 credential, or Agent capability report can
|
||||
enable it. This is containment, not completion of Blocker #3 identity binding.
|
||||
|
||||
The Hub returns HTTP 410 for retired legacy command, scheduling, and unrestricted
|
||||
diagnostic endpoints (after authorization where applicable). Typed control POSTs
|
||||
require `miners.control` and return HTTP 409 `physical_control_disabled_stage_a`.
|
||||
The unrestricted `whatsminer-read-test` endpoint is not a supported read-only tool.
|
||||
|
||||
Authenticated queue polling cancels pending/claimed physical and unknown command
|
||||
types, records an atomic `agent.command.rejected` audit entry, and only delivers
|
||||
fixed telemetry/discovery command types. Results cannot promote retired entries.
|
||||
Original payloads, prior results/errors, timestamps, and terminal history remain.
|
||||
Cancellation of an already claimed command does **not** prove it was never sent;
|
||||
the audit explicitly records its previous execution outcome as unknown.
|
||||
|
||||
The Windows executor rejects all current physical-control schemas without any
|
||||
device connection and advertises physical control as unavailable. Existing Agents
|
||||
must be stopped/drained and upgraded before claiming fleet-wide containment:
|
||||
server cancellation cannot recall payloads cached by older running Agents.
|
||||
Do not enable control or deploy this work before separate security review.
|
||||
The ordered, fail-closed procedure is in
|
||||
[the Stage A old-Agent cutover runbook](../../docs/operations/STAGE_A_OLD_AGENT_CUTOVER.txt).
|
||||
Native Windows PowerShell 5.1/SYSTEM validation and verified fleet network fencing
|
||||
remain production gates; server cancellation alone cannot contain an old executor.
|
||||
|
||||
Retained legacy/API3 enrichment helpers validate exact read commands and fixed
|
||||
parameters before opening a socket. Unknown operations and extra keys fail closed.
|
||||
Schedule cancel/delete require `miners.control` and retire matching live unsupported
|
||||
commands for the stored Agent with atomic actor-attributed rejection audits.
|
||||
Deletion retains schedule history; retries do not duplicate audits. Previously
|
||||
claimed execution remains unknown. Manual cutover retirement does not claim read
|
||||
work and must sweep offline/queue-only Agent IDs until the global live count is zero.
|
||||
|
||||
The Windows Agent runs at a mining site and performs local discovery, polling,
|
||||
API3 enrichment, and pool polling. Physical miner control is disabled in Stage A.
|
||||
MinerOps does not send API3 credentials through the hub command queue; those
|
||||
credentials remain in the Agent's local `agent-config.json`.
|
||||
|
||||
## Recovered Baseline
|
||||
|
||||
The seven runtime and support scripts in this directory were recovered from the
|
||||
live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact
|
||||
pre-change hashes. Historical backups, live configuration, state, logs, and
|
||||
probe output are intentionally excluded.
|
||||
|
||||
## Local Files
|
||||
|
||||
Create `agent-config.json` from `config.example.json` on the Agent host. Never
|
||||
commit that file. `site_id` is the durable MinerOps site UUID. `agent_zone_id` is the optional
|
||||
durable network-reachability Zone UUID; older configurations may omit it only
|
||||
when the Hub can preserve an established assignment or resolve exactly one
|
||||
enabled Zone at the Site. `site_name` is retained only for transitional display
|
||||
compatibility.
|
||||
|
||||
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
|
||||
Installation and upgrades preserve all three paths.
|
||||
|
||||
## Security
|
||||
|
||||
API3 account and password values are local secrets. Missing credentials make
|
||||
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
|
||||
must not be written to normal logs or returned to MinerOps command history.
|
||||
|
||||
New Agent identities use per-Agent credentials created through secure
|
||||
one-time enrollment. The permanent Agent secret is returned only to the
|
||||
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
|
||||
only its hash. The legacy deployment shared secret remains only for explicitly
|
||||
retained compatibility behavior and must not authenticate a newly enrolled
|
||||
per-Agent identity.
|
||||
|
||||
## Legacy Compatibility
|
||||
|
||||
The older direct Hub API3, `/agent-control`, password-change, bulk-control, and
|
||||
scheduling paths remain available for compatibility and are deprecated for the
|
||||
Internal Alpha. The new Miner Detail controls use only the typed control API and
|
||||
Agent queue; the modular control backend does not connect directly to miners.
|
||||
|
||||
## Deployment
|
||||
|
||||
### New Agent enrollment
|
||||
|
||||
For a new Agent, use Agent Management in MinerOps:
|
||||
|
||||
1. Choose the Site and an enabled, unassigned Agent Zone.
|
||||
2. Enter a descriptive Agent name.
|
||||
3. Create a Windows enrollment.
|
||||
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
|
||||
5. Copy the ZIP to the intended Windows machine and extract it.
|
||||
6. Open Windows PowerShell as Administrator in the extracted
|
||||
MinerOpsAgent-Windows directory.
|
||||
7. Run:
|
||||
|
||||
.\Enroll-MinerOpsAgent.ps1
|
||||
|
||||
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
|
||||
the account blank if API3 functionality is not being configured yet.
|
||||
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
|
||||
saves the resulting permanent Agent identity locally, and hands off to the
|
||||
safe runtime installer.
|
||||
10. Verify the Agent appears in Agent Management and checks in successfully.
|
||||
11. Delete the downloaded ZIP and extracted setup directory.
|
||||
|
||||
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
|
||||
credentials in screenshots, chat, tickets, logs, or source control.
|
||||
|
||||
### Existing Agent upgrade
|
||||
|
||||
An existing legacy Agent must receive a per-Agent credential before the global
|
||||
shared-secret compatibility path can be retired for its identity. In Agent
|
||||
Management, use **Upgrade credential** on that Agent. Download the resulting
|
||||
Windows package, transfer it only to that Agent computer, and run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1
|
||||
|
||||
The authorization is bound server-side to the existing Agent ID and its
|
||||
current Site and Agent Zone. The script validates the local Agent ID, upgrades
|
||||
the canonical runtime, stops the main Agent task, generates the permanent
|
||||
secret locally, and recoverably replaces only the credential and durable routing
|
||||
fields in `agent-config.json` using protected same-directory pending and prior
|
||||
names. Existing API3 credentials, polling settings,
|
||||
network ranges, state, logs, and backups are preserved. Before any secret is
|
||||
written, the script creates and verifies a protected recovery file containing
|
||||
the original Scheduled Task state, exact transition token, and exact locally
|
||||
generated credential. The Hub accepts an ambiguous retry only for that same
|
||||
token and credential.
|
||||
|
||||
The Agent directory is a filesystem security boundary. The transition rejects
|
||||
junctions/reparse points, verifies trusted ancestors, denies standard users
|
||||
create/delete/rename rights, and revalidates the boundary before each rename.
|
||||
If an existing Agent directory is discovered insecure, the script first writes
|
||||
`C:\MinerOpsAgent.minerops-filesystem-trust.blocked-<random>.state` under the trusted
|
||||
parent, then hardens the directory only to contain the incident. The blocker is
|
||||
outside the contaminated namespace because changing a DACL does not revoke
|
||||
file or directory handles opened before repair. The same process never redeems
|
||||
a token, creates or activates a permanent credential, runs the installer, or
|
||||
removes that blocker. Ordinary second, third, and later invocations remain
|
||||
blocked even when every current ACL looks correct.
|
||||
|
||||
Recovery, pending, replacement remnants, and late-arriving artifacts are
|
||||
non-authoritative while the external blocker exists. Known transition state is
|
||||
quarantined without parsing. A correct filename, syntax, or ACL cannot promote
|
||||
it on a later run. Invalidate any possibly exposed authorization and retain or
|
||||
remove evidence only under the approved incident procedure.
|
||||
|
||||
### Filesystem trust-epoch recovery
|
||||
|
||||
Use this procedure only after confirming the blocker was created by the
|
||||
containment event and preserving required evidence:
|
||||
|
||||
1. Do not delete or edit the external blocker. Do not run an ordinary retry.
|
||||
2. Use **Restart** to perform a full Windows kernel boot. Shutdown with Fast
|
||||
Startup is not sufficient: Microsoft documents Fast Startup as hibernating
|
||||
and restoring kernel session 0. The script identifies the kernel epoch by
|
||||
the Event Log record anchor of the newest
|
||||
`Microsoft-Windows-Kernel-Boot` event 27 whose `BootType` is exactly `0`.
|
||||
Provider, event ID, record ID, and supported event version come from typed
|
||||
EventRecord properties; namespace-aware XML selection reads only the
|
||||
`BootType` payload. The new full-boot record ID must be strictly greater
|
||||
than the stored record ID. Equality, an older/restored record, log clearing
|
||||
or numbering reset, missing history, duplication, malformed XML, and an
|
||||
unsupported provider schema all fail closed and require safe operator
|
||||
intervention. There is no timestamp fallback. The anchor is unchanged by
|
||||
clock or timezone changes, sleep, hibernation, and Fast Startup. See
|
||||
Microsoft's [system power-state documentation](https://learn.microsoft.com/windows/win32/power/system-power-states)
|
||||
and [Fast Startup support guidance](https://learn.microsoft.com/troubleshoot/windows-client/setup-upgrade-and-drivers/fast-startup-causes-system-hibernation-shutdown-fail).
|
||||
3. For a credential transition, use a fresh transition package if any recovery,
|
||||
pending, remnant, quarantine, or internal blocker existed. Remove those
|
||||
artifacts only under the approved incident procedure after invalidating the
|
||||
old authorization.
|
||||
4. From the reviewed package, run:
|
||||
|
||||
.\Upgrade-MinerOpsAgentCredential.ps1 `
|
||||
-ApproveFilesystemTrustResetAfterReboot
|
||||
|
||||
The script verifies that Windows boot identity changed, rescans the protected
|
||||
namespace, positively verifies and stops the expected Scheduled Task, and asks the
|
||||
installer to replace managed runtime without backing up or restoring any
|
||||
pre-reset runtime. The installer leaves both the Agent task and external
|
||||
blocker untouched after publication; it has no blocker-deletion path. Before
|
||||
its first fallible stop/install step, the parent durably records the strictly
|
||||
validated original task intent as `reset-authorized`; after installer
|
||||
success it advances the blocker to `runtime-published`. It then verifies the
|
||||
config against the package/server identity, commits the credential, and
|
||||
restores the recorded task intent. Only the parent removes the blocker,
|
||||
after active config and final task state have both been positively verified
|
||||
and non-recovery transition cleanup has succeeded. Protected recovery stays
|
||||
until after blocker deletion so an interrupted final cleanup remains safe.
|
||||
5. For a genuinely new enrollment, preserve evidence and use the approved
|
||||
protected-recreation incident procedure to remove the contaminated namespace
|
||||
and its external blocker, then let `Enroll-MinerOpsAgent.ps1` stage a new
|
||||
protected directory. Enrollment has no in-script blocker-deletion path; its
|
||||
explicit compatibility switch cannot override a retained blocker.
|
||||
|
||||
The explicit switch without a strictly newer canonical full-kernel-boot anchor
|
||||
fails.
|
||||
Any suspicious transition artifact, failed runtime publication, failed
|
||||
credential convergence, invalid final task, or interrupted cleanup retains the
|
||||
blocker. Runtime publication alone never authorizes deletion. Protected
|
||||
directory recreation is the alternative when the retained namespace cannot be
|
||||
verified.
|
||||
|
||||
If the script is interrupted after the Hub commits, rerun the same extracted
|
||||
package without deleting or editing `agent-credential-transition.recovery.json`
|
||||
or `agent-config.json.pending`. A protected, matching pending config is resumed;
|
||||
an insecure, malformed, or mismatched artifact fails closed. The original task
|
||||
intent is durable: an Agent which was running before the first attempt ends
|
||||
running, while an intentionally stopped Agent remains stopped. Success is not
|
||||
reported and transition artifacts are not removed until both the active config
|
||||
and expected final task state are verified. The verifier requires exactly one
|
||||
enabled root `MinerOpsAgent` task, the SYSTEM service-account/highest principal,
|
||||
the installed Windows PowerShell action and service-script arguments, and an
|
||||
exact final state of `Running` for running intent or `Ready` for stopped intent.
|
||||
Missing, query failure, access denied, malformed, multiple, disabled, queued,
|
||||
unknown, wrong-action, and wrong-principal results all fail closed and retain
|
||||
recovery.
|
||||
|
||||
After a successful transition, the existing Agent ID, Site, and Zone are
|
||||
unchanged. Explicit Agent ID plus the new per-Agent secret is required, and the
|
||||
legacy shared secret is permanently disabled for that identity.
|
||||
|
||||
For later code-only upgrades of an Agent that already has a per-Agent
|
||||
credential, copy the new canonical Agent files to a temporary directory and run
|
||||
install.ps1 from an elevated Windows PowerShell session.
|
||||
|
||||
The installer copies only managed runtime code to C:\MinerOpsAgent and
|
||||
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
|
||||
and other runtime data. Existing Scheduled Task definitions are authoritative
|
||||
and are not rewritten. Missing tasks are created for a fresh or incomplete
|
||||
installation without hard-coded user names.
|
||||
|
||||
The per-user `MinerOpsAgentTray` task is optional UI and is not credential or
|
||||
core Agent authority. During an explicit filesystem trust reset, runtime
|
||||
publication may run noninteractively as `NT AUTHORITY\SYSTEM`, where no safe
|
||||
interactive user principal can be inferred. The installer still publishes
|
||||
`MinerOpsAgentTray.ps1`, but deliberately defers creation of a missing tray task
|
||||
until a later interactive maintenance install. It never creates the tray task
|
||||
as SYSTEM or invents a username. An existing tray task remains authoritative
|
||||
and is preserved unchanged rather than silently repaired under SYSTEM; inspect
|
||||
and remediate a suspected malformed definition separately. The core
|
||||
`MinerOpsAgent` task remains mandatory and strictly verified by credential
|
||||
recovery. `MinerOpsApi3Enrichment` and `MinerOpsPoolPoller` are non-interactive
|
||||
background tasks with explicit SYSTEM principals, so missing instances continue
|
||||
to be created during trust-reset publication.
|
||||
|
||||
If MinerOpsAgent was running before an upgrade, the installer stops only that
|
||||
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||
again. A previously stopped Agent remains stopped. Before replacing runtime
|
||||
files, the installer copies them to a timestamped backup under
|
||||
C:\MinerOpsAgent\backups.
|
||||
|
||||
Runtime sources, installed destinations, active config, and rollback copies are
|
||||
rejected when they are reparse points or have more than one hard link. A normal
|
||||
trusted upgrade creates a fresh random backup directory and never adopts an
|
||||
existing backup destination. During an explicit post-reboot trust reset, old
|
||||
runtime is never copied into backup or restored; fresh package content replaces
|
||||
it while the external blocker remains, and the Agent task stays stopped for
|
||||
credential verification.
|
||||
|
||||
A failed deployment restores managed files, removes tasks created by that
|
||||
failed attempt, and restores the main Agent's prior running state.
|
||||
|
||||
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
|
||||
code while preserving local configuration, state, logs, and backups.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
1.0.0-alpha
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"hub_url": "https://minerops.example.invalid",
|
||||
"agent_id": "replace-with-agent-id",
|
||||
"site_id": "00000000-0000-0000-0000-000000000000",
|
||||
"agent_zone_id": "00000000-0000-0000-0000-000000000000",
|
||||
"site_name": "Example Site",
|
||||
"agent_secret": "replace-with-agent-secret",
|
||||
"poll_interval_seconds": 120,
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": "Example Range",
|
||||
"cidr": "192.0.2.0/24",
|
||||
"enabled": false,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433
|
||||
}
|
||||
],
|
||||
"api3_account": "replace-with-api3-account",
|
||||
"api3_password": "replace-with-api3-password",
|
||||
"api3_port": 4433
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||
[string]$DestinationPath = "",
|
||||
[ValidateSet("None", "Path")][string]$BackupMode = "None",
|
||||
[string]$BackupPath = "",
|
||||
[Parameter(Mandatory = $true)][string]$ResultPath,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
$sourceCreatedByCaller = $false
|
||||
$sourceReadVerified = $false
|
||||
$sourceModifyVerified = $false
|
||||
$sourceRenameVerified = $false
|
||||
$sourceDeleteAccessVerified = $false
|
||||
$sourceReplaceAccessVerified = $false
|
||||
$replaceInvoked = $false
|
||||
$sourceExistedBeforeAttack = $null
|
||||
$destinationExistedBeforeAttack = $null
|
||||
$backupExistedBeforeAttack = $null
|
||||
$inputFullPath = ""
|
||||
$destinationFullPath = ""
|
||||
$backupFullPath = ""
|
||||
$backupArgumentWasNativeNull = $false
|
||||
|
||||
function Get-ProbeMetadata {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Status,
|
||||
[Exception]$Exception
|
||||
)
|
||||
|
||||
$inputOwnedByCaller = $false
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
try {
|
||||
$callerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$ownerSid = [IO.File]::GetAccessControl($InputPath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]
|
||||
)
|
||||
$inputOwnedByCaller = $callerSid.Equals($ownerSid)
|
||||
}
|
||||
catch {
|
||||
$inputOwnedByCaller = $false
|
||||
}
|
||||
}
|
||||
|
||||
$details = if ($null -ne $Exception) {
|
||||
Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
}
|
||||
else { $null }
|
||||
return [ordered]@{
|
||||
status = $Status
|
||||
operation = $Operation
|
||||
# Retain these two outer-exception fields for compatibility with
|
||||
# earlier native-harness result readers.
|
||||
exception_type = if ($null -ne $Exception) {
|
||||
$Exception.GetType().FullName
|
||||
}
|
||||
else {
|
||||
""
|
||||
}
|
||||
hresult = if ($null -ne $details) { $details.OuterHResult } else { 0 }
|
||||
outer_exception_type = if ($null -ne $details) {
|
||||
$details.OuterExceptionType
|
||||
}
|
||||
else { "" }
|
||||
outer_hresult = if ($null -ne $details) {
|
||||
$details.OuterHResult
|
||||
}
|
||||
else { 0 }
|
||||
leaf_exception_type = if ($null -ne $details) {
|
||||
$details.LeafExceptionType
|
||||
}
|
||||
else { "" }
|
||||
leaf_hresult = if ($null -ne $details) {
|
||||
$details.LeafHResult
|
||||
}
|
||||
else { 0 }
|
||||
native_error = if ($null -ne $details) {
|
||||
$details.NativeError
|
||||
}
|
||||
else { $null }
|
||||
exception_chain_types = if ($null -ne $details) {
|
||||
@($details.ExceptionChainTypes)
|
||||
}
|
||||
else { @() }
|
||||
exception_chain_truncated = if ($null -ne $details) {
|
||||
[bool]$details.ExceptionChainTruncated
|
||||
}
|
||||
else { $false }
|
||||
source_exists = [IO.File]::Exists($InputPath)
|
||||
destination_exists = if (
|
||||
[string]::IsNullOrWhiteSpace($DestinationPath)
|
||||
) {
|
||||
$null
|
||||
}
|
||||
else {
|
||||
[IO.File]::Exists($DestinationPath)
|
||||
}
|
||||
input_owned_by_caller = $inputOwnedByCaller
|
||||
source_created_by_caller = $sourceCreatedByCaller
|
||||
source_read_verified = $sourceReadVerified
|
||||
source_modify_verified = $sourceModifyVerified
|
||||
source_rename_verified = $sourceRenameVerified
|
||||
source_delete_access_verified = $sourceDeleteAccessVerified
|
||||
source_replace_access_verified = $sourceReplaceAccessVerified
|
||||
input_full_path = $inputFullPath
|
||||
destination_full_path = $destinationFullPath
|
||||
backup_mode = $BackupMode
|
||||
backup_full_path = $backupFullPath
|
||||
backup_argument_was_native_null = $backupArgumentWasNativeNull
|
||||
source_existed_before_attack = $sourceExistedBeforeAttack
|
||||
destination_existed_before_attack = $destinationExistedBeforeAttack
|
||||
backup_existed_before_attack = $backupExistedBeforeAttack
|
||||
replace_overload = "System.IO.File.Replace(String,String,String)"
|
||||
replace_invoked = $replaceInvoked
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ProbeResult {
|
||||
param([Parameter(Mandatory = $true)]$Value)
|
||||
|
||||
$json = $Value | ConvertTo-Json -Compress
|
||||
[IO.File]::WriteAllText($ResultPath, $json)
|
||||
}
|
||||
|
||||
$exception = $null
|
||||
$status = "succeeded"
|
||||
try {
|
||||
switch ($Operation) {
|
||||
"read" { [void][IO.File]::ReadAllText($InputPath) }
|
||||
"create" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"modify" { [IO.File]::WriteAllText($InputPath, "untrusted") }
|
||||
"delete" { [IO.File]::Delete($InputPath) }
|
||||
"rename" { [IO.File]::Move($InputPath, $DestinationPath) }
|
||||
"prepare_swap" {
|
||||
if ([IO.File]::Exists($InputPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source unexpectedly existed before caller creation"
|
||||
)
|
||||
}
|
||||
$renameProof = "$InputPath.rename-proof-$([guid]::NewGuid().ToString('N'))"
|
||||
[IO.File]::WriteAllText(
|
||||
$InputPath,
|
||||
"attacker-controlled-swap-source"
|
||||
)
|
||||
$sourceCreatedByCaller = $true
|
||||
$sourceReadVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source"
|
||||
[IO.File]::AppendAllText($InputPath, "-modified")
|
||||
$sourceModifyVerified = [IO.File]::ReadAllText($InputPath) -ceq
|
||||
"attacker-controlled-swap-source-modified"
|
||||
[IO.File]::Move($InputPath, $renameProof)
|
||||
[IO.File]::Move($renameProof, $InputPath)
|
||||
$sourceRenameVerified = [IO.File]::Exists($InputPath) -and
|
||||
-not [IO.File]::Exists($renameProof)
|
||||
|
||||
$requiredRights =
|
||||
[Security.AccessControl.FileSystemRights]::ReadData -bor
|
||||
[Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[Security.AccessControl.FileSystemRights]::Synchronize
|
||||
$rightsProbe = [IO.FileStream]::new(
|
||||
$InputPath,
|
||||
[IO.FileMode]::Open,
|
||||
$requiredRights,
|
||||
[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete,
|
||||
4096,
|
||||
[IO.FileOptions]::None
|
||||
)
|
||||
try {
|
||||
$sourceDeleteAccessVerified = $true
|
||||
$sourceReplaceAccessVerified = $true
|
||||
}
|
||||
finally {
|
||||
$rightsProbe.Dispose()
|
||||
}
|
||||
}
|
||||
"swap" {
|
||||
$inputFullPath = [IO.Path]::GetFullPath($InputPath)
|
||||
$destinationFullPath = [IO.Path]::GetFullPath($DestinationPath)
|
||||
if ($inputFullPath.Equals(
|
||||
$destinationFullPath,
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw [ArgumentException]::new(
|
||||
"swap source and destination are the same path"
|
||||
)
|
||||
}
|
||||
|
||||
$sourceExistedBeforeAttack = [IO.File]::Exists($inputFullPath)
|
||||
$destinationExistedBeforeAttack =
|
||||
[IO.File]::Exists($destinationFullPath)
|
||||
if (-not $sourceExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap source does not exist",
|
||||
$inputFullPath
|
||||
)
|
||||
}
|
||||
if (-not $destinationExistedBeforeAttack) {
|
||||
throw [IO.FileNotFoundException]::new(
|
||||
"swap destination does not exist",
|
||||
$destinationFullPath
|
||||
)
|
||||
}
|
||||
|
||||
if ($BackupMode -ceq "None") {
|
||||
if (-not [string]::IsNullOrEmpty($BackupPath)) {
|
||||
throw [ArgumentException]::new(
|
||||
"backup path was supplied while backup mode is None"
|
||||
)
|
||||
}
|
||||
# A bare PowerShell $null is coerced to String.Empty for this
|
||||
# .NET string parameter. NullString is the explicit native
|
||||
# null required to request no ReplaceFile backup.
|
||||
$backupArgumentWasNativeNull = $true
|
||||
}
|
||||
else {
|
||||
throw [ArgumentException]::new(
|
||||
"backup-producing swap fixtures are prohibited because " +
|
||||
"they can expose protected destination content"
|
||||
)
|
||||
}
|
||||
|
||||
$replaceInvoked = $true
|
||||
[IO.File]::Replace(
|
||||
$inputFullPath,
|
||||
$destinationFullPath,
|
||||
[NullString]::Value
|
||||
)
|
||||
}
|
||||
default { throw [ArgumentException]::new("unknown operation") }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$exception = $_.Exception
|
||||
$status = Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation $Operation -Exception $exception
|
||||
}
|
||||
|
||||
$result = Get-ProbeMetadata -Status $status -Exception $exception
|
||||
Write-ProbeResult -Value $result
|
||||
exit (Get-MinerOpsNativeStatusExitCode -Status $status)
|
||||
@@ -0,0 +1,475 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-MinerOpsNativeGetItemState {
|
||||
$effective = Get-Command Get-Item -ErrorAction Stop
|
||||
$function = if (
|
||||
$effective.CommandType -eq
|
||||
[Management.Automation.CommandTypes]::Function
|
||||
) {
|
||||
$effective
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
FunctionExisted = $null -ne $function
|
||||
FunctionScriptBlock = if ($null -ne $function) {
|
||||
$function.ScriptBlock
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
EffectiveCommandType = [string]$effective.CommandType
|
||||
EffectiveName = [string]$effective.Name
|
||||
EffectiveModuleName = [string]$effective.ModuleName
|
||||
EffectiveSource = [string]$effective.Source
|
||||
EffectiveDefinition = [string]$effective.Definition
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeGetItemState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Expected,
|
||||
[string]$Context = "Get-Item state check"
|
||||
)
|
||||
|
||||
$actual = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$actual.FunctionExisted -ne [bool]$Expected.FunctionExisted) {
|
||||
throw "$Context changed the global Get-Item function's existence."
|
||||
}
|
||||
if (
|
||||
[bool]$Expected.FunctionExisted -and
|
||||
(
|
||||
-not [object]::ReferenceEquals(
|
||||
$actual.FunctionScriptBlock,
|
||||
$Expected.FunctionScriptBlock
|
||||
) -or
|
||||
$actual.FunctionScriptBlock.ToString() -cne
|
||||
$Expected.FunctionScriptBlock.ToString()
|
||||
)
|
||||
) {
|
||||
throw "$Context changed the global Get-Item function's ScriptBlock."
|
||||
}
|
||||
foreach ($property in @(
|
||||
"EffectiveCommandType",
|
||||
"EffectiveName",
|
||||
"EffectiveModuleName",
|
||||
"EffectiveSource",
|
||||
"EffectiveDefinition"
|
||||
)) {
|
||||
if ([string]$actual.$property -cne [string]$Expected.$property) {
|
||||
throw "$Context changed effective Get-Item resolution ($property)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsNativeGetItemTrace {
|
||||
param([Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observedGetItemPaths = [System.Collections.Generic.List[string]]::new()
|
||||
$invokeWithLocalHook = {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][scriptblock]$TraceAction,
|
||||
[Parameter(Mandatory = $true)]$ObservedPaths
|
||||
)
|
||||
|
||||
# Use a child scope because PowerShell can continue resolving a
|
||||
# removed active function until the scope that defined it exits.
|
||||
# Exiting this child scope makes cleanup deterministic before the
|
||||
# caller-state assertion runs.
|
||||
Set-Item -LiteralPath "Function:\local:Get-Item" -Value {
|
||||
[CmdletBinding(DefaultParameterSetName = "Path")]
|
||||
param(
|
||||
[Parameter(Position = 0, ParameterSetName = "Path")]
|
||||
[string[]]$Path,
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "LiteralPath")]
|
||||
[Alias("PSPath")]
|
||||
[string[]]$LiteralPath,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$observed = if ($PSCmdlet.ParameterSetName -eq "LiteralPath") {
|
||||
$LiteralPath
|
||||
}
|
||||
else {
|
||||
$Path
|
||||
}
|
||||
foreach ($candidate in @($observed)) {
|
||||
$ObservedPaths.Add([string]$candidate)
|
||||
}
|
||||
Microsoft.PowerShell.Management\Get-Item @PSBoundParameters
|
||||
}
|
||||
try {
|
||||
& $TraceAction | Out-Null
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath "Function:\local:Get-Item" -Force `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
try {
|
||||
& $invokeWithLocalHook $Action $observedGetItemPaths
|
||||
return @($observedGetItemPaths)
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "Invoke-MinerOpsNativeGetItemTrace cleanup"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeWin32Error {
|
||||
param([Parameter(Mandatory = $true)][int]$HResult)
|
||||
|
||||
# HRESULT_FROM_WIN32 values have the 0x8007 facility prefix. Do not treat
|
||||
# the low word of an arbitrary CLR or PowerShell HRESULT as a Win32 error.
|
||||
$unsignedHResult = [int64]$HResult -band 4294967295L
|
||||
if (
|
||||
($unsignedHResult -band 4294901760L) -eq 2147942400L
|
||||
) {
|
||||
return [int]($unsignedHResult -band 65535L)
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionDetails {
|
||||
param([Parameter(Mandatory = $true)][Exception]$Exception)
|
||||
|
||||
$maximumDepth = 32
|
||||
$visited = [System.Collections.Generic.List[Exception]]::new()
|
||||
$chainTypes = [System.Collections.Generic.List[string]]::new()
|
||||
$current = $Exception
|
||||
$leaf = $Exception
|
||||
$truncated = $false
|
||||
|
||||
for ($depth = 0; $depth -lt $maximumDepth; $depth++) {
|
||||
if ($null -eq $current) { break }
|
||||
|
||||
$alreadyVisited = $false
|
||||
foreach ($candidate in $visited) {
|
||||
if ([object]::ReferenceEquals($candidate, $current)) {
|
||||
$alreadyVisited = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($alreadyVisited) {
|
||||
$truncated = $true
|
||||
break
|
||||
}
|
||||
|
||||
$visited.Add($current)
|
||||
$chainTypes.Add($current.GetType().FullName)
|
||||
$leaf = $current
|
||||
$current = $current.InnerException
|
||||
}
|
||||
if ($null -ne $current) {
|
||||
$truncated = $true
|
||||
}
|
||||
|
||||
$nativeError = Get-MinerOpsNativeWin32Error -HResult ([int]$leaf.HResult)
|
||||
return [pscustomobject]@{
|
||||
OuterException = $Exception
|
||||
OuterExceptionType = $Exception.GetType().FullName
|
||||
OuterHResult = [int]$Exception.HResult
|
||||
LeafException = $leaf
|
||||
LeafExceptionType = $leaf.GetType().FullName
|
||||
LeafHResult = [int]$leaf.HResult
|
||||
NativeError = $nativeError
|
||||
ExceptionChainTypes = @($chainTypes)
|
||||
ExceptionChainTruncated = $truncated
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeExceptionStatus {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Operation,
|
||||
[Parameter(Mandatory = $true)][Exception]$Exception
|
||||
)
|
||||
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $Exception
|
||||
$meaningfulException = $details.LeafException
|
||||
$nativeError = $details.NativeError
|
||||
|
||||
if ($details.ExceptionChainTruncated) {
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
if ($meaningfulException -is [UnauthorizedAccessException]) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [ArgumentException] -or
|
||||
$meaningfulException -is [IO.FileNotFoundException] -or
|
||||
$meaningfulException -is [IO.DirectoryNotFoundException] -or
|
||||
$meaningfulException -is [IO.DriveNotFoundException] -or
|
||||
$meaningfulException -is [IO.PathTooLongException] -or
|
||||
$meaningfulException -is [NotSupportedException]
|
||||
) {
|
||||
return "fixture_error"
|
||||
}
|
||||
|
||||
if (
|
||||
$meaningfulException -is [IO.IOException] -and
|
||||
$null -ne $nativeError
|
||||
) {
|
||||
if ([int]$nativeError -eq 5) {
|
||||
return "security_denied"
|
||||
}
|
||||
|
||||
if ($Operation -ceq "swap" -and [int]$nativeError -eq 1175) {
|
||||
# ReplaceFileW documents ERROR_UNABLE_TO_REMOVE_REPLACED as
|
||||
# retaining both original names. The caller must still prove every
|
||||
# security and identity postcondition before treating this as a
|
||||
# passing denial.
|
||||
return "security_denied_requires_postconditions"
|
||||
}
|
||||
|
||||
if ([int]$nativeError -eq 17) {
|
||||
# ReplaceFile requires source and destination on the same volume.
|
||||
# This says nothing about the protected target's access boundary.
|
||||
return "api_precondition_failure"
|
||||
}
|
||||
}
|
||||
|
||||
return "unexpected_failure"
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeStatusExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$Status)
|
||||
|
||||
switch ($Status) {
|
||||
"succeeded" { return 0 }
|
||||
"security_denied" { return 5 }
|
||||
"fixture_error" { return 6 }
|
||||
"unexpected_failure" { return 7 }
|
||||
"security_denied_requires_postconditions" { return 8 }
|
||||
"api_precondition_failure" { return 9 }
|
||||
default { throw "Unknown standard-user helper status '$Status'." }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeSwapFixtureStatus {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$fixtureFailures = [System.Collections.Generic.List[string]]::new()
|
||||
$requiredFixtureChecks = [ordered]@{
|
||||
SourceExists = "source is missing"
|
||||
TargetExists = "target is missing"
|
||||
PathsDistinct = "source and target are the same path"
|
||||
SourceCreatedByStandardUser = `
|
||||
"source was not created by the standard-user process"
|
||||
SourceOwnedByStandardUser = `
|
||||
"standard user does not own the source"
|
||||
SourceReadable = "standard user cannot read the source"
|
||||
SourceModifiable = "standard user cannot modify the source"
|
||||
SourceRenameable = "standard user cannot rename the source"
|
||||
SourceDeleteCapable = `
|
||||
"standard user does not have required delete access to the source"
|
||||
SourceReplaceAccessVerified = `
|
||||
"standard user lacks source access required by ReplaceFile"
|
||||
TargetAclTrusted = "target ACL was not trusted before attack"
|
||||
TargetOwnerTrusted = "target owner was not trusted before attack"
|
||||
TargetIdentityCaptured = "target identity was not captured"
|
||||
SourceIdentityCaptured = "source identity was not captured"
|
||||
FileIdentitiesDistinct = "source and target resolve to the same file"
|
||||
BackupArgumentValid = "backup argument is invalid"
|
||||
BackupPathsDistinct = `
|
||||
"backup path is not distinct from source and target"
|
||||
BackupExpectedStateVerified = `
|
||||
"backup pre-attack state was not verified"
|
||||
BackupConfidentialitySafe = `
|
||||
"backup choice could expose protected target content"
|
||||
}
|
||||
foreach ($entry in $requiredFixtureChecks.GetEnumerator()) {
|
||||
if (-not [bool]$Fixture.($entry.Key)) {
|
||||
$fixtureFailures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($fixtureFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "fixture_error"
|
||||
Failures = @($fixtureFailures)
|
||||
}
|
||||
}
|
||||
|
||||
$apiFailures = [System.Collections.Generic.List[string]]::new()
|
||||
if (-not [bool]$Fixture.SameVolume) {
|
||||
$apiFailures.Add("source, target, and backup are not on one volume")
|
||||
}
|
||||
if (-not [bool]$Fixture.NtfsVolume) {
|
||||
$apiFailures.Add("attack fixture is not on NTFS")
|
||||
}
|
||||
if ($apiFailures.Count -gt 0) {
|
||||
return [pscustomobject]@{
|
||||
Status = "api_precondition_failure"
|
||||
Failures = @($apiFailures)
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Status = "ready"
|
||||
Failures = @()
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapFixtureReady {
|
||||
param([Parameter(Mandatory = $true)]$Fixture)
|
||||
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $Fixture
|
||||
switch ([string]$classification.Status) {
|
||||
"ready" { return }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture is invalid: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap has an API precondition failure: " +
|
||||
"$(@($classification.Failures) -join '; ')."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap fixture classification failed closed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-MinerOpsNativeSwapSecurityOutcome {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Result,
|
||||
[Parameter(Mandatory = $true)]$Postconditions
|
||||
)
|
||||
|
||||
if ([string]$Result.operation -cne "swap") {
|
||||
throw "Swap result recorded the wrong operation."
|
||||
}
|
||||
$detail = "outer=$([string]$Result.outer_exception_type), " +
|
||||
"outer_hresult=$([string]$Result.outer_hresult), " +
|
||||
"leaf=$([string]$Result.leaf_exception_type), " +
|
||||
"leaf_hresult=$([string]$Result.leaf_hresult), " +
|
||||
"native=$([string]$Result.native_error), " +
|
||||
"chain=$(@($Result.exception_chain_types) -join ' -> ')"
|
||||
switch ([string]$Result.status) {
|
||||
"succeeded" {
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Successful swap result did not record File.Replace invocation."
|
||||
}
|
||||
throw "Standard user completed the protected replacement swap."
|
||||
}
|
||||
"security_denied" { }
|
||||
"security_denied_requires_postconditions" { }
|
||||
"fixture_error" {
|
||||
throw "Standard-user swap fixture was invalid ($detail)."
|
||||
}
|
||||
"api_precondition_failure" {
|
||||
throw "Standard-user swap hit an unrelated API precondition ($detail)."
|
||||
}
|
||||
default {
|
||||
throw "Standard-user swap failed unexpectedly ($detail)."
|
||||
}
|
||||
}
|
||||
if (-not [bool]$Result.replace_invoked) {
|
||||
throw "Standard-user swap did not reach System.IO.File.Replace."
|
||||
}
|
||||
|
||||
$required = [ordered]@{
|
||||
TargetExists = "protected target is missing"
|
||||
TargetIdentityUnchanged = "protected target identity changed"
|
||||
TargetContentUnchanged = "protected target content changed"
|
||||
TargetAclTrusted = "protected target ACL is not trusted"
|
||||
TargetAclUnchanged = "protected target ACL changed"
|
||||
TargetOwnerTrusted = "protected target owner is not trusted"
|
||||
TargetOwnerUnchanged = "protected target owner changed"
|
||||
TargetSingleLink = "protected target is not single-link"
|
||||
SourceExists = "attacker source is missing"
|
||||
SourceIdentityUnchanged = "attacker source identity changed"
|
||||
SourceContentUnchanged = "attacker source content changed"
|
||||
AttackerSourceNotPromoted = "attacker source was promoted to authority"
|
||||
TargetDeniedStandardUserRead = `
|
||||
"protected content became readable by the standard user"
|
||||
TargetDeniedStandardUserModify = `
|
||||
"protected target became modifiable by the standard user"
|
||||
TargetDeniedStandardUserDelete = `
|
||||
"protected target became deletable by the standard user"
|
||||
TargetDeniedStandardUserRename = `
|
||||
"protected target became renameable by the standard user"
|
||||
NoAttackerReadableBackup = `
|
||||
"replacement exposed protected content through a backup"
|
||||
}
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($entry in $required.GetEnumerator()) {
|
||||
if (-not [bool]$Postconditions.($entry.Key)) {
|
||||
$failures.Add([string]$entry.Value)
|
||||
}
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "Swap security postconditions failed ($detail): " +
|
||||
"$($failures -join '; ')."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MinerOpsNativeFileIdentity {
|
||||
param([Parameter(Mandatory = $true)]$Stream)
|
||||
|
||||
if (-not ("MinerOpsNativeHarnessFileIdentity" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
public sealed class MinerOpsNativeHarnessFileIdentityResult
|
||||
{
|
||||
public string Identity { get; set; }
|
||||
public uint VolumeSerialNumber { get; set; }
|
||||
public ulong FileIndex { get; set; }
|
||||
public uint NumberOfLinks { get; set; }
|
||||
}
|
||||
|
||||
public static class MinerOpsNativeHarnessFileIdentity
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file, out ByHandleFileInformation information);
|
||||
|
||||
public static MinerOpsNativeHarnessFileIdentityResult Get(
|
||||
SafeFileHandle file)
|
||||
{
|
||||
ByHandleFileInformation information;
|
||||
if (!GetFileInformationByHandle(file, out information))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
ulong fileIndex = ((ulong)information.FileIndexHigh << 32) |
|
||||
information.FileIndexLow;
|
||||
return new MinerOpsNativeHarnessFileIdentityResult {
|
||||
Identity = String.Format(
|
||||
"{0:X8}:{1:X8}{2:X8}",
|
||||
information.VolumeSerialNumber,
|
||||
information.FileIndexHigh,
|
||||
information.FileIndexLow),
|
||||
VolumeSerialNumber = information.VolumeSerialNumber,
|
||||
FileIndex = fileIndex,
|
||||
NumberOfLinks = information.NumberOfLinks
|
||||
};
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
return [MinerOpsNativeHarnessFileIdentity]::Get($Stream.SafeFileHandle)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
||||
. $scriptPath
|
||||
|
||||
$script:Passed = 0
|
||||
$script:BootEventRecords = @()
|
||||
$script:ToXmlCalls = 0
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory)][bool]$Condition,
|
||||
[Parameter(Mandatory)][string]$Message
|
||||
)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param(
|
||||
[Parameter(Mandatory)][scriptblock]$Action,
|
||||
[string]$Pattern = ""
|
||||
)
|
||||
try { & $Action | Out-Null }
|
||||
catch {
|
||||
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
|
||||
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
|
||||
}
|
||||
return
|
||||
}
|
||||
throw "Expected action to fail."
|
||||
}
|
||||
|
||||
function Invoke-ParserTest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][scriptblock]$Action
|
||||
)
|
||||
& $Action
|
||||
$script:Passed++
|
||||
Write-Host "PASS: $Name"
|
||||
}
|
||||
|
||||
function New-TestKernelBootXml {
|
||||
param(
|
||||
[string]$Namespace =
|
||||
"http://schemas.microsoft.com/win/2004/08/events/event",
|
||||
[string]$BootTypeContent = '<Data Name="BootType">0</Data>',
|
||||
[switch]$OmitSystem,
|
||||
[switch]$OmitEventData,
|
||||
[switch]$DuplicateEventData
|
||||
)
|
||||
|
||||
$system = if ($OmitSystem) { "" } else {
|
||||
@"
|
||||
<System>
|
||||
<Provider Name="Microsoft-Windows-Kernel-Boot" />
|
||||
<EventID>27</EventID>
|
||||
<Version>1</Version>
|
||||
<Level>4</Level>
|
||||
<Task>33</Task>
|
||||
<Opcode>0</Opcode>
|
||||
<EventRecordID>42</EventRecordID>
|
||||
<Channel>System</Channel>
|
||||
</System>
|
||||
"@
|
||||
}
|
||||
$eventData = if ($OmitEventData) { "" } else {
|
||||
" <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
if ($DuplicateEventData) {
|
||||
$eventData += "`n <EventData>$BootTypeContent</EventData>"
|
||||
}
|
||||
return "<Event xmlns=`"$Namespace`">`n$system$eventData`n</Event>"
|
||||
}
|
||||
|
||||
function New-TestEventRecord {
|
||||
param(
|
||||
[AllowNull()]$ProviderName = "Microsoft-Windows-Kernel-Boot",
|
||||
[AllowNull()]$Id = [int]27,
|
||||
[AllowNull()]$RecordId = [long]42,
|
||||
[AllowNull()]$Version = [byte]1,
|
||||
[AllowNull()]$Xml = (New-TestKernelBootXml)
|
||||
)
|
||||
|
||||
$record = [pscustomobject]@{
|
||||
ProviderName = $ProviderName
|
||||
Id = $Id
|
||||
RecordId = $RecordId
|
||||
Version = $Version
|
||||
XmlPayload = $Xml
|
||||
}
|
||||
$record | Add-Member -MemberType ScriptMethod -Name ToXml -Value {
|
||||
$script:ToXmlCalls++
|
||||
return $this.XmlPayload
|
||||
}
|
||||
return $record
|
||||
}
|
||||
|
||||
function Get-WinEvent {
|
||||
param($FilterHashtable, $ErrorAction)
|
||||
return $script:BootEventRecords
|
||||
}
|
||||
|
||||
Invoke-ParserTest "conventional namespaced Event XML and typed metadata parse" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord)
|
||||
Assert-True ($parsed.ProviderName -ceq
|
||||
"Microsoft-Windows-Kernel-Boot") "Typed provider was not retained."
|
||||
Assert-True ($parsed.EventId -is [int] -and $parsed.EventId -eq 27) `
|
||||
"Typed event ID was not retained."
|
||||
Assert-True ($parsed.RecordId -is [long] -and $parsed.RecordId -eq 42) `
|
||||
"Typed record ID was not retained."
|
||||
Assert-True ($parsed.Version -is [byte] -and $parsed.Version -eq 1) `
|
||||
"Typed event version was not retained."
|
||||
Assert-True ($parsed.BootType -is [UInt32] -and $parsed.BootType -eq 0) `
|
||||
"BootType payload was not parsed."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "supported event version zero parses" {
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]0))
|
||||
Assert-True ($parsed.Version -eq 0) "Version zero was not accepted."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "production Get-WinEvent path invokes ToXml parser" {
|
||||
$script:ToXmlCalls = 0
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
)
|
||||
$identifier = Get-MinerOpsBootIdentifier
|
||||
Assert-True ($identifier -ceq
|
||||
"windows-full-boot-event-v1:000000000000002b") `
|
||||
"Production event path returned the wrong identifier."
|
||||
Assert-True ($script:ToXmlCalls -eq 1) `
|
||||
"Production event path did not invoke ToXml exactly once."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "typed metadata is authoritative over XML scalar metadata" {
|
||||
$xml = (New-TestKernelBootXml).Replace(
|
||||
"<EventID>27</EventID>", "<EventID>999</EventID>"
|
||||
).Replace(
|
||||
"<EventRecordID>42</EventRecordID>",
|
||||
"<EventRecordID>999</EventRecordID>"
|
||||
)
|
||||
$parsed = ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml $xml)
|
||||
Assert-True ($parsed.EventId -eq 27 -and $parsed.RecordId -eq 42) `
|
||||
"XML scalar metadata overrode typed EventRecord properties."
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null typed RecordId fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed provider fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -ProviderName "Unexpected-Provider")
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong typed event ID fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Id ([int]28))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "wrong RecordId type fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -RecordId ([UInt64]42))
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "null event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version $null)
|
||||
} "metadata"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unsupported event version fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Version ([byte]2))
|
||||
} "unsupported"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "unexpected Event XML namespace fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-Namespace "urn:unexpected"))
|
||||
} "root"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed Event XML fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord `
|
||||
-EventRecord (New-TestEventRecord -Xml "<Event>")
|
||||
} "malformed"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing System element fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitSystem))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml -OmitEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicated EventData fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml `
|
||||
(New-TestKernelBootXml -DuplicateEventData))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "missing BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="LoadOptions"></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate BootType fails closed" {
|
||||
$payload = '<Data Name="BootType">0</Data>' +
|
||||
'<Data Name="BootType">0</Data>'
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent $payload))
|
||||
} "ambiguous"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonnumeric BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">full</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "noncanonical BootType fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">00</Data>'))
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "structured BootType content fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType"><Value>0</Value></Data>'))
|
||||
} "schema"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "BootType outside UInt32 fails closed" {
|
||||
Assert-Throws {
|
||||
ConvertFrom-MinerOpsKernelBootEventRecord -EventRecord `
|
||||
(New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent `
|
||||
'<Data Name="BootType">4294967296</Data>'))
|
||||
} "invalid"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "nonzero BootType is not a full-boot anchor" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -Xml (New-TestKernelBootXml `
|
||||
-BootTypeContent '<Data Name="BootType">1</Data>')
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "empty event query fails closed" {
|
||||
$script:BootEventRecords = @()
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "duplicate qualifying record IDs fail closed" {
|
||||
$script:BootEventRecords = @(
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
New-TestEventRecord -RecordId ([long]43)
|
||||
New-TestEventRecord -RecordId ([long]42)
|
||||
)
|
||||
Assert-Throws { Get-MinerOpsBootIdentifier } "could not be verified"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "equal stored and current anchors deny reset" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a"
|
||||
} "without a verified full Restart"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "strictly greater current anchor passes ordering" {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "smaller current anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029"
|
||||
} "older than the"
|
||||
}
|
||||
|
||||
Invoke-ParserTest "malformed stored anchor fails closed" {
|
||||
Assert-Throws {
|
||||
Assert-MinerOpsBootEpochChanged `
|
||||
-StoredBootIdentifier "windows-full-boot-event-v1:not-a-record" `
|
||||
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
||||
} "canonical"
|
||||
}
|
||||
|
||||
Write-Host "$script:Passed boot-event parser tests passed."
|
||||
@@ -0,0 +1,115 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:assertions = 0
|
||||
$script:physicalCalls = 0
|
||||
$script:readCalls = 0
|
||||
$script:apiCalls = @()
|
||||
$script:commands = @()
|
||||
|
||||
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||
$script:assertions++
|
||||
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||
}
|
||||
function Assert-Throws($Block, [string]$Message) {
|
||||
$caught = $false
|
||||
try { & $Block | Out-Null } catch {
|
||||
$caught = $true
|
||||
Assert-Equal $_.Exception.Message "physical_control_disabled_stage_a" $Message
|
||||
}
|
||||
Assert-Equal $caught $true "$Message must reject"
|
||||
}
|
||||
|
||||
# Define functions from the real service AST, without executing its service loop,
|
||||
# config loading, startup, or any scheduled tasks.
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$tokens = $null; $parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Join-Path $root "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$parseErrors)
|
||||
Assert-Equal $parseErrors.Count 0 "Service parser"
|
||||
foreach ($function in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
Invoke-Expression $function.Extent.Text
|
||||
}
|
||||
|
||||
$script:productionApi3Payload = (Get-Command Invoke-MinerOpsApi3Payload).ScriptBlock
|
||||
$script:productionPlainCommand = (Get-Command Invoke-MinerPlainCommand).ScriptBlock
|
||||
function Invoke-MinerOpsApi3Payload { $script:physicalCalls++; throw "Unexpected API3 transport" }
|
||||
function Get-MinerOpsApi3Salt { $script:physicalCalls++; throw "Unexpected salt probe" }
|
||||
function Invoke-MinerPlainCommand { $script:physicalCalls++; throw "Unexpected legacy transport" }
|
||||
function Read-AgentConfig { return [pscustomobject]@{
|
||||
agent_id = "simulated-agent"; site_id = ""; site_name = "Simulation"
|
||||
api3_account = "operator"; api3_password = "mock-only"; api3_port = 4433
|
||||
} }
|
||||
function Write-AgentLog { param([string]$Message) }
|
||||
function Invoke-AgentApi {
|
||||
param([string]$Method, [string]$Path, [object]$Body)
|
||||
$script:apiCalls += @{ method = $Method; path = $Path; body = $Body }
|
||||
return @{ ok = $true }
|
||||
}
|
||||
function Get-PendingAgentCommands { return $script:commands }
|
||||
function Run-KnownMinerPoll { $script:readCalls++; return @{ simulated = $true } }
|
||||
function Run-NewMinerDiscovery { $script:readCalls++; return @{ simulated = $true } }
|
||||
|
||||
# The pre-module legacy implementation must also be inert, including helpers
|
||||
# which used to support direct generic API3 or M30/4028 writes.
|
||||
Assert-Throws { Run-MinerControlCommand -Payload @{ action = "reboot"; dry_run = $false } } "Legacy executor"
|
||||
Assert-Throws { Invoke-MinerOpsApi3Service -IpAddress "192.0.2.1" -ServiceAction "stop" } "Legacy service helper"
|
||||
Assert-Throws { & $script:productionApi3Payload -IpAddress "192.0.2.1" -Payload @{ cmd = "set.miner.service"; param = "stop" } } "Generic API3 payload helper"
|
||||
Assert-Throws { & $script:productionPlainCommand -IpAddress "192.0.2.1" -Command "reboot" } "Generic legacy command helper"
|
||||
Assert-Throws { Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Legacy API3"
|
||||
Assert-Throws { Invoke-MinerOpsLegacy4028Command -IpAddress "192.0.2.1" -CommandJson '{"command":"reboot"}' } "Legacy 4028"
|
||||
Assert-Throws { Invoke-MinerOpsM30Legacy4028Test -Payload @{ legacy_4028_command = "reboot" } } "M30 helper"
|
||||
|
||||
. (Join-Path $root "MinerOpsControl.ps1")
|
||||
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "set.miner.service" -Param "stop" } "Typed transport"
|
||||
Assert-Throws { Invoke-MinerOpsControlApi3Command -IpAddress "192.0.2.1" -Command "get.read_then_write" } "Disguised diagnostic"
|
||||
|
||||
$payloads = @(
|
||||
@{ action = "legacy_4028"; legacy_4028_command = "reboot"; dry_run = $false },
|
||||
@{ action = "reboot"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 1; action = "suspend"; ip_address = "192.0.2.1"; miner_id = "spoofed" },
|
||||
@{ schema_version = 1; action = "resume"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 1; action = "performance_mode"; requested_value = "high"; ip_address = "192.0.2.1" },
|
||||
@{ schema_version = 999; action = "suspend" },
|
||||
@{ schema_version = "1"; action = "performance_mode"; performance_mode = "high" },
|
||||
@{ command = "summary"; params = @{ cmd = "set.miner.service"; param = "stop" } },
|
||||
@{ action = "read_reboot"; dry_run = $true },
|
||||
@{},
|
||||
'{"schema_version":0,"action":"reboot"}',
|
||||
"{invalid-json"
|
||||
)
|
||||
foreach ($payload in $payloads) {
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.command_accepted $false "Rejected payload acceptance"
|
||||
Assert-Equal $result.failure_code "physical_control_disabled_stage_a" "Rejected payload reason"
|
||||
Assert-Equal $result._queue_status "failed" "Rejected payload terminal result"
|
||||
Assert-Equal $result.verification_attempted $false "No physical verification/probe"
|
||||
Assert-Equal $result.observed_service_state $null "No invented device state"
|
||||
Assert-Equal $script:physicalCalls 0 "No physical calls"
|
||||
$script:commands = @(@{ command_id = "legacy-delivery"; command_type = "miner_control"; request_payload = $payload })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Delivered legacy command handled"
|
||||
Assert-Equal $script:apiCalls.Count 1 "One failed result submitted"
|
||||
Assert-Equal $script:apiCalls[0].body.status "failed" "Legacy delivery failed"
|
||||
Assert-Equal $script:apiCalls[0].body.result_payload.command_accepted $false "Legacy delivery not accepted"
|
||||
Assert-Equal $script:physicalCalls 0 "No writes after delivery"
|
||||
}
|
||||
foreach ($kind in @("raw_api3", "miner_control_v2", "read_reboot", "")) {
|
||||
$script:commands = @(@{ command_id = "unsupported"; command_type = $kind; request_payload = @{ action = "reboot" } })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Unknown command handled"
|
||||
Assert-Equal $script:apiCalls[0].body.status "failed" "Unknown command fails closed"
|
||||
}
|
||||
Assert-Equal $script:readCalls 0 "No physical request reinterpreted as telemetry"
|
||||
$script:commands = @(@{ command_id = "read-only"; command_type = "poll_known_miners_now"; request_payload = @{} })
|
||||
$script:apiCalls = @()
|
||||
Assert-Equal (Process-PendingCommands) 1 "Fixed read-only command available"
|
||||
Assert-Equal $script:readCalls 1 "Only simulated read performed"
|
||||
Assert-Equal $script:physicalCalls 0 "No physical calls in entire suite"
|
||||
$script:apiCalls = @()
|
||||
Send-AgentCheckin | Out-Null
|
||||
Assert-Equal $script:apiCalls[0].body.control_available $false "Cannot advertise physical capability"
|
||||
Assert-Equal $script:apiCalls[0].body.control_unavailable_reason "physical_control_disabled_stage_a" "Containment advertised"
|
||||
Write-Host "Stage A Windows containment passed: $($script:assertions) assertions; zero physical transport calls."
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:assertions = 0
|
||||
$script:connections = 0
|
||||
$TimeoutMs = 1000
|
||||
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
$script:assertions++
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
function Assert-Rejected([scriptblock]$Call) {
|
||||
$before = $script:connections
|
||||
$caught = $false
|
||||
try { & $Call | Out-Null } catch {
|
||||
$caught = $true
|
||||
Assert-True ($_.Exception.Message -eq "unsupported_diagnostic_operation_stage_a") "Must reject before transport"
|
||||
}
|
||||
Assert-True $caught "Unsupported diagnostics must throw"
|
||||
Assert-True ($script:connections -eq $before) "Rejected request must create zero sockets and write zero bytes"
|
||||
}
|
||||
function Import-Definitions([string]$Path) {
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
||||
Assert-True ($errors.Count -eq 0) "Production script must parse"
|
||||
foreach ($definition in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
. ([scriptblock]::Create("function global:" + $definition.Name + " " + $definition.Body.Extent.Text))
|
||||
}
|
||||
}
|
||||
|
||||
# Load only real function definitions: no config, Hub request, or scheduled loop.
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
Import-Definitions (Join-Path $root "EnrichOneMiner.ps1")
|
||||
Import-Definitions (Join-Path $root "MinerOpsApi3EnrichmentPoller.ps1")
|
||||
Import-Definitions (Join-Path $root "MinerOpsPoolPoller.ps1")
|
||||
|
||||
function New-Object {
|
||||
param([Parameter(Position=0)][string]$TypeName,
|
||||
[Parameter(Position=1)][object[]]$ArgumentList)
|
||||
if ($TypeName -eq "System.Net.Sockets.TcpClient") { $script:connections++ }
|
||||
if ($null -ne $ArgumentList) {
|
||||
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName -ArgumentList $ArgumentList
|
||||
}
|
||||
return Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName
|
||||
}
|
||||
|
||||
# Auditor reproductions plus write families, alias/parameter overrides, injection,
|
||||
# malformed structures and operations that merely contain a read-looking substring.
|
||||
foreach ($command in @("reboot", "set.miner.service", "power_off", "switchpool", "ascset",
|
||||
"summary|reboot", "summary`nreboot", 'summary","cmd":"reboot', "read_then_reboot",
|
||||
"SUMMARY", "", "get.device.info", "get_miner_info|all")) {
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command }
|
||||
}
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command "summary" -Port 4433 }
|
||||
$badRequests = @(
|
||||
@{ cmd = "set.miner.service"; param = "stop" },
|
||||
@{ cmd = "reboot" }, @{ cmd = "set.miner.power_mode"; param = "high" },
|
||||
@{ cmd = "set.device.password"; param = "changed" }, @{ cmd = "get.read_then_write" },
|
||||
@{ cmd = "get.device.info"; command = "reboot" },
|
||||
@{ cmd = "get.device.info"; param = "stop" },
|
||||
@{ cmd = "get.device.info"; account = "operator"; token = "anything" },
|
||||
@{ cmd = "get.miner.status" }, @{ cmd = "get.miner.status"; param = "all" },
|
||||
@{ cmd = "get.miner.status"; param = @("summary+pools+edevs") },
|
||||
@{ cmd = "get.miner.status"; param = "summary+pools+edevs"; command = "reboot" },
|
||||
@{ Cmd = "get.device.info" }, @{ cmd = "GET.DEVICE.INFO" },
|
||||
@{ cmd = "get.device.info`nreboot" }, @{ cmd = @("get.device.info", "reboot") },
|
||||
@{ cmd = $null }, @{}
|
||||
)
|
||||
foreach ($request in $badRequests) {
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request }
|
||||
}
|
||||
# Exact re-audit suffixes: U+0000, U+00AD and U+200B; also prefix/embedded
|
||||
# invisible characters, controls and whitespace. Never trim or normalize inputs.
|
||||
$originalCulture = [Threading.Thread]::CurrentThread.CurrentCulture
|
||||
try {
|
||||
foreach ($cultureName in @("en-US", "tr-TR", "")) {
|
||||
[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo($cultureName)
|
||||
$variants = @()
|
||||
foreach ($code in @(0, 0x00ad, 0x200b, 0x200c, 0x200d, 0x2060, 0xfeff,
|
||||
0x001f, 13, 10, 9, 32, 0x00a0)) {
|
||||
$character = [string][char]$code
|
||||
foreach ($position in @("prefix", "embedded", "suffix")) {
|
||||
$variants += @{ Character = $character; Position = $position }
|
||||
}
|
||||
}
|
||||
foreach ($variant in $variants) {
|
||||
$legacy = "summary"; $api = "get.device.info"; $key = "cmd"; $param = "summary+pools+edevs"
|
||||
if ($variant.Position -eq "prefix") {
|
||||
$legacy = $variant.Character + $legacy; $api = $variant.Character + $api
|
||||
$key = $variant.Character + $key; $param = $variant.Character + $param
|
||||
} elseif ($variant.Position -eq "embedded") {
|
||||
$legacy = "sum" + $variant.Character + "mary"; $api = "get.device." + $variant.Character + "info"
|
||||
$key = "c" + $variant.Character + "md"; $param = "summary+" + $variant.Character + "pools+edevs"
|
||||
} else {
|
||||
$legacy += $variant.Character; $api += $variant.Character
|
||||
$key += $variant.Character; $param += $variant.Character
|
||||
}
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = $api } }
|
||||
Assert-Rejected { Invoke-Api3OkCommand -Ip "127.0.0.1" -Request @{ cmd = $api } }
|
||||
$request = @{}; $request[$key] = "get.device.info"
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{ cmd = "get.miner.status"; param = $param } }
|
||||
$request = @{ cmd = "get.miner.status" }; $request[$key.Replace("cmd", "param")] = "summary+pools+edevs"
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request $request }
|
||||
}
|
||||
foreach ($legacy in @(("summar" + [char]0x0443), ([char]0xff53 + "ummary"),
|
||||
("summ" + [char]0x0061 + [char]0x0301 + "ry"),
|
||||
("summ" + [char]0x00e1 + "ry"), "Summary", "summary/", "summary+")) {
|
||||
Assert-Rejected { Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $legacy }
|
||||
}
|
||||
foreach ($api in @(("get.dev" + [char]0x0456 + "ce.info"), ("get" + [char]0xff0e + "device.info"),
|
||||
([char]0xff47 + "et.device.info"), "Get.Device.Info", "get.device.info/", "get.device.info+")) {
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd=$api} }
|
||||
}
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; Param="summary+pools+edevs"} }
|
||||
Assert-Rejected { Invoke-WhatsMinerApi3Read -Ip "127.0.0.1" -Request @{cmd="get.miner.status"; param="Summary+pools+edevs"} }
|
||||
}
|
||||
} finally { [Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture }
|
||||
Assert-True ($script:connections -eq 0) "All negative cases produced zero transport connections/writes"
|
||||
|
||||
# Real TCP compatibility tests: bind loopback only, one request per simulator.
|
||||
# This suite must be run with physical networks disconnected (Docker --network none).
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
public class MinerOpsDiagnosticSimulator : IDisposable {
|
||||
TcpListener listener;
|
||||
Thread thread;
|
||||
public string Payload;
|
||||
public string Error;
|
||||
public MinerOpsDiagnosticSimulator(int port, bool framed) {
|
||||
listener = new TcpListener(IPAddress.Loopback, port); listener.Start();
|
||||
thread = new Thread(() => {
|
||||
try {
|
||||
using (var client = listener.AcceptTcpClient()) {
|
||||
client.ReceiveTimeout = 2000;
|
||||
var stream = client.GetStream(); var buffer = new byte[65536]; int count;
|
||||
if (framed) {
|
||||
var length = new byte[4]; ReadExact(stream, length, 4);
|
||||
count = BitConverter.ToInt32(length, 0);
|
||||
if(count < 1 || count > buffer.Length) throw new Exception("Bad request length");
|
||||
ReadExact(stream, buffer, count);
|
||||
} else { count = stream.Read(buffer, 0, buffer.Length); }
|
||||
Payload = Encoding.ASCII.GetString(buffer, 0, count);
|
||||
var response = Encoding.ASCII.GetBytes("{\"code\":0,\"POOLS\":[],\"msg\":{}}");
|
||||
if(framed) { var length = BitConverter.GetBytes(response.Length); stream.Write(length,0,4); }
|
||||
stream.Write(response, 0, response.Length);
|
||||
}
|
||||
} catch(Exception e) { Error = e.Message; }
|
||||
finally { listener.Stop(); }
|
||||
}); thread.IsBackground = true; thread.Start();
|
||||
}
|
||||
static void ReadExact(NetworkStream stream, byte[] bytes, int count) {
|
||||
int offset=0;
|
||||
while(offset<count) { int n=stream.Read(bytes,offset,count-offset); if(n==0) throw new Exception("EOF"); offset+=n; }
|
||||
}
|
||||
public void Dispose() { listener.Stop(); thread.Join(3000); }
|
||||
}
|
||||
'@
|
||||
|
||||
foreach ($command in @("summary", "devs", "edevs", "stats", "pools", "devdetails", "version", "get_version", "get_miner_info")) {
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
|
||||
try {
|
||||
$null = Invoke-MinerCommandRaw -IpAddress "127.0.0.1" -Command $command -TimeoutMs 100
|
||||
$observed = $miner.Payload | ConvertFrom-Json
|
||||
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Legacy query must remain compatible"
|
||||
Assert-True ($observed.PSObject.Properties.Count -eq 1) "Legacy request contains only selected read"
|
||||
Assert-True (-not $miner.Error) "Simulator received a valid legacy request"
|
||||
} finally { $miner.Dispose() }
|
||||
}
|
||||
foreach ($command in @("get.device.info", "get.miner.setting", "get.miner.status", "get.fan.setting", "get.device.custom_data")) {
|
||||
$request = @{ cmd = $command }
|
||||
if ($command -ceq "get.miner.status") { $request.param = "summary+pools+edevs" }
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4433, $true)
|
||||
try {
|
||||
$result = Invoke-Api3OkCommand -Ip "127.0.0.1" -Request $request
|
||||
$observed = $miner.Payload | ConvertFrom-Json
|
||||
Assert-True ($null -ne $result) "Existing API3 reader wrapper remains compatible"
|
||||
Assert-True ([string]::Equals($observed.cmd, $command, [StringComparison]::Ordinal)) "Exact API3 query transmitted"
|
||||
Assert-True (@($observed.PSObject.Properties).Count -eq $request.Count) "No additional API3 request keys"
|
||||
if ($command -ceq "get.miner.status") {
|
||||
Assert-True ([string]::Equals($observed.param, "summary+pools+edevs", [StringComparison]::Ordinal)) "Only exact read parameter transmitted"
|
||||
}
|
||||
Assert-True (-not $miner.Error) "Simulator received a valid framed read"
|
||||
} finally { $miner.Dispose() }
|
||||
}
|
||||
$miner = [MinerOpsDiagnosticSimulator]::new(4028, $false)
|
||||
try {
|
||||
$null = Invoke-MinerOps4028PoolsSimple -Ip "127.0.0.1"
|
||||
Assert-True (($miner.Payload | ConvertFrom-Json).command -ceq "pools") "Pool poller sends a constant read"
|
||||
} finally { $miner.Dispose() }
|
||||
Write-Host "Diagnostic containment passed: $($script:assertions) assertions; all rejected operations made zero connections/writes."
|
||||
@@ -0,0 +1,265 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$bootstrap,
|
||||
[ref] $tokens,
|
||||
[ref] $parseErrors
|
||||
) | Out-Null
|
||||
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||
}
|
||||
|
||||
$source = Get-Content -LiteralPath $bootstrap -Raw
|
||||
|
||||
function Assert-SourceContains {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Pattern,
|
||||
[Parameter(Mandatory)][string] $Message
|
||||
)
|
||||
|
||||
if ($source -notmatch $Pattern) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'enrollment\.json' `
|
||||
"Bootstrap enrollment file is not explicit."
|
||||
|
||||
Assert-SourceContains 'workflow\s*-ne\s*"new_agent"' `
|
||||
"New-Agent bootstrap does not reject transition packages."
|
||||
|
||||
Assert-SourceContains '/api/agent/enroll' `
|
||||
"Bootstrap does not use the secure enrollment endpoint."
|
||||
|
||||
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
|
||||
"API3 password is not prompted as a SecureString."
|
||||
|
||||
Assert-SourceContains 'agent-config\.json\.pending' `
|
||||
"Atomic pending config path is missing."
|
||||
|
||||
Assert-SourceContains 'agent-config\.recovery\.json' `
|
||||
"Post-redemption credential recovery path is missing."
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsEnrollmentCredentialFileCreateNew' `
|
||||
"Handle-based credential creation is missing."
|
||||
|
||||
Assert-SourceContains 'FileMode\]::CreateNew' `
|
||||
"Credential files are not created with fail-if-present semantics."
|
||||
|
||||
Assert-SourceContains 'FileShare\]::None' `
|
||||
"Credential files are not held exclusively while being created."
|
||||
|
||||
Assert-SourceContains 'Flush\(\$true\)' `
|
||||
"Credential writes are not durably flushed through the trusted handle."
|
||||
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Enrollment does not reject hard-linked credential targets."
|
||||
|
||||
Assert-SourceContains 'S-1-5-18' `
|
||||
"SYSTEM ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'S-1-5-32-544' `
|
||||
"Administrators ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'SetOwner\(\$administrators\)' `
|
||||
"Stable Administrators ownership is missing from credential ACLs."
|
||||
|
||||
Assert-SourceContains '\[IO\.File\]::Move\(\$PendingConfigPath, \$ConfigPath\)' `
|
||||
"Atomic final config move is missing."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentDirectory' `
|
||||
"Enrollment does not revalidate the directory trust boundary."
|
||||
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Enrollment directory repair lacks an external durable trust blocker."
|
||||
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Enrollment lacks an explicit post-reboot trust-reset gate."
|
||||
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Enrollment blockers do not use canonical full-kernel-boot records."
|
||||
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Enrollment must not use a wall-clock boot timestamp."
|
||||
}
|
||||
|
||||
if ($source -match 'Remove-MinerOpsEnrollmentTrustBlocker') {
|
||||
throw "Enrollment must not have independent trust-blocker deletion authority."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Enrollment cannot clear an existing filesystem trust blocker' `
|
||||
"Enrollment does not fail closed on retained filesystem trust blockers."
|
||||
|
||||
Assert-SourceContains 'Assert-MinerOpsEnrollmentTrustedAncestors' `
|
||||
"Enrollment does not validate ancestor namespace protection."
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsEnrollmentNoReparsePath",
|
||||
"Assert-MinerOpsEnrollmentTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsEnrollmentTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsEnrollmentDirectoryLock' `
|
||||
"Enrollment does not lock an insecure target during repair."
|
||||
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Enrollment does not securely stage a new directory."
|
||||
|
||||
Assert-SourceContains '& \$Installer' `
|
||||
"Bootstrap does not hand off to the safe installer."
|
||||
|
||||
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
||||
"Fresh Agent startup is missing."
|
||||
|
||||
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||
"Consumed extracted enrollment token is not removed."
|
||||
|
||||
if ($source -match '\[IO\.File\]::WriteAllText\(\s*\$(Pending|Recovery)ConfigPath') {
|
||||
throw "Enrollment must not truncate or adopt a credential pathname."
|
||||
}
|
||||
|
||||
$createFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Open-MinerOpsEnrollmentCredentialFileCreateNew \{.*?\n\}'
|
||||
).Value
|
||||
$createIndex = $createFunction.IndexOf(
|
||||
'New-MinerOpsEnrollmentProtectedFileCreateNewStream'
|
||||
)
|
||||
$verifyIndex = $createFunction.IndexOf('Test-MinerOpsEnrollmentCredentialAcl')
|
||||
$linkIndex = $createFunction.IndexOf('Assert-MinerOpsEnrollmentSingleLinkStream')
|
||||
if (
|
||||
$createIndex -lt 0 -or $verifyIndex -le $createIndex -or
|
||||
$linkIndex -le $verifyIndex -or
|
||||
$createFunction -match 'Set-MinerOpsEnrollmentStreamAcl'
|
||||
) {
|
||||
throw "Enrollment credential creation must apply FileSecurity at CreateNew, verify it, then reject links before content."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsEnrollmentProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Enrollment protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Enrollment protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsEnrollmentExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($repairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Enrollment existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($repairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Enrollment existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$writeFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Write-MinerOpsEnrollmentCredentialJson \{.*?\n\}'
|
||||
).Value
|
||||
if (
|
||||
$writeFunction -notmatch 'Assert-MinerOpsEnrollmentSingleLinkStream' -or
|
||||
$writeFunction -notmatch '\$Stream\.Write' -or
|
||||
$writeFunction -notmatch '\$Stream\.Flush\(\$true\)'
|
||||
) {
|
||||
throw "Enrollment credential content is not written and flushed through the verified handle."
|
||||
}
|
||||
|
||||
$existingConfigGuard = $source.IndexOf(
|
||||
'Test-Path -LiteralPath $ConfigPath'
|
||||
)
|
||||
|
||||
$redeemCall = $source.IndexOf(
|
||||
'"$HubUrl/api/agent/enroll"'
|
||||
)
|
||||
|
||||
if (
|
||||
$existingConfigGuard -lt 0 -or
|
||||
$redeemCall -lt 0 -or
|
||||
$existingConfigGuard -gt $redeemCall
|
||||
) {
|
||||
throw "Existing Agent identity must be rejected before token redemption."
|
||||
}
|
||||
|
||||
$configMove = $source.IndexOf(
|
||||
'[IO.File]::Move($PendingConfigPath, $ConfigPath)'
|
||||
)
|
||||
|
||||
$installerCall = $source.IndexOf(
|
||||
'& $Installer'
|
||||
)
|
||||
|
||||
if (
|
||||
$configMove -lt 0 -or
|
||||
$installerCall -lt 0 -or
|
||||
$configMove -gt $installerCall
|
||||
) {
|
||||
throw "Permanent Agent credentials must be persisted before installation."
|
||||
}
|
||||
|
||||
foreach ($sensitiveVariable in @(
|
||||
'EnrollmentToken',
|
||||
'Api3PasswordPlain'
|
||||
)) {
|
||||
if (
|
||||
$source -match (
|
||||
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
|
||||
$sensitiveVariable
|
||||
)
|
||||
) {
|
||||
throw "$sensitiveVariable must not be written to output."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
|
||||
@@ -0,0 +1,286 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installer, [ref]$tokens, [ref]$parseErrors
|
||||
) | Out-Null
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||
}
|
||||
|
||||
$source = Get-Content -LiteralPath $installer -Raw
|
||||
function Assert-SourceContains {
|
||||
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
||||
if ($source -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
$runtimeMatch = [regex]::Match(
|
||||
$source,
|
||||
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
||||
)
|
||||
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
||||
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
||||
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
||||
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
||||
throw "$forbidden must not be a managed deployment target."
|
||||
}
|
||||
}
|
||||
|
||||
$preflight = $source.IndexOf('# Validate the complete source')
|
||||
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
||||
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||
throw "Source preflight must happen before target changes."
|
||||
}
|
||||
|
||||
Assert-SourceContains '\$existingTask\s*=\s*Get-MinerOpsScheduledTask' `
|
||||
"Existing task preservation guard is missing."
|
||||
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||
"Existing task preservation is not explicit."
|
||||
Assert-SourceContains 'Test-MinerOpsInstallerShouldCreateMissingTask' `
|
||||
"Missing-task trust-reset policy helper is absent."
|
||||
Assert-SourceContains 'Deferring missing per-user Scheduled Task' `
|
||||
"Missing per-user task deferral is not explicit."
|
||||
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||
"Fresh enrichment task arguments are missing."
|
||||
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||
"Bounded main Agent stop behavior is missing."
|
||||
Assert-SourceContains '\$mainTaskWasRunning' `
|
||||
"Main Agent prior running state is not represented."
|
||||
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
||||
"Rollback does not restore managed files from backup."
|
||||
Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||
"Rollback does not remove tasks created by a failed install."
|
||||
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||
"Rollback restart failure is not surfaced."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedAncestors' `
|
||||
"Installer does not validate ancestor namespace protection."
|
||||
|
||||
$taskDefinitionsStart = $source.IndexOf('$definitions = @(')
|
||||
$taskLoopStart = $source.IndexOf(
|
||||
'foreach ($definition in $definitions)', $taskDefinitionsStart
|
||||
)
|
||||
if ($taskDefinitionsStart -lt 0 -or $taskLoopStart -le $taskDefinitionsStart) {
|
||||
throw "Scheduled Task definitions or reconciliation loop were not found."
|
||||
}
|
||||
$taskDefinitions = $source.Substring(
|
||||
$taskDefinitionsStart, $taskLoopStart - $taskDefinitionsStart
|
||||
)
|
||||
foreach ($policy in @(
|
||||
@{ Name = "MinerOpsAgent"; Expected = '\$true' },
|
||||
@{ Name = "MinerOpsAgentTray"; Expected = '\$false' },
|
||||
@{ Name = "MinerOpsApi3Enrichment"; Expected = '\$true' },
|
||||
@{ Name = "MinerOpsPoolPoller"; Expected = '\$true' }
|
||||
)) {
|
||||
$definition = [regex]::Match(
|
||||
$taskDefinitions,
|
||||
('(?s)@\{{.*?Name\s*=\s*"{0}".*?\}}' -f
|
||||
[regex]::Escape($policy.Name))
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($definition) -or
|
||||
$definition -notmatch (
|
||||
'CreateWhenMissingDuringTrustReset\s*=\s*{0}' -f
|
||||
$policy.Expected
|
||||
)) {
|
||||
throw "Scheduled Task $($policy.Name) has the wrong trust-reset creation policy."
|
||||
}
|
||||
}
|
||||
|
||||
$taskLoopEnd = $source.IndexOf(
|
||||
'if ($mainTaskWasRunning', $taskLoopStart
|
||||
)
|
||||
if ($taskLoopEnd -le $taskLoopStart) {
|
||||
throw "Scheduled Task reconciliation loop end was not found."
|
||||
}
|
||||
$taskLoop = $source.Substring($taskLoopStart, $taskLoopEnd - $taskLoopStart)
|
||||
$preserveIndex = $taskLoop.IndexOf('$null -ne $existingTask')
|
||||
$deferIndex = $taskLoop.IndexOf(
|
||||
'Test-MinerOpsInstallerShouldCreateMissingTask'
|
||||
)
|
||||
$actionIndex = $taskLoop.IndexOf('New-ScheduledTaskAction')
|
||||
if (
|
||||
$preserveIndex -lt 0 -or
|
||||
$deferIndex -le $preserveIndex -or
|
||||
$actionIndex -le $deferIndex
|
||||
) {
|
||||
throw "Existing-task preservation and missing-task deferral are ordered unsafely."
|
||||
}
|
||||
if ($taskLoop -match '80070534|No mapping between account names') {
|
||||
throw "Missing per-user task handling must be policy-based, not HRESULT-based."
|
||||
}
|
||||
|
||||
$taskPolicyFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Test-MinerOpsInstallerShouldCreateMissingTask \{.*?\n\}'
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($taskPolicyFunction)) {
|
||||
throw "Missing-task trust-reset policy function source was not found."
|
||||
}
|
||||
Invoke-Expression $taskPolicyFunction
|
||||
$trayPolicy = [pscustomobject]@{
|
||||
CreateWhenMissingDuringTrustReset = $false
|
||||
}
|
||||
$systemTaskPolicy = [pscustomobject]@{
|
||||
CreateWhenMissingDuringTrustReset = $true
|
||||
}
|
||||
if (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $trayPolicy -TrustResetMode $true) {
|
||||
throw "Trust reset would create a missing per-user tray task."
|
||||
}
|
||||
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $trayPolicy -TrustResetMode $false)) {
|
||||
throw "Normal interactive installation would not repair a missing tray task."
|
||||
}
|
||||
if (-not (Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition $systemTaskPolicy -TrustResetMode $true)) {
|
||||
throw "Trust reset would skip a missing explicit-SYSTEM task."
|
||||
}
|
||||
$missingPolicyRejected = $false
|
||||
try {
|
||||
[void](Test-MinerOpsInstallerShouldCreateMissingTask `
|
||||
-Definition ([pscustomobject]@{}) -TrustResetMode $true)
|
||||
}
|
||||
catch {
|
||||
$missingPolicyRejected = $_.Exception.Message -match
|
||||
'trust-reset creation policy is missing'
|
||||
}
|
||||
if (-not $missingPolicyRejected) {
|
||||
throw "Trust reset accepted a task definition without explicit policy."
|
||||
}
|
||||
|
||||
foreach ($functionName in @(
|
||||
"Assert-MinerOpsInstallerNoReparsePath",
|
||||
"Assert-MinerOpsInstallerTrustedAncestors"
|
||||
)) {
|
||||
$functionSource = [regex]::Match(
|
||||
$source,
|
||||
"(?s)function $functionName \{.*?\n\}"
|
||||
).Value
|
||||
if ([string]::IsNullOrWhiteSpace($functionSource)) {
|
||||
throw "$functionName source was not found."
|
||||
}
|
||||
if ($functionSource -notmatch '\$current\s*=\s*\$root') {
|
||||
throw "$functionName does not preserve the filesystem root."
|
||||
}
|
||||
if ($functionSource -notmatch '\$fullPath\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued full path from trimming."
|
||||
}
|
||||
if ($functionSource -match '\$root\.TrimEnd\(') {
|
||||
throw "$functionName converts a root-qualified path to drive-relative form."
|
||||
}
|
||||
if ($functionSource -match 'GetFullPath\(\$Path\)\.TrimEnd\(') {
|
||||
throw "$functionName trims the filesystem root before identifying it."
|
||||
}
|
||||
if ($functionName -eq "Assert-MinerOpsInstallerTrustedAncestors" -and
|
||||
$functionSource -notmatch '\$parent\.Equals\(\s*\$root') {
|
||||
throw "$functionName does not protect a root-valued parent from trimming."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'Open-MinerOpsInstallerDirectoryLock' `
|
||||
"Installer does not lock an insecure target against replacement."
|
||||
Assert-SourceContains 'minerops-filesystem-trust\.blocked-' `
|
||||
"Installer directory repair lacks an external durable provenance blocker."
|
||||
Assert-SourceContains 'ApproveFilesystemTrustResetAfterReboot' `
|
||||
"Installer lacks an explicit post-reboot trust-reset gate."
|
||||
Assert-SourceContains 'DirectoryInfo\(\$staging\)' `
|
||||
"Installer does not stage a protected new directory before publication."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerDirectory' `
|
||||
"Installer does not read back the final directory security invariant."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerExistingConfig' `
|
||||
"Installer does not contain the existing active-config ACL."
|
||||
Assert-SourceContains 'GetNumberOfLinks' `
|
||||
"Installer does not reject hard-linked runtime, config, or backup files."
|
||||
Assert-SourceContains 'Assert-MinerOpsInstallerTrustedRuntimeFile' `
|
||||
"Installer does not verify existing runtime rollback provenance."
|
||||
Assert-SourceContains 'Protect-MinerOpsInstallerRuntimeFile' `
|
||||
"Installer does not secure and verify published runtime and rollback copies."
|
||||
Assert-SourceContains 'Pre-reset runtime is evidence, never rollback authority' `
|
||||
"Installer trust reset can adopt pre-reset runtime as rollback authority."
|
||||
Assert-SourceContains 'parent-owned filesystem trust blocker remains' `
|
||||
"Installer does not explicitly retain the parent-owned trust blocker."
|
||||
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
||||
"Installer blockers do not use canonical full-kernel-boot records."
|
||||
if ($source -match 'Remove-MinerOpsInstallerTrustBlocker|Remove-Item[^\r\n]*TrustBlocker') {
|
||||
throw "Installer must have no filesystem trust-blocker deletion path."
|
||||
}
|
||||
if ($source -match 'LastBootUpTime') {
|
||||
throw "Installer must not create wall-clock boot-epoch records."
|
||||
}
|
||||
|
||||
$createStreamFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerProtectedFileCreateNewStream \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredCreateContract in @(
|
||||
'\[IO\.FileStream\]::new',
|
||||
'FileMode\]::CreateNew',
|
||||
'FileSystemRights\]::WriteData',
|
||||
'FileSystemRights\]::ReadPermissions',
|
||||
'FileSystemRights\]::Synchronize',
|
||||
'FileShare\]::None',
|
||||
'\$Acl'
|
||||
)) {
|
||||
if ($createStreamFunction -notmatch $requiredCreateContract) {
|
||||
throw "Installer protected CreateNew stream contract is incomplete."
|
||||
}
|
||||
}
|
||||
if ($createStreamFunction -match 'FullControl|SetAccessControl|Set-Acl') {
|
||||
throw "Installer protected creation requests excess rights or repairs by path."
|
||||
}
|
||||
|
||||
$trustBlockerFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function New-MinerOpsInstallerTrustBlocker \{.*?\n\}'
|
||||
).Value
|
||||
$trustCreateIndex = $trustBlockerFunction.IndexOf(
|
||||
'New-MinerOpsInstallerProtectedFileCreateNewStream'
|
||||
)
|
||||
$trustVerifyIndex = $trustBlockerFunction.IndexOf(
|
||||
'Test-MinerOpsInstallerProtectedFileAcl'
|
||||
)
|
||||
$trustContentIndex = $trustBlockerFunction.IndexOf('$record =')
|
||||
if (
|
||||
$trustCreateIndex -lt 0 -or
|
||||
$trustVerifyIndex -le $trustCreateIndex -or
|
||||
$trustContentIndex -le $trustVerifyIndex
|
||||
) {
|
||||
throw "Installer trust blockers must be protected and verified before content."
|
||||
}
|
||||
|
||||
$configRepairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Protect-MinerOpsInstallerExistingConfig \{.*?\n\}'
|
||||
).Value
|
||||
foreach ($requiredRepairRight in @(
|
||||
'ReadPermissions',
|
||||
'ChangePermissions',
|
||||
'TakeOwnership',
|
||||
'Synchronize'
|
||||
)) {
|
||||
if ($configRepairFunction -notmatch $requiredRepairRight) {
|
||||
throw "Installer existing-config repair is missing $requiredRepairRight."
|
||||
}
|
||||
}
|
||||
if ($configRepairFunction -match 'Set-Acl|FileAccess') {
|
||||
throw "Installer existing-config repair must remain handle-bound."
|
||||
}
|
||||
|
||||
$repairFunction = [regex]::Match(
|
||||
$source,
|
||||
'(?s)function Initialize-MinerOpsInstallerDirectory \{.*?\n\}'
|
||||
).Value
|
||||
$repairIndex = $repairFunction.IndexOf('Set-Acl -LiteralPath $fullPath')
|
||||
$abortIndex = $repairFunction.IndexOf('this invocation will not back up')
|
||||
if ($repairIndex -lt 0 -or $abortIndex -le $repairIndex) {
|
||||
throw "Insecure existing-directory containment must abort before publication."
|
||||
}
|
||||
|
||||
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||
}
|
||||
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
||||
throw "Installer must not inspect or print credentials."
|
||||
}
|
||||
|
||||
Write-Host "MinerOps installer source safety tests passed."
|
||||
@@ -0,0 +1,582 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$supportPath = Join-Path $PSScriptRoot "MinerOpsNativeHarnessSupport.ps1"
|
||||
. $supportPath
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][bool]$Condition,
|
||||
[Parameter(Mandatory = $true)][string]$Message
|
||||
)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Assert-Throws {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action,
|
||||
[Parameter(Mandatory = $true)][string]$Pattern
|
||||
)
|
||||
|
||||
try {
|
||||
& $Action | Out-Null
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message -notmatch $Pattern) {
|
||||
throw "Expected '$Pattern', got '$($_.Exception.Message)'."
|
||||
}
|
||||
return
|
||||
}
|
||||
throw "Expected action to fail with '$Pattern'."
|
||||
}
|
||||
|
||||
$script:Passed = 0
|
||||
function Invoke-HarnessTest {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
|
||||
& $Action
|
||||
$script:Passed++
|
||||
Write-Host "PASS: $Name"
|
||||
}
|
||||
|
||||
function New-PassingSwapPostconditions {
|
||||
return [pscustomobject]@{
|
||||
TargetExists = $true
|
||||
TargetIdentityUnchanged = $true
|
||||
TargetContentUnchanged = $true
|
||||
TargetAclTrusted = $true
|
||||
TargetAclUnchanged = $true
|
||||
TargetOwnerTrusted = $true
|
||||
TargetOwnerUnchanged = $true
|
||||
TargetSingleLink = $true
|
||||
SourceExists = $true
|
||||
SourceIdentityUnchanged = $true
|
||||
SourceContentUnchanged = $true
|
||||
AttackerSourceNotPromoted = $true
|
||||
TargetDeniedStandardUserRead = $true
|
||||
TargetDeniedStandardUserModify = $true
|
||||
TargetDeniedStandardUserDelete = $true
|
||||
TargetDeniedStandardUserRename = $true
|
||||
NoAttackerReadableBackup = $true
|
||||
}
|
||||
}
|
||||
|
||||
function New-ReadySwapFixture {
|
||||
return [pscustomobject]@{
|
||||
SourceExists = $true
|
||||
TargetExists = $true
|
||||
PathsDistinct = $true
|
||||
SourceCreatedByStandardUser = $true
|
||||
SourceOwnedByStandardUser = $true
|
||||
SourceReadable = $true
|
||||
SourceModifiable = $true
|
||||
SourceRenameable = $true
|
||||
SourceDeleteCapable = $true
|
||||
SourceReplaceAccessVerified = $true
|
||||
TargetAclTrusted = $true
|
||||
TargetOwnerTrusted = $true
|
||||
TargetIdentityCaptured = $true
|
||||
SourceIdentityCaptured = $true
|
||||
FileIdentitiesDistinct = $true
|
||||
BackupArgumentValid = $true
|
||||
BackupPathsDistinct = $true
|
||||
BackupExpectedStateVerified = $true
|
||||
BackupConfidentialitySafe = $true
|
||||
SameVolume = $true
|
||||
NtfsVolume = $true
|
||||
}
|
||||
}
|
||||
|
||||
function New-SwapResult {
|
||||
param([Parameter(Mandatory = $true)][string]$Status)
|
||||
return [pscustomobject]@{
|
||||
operation = "swap"
|
||||
status = $Status
|
||||
exception_type = ""
|
||||
hresult = 0
|
||||
outer_exception_type = ""
|
||||
outer_hresult = 0
|
||||
leaf_exception_type = ""
|
||||
leaf_hresult = 0
|
||||
native_error = $null
|
||||
exception_chain_types = @()
|
||||
replace_invoked = $true
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper classifies success and status exit codes" {
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "succeeded") -eq 0) `
|
||||
"Success exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "security_denied") -eq 5) `
|
||||
"Security-denial exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "fixture_error") -eq 6) `
|
||||
"Fixture-error exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode -Status "unexpected_failure") -eq 7) `
|
||||
"Unexpected-failure exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||
-Status "security_denied_requires_postconditions") -eq 8) `
|
||||
"Postcondition-gated security exit code changed."
|
||||
Assert-True ((Get-MinerOpsNativeStatusExitCode `
|
||||
-Status "api_precondition_failure") -eq 9) `
|
||||
"API-precondition exit code changed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper distinguishes security, fixture, and unexpected failures" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $denied) -ceq "security_denied") `
|
||||
"UnauthorizedAccessException was not a security denial."
|
||||
|
||||
$unableToRemove = [IO.IOException]::new("expected", -2147023721)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $unableToRemove) -ceq
|
||||
"security_denied_requires_postconditions") `
|
||||
"ERROR_UNABLE_TO_REMOVE_REPLACED was not postcondition-gated."
|
||||
|
||||
$missing = [IO.FileNotFoundException]::new("missing")
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $missing) -ceq "fixture_error") `
|
||||
"Missing swap source was not a fixture error."
|
||||
|
||||
$invalidArgument = [ArgumentException]::new("illegal backup path")
|
||||
Assert-True ($invalidArgument.HResult -eq -2147024809) `
|
||||
"ArgumentException no longer carries 0x80070057."
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $invalidArgument) -ceq "fixture_error") `
|
||||
"ERROR_INVALID_PARAMETER-like argument failure was not a fixture error."
|
||||
|
||||
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $crossVolume) -ceq
|
||||
"api_precondition_failure") `
|
||||
"Cross-volume swap was not an API-precondition failure."
|
||||
|
||||
$partialMove = [IO.IOException]::new("unexpected", -2147023720)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $partialMove) -ceq "unexpected_failure") `
|
||||
"Partial ReplaceFile failure was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper unwraps MethodInvocationException security denial" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $wrapped) -ceq "security_denied") `
|
||||
"Wrapped UnauthorizedAccessException was not a security denial."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper unwraps multiple PowerShell exception layers" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$methodWrapper = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
$runtimeWrapper = [Management.Automation.RuntimeException]::new(
|
||||
"runtime failed",
|
||||
$methodWrapper
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $runtimeWrapper) -ceq "security_denied") `
|
||||
"Nested PowerShell wrappers were not unwrapped."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "helper decodes only HRESULT_FROM_WIN32 values" {
|
||||
Assert-True ((Get-MinerOpsNativeWin32Error -HResult -2147024891) -eq 5) `
|
||||
"0x80070005 did not decode as ERROR_ACCESS_DENIED."
|
||||
$accessDenied = [IO.IOException]::new("access denied", -2147024891)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $accessDenied) -ceq "security_denied") `
|
||||
"Win32-derived ERROR_ACCESS_DENIED was not security evidence."
|
||||
Assert-True ($null -eq (
|
||||
Get-MinerOpsNativeWin32Error -HResult -2146233087
|
||||
)) "CLR HRESULT 0x80131501 produced a bogus native error."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "unrelated wrapped IOException is not a security denial" {
|
||||
$ioFailure = [IO.IOException]::new("unrelated")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$ioFailure
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $wrapped) -ceq "unexpected_failure") `
|
||||
"Unrelated wrapped IOException was accepted as security evidence."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "wrapped fixture and precondition failures stay distinct" {
|
||||
$missing = [IO.FileNotFoundException]::new("missing")
|
||||
$wrappedMissing = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$missing
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $wrappedMissing) -ceq "fixture_error") `
|
||||
"Wrapped fixture error changed classification."
|
||||
|
||||
$crossVolume = [IO.IOException]::new("different volume", -2147024879)
|
||||
$wrappedCrossVolume = [Reflection.TargetInvocationException]::new(
|
||||
"target invocation failed",
|
||||
$crossVolume
|
||||
)
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation swap -Exception $wrappedCrossVolume) -ceq
|
||||
"api_precondition_failure") `
|
||||
"Wrapped API precondition changed classification."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception diagnostics report outer leaf and chain types" {
|
||||
$denied = [UnauthorizedAccessException]::new("denied")
|
||||
$wrapped = [Management.Automation.MethodInvocationException]::new(
|
||||
"method invocation failed",
|
||||
$denied
|
||||
)
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapped
|
||||
Assert-True ($details.OuterExceptionType -ceq
|
||||
"System.Management.Automation.MethodInvocationException") `
|
||||
"Exception diagnostics recorded the wrong outer type."
|
||||
Assert-True ($details.LeafExceptionType -ceq
|
||||
"System.UnauthorizedAccessException") `
|
||||
"Exception diagnostics recorded the wrong leaf type."
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 2) `
|
||||
"Exception diagnostics recorded the wrong chain length."
|
||||
Assert-True (-not $details.ExceptionChainTruncated) `
|
||||
"A short exception chain was reported as truncated."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception diagnostics handle null InnerException" {
|
||||
$wrapperOnly = [Management.Automation.RuntimeException]::new(
|
||||
"wrapper without inner exception"
|
||||
)
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $wrapperOnly
|
||||
Assert-True ($details.OuterExceptionType -ceq $details.LeafExceptionType) `
|
||||
"Null InnerException did not leave the wrapper as the leaf."
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 1) `
|
||||
"Null InnerException produced an invalid chain."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "exception-chain traversal is bounded defensively" {
|
||||
[Exception]$deep = [UnauthorizedAccessException]::new("denied")
|
||||
foreach ($level in 1..40) {
|
||||
$deep = [Exception]::new("wrapper $level", $deep)
|
||||
}
|
||||
$details = Get-MinerOpsNativeExceptionDetails -Exception $deep
|
||||
Assert-True ($details.ExceptionChainTypes.Count -eq 32) `
|
||||
"Deep exception traversal did not stop at its bound."
|
||||
Assert-True ([bool]$details.ExceptionChainTruncated) `
|
||||
"Deep exception traversal did not report truncation."
|
||||
Assert-True ((Get-MinerOpsNativeExceptionStatus `
|
||||
-Operation read -Exception $deep) -ceq "unexpected_failure") `
|
||||
"Truncated exception evidence did not fail closed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "swap probe parses and passes an explicit native null" {
|
||||
$probePath = Join-Path $PSScriptRoot `
|
||||
"Invoke-MinerOpsStandardUserProbe.ps1"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[Management.Automation.Language.Parser]::ParseFile(
|
||||
$probePath,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
) | Out-Null
|
||||
Assert-True ($parseErrors.Count -eq 0) `
|
||||
"Standard-user probe has parser errors."
|
||||
$probeSource = [IO.File]::ReadAllText($probePath)
|
||||
Assert-True ($probeSource -match '\[NullString\]::Value') `
|
||||
"Swap probe does not pass an explicit native null backup argument."
|
||||
Assert-True ($probeSource -notmatch
|
||||
'File\]::Replace\([^\)]*\$null') `
|
||||
"Swap probe regressed to a bare PowerShell null backup argument."
|
||||
Assert-True ($probeSource -match
|
||||
'backup-producing swap fixtures are prohibited') `
|
||||
"Swap probe no longer prohibits confidentiality-unsafe backups."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "NullString preserves native null for a .NET string" {
|
||||
if (-not ("MinerOpsNativeHarnessNullStringProbe" -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
public static class MinerOpsNativeHarnessNullStringProbe
|
||||
{
|
||||
public static bool IsNull(string value)
|
||||
{
|
||||
return value == null;
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
Assert-True (-not (
|
||||
[MinerOpsNativeHarnessNullStringProbe]::IsNull($null)
|
||||
)) "A bare PowerShell null was not coerced to String.Empty."
|
||||
Assert-True (
|
||||
[MinerOpsNativeHarnessNullStringProbe]::IsNull([NullString]::Value)
|
||||
) "NullString did not preserve native null for a .NET string."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "same source and destination is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.PathsDistinct = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Equal source and destination paths were accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "missing source is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.SourceExists = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing source was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "missing target is a fixture failure" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.TargetExists = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing target was accepted."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "invalid backup argument is a fixture failure" {
|
||||
foreach ($property in @(
|
||||
"BackupArgumentValid",
|
||||
"BackupPathsDistinct",
|
||||
"BackupExpectedStateVerified",
|
||||
"BackupConfidentialitySafe"
|
||||
)) {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.$property = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Invalid backup property $property was accepted."
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "cross-volume limitation is not a security pass" {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.SameVolume = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "api_precondition_failure") `
|
||||
"Cross-volume fixture was not an API-precondition failure."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "valid controlled same-volume source is attack-ready" {
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus `
|
||||
-Fixture (New-ReadySwapFixture)
|
||||
Assert-True ($classification.Status -ceq "ready") `
|
||||
"Valid same-volume fixture was not attack-ready."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "source ownership and control are mandatory" {
|
||||
foreach ($property in @(
|
||||
"SourceCreatedByStandardUser",
|
||||
"SourceOwnedByStandardUser",
|
||||
"SourceReadable",
|
||||
"SourceModifiable",
|
||||
"SourceRenameable",
|
||||
"SourceDeleteCapable",
|
||||
"SourceReplaceAccessVerified"
|
||||
)) {
|
||||
$fixture = New-ReadySwapFixture
|
||||
$fixture.$property = $false
|
||||
$classification = Get-MinerOpsNativeSwapFixtureStatus -Fixture $fixture
|
||||
Assert-True ($classification.Status -ceq "fixture_error") `
|
||||
"Missing source-control proof $property was accepted."
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "invalid swap fixture cannot pass as security" {
|
||||
Assert-Throws -Pattern "fixture was invalid" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "fixture_error") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "unrelated swap API precondition cannot pass as security" {
|
||||
Assert-Throws -Pattern "unrelated API precondition" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "api_precondition_failure") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "successful replacement is a security failure" {
|
||||
Assert-Throws -Pattern "completed the protected replacement" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "succeeded") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "security denial passes only with all postconditions" {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult `
|
||||
-Status "security_denied_requires_postconditions") `
|
||||
-Postconditions (New-PassingSwapPostconditions)
|
||||
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetAclTrusted = $false
|
||||
Assert-Throws -Pattern "target ACL is not trusted" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed target identity is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetIdentityUnchanged = $false
|
||||
Assert-Throws -Pattern "target identity changed" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed target content is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.TargetContentUnchanged = $false
|
||||
Assert-Throws -Pattern "target content changed" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial with changed ACL or owner is a security failure" {
|
||||
foreach ($property in @(
|
||||
"TargetAclTrusted",
|
||||
"TargetAclUnchanged",
|
||||
"TargetOwnerTrusted",
|
||||
"TargetOwnerUnchanged"
|
||||
)) {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.$property = $false
|
||||
Assert-Throws -Pattern "target (ACL|owner)" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "attacker-readable protected backup is a security failure" {
|
||||
$failed = New-PassingSwapPostconditions
|
||||
$failed.NoAttackerReadableBackup = $false
|
||||
Assert-Throws -Pattern "through a backup" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result (New-SwapResult -Status "security_denied") `
|
||||
-Postconditions $failed
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "denial must record that File.Replace was reached" {
|
||||
$result = New-SwapResult -Status "security_denied"
|
||||
$result.replace_invoked = $false
|
||||
Assert-Throws -Pattern "did not reach System.IO.File.Replace" -Action {
|
||||
Assert-MinerOpsNativeSwapSecurityOutcome `
|
||||
-Result $result -Postconditions (New-PassingSwapPostconditions)
|
||||
}
|
||||
}
|
||||
|
||||
$initialState = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
Invoke-HarnessTest "trace leaves no global function when none existed" {
|
||||
$childScript = Join-Path $PSScriptRoot `
|
||||
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||
$powerShell = (Get-Process -Id $PID).Path
|
||||
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||
-Mode NoExistingFunction -SupportPath $supportPath
|
||||
Assert-True ($LASTEXITCODE -eq 0) `
|
||||
"No-existing-function child test failed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace restores an existing global function exactly" {
|
||||
$childScript = Join-Path $PSScriptRoot `
|
||||
"Test-MinerOpsNativeHarnessGetItemState.ps1"
|
||||
$powerShell = (Get-Process -Id $PID).Path
|
||||
& $powerShell -NoLogo -NoProfile -File $childScript `
|
||||
-Mode ExistingFunction -SupportPath $supportPath
|
||||
Assert-True ($LASTEXITCODE -eq 0) `
|
||||
"Existing-function child test failed."
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace cleanup survives Action failure" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
Assert-Throws -Pattern "injected trace failure" -Action {
|
||||
Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath | Out-Null
|
||||
throw "injected trace failure"
|
||||
}
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "throwing trace action"
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "trace cleanup survives a later assertion failure" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
throw "injected post-trace assertion failure"
|
||||
}
|
||||
catch {
|
||||
Assert-True (
|
||||
$_.Exception.Message -match "post-trace assertion failure"
|
||||
) "Unexpected post-trace failure."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "post-trace assertion failure"
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "multiple sequential traces preserve command state" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
foreach ($iteration in 1..3) {
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
Assert-True ($observed.Count -eq 1) `
|
||||
"Sequential trace $iteration recorded the wrong count."
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "sequential trace $iteration"
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-HarnessTest "simulated top-level failure restores Get-Item" {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
try {
|
||||
try {
|
||||
[void](Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $supportPath
|
||||
})
|
||||
throw "injected top-level harness failure"
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "simulated harness final cleanup"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Assert-True (
|
||||
$_.Exception.Message -match "top-level harness failure"
|
||||
) "Unexpected simulated top-level failure."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "after simulated top-level failure"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Assert-MinerOpsNativeGetItemState -Expected $initialState `
|
||||
-Context "portable native-harness tests final cleanup"
|
||||
}
|
||||
|
||||
Write-Host "$Passed native-harness regression tests passed."
|
||||
@@ -0,0 +1,48 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("NoExistingFunction", "ExistingFunction")]
|
||||
[string]$Mode,
|
||||
[Parameter(Mandatory = $true)][string]$SupportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. $SupportPath
|
||||
|
||||
if ($Mode -ceq "NoExistingFunction") {
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
if ([bool]$before.FunctionExisted) {
|
||||
throw "Child process unexpectedly began with a global Get-Item function."
|
||||
}
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item."
|
||||
}
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "no-preexisting-function child test"
|
||||
return
|
||||
}
|
||||
|
||||
$existing = { param($LiteralPath) "preexisting:$LiteralPath" }
|
||||
Set-Item -LiteralPath "Function:\global:Get-Item" -Value $existing -Force
|
||||
$before = Get-MinerOpsNativeGetItemState
|
||||
$observed = @(Invoke-MinerOpsNativeGetItemTrace -Action {
|
||||
Get-Item -LiteralPath $SupportPath
|
||||
})
|
||||
if ($observed -cnotcontains $SupportPath) {
|
||||
throw "Trace did not observe Get-Item over an existing global function."
|
||||
}
|
||||
$after = Get-MinerOpsNativeGetItemState
|
||||
Assert-MinerOpsNativeGetItemState -Expected $before `
|
||||
-Context "preexisting-function child test"
|
||||
if ($after.FunctionScriptBlock.ToString() -cne $existing.ToString()) {
|
||||
throw "Existing Get-Item ScriptBlock was not restored exactly."
|
||||
}
|
||||
if (-not [object]::ReferenceEquals(
|
||||
$after.FunctionScriptBlock,
|
||||
$before.FunctionScriptBlock
|
||||
)) {
|
||||
throw "Existing Get-Item ScriptBlock instance changed."
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$BaselineRoot,
|
||||
[Parameter(Mandatory=$true)][string]$CachedPayloadPath,
|
||||
[Parameter(Mandatory=$true)][string]$MinerIp,
|
||||
[switch]$ExpectFenced
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MINEROPS_STAGE_A_SIMULATION -ne "1") {
|
||||
throw "Requires an explicitly isolated Stage A simulation; never use physical targets."
|
||||
}
|
||||
|
||||
# Supply a git-archived 2d89e80 Windows source directory, NOT a running installation.
|
||||
# Only AST function definitions execute; no startup, config loading, or task loops.
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Join-Path $BaselineRoot "MinerOpsAgentService.ps1"), [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw "Baseline parser error" }
|
||||
foreach ($definition in $ast.FindAll({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Parent -is [System.Management.Automation.Language.NamedBlockAst]
|
||||
}, $true)) {
|
||||
Invoke-Expression $definition.Extent.Text
|
||||
}
|
||||
. (Join-Path $BaselineRoot "MinerOpsControl.ps1")
|
||||
function Read-AgentConfig {
|
||||
return [pscustomobject]@{
|
||||
api3_account = "simulation"; api3_password = "simulation-only"; api3_port = 4433
|
||||
}
|
||||
}
|
||||
function Get-MinerOpsApi3Salt { return @{ salt = "simulation-only"; salt_response = @{} } }
|
||||
|
||||
$payload = Get-Content -LiteralPath $CachedPayloadPath -Raw | ConvertFrom-Json
|
||||
if ($payload.action -ne "suspend") { throw "This simulation expects the audited cached suspend command" }
|
||||
# Remap ONLY the target to the address of the dedicated disposable miner container.
|
||||
$payload.ip_address = $MinerIp
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
if ($ExpectFenced) {
|
||||
if ($result.command_accepted -ne $false -or $result.failure_code -ne "write_rejected") {
|
||||
throw "Old executor unexpectedly reached/accepted a simulated miner through the fence"
|
||||
}
|
||||
Write-Host "Fenced old cached command failed; independently verify simulator write count stayed zero."
|
||||
} else {
|
||||
if ($result.command_accepted -ne $true) { throw "Unfenced control simulation did not reproduce old execution" }
|
||||
Write-Host "Unfenced old cached command reproduced; simulated miner received service stop."
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
# Disposable Windows filesystem-trust validation
|
||||
|
||||
Native Windows evidence is required before deployment. The cross-platform
|
||||
behavioral harness proves deterministic state-machine decisions; it cannot
|
||||
prove NTFS access checks, directory-handle sharing, junction behavior,
|
||||
same-directory rename semantics, or Scheduled Task behavior.
|
||||
|
||||
## Isolation
|
||||
|
||||
Use a newly created, disposable Windows 10/11 or Windows Server VM. Do not join
|
||||
it to a production domain or connect it to a production MinerOps Hub, database,
|
||||
or miner network. Never use a production package, URL, Agent ID, transition
|
||||
token, shared secret, or API3 credential. Copy only the candidate
|
||||
`agents\windows` directory to the VM and take a snapshot first.
|
||||
|
||||
Run 64-bit Windows PowerShell 5.1 as Administrator:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
.\tests\Test-MinerOpsCredentialTransition.Windows.ps1 `
|
||||
-AcknowledgeDisposableVm
|
||||
```
|
||||
|
||||
Record the exact pass count printed by the candidate harness. Do not claim the
|
||||
native suite passed unless this command actually completed on Windows. Save the
|
||||
complete transcript plus:
|
||||
|
||||
```powershell
|
||||
Get-ComputerInfo | Select-Object WindowsProductName, WindowsVersion, OsBuildNumber
|
||||
$PSVersionTable
|
||||
```
|
||||
|
||||
The native primitive suite verifies:
|
||||
|
||||
- protected credential, enrollment, installer, and trust-blocker files are
|
||||
created with their final `FileSecurity`, held exclusively, and read back
|
||||
through the same still-empty handle before any content is written;
|
||||
- the directory and credential-file DACLs deny standard-user read, create,
|
||||
delete, rename, and replacement-swap operations;
|
||||
- ACL setup or read-back failure cannot leave secret-bearing content;
|
||||
- a pre-created pathname remains unchanged when `CreateNew` fails;
|
||||
- an insecure artifact is repaired through an exclusive handle with explicit
|
||||
security rights, then rejected; inability to acquire those rights fails
|
||||
closed without a path-based fallback;
|
||||
- the durable blocker exists before an ACL-repaired artifact could be
|
||||
reconsidered, and remains effective on the next check;
|
||||
- quarantine evidence is unreadable to the standard user;
|
||||
- the two-rename install leaves the new active file and a non-authoritative
|
||||
prior remnant, and an active-missing install does not invent prior authority;
|
||||
- a directory junction is rejected;
|
||||
- a permissive directory is hardened, gets an external durable blocker, and
|
||||
cannot continue in that process or an ordinary rerun;
|
||||
- pre-repair standard-user file and directory handles are exercised after ACL
|
||||
repair while automatic transition remains blocked regardless of whether the
|
||||
handle operation succeeds;
|
||||
- hard-linked enrollment, runtime, and backup targets are rejected; and
|
||||
- a real harmless expected Scheduled Task is positively verified for exact
|
||||
`Running` and `Ready`, while missing, query failure, disabled, malformed,
|
||||
wrong-action, and wrong-principal cases fail closed.
|
||||
|
||||
## Full-kernel-boot epoch matrix
|
||||
|
||||
The source uses the canonical value
|
||||
`windows-full-boot-event-v1:<16 lowercase hex digits>`. The payload is the
|
||||
Event Log `RecordId` anchor of the newest System-log event from
|
||||
`Microsoft-Windows-Kernel-Boot`, event ID 27, whose namespace-aware XML payload
|
||||
contains exactly one canonical `BootType` value equal to `0`. `ProviderName`,
|
||||
`Id`, nullable `RecordId`, and nullable `Version` must have the real typed
|
||||
EventLogRecord shapes; supported versions are 0 and 1. The record ID is not an
|
||||
immutable boot GUID. A reset requires a valid current anchor strictly greater
|
||||
than the stored anchor. Equal, lower/restored/reused, missing, and ambiguous
|
||||
anchors fail closed. The implementation deliberately does not use wall-clock
|
||||
time or the Windows `BootId` counter: Fast Startup is a hibernated kernel
|
||||
restoration and must not authorize reset merely because Windows reports a new
|
||||
startup sequence.
|
||||
|
||||
On a disposable VM, dot-source the transition script and record
|
||||
`Get-MinerOpsBootIdentifier` before and after every row. Preserve the raw event
|
||||
object and `ToXml()` output from `Get-WinEvent`. For each event, record the
|
||||
runtime types and values of `ProviderName`, `Id`, `RecordId`, and `Version`, the
|
||||
root namespace, and the `System/EventData/Data[@Name='BootType']` shape.
|
||||
|
||||
| Transition | Required canonical identity |
|
||||
| --- | --- |
|
||||
| Change system clock within the running session | unchanged |
|
||||
| Change timezone within the running session | unchanged |
|
||||
| Sleep and resume | unchanged |
|
||||
| Hibernate and resume, where supported | unchanged |
|
||||
| Shutdown and power-on with Fast Startup, where supported | unchanged |
|
||||
| Restart from the Windows power menu | strictly greater record anchor |
|
||||
|
||||
For the Fast Startup row, independently confirm that Windows restored kernel
|
||||
session 0 and that the latest event 27 has a nonzero `BootType`; the newest
|
||||
full-boot (`BootType=0`) event record must remain the same. For the Restart row,
|
||||
confirm a new exact `BootType=0` event and a new canonical identity. Also test a
|
||||
missing System log/provider event, duplicated `BootType`, malformed XML value,
|
||||
wrong namespace, unsupported/null version, wrong provider/event ID,
|
||||
zero/malformed record ID, and query access failure; every case must fail
|
||||
closed.
|
||||
|
||||
Use a separate disposable snapshot for log-retention behavior. Preserve the
|
||||
pre-clear stored anchor and evidence first, then clear only the disposable VM's
|
||||
System log. Verify that missing history cannot authorize reset. After a full
|
||||
Restart, if Event Log numbering restarts at or below the stored anchor, verify
|
||||
that automatic reset remains blocked and requires operator intervention. Also
|
||||
restore a snapshot exposing only an older valid full-boot event and confirm the
|
||||
lower record ID is rejected. Do not add a timestamp fallback for any row. A
|
||||
native result is required because EventLogRecord property types, provider
|
||||
namespace/version, log-clear numbering, and power-state behavior cannot be
|
||||
proven by the Linux harness.
|
||||
|
||||
## Loopback workflow fixture
|
||||
|
||||
Use a fresh snapshot for full workflow testing. All values must be disposable.
|
||||
|
||||
1. Create a test `C:\MinerOpsAgent\agent-config.json` containing random Agent,
|
||||
Site, and Zone UUIDs plus placeholder secrets.
|
||||
2. Place a harmless `MinerOpsAgentService.ps1` in the target and register
|
||||
`MinerOpsAgent` with the exact installer-controlled SYSTEM/highest action:
|
||||
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
|
||||
"C:\MinerOpsAgent\MinerOpsAgentService.ps1"`.
|
||||
3. Serve a loopback-only mock at `http://127.0.0.1:<port>`. Its
|
||||
`POST /api/agent/credential-transition` handler records only the token hash
|
||||
and submitted `agent_secret_hash`, returns the fixed disposable
|
||||
Agent/Site/Zone identity, returns the same response for the same token and
|
||||
hash, and rejects a changed hash.
|
||||
4. Build a local package with that loopback origin and a random disposable
|
||||
43-character token. Never download from or call a real Hub.
|
||||
5. For each case, restore the snapshot or recreate the fixture. Run once with
|
||||
the task initially running and once with it intentionally stopped.
|
||||
|
||||
The child-process shape for hard termination is:
|
||||
|
||||
```powershell
|
||||
$checkpoint = 'pending-written'
|
||||
$code = @"
|
||||
. 'C:\validation\agents\windows\Upgrade-MinerOpsAgentCredential.ps1'
|
||||
Invoke-MinerOpsAgentCredentialTransition `
|
||||
-TargetRoot 'C:\MinerOpsAgent' `
|
||||
-PackageRoot 'C:\validation\agents\windows' `
|
||||
-ScheduledTaskName 'MinerOpsAgent' `
|
||||
-CheckpointAction { param(`$name) if (`$name -eq '$checkpoint') { Stop-Process -Id `$PID -Force } }
|
||||
"@
|
||||
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code))
|
||||
Start-Process powershell.exe `
|
||||
-ArgumentList '-NoProfile','-EncodedCommand',$encoded -Wait
|
||||
```
|
||||
|
||||
After termination, rerun normally without deleting, repairing, or editing any
|
||||
artifact. Every retry request must use the same token and
|
||||
`agent_secret_hash`; no retry may generate a replacement secret.
|
||||
|
||||
## Hard-termination matrix
|
||||
|
||||
| Checkpoint | State before rerun | Required rerun result |
|
||||
| --- | --- | --- |
|
||||
| `directory-repair-blocker-written` | external protected blocker durable before repair | no artifact is trusted; every ordinary invocation remains blocked |
|
||||
| `directory-trust-established` | hardened directory; external blocker retained | pre-repair recovery/pending/remnants are quarantined without parsing; even a clean repair cannot continue in this process |
|
||||
| `directory-containment-scan-complete` | initial known-name scan finished; external blocker retained | a late recovery/pending file never becomes authority on this or later ordinary invocations |
|
||||
| `trust-reset-authorized` | changed full-boot epoch and strict original task intent are durable; blocker retained | approved retry preserves that intent and repeats publication; it cannot delete early |
|
||||
| `trust-reset-runtime-published` | fresh package runtime is durable; blocker is `runtime-published` | approved retry resumes credentials/config/task convergence with the blocker retained |
|
||||
| `quarantine-blocker-written` | blocker durable; insecure authoritative artifact may remain | second run never parses or redeems it |
|
||||
| `artifact-moved-to-quarantine` | authority name absent; evidence is beneath quarantine | evidence remains non-authoritative and the quarantine directory is reverified |
|
||||
| `quarantine-directory-verified` | evidence is under the protected non-traversable quarantine directory | rerun remains blocked and never reads or ACL-repairs evidence contents |
|
||||
| `recovery-written` | protected recovery; original task state retained | exact saved token/secret is reused |
|
||||
| `task-stopped` | recovery protected; task stopped; no request yet | transition completes and restores original task intent |
|
||||
| `server-committed` | server may have committed; task stopped | idempotent same-hash redemption completes |
|
||||
| `pending-written` | protected matching recovery and pending | pending is validated and reused, not overwritten |
|
||||
| `before-replace` | active legacy config plus pending | first rename may proceed; rerun converges |
|
||||
| `active-moved-to-prior` | active missing; matching pending and legacy prior present | prior is never promoted; pending becomes active |
|
||||
| `pending-moved-to-active` | committed active plus prior; pending absent | active is verified; prior is cleanup-only |
|
||||
| `after-replace` | same as prior row | active is verified before task restoration |
|
||||
| `task-restored` | active and final task state verified | retry verifies again and cleans up |
|
||||
| `before-trust-blocker-removal` | active config and final strict task state verified; external blocker still present | only an explicitly approved parent retry may verify again and remove the blocker |
|
||||
| `before-recovery-cleanup` | active/task verified; recovery remains last | retry uses recovery and completes cleanup |
|
||||
|
||||
For each row inspect ACLs and task state without displaying secret contents:
|
||||
|
||||
```powershell
|
||||
Get-Acl C:\MinerOpsAgent | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-credential-transition.recovery.json `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-Acl C:\MinerOpsAgent\agent-config.json.pending `
|
||||
-ErrorAction SilentlyContinue | Format-List
|
||||
Get-ScheduledTask -TaskName MinerOpsAgent | Select-Object TaskName, State
|
||||
```
|
||||
|
||||
## Provenance and quarantine cases
|
||||
|
||||
Run these as separate restored-snapshot cases:
|
||||
|
||||
1. **Malicious recovery, three invocations.** Stop after `recovery-written`, add
|
||||
a Users read/write ACE to recovery, replace its secret and
|
||||
`original_task_running`, and run twice. The first run must create a blocker,
|
||||
quarantine without parsing, and send no request. The second must remain
|
||||
blocked, send no request, keep the real original task state, and omit the
|
||||
attacker secret from all output. A third ordinary run must remain blocked.
|
||||
2. **ACL-repair laundering attempt.** Terminate at
|
||||
`quarantine-blocker-written`, then repair the still-authoritative recovery
|
||||
file ACL by hand without deleting the blocker. The next run must quarantine
|
||||
it without reading or further ACL mutation and must not redeem it. This
|
||||
proves ACL correctness cannot override the durable provenance decision.
|
||||
3. **Malicious pending, three invocations.** Stop after `pending-written`, add a
|
||||
Users ACE and alter the credential/Agent data. All runs must send no new
|
||||
request and must never promote pending.
|
||||
4. **Permissive directory with attacker state.** Add Users Modify to the target
|
||||
directory, place recovery and pending before repair, and run. Both artifacts
|
||||
must be quarantined, a blocker retained, and no request sent. A hard stop at
|
||||
`directory-trust-established` must have the same second-run result.
|
||||
5. **Quarantine preservation.** A later successful, separately authorized
|
||||
transition must not silently delete unresolved blocker or evidence files.
|
||||
Operator review must remove them only after invalidating the exposed token
|
||||
and obtaining a new package.
|
||||
6. **Late arrival after scan.** At `directory-containment-scan-complete`, use a
|
||||
handle opened before repair to attempt creation/rename of recovery and
|
||||
pending names, including a file whose ACL now looks correct. The current,
|
||||
second, and third ordinary runs must send no request and perform no
|
||||
activation regardless of the handle operation's Windows result.
|
||||
7. **Trust-epoch reset.** Use Restart, confirm the canonical full-kernel-boot
|
||||
event identity changed, clear suspicious transition state only under the
|
||||
approved procedure, and run with
|
||||
`-ApproveFilesystemTrustResetAfterReboot`. Confirm fresh runtime publication
|
||||
occurs while the external blocker exists, old runtime is never a rollback
|
||||
source, the task remains stopped until transition verification, and the
|
||||
blocker remains through credential/config convergence and final strict task
|
||||
verification. It may disappear only after all of those parent checks pass.
|
||||
The same switch before Restart must fail. Repeat after clock/timezone change,
|
||||
sleep, hibernate, and Fast Startup; each must still fail as the same epoch.
|
||||
8. **Direct installer reset attempts.** With a real external blocker present,
|
||||
invoke `install.ps1 -ApproveFilesystemTrustResetAfterReboot` directly for a
|
||||
successful publication and an injected publication failure. Repeat with the
|
||||
task disabled, `Unknown`, `Queued`, wrong action, and wrong principal. The
|
||||
blocker must remain in every case. Then run the complete parent workflow and
|
||||
prove deletion occurs only after its final convergence checks.
|
||||
|
||||
In every quarantine case, test standard-user reads against every
|
||||
`minerops-quarantine-credential-transition-*.evidence` file. Access must be
|
||||
denied, and neither console nor error transcripts may contain its contents.
|
||||
|
||||
## Hard-link cases
|
||||
|
||||
On NTFS, create a second name for each candidate with `New-Item -ItemType
|
||||
HardLink`. Test attacker-created enrollment pending, recovery, and final config
|
||||
paths; installed runtime destinations; and rollback sources/destinations. Every
|
||||
case must fail before secret write, redemption, publication, or restore. Verify
|
||||
that no attacker-readable alias contains a permanent credential and that a
|
||||
failed `CreateNew` does not delete or adopt the pre-existing file.
|
||||
|
||||
## Strict Scheduled Task matrix
|
||||
|
||||
Use the exact installer-controlled action and principal as the valid fixture.
|
||||
Run both durable intents through `Running`, `Ready`, missing, provider/query
|
||||
exception, access denied where practical, null/empty state, `Disabled`,
|
||||
`Unknown`, `Queued`, malformed object/definition, multiple results, wrong
|
||||
executable/script arguments, and wrong principal. Only running plus `Running`
|
||||
and stopped plus `Ready` succeed. Stopped plus `Running` must issue stop and
|
||||
then positively verify `Ready`.
|
||||
|
||||
Capture the actual provider types for `TaskPath`, `State`, `Settings.Enabled`,
|
||||
`Actions`, action `Execute`/`Arguments`, and principal `UserId`, `LogonType`, and
|
||||
`RunLevel`. Verify root `TaskPath` is an actual string equal to `\` and Enabled
|
||||
is an actual Boolean `$true`. Missing, null, empty, array, numeric, and truthy
|
||||
string substitutions must fail; provider enum normalization is allowed only for
|
||||
`State`, `LogonType`, and `RunLevel`.
|
||||
|
||||
After the server-commit checkpoint, inject final task verification failure.
|
||||
Recovery must remain and no success output or cleanup may occur. Repair the task
|
||||
and rerun; the recorded token and `agent_secret_hash` must be identical and the
|
||||
retry must converge without generating a new secret.
|
||||
|
||||
## Active-missing and replacement cases
|
||||
|
||||
Run all of these with a cryptographically matching protected recovery record:
|
||||
|
||||
- active missing, protected matching pending present, no prior (legacy
|
||||
`ReplaceFile` partial state): converges with the same token and secret;
|
||||
- active missing, matching pending and valid legacy prior present (new first
|
||||
rename completed): converges through pending; prior is never authority;
|
||||
- committed active and valid prior present, pending absent (new second rename
|
||||
completed): verifies active and cleans prior last;
|
||||
- failure before the first rename: active and pending remain, rerun converges;
|
||||
- failure between the two renames: active is absent, pending and prior remain,
|
||||
rerun converges;
|
||||
- failure immediately after pending becomes active: active and prior remain,
|
||||
rerun verifies active before cleanup;
|
||||
- active missing with no recovery or with unknown pending: fails closed;
|
||||
- active missing with malformed pending: fails before redemption;
|
||||
- active missing with mismatched Agent, secret, or secret hash: fails before
|
||||
redemption; and
|
||||
- active missing with only a stale prior: fails closed and never promotes it.
|
||||
|
||||
For every successful active-missing case, confirm exact Agent/Hub/Site/Zone
|
||||
identity, the same token and secret hash on every request, active-file DACL,
|
||||
and final Scheduled Task intent. Initially running must end running;
|
||||
intentionally stopped must remain stopped.
|
||||
|
||||
## Completion and cleanup
|
||||
|
||||
At successful termination, recovery, pending, prior, and consumed enrollment
|
||||
files are absent; unresolved quarantine/blocker evidence is never silently
|
||||
removed. Console/error transcripts contain neither token nor generated secret.
|
||||
|
||||
Record any disposable account, Scheduled Task, junction, directory, or mock
|
||||
service left by an interrupted validation, remove only those validation
|
||||
resources, then restore the snapshot or destroy the VM.
|
||||
@@ -0,0 +1,21 @@
|
||||
#Requires -RunAsAdministrator
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Target = "C:\MinerOpsAgent"
|
||||
$Tasks = @(
|
||||
"MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller"
|
||||
)
|
||||
foreach ($name in $Tasks) {
|
||||
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$RuntimeFiles = @(
|
||||
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||
"VERSION"
|
||||
)
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved."
|
||||
+8
-5
@@ -5,15 +5,18 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic /app/alembic
|
||||
COPY backend/app /app/app
|
||||
COPY backend/alembic.ini /app/alembic.ini
|
||||
COPY backend/alembic /app/alembic
|
||||
|
||||
# Canonical Agent source used to construct short-lived enrollment packages.
|
||||
COPY agents/windows /app/agents/windows
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add inventory Asset ID history
|
||||
|
||||
Revision ID: 0009_inventory_asset_id_history
|
||||
Revises: 0008_manual_miner_inventory
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0009_inventory_asset_id_history"
|
||||
down_revision: Union[str, None] = "0008_manual_miner_inventory"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"miner_asset_id_history",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"miner_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("miners.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("old_asset_id", sa.Text(), nullable=True),
|
||||
sa.Column("new_asset_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"changed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"changed_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"old_asset_id IS NULL OR "
|
||||
"(old_asset_id = trim(old_asset_id) AND old_asset_id <> '')",
|
||||
name="ck_miner_asset_id_history_old_normalized",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"new_asset_id = trim(new_asset_id) AND new_asset_id <> ''",
|
||||
name="ck_miner_asset_id_history_new_normalized",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_miner_asset_id_history_miner_changed_at",
|
||||
"miner_asset_id_history",
|
||||
["miner_id", "changed_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_miner_asset_id_history_changed_by",
|
||||
"miner_asset_id_history",
|
||||
["changed_by"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_miner_asset_id_history_changed_by",
|
||||
table_name="miner_asset_id_history",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_miner_asset_id_history_miner_changed_at",
|
||||
table_name="miner_asset_id_history",
|
||||
)
|
||||
op.drop_table("miner_asset_id_history")
|
||||
@@ -0,0 +1,322 @@
|
||||
"""add begin repair workflow
|
||||
|
||||
Revision ID: 0010_begin_repair
|
||||
Revises: 0009_inventory_asset_id_history
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0010_begin_repair"
|
||||
down_revision: Union[str, None] = "0009_inventory_asset_id_history"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
REPAIR_STATUSES = (
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_PARTS",
|
||||
"READY_FOR_TEST",
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
)
|
||||
ACTIVE_REPAIR_STATUSES = (
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_PARTS",
|
||||
"READY_FOR_TEST",
|
||||
)
|
||||
REPAIR_REASONS = (
|
||||
"HARDWARE_FAILURE",
|
||||
"LOW_HASHRATE",
|
||||
"CONTROL_BOARD",
|
||||
"HASHBOARD",
|
||||
"PSU",
|
||||
"FAN",
|
||||
"OVERHEATING",
|
||||
"NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE",
|
||||
"INSPECTION",
|
||||
"OTHER",
|
||||
)
|
||||
REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE")
|
||||
|
||||
|
||||
def _quoted(values: Sequence[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_miners_lifecycle_status",
|
||||
"miners",
|
||||
"lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')",
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.create', 'Begin repairs for miner assets')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = 'repairs.create'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"repairs",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"miner_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("miners.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'OPEN'"),
|
||||
),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"opened_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"opened_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"completed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"completed_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"status IN ({_quoted(REPAIR_STATUSES)})",
|
||||
name="ck_repairs_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"reason IN ({_quoted(REPAIR_REASONS)})",
|
||||
name="ck_repairs_reason",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(completed_at IS NULL AND completed_by IS NULL) OR "
|
||||
"(completed_at IS NOT NULL AND completed_by IS NOT NULL)",
|
||||
name="ck_repairs_completion_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'COMPLETED') = (completed_at IS NOT NULL)",
|
||||
name="ck_repairs_completed_state",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_repairs_miner_opened_at",
|
||||
"repairs",
|
||||
["miner_id", "opened_at", "id"],
|
||||
)
|
||||
op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"])
|
||||
op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"])
|
||||
op.create_index(
|
||||
"uq_repairs_one_active_per_miner",
|
||||
"repairs",
|
||||
["miner_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})"
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"repair_events",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"repair_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("repairs.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"payload",
|
||||
postgresql.JSONB(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})",
|
||||
name="ck_repair_events_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"sequence > 0",
|
||||
name="ck_repair_events_sequence_positive",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"jsonb_typeof(payload) = 'object'",
|
||||
name="ck_repair_events_payload_object",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"("
|
||||
"event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||
") OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> ''"
|
||||
")",
|
||||
name="ck_repair_events_payload_shape",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"repair_id",
|
||||
"sequence",
|
||||
name="uq_repair_events_repair_sequence",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_repair_events_repair_created",
|
||||
"repair_events",
|
||||
["repair_id", "created_at", "sequence", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_repair_events_created_by",
|
||||
"repair_events",
|
||||
["created_by"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
f"""
|
||||
DO $$
|
||||
DECLARE
|
||||
invalid_miner_ids TEXT;
|
||||
BEGIN
|
||||
SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text)
|
||||
INTO invalid_miner_ids
|
||||
FROM miners AS miner
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS started_event_count
|
||||
FROM repairs AS repair
|
||||
JOIN repair_events AS event
|
||||
ON event.repair_id = repair.id
|
||||
AND event.event_type = 'REPAIR_STARTED'
|
||||
WHERE repair.miner_id = miner.id
|
||||
AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
|
||||
) AS history ON TRUE
|
||||
WHERE miner.lifecycle_status = 'in_repair'
|
||||
AND history.started_event_count <> 1;
|
||||
|
||||
IF invalid_miner_ids IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = format(
|
||||
'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event',
|
||||
invalid_miner_ids
|
||||
),
|
||||
HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
f"""
|
||||
UPDATE miners AS miner
|
||||
SET lifecycle_status = history.from_lifecycle,
|
||||
updated_at = now()
|
||||
FROM (
|
||||
SELECT
|
||||
repair.miner_id,
|
||||
event.payload ->> 'from_lifecycle' AS from_lifecycle
|
||||
FROM repairs AS repair
|
||||
JOIN repair_events AS event
|
||||
ON event.repair_id = repair.id
|
||||
AND event.event_type = 'REPAIR_STARTED'
|
||||
WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
|
||||
) AS history
|
||||
WHERE miner.id = history.miner_id
|
||||
AND miner.lifecycle_status = 'in_repair'
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_index("idx_repair_events_created_by", table_name="repair_events")
|
||||
op.drop_index("idx_repair_events_repair_created", table_name="repair_events")
|
||||
op.drop_table("repair_events")
|
||||
|
||||
op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs")
|
||||
op.drop_index("idx_repairs_completed_by", table_name="repairs")
|
||||
op.drop_index("idx_repairs_opened_by", table_name="repairs")
|
||||
op.drop_index("idx_repairs_miner_opened_at", table_name="repairs")
|
||||
op.drop_table("repairs")
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id = (
|
||||
SELECT id FROM permissions WHERE key = 'repairs.create'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM permissions WHERE key = 'repairs.create'"
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_miners_lifecycle_status",
|
||||
"miners",
|
||||
"lifecycle_status IN ('active', 'spare', 'retired')",
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add repair timeline read permission
|
||||
|
||||
Revision ID: 0011_repair_timeline
|
||||
Revises: 0010_begin_repair
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0011_repair_timeline"
|
||||
down_revision: Union[str, None] = "0010_begin_repair"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.read', 'View repair details and timeline events')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = 'repairs.read'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician', 'Read Only')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id = (
|
||||
SELECT id FROM permissions WHERE key = 'repairs.read'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM permissions WHERE key = 'repairs.read'"
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""add return to service workflow
|
||||
|
||||
Revision ID: 0012_return_to_service
|
||||
Revises: 0011_repair_timeline
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0012_return_to_service"
|
||||
down_revision: Union[str, None] = "0011_repair_timeline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
REPAIR_REASONS = (
|
||||
"HARDWARE_FAILURE",
|
||||
"LOW_HASHRATE",
|
||||
"CONTROL_BOARD",
|
||||
"HASHBOARD",
|
||||
"PSU",
|
||||
"FAN",
|
||||
"OVERHEATING",
|
||||
"NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE",
|
||||
"INSPECTION",
|
||||
"OTHER",
|
||||
)
|
||||
|
||||
|
||||
def _quoted(values: Sequence[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
def _create_event_constraints() -> None:
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', "
|
||||
"'REPAIR_COMPLETED')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"("
|
||||
"event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||
") OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> ''"
|
||||
") OR ("
|
||||
"event_type = 'REPAIR_COMPLETED' "
|
||||
"AND payload ?& ARRAY["
|
||||
"'previous_miner_lifecycle', 'resulting_miner_lifecycle'"
|
||||
"] "
|
||||
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||
"AND payload ->> 'resulting_miner_lifecycle' "
|
||||
"IN ('active', 'spare') "
|
||||
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||
"AND (NOT payload ? 'completion_summary' OR ("
|
||||
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||
"AND char_length(payload ->> 'completion_summary') <= 4000"
|
||||
"))"
|
||||
")",
|
||||
)
|
||||
|
||||
|
||||
def _create_previous_event_constraints() -> None:
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"("
|
||||
"event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair'"
|
||||
") OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> ''"
|
||||
")",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.complete', 'Return repaired miners to service')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = 'repairs.complete'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"repairs",
|
||||
sa.Column("completion_summary", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"repairs",
|
||||
sa.Column("resulting_lifecycle", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repairs_resulting_lifecycle",
|
||||
"repairs",
|
||||
"resulting_lifecycle IS NULL "
|
||||
"OR resulting_lifecycle IN ('active', 'spare')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repairs_completion_summary",
|
||||
"repairs",
|
||||
"completion_summary IS NULL OR ("
|
||||
"trim(completion_summary) <> '' "
|
||||
"AND char_length(completion_summary) <= 4000"
|
||||
")",
|
||||
)
|
||||
# NOT VALID preserves truthful legacy COMPLETED rows whose destination was
|
||||
# never recorded, while PostgreSQL still enforces the check for every new
|
||||
# or updated row after this migration.
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
ALTER TABLE repairs
|
||||
ADD CONSTRAINT ck_repairs_completion_metadata_state
|
||||
CHECK (
|
||||
(
|
||||
status = 'COMPLETED'
|
||||
AND resulting_lifecycle IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
status <> 'COMPLETED'
|
||||
AND completion_summary IS NULL
|
||||
AND resulting_lifecycle IS NULL
|
||||
)
|
||||
)
|
||||
NOT VALID
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
_create_event_constraints()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
completion_count BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO completion_count
|
||||
FROM repairs
|
||||
WHERE resulting_lifecycle IS NOT NULL
|
||||
OR completion_summary IS NOT NULL;
|
||||
|
||||
completion_count := completion_count + (
|
||||
SELECT count(*)
|
||||
FROM repair_events
|
||||
WHERE event_type = 'REPAIR_COMPLETED'
|
||||
);
|
||||
|
||||
IF completion_count > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = format(
|
||||
'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history',
|
||||
completion_count
|
||||
),
|
||||
HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
type_="check",
|
||||
)
|
||||
_create_previous_event_constraints()
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_repairs_completion_metadata_state",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repairs_completion_summary",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_repairs_resulting_lifecycle",
|
||||
"repairs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("repairs", "resulting_lifecycle")
|
||||
op.drop_column("repairs", "completion_summary")
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id = (
|
||||
SELECT id FROM permissions WHERE key = 'repairs.complete'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM permissions WHERE key = 'repairs.complete'"
|
||||
)
|
||||
@@ -0,0 +1,436 @@
|
||||
"""add control board replacement workflow
|
||||
|
||||
Revision ID: 0013_control_board_replacement
|
||||
Revises: 0012_return_to_service
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0013_control_board_replacement"
|
||||
down_revision: Union[str, None] = "0012_return_to_service"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
REPAIR_REASONS = (
|
||||
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
|
||||
"PSU", "FAN", "OVERHEATING", "NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
|
||||
)
|
||||
REPLACEMENT_REASONS = (
|
||||
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
|
||||
"temporary_donor", "unknown",
|
||||
)
|
||||
DISPOSITIONS = (
|
||||
"repair_inventory", "sent_for_repair", "scrapped",
|
||||
"retained_for_diagnosis", "returned_to_vendor", "unknown",
|
||||
)
|
||||
|
||||
|
||||
def _quoted(values: Sequence[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
def _create_event_constraints(include_replacement: bool) -> None:
|
||||
event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"]
|
||||
if include_replacement:
|
||||
event_types.append("CONTROL_BOARD_REPLACED")
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
f"event_type IN ({_quoted(event_types)})",
|
||||
)
|
||||
replacement_clause = ""
|
||||
if include_replacement:
|
||||
replacement_clause = (
|
||||
") OR (event_type = 'CONTROL_BOARD_REPLACED' "
|
||||
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
|
||||
"'old_control_board_serial', 'new_control_board_serial', "
|
||||
"'replacement_reason', 'removed_board_disposition'] "
|
||||
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
|
||||
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
|
||||
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
||||
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
|
||||
"NOT IN ('000000000000', 'ffffffffffff') "
|
||||
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
|
||||
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
|
||||
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
|
||||
"AND trim(payload ->> 'old_control_board_serial') <> '' "
|
||||
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
|
||||
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
|
||||
"AND trim(payload ->> 'new_control_board_serial') <> '' "
|
||||
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
|
||||
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
|
||||
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
|
||||
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'notes') = 'string' "
|
||||
"AND trim(payload ->> 'notes') <> '' "
|
||||
"AND char_length(payload ->> 'notes') <= 4000))"
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"(event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> '') OR ("
|
||||
"event_type = 'REPAIR_COMPLETED' "
|
||||
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
|
||||
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
|
||||
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||
"AND (NOT payload ? 'completion_summary' OR ("
|
||||
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||
"AND char_length(payload ->> 'completion_summary') <= 4000))"
|
||||
f"{replacement_clause})",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE conflicts TEXT;
|
||||
BEGIN
|
||||
WITH identities AS (
|
||||
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac,
|
||||
miner_id
|
||||
FROM miner_network_identities
|
||||
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
|
||||
UNION ALL
|
||||
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id
|
||||
FROM miners
|
||||
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12
|
||||
), duplicate_owners AS (
|
||||
SELECT mac
|
||||
FROM identities
|
||||
GROUP BY mac
|
||||
HAVING count(DISTINCT miner_id) > 1
|
||||
)
|
||||
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners;
|
||||
IF conflicts IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts,
|
||||
HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.';
|
||||
END IF;
|
||||
|
||||
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts
|
||||
FROM (
|
||||
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac
|
||||
FROM miner_network_identities
|
||||
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
|
||||
GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
|
||||
HAVING count(*) > 1
|
||||
) AS duplicate_identity_mac;
|
||||
IF conflicts IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts,
|
||||
HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.';
|
||||
END IF;
|
||||
|
||||
SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text)
|
||||
INTO conflicts
|
||||
FROM (
|
||||
SELECT miner_id FROM miner_network_identities
|
||||
WHERE is_current GROUP BY miner_id HAVING count(*) > 1
|
||||
) AS duplicate_current;
|
||||
IF conflicts IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts,
|
||||
HINT = 'Choose one current network identity for each listed miner before upgrading.';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('miners.replace_control_board', 'Replace miner control boards during repair')
|
||||
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id FROM roles
|
||||
JOIN permissions ON permissions.key = 'miners.replace_control_board'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"miner_network_identities",
|
||||
sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.alter_column("miner_network_identities", "first_seen_at", nullable=True)
|
||||
op.alter_column("miner_network_identities", "last_seen_at", nullable=True)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac
|
||||
ON miner_network_identities (
|
||||
regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
|
||||
)
|
||||
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12;
|
||||
CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner
|
||||
ON miner_network_identities (miner_id) WHERE is_current;
|
||||
CREATE UNIQUE INDEX uq_miners_current_normalized_mac
|
||||
ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'))
|
||||
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12;
|
||||
ALTER TABLE miner_network_identities
|
||||
ADD CONSTRAINT ck_miner_network_identities_mac_valid
|
||||
CHECK (
|
||||
source NOT IN ('control_board_replacement', 'legacy_projection') OR (
|
||||
mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$'
|
||||
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff')
|
||||
AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0
|
||||
)
|
||||
) NOT VALID;
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"miner_mac_ownerships",
|
||||
sa.Column("normalized_mac", sa.Text(), primary_key=True),
|
||||
sa.Column(
|
||||
"miner_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False,
|
||||
),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"claimed_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"normalized_mac ~ '^[0-9a-f]{12}$' "
|
||||
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
|
||||
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0",
|
||||
name="ck_miner_mac_ownerships_valid",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"])
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id)
|
||||
SELECT mac, min(miner_id::text)::uuid FROM (
|
||||
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id
|
||||
FROM miner_network_identities
|
||||
UNION ALL
|
||||
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners
|
||||
) AS known
|
||||
WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff')
|
||||
AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0
|
||||
GROUP BY mac;
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"miner_control_board_installations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("record_origin", sa.Text(), nullable=False),
|
||||
sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("mac_address", sa.Text(), nullable=True),
|
||||
sa.Column("normalized_mac", sa.Text(), nullable=True),
|
||||
sa.Column("board_serial", sa.Text(), nullable=True),
|
||||
sa.Column("board_model", sa.Text(), nullable=True),
|
||||
sa.Column("board_revision", sa.Text(), nullable=True),
|
||||
sa.Column("firmware_version", sa.Text(), nullable=True),
|
||||
sa.Column("replacement_reason", sa.Text(), nullable=True),
|
||||
sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("removed_disposition", sa.Text(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"),
|
||||
sa.CheckConstraint(
|
||||
"normalized_mac IS NULL OR ("
|
||||
"mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
||||
"AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac "
|
||||
"AND normalized_mac ~ '^[0-9a-f]{12}$' "
|
||||
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
|
||||
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)",
|
||||
name="ck_control_board_installations_mac_normalized",
|
||||
),
|
||||
sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"),
|
||||
sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"),
|
||||
sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"),
|
||||
sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"),
|
||||
sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"),
|
||||
sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"),
|
||||
sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"),
|
||||
sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"),
|
||||
sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"),
|
||||
sa.CheckConstraint(
|
||||
"(record_origin = 'legacy_projection' AND installed_repair_id IS NULL "
|
||||
"AND installed_at IS NULL AND installed_by IS NULL "
|
||||
"AND replacement_reason IS NULL) OR "
|
||||
"(record_origin = 'replacement' AND installed_repair_id IS NOT NULL "
|
||||
"AND installed_at IS NOT NULL AND installed_by IS NOT NULL "
|
||||
"AND replacement_reason IS NOT NULL)",
|
||||
name="ck_control_board_installations_install_state",
|
||||
),
|
||||
)
|
||||
op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL"))
|
||||
op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL"))
|
||||
op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"])
|
||||
op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"])
|
||||
op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"])
|
||||
op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"])
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb;
|
||||
BEGIN
|
||||
row_data := to_jsonb(NEW);
|
||||
mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g');
|
||||
target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
|
||||
IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF;
|
||||
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at)
|
||||
VALUES (mac, target_owner, now())
|
||||
ON CONFLICT (normalized_mac) DO NOTHING;
|
||||
SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE;
|
||||
IF existing_owner <> (target_owner) THEN
|
||||
RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
|
||||
CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
|
||||
CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow';
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership();
|
||||
|
||||
|
||||
CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Network identity history is immutable';
|
||||
END IF;
|
||||
IF NEW.miner_id <> OLD.miner_id
|
||||
OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g')
|
||||
<> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN
|
||||
RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history();
|
||||
|
||||
|
||||
CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE target_miner uuid; mismatch boolean; row_data jsonb;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
row_data := to_jsonb(OLD);
|
||||
ELSE
|
||||
row_data := to_jsonb(NEW);
|
||||
END IF;
|
||||
target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM miner_control_board_installations
|
||||
WHERE miner_id = target_miner
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM miner_control_board_installations board
|
||||
JOIN miners miner ON miner.id = board.miner_id
|
||||
JOIN miner_network_identities identity
|
||||
ON identity.miner_id = board.miner_id AND identity.is_current
|
||||
WHERE board.miner_id = target_miner AND board.removed_at IS NULL
|
||||
AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
|
||||
AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial
|
||||
AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
|
||||
) INTO mismatch;
|
||||
IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||
CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||
CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||
|
||||
CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF;
|
||||
IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF;
|
||||
IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN
|
||||
RAISE EXCEPTION 'Control-board installation identity is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history();
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||
_create_event_constraints(True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DO $$ DECLARE history_count bigint; BEGIN
|
||||
SELECT count(*) INTO history_count FROM miner_control_board_installations;
|
||||
history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED');
|
||||
IF history_count > 0 THEN
|
||||
RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN
|
||||
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL)
|
||||
OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN
|
||||
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||
_create_event_constraints(False)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations;
|
||||
DROP FUNCTION minerops_protect_control_board_history();
|
||||
DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations;
|
||||
DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities;
|
||||
DROP TRIGGER trg_miners_check_current_board_projection ON miners;
|
||||
DROP FUNCTION minerops_check_current_board_projection();
|
||||
DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships;
|
||||
DROP FUNCTION minerops_protect_mac_ownership();
|
||||
DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities;
|
||||
DROP FUNCTION minerops_protect_network_identity_history();
|
||||
DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities;
|
||||
DROP TRIGGER trg_miners_claim_mac_owner ON miners;
|
||||
DROP FUNCTION minerops_claim_mac_owner();
|
||||
"""
|
||||
)
|
||||
op.drop_table("miner_control_board_installations")
|
||||
op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships")
|
||||
op.drop_table("miner_mac_ownerships")
|
||||
op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check")
|
||||
op.drop_index("uq_miners_current_normalized_mac", table_name="miners")
|
||||
op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities")
|
||||
op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities")
|
||||
op.alter_column("miner_network_identities", "last_seen_at", nullable=False)
|
||||
op.alter_column("miner_network_identities", "first_seen_at", nullable=False)
|
||||
op.drop_column("miner_network_identities", "associated_at")
|
||||
connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')")
|
||||
connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'")
|
||||
@@ -0,0 +1,590 @@
|
||||
"""add repair parts tracking
|
||||
|
||||
Revision ID: 0014_repair_parts_tracking
|
||||
Revises: 0013_control_board_replacement
|
||||
Create Date: 2026-08-02
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0014_repair_parts_tracking"
|
||||
down_revision: Union[str, None] = "0013_control_board_replacement"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
COMPONENT_TYPES = ("control_board", "hashboard", "power_supply", "fan")
|
||||
CONSUMABLE_UNITS = ("each", "gram", "millilitre", "metre", "centimetre")
|
||||
REPAIR_REASONS = (
|
||||
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
|
||||
"PSU", "FAN", "OVERHEATING", "NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
|
||||
)
|
||||
REPLACEMENT_REASONS = (
|
||||
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
|
||||
"temporary_donor", "unknown",
|
||||
)
|
||||
DISPOSITIONS = (
|
||||
"repair_inventory", "sent_for_repair", "scrapped",
|
||||
"retained_for_diagnosis", "returned_to_vendor", "unknown",
|
||||
)
|
||||
|
||||
|
||||
def _quoted(values: Sequence[str]) -> str:
|
||||
return ", ".join(f"'{value}'" for value in values)
|
||||
|
||||
|
||||
def _create_event_constraints(include_parts: bool) -> None:
|
||||
event_types = [
|
||||
"REPAIR_STARTED",
|
||||
"INITIAL_NOTE",
|
||||
"REPAIR_COMPLETED",
|
||||
"CONTROL_BOARD_REPLACED",
|
||||
]
|
||||
if include_parts:
|
||||
event_types.extend(("REPAIR_PART_RECORDED", "REPAIR_PART_VOIDED"))
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_type",
|
||||
"repair_events",
|
||||
f"event_type IN ({_quoted(event_types)})",
|
||||
)
|
||||
|
||||
parts_clause = ""
|
||||
if include_parts:
|
||||
parts_clause = (
|
||||
") OR (event_type = 'REPAIR_PART_RECORDED' "
|
||||
"AND payload ?& ARRAY['repair_part_id', 'entry_kind', 'notes'] "
|
||||
"AND payload ->> 'repair_part_id' ~ "
|
||||
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
||||
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
||||
"AND (jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'notes') = 'string' "
|
||||
"AND trim(payload ->> 'notes') <> '' "
|
||||
"AND char_length(payload ->> 'notes') <= 4000)) "
|
||||
"AND ((payload ->> 'entry_kind' = 'consumable' "
|
||||
"AND payload ?& ARRAY['description', 'quantity', 'unit'] "
|
||||
"AND NOT payload ? 'component_id' "
|
||||
"AND jsonb_typeof(payload -> 'description') = 'string' "
|
||||
"AND trim(payload ->> 'description') <> '' "
|
||||
"AND char_length(payload ->> 'description') <= 255 "
|
||||
"AND jsonb_typeof(payload -> 'quantity') = 'string' "
|
||||
"AND payload ->> 'quantity' ~ '^[0-9]{1,9}(\\.[0-9]{1,3})?$' "
|
||||
"AND (payload ->> 'quantity')::numeric > 0 "
|
||||
f"AND payload ->> 'unit' IN ({_quoted(CONSUMABLE_UNITS)}) "
|
||||
"AND (payload ->> 'unit' <> 'each' OR "
|
||||
"(payload ->> 'quantity')::numeric = "
|
||||
"trunc((payload ->> 'quantity')::numeric))) OR ("
|
||||
"payload ->> 'entry_kind' = 'tracked_component' "
|
||||
"AND payload ?& ARRAY['component_id', 'component_type', "
|
||||
"'component_asset_id', 'component_manufacturer', "
|
||||
"'component_model', 'component_manufacturer_serial'] "
|
||||
"AND NOT payload ? 'description' "
|
||||
"AND NOT payload ? 'quantity' "
|
||||
"AND NOT payload ? 'unit' "
|
||||
"AND payload ->> 'component_id' ~ "
|
||||
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
||||
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
||||
f"AND payload ->> 'component_type' IN ({_quoted(COMPONENT_TYPES)}) "
|
||||
"AND jsonb_typeof(payload -> 'component_asset_id') IN ('string', 'null') "
|
||||
"AND jsonb_typeof(payload -> 'component_manufacturer') IN ('string', 'null') "
|
||||
"AND jsonb_typeof(payload -> 'component_model') IN ('string', 'null') "
|
||||
"AND jsonb_typeof(payload -> 'component_manufacturer_serial') "
|
||||
"IN ('string', 'null'))))"
|
||||
" OR (event_type = 'REPAIR_PART_VOIDED' "
|
||||
"AND payload ?& ARRAY['repair_part_id', 'reason'] "
|
||||
"AND payload ->> 'repair_part_id' ~ "
|
||||
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
|
||||
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
|
||||
"AND jsonb_typeof(payload -> 'reason') = 'string' "
|
||||
"AND trim(payload ->> 'reason') <> '' "
|
||||
"AND char_length(payload ->> 'reason') <= 1000"
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_repair_events_payload_shape",
|
||||
"repair_events",
|
||||
"(event_type = 'REPAIR_STARTED' "
|
||||
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
|
||||
"event_type = 'INITIAL_NOTE' "
|
||||
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||
"AND trim(payload ->> 'note') <> '') OR ("
|
||||
"event_type = 'REPAIR_COMPLETED' "
|
||||
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
|
||||
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
|
||||
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||
"AND (NOT payload ? 'completion_summary' OR ("
|
||||
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||
"AND char_length(payload ->> 'completion_summary') <= 4000))) OR ("
|
||||
"event_type = 'CONTROL_BOARD_REPLACED' "
|
||||
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
|
||||
"'old_control_board_serial', 'new_control_board_serial', "
|
||||
"'replacement_reason', 'removed_board_disposition'] "
|
||||
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
|
||||
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
|
||||
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
||||
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
|
||||
"NOT IN ('000000000000', 'ffffffffffff') "
|
||||
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
|
||||
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
|
||||
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
|
||||
"AND trim(payload ->> 'old_control_board_serial') <> '' "
|
||||
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
|
||||
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
|
||||
"AND trim(payload ->> 'new_control_board_serial') <> '' "
|
||||
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
|
||||
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
|
||||
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
|
||||
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
||||
"jsonb_typeof(payload -> 'notes') = 'string' "
|
||||
"AND trim(payload ->> 'notes') <> '' "
|
||||
"AND char_length(payload ->> 'notes') <= 4000))"
|
||||
f"{parts_clause})",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES
|
||||
('components.read', 'View tracked component identity'),
|
||||
('components.create', 'Register tracked component identity'),
|
||||
('repairs.parts.manage', 'Record and void repair-part usage')
|
||||
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key IN (
|
||||
'components.read', 'components.create', 'repairs.parts.manage'
|
||||
)
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = 'components.read'
|
||||
WHERE roles.name = 'Read Only'
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"components",
|
||||
sa.Column(
|
||||
"id", postgresql.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("component_type", sa.Text(), nullable=False),
|
||||
sa.Column("asset_id", sa.Text(), nullable=True),
|
||||
sa.Column("manufacturer", sa.Text(), nullable=True),
|
||||
sa.Column("model", sa.Text(), nullable=True),
|
||||
sa.Column("manufacturer_serial", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"component_type IN ({_quoted(COMPONENT_TYPES)})",
|
||||
name="ck_components_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"asset_id IS NULL OR (trim(asset_id) <> '' "
|
||||
"AND asset_id = trim(asset_id) AND char_length(asset_id) <= 255)",
|
||||
name="ck_components_asset_id",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"manufacturer IS NULL OR (trim(manufacturer) <> '' "
|
||||
"AND manufacturer = trim(manufacturer) "
|
||||
"AND char_length(manufacturer) <= 255)",
|
||||
name="ck_components_manufacturer",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"model IS NULL OR (trim(model) <> '' AND model = trim(model) "
|
||||
"AND char_length(model) <= 255)",
|
||||
name="ck_components_model",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"manufacturer_serial IS NULL OR (trim(manufacturer_serial) <> '' "
|
||||
"AND manufacturer_serial = trim(manufacturer_serial) "
|
||||
"AND char_length(manufacturer_serial) <= 255)",
|
||||
name="ck_components_manufacturer_serial",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_components_created_by", "components", ["created_by"])
|
||||
op.create_index("idx_components_type", "components", ["component_type"])
|
||||
op.create_index(
|
||||
"uq_components_normalized_asset_id",
|
||||
"components",
|
||||
[sa.text("lower(trim(asset_id))")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("asset_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"repair_parts",
|
||||
sa.Column(
|
||||
"id", postgresql.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"repair_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False,
|
||||
),
|
||||
sa.Column("consumable_description", sa.Text(), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(precision=12, scale=3), nullable=True),
|
||||
sa.Column("unit", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"component_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("components.id", ondelete="RESTRICT"), nullable=True,
|
||||
),
|
||||
sa.Column("component_type_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("component_asset_id_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("component_manufacturer_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("component_model_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("component_manufacturer_serial_snapshot", sa.Text(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"recorded_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"recorded_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"recorded_event_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"voided_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
|
||||
),
|
||||
sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("void_reason", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"void_event_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"((consumable_description IS NOT NULL AND quantity IS NOT NULL "
|
||||
"AND unit IS NOT NULL AND component_id IS NULL "
|
||||
"AND component_type_snapshot IS NULL "
|
||||
"AND component_asset_id_snapshot IS NULL "
|
||||
"AND component_manufacturer_snapshot IS NULL "
|
||||
"AND component_model_snapshot IS NULL "
|
||||
"AND component_manufacturer_serial_snapshot IS NULL) OR "
|
||||
"(consumable_description IS NULL AND quantity IS NULL "
|
||||
"AND unit IS NULL AND component_id IS NOT NULL "
|
||||
"AND component_type_snapshot IS NOT NULL))",
|
||||
name="ck_repair_parts_entry_xor",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"quantity IS NULL OR quantity > 0",
|
||||
name="ck_repair_parts_quantity_positive",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"unit IS NULL OR unit <> 'each' OR quantity = trunc(quantity)",
|
||||
name="ck_repair_parts_each_whole",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"unit IS NULL OR unit IN ({_quoted(CONSUMABLE_UNITS)})",
|
||||
name="ck_repair_parts_unit",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
f"component_type_snapshot IS NULL OR "
|
||||
f"component_type_snapshot IN ({_quoted(COMPONENT_TYPES)})",
|
||||
name="ck_repair_parts_component_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"consumable_description IS NULL OR ("
|
||||
"consumable_description = trim(consumable_description) "
|
||||
"AND trim(consumable_description) <> '' "
|
||||
"AND char_length(consumable_description) <= 255)",
|
||||
name="ck_repair_parts_description",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"component_asset_id_snapshot IS NULL OR ("
|
||||
"component_asset_id_snapshot = trim(component_asset_id_snapshot) "
|
||||
"AND trim(component_asset_id_snapshot) <> '' "
|
||||
"AND char_length(component_asset_id_snapshot) <= 255)",
|
||||
name="ck_repair_parts_asset_snapshot",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"component_manufacturer_snapshot IS NULL OR ("
|
||||
"component_manufacturer_snapshot = trim(component_manufacturer_snapshot) "
|
||||
"AND trim(component_manufacturer_snapshot) <> '' "
|
||||
"AND char_length(component_manufacturer_snapshot) <= 255)",
|
||||
name="ck_repair_parts_manufacturer_snapshot",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"component_model_snapshot IS NULL OR ("
|
||||
"component_model_snapshot = trim(component_model_snapshot) "
|
||||
"AND trim(component_model_snapshot) <> '' "
|
||||
"AND char_length(component_model_snapshot) <= 255)",
|
||||
name="ck_repair_parts_model_snapshot",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"component_manufacturer_serial_snapshot IS NULL OR ("
|
||||
"component_manufacturer_serial_snapshot = "
|
||||
"trim(component_manufacturer_serial_snapshot) "
|
||||
"AND trim(component_manufacturer_serial_snapshot) <> '' "
|
||||
"AND char_length(component_manufacturer_serial_snapshot) <= 255)",
|
||||
name="ck_repair_parts_serial_snapshot",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"notes IS NULL OR (notes = trim(notes) AND trim(notes) <> '' "
|
||||
"AND char_length(notes) <= 4000)",
|
||||
name="ck_repair_parts_notes",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(voided_by IS NULL AND voided_at IS NULL AND void_reason IS NULL "
|
||||
"AND void_event_id IS NULL) OR "
|
||||
"(voided_by IS NOT NULL AND voided_at IS NOT NULL "
|
||||
"AND void_reason IS NOT NULL AND void_event_id IS NOT NULL)",
|
||||
name="ck_repair_parts_void_state",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"void_reason IS NULL OR (void_reason = trim(void_reason) "
|
||||
"AND trim(void_reason) <> '' AND char_length(void_reason) <= 1000)",
|
||||
name="ck_repair_parts_void_reason",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"voided_at IS NULL OR voided_at >= recorded_at",
|
||||
name="ck_repair_parts_void_chronology",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_repair_parts_repair",
|
||||
"repair_parts",
|
||||
["repair_id", "recorded_event_id", "id"],
|
||||
)
|
||||
op.create_index("idx_repair_parts_component", "repair_parts", ["component_id"])
|
||||
op.create_index("idx_repair_parts_recorded_by", "repair_parts", ["recorded_by"])
|
||||
op.create_index("idx_repair_parts_voided_by", "repair_parts", ["voided_by"])
|
||||
op.create_index(
|
||||
"uq_repair_parts_recorded_event",
|
||||
"repair_parts",
|
||||
["recorded_event_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_repair_parts_void_event",
|
||||
"repair_parts",
|
||||
["void_event_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("void_event_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||
_create_event_constraints(True)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION minerops_protect_component_identity()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Durable component identity cannot be deleted';
|
||||
END IF;
|
||||
IF NEW.id <> OLD.id
|
||||
OR NEW.component_type <> OLD.component_type
|
||||
OR NEW.created_at <> OLD.created_at
|
||||
OR NEW.created_by <> OLD.created_by THEN
|
||||
RAISE EXCEPTION 'Component identity, type, and creation provenance are immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_component_identity
|
||||
BEFORE UPDATE OR DELETE ON components
|
||||
FOR EACH ROW EXECUTE FUNCTION minerops_protect_component_identity();
|
||||
|
||||
CREATE FUNCTION minerops_protect_repair_event_history()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Repair event history is immutable';
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_repair_event_history
|
||||
BEFORE UPDATE OR DELETE ON repair_events
|
||||
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_event_history();
|
||||
|
||||
CREATE FUNCTION minerops_protect_repair_part_history()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'Repair-part history is immutable';
|
||||
END IF;
|
||||
IF NEW.id <> OLD.id
|
||||
OR NEW.repair_id <> OLD.repair_id
|
||||
OR NEW.consumable_description IS DISTINCT FROM OLD.consumable_description
|
||||
OR NEW.quantity IS DISTINCT FROM OLD.quantity
|
||||
OR NEW.unit IS DISTINCT FROM OLD.unit
|
||||
OR NEW.component_id IS DISTINCT FROM OLD.component_id
|
||||
OR NEW.component_type_snapshot IS DISTINCT FROM OLD.component_type_snapshot
|
||||
OR NEW.component_asset_id_snapshot IS DISTINCT FROM OLD.component_asset_id_snapshot
|
||||
OR NEW.component_manufacturer_snapshot IS DISTINCT FROM OLD.component_manufacturer_snapshot
|
||||
OR NEW.component_model_snapshot IS DISTINCT FROM OLD.component_model_snapshot
|
||||
OR NEW.component_manufacturer_serial_snapshot IS DISTINCT FROM OLD.component_manufacturer_serial_snapshot
|
||||
OR NEW.notes IS DISTINCT FROM OLD.notes
|
||||
OR NEW.recorded_by <> OLD.recorded_by
|
||||
OR NEW.recorded_at <> OLD.recorded_at
|
||||
OR NEW.recorded_event_id <> OLD.recorded_event_id THEN
|
||||
RAISE EXCEPTION 'Original repair-part fields are immutable';
|
||||
END IF;
|
||||
IF OLD.voided_by IS NOT NULL OR OLD.voided_at IS NOT NULL
|
||||
OR OLD.void_reason IS NOT NULL OR OLD.void_event_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Voided repair-part history is immutable';
|
||||
END IF;
|
||||
IF NEW.voided_by IS NULL OR NEW.voided_at IS NULL
|
||||
OR NEW.void_reason IS NULL OR NEW.void_event_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Repair-part void metadata must be written atomically';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_protect_repair_part_history
|
||||
BEFORE UPDATE OR DELETE ON repair_parts
|
||||
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_part_history();
|
||||
|
||||
CREATE FUNCTION minerops_validate_repair_part_history()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE recorded repair_events%%ROWTYPE;
|
||||
DECLARE voided repair_events%%ROWTYPE;
|
||||
DECLARE component components%%ROWTYPE;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' AND (
|
||||
NEW.voided_by IS NOT NULL OR NEW.voided_at IS NOT NULL
|
||||
OR NEW.void_reason IS NOT NULL OR NEW.void_event_id IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'A repair part must be recorded before it can be voided';
|
||||
END IF;
|
||||
SELECT * INTO recorded FROM repair_events WHERE id = NEW.recorded_event_id;
|
||||
IF recorded.id IS NULL
|
||||
OR recorded.repair_id <> NEW.repair_id
|
||||
OR recorded.event_type <> 'REPAIR_PART_RECORDED'
|
||||
OR recorded.created_by <> NEW.recorded_by
|
||||
OR recorded.created_at <> NEW.recorded_at
|
||||
OR recorded.payload ->> 'repair_part_id' <> NEW.id::text THEN
|
||||
RAISE EXCEPTION 'Recorded repair-part event does not match its entry';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' AND NEW.component_id IS NOT NULL THEN
|
||||
SELECT * INTO component FROM components WHERE id = NEW.component_id;
|
||||
IF component.id IS NULL
|
||||
OR component.component_type <> NEW.component_type_snapshot
|
||||
OR component.asset_id IS DISTINCT FROM NEW.component_asset_id_snapshot
|
||||
OR component.manufacturer IS DISTINCT FROM NEW.component_manufacturer_snapshot
|
||||
OR component.model IS DISTINCT FROM NEW.component_model_snapshot
|
||||
OR component.manufacturer_serial IS DISTINCT FROM NEW.component_manufacturer_serial_snapshot THEN
|
||||
RAISE EXCEPTION 'Tracked-component snapshot does not match the component';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF NEW.void_event_id IS NOT NULL THEN
|
||||
SELECT * INTO voided FROM repair_events WHERE id = NEW.void_event_id;
|
||||
IF voided.id IS NULL
|
||||
OR voided.repair_id <> NEW.repair_id
|
||||
OR voided.event_type <> 'REPAIR_PART_VOIDED'
|
||||
OR voided.created_by <> NEW.voided_by
|
||||
OR voided.created_at <> NEW.voided_at
|
||||
OR voided.payload ->> 'repair_part_id' <> NEW.id::text
|
||||
OR voided.payload ->> 'reason' <> NEW.void_reason THEN
|
||||
RAISE EXCEPTION 'Voided repair-part event does not match its entry';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_validate_repair_part_history
|
||||
BEFORE INSERT OR UPDATE ON repair_parts
|
||||
FOR EACH ROW EXECUTE FUNCTION minerops_validate_repair_part_history();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE history_count bigint;
|
||||
BEGIN
|
||||
SELECT count(*) INTO history_count FROM components;
|
||||
history_count := history_count + (SELECT count(*) FROM repair_parts);
|
||||
history_count := history_count + (
|
||||
SELECT count(*) FROM repair_events
|
||||
WHERE event_type IN ('REPAIR_PART_RECORDED', 'REPAIR_PART_VOIDED')
|
||||
);
|
||||
IF history_count > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = format(
|
||||
'Cannot downgrade Repair Parts Tracking: %s component, repair-part, or event records would be destroyed',
|
||||
history_count
|
||||
),
|
||||
HINT = 'Preserve or explicitly migrate Task 005 identity and history before downgrading; MinerOps will not delete or invent component, actor, timestamp, snapshot, or repair-part history.';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DROP TRIGGER trg_validate_repair_part_history ON repair_parts;
|
||||
DROP FUNCTION minerops_validate_repair_part_history();
|
||||
DROP TRIGGER trg_protect_repair_part_history ON repair_parts;
|
||||
DROP FUNCTION minerops_protect_repair_part_history();
|
||||
DROP TRIGGER trg_protect_repair_event_history ON repair_events;
|
||||
DROP FUNCTION minerops_protect_repair_event_history();
|
||||
DROP TRIGGER trg_protect_component_identity ON components;
|
||||
DROP FUNCTION minerops_protect_component_identity();
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||
_create_event_constraints(False)
|
||||
|
||||
op.drop_index("uq_repair_parts_void_event", table_name="repair_parts")
|
||||
op.drop_index("uq_repair_parts_recorded_event", table_name="repair_parts")
|
||||
op.drop_index("idx_repair_parts_voided_by", table_name="repair_parts")
|
||||
op.drop_index("idx_repair_parts_recorded_by", table_name="repair_parts")
|
||||
op.drop_index("idx_repair_parts_component", table_name="repair_parts")
|
||||
op.drop_index("idx_repair_parts_repair", table_name="repair_parts")
|
||||
op.drop_table("repair_parts")
|
||||
|
||||
op.drop_index("uq_components_normalized_asset_id", table_name="components")
|
||||
op.drop_index("idx_components_type", table_name="components")
|
||||
op.drop_index("idx_components_created_by", table_name="components")
|
||||
op.drop_table("components")
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions
|
||||
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage')
|
||||
);
|
||||
DELETE FROM permissions
|
||||
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage');
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""add miner control foundation
|
||||
|
||||
Revision ID: 0015_miner_control_foundation
|
||||
Revises: 0014_repair_parts_tracking
|
||||
Create Date: 2026-08-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0015_miner_control_foundation"
|
||||
down_revision: Union[str, None] = "0014_repair_parts_tracking"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled")
|
||||
|
||||
|
||||
def _format_values(values: object) -> str:
|
||||
return ", ".join(str(value) for value in (values or []))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
unknown_statuses = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT array_agg(DISTINCT command_status ORDER BY command_status)
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')
|
||||
"""
|
||||
),
|
||||
).scalar()
|
||||
if unknown_statuses:
|
||||
raise RuntimeError(
|
||||
"Cannot upgrade to 0015: minerops_agent_command_queue contains "
|
||||
f"unsupported command_status values: {_format_values(unknown_statuses)}. "
|
||||
"Correct those rows to pending, claimed, completed, failed, or cancelled "
|
||||
"before retrying the migration."
|
||||
)
|
||||
|
||||
duplicate_live_miners = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT request_payload ->> 'miner_id' AS miner_id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_type = 'miner_control'
|
||||
AND command_status IN ('pending', 'claimed')
|
||||
AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL
|
||||
GROUP BY request_payload ->> 'miner_id'
|
||||
HAVING count(*) > 1
|
||||
ORDER BY request_payload ->> 'miner_id'
|
||||
"""
|
||||
)
|
||||
).scalars().all()
|
||||
if duplicate_live_miners:
|
||||
raise RuntimeError(
|
||||
"Cannot upgrade to 0015: more than one pending or claimed miner_control "
|
||||
"command exists for miner UUID(s): "
|
||||
f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate "
|
||||
"live commands before retrying the migration; no queue history was changed."
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
"sites",
|
||||
["site_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
["site_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_agent_command_queue_status",
|
||||
"minerops_agent_command_queue",
|
||||
"command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_agent_queue_live_miner_control",
|
||||
"minerops_agent_command_queue",
|
||||
[sa.text("(request_payload ->> 'miner_id')")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"command_type = 'miner_control' "
|
||||
"AND command_status IN ('pending', 'claimed')"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_agent_queue_miner_control_history",
|
||||
"minerops_agent_command_queue",
|
||||
[
|
||||
sa.text("(request_payload ->> 'miner_id')"),
|
||||
sa.text("created_at DESC"),
|
||||
sa.text("id DESC"),
|
||||
],
|
||||
postgresql_where=sa.text("command_type = 'miner_control'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_agent_queue_miner_control_history",
|
||||
table_name="minerops_agent_command_queue",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_queue_live_miner_control",
|
||||
table_name="minerops_agent_command_queue",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_agent_command_queue_status",
|
||||
"minerops_agent_command_queue",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "site_id")
|
||||
@@ -0,0 +1,365 @@
|
||||
"""add agent zones
|
||||
|
||||
Revision ID: 0016_agent_zones
|
||||
Revises: 0015_miner_control_foundation
|
||||
Create Date: 2026-08-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0016_agent_zones"
|
||||
down_revision: Union[str, None] = "0015_miner_control_foundation"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ZONE_NAME = "Default Zone"
|
||||
|
||||
|
||||
def _format_rows(rows: list[tuple]) -> str:
|
||||
return ", ".join("/".join(str(value) for value in row) for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_zones",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"site_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
|
||||
name="ck_agent_zones_name",
|
||||
),
|
||||
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
|
||||
)
|
||||
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
|
||||
op.create_index(
|
||||
"uq_agent_zones_active_site_name",
|
||||
"agent_zones",
|
||||
["site_id", sa.text("lower(name)")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_zone_network_ranges",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"agent_zone_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("network", postgresql.CIDR(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
"agent_zone_network_ranges",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"miners",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_miners_agent_zone_requires_site",
|
||||
"miners",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_miners_agent_zone_site",
|
||||
"miners",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
|
||||
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_snapshots",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_discoveries",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO agent_zones (site_id, name)
|
||||
SELECT sites.id, :default_name
|
||||
FROM sites
|
||||
ORDER BY sites.id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE miners AS miner
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE miner.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND miner.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE agent.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND agent.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_snapshots AS snapshot
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE snapshot.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND snapshot.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_discoveries AS discovery
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE discovery.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND discovery.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
"minerops_site_agents",
|
||||
["agent_zone_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
duplicate_site_agents = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id IS NOT NULL
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY site_id
|
||||
"""
|
||||
)
|
||||
).all()
|
||||
if duplicate_site_agents:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
|
||||
"assigned within Site(s): "
|
||||
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
|
||||
"until each Site has at most one Agent before retrying; no Agent Zone "
|
||||
"data was changed."
|
||||
)
|
||||
|
||||
non_reducible_zones = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM agent_zones
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
|
||||
ORDER BY site_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
range_count = connection.execute(
|
||||
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
|
||||
).scalar_one()
|
||||
if non_reducible_zones or range_count:
|
||||
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
|
||||
f"represented by the one-Agent-per-Site model ({detail}). Remove "
|
||||
"additional/custom Zones and Zone network ranges before retrying; no "
|
||||
"Agent Zone data was changed."
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
["site_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
table_name="minerops_agent_discoveries",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
table_name="minerops_agent_snapshots",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
|
||||
|
||||
op.drop_constraint(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "agent_zone_id")
|
||||
|
||||
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
|
||||
op.drop_constraint(
|
||||
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_miners_agent_zone_requires_site", "miners", type_="check"
|
||||
)
|
||||
op.drop_column("miners", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
table_name="agent_zone_network_ranges",
|
||||
)
|
||||
op.drop_table("agent_zone_network_ranges")
|
||||
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
|
||||
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
|
||||
op.drop_table("agent_zones")
|
||||
@@ -0,0 +1,333 @@
|
||||
"""add secure Agent enrollment and per-Agent credential storage
|
||||
|
||||
Revision ID: 0017_agent_enrollment
|
||||
Revises: 0016_agent_zones
|
||||
Create Date: 2026-08-26
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0017_agent_enrollment"
|
||||
down_revision: Union[str, None] = "0016_agent_zones"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Per-Agent credentials are additive. Existing Agents retain NULL credential
|
||||
# fields and therefore remain eligible for the temporary legacy shared-secret
|
||||
# authentication path until explicitly migrated.
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("agent_secret_hash", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"credential_created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"credential_revoked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_display_name",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
display_name IS NULL
|
||||
OR (
|
||||
btrim(display_name) <> ''
|
||||
AND char_length(display_name) <= 120
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_secret_hash",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
agent_secret_hash IS NULL
|
||||
OR agent_secret_hash ~ '^[0-9a-f]{64}$'
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_pair",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
(
|
||||
agent_secret_hash IS NULL
|
||||
AND credential_created_at IS NULL
|
||||
)
|
||||
OR (
|
||||
agent_secret_hash IS NOT NULL
|
||||
AND credential_created_at IS NOT NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_revoked_time",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
credential_revoked_at IS NULL
|
||||
OR (
|
||||
credential_created_at IS NOT NULL
|
||||
AND credential_revoked_at >= credential_created_at
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_agent_secret_hash",
|
||||
"minerops_site_agents",
|
||||
["agent_secret_hash"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("agent_secret_hash IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_enrollments",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"site_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"agent_zone_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("display_name", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"expires_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"used_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"revoked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("enrolled_agent_id", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["agent_zone_id", "site_id"],
|
||||
["agent_zones.id", "agent_zones.site_id"],
|
||||
name="fk_agent_enrollments_zone_site",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["enrolled_agent_id"],
|
||||
["minerops_site_agents.agent_id"],
|
||||
name="fk_agent_enrollments_agent",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"token_hash ~ '^[0-9a-f]{64}$'",
|
||||
name="ck_agent_enrollments_token_hash",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"btrim(display_name) <> '' AND char_length(display_name) <= 120",
|
||||
name="ck_agent_enrollments_display_name",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expires_at > created_at",
|
||||
name="ck_agent_enrollments_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"used_at IS NULL OR used_at >= created_at",
|
||||
name="ck_agent_enrollments_used_time",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"used_at IS NULL OR used_at <= expires_at",
|
||||
name="ck_agent_enrollments_used_before_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"revoked_at IS NULL OR revoked_at >= created_at",
|
||||
name="ck_agent_enrollments_revoked_time",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"NOT (used_at IS NOT NULL AND revoked_at IS NOT NULL)",
|
||||
name="ck_agent_enrollments_terminal_state",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"""
|
||||
(
|
||||
used_at IS NULL
|
||||
AND enrolled_agent_id IS NULL
|
||||
)
|
||||
OR (
|
||||
used_at IS NOT NULL
|
||||
AND enrolled_agent_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
name="ck_agent_enrollments_agent_when_used",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_token_hash",
|
||||
"agent_enrollments",
|
||||
["token_hash"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# PostgreSQL cannot use now() in a partial-index predicate because now() is
|
||||
# not immutable. Therefore "open" means unused and unrevoked. Creation code
|
||||
# will revoke an old expired/open enrollment before inserting a replacement.
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_open_zone",
|
||||
"agent_enrollments",
|
||||
["agent_zone_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"used_at IS NULL AND revoked_at IS NULL"
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_enrolled_agent",
|
||||
"agent_enrollments",
|
||||
["enrolled_agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("enrolled_agent_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_agent_enrollments_site_id",
|
||||
"agent_enrollments",
|
||||
["site_id"],
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_agent_enrollments_created_at",
|
||||
"agent_enrollments",
|
||||
["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 0016 has nowhere to represent enrollment history or per-Agent
|
||||
# credentials. Refuse before destructive DDL if either has been used.
|
||||
connection = op.get_bind()
|
||||
|
||||
enrollment_count = connection.execute(
|
||||
sa.text("SELECT COUNT(*) FROM agent_enrollments")
|
||||
).scalar_one()
|
||||
|
||||
credential_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM minerops_site_agents
|
||||
WHERE display_name IS NOT NULL
|
||||
OR agent_secret_hash IS NOT NULL
|
||||
OR credential_created_at IS NOT NULL
|
||||
OR credential_revoked_at IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if enrollment_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0017: Agent enrollment history exists"
|
||||
)
|
||||
|
||||
if credential_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0017: per-Agent credentials exist"
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"idx_agent_enrollments_created_at",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_agent_enrollments_site_id",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_enrolled_agent",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_open_zone",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_token_hash",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_table("agent_enrollments")
|
||||
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_agent_secret_hash",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_revoked_time",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_pair",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_secret_hash",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_display_name",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
|
||||
op.drop_column("minerops_site_agents", "credential_revoked_at")
|
||||
op.drop_column("minerops_site_agents", "credential_created_at")
|
||||
op.drop_column("minerops_site_agents", "agent_secret_hash")
|
||||
op.drop_column("minerops_site_agents", "display_name")
|
||||
@@ -0,0 +1,259 @@
|
||||
"""route pre-Zone Agent installations without rewriting ownership history
|
||||
|
||||
Revision ID: 0018_legacy_agent_routing
|
||||
Revises: 0017_agent_enrollment
|
||||
Create Date: 2026-08-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0018_legacy_agent_routing"
|
||||
down_revision: Union[str, None] = "0017_agent_enrollment"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ZONE_NAME = "Default Zone"
|
||||
|
||||
# Every preflight and the eventual Site UPDATE use this exact resolver. An
|
||||
# exact trim/case-folded name match wins. The sole-Site fallback applies only
|
||||
# when there are zero exact matches and exactly one durable Site in the whole
|
||||
# database. Ambiguous exact matches and unresolved multi-Site installations
|
||||
# deliberately produce NULL resolved_site_id and fail before any UPDATE.
|
||||
RESOLVED_AGENT_SITES_CTE = """
|
||||
site_catalog AS (
|
||||
SELECT count(*) AS site_count,
|
||||
(array_agg(id ORDER BY id))[1] AS sole_site_id
|
||||
FROM sites
|
||||
),
|
||||
unassigned_exact_matches AS (
|
||||
SELECT agent.agent_id,
|
||||
agent.site_name,
|
||||
count(site.id) AS exact_match_count,
|
||||
(
|
||||
array_agg(site.id ORDER BY site.id)
|
||||
FILTER (WHERE site.id IS NOT NULL)
|
||||
)[1] AS exact_site_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON lower(btrim(site.name)) = lower(btrim(agent.site_name))
|
||||
WHERE agent.site_id IS NULL
|
||||
GROUP BY agent.agent_id, agent.site_name
|
||||
),
|
||||
resolved_unassigned_agents AS (
|
||||
SELECT match.agent_id,
|
||||
match.site_name,
|
||||
match.exact_match_count,
|
||||
catalog.site_count,
|
||||
CASE
|
||||
WHEN match.exact_match_count = 1
|
||||
THEN match.exact_site_id
|
||||
WHEN match.exact_match_count = 0
|
||||
AND catalog.site_count = 1
|
||||
THEN catalog.sole_site_id
|
||||
ELSE NULL
|
||||
END AS resolved_site_id
|
||||
FROM unassigned_exact_matches AS match
|
||||
CROSS JOIN site_catalog AS catalog
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _format_rows(rows: list[tuple]) -> str:
|
||||
return ", ".join("/".join(repr(str(value)) for value in row) for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
# Keep every preflight result stable until the corresponding routing and
|
||||
# history updates commit. Production must still take the Hub and all queue
|
||||
# producers offline before migration; these locks are the database-level
|
||||
# backstop against an accidental concurrent Site, Zone, Agent, snapshot, or
|
||||
# discovery write changing an already-checked assumption.
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
LOCK TABLE
|
||||
agent_zones,
|
||||
minerops_agent_discoveries,
|
||||
minerops_agent_snapshots,
|
||||
minerops_site_agents,
|
||||
sites
|
||||
IN SHARE ROW EXCLUSIVE MODE
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Run every fail-closed preflight before the first UPDATE. Site resolution
|
||||
# is intentionally limited to legacy rows without an explicit Site
|
||||
# assignment; explicit site_id values are never candidates for replacement.
|
||||
site_match_failures = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||
SELECT agent_id, site_name, exact_match_count, site_count
|
||||
FROM resolved_unassigned_agents
|
||||
WHERE resolved_site_id IS NULL
|
||||
ORDER BY agent_id
|
||||
"""
|
||||
)
|
||||
).all()
|
||||
if site_match_failures:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: each unassigned Agent "
|
||||
"must have exactly one Site name match after trim/case folding, "
|
||||
"or zero exact matches while the database contains exactly one "
|
||||
"Site. Ambiguous exact matches and unmatched multi-Site "
|
||||
"installations fail closed. Failing "
|
||||
"agent/site/exact-match-count/site-count rows: "
|
||||
f"{_format_rows(list(site_match_failures))}. Assign site_id "
|
||||
"explicitly, make Site names unambiguous, or identify the correct "
|
||||
"Site before retrying; no "
|
||||
"Agent, miner, MAC, queue, snapshot, or discovery data was changed."
|
||||
)
|
||||
|
||||
default_zone_failures = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||
effective_agents AS (
|
||||
SELECT agent.agent_id,
|
||||
coalesce(
|
||||
agent.site_id,
|
||||
resolved.resolved_site_id
|
||||
) AS site_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||
ON resolved.agent_id = agent.agent_id
|
||||
WHERE agent.agent_zone_id IS NULL
|
||||
)
|
||||
SELECT agent.agent_id, agent.site_id::text, count(zone.id)
|
||||
FROM effective_agents AS agent
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.site_id = agent.site_id
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
GROUP BY agent.agent_id, agent.site_id
|
||||
HAVING count(zone.id) <> 1
|
||||
ORDER BY agent.agent_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
if default_zone_failures:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: each Agent without an "
|
||||
"explicit Zone must have exactly one enabled Default Zone in its "
|
||||
"effective Site. Failing agent/site/count rows: "
|
||||
f"{_format_rows(list(default_zone_failures))}. Repair the Site's "
|
||||
"Default Zone or assign agent_zone_id explicitly before retrying; "
|
||||
"no data was changed."
|
||||
)
|
||||
|
||||
zone_conflicts = connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE},
|
||||
targets AS (
|
||||
SELECT agent.agent_id, zone.id AS agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN resolved_unassigned_agents AS resolved
|
||||
ON resolved.agent_id = agent.agent_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.site_id = coalesce(
|
||||
agent.site_id,
|
||||
resolved.resolved_site_id
|
||||
)
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
WHERE agent.agent_zone_id IS NULL
|
||||
)
|
||||
SELECT target.agent_zone_id::text,
|
||||
string_agg(target.agent_id, ',' ORDER BY target.agent_id),
|
||||
coalesce(string_agg(occupant.agent_id, ','
|
||||
ORDER BY occupant.agent_id), '')
|
||||
FROM targets AS target
|
||||
LEFT JOIN minerops_site_agents AS occupant
|
||||
ON occupant.agent_zone_id = target.agent_zone_id
|
||||
GROUP BY target.agent_zone_id
|
||||
HAVING count(DISTINCT target.agent_id) > 1
|
||||
OR count(occupant.agent_id) > 0
|
||||
ORDER BY target.agent_zone_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
if zone_conflicts:
|
||||
raise RuntimeError(
|
||||
"Cannot route legacy Agent installations: one-Agent-per-Zone would "
|
||||
"be violated. Failing zone/target Agents/current Agent rows: "
|
||||
f"{_format_rows(list(zone_conflicts))}. Assign distinct enabled "
|
||||
"Zones explicitly before retrying; no data was changed."
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
WITH {RESOLVED_AGENT_SITES_CTE}
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET site_id = resolved.resolved_site_id
|
||||
FROM resolved_unassigned_agents AS resolved
|
||||
WHERE agent.agent_id = resolved.agent_id
|
||||
AND agent.site_id IS NULL
|
||||
AND resolved.resolved_site_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE agent.site_id = zone.site_id
|
||||
AND zone.is_enabled
|
||||
AND lower(btrim(zone.name)) = lower(:default_name)
|
||||
AND agent.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_snapshots AS snapshot
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE snapshot.agent_id = agent.agent_id
|
||||
AND snapshot.agent_zone_id IS NULL
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_discoveries AS discovery
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE discovery.agent_id = agent.agent_id
|
||||
AND discovery.agent_zone_id IS NULL
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# There is no durable, trustworthy way to distinguish values assigned here
|
||||
# from assignments made by an operator after upgrade. Refuse instead of
|
||||
# erasing potentially explicit production routing or historical attribution.
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0018 automatically: legacy Site/Zone routing and "
|
||||
"snapshot/discovery attribution may now be production data. Restore the "
|
||||
"pre-upgrade database backup for rollback; no data was changed."
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""make legacy Agent authentication retirement durable and one-way
|
||||
|
||||
Revision ID: 0019_agent_legacy_auth_retire
|
||||
Revises: 0018_legacy_agent_routing
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0019_agent_legacy_auth_retire"
|
||||
down_revision: Union[str, None] = "0018_legacy_agent_routing"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Keep the row-level value as a projection for operators and defense in
|
||||
# depth. The independent identity table below is the authoritative record:
|
||||
# it deliberately has no foreign key to the deletable current Agent row.
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"legacy_auth_disabled_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"agent_legacy_auth_retirements",
|
||||
sa.Column("agent_id", sa.Text(), primary_key=True),
|
||||
sa.Column(
|
||||
"retired_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("clock_timestamp()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"btrim(agent_id) <> ''",
|
||||
name="ck_agent_legacy_auth_retirements_agent_id",
|
||||
),
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
# Stabilize both credential sources until backfill and all enforcement
|
||||
# triggers are installed. This preserves a single atomic migration view.
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
LOCK TABLE minerops_site_agents, agent_enrollments
|
||||
IN SHARE ROW EXCLUSIVE MODE
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Backfill every reliable indication that a per-Agent secret was issued.
|
||||
# A used enrollment remains evidence even if credential columns were later
|
||||
# cleared. Merely existing as an uncredentialed Agent is not evidence, so
|
||||
# the production-shaped legacy Plexus identity remains eligible.
|
||||
op.execute(
|
||||
"""
|
||||
WITH credential_evidence AS (
|
||||
SELECT
|
||||
agent_id,
|
||||
coalesce(
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
clock_timestamp()
|
||||
) AS retired_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_secret_hash IS NOT NULL
|
||||
OR credential_created_at IS NOT NULL
|
||||
OR credential_revoked_at IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT enrolled_agent_id AS agent_id, used_at AS retired_at
|
||||
FROM agent_enrollments
|
||||
WHERE enrolled_agent_id IS NOT NULL
|
||||
AND used_at IS NOT NULL
|
||||
)
|
||||
INSERT INTO agent_legacy_auth_retirements (agent_id, retired_at)
|
||||
SELECT agent_id, min(retired_at)
|
||||
FROM credential_evidence
|
||||
GROUP BY agent_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET legacy_auth_disabled_at = retirement.retired_at
|
||||
FROM agent_legacy_auth_retirements AS retirement
|
||||
WHERE retirement.agent_id = agent.agent_id
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
legacy_auth_disabled_at IS NOT NULL
|
||||
OR (
|
||||
agent_secret_hash IS NULL
|
||||
AND credential_created_at IS NULL
|
||||
AND credential_revoked_at IS NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
# The retirement ledger is append-only for ordinary database operations.
|
||||
# Migration rollback can still DROP the empty table, and a DBA retains the
|
||||
# usual PostgreSQL superuser disaster-recovery capabilities.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'legacy Agent authentication retirement history is append-only'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||
BEFORE UPDATE OR DELETE
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||
BEFORE TRUNCATE
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION minerops_prevent_legacy_auth_retirement_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
# Any database path that stores credential evidence also creates the
|
||||
# identity tombstone in the same transaction. The row marker is always
|
||||
# synchronized from that tombstone, including on recreation of an Agent
|
||||
# row whose identity was previously retired.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
identity_retired_at timestamptz;
|
||||
BEGIN
|
||||
IF NEW.agent_secret_hash IS NOT NULL
|
||||
OR NEW.credential_created_at IS NOT NULL
|
||||
OR NEW.credential_revoked_at IS NOT NULL
|
||||
OR NEW.legacy_auth_disabled_at IS NOT NULL THEN
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (
|
||||
NEW.agent_id,
|
||||
coalesce(
|
||||
NEW.credential_created_at,
|
||||
NEW.legacy_auth_disabled_at,
|
||||
NEW.credential_revoked_at,
|
||||
clock_timestamp()
|
||||
)
|
||||
)
|
||||
ON CONFLICT (agent_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
SELECT retired_at
|
||||
INTO identity_retired_at
|
||||
FROM agent_legacy_auth_retirements
|
||||
WHERE agent_id = NEW.agent_id;
|
||||
|
||||
IF identity_retired_at IS NOT NULL THEN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW.agent_id = OLD.agent_id
|
||||
AND NEW.legacy_auth_disabled_at
|
||||
IS DISTINCT FROM OLD.legacy_auth_disabled_at
|
||||
AND NEW.legacy_auth_disabled_at
|
||||
IS DISTINCT FROM identity_retired_at THEN
|
||||
RAISE EXCEPTION
|
||||
'legacy Agent authentication retirement is permanent'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
NEW.legacy_auth_disabled_at := identity_retired_at;
|
||||
ELSE
|
||||
NEW.legacy_auth_disabled_at := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||
BEFORE INSERT OR UPDATE OF
|
||||
agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
legacy_auth_disabled_at
|
||||
ON minerops_site_agents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_sync_agent_legacy_auth_retirement()
|
||||
"""
|
||||
)
|
||||
|
||||
# Direct, one-way ledger inserts also refresh the optional row projection.
|
||||
# Skip the nested case where the Agent trigger itself inserted the ledger
|
||||
# row; that outer trigger already has NEW and will set the projection.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF pg_trigger_depth() = 1 THEN
|
||||
UPDATE minerops_site_agents
|
||||
SET legacy_auth_disabled_at = NEW.retired_at
|
||||
WHERE agent_id = NEW.agent_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_agent_legacy_auth_retirements_project
|
||||
AFTER INSERT
|
||||
ON agent_legacy_auth_retirements
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION minerops_project_agent_legacy_auth_retirement()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
retired_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if retired_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0019: durable identity-level legacy-auth "
|
||||
"retirement history exists. Restore the pre-upgrade database "
|
||||
"backup rather than making retired Agent identities eligible for "
|
||||
"legacy auth again."
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_project
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute("DROP FUNCTION minerops_project_agent_legacy_auth_retirement()")
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_minerops_site_agents_legacy_auth_retirement
|
||||
ON minerops_site_agents
|
||||
"""
|
||||
)
|
||||
op.execute("DROP FUNCTION minerops_sync_agent_legacy_auth_retirement()")
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_no_truncate
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DROP TRIGGER trg_agent_legacy_auth_retirements_immutable
|
||||
ON agent_legacy_auth_retirements
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"DROP FUNCTION minerops_prevent_legacy_auth_retirement_mutation()"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_retires_legacy_auth",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "legacy_auth_disabled_at")
|
||||
op.drop_table("agent_legacy_auth_retirements")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""bind one-time credential transitions to existing Agent identities
|
||||
|
||||
Revision ID: 0020_existing_agent_transition
|
||||
Revises: 0019_agent_legacy_auth_retire
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0020_existing_agent_transition"
|
||||
down_revision: Union[str, None] = "0019_agent_legacy_auth_retire"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# NULL continues to mean a normal new-Agent enrollment. A non-NULL value
|
||||
# is server-derived from the current Agent row and binds the one-time token
|
||||
# to that exact durable identity without accepting browser routing input.
|
||||
op.add_column(
|
||||
"agent_enrollments",
|
||||
sa.Column("target_agent_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
"minerops_site_agents",
|
||||
["target_agent_id"],
|
||||
["agent_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
"target_agent_id IS NULL OR btrim(target_agent_id) <> ''",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_enrollments_target_result",
|
||||
"agent_enrollments",
|
||||
"""
|
||||
target_agent_id IS NULL
|
||||
OR enrolled_agent_id IS NULL
|
||||
OR enrolled_agent_id = target_agent_id
|
||||
""",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_open_target_agent",
|
||||
"agent_enrollments",
|
||||
["target_agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"target_agent_id IS NOT NULL "
|
||||
"AND used_at IS NULL AND revoked_at IS NULL"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
transition_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM agent_enrollments
|
||||
WHERE target_agent_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if transition_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0020: existing-Agent credential transition "
|
||||
"history exists. Restore the pre-upgrade database backup rather "
|
||||
"than discarding the identity binding."
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_open_target_agent",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_enrollments_target_result",
|
||||
"agent_enrollments",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_agent_enrollments_target_agent",
|
||||
"agent_enrollments",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("agent_enrollments", "target_agent_id")
|
||||
@@ -0,0 +1 @@
|
||||
"""Per-Agent authentication support."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain errors for Agent authentication."""
|
||||
|
||||
|
||||
class AgentAuthenticationError(Exception):
|
||||
"""Base Agent authentication error."""
|
||||
|
||||
|
||||
class AgentAuthenticationUnavailable(AgentAuthenticationError):
|
||||
"""Agent authentication cannot currently be performed."""
|
||||
|
||||
|
||||
class AgentAuthenticationFailed(AgentAuthenticationError):
|
||||
"""The supplied Agent secret is invalid."""
|
||||
|
||||
|
||||
class AgentIdentityUnauthorized(AgentAuthenticationError):
|
||||
"""The supplied Agent identity is not authorized."""
|
||||
|
||||
|
||||
class AgentCredentialRevoked(AgentAuthenticationError):
|
||||
"""The Agent's permanent credential has been revoked."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PostgreSQL persistence for Agent authentication."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentAuthRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_credential(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT identity.agent_id,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
retirement.retired_at AS legacy_auth_disabled_at
|
||||
FROM (VALUES (%s::text)) AS identity(agent_id)
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_id = identity.agent_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = identity.agent_id
|
||||
WHERE agent.agent_id IS NOT NULL
|
||||
OR retirement.agent_id IS NOT NULL
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AgentAuthService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
# The production Alpha Agent predates the identity header. Missing-ID
|
||||
# compatibility is therefore bound to exactly the configured legacy
|
||||
# identity; no request value is allowed to select another Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
missing_identity = not identity
|
||||
if missing_identity:
|
||||
if not legacy_id:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
identity = legacy_id
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Revocation is authoritative regardless of which credential path
|
||||
# would otherwise apply. Never allow malformed or historical
|
||||
# credential state to fall through to the legacy shared secret.
|
||||
if credential and credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
# This value comes from the identity-level retirement ledger, not the
|
||||
# deletable current Agent row. It is therefore authoritative even when
|
||||
# the Agent was deleted and has not yet been recreated.
|
||||
legacy_auth_retired = bool(
|
||||
credential
|
||||
and credential.get("legacy_auth_disabled_at") is not None
|
||||
)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
# Once the configured legacy identity has ever received a
|
||||
# per-Agent credential, secret-only fallback is permanently
|
||||
# disabled. The legacy global secret cannot authenticate it and a
|
||||
# per-Agent secret cannot be used without an explicit identity.
|
||||
if missing_identity:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Explicit Agent identity is required for enrolled Agents"
|
||||
)
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
supplied_hash,
|
||||
str(credential["agent_secret_hash"]),
|
||||
):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
|
||||
# The durable identity retirement survives Agent-row deletion,
|
||||
# credential-field corruption, administrative mistakes, and future
|
||||
# credential rotation workflows.
|
||||
if legacy_auth_retired:
|
||||
raise AgentAuthenticationFailed(
|
||||
"Legacy Agent authentication has been permanently disabled"
|
||||
)
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(identity, legacy_id):
|
||||
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||
|
||||
if not hmac.compare_digest(secret, legacy_secret):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
@@ -0,0 +1 @@
|
||||
"""Authenticated Agent command transport for MinerOps."""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Dependencies for the authenticated Agent queue protocol."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
|
||||
|
||||
def get_agent_command_service() -> AgentCommandService:
|
||||
return AgentCommandService(AgentCommandRepository())
|
||||
|
||||
|
||||
def get_agent_auth_service() -> AgentAuthService:
|
||||
return AgentAuthService(AgentAuthRepository())
|
||||
|
||||
|
||||
def require_agent_identity(
|
||||
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
|
||||
provided_secret: Annotated[
|
||||
str | None, Header(alias="X-MinerOps-Agent-Secret")
|
||||
] = None,
|
||||
service: AgentAuthService = Depends(get_agent_auth_service),
|
||||
) -> str:
|
||||
try:
|
||||
return service.authenticate(agent_id, provided_secret)
|
||||
except AgentAuthenticationUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Agent authentication is not configured",
|
||||
) from exc
|
||||
except AgentAuthenticationFailed as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
except AgentCredentialRevoked as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except AgentIdentityUnauthorized as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Domain-neutral Agent command protocol errors."""
|
||||
|
||||
|
||||
class AgentCommandError(Exception):
|
||||
"""Base Agent command protocol error."""
|
||||
|
||||
|
||||
class AgentCommandNotFound(AgentCommandError):
|
||||
"""The submitted command identifier is unknown."""
|
||||
|
||||
|
||||
class AgentCommandConflict(AgentCommandError):
|
||||
"""The queue row cannot accept the requested transition."""
|
||||
|
||||
|
||||
class AgentResultValidationError(AgentCommandError):
|
||||
"""The Agent result does not match its command contract."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Typed contracts for the Agent queue protocol."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class AgentCommand(BaseModel):
|
||||
command_id: str
|
||||
command_type: str
|
||||
request_payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentCommandListResponse(BaseModel):
|
||||
ok: Literal[True] = True
|
||||
commands: list[AgentCommand]
|
||||
|
||||
|
||||
class MinerControlResultPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal[1]
|
||||
command_accepted: bool
|
||||
verification_attempted: bool
|
||||
verification_status: Literal[
|
||||
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||
]
|
||||
observed_service_state: None = None
|
||||
observed_performance_mode: Literal["low", "normal", "high"] | None = None
|
||||
failure_code: str | None = Field(
|
||||
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_truthfulness(self) -> "MinerControlResultPayload":
|
||||
if self.verification_status == "confirmed" and not self.verification_attempted:
|
||||
raise ValueError("confirmed results require verification_attempted")
|
||||
if not self.command_accepted and not self.failure_code:
|
||||
raise ValueError("rejected commands require a failure_code")
|
||||
return self
|
||||
|
||||
|
||||
class AgentCommandResultRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
command_id: str = Field(min_length=1, max_length=128)
|
||||
status: Literal["completed", "failed"]
|
||||
result_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
error_message: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentCommandResultResponse(BaseModel):
|
||||
ok: Literal[True] = True
|
||||
command_id: str
|
||||
status: Literal["completed", "failed"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Stage A containment: no current payload authorizes a physical write.
|
||||
|
||||
There is deliberately no environment-variable override. Re-enabling physical
|
||||
control requires a separately reviewed identity-binding protocol.
|
||||
"""
|
||||
|
||||
PHYSICAL_CONTROL_DISABLED = "physical_control_disabled_stage_a"
|
||||
UNSUPPORTED_COMMAND = "unsupported_command_schema_stage_a"
|
||||
READ_ONLY_COMMAND_TYPES = (
|
||||
"poll_known_miners_now",
|
||||
"discover_new_miners",
|
||||
"enrich_match_discovery",
|
||||
)
|
||||
|
||||
|
||||
def rejection_reason(command_type: str) -> str | None:
|
||||
if command_type == "miner_control":
|
||||
return PHYSICAL_CONTROL_DISABLED
|
||||
if command_type not in READ_ONLY_COMMAND_TYPES:
|
||||
return UNSUPPORTED_COMMAND
|
||||
return None
|
||||
|
||||
|
||||
def rejection_execution_outcome(row: dict) -> str:
|
||||
"""A current pending state alone says nothing about earlier delivery.
|
||||
|
||||
The repository supplies an explicit pre-transition evidence check while
|
||||
holding the queue row lock. Missing/ambiguous checks and result submissions
|
||||
are unknown. This describes queue delivery, never proof of physical outcome.
|
||||
"""
|
||||
if (row.get("previous_status") == "pending"
|
||||
and row.get("no_delivery_evidence") is True
|
||||
and row.get("delivery_reported", False) is False):
|
||||
return "not_dispatched"
|
||||
return "unknown"
|
||||
@@ -0,0 +1,322 @@
|
||||
"""PostgreSQL persistence for Agent command transport."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
CLAIM_COMMANDS_SQL = """
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = %s
|
||||
AND command_status = 'pending'
|
||||
AND command_type = ANY(%s)
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'claimed', claimed_at = now()
|
||||
FROM candidates
|
||||
WHERE queue.id = candidates.id
|
||||
AND queue.command_status = 'pending'
|
||||
RETURNING queue.id AS command_id, queue.command_type,
|
||||
queue.request_payload, queue.created_at
|
||||
"""
|
||||
|
||||
# Inspect existing durable history before changing status/completed_at/error.
|
||||
# A linked non-creation audit is conservatively ambiguous, even if timestamps
|
||||
# were lost. Legacy results need not agree on Agent ID to prevent a false claim
|
||||
# of non-delivery. Missing tables/permissions fail the transaction closed.
|
||||
NO_DELIVERY_EVIDENCE_SQL = """
|
||||
q.created_at IS NOT NULL
|
||||
AND q.claimed_at IS NULL AND q.completed_at IS NULL
|
||||
AND q.result_payload IS NULL AND COALESCE(q.error_message, '') = ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM minerops_agent_command_results r
|
||||
WHERE r.command_id = q.id
|
||||
OR (r.agent_id = q.agent_id AND NULLIF(btrim(r.command_id), '') IS NULL)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM audit_logs a
|
||||
WHERE a.action <> 'miner.control.queued'
|
||||
AND (a.old_value_json ->> 'command_id' = q.id
|
||||
OR a.new_value_json ->> 'command_id' = q.id
|
||||
OR a.old_value_json ->> 'operation_id' = q.id
|
||||
OR a.new_value_json ->> 'operation_id' = q.id
|
||||
OR (a.entity_type = 'agent_command' AND a.entity_id::text = q.id))
|
||||
)
|
||||
"""
|
||||
|
||||
RETIRE_COMMANDS_SQL = f"""
|
||||
WITH candidates AS (
|
||||
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE agent_id = %s AND command_status IN ('pending', 'claimed')
|
||||
AND NOT (command_type = ANY(%s))
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1000
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
|
||||
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
|
||||
THEN 'physical_control_disabled_stage_a'
|
||||
ELSE 'unsupported_command_schema_stage_a' END)
|
||||
FROM candidates WHERE queue.id = candidates.id
|
||||
RETURNING queue.id, queue.agent_id, queue.command_type,
|
||||
candidates.command_status AS previous_status, candidates.no_delivery_evidence, queue.error_message
|
||||
"""
|
||||
|
||||
RETIRE_SCHEDULE_COMMANDS_SQL = f"""
|
||||
WITH candidates AS (
|
||||
SELECT q.id, q.command_status, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE agent_id = %s AND request_payload ->> 'scheduled_action_id' = %s
|
||||
AND command_status IN ('pending', 'claimed')
|
||||
AND NOT (command_type = ANY(%s))
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'cancelled', completed_at = COALESCE(queue.completed_at, now()),
|
||||
error_message = concat_ws(chr(10), NULLIF(queue.error_message, ''), CASE WHEN queue.command_type = 'miner_control'
|
||||
THEN 'physical_control_disabled_stage_a'
|
||||
ELSE 'unsupported_command_schema_stage_a' END)
|
||||
FROM candidates WHERE queue.id = candidates.id
|
||||
RETURNING queue.id, queue.agent_id, queue.command_type,
|
||||
candidates.command_status AS previous_status, candidates.no_delivery_evidence
|
||||
"""
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentCommandRepository:
|
||||
"""Own queue locking and immutable terminal transitions."""
|
||||
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
self._retire_unsupported(cur, agent_id)
|
||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
connection.commit()
|
||||
return rows
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _audit_rejection(
|
||||
cur, row: dict, *, actor_user_id: str | None = None,
|
||||
source: str = "agent_poll",
|
||||
) -> None:
|
||||
# Queue IDs can be legacy text, not UUIDs. Preserve them in JSON rather
|
||||
# than coercing them into audit_logs.entity_id. Never log raw payloads.
|
||||
cur.execute(
|
||||
"""INSERT INTO audit_logs
|
||||
(actor_user_id, action, entity_type, old_value_json, new_value_json)
|
||||
VALUES (%s, 'agent.command.rejected', 'agent_command', %s, %s)""",
|
||||
(actor_user_id, Jsonb({"command_id": row["id"], "agent_id": row["agent_id"],
|
||||
"status": row["previous_status"]}),
|
||||
Jsonb({"status": "cancelled", "reason": rejection_reason(row["command_type"]),
|
||||
"source": source,
|
||||
"execution_outcome": rejection_execution_outcome(row)})),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _retire_unsupported(
|
||||
cls, cur, agent_id: str, *, source: str = "agent_poll",
|
||||
actor_user_id: str | None = None,
|
||||
) -> int:
|
||||
cur.execute(RETIRE_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES)))
|
||||
rows = list(cur.fetchall())
|
||||
for row in rows:
|
||||
cls._audit_rejection(cur, row, source=source, actor_user_id=actor_user_id)
|
||||
return len(rows)
|
||||
|
||||
def retire_unsupported(self, agent_id: str, *, actor_user_id: str) -> int:
|
||||
"""Audited cutover batch, including offline Agents; never claim any work.
|
||||
|
||||
A zero batch can mean locked rows were skipped. Operators must separately
|
||||
verify no unsupported live rows remain before declaring a drain complete.
|
||||
"""
|
||||
if not actor_user_id:
|
||||
raise ValueError("Cutover retirement requires an accountable operator actor")
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
count = self._retire_unsupported(cur, agent_id, source="stage_a_cutover",
|
||||
actor_user_id=actor_user_id)
|
||||
connection.commit()
|
||||
return count
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def retire_schedule(self, schedule_id: str, *, actor_user_id: str, operation: str) -> int:
|
||||
"""Cancel live unsupported work and retain schedule history atomically.
|
||||
|
||||
The stored schedule determines Agent scope. Delete retires the schedule;
|
||||
neither operation erases evidence or changes terminal command history.
|
||||
"""
|
||||
if not actor_user_id or operation not in {"cancel", "delete"}:
|
||||
raise ValueError("An authenticated actor and cancel/delete operation are required")
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
# Serialize retries and cancel/delete workers for this schedule.
|
||||
cur.execute(
|
||||
"""SELECT id, agent_id, status FROM minerops_scheduled_bulk_actions
|
||||
WHERE id = %s FOR UPDATE""", (schedule_id,),
|
||||
)
|
||||
schedule = cur.fetchone()
|
||||
if not schedule:
|
||||
raise AgentCommandNotFound("Scheduled action not found")
|
||||
cur.execute(RETIRE_SCHEDULE_COMMANDS_SQL,
|
||||
(schedule["agent_id"], schedule_id, list(READ_ONLY_COMMAND_TYPES)))
|
||||
rows = list(cur.fetchall())
|
||||
for row in rows:
|
||||
self._audit_rejection(cur, row, actor_user_id=actor_user_id,
|
||||
source=f"schedule_{operation}")
|
||||
if schedule["status"] in {"pending", "queued", "running"}:
|
||||
cur.execute(
|
||||
"""UPDATE minerops_scheduled_bulk_actions
|
||||
SET status = 'cancelled', updated_at = now(),
|
||||
last_error = concat_ws(chr(10), NULLIF(last_error, ''),
|
||||
'physical_control_disabled_stage_a')
|
||||
WHERE id = %s""", (schedule_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""INSERT INTO audit_logs
|
||||
(actor_user_id, action, entity_type, old_value_json, new_value_json)
|
||||
VALUES (%s, 'scheduled_action.retired', 'scheduled_action', %s, %s)""",
|
||||
(actor_user_id, Jsonb(dict(schedule)),
|
||||
Jsonb({"status": "cancelled", "operation": operation,
|
||||
"reason": "physical_control_disabled_stage_a"})),
|
||||
)
|
||||
connection.commit()
|
||||
return len(rows)
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_command(self, command_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, agent_id, command_type, command_status, request_payload
|
||||
FROM minerops_agent_command_queue WHERE id = %s
|
||||
""",
|
||||
(command_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def finish_command(
|
||||
self,
|
||||
command_id: str,
|
||||
agent_id: str,
|
||||
status: str,
|
||||
result_payload: dict[str, Any],
|
||||
error_message: str | None,
|
||||
) -> None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
# Enforce the same boundary on results: an old claimed entry
|
||||
# cannot be promoted by an old Agent, retry, or direct caller.
|
||||
cur.execute(
|
||||
f"""SELECT q.id, q.agent_id, q.command_type, q.command_status AS previous_status,
|
||||
({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||
FROM minerops_agent_command_queue q
|
||||
WHERE q.id = %s AND q.agent_id = %s FOR UPDATE""",
|
||||
(command_id, agent_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing and rejection_reason(existing["command_type"]):
|
||||
reason = rejection_reason(existing["command_type"])
|
||||
if existing["previous_status"] in {"pending", "claimed"}:
|
||||
cur.execute(
|
||||
"""UPDATE minerops_agent_command_queue
|
||||
SET command_status = 'cancelled',
|
||||
error_message = concat_ws(chr(10), NULLIF(error_message, ''), %s::text),
|
||||
completed_at = COALESCE(completed_at, now()) WHERE id = %s""",
|
||||
(reason, command_id),
|
||||
)
|
||||
# A result submission itself reports possible delivery, even
|
||||
# when earlier durable claim metadata is absent.
|
||||
existing["delivery_reported"] = True
|
||||
self._audit_rejection(cur, existing, source="agent_result")
|
||||
connection.commit()
|
||||
raise AgentCommandConflict(reason)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET command_status = %s, result_payload = %s,
|
||||
error_message = %s, completed_at = now()
|
||||
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
|
||||
RETURNING id
|
||||
""",
|
||||
(status, Jsonb(result_payload), error_message, command_id, agent_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
connection.commit()
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, command_status
|
||||
FROM minerops_agent_command_queue WHERE id = %s
|
||||
""",
|
||||
(command_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
connection.rollback()
|
||||
if not row:
|
||||
raise AgentCommandNotFound("Agent command not found")
|
||||
if row["agent_id"] != agent_id:
|
||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||
raise AgentCommandConflict(
|
||||
f"Agent command is {row['command_status']}, not claimed"
|
||||
)
|
||||
except (AgentCommandNotFound, AgentCommandConflict):
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""HTTP boundary for authenticated Agent command transport."""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentCommandNotFound,
|
||||
AgentResultValidationError,
|
||||
)
|
||||
from app.agent_commands.models import (
|
||||
AgentCommandListResponse,
|
||||
AgentCommandResultRequest,
|
||||
AgentCommandResultResponse,
|
||||
)
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
|
||||
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
||||
|
||||
|
||||
@router.get("/pending", response_model=AgentCommandListResponse)
|
||||
def claim_pending_commands(
|
||||
request: Request, agent_id: AgentIdentity, service: AgentService
|
||||
) -> AgentCommandListResponse:
|
||||
del request
|
||||
return AgentCommandListResponse(commands=service.claim(agent_id))
|
||||
|
||||
|
||||
@router.post("/result", response_model=AgentCommandResultResponse)
|
||||
def submit_command_result(
|
||||
request: AgentCommandResultRequest,
|
||||
agent_id: AgentIdentity,
|
||||
service: AgentService,
|
||||
) -> AgentCommandResultResponse:
|
||||
try:
|
||||
service.submit_result(agent_id, request)
|
||||
except AgentCommandNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentCommandConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except AgentResultValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Agent command transport coordination and result validation."""
|
||||
|
||||
import re
|
||||
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentCommandNotFound,
|
||||
)
|
||||
from app.agent_commands.models import (
|
||||
AgentCommand,
|
||||
AgentCommandResultRequest,
|
||||
)
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.policy import rejection_reason
|
||||
|
||||
|
||||
class AgentCommandService:
|
||||
def __init__(self, repository: AgentCommandRepository):
|
||||
self.repository = repository
|
||||
|
||||
def claim(self, agent_id: str) -> list[AgentCommand]:
|
||||
return [
|
||||
AgentCommand.model_validate(row)
|
||||
for row in self.repository.claim_pending(agent_id)
|
||||
if rejection_reason(row["command_type"]) is None
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _legacy_error(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
|
||||
return sanitized[:255]
|
||||
|
||||
def submit_result(
|
||||
self, agent_id: str, request: AgentCommandResultRequest
|
||||
) -> None:
|
||||
command = self.repository.get_command(request.command_id)
|
||||
if not command:
|
||||
raise AgentCommandNotFound("Agent command not found")
|
||||
if command["agent_id"] != agent_id:
|
||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||
reason = rejection_reason(command["command_type"])
|
||||
if reason:
|
||||
# Repository repeats this check under a row lock and records the
|
||||
# cancellation atomically; never accept an old physical result.
|
||||
self.repository.finish_command(
|
||||
request.command_id, agent_id, "cancelled", {}, reason
|
||||
)
|
||||
raise AgentCommandConflict(reason)
|
||||
if command["command_status"] != "claimed":
|
||||
raise AgentCommandConflict(
|
||||
f"Agent command is {command['command_status']}, not claimed"
|
||||
)
|
||||
|
||||
result_payload = request.result_payload
|
||||
error_message = self._legacy_error(request.error_message)
|
||||
|
||||
self.repository.finish_command(
|
||||
request.command_id,
|
||||
agent_id,
|
||||
request.status,
|
||||
result_payload,
|
||||
error_message,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Secure one-time Agent enrollment."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency wiring for secure Agent enrollment."""
|
||||
|
||||
from app.agent_enrollment.repository import AgentEnrollmentRepository
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
|
||||
|
||||
def get_agent_enrollment_service() -> AgentEnrollmentService:
|
||||
return AgentEnrollmentService(AgentEnrollmentRepository())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain exceptions for secure Agent enrollment."""
|
||||
|
||||
|
||||
class AgentEnrollmentError(Exception):
|
||||
"""Base Agent enrollment error."""
|
||||
|
||||
|
||||
class AgentEnrollmentNotFound(AgentEnrollmentError):
|
||||
"""The requested Site or Agent Zone does not exist."""
|
||||
|
||||
|
||||
class AgentEnrollmentConflict(AgentEnrollmentError):
|
||||
"""The requested enrollment conflicts with current Agent routing state."""
|
||||
|
||||
|
||||
class AgentEnrollmentTokenInvalid(AgentEnrollmentError):
|
||||
"""The supplied bootstrap token cannot be redeemed."""
|
||||
|
||||
|
||||
class AgentEnrollmentCredentialCollision(AgentEnrollmentError):
|
||||
"""A generated machine credential collided with an existing credential."""
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Typed contracts for secure Agent enrollment."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AgentEnrollmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
site_id: UUID
|
||||
agent_zone_id: UUID
|
||||
display_name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def normalize_display_name(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("display_name cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentEnrollmentCreated(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
enrollment_token: str
|
||||
|
||||
|
||||
class AgentEnrollmentCreateResponse(BaseModel):
|
||||
data: AgentEnrollmentCreated
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentEnrollmentSummary(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_by_user_id: UUID | None = None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
target_agent_id: str | None = None
|
||||
enrolled_agent_id: str | None = None
|
||||
purpose: Literal["new_agent", "existing_agent_credential_transition"]
|
||||
status: Literal["waiting", "used", "revoked", "expired"]
|
||||
|
||||
|
||||
class AgentEnrollmentListResponse(BaseModel):
|
||||
data: list[AgentEnrollmentSummary]
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentEnrollRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def normalize_token(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("token cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentEnrollCredentials(BaseModel):
|
||||
agent_id: str
|
||||
agent_secret: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
ip_ranges: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentEnrollResponse(BaseModel):
|
||||
data: AgentEnrollCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||
"""Short-lived token used to construct a Windows bootstrap package."""
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("agent_id")
|
||||
@classmethod
|
||||
def normalize_agent_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("agent_id cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreated(BaseModel):
|
||||
id: UUID
|
||||
target_agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
transition_token: str
|
||||
|
||||
|
||||
class AgentCredentialTransitionCreateResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCreated
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentCredentialTransitionRedeemRequest(AgentEnrollRequest):
|
||||
agent_secret_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class AgentCredentialTransitionCredentials(BaseModel):
|
||||
agent_id: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
ip_ranges: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentCredentialTransitionResponse(BaseModel):
|
||||
data: AgentCredentialTransitionCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,968 @@
|
||||
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||
|
||||
import hmac
|
||||
from collections.abc import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||
OPEN_TARGET_CONSTRAINT = "uq_agent_enrollments_open_target_agent"
|
||||
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentEnrollmentRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def create_enrollment(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
site_id: UUID,
|
||||
agent_zone_id: UUID,
|
||||
display_name: str,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT zone.id,
|
||||
zone.site_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled,
|
||||
site.name AS site_name,
|
||||
agent.agent_id AS assigned_agent_id
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site ON site.id = zone.site_id
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_zone_id = zone.id
|
||||
WHERE zone.id = %s
|
||||
AND zone.site_id = %s
|
||||
FOR UPDATE OF zone
|
||||
""",
|
||||
(agent_zone_id, site_id),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
|
||||
if not zone:
|
||||
raise AgentEnrollmentNotFound(
|
||||
"Agent Zone was not found for the selected Site"
|
||||
)
|
||||
|
||||
if zone["is_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone is disabled"
|
||||
)
|
||||
|
||||
if zone["assigned_agent_id"]:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"SELECT clock_timestamp() AS operation_now"
|
||||
)
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET revoked_at = %s
|
||||
WHERE agent_zone_id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(operation_now, agent_zone_id),
|
||||
)
|
||||
replaced_ids = [
|
||||
str(row["id"]) for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s + interval '30 minutes'
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_at,
|
||||
expires_at
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
actor_user_id,
|
||||
operation_now,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
enrollment = dict(cur.fetchone())
|
||||
|
||||
enrollment["site_name"] = zone["site_name"]
|
||||
enrollment["agent_zone_name"] = zone["agent_zone_name"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
'agent.enrollment.created',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"site_id": str(site_id),
|
||||
"agent_zone_id": str(agent_zone_id),
|
||||
"display_name": display_name,
|
||||
"expires_at": enrollment[
|
||||
"expires_at"
|
||||
].isoformat(),
|
||||
"replaced_enrollment_ids": replaced_ids,
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
return enrollment
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated enrollment token collided"
|
||||
) from exc
|
||||
|
||||
if constraint == OPEN_ZONE_CONSTRAINT:
|
||||
raise AgentEnrollmentConflict(
|
||||
"An active enrollment already exists for this Agent Zone"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
target_agent_id: str,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(target_agent_id,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentNotFound(
|
||||
"Existing Agent with durable Site and Zone routing was not found"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is not eligible for legacy credential transition"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET revoked_at = %s
|
||||
WHERE agent_zone_id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(operation_now, agent["agent_zone_id"]),
|
||||
)
|
||||
replaced_ids = [str(row["id"]) for row in cur.fetchall()]
|
||||
|
||||
display_name = (
|
||||
str(agent["display_name"] or agent["agent_id"]).strip()[:120]
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s + interval '30 minutes', %s)
|
||||
RETURNING
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_at,
|
||||
expires_at,
|
||||
target_agent_id
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
agent["site_id"],
|
||||
agent["agent_zone_id"],
|
||||
display_name,
|
||||
actor_user_id,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
transition = dict(cur.fetchone())
|
||||
transition["site_name"] = agent["site_name"]
|
||||
transition["agent_zone_name"] = agent["agent_zone_name"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
'agent.credential_transition.created',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
transition["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"target_agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"expires_at": transition["expires_at"].isoformat(),
|
||||
"replaced_enrollment_ids": replaced_ids,
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
return transition
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated transition token collided"
|
||||
) from exc
|
||||
|
||||
if constraint in {OPEN_ZONE_CONSTRAINT, OPEN_TARGET_CONSTRAINT}:
|
||||
raise AgentEnrollmentConflict(
|
||||
"An active credential transition already exists for this Agent"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
agent_zone_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
enrollment.id,
|
||||
enrollment.site_id,
|
||||
site.name AS site_name,
|
||||
enrollment.agent_zone_id,
|
||||
zone.name AS agent_zone_name,
|
||||
enrollment.display_name,
|
||||
enrollment.created_by_user_id,
|
||||
enrollment.created_at,
|
||||
enrollment.expires_at,
|
||||
enrollment.used_at,
|
||||
enrollment.revoked_at,
|
||||
enrollment.target_agent_id,
|
||||
enrollment.enrolled_agent_id,
|
||||
CASE
|
||||
WHEN enrollment.target_agent_id IS NULL
|
||||
THEN 'new_agent'
|
||||
ELSE 'existing_agent_credential_transition'
|
||||
END AS purpose,
|
||||
CASE
|
||||
WHEN enrollment.used_at IS NOT NULL
|
||||
THEN 'used'
|
||||
WHEN enrollment.revoked_at IS NOT NULL
|
||||
THEN 'revoked'
|
||||
WHEN enrollment.expires_at <= now()
|
||||
THEN 'expired'
|
||||
ELSE 'waiting'
|
||||
END AS status
|
||||
FROM agent_enrollments AS enrollment
|
||||
JOIN sites AS site
|
||||
ON site.id = enrollment.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = enrollment.agent_zone_id
|
||||
WHERE (%s::uuid IS NULL OR enrollment.site_id = %s::uuid)
|
||||
AND (
|
||||
%s::uuid IS NULL
|
||||
OR enrollment.agent_zone_id = %s::uuid
|
||||
)
|
||||
ORDER BY enrollment.created_at DESC, enrollment.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
agent_zone_id,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def redeem_enrollment(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
agent_id: str,
|
||||
agent_secret_hash: str,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at,
|
||||
target_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
enrollment = cur.fetchone()
|
||||
|
||||
if not enrollment:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
if (
|
||||
enrollment["used_at"] is not None
|
||||
or enrollment["revoked_at"] is not None
|
||||
or enrollment["target_agent_id"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled,
|
||||
site.name AS site_name
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site ON site.id = zone.site_id
|
||||
WHERE zone.id = %s
|
||||
AND zone.site_id = %s
|
||||
FOR UPDATE OF zone
|
||||
""",
|
||||
(
|
||||
enrollment["agent_zone_id"],
|
||||
enrollment["site_id"],
|
||||
),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
|
||||
if not zone:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Enrollment Agent Zone no longer exists"
|
||||
)
|
||||
|
||||
if zone["is_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone is disabled"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_zone_id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
existing_agent = cur.fetchone()
|
||||
|
||||
if existing_agent:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"SELECT clock_timestamp() AS operation_now"
|
||||
)
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
if enrollment["expires_at"] <= operation_now:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
# Retire the identity before storing its credential. Both
|
||||
# writes use this transaction, so a failed issuance cannot
|
||||
# leave a new misleading tombstone. ON CONFLICT preserves an
|
||||
# earlier retirement if this identity has credential history.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (agent_id) DO NOTHING
|
||||
""",
|
||||
(agent_id, operation_now),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
legacy_auth_disabled_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
enrollment["site_id"],
|
||||
enrollment["agent_zone_id"],
|
||||
zone["site_name"],
|
||||
enrollment["display_name"],
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET used_at = %s,
|
||||
enrolled_agent_id = %s
|
||||
WHERE id = %s
|
||||
RETURNING used_at
|
||||
""",
|
||||
(operation_now, agent_id, enrollment["id"]),
|
||||
)
|
||||
used_at = cur.fetchone()["used_at"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT network::text AS network
|
||||
FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
ORDER BY network, id
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
networks = [
|
||||
row["network"] for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
'agent.enrollment.redeemed',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"site_id": str(enrollment["site_id"]),
|
||||
"agent_zone_id": str(
|
||||
enrollment["agent_zone_id"]
|
||||
),
|
||||
"display_name": enrollment[
|
||||
"display_name"
|
||||
],
|
||||
"used_at": used_at.isoformat(),
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
result = {
|
||||
"agent_id": agent_id,
|
||||
"site_id": enrollment["site_id"],
|
||||
"site_name": zone["site_name"],
|
||||
"agent_zone_id": enrollment["agent_zone_id"],
|
||||
"agent_zone_name": zone["agent_zone_name"],
|
||||
"display_name": enrollment["display_name"],
|
||||
"ip_ranges": networks,
|
||||
}
|
||||
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint in {
|
||||
AGENT_ID_CONSTRAINT,
|
||||
AGENT_SECRET_HASH_CONSTRAINT,
|
||||
}:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated Agent credential collided"
|
||||
) from exc
|
||||
|
||||
if constraint == AGENT_ZONE_CONSTRAINT:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
agent_secret_hash: str,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at,
|
||||
target_agent_id,
|
||||
enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
enrollment = cur.fetchone()
|
||||
|
||||
if (
|
||||
not enrollment
|
||||
or enrollment["target_agent_id"] is None
|
||||
or enrollment["revoked_at"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
agent.display_name,
|
||||
agent.agent_secret_hash,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
agent.legacy_auth_disabled_at,
|
||||
site.name AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
retirement.retired_at
|
||||
FROM minerops_site_agents AS agent
|
||||
JOIN sites AS site ON site.id = agent.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
AND zone.site_id = agent.site_id
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
WHERE agent.agent_id = %s
|
||||
FOR UPDATE OF agent, zone
|
||||
""",
|
||||
(enrollment["target_agent_id"],),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
|
||||
if not agent:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target no longer exists with durable routing"
|
||||
)
|
||||
|
||||
if (
|
||||
agent["site_id"] != enrollment["site_id"]
|
||||
or agent["agent_zone_id"] != enrollment["agent_zone_id"]
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent routing changed after transition authorization"
|
||||
)
|
||||
|
||||
if agent["agent_zone_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict("Agent Zone is disabled")
|
||||
|
||||
def transition_result() -> dict:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT network::text AS network
|
||||
FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
ORDER BY network, id
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
return {
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": agent["site_id"],
|
||||
"site_name": agent["site_name"],
|
||||
"agent_zone_id": agent["agent_zone_id"],
|
||||
"agent_zone_name": agent["agent_zone_name"],
|
||||
"display_name": str(
|
||||
agent["display_name"] or enrollment["display_name"]
|
||||
),
|
||||
"ip_ranges": [row["network"] for row in cur.fetchall()],
|
||||
}
|
||||
|
||||
# A retry after an ambiguous network response is successful only
|
||||
# for the exact locally generated credential that won the first
|
||||
# transaction. It never rotates or reveals a credential.
|
||||
if enrollment["used_at"] is not None:
|
||||
stored_hash = str(agent["agent_secret_hash"] or "")
|
||||
if (
|
||||
enrollment["enrolled_agent_id"]
|
||||
== enrollment["target_agent_id"]
|
||||
and stored_hash
|
||||
and hmac.compare_digest(stored_hash, agent_secret_hash)
|
||||
and agent["credential_created_at"] is not None
|
||||
and agent["credential_revoked_at"] is None
|
||||
and agent["legacy_auth_disabled_at"] is not None
|
||||
and agent["retired_at"] is not None
|
||||
):
|
||||
result = transition_result()
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Credential transition token has already been consumed"
|
||||
)
|
||||
|
||||
cur.execute("SELECT clock_timestamp() AS operation_now")
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
if enrollment["expires_at"] <= operation_now:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired credential transition token"
|
||||
)
|
||||
|
||||
if any(
|
||||
agent[field] is not None
|
||||
for field in (
|
||||
"agent_secret_hash",
|
||||
"credential_created_at",
|
||||
"credential_revoked_at",
|
||||
"legacy_auth_disabled_at",
|
||||
"retired_at",
|
||||
)
|
||||
):
|
||||
raise AgentEnrollmentConflict(
|
||||
"Existing Agent is no longer eligible for credential transition"
|
||||
)
|
||||
|
||||
# The durable retirement, Agent credential, enrollment terminal
|
||||
# state, and audit record commit together. Any later failure
|
||||
# rolls all of them back.
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_legacy_auth_retirements (
|
||||
agent_id,
|
||||
retired_at
|
||||
)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(agent["agent_id"], operation_now),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_site_agents
|
||||
SET agent_secret_hash = %s,
|
||||
credential_created_at = %s,
|
||||
legacy_auth_disabled_at = %s
|
||||
WHERE agent_id = %s
|
||||
RETURNING agent_id
|
||||
""",
|
||||
(
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
operation_now,
|
||||
agent["agent_id"],
|
||||
),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition target disappeared"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET used_at = %s,
|
||||
enrolled_agent_id = target_agent_id
|
||||
WHERE id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING used_at, enrolled_agent_id
|
||||
""",
|
||||
(operation_now, enrollment["id"]),
|
||||
)
|
||||
completed = cur.fetchone()
|
||||
if not completed:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Credential transition state changed concurrently"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
'agent.credential_transition.redeemed',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"agent_id": agent["agent_id"],
|
||||
"site_id": str(agent["site_id"]),
|
||||
"agent_zone_id": str(agent["agent_zone_id"]),
|
||||
"used_at": completed["used_at"].isoformat(),
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
result = transition_result()
|
||||
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == AGENT_SECRET_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Agent credential is already in use"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""HTTP routes for Agent enrollment and bootstrap."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionCreateResponse,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentCredentialTransitionResponse,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollmentCreateResponse,
|
||||
AgentEnrollmentListResponse,
|
||||
AgentEnrollRequest,
|
||||
AgentEnrollResponse,
|
||||
AgentWindowsPackageRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_packages.windows import (
|
||||
AgentWindowsPackageError,
|
||||
AgentWindowsPackageSourceError,
|
||||
build_windows_agent_package,
|
||||
normalize_hub_url,
|
||||
)
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agent-enrollment"])
|
||||
|
||||
EnrollmentService = Annotated[
|
||||
AgentEnrollmentService,
|
||||
Depends(get_agent_enrollment_service),
|
||||
]
|
||||
EnrollmentViewer = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.view")),
|
||||
]
|
||||
EnrollmentManager = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.manage")),
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_enrollment(
|
||||
request: AgentEnrollmentCreateRequest,
|
||||
response: Response,
|
||||
current_user: EnrollmentManager,
|
||||
service: EnrollmentService,
|
||||
) -> AgentEnrollmentCreateResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
enrollment = service.create_enrollment(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
return AgentEnrollmentCreateResponse(data=enrollment)
|
||||
except AgentEnrollmentNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-credential-transitions",
|
||||
response_model=AgentCredentialTransitionCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_credential_transition(
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
response: Response,
|
||||
current_user: EnrollmentManager,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionCreateResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
transition = service.create_credential_transition(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
return AgentCredentialTransitionCreateResponse(data=transition)
|
||||
except AgentEnrollmentNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentListResponse,
|
||||
)
|
||||
def list_agent_enrollments(
|
||||
current_user: EnrollmentViewer,
|
||||
service: EnrollmentService,
|
||||
site_id: UUID | None = Query(default=None),
|
||||
agent_zone_id: UUID | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> AgentEnrollmentListResponse:
|
||||
del current_user
|
||||
|
||||
return AgentEnrollmentListResponse(
|
||||
data=service.list_enrollments(
|
||||
site_id=site_id,
|
||||
agent_zone_id=agent_zone_id,
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/api/agent-enrollments/windows-package")
|
||||
def download_windows_agent_package(
|
||||
package_request: AgentWindowsPackageRequest,
|
||||
http_request: Request,
|
||||
current_user: EnrollmentManager,
|
||||
) -> Response:
|
||||
del current_user
|
||||
|
||||
browser_origin = http_request.headers.get("origin", "").strip()
|
||||
|
||||
try:
|
||||
hub_url = normalize_hub_url(browser_origin)
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Windows Agent package downloads require "
|
||||
"a valid browser Origin"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="new_agent",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return Response(
|
||||
content=package,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="MinerOpsAgent-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agent-credential-transitions/windows-package")
|
||||
def download_windows_agent_credential_transition_package(
|
||||
package_request: AgentWindowsPackageRequest,
|
||||
http_request: Request,
|
||||
current_user: EnrollmentManager,
|
||||
) -> Response:
|
||||
del current_user
|
||||
|
||||
browser_origin = http_request.headers.get("origin", "").strip()
|
||||
try:
|
||||
hub_url = normalize_hub_url(browser_origin)
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Windows Agent package downloads require "
|
||||
"a valid browser Origin"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
workflow="existing_agent_credential_transition",
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return Response(
|
||||
content=package,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="MinerOpsAgent-Credential-Upgrade-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def enroll_agent(
|
||||
request: AgentEnrollRequest,
|
||||
response: Response,
|
||||
service: EnrollmentService,
|
||||
) -> AgentEnrollResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
return AgentEnrollResponse(data=service.redeem(request))
|
||||
except AgentEnrollmentTokenInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired enrollment token",
|
||||
) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent/credential-transition",
|
||||
response_model=AgentCredentialTransitionResponse,
|
||||
)
|
||||
def transition_agent_credential(
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
response: Response,
|
||||
service: EnrollmentService,
|
||||
) -> AgentCredentialTransitionResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
return AgentCredentialTransitionResponse(
|
||||
data=service.redeem_credential_transition(request)
|
||||
)
|
||||
except AgentEnrollmentTokenInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired credential transition token",
|
||||
) from exc
|
||||
except (AgentEnrollmentConflict, AgentEnrollmentCredentialCollision) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Application service for secure one-time Agent enrollment."""
|
||||
|
||||
import secrets
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentCredentialTransitionCreateRequest,
|
||||
AgentCredentialTransitionRedeemRequest,
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
|
||||
|
||||
MAX_GENERATION_ATTEMPTS = 3
|
||||
|
||||
|
||||
def generate_enrollment_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_agent_secret() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_agent_id() -> str:
|
||||
return f"agent-{uuid4().hex}"
|
||||
|
||||
|
||||
class AgentEnrollmentService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def create_enrollment(
|
||||
self,
|
||||
request: AgentEnrollmentCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
token = generate_enrollment_token()
|
||||
try:
|
||||
enrollment = self.repository.create_enrollment(
|
||||
token_hash=hash_agent_secret(token),
|
||||
site_id=request.site_id,
|
||||
agent_zone_id=request.agent_zone_id,
|
||||
display_name=request.display_name,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {
|
||||
**enrollment,
|
||||
"enrollment_token": token,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate a unique enrollment token"
|
||||
)
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
agent_zone_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
return self.repository.list_enrollments(
|
||||
site_id=site_id,
|
||||
agent_zone_id=agent_zone_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def create_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
token = generate_enrollment_token()
|
||||
try:
|
||||
transition = self.repository.create_credential_transition(
|
||||
token_hash=hash_agent_secret(token),
|
||||
target_agent_id=request.agent_id,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {
|
||||
**transition,
|
||||
"transition_token": token,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate a unique credential transition token"
|
||||
)
|
||||
|
||||
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||
token_hash = hash_agent_secret(request.token)
|
||||
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
agent_id = generate_agent_id()
|
||||
agent_secret = generate_agent_secret()
|
||||
|
||||
try:
|
||||
result = self.repository.redeem_enrollment(
|
||||
token_hash=token_hash,
|
||||
agent_id=agent_id,
|
||||
agent_secret_hash=hash_agent_secret(agent_secret),
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"agent_secret": agent_secret,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate unique Agent credentials"
|
||||
)
|
||||
|
||||
def redeem_credential_transition(
|
||||
self,
|
||||
request: AgentCredentialTransitionRedeemRequest,
|
||||
) -> dict:
|
||||
return self.repository.redeem_credential_transition(
|
||||
token_hash=hash_agent_secret(request.token),
|
||||
agent_secret_hash=request.agent_secret_hash,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Downloadable MinerOps Agent deployment packages."""
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Build short-lived Windows Agent enrollment packages in memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||
|
||||
WINDOWS_PACKAGE_FILES = (
|
||||
"Enroll-MinerOpsAgent.ps1",
|
||||
"Upgrade-MinerOpsAgentCredential.ps1",
|
||||
"install.ps1",
|
||||
"uninstall.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
"MinerOpsAgentTray.ps1",
|
||||
"MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||
"MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1",
|
||||
"EnrichOneMiner.ps1",
|
||||
"MinerOpsControl.ps1",
|
||||
"VERSION",
|
||||
"README.md",
|
||||
)
|
||||
|
||||
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
|
||||
os.environ.get(
|
||||
"MINEROPS_WINDOWS_AGENT_SOURCE",
|
||||
"/app/agents/windows",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AgentWindowsPackageError(RuntimeError):
|
||||
"""Base Windows package construction error."""
|
||||
|
||||
|
||||
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
|
||||
"""Canonical Windows Agent source is unavailable or incomplete."""
|
||||
|
||||
|
||||
def normalize_hub_url(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
parsed = urlsplit(candidate)
|
||||
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must use http or https"
|
||||
)
|
||||
|
||||
if not parsed.netloc:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must include a host"
|
||||
)
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must not contain credentials"
|
||||
)
|
||||
|
||||
if parsed.query or parsed.fragment:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must not contain query or fragment data"
|
||||
)
|
||||
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must be an origin without a path"
|
||||
)
|
||||
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def build_windows_agent_package(
|
||||
*,
|
||||
enrollment_token: str,
|
||||
hub_url: str,
|
||||
workflow: str = "new_agent",
|
||||
source_root: Path | None = None,
|
||||
) -> bytes:
|
||||
token = enrollment_token.strip()
|
||||
|
||||
if not 32 <= len(token) <= 256:
|
||||
raise AgentWindowsPackageError(
|
||||
"Enrollment token has an invalid length"
|
||||
)
|
||||
|
||||
normalized_hub_url = normalize_hub_url(hub_url)
|
||||
|
||||
if workflow not in {
|
||||
"new_agent",
|
||||
"existing_agent_credential_transition",
|
||||
}:
|
||||
raise AgentWindowsPackageError("Unsupported Windows Agent workflow")
|
||||
|
||||
root = (
|
||||
Path(source_root)
|
||||
if source_root is not None
|
||||
else DEFAULT_WINDOWS_AGENT_SOURCE
|
||||
)
|
||||
|
||||
if not root.is_dir():
|
||||
raise AgentWindowsPackageSourceError(
|
||||
"Canonical Windows Agent source is unavailable"
|
||||
)
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name in WINDOWS_PACKAGE_FILES
|
||||
if not (root / name).is_file()
|
||||
]
|
||||
|
||||
if missing:
|
||||
raise AgentWindowsPackageSourceError(
|
||||
"Canonical Windows Agent source is incomplete: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
enrollment = {
|
||||
"hub_url": normalized_hub_url,
|
||||
"token": token,
|
||||
"workflow": workflow,
|
||||
}
|
||||
|
||||
if workflow == "new_agent":
|
||||
command = ".\\Enroll-MinerOpsAgent.ps1"
|
||||
description = "enrollment"
|
||||
else:
|
||||
command = ".\\Upgrade-MinerOpsAgentCredential.ps1"
|
||||
description = "existing-Agent credential transition"
|
||||
|
||||
instructions = f"""MinerOps Windows Agent
|
||||
|
||||
1. Extract this ZIP on the target Windows machine.
|
||||
2. Open Windows PowerShell as Administrator.
|
||||
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||
4. Run:
|
||||
|
||||
{command}
|
||||
|
||||
This package is for the {description} workflow. The credential is short-lived
|
||||
and single-use. Do not use the other bootstrap script with this package.
|
||||
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||
Delete the ZIP and extracted setup folder after successful installation.
|
||||
"""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(
|
||||
buffer,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
for name in WINDOWS_PACKAGE_FILES:
|
||||
archive.write(
|
||||
root / name,
|
||||
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
|
||||
)
|
||||
|
||||
archive.writestr(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
|
||||
json.dumps(
|
||||
enrollment,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
archive.writestr(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
|
||||
instructions,
|
||||
)
|
||||
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1 @@
|
||||
"""Read-only Agent registry projection."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependencies for the Agent registry read model."""
|
||||
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
|
||||
|
||||
def get_agent_registry_service() -> AgentRegistryService:
|
||||
return AgentRegistryService(AgentRegistryRepository())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Typed read models for Agent Management."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CredentialState = Literal["legacy", "active", "revoked", "retired"]
|
||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||
|
||||
|
||||
class AgentRegistrySite(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class AgentRegistryZone(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_enabled: bool
|
||||
assigned_agent_id: str | None = None
|
||||
networks: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryAgent(BaseModel):
|
||||
agent_id: str
|
||||
display_name: str | None = None
|
||||
|
||||
site_id: UUID | None = None
|
||||
site_name: str
|
||||
|
||||
agent_zone_id: UUID | None = None
|
||||
agent_zone_name: str | None = None
|
||||
agent_zone_enabled: bool | None = None
|
||||
|
||||
hostname: str | None = None
|
||||
version: str | None = None
|
||||
ip_address: str | None = None
|
||||
last_checkin_at: datetime
|
||||
|
||||
credential_state: CredentialState
|
||||
credential_created_at: datetime | None = None
|
||||
credential_revoked_at: datetime | None = None
|
||||
|
||||
routing_state: RoutingState
|
||||
|
||||
|
||||
class AgentRegistryData(BaseModel):
|
||||
agents: list[AgentRegistryAgent] = Field(default_factory=list)
|
||||
sites: list[AgentRegistrySite] = Field(default_factory=list)
|
||||
zones: list[AgentRegistryZone] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryResponse(BaseModel):
|
||||
data: AgentRegistryData
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,131 @@
|
||||
"""PostgreSQL reads for the durable Agent registry."""
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def default_connection_factory():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
row_factory=dict_row,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryRepository:
|
||||
def __init__(self, connection_factory=default_connection_factory):
|
||||
self.connection_factory = connection_factory
|
||||
|
||||
def list_registry(self) -> dict[str, list[dict]]:
|
||||
connection = self.connection_factory()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
site.id,
|
||||
site.name
|
||||
FROM sites AS site
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM agent_zones AS zone
|
||||
WHERE zone.site_id = site.id
|
||||
)
|
||||
ORDER BY lower(site.name), site.id
|
||||
"""
|
||||
)
|
||||
sites = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name AS site_name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id AS assigned_agent_id,
|
||||
COALESCE(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site
|
||||
ON site.id = zone.site_id
|
||||
LEFT JOIN minerops_site_agents AS assigned
|
||||
ON assigned.agent_zone_id = zone.id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
GROUP BY
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id
|
||||
ORDER BY
|
||||
lower(site.name),
|
||||
lower(zone.name),
|
||||
zone.id
|
||||
"""
|
||||
)
|
||||
zones = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.display_name,
|
||||
agent.site_id,
|
||||
COALESCE(site.name, agent.site_name) AS site_name,
|
||||
agent.agent_zone_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
agent.hostname,
|
||||
agent.version,
|
||||
agent.ip_address,
|
||||
agent.last_checkin_at,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
CASE
|
||||
WHEN agent.credential_revoked_at IS NOT NULL
|
||||
THEN 'revoked'
|
||||
WHEN agent.agent_secret_hash IS NOT NULL
|
||||
THEN 'active'
|
||||
WHEN retirement.retired_at IS NOT NULL
|
||||
THEN 'retired'
|
||||
ELSE 'legacy'
|
||||
END AS credential_state
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN agent_legacy_auth_retirements AS retirement
|
||||
ON retirement.agent_id = agent.agent_id
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
ORDER BY
|
||||
lower(COALESCE(site.name, agent.site_name)),
|
||||
lower(COALESCE(agent.display_name, agent.agent_id)),
|
||||
agent.agent_id
|
||||
"""
|
||||
)
|
||||
agents = cur.fetchall()
|
||||
|
||||
return {
|
||||
"sites": sites,
|
||||
"zones": zones,
|
||||
"agents": agents,
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Read-only Agent Management API."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.agent_registry.dependencies import get_agent_registry_service
|
||||
from app.agent_registry.models import AgentRegistryResponse
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agents"])
|
||||
|
||||
RegistryService = Annotated[
|
||||
AgentRegistryService,
|
||||
Depends(get_agent_registry_service),
|
||||
]
|
||||
|
||||
AgentViewer = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.view")),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agents",
|
||||
response_model=AgentRegistryResponse,
|
||||
)
|
||||
def list_agents(
|
||||
response: Response,
|
||||
current_user: AgentViewer,
|
||||
service: RegistryService,
|
||||
) -> AgentRegistryResponse:
|
||||
del current_user
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
data = service.list_registry()
|
||||
|
||||
return AgentRegistryResponse(
|
||||
data=data,
|
||||
meta={
|
||||
"agent_count": len(data.agents),
|
||||
"site_count": len(data.sites),
|
||||
"zone_count": len(data.zones),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Application service for the Agent Management read model."""
|
||||
|
||||
from app.agent_registry.models import (
|
||||
AgentRegistryAgent,
|
||||
AgentRegistryData,
|
||||
AgentRegistrySite,
|
||||
AgentRegistryZone,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def list_registry(self) -> AgentRegistryData:
|
||||
rows = self.repository.list_registry()
|
||||
|
||||
sites = [
|
||||
AgentRegistrySite(**row)
|
||||
for row in rows["sites"]
|
||||
]
|
||||
|
||||
zones = [
|
||||
AgentRegistryZone(**row)
|
||||
for row in rows["zones"]
|
||||
]
|
||||
|
||||
agents: list[AgentRegistryAgent] = []
|
||||
|
||||
for row in rows["agents"]:
|
||||
zone_id = row.get("agent_zone_id")
|
||||
zone_enabled = row.get("agent_zone_enabled")
|
||||
|
||||
if zone_id is None:
|
||||
routing_state = "unassigned"
|
||||
elif zone_enabled is False:
|
||||
routing_state = "disabled"
|
||||
else:
|
||||
routing_state = "assigned"
|
||||
|
||||
agents.append(
|
||||
AgentRegistryAgent(
|
||||
**row,
|
||||
routing_state=routing_state,
|
||||
)
|
||||
)
|
||||
|
||||
return AgentRegistryData(
|
||||
agents=agents,
|
||||
sites=sites,
|
||||
zones=zones,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent Zone routing domain."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Agent Zone dependency wiring."""
|
||||
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
|
||||
|
||||
def get_agent_zone_service() -> AgentZoneService:
|
||||
return AgentZoneService(AgentZoneRepository())
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Agent Zone domain exceptions."""
|
||||
|
||||
|
||||
class AgentZoneError(Exception):
|
||||
"""Base Agent Zone error."""
|
||||
|
||||
|
||||
class AgentZoneNotFound(AgentZoneError):
|
||||
"""A Site or Agent Zone does not exist."""
|
||||
|
||||
|
||||
class AgentZoneValidationError(AgentZoneError):
|
||||
"""Agent Zone input or assignment is invalid."""
|
||||
|
||||
|
||||
class AgentZoneConflict(AgentZoneError):
|
||||
"""An Agent Zone uniqueness or assignment invariant conflicts."""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Typed Agent Zone and check-in contracts."""
|
||||
|
||||
from ipaddress import ip_network
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class AgentZoneCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
site_id: UUID
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
networks: list[str] = Field(default_factory=list, max_length=64)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("name cannot be blank")
|
||||
return normalized
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def normalize_description(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
@field_validator("networks")
|
||||
@classmethod
|
||||
def normalize_networks(cls, values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
try:
|
||||
network = str(ip_network(value.strip(), strict=True))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid CIDR network: {value}") from exc
|
||||
if network in normalized:
|
||||
raise ValueError(f"duplicate CIDR network: {network}")
|
||||
normalized.append(network)
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentZoneNetwork(BaseModel):
|
||||
id: UUID
|
||||
network: str
|
||||
|
||||
|
||||
class AgentZoneSummary(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_enabled: bool
|
||||
assigned_agent_id: str | None = None
|
||||
networks: list[AgentZoneNetwork] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentZoneListResponse(BaseModel):
|
||||
data: list[AgentZoneSummary]
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentZoneCreateResponse(BaseModel):
|
||||
data: AgentZoneSummary
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentCheckinRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
agent_id: str | None = Field(default=None, max_length=255)
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
site_name: str | None = Field(default=None, max_length=255)
|
||||
hostname: str | None = Field(default=None, max_length=255)
|
||||
version: str | None = Field(default=None, max_length=120)
|
||||
ip_address: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
|
||||
if self.agent_zone_id is not None and self.site_id is None:
|
||||
raise ValueError("site_id is required when agent_zone_id is supplied")
|
||||
return self
|
||||
|
||||
|
||||
class AgentCheckinResponse(BaseModel):
|
||||
ok: bool = True
|
||||
hub_mode: bool = True
|
||||
agent_id: str
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_available: bool
|
||||
routing_reason: str | None = None
|
||||
site_name: str
|
||||
message: str = "Agent check-in received."
|
||||
|
||||
|
||||
class AgentZoneRoutingContext(BaseModel):
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
class MinerZoneAssociationResult(BaseModel):
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_reason: str | None = None
|
||||
outcomes: dict[UUID, str] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneConflict,
|
||||
AgentZoneNotFound,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentZoneRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_agent(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||
version, ip_address, last_checkin_at, last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_zone(self, zone_id: UUID) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, name, description, is_enabled,
|
||||
created_at, updated_at
|
||||
FROM agent_zones
|
||||
WHERE id = %s
|
||||
""",
|
||||
(zone_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_usable_zones(self, site_id: UUID) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, name, description, is_enabled
|
||||
FROM agent_zones
|
||||
WHERE site_id = %s AND is_enabled
|
||||
ORDER BY lower(name), id
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT zone.id, zone.site_id, zone.name, zone.description,
|
||||
zone.is_enabled, agent.agent_id AS assigned_agent_id,
|
||||
range.id AS range_id, range.network::text AS network
|
||||
FROM agent_zones AS zone
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_zone_id = zone.id
|
||||
LEFT JOIN agent_zone_network_ranges AS range
|
||||
ON range.agent_zone_id = zone.id
|
||||
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
|
||||
ORDER BY lower(zone.name), zone.id, range.network, range.id
|
||||
""",
|
||||
(site_id, site_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
zones: dict[UUID, dict] = {}
|
||||
for row in rows:
|
||||
zone = zones.setdefault(
|
||||
row["id"],
|
||||
{
|
||||
"id": row["id"],
|
||||
"site_id": row["site_id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"is_enabled": row["is_enabled"],
|
||||
"assigned_agent_id": row["assigned_agent_id"],
|
||||
"networks": [],
|
||||
},
|
||||
)
|
||||
if row["range_id"] is not None:
|
||||
zone["networks"].append(
|
||||
{"id": row["range_id"], "network": row["network"]}
|
||||
)
|
||||
return list(zones.values())
|
||||
|
||||
def create_zone(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID,
|
||||
name: str,
|
||||
description: str | None,
|
||||
networks: list[str],
|
||||
) -> UUID:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (site_id, name, description)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, name, description),
|
||||
)
|
||||
zone_id = cur.fetchone()["id"]
|
||||
for network in networks:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, %s::cidr)
|
||||
""",
|
||||
(zone_id, network),
|
||||
)
|
||||
connection.commit()
|
||||
return zone_id
|
||||
except ForeignKeyViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneNotFound("Site not found") from exc
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict(
|
||||
"An enabled Agent Zone with this name already exists at the Site"
|
||||
) from exc
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def save_checkin(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
site_id: UUID | None,
|
||||
agent_zone_id: UUID | None,
|
||||
site_name: str,
|
||||
hostname: str | None,
|
||||
version: str | None,
|
||||
ip_address: str | None,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||
version, ip_address, last_checkin_at, last_payload
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
site_id = EXCLUDED.site_id,
|
||||
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||
site_name = EXCLUDED.site_name,
|
||||
hostname = EXCLUDED.hostname,
|
||||
version = EXCLUDED.version,
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
last_checkin_at = now(),
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
ip_address,
|
||||
Jsonb(payload),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict(
|
||||
"The Agent Zone already has an assigned Agent"
|
||||
) from exc
|
||||
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_agent_assignment(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
|
||||
zone.is_enabled AS zone_enabled
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
|
||||
WHERE agent.agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_agent_configuration(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
coalesce(site.name, agent.site_name) AS site_name,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
coalesce(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text, network.id
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
WHERE agent.agent_id = %s
|
||||
GROUP BY
|
||||
agent.agent_id,
|
||||
agent.site_id,
|
||||
agent.agent_zone_id,
|
||||
site.name,
|
||||
agent.site_name,
|
||||
zone.name,
|
||||
zone.is_enabled
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def assign_miners_to_zone(
|
||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||
) -> dict[UUID, str]:
|
||||
unique_ids = list(dict.fromkeys(miner_ids))
|
||||
if not unique_ids:
|
||||
return {}
|
||||
connection = self._connection_factory()
|
||||
outcomes: dict[UUID, str] = {}
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, site_id FROM agent_zones WHERE id = %s",
|
||||
(agent_zone_id,),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
if not zone:
|
||||
raise AgentZoneNotFound("Agent Zone not found")
|
||||
for miner_id in unique_ids:
|
||||
cur.execute(
|
||||
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
|
||||
(miner_id,),
|
||||
)
|
||||
miner = cur.fetchone()
|
||||
if not miner:
|
||||
outcomes[miner_id] = "miner_not_found"
|
||||
elif miner["agent_zone_id"] == agent_zone_id:
|
||||
outcomes[miner_id] = "preserved"
|
||||
elif miner["agent_zone_id"] is not None:
|
||||
outcomes[miner_id] = "agent_zone_conflict"
|
||||
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||
outcomes[miner_id] = "site_conflict"
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE miners
|
||||
SET site_id = %s, agent_zone_id = %s, updated_at = now()
|
||||
WHERE id = %s AND agent_zone_id IS NULL
|
||||
""",
|
||||
(zone["site_id"], agent_zone_id, miner_id),
|
||||
)
|
||||
outcomes[miner_id] = "assigned"
|
||||
connection.commit()
|
||||
return outcomes
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""HTTP mapping for Agent Zones and Agent check-in."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.agent_commands.dependencies import require_agent_identity
|
||||
from app.agent_zones.dependencies import get_agent_zone_service
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneConflict,
|
||||
AgentZoneNotFound,
|
||||
AgentZoneValidationError,
|
||||
)
|
||||
from app.agent_zones.models import (
|
||||
AgentCheckinRequest,
|
||||
AgentCheckinResponse,
|
||||
AgentZoneCreateRequest,
|
||||
AgentZoneCreateResponse,
|
||||
AgentZoneListResponse,
|
||||
)
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agent-zones"])
|
||||
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
|
||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
|
||||
|
||||
|
||||
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
|
||||
def list_agent_zones(
|
||||
current_user: ZoneViewer,
|
||||
service: ZoneService,
|
||||
site_id: UUID | None = Query(default=None),
|
||||
) -> AgentZoneListResponse:
|
||||
del current_user
|
||||
return AgentZoneListResponse(data=service.list_zones(site_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-zones",
|
||||
response_model=AgentZoneCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_zone(
|
||||
request: AgentZoneCreateRequest,
|
||||
current_user: ZoneManager,
|
||||
service: ZoneService,
|
||||
) -> AgentZoneCreateResponse:
|
||||
del current_user
|
||||
try:
|
||||
return AgentZoneCreateResponse(data=service.create_zone(request))
|
||||
except AgentZoneNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentZoneConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
|
||||
def agent_checkin(
|
||||
request: AgentCheckinRequest,
|
||||
agent_id: AgentIdentity,
|
||||
service: ZoneService,
|
||||
) -> AgentCheckinResponse:
|
||||
try:
|
||||
return service.checkin(agent_id, request)
|
||||
except AgentZoneNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentZoneConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except AgentZoneValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Business rules for Agent Zone routing and transitional check-in."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneNotFound,
|
||||
AgentZoneValidationError,
|
||||
)
|
||||
from app.agent_zones.models import (
|
||||
AgentCheckinRequest,
|
||||
AgentCheckinResponse,
|
||||
AgentZoneRoutingContext,
|
||||
AgentZoneCreateRequest,
|
||||
AgentZoneSummary,
|
||||
MinerZoneAssociationResult,
|
||||
)
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"agent_secret",
|
||||
"api3_account",
|
||||
"api3_password",
|
||||
"password",
|
||||
"salt",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _sanitize(item)
|
||||
for key, item in value.items()
|
||||
if key.lower() not in SENSITIVE_KEYS
|
||||
and not any(
|
||||
marker in key.lower()
|
||||
for marker in ("secret", "password", "token", "salt")
|
||||
)
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class AgentZoneService:
|
||||
@staticmethod
|
||||
def validate_agent_identity(
|
||||
authenticated_agent_id: str, claimed_agent_id: str | None
|
||||
) -> None:
|
||||
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
|
||||
raise AgentZoneValidationError(
|
||||
"Payload agent_id does not match authenticated Agent identity"
|
||||
)
|
||||
|
||||
def __init__(self, repository: AgentZoneRepository):
|
||||
self.repository = repository
|
||||
|
||||
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
|
||||
return [
|
||||
AgentZoneSummary.model_validate(row)
|
||||
for row in self.repository.list_zones(site_id)
|
||||
]
|
||||
|
||||
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
|
||||
zone_id = self.repository.create_zone(
|
||||
site_id=request.site_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
networks=request.networks,
|
||||
)
|
||||
for zone in self.list_zones(request.site_id):
|
||||
if zone.id == zone_id:
|
||||
return zone
|
||||
raise AgentZoneNotFound("Created Agent Zone was not found")
|
||||
|
||||
def checkin(
|
||||
self, authenticated_agent_id: str, request: AgentCheckinRequest
|
||||
) -> AgentCheckinResponse:
|
||||
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||
|
||||
existing = self.repository.get_agent(authenticated_agent_id)
|
||||
requested_site = request.site_id
|
||||
requested_zone = request.agent_zone_id
|
||||
final_site = requested_site or (existing or {}).get("site_id")
|
||||
final_zone: UUID | None = None
|
||||
reason: str | None = None
|
||||
|
||||
established_zone = None
|
||||
established_zone_id = (existing or {}).get("agent_zone_id")
|
||||
if established_zone_id is not None:
|
||||
established_zone = self.repository.get_zone(established_zone_id)
|
||||
if not established_zone:
|
||||
raise AgentZoneValidationError(
|
||||
"Existing Agent Zone assignment is invalid"
|
||||
)
|
||||
if (
|
||||
requested_site is not None
|
||||
and established_zone["site_id"] != requested_site
|
||||
):
|
||||
raise AgentZoneValidationError(
|
||||
"Agent cannot change Sites while assigned to an Agent Zone"
|
||||
)
|
||||
|
||||
if requested_zone is not None:
|
||||
zone = self.repository.get_zone(requested_zone)
|
||||
if not zone:
|
||||
raise AgentZoneNotFound("Agent Zone not found")
|
||||
if zone["site_id"] != requested_site:
|
||||
raise AgentZoneValidationError(
|
||||
"Agent Zone does not belong to the supplied Site"
|
||||
)
|
||||
if not zone["is_enabled"]:
|
||||
raise AgentZoneValidationError("Agent Zone is disabled")
|
||||
if established_zone_id is not None and established_zone_id != requested_zone:
|
||||
raise AgentZoneValidationError(
|
||||
"Agent is already assigned to a different Agent Zone"
|
||||
)
|
||||
final_zone = requested_zone
|
||||
elif established_zone is not None:
|
||||
final_site = established_zone["site_id"]
|
||||
final_zone = established_zone["id"]
|
||||
if not established_zone["is_enabled"]:
|
||||
reason = "agent_zone_disabled"
|
||||
elif final_site is not None:
|
||||
usable = self.repository.list_usable_zones(final_site)
|
||||
if len(usable) == 1:
|
||||
final_zone = usable[0]["id"]
|
||||
elif not usable:
|
||||
reason = "agent_zone_unavailable"
|
||||
else:
|
||||
reason = "agent_zone_ambiguous"
|
||||
else:
|
||||
reason = "site_unassigned"
|
||||
|
||||
site_name = (request.site_name or "").strip()
|
||||
if not site_name:
|
||||
site_name = str((existing or {}).get("site_name") or "Unassigned")
|
||||
payload = _sanitize(request.model_dump(mode="json"))
|
||||
self.repository.save_checkin(
|
||||
agent_id=authenticated_agent_id,
|
||||
site_id=final_site,
|
||||
agent_zone_id=final_zone,
|
||||
site_name=site_name,
|
||||
hostname=(request.hostname or "").strip() or None,
|
||||
version=(request.version or "").strip() or None,
|
||||
ip_address=(request.ip_address or "").strip() or None,
|
||||
payload=payload,
|
||||
)
|
||||
routing_available = final_zone is not None and reason is None
|
||||
return AgentCheckinResponse(
|
||||
agent_id=authenticated_agent_id,
|
||||
site_id=final_site,
|
||||
agent_zone_id=final_zone,
|
||||
routing_available=routing_available,
|
||||
routing_reason=reason,
|
||||
site_name=site_name,
|
||||
)
|
||||
|
||||
def routing_context(
|
||||
self, agent_id: str, recorded_zone_id: UUID | None = None
|
||||
) -> AgentZoneRoutingContext:
|
||||
assignment = self.repository.get_agent_assignment(agent_id)
|
||||
if not assignment or assignment.get("agent_zone_id") is None:
|
||||
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
|
||||
zone_id = assignment["agent_zone_id"]
|
||||
if recorded_zone_id is not None and recorded_zone_id != zone_id:
|
||||
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
|
||||
if assignment.get("zone_enabled") is not True:
|
||||
return AgentZoneRoutingContext(
|
||||
site_id=assignment.get("site_id"),
|
||||
agent_zone_id=zone_id,
|
||||
routing_reason="agent_zone_disabled",
|
||||
)
|
||||
return AgentZoneRoutingContext(
|
||||
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||
)
|
||||
|
||||
def configuration(self, agent_id: str) -> dict[str, Any] | None:
|
||||
row = self.repository.get_agent_configuration(agent_id)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"site_id": row["site_id"],
|
||||
"site_name": row["site_name"],
|
||||
"agent_zone_id": row["agent_zone_id"],
|
||||
"agent_zone_name": row["agent_zone_name"],
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": row["agent_zone_name"],
|
||||
"cidr": network,
|
||||
"enabled": row["agent_zone_enabled"] is True,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433,
|
||||
}
|
||||
for network in row.get("networks") or []
|
||||
],
|
||||
}
|
||||
|
||||
def associate_miners_from_agent(
|
||||
self, agent_id: str, miner_ids: list[UUID]
|
||||
) -> MinerZoneAssociationResult:
|
||||
routing = self.routing_context(agent_id)
|
||||
if routing.routing_reason:
|
||||
return MinerZoneAssociationResult(
|
||||
agent_zone_id=routing.agent_zone_id,
|
||||
routing_reason=routing.routing_reason,
|
||||
)
|
||||
return MinerZoneAssociationResult(
|
||||
agent_zone_id=routing.agent_zone_id,
|
||||
outcomes=self.repository.assign_miners_to_zone(
|
||||
miner_ids, routing.agent_zone_id
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Minimal durable component registry for repair-part references."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Components domain."""
|
||||
|
||||
from app.components.repository import ComponentRepository
|
||||
from app.components.service import ComponentService
|
||||
|
||||
|
||||
def get_component_service() -> ComponentService:
|
||||
return ComponentService(ComponentRepository())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Domain exceptions for the minimal component registry."""
|
||||
|
||||
|
||||
class ComponentError(Exception):
|
||||
"""Base exception for component operations."""
|
||||
|
||||
|
||||
class ComponentNotFound(ComponentError):
|
||||
"""Raised when a component UUID does not exist."""
|
||||
|
||||
def __init__(self, component_id: object):
|
||||
self.component_id = component_id
|
||||
super().__init__(f"Component not found: {component_id}")
|
||||
|
||||
|
||||
class DuplicateComponentAssetID(ComponentError):
|
||||
"""Raised when a normalized component Asset ID already exists."""
|
||||
|
||||
def __init__(self, asset_id: str):
|
||||
self.asset_id = asset_id
|
||||
super().__init__(f"Component Asset ID already exists: {asset_id}")
|
||||
|
||||
|
||||
class ComponentValidationError(ComponentError):
|
||||
"""Raised when component metadata violates a domain rule."""
|
||||
|
||||
|
||||
class ComponentActorRequired(ComponentError):
|
||||
"""Raised when component creation lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("An authenticated actor is required to create a component")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Typed contracts for the minimal component registry."""
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
|
||||
|
||||
|
||||
class ComponentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = Field(default=None, max_length=255)
|
||||
manufacturer: str | None = Field(default=None, max_length=255)
|
||||
model: str | None = Field(default=None, max_length=255)
|
||||
manufacturer_serial: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ComponentDetail(BaseModel):
|
||||
component_id: UUID
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
manufacturer_serial: str | None = None
|
||||
created_at: AwareDatetime
|
||||
created_by: UUID
|
||||
|
||||
|
||||
class ComponentResponse(BaseModel):
|
||||
data: ComponentDetail
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PostgreSQL persistence for the minimal component registry."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.components.exceptions import DuplicateComponentAssetID
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class ComponentRepository:
|
||||
"""Persistence boundary for minimal component identity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator["ComponentRepository"]:
|
||||
if self._connection is not None:
|
||||
yield self
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
repository = ComponentRepository(
|
||||
self._connection_factory,
|
||||
connection=connection,
|
||||
)
|
||||
try:
|
||||
yield repository
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
|
||||
raise DuplicateComponentAssetID("normalized Asset ID") from exc
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
*,
|
||||
component_type: str,
|
||||
asset_id: str | None,
|
||||
manufacturer: str | None,
|
||||
model: str | None,
|
||||
manufacturer_serial: str | None,
|
||||
created_by: UUID,
|
||||
) -> dict:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO components (
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
""",
|
||||
(
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by,
|
||||
),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def get_component(self, component_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
FROM components
|
||||
WHERE id = %s
|
||||
""",
|
||||
(component_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HTTP mapping for the minimal component registry."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.components.dependencies import get_component_service
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import (
|
||||
ComponentCreateRequest,
|
||||
ComponentResponse,
|
||||
)
|
||||
from app.components.service import ComponentService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/components", tags=["components"])
|
||||
require_component_read = require_permission("components.read")
|
||||
require_component_create = require_permission("components.create")
|
||||
|
||||
ComponentServiceDependency = Annotated[
|
||||
ComponentService,
|
||||
Depends(get_component_service),
|
||||
]
|
||||
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
|
||||
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ComponentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_component(
|
||||
request: ComponentCreateRequest,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentCreateUser,
|
||||
) -> ComponentResponse:
|
||||
try:
|
||||
component = service.create_component(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (ComponentActorRequired, ComponentValidationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{component_id}",
|
||||
response_model=ComponentResponse,
|
||||
)
|
||||
def get_component(
|
||||
component_id: UUID,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentReadUser,
|
||||
) -> ComponentResponse:
|
||||
del current_user
|
||||
try:
|
||||
component = service.get_component(component_id)
|
||||
except ComponentNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Component not found",
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Business rules for the minimal component registry."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import ComponentCreateRequest, ComponentDetail
|
||||
from app.components.repository import ComponentRepository
|
||||
|
||||
|
||||
class ComponentService:
|
||||
"""Minimal component workflows independent of FastAPI."""
|
||||
|
||||
def __init__(self, repository: ComponentRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not be blank when supplied"
|
||||
)
|
||||
if len(normalized) > 255:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not exceed 255 characters"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _map_component(component: dict) -> ComponentDetail:
|
||||
return ComponentDetail(**component)
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
request: ComponentCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> ComponentDetail:
|
||||
if actor_user_id is None:
|
||||
raise ComponentActorRequired()
|
||||
|
||||
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
|
||||
values = {
|
||||
"manufacturer": self._normalize_optional_text(
|
||||
request.manufacturer, "manufacturer"
|
||||
),
|
||||
"model": self._normalize_optional_text(request.model, "model"),
|
||||
"manufacturer_serial": self._normalize_optional_text(
|
||||
request.manufacturer_serial, "manufacturer_serial"
|
||||
),
|
||||
}
|
||||
try:
|
||||
with self.repository.transaction() as repository:
|
||||
component = repository.create_component(
|
||||
component_type=request.component_type,
|
||||
asset_id=asset_id,
|
||||
created_by=actor_user_id,
|
||||
**values,
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise DuplicateComponentAssetID(asset_id or "") from exc
|
||||
return self._map_component(component)
|
||||
|
||||
def get_component(self, component_id: UUID) -> ComponentDetail:
|
||||
component = self.repository.get_component(component_id)
|
||||
if component is None:
|
||||
raise ComponentNotFound(component_id)
|
||||
return self._map_component(component)
|
||||
@@ -5,6 +5,8 @@ class Settings(BaseSettings):
|
||||
app_name: str = "MinerOps Console"
|
||||
app_env: str = "development"
|
||||
app_timezone: str = "America/Winnipeg"
|
||||
minerops_build_commit: str | None = None
|
||||
minerops_build_version: str | None = None
|
||||
|
||||
postgres_db: str = "minerops"
|
||||
postgres_user: str = "minerops"
|
||||
@@ -14,6 +16,9 @@ class Settings(BaseSettings):
|
||||
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
minerops_agent_id: str = "plexus-windows-agent"
|
||||
minerops_agent_shared_secret: str = ""
|
||||
|
||||
jwt_secret_key: str
|
||||
jwt_access_token_minutes: int = 30
|
||||
jwt_refresh_token_days: int = 14
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Permission-aware operational Dashboard read model."""
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Dependency construction for the Dashboard composition service."""
|
||||
|
||||
from app.dashboard.service import DashboardService
|
||||
from app.inventory.dependencies import get_inventory_service
|
||||
from app.locations.dependencies import get_location_map_service
|
||||
from app.repairs.dependencies import get_repair_center_service
|
||||
|
||||
|
||||
def get_dashboard_service() -> DashboardService:
|
||||
return DashboardService(
|
||||
inventory_service=get_inventory_service(),
|
||||
repair_center_service=get_repair_center_service(),
|
||||
location_service=get_location_map_service(),
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Typed contracts for the operational Dashboard overview."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.inventory.models import InventoryFleetSummary
|
||||
from app.locations.models import LocationSummary
|
||||
from app.repairs.models import ActiveRepairSummary
|
||||
|
||||
|
||||
class DashboardAccess(BaseModel):
|
||||
can_view_miners: bool = False
|
||||
can_view_repairs: bool = False
|
||||
can_view_locations: bool = False
|
||||
|
||||
|
||||
class DashboardOverviewData(BaseModel):
|
||||
fleet: InventoryFleetSummary | None = None
|
||||
repairs: ActiveRepairSummary | None = None
|
||||
locations: LocationSummary | None = None
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class DashboardOverviewMeta(BaseModel):
|
||||
available_sections: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DashboardOverviewResponse(BaseModel):
|
||||
data: DashboardOverviewData
|
||||
meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta)
|
||||
error: None = None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""HTTP boundary for the operational Dashboard overview."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.dashboard.dependencies import get_dashboard_service
|
||||
from app.dashboard.models import (
|
||||
DashboardAccess,
|
||||
DashboardOverviewResponse,
|
||||
)
|
||||
from app.dashboard.service import DashboardService
|
||||
from app.identity import require_permission, user_has_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
DashboardServiceDependency = Annotated[
|
||||
DashboardService,
|
||||
Depends(get_dashboard_service),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverviewResponse)
|
||||
def get_dashboard_overview(
|
||||
current_user: DashboardUser,
|
||||
service: DashboardServiceDependency,
|
||||
response: Response,
|
||||
) -> DashboardOverviewResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.get_overview(
|
||||
DashboardAccess(
|
||||
can_view_miners=user_has_permission(current_user, "miners.view"),
|
||||
can_view_repairs=user_has_permission(current_user, "repairs.view"),
|
||||
can_view_locations=user_has_permission(
|
||||
current_user,
|
||||
"locations.view",
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Composition service for permission-aware Dashboard summaries."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.dashboard.models import (
|
||||
DashboardAccess,
|
||||
DashboardOverviewData,
|
||||
DashboardOverviewMeta,
|
||||
DashboardOverviewResponse,
|
||||
)
|
||||
from app.inventory.service import InventoryService
|
||||
from app.locations.service import LocationMapService
|
||||
from app.repairs.center_service import RepairCenterService
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Coordinate domain-owned summaries without introducing new rules."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory_service: InventoryService,
|
||||
repair_center_service: RepairCenterService,
|
||||
location_service: LocationMapService,
|
||||
):
|
||||
self.inventory_service = inventory_service
|
||||
self.repair_center_service = repair_center_service
|
||||
self.location_service = location_service
|
||||
|
||||
def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse:
|
||||
fleet = (
|
||||
self.inventory_service.summarize_fleet()
|
||||
if access.can_view_miners
|
||||
else None
|
||||
)
|
||||
repairs = (
|
||||
self.repair_center_service.summarize_active_repairs()
|
||||
if access.can_view_repairs
|
||||
else None
|
||||
)
|
||||
locations = (
|
||||
self.location_service.summarize_locations(
|
||||
include_miner_counts=access.can_view_miners
|
||||
)
|
||||
if access.can_view_locations
|
||||
else None
|
||||
)
|
||||
available_sections = [
|
||||
name
|
||||
for name, value in (
|
||||
("fleet", fleet),
|
||||
("repairs", repairs),
|
||||
("locations", locations),
|
||||
)
|
||||
if value is not None
|
||||
]
|
||||
return DashboardOverviewResponse(
|
||||
data=DashboardOverviewData(
|
||||
fleet=fleet,
|
||||
repairs=repairs,
|
||||
locations=locations,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
meta=DashboardOverviewMeta(available_sections=available_sections),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Server-rendered frontend foundation for MinerOps."""
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Semantic HTML for the Internal Alpha Agent Management page."""
|
||||
|
||||
|
||||
def agent_management_content(can_manage_agents: bool) -> str:
|
||||
management = """
|
||||
<section class="panel agent-enrollment-panel"
|
||||
aria-labelledby="agentEnrollmentCreateTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
|
||||
<p class="panel-copy">
|
||||
Create a short-lived one-time enrollment for an Agent Zone.
|
||||
Windows is the currently supported deployment platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="agentCreateForm" class="agent-create-form">
|
||||
<div class="field">
|
||||
<label for="agentSite">Site</label>
|
||||
<select id="agentSite" required>
|
||||
<option value="">Choose a site</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentZone">Agent Zone</label>
|
||||
<select id="agentZone" required disabled>
|
||||
<option value="">Choose a site first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentDisplayName">Agent name</label>
|
||||
<input id="agentDisplayName"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
autocomplete="off"
|
||||
placeholder="Example: Plexus Unit 4 Agent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentPlatform">Platform</label>
|
||||
<select id="agentPlatform">
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux-x64" disabled>Linux x64 — coming later</option>
|
||||
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentCreateSubmit"
|
||||
class="command-button"
|
||||
type="submit">
|
||||
Create Windows enrollment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentCreateStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<section id="agentEnrollmentResult"
|
||||
class="agent-enrollment-result"
|
||||
aria-labelledby="agentEnrollmentResultTitle"
|
||||
hidden>
|
||||
<div>
|
||||
<p class="eyebrow">One-time credential</p>
|
||||
<h3 id="agentEnrollmentResultTitle">
|
||||
Enrollment created
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentResultCopy">
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
|
||||
<div class="agent-token-box">
|
||||
<code id="agentEnrollmentToken"></code>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentExpires" class="freshness"></p>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentEnrollmentDownload"
|
||||
class="command-button"
|
||||
type="button">
|
||||
Download Windows package
|
||||
</button>
|
||||
<button id="agentEnrollmentCopy"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Copy token
|
||||
</button>
|
||||
<button id="agentEnrollmentDismiss"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Clear token from screen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentCopyStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
|
||||
<p class="panel-copy">
|
||||
The Windows package contains the short-lived enrollment
|
||||
credential and generic MinerOps Agent runtime. Transfer it
|
||||
only to the intended Agent computer and delete the ZIP after
|
||||
successful installation.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
""" if can_manage_agents else """
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2>Agent enrollment</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="permission-note">
|
||||
Your account can view Agent status but cannot create Agent
|
||||
enrollments.
|
||||
</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div id="agentManagementLoading" class="state-message" role="status">
|
||||
<strong>Loading Agents</strong>
|
||||
<span>Requesting Agent registry and enrollment information.</span>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementError"
|
||||
class="state-message"
|
||||
data-kind="error"
|
||||
role="alert"
|
||||
hidden>
|
||||
<strong>Agent Management unavailable</strong>
|
||||
<span id="agentManagementErrorText">
|
||||
Agent information could not be loaded.
|
||||
</span>
|
||||
<button id="agentManagementRetry"
|
||||
class="command-button"
|
||||
type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent"
|
||||
data-can-manage-agents="{str(can_manage_agents).lower()}"
|
||||
hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
<strong id="agentCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Agent Zones</span>
|
||||
<strong id="agentZoneCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Waiting enrollments</span>
|
||||
<strong id="agentWaitingEnrollmentCount">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="agent-management-columns">
|
||||
<section class="panel" aria-labelledby="registeredAgentsTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Durable identities</p>
|
||||
<h2 id="registeredAgentsTitle">Agents</h2>
|
||||
<p class="panel-copy">
|
||||
Registered Agent identities and their latest factual
|
||||
check-in information.
|
||||
</p>
|
||||
</div>
|
||||
<button id="agentManagementRefresh"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Host</th>
|
||||
<th>Version</th>
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEmpty" class="panel-copy" hidden>
|
||||
No registered Agents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="agentZonesTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network routing boundaries</p>
|
||||
<h2 id="agentZonesTitle">Agent Zones</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-zone-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Zone</th>
|
||||
<th>Networks</th>
|
||||
<th>Assigned Agent</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentZoneRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentZoneEmpty" class="panel-copy" hidden>
|
||||
No Agent Zones.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{management}
|
||||
|
||||
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Bootstrap history</p>
|
||||
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
|
||||
<p class="panel-copy">
|
||||
Metadata only. Plaintext enrollment tokens are never
|
||||
returned by this history view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-enrollment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent name</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Enrolled Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentEnrollmentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
|
||||
No Agent enrollment records.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/assets/minerops-agent-management.js" defer></script>
|
||||
"""
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Semantic HTML for the Internal Alpha Miner Detail page."""
|
||||
|
||||
|
||||
def miner_detail_content() -> str:
|
||||
return """
|
||||
<div class="state-message" id="minerDetailLoading" role="status">
|
||||
<strong>Loading miner</strong>
|
||||
<span>Requesting current identity and operational context.</span>
|
||||
</div>
|
||||
<div class="state-message" id="minerDetailError" data-kind="error" role="alert" hidden>
|
||||
<strong>Miner detail unavailable</strong>
|
||||
<span id="minerDetailErrorText">The miner could not be loaded.</span>
|
||||
<button class="command-button" id="minerDetailRetry" type="button">Retry</button>
|
||||
</div>
|
||||
<div id="minerDetailContent" hidden>
|
||||
<section class="miner-detail-hero" aria-labelledby="minerIdentityTitle">
|
||||
<div>
|
||||
<p class="eyebrow">Durable miner identity</p>
|
||||
<h2 id="minerIdentityTitle">Miner</h2>
|
||||
<p id="minerDetailModel" class="panel-copy">Model unavailable</p>
|
||||
<p id="minerDetailUuid" class="miner-detail-uuid"></p>
|
||||
</div>
|
||||
<div class="miner-detail-state">
|
||||
<strong id="minerDetailOperational">Operational state unknown</strong>
|
||||
<span id="minerDetailLifecycle">Lifecycle unavailable</span>
|
||||
<span id="minerDetailLocationSummary">Location unavailable</span>
|
||||
</div>
|
||||
<nav class="miner-detail-actions" aria-label="Miner destinations">
|
||||
<a class="quiet-link" id="minerDetailReturn" href="/assets/miners" hidden>Return</a>
|
||||
<a class="quiet-link" href="/assets/miners">Back to Fleet</a>
|
||||
<a class="quiet-link" id="minerDetailMap" href="/monitoring/map" hidden>View on Mine Map</a>
|
||||
<a class="quiet-link" id="minerDetailTrends" href="/monitoring/analytics">View trends</a>
|
||||
<a class="quiet-link" id="minerDetailRepairs" href="/operations/repairs" hidden>Open filtered Repair Center</a>
|
||||
<button class="quiet-button" id="minerDetailRefresh" type="button">Refresh</button>
|
||||
</nav>
|
||||
</section>
|
||||
<div class="miner-detail-grid">
|
||||
<section class="panel" aria-labelledby="minerIdentitySectionTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Asset record</p>
|
||||
<h2 id="minerIdentitySectionTitle">Identity</h2></div></div>
|
||||
<dl class="definition-list" id="minerIdentityDetails"></dl>
|
||||
<div id="minerTags" class="tag-list" aria-label="Miner tags"></div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="minerCurrentStateTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Latest facts</p>
|
||||
<h2 id="minerCurrentStateTitle">Current state</h2></div>
|
||||
<span class="freshness" id="minerTelemetryAge">No observation</span></div>
|
||||
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel miner-controls" id="minerControlsSection"
|
||||
aria-labelledby="minerControlsTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
|
||||
<h2 id="minerControlsTitle">Controls</h2></div>
|
||||
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
|
||||
</div>
|
||||
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
|
||||
Loading factual capability and recent operations.
|
||||
</p>
|
||||
<div id="minerControlCapabilities" class="control-capability-grid"></div>
|
||||
<div id="minerControlActions" class="miner-control-actions" hidden>
|
||||
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
|
||||
<form id="minerPerformanceControl" class="performance-control" hidden>
|
||||
<label for="minerPerformanceMode">Performance mode</label>
|
||||
<select id="minerPerformanceMode">
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<button class="command-button" type="submit">Set mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p id="minerControlPermission" class="permission-note" hidden>
|
||||
Your account can inspect control capability and history but cannot queue controls.
|
||||
</p>
|
||||
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
|
||||
<div class="table-wrap compact-history-table">
|
||||
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
|
||||
<th>Verification</th><th>Created</th></tr></thead>
|
||||
<tbody id="minerControlHistory"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
||||
<h2 id="minerLocationTitle">Location</h2></div></div>
|
||||
<div id="minerLocationBody"></div>
|
||||
</section>
|
||||
<section class="panel" id="minerRepairSection" aria-labelledby="minerRepairTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Work context</p>
|
||||
<h2 id="minerRepairTitle">Repair context</h2></div></div>
|
||||
<div id="minerRepairBody"></div>
|
||||
<div id="minerRepairEpisodes" class="disclosure-list"></div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="minerHistoryTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Recorded facts</p>
|
||||
<h2 id="minerHistoryTitle">History</h2></div></div>
|
||||
<div class="disclosure-list">
|
||||
<details id="minerIdentityHistory">
|
||||
<summary>Identity history</summary>
|
||||
<div class="lazy-content" id="minerIdentityHistoryBody" aria-live="polite">Open to load Asset ID and network identity history.</div>
|
||||
</details>
|
||||
<details id="minerObservations">
|
||||
<summary>Recent observations</summary>
|
||||
<div class="lazy-content" id="minerObservationsBody" aria-live="polite">Open to load up to 100 recent factual observations.</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
|
||||
|
||||
|
||||
def monitoring_trends_content(can_view_locations: bool) -> str:
|
||||
location_options = (
|
||||
'<option value="site">Site</option><option value="room">Room</option>'
|
||||
if can_view_locations
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
<section class="trend-controls panel" aria-labelledby="trendControlsTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Historical scope</p>
|
||||
<h2 id="trendControlsTitle">Trend controls</h2></div>
|
||||
<p class="freshness" id="trendFreshness">Not loaded</p></div>
|
||||
<form id="trendControls">
|
||||
<label for="trendScope">Scope
|
||||
<select id="trendScope">
|
||||
<option value="fleet">Fleet</option>{location_options}
|
||||
</select>
|
||||
</label>
|
||||
<label for="trendSite" id="trendSiteLabel" hidden>Site
|
||||
<select id="trendSite"></select>
|
||||
</label>
|
||||
<label for="trendRoom" id="trendRoomLabel" hidden>Room
|
||||
<select id="trendRoom"></select>
|
||||
</label>
|
||||
<fieldset class="trend-ranges">
|
||||
<legend>Time range</legend>
|
||||
<label><input type="radio" name="trendRange" value="6h">6 hours</label>
|
||||
<label><input type="radio" name="trendRange" value="24h" checked>24 hours</label>
|
||||
<label><input type="radio" name="trendRange" value="7d">7 days</label>
|
||||
</fieldset>
|
||||
<button class="command-button" type="submit">Apply</button>
|
||||
</form>
|
||||
<p class="panel-copy" id="trendScopeNote">Fleet trends use current miner inventory.</p>
|
||||
</section>
|
||||
<div class="state-message" id="trendLoading" role="status">
|
||||
<strong>Loading trends</strong><span>Aggregating bounded raw telemetry.</span>
|
||||
</div>
|
||||
<div class="state-message" id="trendError" data-kind="error" role="alert" hidden>
|
||||
<strong>Trends unavailable</strong><span id="trendErrorText">The request failed.</span>
|
||||
<button class="command-button" id="trendRetry" type="button">Retry</button>
|
||||
</div>
|
||||
<section id="trendContent" aria-label="Historical telemetry trends" hidden>
|
||||
<div class="trend-summary" aria-live="polite">
|
||||
<p><strong id="trendScopeLabel">Fleet</strong></p>
|
||||
<p id="trendRangeSummary"></p>
|
||||
<p id="trendTimezone"></p>
|
||||
</div>
|
||||
<div id="trendCharts" class="trend-charts"></div>
|
||||
</section>
|
||||
"""
|
||||
@@ -0,0 +1,163 @@
|
||||
"""HTTP routes for the MinerOps Internal Alpha application shell."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from app.frontend.views import render_app_page
|
||||
from app.identity import require_permission, safe_next_path, user_has_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
STATIC_ROOT = Path(__file__).with_name("static")
|
||||
|
||||
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||
|
||||
|
||||
def render_page(view: str, current_user: dict, **context) -> str:
|
||||
return render_app_page(
|
||||
view,
|
||||
current_user,
|
||||
lambda permission: user_has_permission(current_user, permission),
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-app.css", include_in_schema=False)
|
||||
def frontend_styles() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "app.css").read_text(encoding="utf-8"),
|
||||
media_type="text/css",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-app.js", include_in_schema=False)
|
||||
def frontend_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "app.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-dashboard.js", include_in_schema=False)
|
||||
def dashboard_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
|
||||
def mine_map_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
|
||||
def fleet_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/assets/minerops-repair-center.js", include_in_schema=False)
|
||||
def repair_center_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-miner-detail.js", include_in_schema=False)
|
||||
def miner_detail_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/assets/minerops-agent-management.js",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/management",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_page(current_user: AgentUser) -> str:
|
||||
return render_page("agent_management", current_user)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard_page(current_user: DashboardUser) -> str:
|
||||
return render_page("dashboard", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/monitoring/map",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def mine_map_page(current_user: MapUser) -> str:
|
||||
return render_page("map", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations/repairs",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def repair_center_page(current_user: RepairUser) -> str:
|
||||
return render_page("repairs", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assets/miners",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def miners_page(current_user: MinerUser) -> str:
|
||||
return render_page("miners", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/miners/{miner_id}/detail",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def miner_detail_page(
|
||||
miner_id: UUID,
|
||||
current_user: MinerUser,
|
||||
return_to: Annotated[str | None, Query(max_length=2048)] = None,
|
||||
) -> str:
|
||||
safe_return = safe_next_path(return_to) if return_to else None
|
||||
if safe_return != return_to:
|
||||
safe_return = None
|
||||
return render_page("miner_detail", current_user, miner_id=str(miner_id),
|
||||
return_to=safe_return)
|
||||
@@ -0,0 +1,567 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "agent_management") return;
|
||||
|
||||
const ids = [
|
||||
"agentManagementLoading",
|
||||
"agentManagementError",
|
||||
"agentManagementErrorText",
|
||||
"agentManagementRetry",
|
||||
"agentManagementContent",
|
||||
"agentManagementRefresh",
|
||||
"agentCount",
|
||||
"agentZoneCount",
|
||||
"agentWaitingEnrollmentCount",
|
||||
"agentRows",
|
||||
"agentEmpty",
|
||||
"agentZoneRows",
|
||||
"agentZoneEmpty",
|
||||
"agentEnrollmentRows",
|
||||
"agentEnrollmentEmpty",
|
||||
"agentCreateForm",
|
||||
"agentSite",
|
||||
"agentZone",
|
||||
"agentDisplayName",
|
||||
"agentPlatform",
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentResultTitle",
|
||||
"agentEnrollmentResultCopy",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
"agentEnrollmentCopy",
|
||||
"agentEnrollmentDismiss",
|
||||
"agentEnrollmentCopyStatus"
|
||||
];
|
||||
|
||||
const el = {};
|
||||
|
||||
ids.forEach(function (id) {
|
||||
el[id] = document.getElementById(id);
|
||||
});
|
||||
|
||||
let registry = {
|
||||
agents: [],
|
||||
sites: [],
|
||||
zones: []
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
let packageEndpoint = "/api/agent-enrollments/windows-package";
|
||||
let packageFilename = "MinerOpsAgent-Windows.zip";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return fallback || "Unavailable";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Never";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return text(value, "Unknown")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, function (letter) {
|
||||
return letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function cell(value, className) {
|
||||
const node = document.createElement("td");
|
||||
if (className) node.className = className;
|
||||
node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function setState(name) {
|
||||
el.agentManagementLoading.hidden = name !== "loading";
|
||||
el.agentManagementError.hidden = name !== "error";
|
||||
el.agentManagementContent.hidden = name !== "ready";
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, Object.assign({
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}, options || {}));
|
||||
|
||||
const payload = await response.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload && payload.detail
|
||||
? String(payload.detail)
|
||||
: "Request failed with status " + response.status + ".";
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderAgents() {
|
||||
el.agentRows.replaceChildren();
|
||||
|
||||
registry.agents.forEach(function (agent) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const identity = document.createElement("td");
|
||||
const name = document.createElement("strong");
|
||||
const id = document.createElement("span");
|
||||
|
||||
name.textContent = text(agent.display_name, "Unnamed Agent");
|
||||
id.className = "secondary";
|
||||
id.textContent = agent.agent_id;
|
||||
|
||||
identity.append(name, id);
|
||||
|
||||
const location = [
|
||||
text(agent.site_name, "Unknown site"),
|
||||
text(agent.agent_zone_name, "No Agent Zone")
|
||||
].join(" / ");
|
||||
|
||||
const host = [
|
||||
text(agent.hostname, "Unknown host"),
|
||||
text(agent.ip_address, "No IP")
|
||||
].join(" / ");
|
||||
|
||||
row.append(
|
||||
identity,
|
||||
cell(location),
|
||||
cell(host),
|
||||
cell(text(agent.version, "Unknown")),
|
||||
cell(dateTime(agent.last_checkin_at)),
|
||||
cell(words(agent.credential_state)),
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
const action = document.createElement("td");
|
||||
const canManage =
|
||||
el.agentManagementContent.dataset.canManageAgents === "true";
|
||||
if (
|
||||
canManage &&
|
||||
agent.credential_state === "legacy" &&
|
||||
agent.routing_state === "assigned"
|
||||
) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "quiet-button";
|
||||
button.textContent = "Upgrade credential";
|
||||
button.addEventListener("click", function () {
|
||||
createCredentialTransition(agent);
|
||||
});
|
||||
action.appendChild(button);
|
||||
} else {
|
||||
action.textContent = "—";
|
||||
}
|
||||
row.appendChild(action);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEmpty.hidden = registry.agents.length !== 0;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
el.agentZoneRows.replaceChildren();
|
||||
|
||||
registry.zones.forEach(function (zone) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const networks = Array.isArray(zone.networks) && zone.networks.length
|
||||
? zone.networks.join(", ")
|
||||
: "No networks recorded";
|
||||
|
||||
row.append(
|
||||
cell(zone.site_name),
|
||||
cell(zone.name),
|
||||
cell(networks),
|
||||
cell(text(zone.assigned_agent_id, "Unassigned")),
|
||||
cell(zone.is_enabled ? "Enabled" : "Disabled")
|
||||
);
|
||||
|
||||
el.agentZoneRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
|
||||
}
|
||||
|
||||
function renderEnrollments() {
|
||||
el.agentEnrollmentRows.replaceChildren();
|
||||
|
||||
enrollments.forEach(function (enrollment) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
row.append(
|
||||
cell(enrollment.display_name),
|
||||
cell(
|
||||
enrollment.site_name + " / " +
|
||||
enrollment.agent_zone_name
|
||||
),
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(
|
||||
enrollment.enrolled_agent_id || enrollment.target_agent_id,
|
||||
"Not enrolled"
|
||||
))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
|
||||
}
|
||||
|
||||
function populateSites() {
|
||||
if (!el.agentSite) return;
|
||||
|
||||
const selected = el.agentSite.value;
|
||||
|
||||
el.agentSite.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Choose a site";
|
||||
el.agentSite.appendChild(placeholder);
|
||||
|
||||
registry.sites.forEach(function (site) {
|
||||
const option = document.createElement("option");
|
||||
option.value = site.id;
|
||||
option.textContent = site.name;
|
||||
el.agentSite.appendChild(option);
|
||||
});
|
||||
|
||||
if ([...el.agentSite.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentSite.value = selected;
|
||||
}
|
||||
|
||||
populateZones();
|
||||
}
|
||||
|
||||
function populateZones() {
|
||||
if (!el.agentZone || !el.agentSite) return;
|
||||
|
||||
const siteId = el.agentSite.value;
|
||||
const selected = el.agentZone.value;
|
||||
|
||||
el.agentZone.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
|
||||
if (!siteId) {
|
||||
placeholder.textContent = "Choose a site first";
|
||||
el.agentZone.disabled = true;
|
||||
el.agentZone.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.textContent = "Choose an Agent Zone";
|
||||
el.agentZone.appendChild(placeholder);
|
||||
|
||||
const available = registry.zones.filter(function (zone) {
|
||||
return (
|
||||
zone.site_id === siteId &&
|
||||
zone.is_enabled === true &&
|
||||
!zone.assigned_agent_id
|
||||
);
|
||||
});
|
||||
|
||||
available.forEach(function (zone) {
|
||||
const option = document.createElement("option");
|
||||
option.value = zone.id;
|
||||
option.textContent = zone.name;
|
||||
el.agentZone.appendChild(option);
|
||||
});
|
||||
|
||||
el.agentZone.disabled = available.length === 0;
|
||||
|
||||
if ([...el.agentZone.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentZone.value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
el.agentCount.textContent = String(registry.agents.length);
|
||||
el.agentZoneCount.textContent = String(registry.zones.length);
|
||||
|
||||
const waiting = enrollments.filter(function (item) {
|
||||
return item.status === "waiting";
|
||||
}).length;
|
||||
|
||||
el.agentWaitingEnrollmentCount.textContent = String(waiting);
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSummary();
|
||||
renderAgents();
|
||||
renderZones();
|
||||
renderEnrollments();
|
||||
populateSites();
|
||||
}
|
||||
|
||||
async function refreshData(showLoading) {
|
||||
if (showLoading !== false) setState("loading");
|
||||
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api("/api/agents"),
|
||||
api("/api/agent-enrollments")
|
||||
]);
|
||||
|
||||
registry = results[0] && results[0].data
|
||||
? results[0].data
|
||||
: { agents: [], sites: [], zones: [] };
|
||||
|
||||
enrollments = results[1] && Array.isArray(results[1].data)
|
||||
? results[1].data
|
||||
: [];
|
||||
|
||||
render();
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
el.agentManagementErrorText.textContent =
|
||||
error.message || "Agent information could not be loaded.";
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function createEnrollment(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!el.agentCreateForm) return;
|
||||
|
||||
el.agentCreateSubmit.disabled = true;
|
||||
el.agentCreateStatus.textContent = "Creating enrollment...";
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-enrollments", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
site_id: el.agentSite.value,
|
||||
agent_zone_id: el.agentZone.value,
|
||||
display_name: el.agentDisplayName.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
showOneTimeToken({
|
||||
token: enrollment.enrollment_token,
|
||||
expiresAt: enrollment.expires_at,
|
||||
title: "Enrollment created",
|
||||
copy: "This token creates a new Agent identity. Keep it private. " +
|
||||
"MinerOps does not store the plaintext token.",
|
||||
endpoint: "/api/agent-enrollments/windows-package",
|
||||
filename: "MinerOpsAgent-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Enrollment could not be created.";
|
||||
} finally {
|
||||
el.agentCreateSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showOneTimeToken(options) {
|
||||
el.agentEnrollmentToken.textContent = options.token;
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(options.expiresAt);
|
||||
el.agentEnrollmentResultTitle.textContent = options.title;
|
||||
el.agentEnrollmentResultCopy.textContent = options.copy;
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
packageEndpoint = options.endpoint;
|
||||
packageFilename = options.filename;
|
||||
}
|
||||
|
||||
async function createCredentialTransition(agent) {
|
||||
const confirmed = window.confirm(
|
||||
"Create a one-time credential upgrade for " + agent.agent_id +
|
||||
"? Its Agent identity, Site, and Zone will remain unchanged."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-credential-transitions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({agent_id: agent.agent_id})
|
||||
});
|
||||
const transition = payload.data;
|
||||
showOneTimeToken({
|
||||
token: transition.transition_token,
|
||||
expiresAt: transition.expires_at,
|
||||
title: "Existing Agent credential upgrade created",
|
||||
copy: "This token is bound to the existing Agent identity " +
|
||||
transition.target_agent_id + ". It cannot enroll a new " +
|
||||
"Agent or transfer its Zone.",
|
||||
endpoint: "/api/agent-credential-transitions/windows-package",
|
||||
filename: "MinerOpsAgent-Credential-Upgrade-Windows.zip"
|
||||
});
|
||||
el.agentCreateStatus.textContent =
|
||||
"Credential upgrade created for " + transition.target_agent_id + ".";
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Credential upgrade could not be created.";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWindowsPackage() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
if (!token) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"No enrollment token is available to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
el.agentEnrollmentDownload.disabled = true;
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Building Windows Agent package...";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
packageEndpoint,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Accept": "application/zip",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
payload && payload.detail
|
||||
? String(payload.detail)
|
||||
: "Package request failed with status " +
|
||||
response.status + "."
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = packageFilename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
dismissToken();
|
||||
|
||||
el.agentCreateStatus.textContent =
|
||||
"Windows Agent package downloaded. " +
|
||||
"The on-screen token has been cleared.";
|
||||
} catch (error) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
error.message || "Windows package could not be downloaded.";
|
||||
} finally {
|
||||
el.agentEnrollmentDownload.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Enrollment token copied.";
|
||||
} catch (error) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Copy was unavailable. Select and copy the token manually.";
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToken() {
|
||||
el.agentEnrollmentToken.textContent = "";
|
||||
el.agentEnrollmentExpires.textContent = "";
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = true;
|
||||
}
|
||||
|
||||
el.agentManagementRetry.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
|
||||
if (el.agentManagementRefresh) {
|
||||
el.agentManagementRefresh.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (el.agentSite) {
|
||||
el.agentSite.addEventListener("change", populateZones);
|
||||
}
|
||||
|
||||
if (el.agentCreateForm) {
|
||||
el.agentCreateForm.addEventListener("submit", createEnrollment);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDownload) {
|
||||
el.agentEnrollmentDownload.addEventListener(
|
||||
"click",
|
||||
downloadWindowsPackage
|
||||
);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentCopy) {
|
||||
el.agentEnrollmentCopy.addEventListener("click", copyToken);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDismiss) {
|
||||
el.agentEnrollmentDismiss.addEventListener(
|
||||
"click",
|
||||
dismissToken
|
||||
);
|
||||
}
|
||||
|
||||
refreshData(true);
|
||||
}());
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user