Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0d32cd6b | ||
|
|
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 | ||
|
|
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,198 +1,322 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="branding/wordmark.svg" alt="MinerOps" width="320">
|
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">MinerOps</h1>
|
<h1 align="center">MinerOps</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
||||||
<br>
|
|
||||||
Know every miner. Track every repair. Control every rack.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Know every miner • Track every repair • Control every rack
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
MinerOps is a self-hosted platform intended to provide one authoritative source
|
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
|
||||||
of truth for cryptocurrency mining operations. It is being built to connect
|
|
||||||
asset records, network identity, repair history, physical location, telemetry,
|
|
||||||
and operational controls without depending on a hosted management service.
|
|
||||||
|
|
||||||
The repository contains a working early-stage application and the first
|
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
|
||||||
foundations of a more modular architecture. Current development is focused on
|
|
||||||
turning those foundations into well-defined product domains with tested service
|
|
||||||
and persistence boundaries.
|
|
||||||
|
|
||||||
The longer-term product direction combines:
|
MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
|
||||||
|
|
||||||
- Miner inventory
|
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.
|
||||||
- Network discovery and identity tracking
|
|
||||||
- Repairs and maintenance
|
|
||||||
- Locations and electrical infrastructure
|
|
||||||
- Monitoring and alerts
|
|
||||||
- Fleet control
|
|
||||||
- Scheduling and automation
|
|
||||||
- Historical records and reporting
|
|
||||||
|
|
||||||
These areas describe the product direction, not a claim that every workflow is
|
---
|
||||||
complete or ready for production use.
|
|
||||||
|
|
||||||
## Why MinerOps?
|
# Vision
|
||||||
|
|
||||||
Mining operations often accumulate several partial versions of the truth:
|
Running a mining operation requires far more than knowing whether a miner is online.
|
||||||
spreadsheets for assets, separate monitoring tools for performance, informal
|
|
||||||
repair notes, and location knowledge held by individual technicians.
|
|
||||||
|
|
||||||
That fragmentation becomes harder to manage when IP addresses change, control
|
MinerOps is designed to become the operational hub for an entire mining facility by combining:
|
||||||
boards or network interfaces are replaced, equipment moves between racks, and
|
|
||||||
repair history is incomplete. MinerOps is intended to give operators one
|
|
||||||
self-hosted operational record that follows each miner through its full
|
|
||||||
lifecycle.
|
|
||||||
|
|
||||||
## Core Capabilities
|
- Asset Inventory
|
||||||
|
- Physical Location Management
|
||||||
|
- Repair & Maintenance Tracking
|
||||||
|
- Live Fleet Monitoring
|
||||||
|
- Historical Reporting
|
||||||
|
- Secure Remote Management
|
||||||
|
- Operational Analytics
|
||||||
|
- Fleet Automation
|
||||||
|
|
||||||
### Asset and Inventory Management
|
into one cohesive platform.
|
||||||
|
|
||||||
Maintain stable asset records independently of a miner's current IP address.
|
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
|
||||||
The inventory schema and backend domain foundation currently support Asset IDs,
|
|
||||||
hardware identifiers, lifecycle state, tags, notes, location references, and
|
|
||||||
seen timestamps. Inventory HTTP APIs and product workflows are the next
|
|
||||||
application milestone.
|
|
||||||
|
|
||||||
### Miner Identity and Discovery
|
---
|
||||||
|
|
||||||
Associate changing network information with durable miner records. Existing
|
# Current Status
|
||||||
code includes discovery and network identity foundations; formal identity
|
|
||||||
history and transfer workflows remain planned work.
|
|
||||||
|
|
||||||
### Repairs and Maintenance
|
⚠️ **MinerOps is under active development.**
|
||||||
|
|
||||||
Build a complete service history around each miner, including repair status,
|
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
|
||||||
notes, parts, and return-to-service workflows. Operational repair functionality
|
|
||||||
exists today, while extraction into a dedicated domain architecture is still
|
|
||||||
planned.
|
|
||||||
|
|
||||||
### Locations and Infrastructure
|
## Completed
|
||||||
|
|
||||||
Track where equipment is installed and relate miners to sites, rooms, racks,
|
|
||||||
shelves, and operational infrastructure. Existing location workflows will be
|
|
||||||
refined as the modular architecture develops.
|
|
||||||
|
|
||||||
### Monitoring and Health
|
|
||||||
|
|
||||||
Collect miner status and telemetry, identify stale or unhealthy equipment, and
|
|
||||||
support operational health views. The repository contains current telemetry and
|
|
||||||
retention foundations; broader alerting remains planned.
|
|
||||||
|
|
||||||
### Fleet Operations
|
|
||||||
|
|
||||||
Provide controlled fleet actions while keeping command policy, authorization,
|
|
||||||
and site-local execution explicit. Existing command workflows are under active
|
|
||||||
development and should not be treated as a finished control platform.
|
|
||||||
|
|
||||||
### Automation and Scheduling
|
|
||||||
|
|
||||||
Coordinate recurring scans, scheduled actions, retention, and other repeatable
|
|
||||||
operations. Scheduling and automation will expand after the core inventory and
|
|
||||||
identity domains are established.
|
|
||||||
|
|
||||||
### Audit History and Reporting
|
|
||||||
|
|
||||||
Preserve important operational changes and support practical reporting across
|
|
||||||
assets, repairs, locations, and fleet activity. Audit-log and reporting
|
|
||||||
foundations exist; the complete reporting model remains planned.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
MinerOps is under active development.
|
|
||||||
|
|
||||||
**Current development stage:** `v0.2 - Foundation`
|
|
||||||
|
|
||||||
Verified foundations in the repository include:
|
|
||||||
|
|
||||||
- Docker-based development environment
|
- Docker-based development environment
|
||||||
- PostgreSQL data store
|
- PostgreSQL + Redis backend
|
||||||
- Redis service
|
- Alembic migration framework
|
||||||
- Alembic schema migrations
|
- Authentication
|
||||||
- Session-based authentication and login gating
|
- Role-based access control
|
||||||
- Admin, Manager, Technician, and Read Only role foundations
|
- Manual inventory schema
|
||||||
- Manual miner inventory database schema
|
- Inventory domain architecture
|
||||||
- Inventory Pydantic models
|
- Automated migration testing
|
||||||
- Inventory repository and service domain foundation
|
- Disposable development environment
|
||||||
- Automated repository and service tests for the Inventory domain
|
|
||||||
- Disposable development baseline tests
|
|
||||||
|
|
||||||
**Next application milestone:** `v0.3 - Inventory`
|
## Currently In Progress
|
||||||
|
|
||||||
**Next technical feature:** Inventory API
|
- Inventory API
|
||||||
|
- Inventory user interface
|
||||||
|
- Domain-driven architecture migration
|
||||||
|
|
||||||
## Architecture
|
## Planned
|
||||||
|
|
||||||
MinerOps is moving toward a modular backend with deliberate separation between
|
- Asset identity & MAC history
|
||||||
transport, business rules, and persistence:
|
- 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
|
||||||
|
|
||||||
```text
|
---
|
||||||
Frontend
|
|
||||||
|
|
# Core Features
|
||||||
v
|
|
||||||
FastAPI routers
|
## 📦 Asset Inventory
|
||||||
|
|
|
||||||
v
|
Maintain a permanent identity for every miner independent of its network configuration.
|
||||||
Domain services
|
|
||||||
|
|
Planned capabilities include:
|
||||||
v
|
|
||||||
Repositories
|
- Asset IDs
|
||||||
|
|
- MAC history
|
||||||
v
|
- 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
|
||||||
|
- Attachments
|
||||||
|
- Maintenance mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Fleet Monitoring
|
||||||
|
|
||||||
|
Monitor operational health across the fleet.
|
||||||
|
|
||||||
|
Planned monitoring includes:
|
||||||
|
|
||||||
|
- Hashrate
|
||||||
|
- Temperature
|
||||||
|
- Fan status
|
||||||
|
- Hashboard health
|
||||||
|
- PSU status
|
||||||
|
- Pool connectivity
|
||||||
|
- Worker information
|
||||||
|
- Firmware
|
||||||
|
- Alerts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Network Discovery
|
||||||
|
|
||||||
|
Automatically discover miners while maintaining stable asset identities.
|
||||||
|
|
||||||
|
Planned capabilities include:
|
||||||
|
|
||||||
|
- Configurable scan ranges
|
||||||
|
- Duplicate detection
|
||||||
|
- MAC history
|
||||||
|
- IP history
|
||||||
|
- Capability detection
|
||||||
|
- Discovery review workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎛 Secure Remote Control
|
||||||
|
|
||||||
|
Safely execute commands against supported miners.
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
- Reboot
|
||||||
|
- Shutdown
|
||||||
|
- Performance mode
|
||||||
|
- Pool configuration
|
||||||
|
- Firmware operations
|
||||||
|
|
||||||
|
All commands will be permission-aware, audited, and designed for safe execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛰 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
|
PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
FastAPI routers will own HTTP request and response concerns. Domain services
|
Business rules belong in Services.
|
||||||
will own operational rules and orchestration. Repositories will own database
|
|
||||||
access. The Inventory domain is the first backend area established with these
|
|
||||||
boundaries; other working application areas are still being migrated
|
|
||||||
incrementally.
|
|
||||||
|
|
||||||
## Technology Stack
|
Database access belongs in Repositories.
|
||||||
|
|
||||||
Current technologies:
|
Schema changes are managed exclusively through Alembic migrations.
|
||||||
|
|
||||||
|
This architecture keeps MinerOps modular, testable, and easy to extend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
Current
|
||||||
|
|
||||||
- Python
|
- Python
|
||||||
- FastAPI
|
- FastAPI
|
||||||
- Pydantic
|
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Alembic
|
|
||||||
- Redis
|
- Redis
|
||||||
- Docker and Docker Compose
|
- Alembic
|
||||||
- Nginx development gateway
|
- Docker Compose
|
||||||
|
- Pydantic
|
||||||
|
|
||||||
A typed, responsive frontend with a shared design system is planned. Its
|
Planned
|
||||||
technology selection has not yet been finalized in this repository.
|
|
||||||
|
|
||||||
## Roadmap
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Site Agents
|
||||||
|
- Protocol adapters
|
||||||
|
- Background task processing
|
||||||
|
|
||||||
- `v0.2 - Foundation`
|
---
|
||||||
- `v0.3 - Inventory`
|
|
||||||
- `v0.4 - Identity`
|
|
||||||
- `v0.5 - Operations`
|
|
||||||
- `v0.6 - Monitoring`
|
|
||||||
- `v0.7 - Automation`
|
|
||||||
- `v1.0 - Production Ready`
|
|
||||||
|
|
||||||
Roadmap scope and release boundaries may evolve as development continues and
|
# Development Philosophy
|
||||||
operational requirements become clearer.
|
|
||||||
|
|
||||||
## Documentation
|
MinerOps is built around a few guiding principles.
|
||||||
|
|
||||||
Detailed product, architecture, development, database, testing, and roadmap
|
- Self-hosted first
|
||||||
documentation will live under `docs/`. That documentation set is still being
|
- Reliability over unnecessary complexity
|
||||||
developed, so this README does not link to files that do not yet exist.
|
- 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
|
||||||
|
|
||||||
## Contributing
|
---
|
||||||
|
|
||||||
MinerOps is in early development. Contribution guidelines will be added as the
|
# Roadmap
|
||||||
architecture, development workflow, and review expectations stabilize.
|
|
||||||
|
|
||||||
## License
|
| 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 |
|
||||||
|
|
||||||
The MinerOps license has not yet been finalized. No license file is currently
|
Detailed planning will be maintained in **docs/ROADMAP.md**.
|
||||||
included in this repository.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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,283 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$IpAddress,
|
||||||
|
|
||||||
|
[string]$MinerId = "",
|
||||||
|
[string]$DiscoveryId = "",
|
||||||
|
[string]$ObservationVersion = "",
|
||||||
|
[string]$CommandId = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$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
|
||||||
|
|
||||||
|
# Revalidate the claimed command against current Hub authority before miner sockets.
|
||||||
|
if (!$DiscoveryId -or !$ObservationVersion -or !$CommandId) { throw "discovery_observation_required" }
|
||||||
|
$target = Invoke-RestMethod -Method Post -Uri "$hubUrl/api/agent/discovery-target" `
|
||||||
|
-Headers @{ "X-MinerOps-Agent-ID" = $agentId; "X-MinerOps-Agent-Secret" = $secret } `
|
||||||
|
-ContentType "application/json" -Body (@{ discovery_id=$DiscoveryId; version=$ObservationVersion; command_id=$CommandId } | ConvertTo-Json)
|
||||||
|
$IpAddress = [string]$target.ip_address
|
||||||
|
if (!$target.ok -or !$IpAddress) { throw "discovery_target_denied" }
|
||||||
|
|
||||||
|
$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
|
||||||
|
discovery_id = $DiscoveryId
|
||||||
|
version = $ObservationVersion
|
||||||
|
command_id = $CommandId
|
||||||
|
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,296 @@
|
|||||||
|
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]@{
|
||||||
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
|
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]@{
|
||||||
|
miner_id = [string]$miner.miner_id
|
||||||
|
miner_version = [string]$miner.miner_version
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
$allItems = @($items.ToArray())
|
||||||
|
$result = @()
|
||||||
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
|
$payload.items = @($allItems[$offset..$last])
|
||||||
|
$result += Invoke-RestMethod -Uri "$HubUrl/api/agent/api3-enrichment" -Method Post `
|
||||||
|
-Headers $Headers -ContentType "application/json" -Body ($payload | ConvertTo-Json -Depth 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,144 @@
|
|||||||
|
$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
|
||||||
|
miner_version = $miner.miner_version
|
||||||
|
ip_address = $ip
|
||||||
|
mac_address = $miner.mac_address
|
||||||
|
model = $miner.model
|
||||||
|
pools = @($pools)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||||
|
|
||||||
|
$allItems = @($poolSnapshots.ToArray())
|
||||||
|
$result = @()
|
||||||
|
for ($offset = 0; $offset -lt $allItems.Count; $offset += 128) {
|
||||||
|
$last = [Math]::Min($offset + 127, $allItems.Count - 1)
|
||||||
|
$body = @{agent_id=$AgentId; pool_snapshots=@($allItems[$offset..$last])} | ConvertTo-Json -Depth 80
|
||||||
|
$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,55 @@
|
|||||||
|
param([string]$AgentRoot = (Join-Path $PSScriptRoot '..'))
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$checks=0
|
||||||
|
foreach ($file in @('MinerOpsAgentService.ps1','EnrichOneMiner.ps1','MinerOpsApi3EnrichmentPoller.ps1','MinerOpsPoolPoller.ps1')) {
|
||||||
|
$tokens=$null; $errors=$null
|
||||||
|
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $AgentRoot $file),[ref]$tokens,[ref]$errors)
|
||||||
|
if ($errors.Count) { throw "Parse errors in ${file}: $errors" }
|
||||||
|
$checks++
|
||||||
|
if ($file -eq 'MinerOpsAgentService.ps1') {
|
||||||
|
$function=$ast.Find({param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Invoke-AgentApi'},$true)
|
||||||
|
Invoke-Expression $function.Extent.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Read-AgentConfig { return @{hub_url='https://hub.invalid';agent_id='simulated-agent';agent_secret='disposable-only'} }
|
||||||
|
$script:requests=@()
|
||||||
|
function Invoke-RestMethod {
|
||||||
|
param($Method,$Uri,$Headers,$ContentType,$Body,$TimeoutSec)
|
||||||
|
$parsed=$Body | ConvertFrom-Json
|
||||||
|
$script:requests+=@{body=$parsed;headers=$Headers}
|
||||||
|
return @{ok=$true}
|
||||||
|
}
|
||||||
|
foreach ($selection in @(@{path='/api/agent/discoveries';field='discoveries'},@{path='/api/agent/miner-snapshots';field='miners'})) {
|
||||||
|
$script:requests=@();$body=@{agent_id='simulated-agent'}
|
||||||
|
$body[$selection.field]=@(1..257 | ForEach-Object { @{sample=$_;miner_version='signed-version'} })
|
||||||
|
$result=Invoke-AgentApi -Method POST -Path $selection.path -Body $body
|
||||||
|
$sizes=@($script:requests | ForEach-Object { @($_.body.($selection.field)).Count })
|
||||||
|
if (($sizes -join ',') -ne '128,128,1') { throw "Unsafe batching: $sizes" }
|
||||||
|
foreach ($request in $script:requests) {
|
||||||
|
if ($request.headers['X-MinerOps-Agent-ID'] -ne 'simulated-agent') { throw 'Identity lost' }
|
||||||
|
}
|
||||||
|
if ($result.processed -ne 257) { throw 'Samples lost' }
|
||||||
|
$checks+=3
|
||||||
|
}
|
||||||
|
# Execute the actual target-validation prefix only; transport functions/loops are never loaded.
|
||||||
|
$source=Get-Content -LiteralPath (Join-Path $AgentRoot 'EnrichOneMiner.ps1') -Raw
|
||||||
|
$start=$source.IndexOf('$config = Read-AgentConfig')
|
||||||
|
$finish=$source.IndexOf('$commands = @(',$start)
|
||||||
|
$prefix=$source.Substring($start,$finish-$start)
|
||||||
|
$script:targetRequests=0
|
||||||
|
function Invoke-RestMethod {
|
||||||
|
param($Method,$Uri,$Headers,$ContentType,$Body)
|
||||||
|
$script:targetRequests++
|
||||||
|
if ($Uri -notlike '*/api/agent/discovery-target') { throw 'Unexpected request' }
|
||||||
|
return @{ok=$true;ip_address='198.51.100.10'}
|
||||||
|
}
|
||||||
|
$DiscoveryId='';$ObservationVersion='';$CommandId='';$IpAddress='203.0.113.99'
|
||||||
|
$denied=$false
|
||||||
|
try { Invoke-Expression $prefix } catch { $denied=$_.Exception.Message -eq 'discovery_observation_required' }
|
||||||
|
if (!$denied -or $script:targetRequests -ne 0) { throw 'Missing identity was not rejected before I/O' }
|
||||||
|
$checks++
|
||||||
|
$DiscoveryId='00000000-0000-4000-8000-000000000001';$ObservationVersion='version';$CommandId='command'
|
||||||
|
Invoke-Expression $prefix
|
||||||
|
if ($IpAddress -ne '198.51.100.10' -or $script:targetRequests -ne 1) { throw 'Unvalidated target retained' }
|
||||||
|
$checks++
|
||||||
|
Write-Output "Discovery PowerShell checks passed: $checks; mocked Hub only; zero miner sockets."
|
||||||
@@ -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."
|
||||||
+7
-4
@@ -5,14 +5,17 @@ WORKDIR /app
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=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 \
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
COPY app /app/app
|
COPY backend/app /app/app
|
||||||
COPY alembic.ini /app/alembic.ini
|
COPY backend/alembic.ini /app/alembic.ini
|
||||||
COPY alembic /app/alembic
|
COPY backend/alembic /app/alembic
|
||||||
|
|
||||||
|
# Canonical Agent source used to construct short-lived enrollment packages.
|
||||||
|
COPY agents/windows /app/agents/windows
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -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,344 @@
|
|||||||
|
"""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
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
DiscoveryRepository.lock(cur)
|
||||||
|
self._retire_unsupported(cur, agent_id)
|
||||||
|
discovery = DiscoveryRepository(self._connection_factory)
|
||||||
|
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
|
||||||
|
FROM minerops_agent_command_queue q WHERE agent_id=%s
|
||||||
|
AND command_status='pending' AND command_type=ANY(%s)
|
||||||
|
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
|
||||||
|
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
|
||||||
|
pending=list(cur.fetchall())
|
||||||
|
for command in pending:
|
||||||
|
try:
|
||||||
|
discovery.validate_command(cur,agent_id,command)
|
||||||
|
except HTTPException as exc:
|
||||||
|
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
|
||||||
|
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
|
||||||
|
WHERE id=%s""",(exc.detail,command['id']))
|
||||||
|
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
|
||||||
|
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
|
||||||
|
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
|
||||||
|
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
|
||||||
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
|
for row in rows:
|
||||||
|
discovery.validate_command(cur,agent_id,row)
|
||||||
|
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,119 @@
|
|||||||
|
"""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 AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
|
||||||
|
miner_id: UUID | None = None
|
||||||
|
matched_by: 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,354 @@
|
|||||||
|
"""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
|
||||||
|
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
|
||||||
|
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
hostname,
|
||||||
|
version,
|
||||||
|
ip_address,
|
||||||
|
Jsonb(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
raise AgentZoneConflict("Agent assignment changed concurrently")
|
||||||
|
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 find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
|
||||||
|
mac_address: str | None, ip_address: str | None) -> dict | None:
|
||||||
|
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
|
||||||
|
from app.discovery.repository import DiscoveryRepository, reject
|
||||||
|
repository = DiscoveryRepository(self._connection_factory)
|
||||||
|
with repository.transaction() as cur:
|
||||||
|
context = repository.context(cur, agent_id)
|
||||||
|
if context['agent_zone_id'] != agent_zone_id:
|
||||||
|
reject('discovery_stale_authority')
|
||||||
|
if not mac_address and not ip_address:
|
||||||
|
return None
|
||||||
|
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
|
||||||
|
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
|
||||||
|
|
||||||
|
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:
|
||||||
|
outcomes[miner_id] = "miner_zone_unassigned"
|
||||||
|
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,243 @@
|
|||||||
|
"""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,
|
||||||
|
AgentDiscoveryMinerMatch,
|
||||||
|
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)
|
||||||
|
if not existing or existing.get("agent_zone_id") is None:
|
||||||
|
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
|
||||||
|
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 match_discovered_miner(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
mac_address: str | None,
|
||||||
|
ip_address: str | None,
|
||||||
|
recorded_zone_id: UUID | None = None,
|
||||||
|
) -> AgentDiscoveryMinerMatch:
|
||||||
|
routing = self.routing_context(agent_id, recorded_zone_id)
|
||||||
|
if routing.routing_reason or routing.agent_zone_id is None:
|
||||||
|
return AgentDiscoveryMinerMatch(**routing.model_dump())
|
||||||
|
|
||||||
|
match = self.repository.find_miner_in_zone(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
mac_address=mac_address,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
return AgentDiscoveryMinerMatch(
|
||||||
|
**routing.model_dump(),
|
||||||
|
miner_id=match["id"] if match else None,
|
||||||
|
matched_by=match["matched_by"] if match else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
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_name: str = "MinerOps Console"
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
app_timezone: str = "America/Winnipeg"
|
app_timezone: str = "America/Winnipeg"
|
||||||
|
minerops_build_commit: str | None = None
|
||||||
|
minerops_build_version: str | None = None
|
||||||
|
|
||||||
postgres_db: str = "minerops"
|
postgres_db: str = "minerops"
|
||||||
postgres_user: str = "minerops"
|
postgres_user: str = "minerops"
|
||||||
@@ -14,6 +16,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
redis_url: str = "redis://redis:6379/0"
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
minerops_agent_id: str = "plexus-windows-agent"
|
||||||
|
minerops_agent_shared_secret: str = ""
|
||||||
|
|
||||||
jwt_secret_key: str
|
jwt_secret_key: str
|
||||||
jwt_access_token_minutes: int = 30
|
jwt_access_token_minutes: int = 30
|
||||||
jwt_refresh_token_days: int = 14
|
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 @@
|
|||||||
|
"""Agent discovery isolation; physical miner writes are never authorized here."""
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Exact administrative CSV selection, inside the caller's locked transaction.
|
||||||
|
|
||||||
|
CSV metadata never establishes ownership or replaces board identity. Every supplied
|
||||||
|
identity is a constraint; a failed strong identifier never falls back to an IP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.discovery.repository import address, mac
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
|
||||||
|
def fail(message, status=409):
|
||||||
|
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
|
||||||
|
|
||||||
|
def field(names, normalize=lambda v: v):
|
||||||
|
values = []
|
||||||
|
for name in names:
|
||||||
|
value = row.get(name)
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
try:
|
||||||
|
values.append(normalize(str(value).strip()))
|
||||||
|
except (ValueError, HTTPException):
|
||||||
|
fail(f"invalid {names[0]}", 422)
|
||||||
|
if len(set(values)) > 1:
|
||||||
|
fail(f"conflicting {names[0]} columns")
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
def uuid(value):
|
||||||
|
return str(UUID(value))
|
||||||
|
|
||||||
|
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
|
||||||
|
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
|
||||||
|
site = field(["Site ID", "site_id"], uuid)
|
||||||
|
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
|
||||||
|
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
|
||||||
|
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
|
||||||
|
if selected_zone:
|
||||||
|
try:
|
||||||
|
selected_zone = uuid(selected_zone)
|
||||||
|
except ValueError:
|
||||||
|
fail("invalid selected Agent Zone ID", 422)
|
||||||
|
if zone and zone != selected_zone:
|
||||||
|
fail("row Zone disagrees with selected Zone")
|
||||||
|
zone = selected_zone
|
||||||
|
if zone:
|
||||||
|
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
|
||||||
|
zone_row = cur.fetchone()
|
||||||
|
if not zone_row:
|
||||||
|
fail("selected Zone does not exist", 422)
|
||||||
|
if site and site != zone_row["site_id"]:
|
||||||
|
fail("Site and Zone disagree")
|
||||||
|
if not (mid or current_mac or serial or (ip and zone)):
|
||||||
|
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
|
||||||
|
|
||||||
|
selectors = []
|
||||||
|
if mid:
|
||||||
|
selectors.append(("id=%s::uuid", mid))
|
||||||
|
if current_mac:
|
||||||
|
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
|
||||||
|
if serial:
|
||||||
|
selectors.append(("miner_sn=%s", serial))
|
||||||
|
# IP is only a namespace-scoped selector when no stronger identity is supplied.
|
||||||
|
if not selectors:
|
||||||
|
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
|
||||||
|
|
||||||
|
candidates = {}
|
||||||
|
for predicate, value in selectors:
|
||||||
|
params = value if isinstance(value, tuple) else (value,)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id::text, site_id::text, agent_zone_id::text, "
|
||||||
|
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
|
||||||
|
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
matches = cur.fetchall()
|
||||||
|
if len(matches) > 1:
|
||||||
|
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
|
||||||
|
candidates.update({m["id"]: m for m in matches})
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if len(candidates) != 1:
|
||||||
|
fail("identifiers select different inventory records")
|
||||||
|
miner = next(iter(candidates.values()))
|
||||||
|
if (
|
||||||
|
(mid and mid != miner["id"])
|
||||||
|
or (zone and zone != miner["agent_zone_id"])
|
||||||
|
or (site and site != miner["site_id"])
|
||||||
|
or (ip and ip != miner["current_ip"])
|
||||||
|
or (serial and serial != miner["miner_sn"])
|
||||||
|
or (current_mac and current_mac != mac(miner["current_mac"]))
|
||||||
|
):
|
||||||
|
fail("identifiers disagree with current inventory; refresh selection before importing")
|
||||||
|
return miner
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Bounded HTTP adapter; authentication remains on the registered routes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
import psycopg
|
||||||
|
from app.discovery.repository import DiscoveryRepository
|
||||||
|
|
||||||
|
MAX_BODY = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(method, request=None, **kwargs):
|
||||||
|
if request is not None:
|
||||||
|
chunks = []
|
||||||
|
size = 0
|
||||||
|
async for chunk in request.stream():
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_BODY:
|
||||||
|
raise HTTPException(413, "discovery_payload_too_large")
|
||||||
|
chunks.append(chunk)
|
||||||
|
try:
|
||||||
|
|
||||||
|
def invalid_constant(_):
|
||||||
|
raise ValueError("Non-finite JSON number")
|
||||||
|
|
||||||
|
kwargs["body"] = json.loads(
|
||||||
|
b"".join(chunks), parse_constant=invalid_constant
|
||||||
|
)
|
||||||
|
except (ValueError, UnicodeDecodeError, RecursionError):
|
||||||
|
raise HTTPException(422, "discovery_invalid_json")
|
||||||
|
try:
|
||||||
|
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
|
||||||
|
except (
|
||||||
|
psycopg.errors.LockNotAvailable,
|
||||||
|
psycopg.errors.QueryCanceled,
|
||||||
|
psycopg.errors.DeadlockDetected,
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "discovery_concurrent_change_retry")
|
||||||
|
except psycopg.DataError:
|
||||||
|
raise HTTPException(422, "discovery_invalid_value")
|
||||||
|
except psycopg.IntegrityError:
|
||||||
|
raise HTTPException(409, "discovery_identity_conflict")
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
"""
|
||||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user