Compare commits
8
Commits
d1d02962da
...
d738b8e27e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d738b8e27e | ||
|
|
a87ab2709f | ||
|
|
51aec1b404 | ||
|
|
979885bbdd | ||
|
|
93e034ea07 | ||
|
|
7280f1dfea | ||
|
|
fbf0f81df1 | ||
|
|
1cae60ab46 |
No files matched your search
@@ -1,122 +1,198 @@
|
||||
# MinerOps
|
||||
<p align="center">
|
||||
<img src="branding/wordmark.svg" alt="MinerOps" width="320">
|
||||
</p>
|
||||
|
||||
MinerOps is a self-hosted mining fleet operations console for tracking, repairing, locating, and controlling WhatsMiner ASICs.
|
||||
<h1 align="center">MinerOps</h1>
|
||||
|
||||
## Current deployment
|
||||
<p align="center">
|
||||
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
||||
<br>
|
||||
Know every miner. Track every repair. Control every rack.
|
||||
</p>
|
||||
|
||||
- Hub CT: 306
|
||||
- Public URL: https://plexus.lussier.cc
|
||||
- Repository: https://gitea.lussier.cc/lussierj/minerops
|
||||
- Backend container: minerops-backend
|
||||
- Database container: minerops-postgres
|
||||
- Redis container: minerops-redis
|
||||
## Overview
|
||||
|
||||
## Main pages
|
||||
MinerOps is a self-hosted platform intended to provide one authoritative source
|
||||
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.
|
||||
|
||||
- /dashboard - fleet dashboard and repair summary
|
||||
- /repairs - repair queue, notes, parts used, history, CSV export
|
||||
- /locations - room/rack/shelf location map
|
||||
- /system-health - app, database, table size, route, and retention health
|
||||
The repository contains a working early-stage application and the first
|
||||
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.
|
||||
|
||||
## Repair workflow
|
||||
The longer-term product direction combines:
|
||||
|
||||
1. Open /repairs.
|
||||
2. Find the miner by MAC, IP, model, serial, or problem.
|
||||
3. Add repair notes.
|
||||
4. Select parts used and quantity.
|
||||
5. Update repair status.
|
||||
6. Use History to edit or delete mistaken entries.
|
||||
7. Mark Ready to Shelf or use Move to Shelf.
|
||||
8. Return the miner to an open shelf position on /locations.
|
||||
- Miner inventory
|
||||
- Network discovery and identity tracking
|
||||
- Repairs and maintenance
|
||||
- Locations and electrical infrastructure
|
||||
- Monitoring and alerts
|
||||
- Fleet control
|
||||
- Scheduling and automation
|
||||
- Historical records and reporting
|
||||
|
||||
## Repair statuses
|
||||
These areas describe the product direction, not a claim that every workflow is
|
||||
complete or ready for production use.
|
||||
|
||||
- Open
|
||||
- Diagnosing
|
||||
- Waiting Parts
|
||||
- In Progress
|
||||
- Testing
|
||||
- Ready to Shelf
|
||||
- Needs Follow-up
|
||||
- Completed
|
||||
- Failed
|
||||
- Cancelled
|
||||
## Why MinerOps?
|
||||
|
||||
## Retention
|
||||
Mining operations often accumulate several partial versions of the truth:
|
||||
spreadsheets for assets, separate monitoring tools for performance, informal
|
||||
repair notes, and location knowledge held by individual technicians.
|
||||
|
||||
Retention is currently scheduled in dry-run mode.
|
||||
That fragmentation becomes harder to manage when IP addresses change, control
|
||||
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.
|
||||
|
||||
Current intended policy:
|
||||
## Core Capabilities
|
||||
|
||||
- Raw status snapshots: 30 days
|
||||
- Hourly rollups: 1 year or longer
|
||||
- Daily rollups: long term
|
||||
- Repair, location, and MAC history: permanent
|
||||
### Asset and Inventory Management
|
||||
|
||||
Verify /system-health before switching retention to apply mode.
|
||||
Maintain stable asset records independently of a miner's current IP address.
|
||||
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.
|
||||
|
||||
## Common commands
|
||||
### Miner Identity and Discovery
|
||||
|
||||
Health check:
|
||||
Associate changing network information with durable miner records. Existing
|
||||
code includes discovery and network identity foundations; formal identity
|
||||
history and transfer workflows remain planned work.
|
||||
|
||||
pct exec 306 -- bash -lc "curl -s http://127.0.0.1:8000/api/health"
|
||||
### Repairs and Maintenance
|
||||
|
||||
Rebuild backend:
|
||||
Build a complete service history around each miner, including repair status,
|
||||
notes, parts, and return-to-service workflows. Operational repair functionality
|
||||
exists today, while extraction into a dedicated domain architecture is still
|
||||
planned.
|
||||
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose build backend || docker-compose build backend"
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose up -d --force-recreate backend || docker-compose up -d --force-recreate backend"
|
||||
### Locations and Infrastructure
|
||||
|
||||
Check logs:
|
||||
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.
|
||||
|
||||
pct exec 306 -- bash -lc "docker logs minerops-backend --since 30m"
|
||||
### Monitoring and Health
|
||||
|
||||
Git status:
|
||||
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.
|
||||
|
||||
pct exec 306 -- bash -lc "cd /opt/minerops && git status --short"
|
||||
### Fleet Operations
|
||||
|
||||
## Safe local development
|
||||
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.
|
||||
|
||||
Development uses a separate Compose project, database volume, Redis volume, and
|
||||
localhost-only backend port. It does not reuse the production `.env` file.
|
||||
Scheduled scanning and real miner power/reboot controls are disabled.
|
||||
### Automation and Scheduling
|
||||
|
||||
First-time setup:
|
||||
Coordinate recurring scans, scheduled actions, retention, and other repeatable
|
||||
operations. Scheduling and automation will expand after the core inventory and
|
||||
identity domains are established.
|
||||
|
||||
```bash
|
||||
./scripts/dev-env-init.sh
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml up -d --build
|
||||
### 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
|
||||
- PostgreSQL data store
|
||||
- Redis service
|
||||
- Alembic schema migrations
|
||||
- Session-based authentication and login gating
|
||||
- Admin, Manager, Technician, and Read Only role foundations
|
||||
- Manual miner inventory database schema
|
||||
- Inventory Pydantic models
|
||||
- Inventory repository and service domain foundation
|
||||
- Automated repository and service tests for the Inventory domain
|
||||
- Disposable development baseline tests
|
||||
|
||||
**Next application milestone:** `v0.3 - Inventory`
|
||||
|
||||
**Next technical feature:** Inventory API
|
||||
|
||||
## Architecture
|
||||
|
||||
MinerOps is moving toward a modular backend with deliberate separation between
|
||||
transport, business rules, and persistence:
|
||||
|
||||
```text
|
||||
Frontend
|
||||
|
|
||||
v
|
||||
FastAPI routers
|
||||
|
|
||||
v
|
||||
Domain services
|
||||
|
|
||||
v
|
||||
Repositories
|
||||
|
|
||||
v
|
||||
PostgreSQL
|
||||
```
|
||||
|
||||
Verify the environment:
|
||||
FastAPI routers will own HTTP request and response concerns. Domain 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.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml ps
|
||||
curl --fail --silent http://127.0.0.1:18000/api/health
|
||||
```
|
||||
## Technology Stack
|
||||
|
||||
Run the disposable clean-database baseline:
|
||||
Current technologies:
|
||||
|
||||
```bash
|
||||
./scripts/test-dev-baseline.sh
|
||||
```
|
||||
- Python
|
||||
- FastAPI
|
||||
- Pydantic
|
||||
- PostgreSQL
|
||||
- Alembic
|
||||
- Redis
|
||||
- Docker and Docker Compose
|
||||
- Nginx development gateway
|
||||
|
||||
The baseline uses a separate Compose project and port, verifies migrations,
|
||||
schema, pages, retention rollups, and safety interlocks, then deletes only its
|
||||
own temporary volumes.
|
||||
A typed, responsive frontend with a shared design system is planned. Its
|
||||
technology selection has not yet been finalized in this repository.
|
||||
|
||||
Stop development services without deleting their data:
|
||||
## Roadmap
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.dev -f compose.dev.yaml down
|
||||
```
|
||||
- `v0.2 - Foundation`
|
||||
- `v0.3 - Inventory`
|
||||
- `v0.4 - Identity`
|
||||
- `v0.5 - Operations`
|
||||
- `v0.6 - Monitoring`
|
||||
- `v0.7 - Automation`
|
||||
- `v1.0 - Production Ready`
|
||||
|
||||
The generated `.env.dev` file is mode `0600` and ignored by Git. To use a
|
||||
different local port, change `MINEROPS_DEV_PORT` in `.env.dev`.
|
||||
Roadmap scope and release boundaries may evolve as development continues and
|
||||
operational requirements become clearer.
|
||||
|
||||
## Safety notes
|
||||
## Documentation
|
||||
|
||||
- Do not commit .env.
|
||||
- Do not commit backups, logs, database dumps, imports, or runtime data.
|
||||
- Do not enable scanning or real miner controls in the development environment.
|
||||
- Check /system-health after major changes.
|
||||
- Commit and push after each stable working change.
|
||||
Detailed product, architecture, development, database, testing, and roadmap
|
||||
documentation will live under `docs/`. That documentation set is still being
|
||||
developed, so this README does not link to files that do not yet exist.
|
||||
|
||||
## Contributing
|
||||
|
||||
MinerOps is in early development. Contribution guidelines will be added as the
|
||||
architecture, development workflow, and review expectations stabilize.
|
||||
|
||||
## License
|
||||
|
||||
The MinerOps license has not yet been finalized. No license file is currently
|
||||
included in this repository.
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add inventory Asset ID history
|
||||
|
||||
Revision ID: 0009_inventory_asset_id_history
|
||||
Revises: 0008_manual_miner_inventory
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0009_inventory_asset_id_history"
|
||||
down_revision: Union[str, None] = "0008_manual_miner_inventory"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"miner_asset_id_history",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"miner_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("miners.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("old_asset_id", sa.Text(), nullable=True),
|
||||
sa.Column("new_asset_id", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"changed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"changed_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"old_asset_id IS NULL OR "
|
||||
"(old_asset_id = trim(old_asset_id) AND old_asset_id <> '')",
|
||||
name="ck_miner_asset_id_history_old_normalized",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"new_asset_id = trim(new_asset_id) AND new_asset_id <> ''",
|
||||
name="ck_miner_asset_id_history_new_normalized",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_miner_asset_id_history_miner_changed_at",
|
||||
"miner_asset_id_history",
|
||||
["miner_id", "changed_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_miner_asset_id_history_changed_by",
|
||||
"miner_asset_id_history",
|
||||
["changed_by"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_miner_asset_id_history_changed_by",
|
||||
table_name="miner_asset_id_history",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_miner_asset_id_history_miner_changed_at",
|
||||
table_name="miner_asset_id_history",
|
||||
)
|
||||
op.drop_table("miner_asset_id_history")
|
||||
@@ -8,6 +8,7 @@ from app.inventory.exceptions import (
|
||||
)
|
||||
from app.inventory.models import (
|
||||
InventoryCreateRequest,
|
||||
InventoryMetadataUpdateRequest,
|
||||
InventoryMinerDetail,
|
||||
InventoryMinerSummary,
|
||||
InventorySearchFilters,
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"DuplicateAssetID",
|
||||
"InventoryCreateRequest",
|
||||
"InventoryError",
|
||||
"InventoryMetadataUpdateRequest",
|
||||
"InventoryMinerDetail",
|
||||
"InventoryMinerSummary",
|
||||
"InventoryRepository",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Inventory domain."""
|
||||
|
||||
from app.inventory.repository import InventoryRepository
|
||||
from app.inventory.service import InventoryService
|
||||
|
||||
|
||||
def get_inventory_service() -> InventoryService:
|
||||
return InventoryService(InventoryRepository())
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, IPvAnyAddress
|
||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||
|
||||
|
||||
LifecycleStatus = Literal["active", "spare", "retired"]
|
||||
@@ -43,6 +43,14 @@ class InventoryMinerDetail(InventoryMinerSummary):
|
||||
first_seen_at: datetime | None = None
|
||||
|
||||
|
||||
class InventoryAssetIDHistoryEntry(BaseModel):
|
||||
old_asset_id: str | None = None
|
||||
new_asset_id: str
|
||||
changed_at: datetime
|
||||
changed_by: UUID
|
||||
changed_by_display_name: str
|
||||
|
||||
|
||||
class InventoryCreateRequest(BaseModel):
|
||||
asset_id: str = Field(min_length=1, max_length=120)
|
||||
current_mac: str | None = Field(default=None, max_length=64)
|
||||
@@ -60,6 +68,16 @@ class InventoryCreateRequest(BaseModel):
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InventoryMetadataUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
asset_id: str | None = Field(default=None, max_length=120)
|
||||
miner_sn: str | None = Field(default=None, max_length=255)
|
||||
psu_serial: str | None = Field(default=None, max_length=255)
|
||||
model: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InventoryUpdateRequest(BaseModel):
|
||||
asset_id: str | None = Field(default=None, max_length=120)
|
||||
current_mac: str | None = Field(default=None, max_length=64)
|
||||
@@ -84,3 +102,27 @@ class InventorySearchFilters(BaseModel):
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
limit: int = Field(default=100, ge=1, le=500)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class InventoryListMeta(BaseModel):
|
||||
limit: int
|
||||
offset: int
|
||||
returned: int
|
||||
|
||||
|
||||
class InventoryMinerListResponse(BaseModel):
|
||||
data: list[InventoryMinerSummary]
|
||||
meta: InventoryListMeta
|
||||
error: None = None
|
||||
|
||||
|
||||
class InventoryMinerDetailResponse(BaseModel):
|
||||
data: InventoryMinerDetail
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class InventoryAssetIDHistoryResponse(BaseModel):
|
||||
data: list[InventoryAssetIDHistoryEntry]
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -145,8 +145,18 @@ class InventoryRepository:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None:
|
||||
cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params)
|
||||
def _fetch_miner(
|
||||
cur,
|
||||
where_sql: str,
|
||||
params: Sequence[Any],
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> dict | None:
|
||||
lock_sql = " FOR UPDATE OF miners" if for_update else ""
|
||||
cur.execute(
|
||||
f"{MINER_SELECT} WHERE {where_sql}{lock_sql}",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -155,6 +165,16 @@ class InventoryRepository:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
|
||||
|
||||
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
return self._fetch_miner(
|
||||
cur,
|
||||
"miners.id = %s",
|
||||
(miner_id,),
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
def get_miner_by_asset_id(self, asset_id: str) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
@@ -291,6 +311,60 @@ class InventoryRepository:
|
||||
return None
|
||||
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
|
||||
|
||||
def add_asset_id_history(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
old_asset_id: str | None,
|
||||
new_asset_id: str,
|
||||
changed_by: UUID,
|
||||
) -> dict:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miner_asset_id_history (
|
||||
miner_id,
|
||||
old_asset_id,
|
||||
new_asset_id,
|
||||
changed_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING
|
||||
id,
|
||||
miner_id,
|
||||
old_asset_id,
|
||||
new_asset_id,
|
||||
changed_at,
|
||||
changed_by
|
||||
""",
|
||||
(miner_id, old_asset_id, new_asset_id, changed_by),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def list_asset_id_history(self, miner_id: UUID) -> list[dict]:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
history.id,
|
||||
history.miner_id,
|
||||
history.old_asset_id,
|
||||
history.new_asset_id,
|
||||
history.changed_at,
|
||||
history.changed_by,
|
||||
changed_by_user.display_name
|
||||
AS changed_by_display_name
|
||||
FROM miner_asset_id_history AS history
|
||||
JOIN users AS changed_by_user
|
||||
ON changed_by_user.id = history.changed_by
|
||||
WHERE history.miner_id = %s
|
||||
ORDER BY history.changed_at, history.id
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def list_tags(self) -> list[dict]:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Read-only HTTP routes for the Inventory domain."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.identity import require_permission
|
||||
from app.inventory.dependencies import get_inventory_service
|
||||
from app.inventory.exceptions import (
|
||||
DuplicateAssetID,
|
||||
InventoryValidationError,
|
||||
MinerNotFound,
|
||||
)
|
||||
from app.inventory.models import (
|
||||
InventoryAssetIDHistoryResponse,
|
||||
InventoryCreateRequest,
|
||||
InventoryListMeta,
|
||||
InventoryMetadataUpdateRequest,
|
||||
InventoryMinerDetailResponse,
|
||||
InventoryMinerListResponse,
|
||||
InventorySearchFilters,
|
||||
)
|
||||
from app.inventory.service import InventoryService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"])
|
||||
require_inventory_read = require_permission("miners.view")
|
||||
require_inventory_write = require_permission("miners.update")
|
||||
|
||||
InventoryServiceDependency = Annotated[
|
||||
InventoryService,
|
||||
Depends(get_inventory_service),
|
||||
]
|
||||
InventoryReadUser = Annotated[dict, Depends(require_inventory_read)]
|
||||
InventoryWriteUser = Annotated[dict, Depends(require_inventory_write)]
|
||||
InventoryQuery = Annotated[InventorySearchFilters, Query()]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=InventoryMinerDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_inventory_miner(
|
||||
request: InventoryCreateRequest,
|
||||
service: InventoryServiceDependency,
|
||||
current_user: InventoryWriteUser,
|
||||
) -> InventoryMinerDetailResponse:
|
||||
del current_user
|
||||
try:
|
||||
miner = service.create_miner(request)
|
||||
except DuplicateAssetID as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except InventoryValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return InventoryMinerDetailResponse(data=miner)
|
||||
|
||||
|
||||
@router.patch("/{miner_id}", response_model=InventoryMinerDetailResponse)
|
||||
def update_inventory_miner_metadata(
|
||||
miner_id: UUID,
|
||||
request: InventoryMetadataUpdateRequest,
|
||||
service: InventoryServiceDependency,
|
||||
current_user: InventoryWriteUser,
|
||||
) -> InventoryMinerDetailResponse:
|
||||
try:
|
||||
miner = service.update_metadata(
|
||||
miner_id,
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except MinerNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Miner not found",
|
||||
) from exc
|
||||
except DuplicateAssetID as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except InventoryValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return InventoryMinerDetailResponse(data=miner)
|
||||
|
||||
|
||||
@router.get("", response_model=InventoryMinerListResponse)
|
||||
def list_inventory_miners(
|
||||
filters: InventoryQuery,
|
||||
service: InventoryServiceDependency,
|
||||
current_user: InventoryReadUser,
|
||||
) -> InventoryMinerListResponse:
|
||||
del current_user
|
||||
try:
|
||||
miners = service.search_miners(filters)
|
||||
except InventoryValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return InventoryMinerListResponse(
|
||||
data=miners,
|
||||
meta=InventoryListMeta(
|
||||
limit=filters.limit,
|
||||
offset=filters.offset,
|
||||
returned=len(miners),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{miner_id}/asset-id-history",
|
||||
response_model=InventoryAssetIDHistoryResponse,
|
||||
)
|
||||
def get_inventory_miner_asset_id_history(
|
||||
miner_id: UUID,
|
||||
service: InventoryServiceDependency,
|
||||
current_user: InventoryReadUser,
|
||||
) -> InventoryAssetIDHistoryResponse:
|
||||
del current_user
|
||||
try:
|
||||
history = service.get_asset_id_history(miner_id)
|
||||
except MinerNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Miner not found",
|
||||
) from exc
|
||||
|
||||
return InventoryAssetIDHistoryResponse(data=history)
|
||||
|
||||
|
||||
@router.get("/{miner_id}", response_model=InventoryMinerDetailResponse)
|
||||
def get_inventory_miner(
|
||||
miner_id: UUID,
|
||||
service: InventoryServiceDependency,
|
||||
current_user: InventoryReadUser,
|
||||
) -> InventoryMinerDetailResponse:
|
||||
del current_user
|
||||
try:
|
||||
miner = service.get_miner(miner_id)
|
||||
except MinerNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Miner not found",
|
||||
) from exc
|
||||
|
||||
return InventoryMinerDetailResponse(data=miner)
|
||||
@@ -12,7 +12,9 @@ from app.inventory.exceptions import (
|
||||
MinerNotFound,
|
||||
)
|
||||
from app.inventory.models import (
|
||||
InventoryAssetIDHistoryEntry,
|
||||
InventoryCreateRequest,
|
||||
InventoryMetadataUpdateRequest,
|
||||
InventoryMinerDetail,
|
||||
InventoryMinerSummary,
|
||||
InventorySearchFilters,
|
||||
@@ -111,6 +113,17 @@ class InventoryService:
|
||||
raise MinerNotFound(miner_id)
|
||||
return self._detail_model(row)
|
||||
|
||||
def get_asset_id_history(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
) -> list[InventoryAssetIDHistoryEntry]:
|
||||
if not self.repository.get_miner_by_id(miner_id):
|
||||
raise MinerNotFound(miner_id)
|
||||
return [
|
||||
InventoryAssetIDHistoryEntry.model_validate(row)
|
||||
for row in self.repository.list_asset_id_history(miner_id)
|
||||
]
|
||||
|
||||
def list_miners(
|
||||
self,
|
||||
*,
|
||||
@@ -197,6 +210,85 @@ class InventoryService:
|
||||
|
||||
return self._detail_model(row)
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
request: InventoryMetadataUpdateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> InventoryMinerDetail:
|
||||
fields_set = request.model_fields_set
|
||||
if not fields_set:
|
||||
raise InventoryValidationError(
|
||||
"At least one metadata field must be provided"
|
||||
)
|
||||
|
||||
requested_asset_id = None
|
||||
if "asset_id" in fields_set:
|
||||
if request.asset_id is None:
|
||||
raise InventoryValidationError("Asset ID cannot be cleared")
|
||||
requested_asset_id = self.normalize_asset_id(request.asset_id)
|
||||
|
||||
requested_values: dict[str, Any] = {}
|
||||
field_map = {
|
||||
"miner_sn": "miner_sn",
|
||||
"psu_serial": "power_sn",
|
||||
"model": "model",
|
||||
"notes": "notes",
|
||||
}
|
||||
for model_field, database_column in field_map.items():
|
||||
if model_field in fields_set:
|
||||
requested_values[database_column] = self._clean_optional_text(
|
||||
getattr(request, model_field)
|
||||
)
|
||||
|
||||
try:
|
||||
with self.repository.transaction() as repository:
|
||||
existing = repository.get_miner_by_id_for_update(miner_id)
|
||||
if not existing:
|
||||
raise MinerNotFound(miner_id)
|
||||
|
||||
previous_asset_id = (
|
||||
str(existing.get("asset_tag") or "").strip() or None
|
||||
)
|
||||
asset_id_changed = (
|
||||
requested_asset_id is not None
|
||||
and requested_asset_id != previous_asset_id
|
||||
)
|
||||
|
||||
values = {
|
||||
key: value
|
||||
for key, value in requested_values.items()
|
||||
if existing.get(key) != value
|
||||
}
|
||||
if asset_id_changed:
|
||||
duplicate = repository.get_miner_by_asset_id(
|
||||
requested_asset_id
|
||||
)
|
||||
if duplicate and duplicate["id"] != miner_id:
|
||||
raise DuplicateAssetID(requested_asset_id)
|
||||
values["asset_tag"] = requested_asset_id
|
||||
|
||||
if not values:
|
||||
return self._detail_model(existing)
|
||||
|
||||
self._before_update(existing, values)
|
||||
row = repository.update_miner(miner_id, values)
|
||||
if asset_id_changed:
|
||||
repository.add_asset_id_history(
|
||||
miner_id,
|
||||
previous_asset_id,
|
||||
requested_asset_id,
|
||||
actor_user_id,
|
||||
)
|
||||
row = repository.get_miner_by_id(miner_id)
|
||||
self._after_update(row)
|
||||
except UniqueViolation as exc:
|
||||
asset_id = requested_asset_id or "unknown"
|
||||
raise DuplicateAssetID(asset_id) from exc
|
||||
|
||||
return self._detail_model(row)
|
||||
|
||||
def update_miner(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
@@ -213,10 +305,13 @@ class InventoryService:
|
||||
if request.asset_id is None:
|
||||
raise InventoryValidationError("Asset ID cannot be cleared")
|
||||
asset_id = self.normalize_asset_id(request.asset_id)
|
||||
duplicate = self.repository.get_miner_by_asset_id(asset_id)
|
||||
if duplicate and duplicate["id"] != miner_id:
|
||||
raise DuplicateAssetID(asset_id)
|
||||
values["asset_tag"] = asset_id
|
||||
existing_asset_id = (
|
||||
str(existing.get("asset_tag") or "").strip() or None
|
||||
)
|
||||
if asset_id != existing_asset_id:
|
||||
raise InventoryValidationError(
|
||||
"Asset ID changes require actor-attributed metadata update"
|
||||
)
|
||||
else:
|
||||
asset_id = existing["asset_tag"]
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ from app.identity import (
|
||||
install_identity_middleware,
|
||||
router as identity_router,
|
||||
)
|
||||
from app.inventory.router import router as inventory_router
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(inventory_router)
|
||||
install_identity_middleware(app)
|
||||
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ email-validator==2.3.0
|
||||
python-multipart==0.0.20
|
||||
asyncpg
|
||||
pycryptodome==3.23.0
|
||||
httpx==0.28.1
|
||||
@@ -1,19 +1,25 @@
|
||||
import ast
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||
|
||||
from app.inventory import (
|
||||
DuplicateAssetID,
|
||||
InventoryCreateRequest,
|
||||
InventoryMetadataUpdateRequest,
|
||||
InventoryRepository,
|
||||
InventorySearchFilters,
|
||||
InventoryService,
|
||||
InventoryUpdateRequest,
|
||||
InventoryValidationError,
|
||||
MinerNotFound,
|
||||
)
|
||||
from app.inventory.models import InventoryAssetIDHistoryEntry
|
||||
|
||||
|
||||
def inventory_row(**overrides):
|
||||
@@ -48,14 +54,26 @@ class FakeInventoryRepository:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self.created_values = None
|
||||
self.asset_id_history = []
|
||||
self.update_error = None
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
rows_snapshot = deepcopy(self.rows)
|
||||
history_snapshot = deepcopy(self.asset_id_history)
|
||||
try:
|
||||
yield self
|
||||
except Exception:
|
||||
self.rows = rows_snapshot
|
||||
self.asset_id_history = history_snapshot
|
||||
raise
|
||||
|
||||
def get_miner_by_id(self, miner_id):
|
||||
return self.rows.get(miner_id)
|
||||
|
||||
def get_miner_by_id_for_update(self, miner_id):
|
||||
return self.get_miner_by_id(miner_id)
|
||||
|
||||
def get_miner_by_asset_id(self, asset_id):
|
||||
key = asset_id.strip().casefold()
|
||||
return next(
|
||||
@@ -86,6 +104,8 @@ class FakeInventoryRepository:
|
||||
return row
|
||||
|
||||
def update_miner(self, miner_id, values):
|
||||
if self.update_error is not None:
|
||||
raise self.update_error
|
||||
row = self.rows.get(miner_id)
|
||||
if not row:
|
||||
return None
|
||||
@@ -93,6 +113,28 @@ class FakeInventoryRepository:
|
||||
row["updated_at"] = datetime.now(timezone.utc)
|
||||
return row
|
||||
|
||||
def add_asset_id_history(
|
||||
self, miner_id, old_asset_id, new_asset_id, changed_by
|
||||
):
|
||||
history = {
|
||||
"id": uuid4(),
|
||||
"miner_id": miner_id,
|
||||
"old_asset_id": old_asset_id,
|
||||
"new_asset_id": new_asset_id,
|
||||
"changed_at": datetime.now(timezone.utc),
|
||||
"changed_by": changed_by,
|
||||
"changed_by_display_name": "Test Actor",
|
||||
}
|
||||
self.asset_id_history.append(history)
|
||||
return history
|
||||
|
||||
def list_asset_id_history(self, miner_id):
|
||||
return [
|
||||
row
|
||||
for row in self.asset_id_history
|
||||
if row["miner_id"] == miner_id
|
||||
]
|
||||
|
||||
def add_tag(self, miner_id, tag_name):
|
||||
now = datetime.now(timezone.utc)
|
||||
tag = {
|
||||
@@ -121,6 +163,7 @@ class InventoryServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeInventoryRepository()
|
||||
self.service = InventoryService(self.repository)
|
||||
self.actor_user_id = uuid4()
|
||||
|
||||
def test_create_normalizes_asset_id_tags_and_never_seen(self):
|
||||
created = self.service.create_miner(
|
||||
@@ -167,6 +210,177 @@ class InventoryServiceTests(unittest.TestCase):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.normalize_tags(["valid", "\t\n"])
|
||||
|
||||
def test_asset_id_history_empty_for_known_miner(self):
|
||||
row = inventory_row()
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
self.assertEqual(
|
||||
self.service.get_asset_id_history(row["id"]),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_asset_id_history_maps_populated_repository_results(self):
|
||||
row = inventory_row()
|
||||
self.repository.rows[row["id"]] = row
|
||||
first_changed_at = datetime(2026, 7, 27, tzinfo=timezone.utc)
|
||||
second_changed_at = datetime(2026, 7, 28, tzinfo=timezone.utc)
|
||||
self.repository.asset_id_history = [
|
||||
{
|
||||
"id": uuid4(),
|
||||
"miner_id": row["id"],
|
||||
"old_asset_id": "ASSET-FIRST",
|
||||
"new_asset_id": "ASSET-SECOND",
|
||||
"changed_at": first_changed_at,
|
||||
"changed_by": self.actor_user_id,
|
||||
"changed_by_display_name": "Test Actor",
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"miner_id": row["id"],
|
||||
"old_asset_id": "ASSET-SECOND",
|
||||
"new_asset_id": "ASSET-THIRD",
|
||||
"changed_at": second_changed_at,
|
||||
"changed_by": self.actor_user_id,
|
||||
"changed_by_display_name": "Test Actor",
|
||||
},
|
||||
]
|
||||
|
||||
history = self.service.get_asset_id_history(row["id"])
|
||||
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertTrue(
|
||||
all(
|
||||
isinstance(entry, InventoryAssetIDHistoryEntry)
|
||||
for entry in history
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
[entry.new_asset_id for entry in history],
|
||||
["ASSET-SECOND", "ASSET-THIRD"],
|
||||
)
|
||||
self.assertEqual(history[0].changed_by_display_name, "Test Actor")
|
||||
|
||||
def test_asset_id_history_unknown_miner_raises_not_found(self):
|
||||
with self.assertRaises(MinerNotFound):
|
||||
self.service.get_asset_id_history(uuid4())
|
||||
|
||||
def test_metadata_update_preserves_unspecified_fields(self):
|
||||
row = inventory_row(
|
||||
asset_tag="ASSET-001",
|
||||
miner_sn="MINER-OLD",
|
||||
power_sn="PSU-OLD",
|
||||
model="M50",
|
||||
notes="Old notes",
|
||||
)
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
updated = self.service.update_metadata(
|
||||
row["id"],
|
||||
InventoryMetadataUpdateRequest(
|
||||
model="M60",
|
||||
notes=None,
|
||||
),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(updated.id, row["id"])
|
||||
self.assertEqual(updated.asset_id, "ASSET-001")
|
||||
self.assertEqual(updated.miner_sn, "MINER-OLD")
|
||||
self.assertEqual(updated.psu_serial, "PSU-OLD")
|
||||
self.assertEqual(updated.model, "M60")
|
||||
self.assertIsNone(updated.notes)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_asset_id_change_preserves_uuid_and_creates_history(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD")
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
updated = self.service.update_metadata(
|
||||
row["id"],
|
||||
InventoryMetadataUpdateRequest(asset_id=" ASSET-NEW "),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(updated.id, row["id"])
|
||||
self.assertEqual(updated.asset_id, "ASSET-NEW")
|
||||
self.assertEqual(len(self.repository.asset_id_history), 1)
|
||||
history = self.repository.asset_id_history[0]
|
||||
self.assertEqual(history["miner_id"], row["id"])
|
||||
self.assertEqual(history["old_asset_id"], "ASSET-OLD")
|
||||
self.assertEqual(history["new_asset_id"], "ASSET-NEW")
|
||||
self.assertEqual(history["changed_by"], self.actor_user_id)
|
||||
|
||||
def test_unchanged_normalized_asset_id_creates_no_history(self):
|
||||
row = inventory_row(asset_tag="ASSET-001")
|
||||
original_updated_at = row["updated_at"]
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
updated = self.service.update_metadata(
|
||||
row["id"],
|
||||
InventoryMetadataUpdateRequest(asset_id=" ASSET-001 "),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(updated.asset_id, "ASSET-001")
|
||||
self.assertEqual(updated.updated_at, original_updated_at)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_duplicate_asset_id_leaves_miner_and_history_unchanged(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD")
|
||||
duplicate = inventory_row(asset_tag="ASSET-TAKEN")
|
||||
self.repository.rows[row["id"]] = row
|
||||
self.repository.rows[duplicate["id"]] = duplicate
|
||||
|
||||
with self.assertRaises(DuplicateAssetID):
|
||||
self.service.update_metadata(
|
||||
row["id"],
|
||||
InventoryMetadataUpdateRequest(asset_id=" asset-taken "),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.rows[row["id"]]["asset_tag"],
|
||||
"ASSET-OLD",
|
||||
)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_empty_or_invalid_metadata_update_does_not_write(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD", model="M50")
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
for request in (
|
||||
InventoryMetadataUpdateRequest(),
|
||||
InventoryMetadataUpdateRequest(asset_id=" ", model="M60"),
|
||||
):
|
||||
with self.subTest(request=request):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.update_metadata(
|
||||
row["id"],
|
||||
request,
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(self.repository.rows[row["id"]]["model"], "M50")
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_uniqueness_race_becomes_duplicate_asset_id(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD")
|
||||
self.repository.rows[row["id"]] = row
|
||||
self.repository.update_error = UniqueViolation("duplicate Asset ID")
|
||||
|
||||
with self.assertRaises(DuplicateAssetID):
|
||||
self.service.update_metadata(
|
||||
row["id"],
|
||||
InventoryMetadataUpdateRequest(asset_id="ASSET-NEW"),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.rows[row["id"]]["asset_tag"],
|
||||
"ASSET-OLD",
|
||||
)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_update_maps_product_names_without_repository_rules(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD")
|
||||
self.repository.rows[row["id"]] = row
|
||||
@@ -174,16 +388,34 @@ class InventoryServiceTests(unittest.TestCase):
|
||||
updated = self.service.update_miner(
|
||||
row["id"],
|
||||
InventoryUpdateRequest(
|
||||
asset_id=" ASSET-NEW ",
|
||||
psu_serial=" PSU-NEW ",
|
||||
lifecycle_status="retired",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(updated.asset_id, "ASSET-NEW")
|
||||
self.assertEqual(updated.asset_id, "ASSET-OLD")
|
||||
self.assertEqual(updated.psu_serial, "PSU-NEW")
|
||||
self.assertEqual(updated.lifecycle_status, "retired")
|
||||
|
||||
def test_legacy_update_rejects_asset_id_change_without_actor(self):
|
||||
row = inventory_row(asset_tag="ASSET-OLD")
|
||||
self.repository.rows[row["id"]] = row
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
InventoryValidationError,
|
||||
"actor-attributed metadata update",
|
||||
):
|
||||
self.service.update_miner(
|
||||
row["id"],
|
||||
InventoryUpdateRequest(asset_id="ASSET-NEW"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.rows[row["id"]]["asset_tag"],
|
||||
"ASSET-OLD",
|
||||
)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_search_validation_precedes_repository(self):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.search_miners(
|
||||
@@ -197,11 +429,30 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
||||
cls.repository = InventoryRepository()
|
||||
|
||||
def setUp(self):
|
||||
self.actor_user_id = UUID("00000000-0000-0000-0000-000000000951")
|
||||
with self.repository.transaction() as repository:
|
||||
with repository._connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM miner_asset_id_history")
|
||||
cur.execute("DELETE FROM miner_tags")
|
||||
cur.execute("DELETE FROM tags")
|
||||
cur.execute("DELETE FROM miners")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, display_name, password_hash, role_id
|
||||
)
|
||||
SELECT %s, %s, %s, %s, id
|
||||
FROM roles
|
||||
WHERE name = 'Admin'
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
self.actor_user_id,
|
||||
"inventory-history-test",
|
||||
"Inventory History Test",
|
||||
"not-used",
|
||||
),
|
||||
)
|
||||
|
||||
def test_repository_crud_search_and_tags(self):
|
||||
created = self.repository.create_miner(
|
||||
@@ -262,6 +513,83 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
||||
[],
|
||||
)
|
||||
|
||||
def test_repeated_asset_id_changes_preserve_chronological_history(self):
|
||||
service = InventoryService(self.repository)
|
||||
created = service.create_miner(
|
||||
InventoryCreateRequest(asset_id="ASSET-FIRST")
|
||||
)
|
||||
|
||||
second = service.update_metadata(
|
||||
created.id,
|
||||
InventoryMetadataUpdateRequest(asset_id="ASSET-SECOND"),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
third = service.update_metadata(
|
||||
created.id,
|
||||
InventoryMetadataUpdateRequest(asset_id="ASSET-THIRD"),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
other = service.create_miner(
|
||||
InventoryCreateRequest(asset_id="OTHER-FIRST")
|
||||
)
|
||||
service.update_metadata(
|
||||
other.id,
|
||||
InventoryMetadataUpdateRequest(asset_id="OTHER-SECOND"),
|
||||
actor_user_id=self.actor_user_id,
|
||||
)
|
||||
|
||||
self.assertEqual(created.id, second.id)
|
||||
self.assertEqual(second.id, third.id)
|
||||
history = self.repository.list_asset_id_history(created.id)
|
||||
self.assertEqual(len(history), 2)
|
||||
self.assertEqual(
|
||||
[
|
||||
(row["old_asset_id"], row["new_asset_id"])
|
||||
for row in history
|
||||
],
|
||||
[
|
||||
("ASSET-FIRST", "ASSET-SECOND"),
|
||||
("ASSET-SECOND", "ASSET-THIRD"),
|
||||
],
|
||||
)
|
||||
self.assertLessEqual(
|
||||
history[0]["changed_at"],
|
||||
history[1]["changed_at"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
row["miner_id"] == created.id
|
||||
and row["changed_by_display_name"]
|
||||
== "Inventory History Test"
|
||||
for row in history
|
||||
)
|
||||
)
|
||||
other_history = self.repository.list_asset_id_history(other.id)
|
||||
self.assertEqual(len(other_history), 1)
|
||||
self.assertEqual(other_history[0]["new_asset_id"], "OTHER-SECOND")
|
||||
|
||||
def test_history_failure_rolls_back_asset_id_update(self):
|
||||
service = InventoryService(self.repository)
|
||||
created = service.create_miner(
|
||||
InventoryCreateRequest(asset_id="ASSET-ROLLBACK-OLD")
|
||||
)
|
||||
|
||||
with self.assertRaises(ForeignKeyViolation):
|
||||
service.update_metadata(
|
||||
created.id,
|
||||
InventoryMetadataUpdateRequest(
|
||||
asset_id="ASSET-ROLLBACK-NEW"
|
||||
),
|
||||
actor_user_id=uuid4(),
|
||||
)
|
||||
|
||||
persisted = self.repository.get_miner_by_id(created.id)
|
||||
self.assertEqual(persisted["asset_tag"], "ASSET-ROLLBACK-OLD")
|
||||
self.assertEqual(
|
||||
self.repository.list_asset_id_history(created.id),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_transaction_rolls_back_repository_writes(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
with self.repository.transaction() as repository:
|
||||
@@ -281,11 +609,17 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
||||
|
||||
|
||||
class InventoryArchitectureTests(unittest.TestCase):
|
||||
def test_inventory_package_has_no_http_layer_or_router(self):
|
||||
import app.inventory as inventory
|
||||
|
||||
def test_inventory_domain_layers_have_no_http_dependency(self):
|
||||
inventory_path = Path(__file__).parents[1] / "app" / "inventory"
|
||||
for source_path in inventory_path.glob("*.py"):
|
||||
domain_files = (
|
||||
"dependencies.py",
|
||||
"exceptions.py",
|
||||
"models.py",
|
||||
"repository.py",
|
||||
"service.py",
|
||||
)
|
||||
for filename in domain_files:
|
||||
source_path = inventory_path / filename
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
imported_modules = []
|
||||
for node in ast.walk(tree):
|
||||
@@ -298,8 +632,6 @@ class InventoryArchitectureTests(unittest.TestCase):
|
||||
source_path,
|
||||
)
|
||||
|
||||
self.assertFalse(hasattr(inventory, "router"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,604 @@
|
||||
import ast
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.identity import get_current_user
|
||||
from app.inventory.dependencies import get_inventory_service
|
||||
from app.inventory.exceptions import (
|
||||
DuplicateAssetID,
|
||||
InventoryValidationError,
|
||||
MinerNotFound,
|
||||
)
|
||||
from app.inventory.models import (
|
||||
InventoryAssetIDHistoryEntry,
|
||||
InventoryCreateRequest,
|
||||
InventoryMetadataUpdateRequest,
|
||||
InventoryMinerDetail,
|
||||
InventoryMinerSummary,
|
||||
InventorySearchFilters,
|
||||
)
|
||||
from app.inventory.router import router
|
||||
|
||||
|
||||
class StubInventoryService:
|
||||
def __init__(self):
|
||||
self.search_result: list[InventoryMinerSummary] = []
|
||||
self.detail_result: InventoryMinerDetail | None = None
|
||||
self.search_filters: InventorySearchFilters | None = None
|
||||
self.detail_miner_id: UUID | None = None
|
||||
self.search_error: InventoryValidationError | None = None
|
||||
self.create_result: InventoryMinerDetail | None = None
|
||||
self.create_error: Exception | None = None
|
||||
self.create_request: InventoryCreateRequest | None = None
|
||||
self.update_result: InventoryMinerDetail | None = None
|
||||
self.update_error: Exception | None = None
|
||||
self.update_miner_id: UUID | None = None
|
||||
self.update_request: InventoryMetadataUpdateRequest | None = None
|
||||
self.update_actor_user_id: UUID | None = None
|
||||
self.history_result: list[InventoryAssetIDHistoryEntry] = []
|
||||
self.history_error: Exception | None = None
|
||||
self.history_miner_id: UUID | None = None
|
||||
|
||||
def search_miners(
|
||||
self,
|
||||
filters: InventorySearchFilters,
|
||||
) -> list[InventoryMinerSummary]:
|
||||
self.search_filters = filters
|
||||
if self.search_error is not None:
|
||||
raise self.search_error
|
||||
return self.search_result
|
||||
|
||||
def create_miner(
|
||||
self,
|
||||
request: InventoryCreateRequest,
|
||||
) -> InventoryMinerDetail:
|
||||
self.create_request = request
|
||||
if self.create_error is not None:
|
||||
raise self.create_error
|
||||
if self.create_result is None:
|
||||
raise AssertionError("create_result was not configured")
|
||||
return self.create_result
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
request: InventoryMetadataUpdateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> InventoryMinerDetail:
|
||||
self.update_miner_id = miner_id
|
||||
self.update_request = request
|
||||
self.update_actor_user_id = actor_user_id
|
||||
if self.update_error is not None:
|
||||
raise self.update_error
|
||||
if self.update_result is None:
|
||||
raise AssertionError("update_result was not configured")
|
||||
return self.update_result
|
||||
|
||||
def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
|
||||
self.detail_miner_id = miner_id
|
||||
if self.detail_result is None or self.detail_result.id != miner_id:
|
||||
raise MinerNotFound(miner_id)
|
||||
return self.detail_result
|
||||
|
||||
def get_asset_id_history(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
) -> list[InventoryAssetIDHistoryEntry]:
|
||||
self.history_miner_id = miner_id
|
||||
if self.history_error is not None:
|
||||
raise self.history_error
|
||||
return self.history_result
|
||||
|
||||
|
||||
def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
|
||||
now = datetime.now(timezone.utc)
|
||||
return InventoryMinerSummary(
|
||||
id=miner_id or uuid4(),
|
||||
asset_id="ASSET-001",
|
||||
model="M50",
|
||||
lifecycle_status="active",
|
||||
runtime_status="unknown",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
|
||||
summary = miner_summary(miner_id)
|
||||
return InventoryMinerDetail(
|
||||
**summary.model_dump(),
|
||||
miner_sn="MINER-001",
|
||||
control_board_sn="CB-001",
|
||||
psu_serial="PSU-001",
|
||||
)
|
||||
|
||||
|
||||
def history_entry(
|
||||
*,
|
||||
old_asset_id: str | None,
|
||||
new_asset_id: str,
|
||||
changed_at: datetime,
|
||||
) -> InventoryAssetIDHistoryEntry:
|
||||
return InventoryAssetIDHistoryEntry(
|
||||
old_asset_id=old_asset_id,
|
||||
new_asset_id=new_asset_id,
|
||||
changed_at=changed_at,
|
||||
changed_by=UUID("00000000-0000-0000-0000-000000000951"),
|
||||
changed_by_display_name="Inventory Test User",
|
||||
)
|
||||
|
||||
|
||||
class InventoryRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.service = StubInventoryService()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_inventory_service] = (
|
||||
lambda: self.service
|
||||
)
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(self, permissions=("miners.view",)):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()),
|
||||
"username": "reader",
|
||||
"role": "Read Only",
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def test_create_requires_authentication(self):
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json={"asset_id": "ASSET-NEW"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Login required")
|
||||
|
||||
def test_create_requires_miners_update_permission(self):
|
||||
self.authenticate(permissions=("miners.view",))
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json={"asset_id": "ASSET-NEW"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIsNone(self.service.create_request)
|
||||
|
||||
def test_create_returns_new_miner_in_typed_envelope(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
created = miner_detail()
|
||||
self.service.create_result = created
|
||||
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json={
|
||||
"asset_id": " ASSET-NEW ",
|
||||
"lifecycle_status": "spare",
|
||||
"site_id": str(uuid4()),
|
||||
"tags": ["Priority", "Repair"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["data"]["id"], str(created.id))
|
||||
self.assertEqual(response.json()["meta"], {})
|
||||
self.assertIsNone(response.json()["error"])
|
||||
request = self.service.create_request
|
||||
self.assertIsNotNone(request)
|
||||
self.assertEqual(request.asset_id, " ASSET-NEW ")
|
||||
self.assertEqual(request.lifecycle_status, "spare")
|
||||
self.assertEqual(request.tags, ["Priority", "Repair"])
|
||||
|
||||
def test_duplicate_asset_id_returns_conflict(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
self.service.create_error = DuplicateAssetID("ASSET-001")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json={"asset_id": "ASSET-001"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"Asset ID already exists: ASSET-001",
|
||||
)
|
||||
|
||||
def test_invalid_create_body_returns_validation_error(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
for body in (
|
||||
{},
|
||||
{"asset_id": "A" * 121},
|
||||
{"asset_id": "ASSET-001", "site_id": "not-a-uuid"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json=body,
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.create_request)
|
||||
|
||||
def test_create_maps_domain_validation_error(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
self.service.create_error = InventoryValidationError(
|
||||
"Tag names cannot be blank"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/inventory/miners",
|
||||
json={"asset_id": "ASSET-001", "tags": [" "]},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"Tag names cannot be blank",
|
||||
)
|
||||
|
||||
def test_patch_requires_authentication(self):
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{uuid4()}",
|
||||
json={"model": "M60"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Login required")
|
||||
|
||||
def test_patch_requires_miners_update_permission(self):
|
||||
self.authenticate(permissions=("miners.view",))
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{uuid4()}",
|
||||
json={"model": "M60"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIsNone(self.service.update_request)
|
||||
|
||||
def test_patch_unknown_miner_returns_not_found(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
miner_id = uuid4()
|
||||
self.service.update_error = MinerNotFound(miner_id)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{miner_id}",
|
||||
json={"model": "M60"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["detail"], "Miner not found")
|
||||
|
||||
def test_patch_malformed_uuid_returns_validation_error(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
response = self.client.patch(
|
||||
"/api/inventory/miners/not-a-uuid",
|
||||
json={"model": "M60"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.update_request)
|
||||
|
||||
def test_empty_patch_maps_domain_validation_error(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
self.service.update_error = InventoryValidationError(
|
||||
"At least one metadata field must be provided"
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{uuid4()}",
|
||||
json={},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"At least one metadata field must be provided",
|
||||
)
|
||||
|
||||
def test_patch_rejects_unknown_and_operational_fields(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
for body in (
|
||||
{"unknown_field": "value"},
|
||||
{"lifecycle_status": "retired"},
|
||||
{"current_ip": "192.0.2.10"},
|
||||
{"current_mac": "AA:BB:CC:DD:EE:FF"},
|
||||
{"control_board_sn": "CB-NEW"},
|
||||
{"site_id": str(uuid4())},
|
||||
{"room_id": str(uuid4())},
|
||||
{"tags": ["Priority"]},
|
||||
{"firmware_version": "1.2.3"},
|
||||
{"hostname": "miner-host"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{uuid4()}",
|
||||
json=body,
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.update_request)
|
||||
|
||||
def test_valid_metadata_patch_returns_typed_envelope(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
updated = miner_detail()
|
||||
self.service.update_result = updated
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{updated.id}",
|
||||
json={
|
||||
"asset_id": " ASSET-NEW ",
|
||||
"miner_sn": "MINER-NEW",
|
||||
"psu_serial": None,
|
||||
"model": "M60",
|
||||
"notes": "Bench verified",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["data"]["id"], str(updated.id))
|
||||
self.assertEqual(body["meta"], {})
|
||||
self.assertIsNone(body["error"])
|
||||
self.assertEqual(self.service.update_miner_id, updated.id)
|
||||
self.assertIsInstance(self.service.update_actor_user_id, UUID)
|
||||
request = self.service.update_request
|
||||
self.assertIsNotNone(request)
|
||||
self.assertEqual(
|
||||
request.model_fields_set,
|
||||
{"asset_id", "miner_sn", "psu_serial", "model", "notes"},
|
||||
)
|
||||
self.assertIsNone(request.psu_serial)
|
||||
|
||||
def test_patch_duplicate_asset_id_returns_conflict(self):
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
self.service.update_error = DuplicateAssetID("ASSET-TAKEN")
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/inventory/miners/{uuid4()}",
|
||||
json={"asset_id": "ASSET-TAKEN"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"Asset ID already exists: ASSET-TAKEN",
|
||||
)
|
||||
|
||||
def test_list_requires_authentication(self):
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Login required")
|
||||
|
||||
def test_detail_requires_authentication(self):
|
||||
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Login required")
|
||||
|
||||
def test_asset_id_history_requires_authentication(self):
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{uuid4()}/asset-id-history"
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Login required")
|
||||
|
||||
def test_list_requires_miners_view_permission(self):
|
||||
self.authenticate(permissions=())
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_asset_id_history_requires_miners_view_permission(self):
|
||||
self.authenticate(permissions=())
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{uuid4()}/asset-id-history"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIsNone(self.service.history_miner_id)
|
||||
|
||||
def test_authenticated_list_returns_typed_envelope(self):
|
||||
self.authenticate()
|
||||
self.service.search_result = [miner_summary()]
|
||||
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(len(body["data"]), 1)
|
||||
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
|
||||
self.assertEqual(
|
||||
body["meta"],
|
||||
{"limit": 100, "offset": 0, "returned": 1},
|
||||
)
|
||||
self.assertIsNone(body["error"])
|
||||
|
||||
def test_empty_list_returns_successful_empty_collection(self):
|
||||
self.authenticate()
|
||||
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"], [])
|
||||
self.assertEqual(response.json()["meta"]["returned"], 0)
|
||||
|
||||
def test_search_filters_are_passed_to_service(self):
|
||||
self.authenticate()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/inventory/miners",
|
||||
params=[
|
||||
("query", "M50"),
|
||||
("asset_id", "ASSET-001"),
|
||||
("lifecycle_status", "spare"),
|
||||
("tags", "Priority"),
|
||||
("tags", "Repair"),
|
||||
("limit", "25"),
|
||||
("offset", "50"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
filters = self.service.search_filters
|
||||
self.assertIsNotNone(filters)
|
||||
self.assertEqual(filters.query, "M50")
|
||||
self.assertEqual(filters.asset_id, "ASSET-001")
|
||||
self.assertEqual(filters.lifecycle_status, "spare")
|
||||
self.assertEqual(filters.tags, ["Priority", "Repair"])
|
||||
self.assertEqual(filters.limit, 25)
|
||||
self.assertEqual(filters.offset, 50)
|
||||
|
||||
def test_invalid_lifecycle_filter_returns_client_error(self):
|
||||
self.authenticate()
|
||||
self.service.search_error = InventoryValidationError(
|
||||
"Lifecycle status must be active, spare, or retired"
|
||||
)
|
||||
response = self.client.get(
|
||||
"/api/inventory/miners",
|
||||
params={"lifecycle_status": "broken"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIn("Lifecycle status", response.json()["detail"])
|
||||
|
||||
def test_pagination_is_validated_and_bounded(self):
|
||||
self.authenticate()
|
||||
for params in (
|
||||
{"limit": 0},
|
||||
{"limit": 501},
|
||||
{"offset": -1},
|
||||
):
|
||||
with self.subTest(params=params):
|
||||
response = self.client.get(
|
||||
"/api/inventory/miners",
|
||||
params=params,
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_existing_uuid_returns_inventory_detail(self):
|
||||
self.authenticate()
|
||||
expected = miner_detail()
|
||||
self.service.detail_result = expected
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{expected.id}"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["id"], str(expected.id))
|
||||
self.assertEqual(response.json()["data"]["psu_serial"], "PSU-001")
|
||||
self.assertEqual(self.service.detail_miner_id, expected.id)
|
||||
|
||||
def test_unknown_uuid_returns_not_found(self):
|
||||
self.authenticate()
|
||||
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["detail"], "Miner not found")
|
||||
|
||||
def test_malformed_uuid_returns_client_error(self):
|
||||
self.authenticate()
|
||||
response = self.client.get("/api/inventory/miners/not-a-uuid")
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_asset_id_history_malformed_uuid_returns_validation_error(self):
|
||||
self.authenticate()
|
||||
response = self.client.get(
|
||||
"/api/inventory/miners/not-a-uuid/asset-id-history"
|
||||
)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertIsNone(self.service.history_miner_id)
|
||||
|
||||
def test_asset_id_history_unknown_miner_returns_not_found(self):
|
||||
self.authenticate()
|
||||
miner_id = uuid4()
|
||||
self.service.history_error = MinerNotFound(miner_id)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{miner_id}/asset-id-history"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["detail"], "Miner not found")
|
||||
|
||||
def test_asset_id_history_empty_collection_uses_typed_envelope(self):
|
||||
self.authenticate()
|
||||
miner_id = uuid4()
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{miner_id}/asset-id-history"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{"data": [], "meta": {}, "error": None},
|
||||
)
|
||||
self.assertEqual(self.service.history_miner_id, miner_id)
|
||||
|
||||
def test_asset_id_history_preserves_chronological_service_order(self):
|
||||
self.authenticate()
|
||||
miner_id = uuid4()
|
||||
first = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
|
||||
second = datetime(2026, 7, 28, 12, 0, tzinfo=timezone.utc)
|
||||
self.service.history_result = [
|
||||
history_entry(
|
||||
old_asset_id="ASSET-FIRST",
|
||||
new_asset_id="ASSET-SECOND",
|
||||
changed_at=first,
|
||||
),
|
||||
history_entry(
|
||||
old_asset_id="ASSET-SECOND",
|
||||
new_asset_id="ASSET-FIRST",
|
||||
changed_at=second,
|
||||
),
|
||||
]
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/inventory/miners/{miner_id}/asset-id-history"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()["data"]
|
||||
self.assertEqual(
|
||||
[entry["new_asset_id"] for entry in data],
|
||||
["ASSET-SECOND", "ASSET-FIRST"],
|
||||
)
|
||||
self.assertEqual(
|
||||
data[0]["changed_by"],
|
||||
"00000000-0000-0000-0000-000000000951",
|
||||
)
|
||||
self.assertEqual(
|
||||
data[0]["changed_by_display_name"],
|
||||
"Inventory Test User",
|
||||
)
|
||||
self.assertEqual(response.json()["meta"], {})
|
||||
self.assertIsNone(response.json()["error"])
|
||||
|
||||
def test_router_has_no_sql_or_database_driver_imports(self):
|
||||
router_path = (
|
||||
Path(__file__).parents[1] / "app" / "inventory" / "router.py"
|
||||
)
|
||||
source = router_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
imported_modules = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported_modules.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported_modules.append(node.module)
|
||||
|
||||
database_modules = ("psycopg", "asyncpg", "sqlalchemy")
|
||||
self.assertFalse(
|
||||
any(
|
||||
module == name or module.startswith(f"{name}.")
|
||||
for module in imported_modules
|
||||
for name in database_modules
|
||||
)
|
||||
)
|
||||
upper_source = source.upper()
|
||||
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
|
||||
self.assertNotIn(keyword, upper_source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
# MinerOps Brand Guidelines
|
||||
|
||||
## Brand Position
|
||||
|
||||
MinerOps is practical infrastructure for operators who need a clear, reliable
|
||||
view of mining assets, repairs, locations, and controls. The identity should
|
||||
feel precise, capable, and built for daily engineering work.
|
||||
|
||||
Primary tagline:
|
||||
|
||||
> The self-hosted operating system for cryptocurrency mining operations.
|
||||
|
||||
Secondary tagline:
|
||||
|
||||
> Know every miner. Track every repair. Control every rack.
|
||||
|
||||
## Branding Philosophy
|
||||
|
||||
- **Operational clarity:** Prefer direct language, strong hierarchy, and
|
||||
purposeful visual elements.
|
||||
- **Engineering confidence:** Use measured geometry, consistent spacing, and
|
||||
restrained color.
|
||||
- **Self-hosted independence:** Present MinerOps as dependable infrastructure
|
||||
owned and controlled by its operator.
|
||||
- **Human utility:** Keep the identity approachable and readable without
|
||||
becoming playful or ornamental.
|
||||
|
||||
## Logo System
|
||||
|
||||
The MinerOps mark combines a geometric hexagon with an angular letter M. The
|
||||
hexagon suggests infrastructure, modular systems, and engineered hardware. The
|
||||
M creates immediate product recognition.
|
||||
|
||||
Available placeholders:
|
||||
|
||||
- `logo.svg`: Primary mark for general use.
|
||||
- `logo-light.svg`: Navy and orange mark for light backgrounds.
|
||||
- `logo-dark.svg`: White and orange mark for dark backgrounds.
|
||||
- `favicon.svg`: Compact filled mark optimized for small sizes.
|
||||
- `wordmark.svg`: Symbol paired with the MinerOps name.
|
||||
|
||||
Use the supplied artwork without changing its proportions, stroke weights, or
|
||||
relative colors. Do not rotate, skew, outline, add effects, or place the mark
|
||||
inside another shape. Do not use gradients.
|
||||
|
||||
## Clear Space
|
||||
|
||||
Maintain clear space around the logo equal to at least one quarter of the
|
||||
mark's width. For the wordmark, use the hexagon's internal margin as the
|
||||
minimum clear space on every side.
|
||||
|
||||
Recommended minimum sizes:
|
||||
|
||||
- Standalone logo: 24 px digital, 8 mm print.
|
||||
- Wordmark: 120 px digital, 32 mm print.
|
||||
- Favicon: 16 px digital.
|
||||
|
||||
## Wordmark Usage
|
||||
|
||||
Use the wordmark when the audience needs explicit product identification, such
|
||||
as documentation covers, sign-in screens, repository graphics, and event
|
||||
materials. Use the standalone mark where MinerOps is already named nearby or
|
||||
space is limited.
|
||||
|
||||
Keep `Miner` in Miner Navy or White and `Ops` in Ops Orange. Do not alter the
|
||||
capitalization or insert a space between the words.
|
||||
|
||||
## Typography
|
||||
|
||||
Recommended interface and brand typefaces:
|
||||
|
||||
1. **Inter:** Primary choice for user interfaces, documentation, and marketing
|
||||
copy.
|
||||
2. **IBM Plex Sans:** Alternative for a more technical editorial voice.
|
||||
3. **IBM Plex Mono:** Recommended for asset IDs, IP addresses, serial numbers,
|
||||
logs, and other machine-oriented data.
|
||||
|
||||
Use system sans-serif fallbacks when these fonts are unavailable. Favor medium
|
||||
or semibold weights for headings and regular weight for body copy. Avoid
|
||||
condensed, decorative, or rounded display type.
|
||||
|
||||
## Spacing
|
||||
|
||||
Use a 4 px base spacing unit for digital products. Preferred increments are
|
||||
4, 8, 12, 16, 24, 32, 48, and 64 px. Align logo placement, typography, and
|
||||
supporting elements to a consistent grid.
|
||||
|
||||
Brand compositions should have enough open space to feel calm and technical.
|
||||
Avoid crowding the mark with borders, controls, or dense copy.
|
||||
|
||||
## Icon Usage
|
||||
|
||||
Use simple outline icons with consistent stroke weight and square optical
|
||||
bounds. Icons should describe actions or system states, not serve as
|
||||
decoration. Use filled icons sparingly for selected or critical states.
|
||||
|
||||
Prefer Miner Navy or Steel Gray for neutral icons, Ops Orange for active
|
||||
emphasis, and semantic colors defined by the product design system for status
|
||||
and alerts.
|
||||
|
||||
Do not force unrelated interface icons into the hexagonal logo enclosure. The
|
||||
hexagon-and-M combination is reserved for the MinerOps brand.
|
||||
|
||||
## Tagline Usage
|
||||
|
||||
Use the primary tagline for formal product positioning and first introductions.
|
||||
Use the secondary tagline for compact campaign copy, presentation endings, or
|
||||
operator-focused material.
|
||||
|
||||
Taglines should appear as supporting copy and should not be locked permanently
|
||||
to the logo artwork. Preserve the exact wording and punctuation shown above.
|
||||
@@ -0,0 +1,40 @@
|
||||
# MinerOps Color Palette
|
||||
|
||||
## Core Colors
|
||||
|
||||
| Color | Hex | Intended usage |
|
||||
| --- | --- | --- |
|
||||
| Miner Navy | `#0F172A` | Primary brand color, headings, navigation, dark surfaces, and the main logo structure |
|
||||
| Ops Orange | `#F97316` | Active emphasis, calls to action, selected states, and the M or Ops accent |
|
||||
| Steel Gray | `#475569` | Secondary text, neutral icons, borders, metadata, and supporting UI elements |
|
||||
| Light Gray | `#E2E8F0` | Dividers, subtle borders, disabled surfaces, and light neutral backgrounds |
|
||||
| White | `#FFFFFF` | Content surfaces, reversed text, and logo details on dark backgrounds |
|
||||
|
||||
## Usage Principles
|
||||
|
||||
- Use Miner Navy as the primary visual anchor.
|
||||
- Reserve Ops Orange for meaningful emphasis rather than large background
|
||||
fields.
|
||||
- Use Steel Gray for supporting information that should remain readable without
|
||||
competing with primary content.
|
||||
- Use Light Gray to organize interfaces with subtle separation.
|
||||
- Use White to preserve clarity and contrast on dark or dense surfaces.
|
||||
- Do not introduce gradients into core brand artwork.
|
||||
- Check text and control combinations for accessible contrast before production
|
||||
use.
|
||||
|
||||
## Common Combinations
|
||||
|
||||
| Context | Background | Foreground or accent |
|
||||
| --- | --- | --- |
|
||||
| Standard light surface | White | Miner Navy with Ops Orange emphasis |
|
||||
| Subtle light surface | Light Gray | Miner Navy or Steel Gray |
|
||||
| Primary dark surface | Miner Navy | White with Ops Orange emphasis |
|
||||
| Neutral metadata | White or Light Gray | Steel Gray |
|
||||
| Active control | Ops Orange | Miner Navy or White after contrast review |
|
||||
|
||||
## Color Proportions
|
||||
|
||||
For general brand compositions, use neutral surfaces and Miner Navy for most of
|
||||
the visual area. Ops Orange should typically occupy the smallest share and
|
||||
signal action, focus, or product identity.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">MinerOps favicon</title>
|
||||
<path fill="#0F172A" d="M32 2 59 17.5v29L32 62 5 46.5v-29L32 2Z"/>
|
||||
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="7" d="M18 44V21l14 14 14-14v23"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">MinerOps logo for dark backgrounds</title>
|
||||
<path fill="#FFFFFF" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
|
||||
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">MinerOps logo for light backgrounds</title>
|
||||
<path fill="none" stroke="#0F172A" stroke-linejoin="miter" stroke-width="6" d="M32 6.5 54 19v26L32 57.5 10 45V19L32 6.5Z"/>
|
||||
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">MinerOps logo</title>
|
||||
<path fill="#0F172A" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
|
||||
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72" role="img" aria-labelledby="title">
|
||||
<title id="title">MinerOps wordmark</title>
|
||||
<g transform="translate(4 4) scale(.95)">
|
||||
<path fill="#0F172A" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
|
||||
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
|
||||
</g>
|
||||
<text x="76" y="49" fill="#0F172A" font-family="Inter, IBM Plex Sans, Arial, sans-serif" font-size="42" font-weight="700">Miner</text>
|
||||
<text x="188" y="49" fill="#F97316" font-family="Inter, IBM Plex Sans, Arial, sans-serif" font-size="42" font-weight="700">Ops</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 728 B |
@@ -0,0 +1,457 @@
|
||||
# MinerOps Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the primary engineering architecture guide for MinerOps. It
|
||||
explains the codebase's current structure and the rules contributors must follow
|
||||
when adding or changing features.
|
||||
|
||||
MinerOps is self-hosted software with operational data and workflows that must
|
||||
remain understandable as the system grows. Maintainability, testability, and
|
||||
clear boundaries are primary architectural goals. A feature is not complete
|
||||
merely because it works; its responsibilities and dependencies must also be in
|
||||
the correct layer.
|
||||
|
||||
**Current implementation:** The repository is transitional. Most existing HTTP
|
||||
routes, persistence, and server-rendered assets are concentrated in
|
||||
`backend/app/main.py`. Identity has been separated into a FastAPI router.
|
||||
Inventory is the first domain with explicit models, exceptions, a service, a
|
||||
repository, and focused tests.
|
||||
|
||||
**Engineering rule:** New domain code follows the layered architecture in this
|
||||
document. Existing integrated code is compatibility context, not a pattern to
|
||||
copy.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
Transport, business rules, persistence, and presentation are different
|
||||
responsibilities. Keep them separate so each can change and be tested without
|
||||
requiring unrelated changes.
|
||||
|
||||
### Single Responsibility
|
||||
|
||||
Each module, class, and function should have one clear reason to change. A
|
||||
router handles HTTP. A service handles domain behavior. A repository handles
|
||||
persistence. Combining these responsibilities makes ownership ambiguous and
|
||||
tests expensive.
|
||||
|
||||
### Thin HTTP Layer
|
||||
|
||||
Routers accept and validate transport input, invoke services, and map results to
|
||||
HTTP responses. They do not implement business workflows and do not access the
|
||||
database directly.
|
||||
|
||||
### Business Logic Belongs in Services
|
||||
|
||||
Services own normalization, domain validation, decisions, and orchestration.
|
||||
They must not depend on FastAPI request or response objects, route decorators,
|
||||
or HTTP status codes.
|
||||
|
||||
### Persistence Belongs in Repositories
|
||||
|
||||
Repositories own queries, writes, database-facing filters, transaction
|
||||
mechanics, and persistence mapping. They contain no HTTP behavior, permission
|
||||
rules, or product decisions.
|
||||
|
||||
### Explicit Dependencies
|
||||
|
||||
Dependencies are visible at construction or call boundaries. The current
|
||||
`InventoryService` receives its repository through its constructor, allowing a
|
||||
substitute repository in service tests and a PostgreSQL repository in
|
||||
integration tests.
|
||||
|
||||
### Incremental Evolution
|
||||
|
||||
MinerOps has working behavior that must remain usable while responsibilities
|
||||
are separated. Improve boundaries in small, verified changes. Do not require a
|
||||
large rewrite before a domain can become clearer.
|
||||
|
||||
### Testability
|
||||
|
||||
Architecture should make the correct test easy to write. Business rules should
|
||||
be testable without FastAPI or PostgreSQL. Repository behavior should be
|
||||
testable against a disposable PostgreSQL database. Cross-application behavior
|
||||
should be testable through the disposable development stack.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The required dependency flow for domain-backed application features is:
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
v
|
||||
FastAPI Routers
|
||||
|
|
||||
v
|
||||
Services
|
||||
|
|
||||
v
|
||||
Repositories
|
||||
|
|
||||
v
|
||||
PostgreSQL
|
||||
```
|
||||
|
||||
- **Browser:** Presents the interface and sends authenticated HTTP requests. The
|
||||
current interface is served by FastAPI using HTML, CSS, and JavaScript.
|
||||
- **FastAPI routers:** Define the HTTP contract, enforce transport-level
|
||||
validation and authorization dependencies, call services, and serialize
|
||||
responses.
|
||||
- **Services:** Implement use cases and domain rules without knowing how the
|
||||
request arrived.
|
||||
- **Repositories:** Perform database operations and expose persistence behavior
|
||||
needed by services.
|
||||
- **PostgreSQL:** Stores authoritative operational records and enforces durable
|
||||
constraints.
|
||||
|
||||
Redis and Nginx are current infrastructure components, but they do not change
|
||||
this domain dependency flow. Redis is configured as an application service and
|
||||
included in health checks. Nginx is the gateway in the development Compose
|
||||
stack.
|
||||
|
||||
## Repository Organization
|
||||
|
||||
The repository is organized by runtime responsibility and supporting material:
|
||||
|
||||
- **`backend/`:** Python application code, FastAPI composition, domain packages,
|
||||
Alembic migrations, backend dependencies, tests, and the backend container
|
||||
definition.
|
||||
- **`frontend/` (planned):** A future standalone frontend may live here after a
|
||||
frontend technology and migration plan are selected. No `frontend/` directory
|
||||
or standalone frontend application exists today.
|
||||
- **`docs/`:** Engineering and product documentation that applies across
|
||||
domains.
|
||||
- **`branding/`:** Product identity guidelines and source vector assets. It has
|
||||
no runtime application responsibility.
|
||||
- **`scripts/`:** Development setup, disposable test runners, schema checks,
|
||||
retention helpers, and operational utilities.
|
||||
|
||||
Root Compose files define containerized services for the backend, PostgreSQL,
|
||||
and Redis. The development configuration also defines an Nginx gateway and runs
|
||||
Alembic before starting Uvicorn.
|
||||
|
||||
## Backend Layers
|
||||
|
||||
### Routers
|
||||
|
||||
Routers are the HTTP boundary. A domain router should be an `APIRouter` included
|
||||
by the application entry point.
|
||||
|
||||
Router responsibilities:
|
||||
|
||||
- Declare paths, HTTP methods, parameters, and response status codes.
|
||||
- Parse path, query, header, and body values.
|
||||
- Apply authentication and permission dependencies.
|
||||
- Construct domain input models from transport input.
|
||||
- Call services.
|
||||
- Translate domain exceptions into consistent HTTP errors.
|
||||
- Serialize service results into documented response models.
|
||||
|
||||
Routers must never contain:
|
||||
|
||||
- SQL or database connection management.
|
||||
- Business rules, normalization policy, or lifecycle decisions.
|
||||
- Multi-step persistence workflows or transaction control.
|
||||
- Device protocol implementations.
|
||||
- Copies of logic already owned by a service or repository.
|
||||
|
||||
A router may reject structurally invalid HTTP input. It must delegate questions
|
||||
such as whether an Asset ID is valid for a use case, whether a lifecycle change
|
||||
is allowed, or whether a record conflicts with existing state to the service.
|
||||
|
||||
**Current implementation:** `backend/app/main.py` creates the `FastAPI`
|
||||
application and includes the router from `backend/app/identity.py`. Identity is
|
||||
the current example of router extraction. Most other routes remain registered
|
||||
directly on `app` and often contain SQL or business logic. Those routes predate
|
||||
the current layering rules and must not be used as templates for new domains.
|
||||
The Inventory package currently has no router and registers no HTTP endpoints.
|
||||
|
||||
### Services
|
||||
|
||||
Services implement application use cases for a domain. A service coordinates
|
||||
repositories and applies rules that must remain consistent regardless of the
|
||||
transport calling it.
|
||||
|
||||
Service responsibilities:
|
||||
|
||||
- Normalize values according to domain semantics.
|
||||
- Validate business rules and allowed state transitions.
|
||||
- Detect conflicts meaningful to the domain.
|
||||
- Coordinate multiple repository operations as one use case.
|
||||
- Establish required transaction scope through repository abstractions.
|
||||
- Return domain models or domain-specific results.
|
||||
- Raise domain exceptions rather than HTTP exceptions.
|
||||
|
||||
The current `InventoryService` demonstrates these responsibilities. It:
|
||||
|
||||
- Normalizes Asset IDs and optional text.
|
||||
- Normalizes and deduplicates tags.
|
||||
- Validates `active`, `spare`, and `retired` lifecycle states.
|
||||
- Detects duplicate Asset IDs.
|
||||
- Defines never-seen timestamps for manually created miners.
|
||||
- Coordinates miner creation or update with tag associations.
|
||||
- Maps database-shaped records to Inventory summary and detail models.
|
||||
|
||||
The service imports no FastAPI components. Its extension hooks are domain-level
|
||||
hooks, not route callbacks. A service must not inspect cookies, headers, request
|
||||
objects, or response objects.
|
||||
|
||||
The current Inventory service catches a PostgreSQL uniqueness exception to
|
||||
translate a race into `DuplicateAssetID`. This is a known persistence coupling.
|
||||
New code should prevent database-specific exceptions from spreading further
|
||||
through the service layer, preferably by translating them at an explicit
|
||||
repository boundary.
|
||||
|
||||
### Repositories
|
||||
|
||||
Repositories are the persistence boundary for a domain. They expose operations
|
||||
in terms useful to services while keeping SQL and database mechanics out of
|
||||
higher layers.
|
||||
|
||||
Repository responsibilities:
|
||||
|
||||
- Execute parameterized reads and writes.
|
||||
- Implement database-facing search, filtering, ordering, and pagination.
|
||||
- Manage connections, commits, rollbacks, and transaction-bound instances.
|
||||
- Maintain associations owned by the domain.
|
||||
- Map database values into persistence records.
|
||||
- Translate low-level persistence failures when an owned abstraction exists.
|
||||
|
||||
Repositories contain database access only. They must not:
|
||||
|
||||
- Import FastAPI or router modules.
|
||||
- Choose HTTP responses or status codes.
|
||||
- Check the current user's permissions.
|
||||
- Normalize values according to product policy.
|
||||
- Decide whether a lifecycle transition or operational action is allowed.
|
||||
|
||||
`InventoryRepository` is the current reference implementation. It uses
|
||||
`psycopg`, accepts an optional connection factory, supports explicit transaction
|
||||
scope, and owns miner and tag CRUD, search, and associations. Its methods return
|
||||
database-shaped dictionaries; `InventoryService` maps those records to domain
|
||||
models.
|
||||
|
||||
Dynamic SQL must be restricted to trusted identifiers or fixed fragments.
|
||||
User-provided values always use database parameters. Repositories must not accept
|
||||
raw SQL from routers or services.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL is the authoritative persistent store for MinerOps. Application rules
|
||||
that must hold for every writer are also represented as database constraints
|
||||
where practical, including uniqueness, allowed values, and referential
|
||||
integrity.
|
||||
|
||||
Alembic is the authority for new persistent schema changes. Migrations must
|
||||
state how existing data is handled and provide a downgrade when it can be done
|
||||
without ambiguity or data loss.
|
||||
|
||||
**Current implementation:** Alembic migrations cover the foundational,
|
||||
scanning, audit, identity, role, and Inventory schemas. Some legacy code in
|
||||
`main.py` still creates or alters tables at runtime for compatibility. Do not add
|
||||
new runtime DDL. Move persistent schema changes through reviewed Alembic
|
||||
migrations.
|
||||
|
||||
The repository also contains a SQLAlchemy engine and session factory, while
|
||||
most current operational persistence and the Inventory repository use direct
|
||||
`psycopg`; some existing agent paths use `asyncpg`. A domain must choose and
|
||||
encapsulate its persistence mechanism inside its repository rather than expose
|
||||
driver details to routers.
|
||||
|
||||
## Domains
|
||||
|
||||
A domain package groups one operational capability behind an explicit public
|
||||
interface. Inventory is the current reference domain:
|
||||
|
||||
```text
|
||||
backend/app/inventory/
|
||||
__init__.py
|
||||
exceptions.py
|
||||
models.py
|
||||
repository.py
|
||||
service.py
|
||||
```
|
||||
|
||||
- `models.py` defines typed request, update, search, summary, detail, and tag
|
||||
contracts.
|
||||
- `exceptions.py` defines errors meaningful to Inventory callers.
|
||||
- `service.py` defines Inventory rules and use cases.
|
||||
- `repository.py` defines Inventory persistence.
|
||||
- `__init__.py` exposes the package's intended public surface.
|
||||
|
||||
Inventory's internal asset identity is the miner UUID. Product-facing Asset IDs
|
||||
are normalized and checked independently of mutable network fields. The domain
|
||||
maps product terminology such as `asset_id` and `psu_serial` to existing database
|
||||
columns at its service and persistence boundary.
|
||||
|
||||
Future domain packages should follow the same separation when it fits their use
|
||||
cases. Identity, Repairs, Monitoring, Operations, Scheduling, and Locations are
|
||||
examples of possible domain boundaries, not a fixed package list. A domain
|
||||
boundary must be based on ownership of rules and data, not on menu pages or
|
||||
route prefixes alone.
|
||||
|
||||
A domain may call another domain only through an explicit service contract. It
|
||||
must not import another domain's repository to update tables it does not own.
|
||||
Shared read requirements should use a clearly owned query service or read model
|
||||
rather than duplicating cross-domain SQL.
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
Dependencies flow downward:
|
||||
|
||||
```text
|
||||
Routers
|
||||
|
|
||||
v
|
||||
Services
|
||||
|
|
||||
v
|
||||
Repositories
|
||||
|
|
||||
v
|
||||
Database
|
||||
```
|
||||
|
||||
Allowed dependencies:
|
||||
|
||||
- The application entry point may include routers and install application-wide
|
||||
middleware.
|
||||
- Routers may depend on domain models, services, and exceptions.
|
||||
- Services may depend on their domain's models, exceptions, and explicit
|
||||
repository contracts.
|
||||
- Repositories may depend on configuration, database drivers, and
|
||||
persistence-specific utilities.
|
||||
- Tests may replace repositories or connection factories at defined dependency
|
||||
boundaries.
|
||||
|
||||
Examples of allowed flow:
|
||||
|
||||
- An Inventory router validates a UUID, calls `InventoryService.get_miner`, and
|
||||
translates `MinerNotFound` into an HTTP 404 response.
|
||||
- `InventoryService.create_miner` normalizes an Asset ID and coordinates miner
|
||||
and tag writes through an `InventoryRepository` transaction.
|
||||
- `InventoryRepository.search_miners` builds parameterized filters and executes
|
||||
them against PostgreSQL.
|
||||
|
||||
Prohibited dependencies:
|
||||
|
||||
- Repositories importing services, routers, or `app.main`.
|
||||
- Services importing FastAPI request or response classes.
|
||||
- Routers opening database connections or issuing SQL.
|
||||
- Domain code importing globals from `app.main`.
|
||||
- A lower layer choosing permissions or HTTP status codes.
|
||||
- A domain bypassing another domain's service to write its tables.
|
||||
- Circular imports between domains or layers.
|
||||
|
||||
Examples of prohibited flow:
|
||||
|
||||
- A route updating `miners` directly after checking request JSON.
|
||||
- A service returning `JSONResponse` or raising `HTTPException`.
|
||||
- A repository checking whether the current user is an administrator.
|
||||
- A Repairs service importing `InventoryRepository` to mutate miner ownership
|
||||
without an Inventory service contract.
|
||||
|
||||
Dependency direction is an architectural constraint, not a naming convention.
|
||||
Putting SQL in a file named `service.py` does not make it service logic.
|
||||
|
||||
## Database Philosophy
|
||||
|
||||
- **Alembic-managed schema:** New persistent tables, columns, constraints, and
|
||||
indexes are introduced through versioned migrations.
|
||||
- **Explicit migrations:** Every migration explains existing-data behavior,
|
||||
upgrade ordering, and downgrade behavior. Migrations do not silently merge,
|
||||
rename, or discard operational records.
|
||||
- **Stable identifiers:** Internal identity uses immutable identifiers rather
|
||||
than mutable IP addresses, MAC addresses, names, or locations.
|
||||
- **Repository ownership:** Each domain has one clear persistence boundary for
|
||||
its owned records. Other layers do not duplicate its SQL.
|
||||
- **Database enforcement:** PostgreSQL constraints protect invariants that must
|
||||
hold independently of application code.
|
||||
- **Transactional consistency:** Operations that must succeed or fail together
|
||||
execute in one explicit transaction.
|
||||
- **Data preservation:** Schema evolution preserves existing records unless a
|
||||
separately reviewed product decision explicitly requires otherwise.
|
||||
|
||||
Migration tests should verify upgrade, downgrade, constraints, and existing-data
|
||||
behavior. Runtime schema changes are legacy compatibility behavior and are not
|
||||
permitted for new work.
|
||||
|
||||
## API Philosophy
|
||||
|
||||
MinerOps APIs use resource-oriented HTTP semantics. New or replaced domain APIs
|
||||
follow these conventions:
|
||||
|
||||
- Use nouns for resources and standard HTTP methods for operations.
|
||||
- Validate path, query, and body structure at the router boundary.
|
||||
- Apply business validation in the service.
|
||||
- Use Pydantic models for request and response contracts.
|
||||
- Return predictable status codes and response shapes.
|
||||
- Translate domain exceptions into consistent, non-leaking API errors.
|
||||
- Bound collection endpoints with pagination.
|
||||
- Make supported filtering, searching, and ordering explicit.
|
||||
- Preserve stable identifiers in URLs and response contracts.
|
||||
- Enforce authentication and permissions on the server.
|
||||
- Avoid duplicate endpoints for the same use case.
|
||||
|
||||
**Current implementation:** Many routes return the common `data`, `meta`, and
|
||||
`error` envelope defined in `main.py`. Pydantic validation, pagination, and
|
||||
error shapes are not uniform across all existing routes. Identity supplies
|
||||
current authentication and role-aware dependencies. New endpoints should follow
|
||||
the conventions above while preserving compatibility for any route they
|
||||
replace.
|
||||
|
||||
Pagination metadata should be sufficient for callers to understand the returned
|
||||
window. Filters must be parameterized in repositories, not interpolated in
|
||||
routers. Database exceptions and internal details must not be exposed to API
|
||||
clients.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
Tests follow the same boundaries as production code.
|
||||
|
||||
- **Unit tests:** Exercise service rules with substituted dependencies. They
|
||||
should not require FastAPI or a database when the behavior is purely domain
|
||||
logic.
|
||||
- **Repository tests:** Exercise real queries, constraints, associations, and
|
||||
transactions against a disposable PostgreSQL database.
|
||||
- **Migration tests:** Verify schema upgrade, downgrade, constraint behavior,
|
||||
and existing-data preservation.
|
||||
- **Integration tests:** Exercise application startup, migrations,
|
||||
authentication, authorization, safety behavior, and cross-layer workflows in
|
||||
a disposable Compose stack.
|
||||
- **Architecture tests:** Protect dependency rules that can be checked
|
||||
mechanically, such as the Inventory package containing no FastAPI imports or
|
||||
router registration.
|
||||
|
||||
Current Inventory tests use a fake repository for service rules and PostgreSQL
|
||||
for repository behavior. The focused runner migrates a disposable database
|
||||
through Alembic before executing the suite. The development baseline starts an
|
||||
isolated Compose project and checks schema, login gating, roles, and safety
|
||||
interlocks.
|
||||
|
||||
Before committing, run validation appropriate to the change. This commonly
|
||||
includes Python compilation, shell syntax checks, focused unit or repository
|
||||
tests, migration tests for schema work, the disposable development baseline for
|
||||
cross-application changes, and `git diff --check`.
|
||||
|
||||
Keep commits small and reviewable. A commit should express one coherent change,
|
||||
include focused tests when behavior changes, and avoid unrelated formatting or
|
||||
refactoring. Broad integration coverage does not replace a focused test for a
|
||||
new rule.
|
||||
|
||||
## Future Evolution
|
||||
|
||||
MinerOps will evolve incrementally as existing responsibilities are moved into
|
||||
clear domain boundaries. New infrastructure or domain packages must preserve
|
||||
the router-to-service-to-repository dependency direction, explicit ownership,
|
||||
and testability rules in this document. Architectural changes should clarify
|
||||
those rules rather than bypass them.
|
||||
|
||||
## When In Doubt
|
||||
|
||||
When a feature does not fit naturally into an existing module, refine the
|
||||
architecture instead of weakening architectural boundaries. Clear boundaries
|
||||
are easier to maintain than clever shortcuts.
|
||||
@@ -0,0 +1,372 @@
|
||||
# MinerOps Architecture & Product Decisions
|
||||
|
||||
This document records long-lived engineering and product decisions for
|
||||
MinerOps. Each record explains the context, the chosen approach, and its
|
||||
consequences so future work can preserve intent rather than infer it from code.
|
||||
|
||||
Decisions may evolve as operational evidence changes. Replacing or reversing a
|
||||
decision should be deliberate and documented in a later record. Existing
|
||||
records should remain available as history rather than being silently rewritten.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0001
|
||||
|
||||
**Title:** MinerOps is self-hosted first.
|
||||
|
||||
## Context
|
||||
|
||||
Mining operations depend on local equipment, segmented networks, and
|
||||
operational records that may need to remain under direct organizational control.
|
||||
Requiring a hosted service would make availability, data access, and subscription
|
||||
terms dependencies of core operations.
|
||||
|
||||
External services can still provide useful integrations. The question is
|
||||
whether they are optional participants or mandatory owners of the operational
|
||||
record.
|
||||
|
||||
## Decision
|
||||
|
||||
MinerOps is self-hosted first. Operators control the deployment and retain
|
||||
ownership of their operational data. Core use must not require a mandatory SaaS
|
||||
subscription or transfer the authoritative record to a hosted provider.
|
||||
|
||||
Remote access and integrations are allowed when the operator chooses them. They
|
||||
must not change the self-hosted deployment into a secondary cache of a required
|
||||
external service.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Operators can place MinerOps near local mining networks, retain
|
||||
control of data and availability, and use it in restricted environments.
|
||||
|
||||
**Tradeoffs:** Operators are responsible for deployment, updates, backups, and
|
||||
recovery. Optional hosted integrations must be designed so loss of the
|
||||
integration does not remove ownership of the core record.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0002
|
||||
|
||||
**Title:** Assets use durable internal identity.
|
||||
|
||||
## Context
|
||||
|
||||
A miner's observable properties change during normal operations. IP addresses
|
||||
change, network interfaces and control boards are replaced, equipment moves,
|
||||
and operator-facing labels may be corrected. None of those changes should make
|
||||
the asset appear to be a different historical object by default.
|
||||
|
||||
Operators also need a practical label that can be printed, scanned, and used in
|
||||
conversation without exposing an internal database key.
|
||||
|
||||
## Decision
|
||||
|
||||
Every miner asset has an immutable internal UUID. That UUID is the durable
|
||||
identity used for relationships and history.
|
||||
|
||||
The Asset ID is the operator-facing identifier. It is unique within a MinerOps
|
||||
installation and can be managed independently of mutable network values. IP
|
||||
addresses, MAC addresses, hardware serials, and location are attributes or
|
||||
associations of the asset, not substitutes for its internal identity.
|
||||
|
||||
Hardware replacement does not automatically create a new asset identity. The
|
||||
correct outcome depends on whether the operation represents repair of the same
|
||||
asset or introduction of a genuinely different asset, and that distinction must
|
||||
be explicit.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Records can remain connected through network changes, movement,
|
||||
and component replacement. Internal relationships do not depend on a mutable or
|
||||
human-entered value.
|
||||
|
||||
**Tradeoffs:** Imports, discovery, and repair workflows must resolve observations
|
||||
to an existing UUID or deliberately create a new asset. Duplicate resolution
|
||||
cannot safely rely on IP or MAC alone.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0003
|
||||
|
||||
**Title:** Network identity is not asset identity.
|
||||
|
||||
## Context
|
||||
|
||||
Discovery observes devices through IP addresses, MAC addresses, hostnames, and
|
||||
protocol responses. These values are useful for finding and communicating with
|
||||
a miner, but they can change because of DHCP, network reconfiguration, control
|
||||
board replacement, or data correction.
|
||||
|
||||
Treating the latest network observation as the asset identity loses continuity
|
||||
and can attach history to the wrong equipment.
|
||||
|
||||
## Decision
|
||||
|
||||
Network identities are associated with durable assets. Current IP and MAC values
|
||||
represent the latest known network state, while meaningful prior associations
|
||||
should remain available as history.
|
||||
|
||||
Discovery proposes or updates associations; it does not redefine the asset
|
||||
solely because an address changed. Ambiguous matches require a deliberate
|
||||
identity workflow rather than silent merging or replacement.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Discovery can remain useful without erasing asset continuity.
|
||||
Operators can understand address changes and component replacement in historical
|
||||
context.
|
||||
|
||||
**Tradeoffs:** Matching requires more information and explicit conflict handling.
|
||||
Some observations cannot be attached automatically, and network history adds
|
||||
records that must be maintained and presented clearly.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0004
|
||||
|
||||
**Title:** Business logic belongs in services.
|
||||
|
||||
## Context
|
||||
|
||||
When business rules are implemented in HTTP handlers, they become coupled to a
|
||||
transport and are difficult to reuse or test. When they are placed in database
|
||||
code, persistence concerns become mixed with product decisions.
|
||||
|
||||
MinerOps has existing integrated routes that predate the current domain
|
||||
boundaries. Copying that structure would continue the coupling.
|
||||
|
||||
## Decision
|
||||
|
||||
Domain services own normalization, business validation, decisions, state-change
|
||||
rules, and use-case orchestration. Routers handle HTTP concerns and call
|
||||
services. Repositories persist the outcomes requested by services.
|
||||
|
||||
Services raise domain errors and return domain results. They do not depend on
|
||||
FastAPI requests, responses, decorators, or status codes. The Inventory service
|
||||
is the current reference for this separation.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Business behavior can be tested without HTTP or a real database,
|
||||
used by more than one adapter, and maintained in one explicit location.
|
||||
|
||||
**Tradeoffs:** Features require an additional boundary and clear input and output
|
||||
contracts. Poorly chosen service responsibilities can become overly broad, so
|
||||
service ownership must follow domain use cases rather than convenience.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0005
|
||||
|
||||
**Title:** Persistence is isolated behind repositories.
|
||||
|
||||
## Context
|
||||
|
||||
SQL embedded in routes or services couples product behavior to schema details,
|
||||
connection handling, and a specific database driver. Repeated queries also make
|
||||
ownership unclear and allow filtering or transaction behavior to diverge.
|
||||
|
||||
Tests need a boundary where service behavior can substitute persistence and
|
||||
where database behavior can be verified independently.
|
||||
|
||||
## Decision
|
||||
|
||||
Each domain isolates its database access behind repositories. Repositories own
|
||||
SQL, database-facing filtering and ordering, connection and transaction
|
||||
mechanics, and persistence mapping.
|
||||
|
||||
Routers and services do not issue SQL. A domain owns persistence for its records,
|
||||
and another domain does not bypass that ownership by importing its repository
|
||||
to perform unrelated writes.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** SQL has explicit ownership, service tests can use substitutes,
|
||||
repository tests can target real database behavior, and persistence changes have
|
||||
a defined boundary.
|
||||
|
||||
**Tradeoffs:** Repository interfaces must be maintained, and cross-domain read
|
||||
requirements need deliberate query ownership. A repository is not an excuse to
|
||||
hide business rules in database code.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0006
|
||||
|
||||
**Title:** Database schema changes use Alembic.
|
||||
|
||||
## Context
|
||||
|
||||
Persistent schema must be reproducible across new installations, existing
|
||||
deployments, tests, and recovery environments. Runtime table creation makes
|
||||
schema state depend on which application paths have executed and is difficult
|
||||
to review or reverse.
|
||||
|
||||
The existing application contains legacy runtime schema creation for
|
||||
compatibility, but the repository now has a versioned Alembic migration chain
|
||||
and focused migration tests.
|
||||
|
||||
## Decision
|
||||
|
||||
New persistent schema changes are made through explicit, versioned Alembic
|
||||
migrations. A migration documents upgrade behavior, treatment of existing data,
|
||||
constraints and indexes, and a downgrade path where it is safe and meaningful.
|
||||
|
||||
New code must not create or alter persistent tables at application runtime.
|
||||
Legacy runtime schema behavior is an existing compatibility condition, not a
|
||||
pattern for new work.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Schema state is reviewable, reproducible, testable, and ordered.
|
||||
Deployments and disposable tests can reach the same known schema from an empty
|
||||
or existing database.
|
||||
|
||||
**Tradeoffs:** Schema work requires migration design and validation before the
|
||||
application can use it. Some destructive downgrades cannot preserve all data and
|
||||
must be documented explicitly.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0007
|
||||
|
||||
**Title:** Operational history is preserved.
|
||||
|
||||
## Context
|
||||
|
||||
The current value of an asset does not explain how it arrived there. Repairs,
|
||||
movement, lifecycle changes, network identity changes, component replacement,
|
||||
and operator actions all affect operational understanding.
|
||||
|
||||
Overwriting important changes may simplify the current record but removes the
|
||||
context technicians and managers need to diagnose problems and trust the
|
||||
system.
|
||||
|
||||
## Decision
|
||||
|
||||
MinerOps preserves meaningful operational history. Current state remains easy
|
||||
to identify, while important repairs, movement, lifecycle events, identity
|
||||
associations, and audited actions remain traceable with time and context.
|
||||
|
||||
Corrections must not silently destroy history that remains operationally
|
||||
relevant. Retention policy may summarize high-volume observations, but durable
|
||||
asset, repair, movement, lifecycle, and audit meaning is preserved.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Operators can understand an asset across its lifecycle, technicians
|
||||
can review prior work, and changes can be audited and explained.
|
||||
|
||||
**Tradeoffs:** Historical data requires storage, retention policy, and interfaces
|
||||
that distinguish current state from prior state. Corrections and privacy or
|
||||
retention requirements need deliberate handling.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0008
|
||||
|
||||
**Title:** Safety takes precedence over convenience.
|
||||
|
||||
## Context
|
||||
|
||||
MinerOps can coordinate actions that interrupt individual miners or affect a
|
||||
large part of a fleet. A fast or convenient action can create substantial
|
||||
operational impact when its target, scope, timing, or authorization is wrong.
|
||||
|
||||
Bulk operations and automation increase this risk because one request may affect
|
||||
many devices without individual review.
|
||||
|
||||
## Decision
|
||||
|
||||
Potentially disruptive actions favor safety over minimum interaction. Product
|
||||
workflows enforce permissions, communicate target and scope, require clear
|
||||
confirmation where appropriate, and leave an audit trail.
|
||||
|
||||
Broad actions should support safeguards such as preview or dry-run behavior,
|
||||
staggering, limits, expiry, and awareness of maintenance or intentionally
|
||||
offline state when those safeguards apply. Development and new installations
|
||||
use safe defaults.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Operators receive clearer intent and accountability, accidental
|
||||
fleet-wide disruption is less likely, and actions can be investigated after the
|
||||
fact.
|
||||
|
||||
**Tradeoffs:** Some operations require more steps and may complete more slowly.
|
||||
Safety behavior adds product and test complexity and must be maintained rather
|
||||
than bypassed for convenience.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0009
|
||||
|
||||
**Title:** Progressive complexity.
|
||||
|
||||
## Context
|
||||
|
||||
Mining operations vary from small fleets with simple inventory needs to
|
||||
multi-site organizations with detailed infrastructure, automation, and role
|
||||
requirements. Requiring every installation to configure the largest possible
|
||||
model would make basic workflows difficult to adopt and maintain.
|
||||
|
||||
At the same time, limiting the product to the smallest deployment would prevent
|
||||
larger operators from representing their real work.
|
||||
|
||||
## Decision
|
||||
|
||||
MinerOps exposes useful basic workflows without requiring every advanced
|
||||
capability. Operators can begin with the records and structure they need, then
|
||||
adopt additional location depth, monitoring, automation, integrations, and
|
||||
organizational controls as their operation requires them.
|
||||
|
||||
Advanced features remain coherent extensions of the same operational record.
|
||||
They do not create a separate product mode or make basic asset and repair work
|
||||
dependent on unrelated configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Small deployments can gain value with less setup, while larger
|
||||
deployments have room to model additional complexity without abandoning the
|
||||
same system of record.
|
||||
|
||||
**Tradeoffs:** Defaults and optional states must be designed carefully. Features
|
||||
must work when advanced context is absent, which increases the number of valid
|
||||
configurations that testing must cover.
|
||||
|
||||
---
|
||||
|
||||
# ADR-0010
|
||||
|
||||
**Title:** Architecture evolves incrementally.
|
||||
|
||||
## Context
|
||||
|
||||
MinerOps contains working early-stage software alongside newer domain
|
||||
boundaries. Replacing the application in one rewrite would put existing
|
||||
workflows, data compatibility, and validation at unnecessary risk.
|
||||
|
||||
Leaving all existing responsibilities in integrated modules would also make new
|
||||
features harder to test and maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
Architecture improves incrementally. New domain code follows the established
|
||||
router, service, and repository boundaries. Existing behavior moves behind
|
||||
those boundaries in small, reviewable changes that preserve working software
|
||||
and include compatibility validation.
|
||||
|
||||
A new boundary is introduced when it clarifies ownership or testability, not
|
||||
merely to rename or relocate code. Existing integrated code is context to
|
||||
preserve, not a template to duplicate.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits:** Architectural quality improves while the application remains
|
||||
usable. Smaller changes are easier to review, test, diagnose, and reverse.
|
||||
|
||||
**Tradeoffs:** The repository will contain transitional patterns for a period of
|
||||
time. Contributors must understand which code represents current rules and
|
||||
which code remains for compatibility, and some temporary duplication may need
|
||||
careful removal after migration.
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
# MinerOps Product Guide
|
||||
|
||||
## Purpose
|
||||
|
||||
MinerOps is intended to become the self-hosted operational system of record for
|
||||
cryptocurrency mining operations.
|
||||
|
||||
Its primary product position is:
|
||||
|
||||
> The self-hosted operating system for cryptocurrency mining operations.
|
||||
|
||||
Its supporting statement is:
|
||||
|
||||
> Know every miner. Track every repair. Control every rack.
|
||||
|
||||
"Operating system" describes a shared operational foundation, not a replacement
|
||||
for a computer operating system or miner firmware. MinerOps is intended to
|
||||
connect the records and workflows that operators use to understand their fleet,
|
||||
maintain equipment, and coordinate work.
|
||||
|
||||
"Know every miner" means that each asset should have a durable, understandable
|
||||
record. "Track every repair" means that failures, work, parts, tests, and
|
||||
outcomes should remain connected to that record. "Control every rack" means
|
||||
that operational actions should be available with appropriate context,
|
||||
permissions, safeguards, and visibility.
|
||||
|
||||
**Current implementation:** MinerOps is an early-stage application. The
|
||||
repository contains authentication and role foundations, an Inventory schema
|
||||
and backend domain foundation, and existing operational workflows for areas such
|
||||
as discovery, repairs, locations, monitoring, and control. These areas are not
|
||||
all complete or organized as finished product domains.
|
||||
|
||||
**Product direction:** MinerOps should bring these capabilities together around
|
||||
one durable operational record while remaining practical for self-hosted use.
|
||||
The directions in this guide are not claims that every capability is currently
|
||||
implemented.
|
||||
|
||||
## Target Users
|
||||
|
||||
MinerOps is intended for people responsible for operating and maintaining
|
||||
cryptocurrency mining equipment, including:
|
||||
|
||||
- Mining farm operators
|
||||
- Technicians
|
||||
- Operations managers
|
||||
- Site administrators
|
||||
- Owners managing small or medium fleets
|
||||
- Organizations operating multiple rooms or sites
|
||||
|
||||
These users need different levels of visibility and control. A technician may
|
||||
need fast access to asset history and repair context from the floor. A manager
|
||||
may need fleet state, work status, and accountability across teams. A site
|
||||
administrator may need configuration and access controls. An owner may need a
|
||||
clear view across one facility or several sites.
|
||||
|
||||
MinerOps should support those different working contexts without forcing every
|
||||
user to navigate the same amount of detail or receive the same operational
|
||||
control. Detailed permissions belong to access-control design rather than this
|
||||
product guide.
|
||||
|
||||
## Problems MinerOps Solves
|
||||
|
||||
Mining operations often build their working record from several disconnected
|
||||
sources. Asset details may live in spreadsheets, current health in monitoring
|
||||
tools, repair notes in informal documents, and physical location knowledge in a
|
||||
technician's memory.
|
||||
|
||||
Common operational problems include:
|
||||
|
||||
- Asset information spread across spreadsheets and ad hoc records.
|
||||
- IP addresses changing without a reliable link to the durable asset.
|
||||
- Control board replacements changing MAC addresses and confusing identity.
|
||||
- Repair and maintenance history being incomplete or difficult to find.
|
||||
- Equipment being difficult to locate across rooms, racks, shelves, or storage.
|
||||
- Important fleet knowledge existing only in individual technicians' memories.
|
||||
- Monitoring and control tools being disconnected from inventory and repair
|
||||
records.
|
||||
- Limited audit history for changes and operational actions.
|
||||
- Uncertainty about which miners are active, spare, under repair, missing, or
|
||||
retired.
|
||||
- Difficulty coordinating safe actions across many miners at once.
|
||||
|
||||
MinerOps is intended to reduce this fragmentation. It should help operators
|
||||
answer practical questions about what an asset is, where it is, what condition
|
||||
it is in, what has happened to it, and what action is appropriate next.
|
||||
|
||||
## Product Vision
|
||||
|
||||
The long-term product direction is one self-hosted platform connecting:
|
||||
|
||||
- Inventory
|
||||
- Durable asset identity
|
||||
- Network discovery
|
||||
- Locations
|
||||
- Repairs and work orders
|
||||
- Spare parts
|
||||
- Monitoring and telemetry
|
||||
- Alerts
|
||||
- Fleet control
|
||||
- Scheduling and automation
|
||||
- Electrical and infrastructure records
|
||||
- Historical reporting
|
||||
- Audit history
|
||||
|
||||
These capabilities should reinforce one another. A monitoring alert should
|
||||
point to the correct asset. A repair should include the relevant history and
|
||||
location. A component replacement should not erase the identity of the miner. A
|
||||
fleet action should respect maintenance state and leave an audit record.
|
||||
|
||||
This list defines product direction. The repository contains foundations and
|
||||
working early-stage workflows in several of these areas, but it does not yet
|
||||
provide the complete product described here.
|
||||
|
||||
## Product Principles
|
||||
|
||||
### Single Source of Truth
|
||||
|
||||
Important operational records should have one authoritative home. Other tools
|
||||
may contribute observations or consume data, but MinerOps should reduce the
|
||||
need to reconcile competing spreadsheets, notes, and dashboards.
|
||||
|
||||
A source of truth must be understandable and correctable. It should make the
|
||||
current record clear while retaining enough history to explain how that record
|
||||
changed.
|
||||
|
||||
### Asset Identity Before Network Identity
|
||||
|
||||
A miner is not defined by its current IP address or MAC address. Network values
|
||||
are mutable observations about an asset, not the asset's durable identity.
|
||||
|
||||
MinerOps should preserve a stable internal identity through address changes,
|
||||
control board replacements, movement, repair, storage, and retirement. Network
|
||||
identity remains important, but it must be associated with the asset rather
|
||||
than substituted for it.
|
||||
|
||||
### Preserve History
|
||||
|
||||
Operationally important changes should remain traceable instead of silently
|
||||
overwriting the past. Repairs, locations, network identities, components,
|
||||
status changes, and operator actions may all affect how an asset's current state
|
||||
is understood.
|
||||
|
||||
History should be useful rather than merely accumulated. Records need clear
|
||||
timestamps, context, and attribution where appropriate.
|
||||
|
||||
### Self-Hosted First
|
||||
|
||||
Operators should retain control of their operational data and deployment.
|
||||
MinerOps should work as software that an organization can run within its own
|
||||
environment without requiring a mandatory hosted subscription.
|
||||
|
||||
Self-hosted first does not prohibit internet access, remote access, or external
|
||||
integrations. Those capabilities should remain deliberate choices under the
|
||||
operator's control.
|
||||
|
||||
### Safe by Default
|
||||
|
||||
MinerOps may coordinate actions that interrupt mining or affect many devices.
|
||||
Potentially disruptive actions require deliberate authorization, clear scope,
|
||||
validation, and auditability.
|
||||
|
||||
Defaults should favor observation and review over unexpected action. A new
|
||||
installation or development environment should not begin issuing broad or
|
||||
destructive commands without explicit configuration.
|
||||
|
||||
### Practical Operations Over Cryptocurrency Hype
|
||||
|
||||
MinerOps is an operations and infrastructure product. Its value comes from
|
||||
making assets, repairs, locations, health, and controls easier to understand and
|
||||
manage.
|
||||
|
||||
It is not a trading, investment, or promotional product. Product decisions
|
||||
should be evaluated against operational usefulness rather than cryptocurrency
|
||||
trends or speculative features.
|
||||
|
||||
### Technician-Friendly
|
||||
|
||||
Common workflows should be usable where the work happens. Interfaces should
|
||||
support clear scanning, direct actions, and responsive layouts suitable for
|
||||
phones and workstations.
|
||||
|
||||
Mobile and QR-assisted workflows are appropriate product directions when they
|
||||
reduce lookup time or data-entry mistakes. They should complement durable asset
|
||||
identity rather than introduce a separate record.
|
||||
|
||||
### Progressive Complexity
|
||||
|
||||
A small operator should be able to begin with useful inventory, location, and
|
||||
repair workflows without configuring every advanced capability. Larger or more
|
||||
complex installations should be able to add structure as their needs grow.
|
||||
|
||||
Advanced monitoring, automation, infrastructure mapping, and integrations
|
||||
should not make the basic operational record harder to use.
|
||||
|
||||
### Explicit Human Control
|
||||
|
||||
Automation should assist operators without making important decisions
|
||||
invisible. Users should understand what will happen, what is happening, and what
|
||||
happened afterward.
|
||||
|
||||
Automated behavior should expose its scope, conditions, limits, and result. An
|
||||
operator must be able to distinguish intentional automation from an unexpected
|
||||
system action.
|
||||
|
||||
## Core Product Areas
|
||||
|
||||
### Inventory and Assets
|
||||
|
||||
Inventory provides durable records for miners and related hardware. It connects
|
||||
operator-facing Asset IDs, hardware identifiers, lifecycle state, tags, notes,
|
||||
and ownership of the asset record without relying on current network values.
|
||||
|
||||
### Identity and Discovery
|
||||
|
||||
Identity and discovery connect observations from the network to stable assets.
|
||||
The product should distinguish current network information from history and
|
||||
provide deliberate handling when components or identities change.
|
||||
|
||||
### Locations and Infrastructure
|
||||
|
||||
Locations and infrastructure describe where equipment is installed or stored
|
||||
and how it relates to sites, rooms, racks, shelves, positions, and electrical
|
||||
context. Movement should remain understandable over time.
|
||||
|
||||
### Repairs and Maintenance
|
||||
|
||||
Repairs and maintenance connect problems, diagnostics, work, parts, testing,
|
||||
and return-to-service outcomes to the asset. Technicians should be able to see
|
||||
prior work before beginning another repair.
|
||||
|
||||
### Monitoring and Alerts
|
||||
|
||||
Monitoring and alerts explain live and historical operational health. They
|
||||
should add observations to the operational record without replacing durable
|
||||
inventory, repair, or lifecycle information.
|
||||
|
||||
### Fleet Operations
|
||||
|
||||
Fleet operations provide permission-controlled actions for individual miners or
|
||||
groups. Controls should include enough identity, location, health, and
|
||||
maintenance context to reduce avoidable mistakes.
|
||||
|
||||
### Scheduling and Automation
|
||||
|
||||
Scheduling and automation support repeatable work while preserving safeguards,
|
||||
expiry, rollback or recovery behavior where practical, and operator visibility.
|
||||
Automation should make coordinated operations more predictable, not less
|
||||
accountable.
|
||||
|
||||
### Reporting and Audit History
|
||||
|
||||
Reporting and audit history help operators understand what changed, who changed
|
||||
it, why it changed, and what happened over time. Reports should draw from the
|
||||
same operational records used by day-to-day workflows.
|
||||
|
||||
## Operational Source of Truth
|
||||
|
||||
MinerOps should connect information that is commonly separated:
|
||||
|
||||
- Asset identity
|
||||
- Network identity
|
||||
- Physical location
|
||||
- Repair status
|
||||
- Operational status
|
||||
- Historical changes
|
||||
|
||||
These are related views of one operational asset, not independent records to be
|
||||
manually reconciled. Current status should remain distinct from lifecycle,
|
||||
repair, and location state so that one value does not erase important context.
|
||||
|
||||
A miner should remain understandable throughout its lifecycle: initial entry,
|
||||
deployment, discovery, movement, component replacement, failure, repair,
|
||||
testing, storage, redeployment, and retirement. Operators should be able to
|
||||
follow that history even when the miner's address, control board, rack, or
|
||||
condition changes.
|
||||
|
||||
The source-of-truth role also requires correction. Authorized users should be
|
||||
able to fix inaccurate records without destroying meaningful history or hiding
|
||||
who made the change.
|
||||
|
||||
## Self-Hosted Philosophy
|
||||
|
||||
Self-hosting matters because mining operations depend on local equipment,
|
||||
segmented networks, and operational data that may need to remain under direct
|
||||
organizational control.
|
||||
|
||||
A self-hosted MinerOps deployment should support:
|
||||
|
||||
- Operational control over configuration and availability.
|
||||
- Ownership of asset, repair, telemetry, and audit data.
|
||||
- Access to equipment on local networks where appropriate.
|
||||
- Independence from mandatory hosted subscriptions.
|
||||
- Operation in restricted or segmented environments.
|
||||
- Backup, restore, and migration under the operator's control.
|
||||
|
||||
Self-hosted does not mean isolated. Optional remote access, manufacturer
|
||||
interfaces, reporting systems, or other integrations may be valuable. They
|
||||
should not make a hosted third party the mandatory owner of the operational
|
||||
record.
|
||||
|
||||
## Safety and Control
|
||||
|
||||
MinerOps may eventually perform actions capable of disrupting individual miners
|
||||
or a significant part of a fleet. Safety is therefore a product behavior, not
|
||||
only an implementation concern.
|
||||
|
||||
Product expectations include:
|
||||
|
||||
- Role and permission enforcement.
|
||||
- Clear confirmation for destructive or broad actions.
|
||||
- Dry-run or preview behavior where practical.
|
||||
- Staggering, rate limits, and scope limits for bulk actions.
|
||||
- Commands that expire rather than remain indefinitely actionable.
|
||||
- Audit history for requested actions and outcomes.
|
||||
- Awareness of maintenance state and intentionally offline equipment.
|
||||
- Safe defaults in development and new installations.
|
||||
|
||||
The interface should communicate the intended target, scope, and likely impact
|
||||
before an action is accepted. Afterward, the operator should be able to see
|
||||
whether the action ran, partially succeeded, failed, expired, or was cancelled.
|
||||
|
||||
Automation does not remove the need for authorization or visibility. The
|
||||
broader the potential effect, the more explicit the safeguards should be.
|
||||
|
||||
## What MinerOps Is Not
|
||||
|
||||
MinerOps is not intended to be:
|
||||
|
||||
- A cryptocurrency exchange
|
||||
- A wallet
|
||||
- A trading platform
|
||||
- An investment dashboard
|
||||
- A hosted mining provider
|
||||
- A replacement for manufacturer firmware
|
||||
- A general-purpose data centre management suite
|
||||
- A tool that hides unsafe automation from operators
|
||||
- A promotional cryptocurrency project
|
||||
|
||||
MinerOps may integrate with manufacturer interfaces, accounting systems,
|
||||
monitoring tools, or other external services when those integrations improve
|
||||
mining operations. Integration does not change the product's core purpose: a
|
||||
self-hosted operational system of record for mining assets and workflows.
|
||||
|
||||
## Measures of Success
|
||||
|
||||
MinerOps is succeeding when:
|
||||
|
||||
- Operators can identify and locate any recorded miner quickly.
|
||||
- A miner's history remains understandable after IP, MAC, control board, or
|
||||
location changes.
|
||||
- Technicians can see prior failures and repairs before starting work.
|
||||
- Managers can understand fleet state without reconciling several spreadsheets.
|
||||
- Bulk actions are controlled, visible, and auditable.
|
||||
- Operational data can be backed up, restored, and migrated.
|
||||
- The system remains useful for both small and larger installations.
|
||||
- New features strengthen a coherent operational record instead of creating
|
||||
another disconnected tool.
|
||||
|
||||
These are practical outcomes rather than numerical targets. Measures should be
|
||||
refined using real operator workflows and evidence as the product develops.
|
||||
|
||||
## Product Decision Test
|
||||
|
||||
A proposed feature should normally answer yes to one or more of these questions:
|
||||
|
||||
- Does it help identify a miner or related asset?
|
||||
- Does it help locate equipment?
|
||||
- Does it improve understanding of current or historical condition?
|
||||
- Does it support repair, maintenance, or return-to-service work?
|
||||
- Does it improve safe monitoring or control?
|
||||
- Does it preserve meaningful operational history?
|
||||
- Does it reduce fragmented or duplicated records?
|
||||
- Does it remain consistent with self-hosting and operator control?
|
||||
|
||||
A technically interesting feature is not automatically a MinerOps feature. It
|
||||
should improve cryptocurrency mining operations and fit the product's
|
||||
source-of-truth role.
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# MinerOps Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap communicates the intended direction of MinerOps from its current
|
||||
early-stage foundation toward a production-ready, self-hosted mining operations
|
||||
platform. It describes broad development phases, their priorities, and why work
|
||||
is ordered that way.
|
||||
|
||||
The roadmap is not detailed project scheduling. It does not assign dates,
|
||||
promise delivery order within a phase, or replace the work needed to define and
|
||||
validate individual features. Priorities may change as operational experience
|
||||
and operator feedback reveal better sequencing.
|
||||
|
||||
The architectural rules in `ARCHITECTURE.md`, product principles in
|
||||
`PRODUCT.md`, and recorded decisions in `DECISIONS.md` take precedence over
|
||||
roadmap speed. A milestone should move more slowly rather than weaken durable
|
||||
identity, history, safety, testability, or domain ownership.
|
||||
|
||||
## Current Status
|
||||
|
||||
MinerOps currently has an early-stage backend and operational application,
|
||||
containerized development services, PostgreSQL and Redis infrastructure, and a
|
||||
versioned Alembic schema. Authentication, login gating, and role foundations are
|
||||
implemented.
|
||||
|
||||
The Inventory domain has a database schema, Pydantic models, domain exceptions,
|
||||
a service, a PostgreSQL repository, and focused automated tests. It is not yet
|
||||
exposed through a new domain HTTP API. Existing application code also contains
|
||||
working early-stage workflows for discovery, repairs, locations, telemetry, and
|
||||
control, but those areas are not complete product domains and remain in the
|
||||
integrated application module.
|
||||
|
||||
The repository now includes established branding and primary guides for product,
|
||||
architecture, and long-lived decisions. The project remains in the Foundation
|
||||
phase.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
Roadmap ordering follows these principles:
|
||||
|
||||
- **Stable foundations before features:** Identity, schema, access control, and
|
||||
test boundaries must support later workflows rather than be repaired after
|
||||
every expansion.
|
||||
- **Durable operational records before automation:** MinerOps must know which
|
||||
asset, location, condition, and history an action affects before automating
|
||||
that action.
|
||||
- **Safety before convenience:** Controls and broad actions require permissions,
|
||||
clear scope, safeguards, and auditability.
|
||||
- **Incremental delivery:** Working behavior is preserved while features move
|
||||
behind explicit domain boundaries in reviewable steps.
|
||||
- **Maintainability over rapid expansion:** New capability should strengthen the
|
||||
shared operational record instead of creating another isolated workflow.
|
||||
|
||||
These principles describe how phases are evaluated. They do not require every
|
||||
capability in one phase to be exhausted before useful work in another phase can
|
||||
begin.
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
**Status: Current phase.**
|
||||
|
||||
Foundation establishes the application, authentication and role model, database
|
||||
migration authority, initial Inventory domain, repeatable development stack,
|
||||
focused tests, branding, and primary project documentation.
|
||||
|
||||
The purpose of this phase is to create stable ownership and validation before
|
||||
adding more product surface. Inventory is the first reference domain because
|
||||
asset records are needed by identity, repair, monitoring, and control workflows.
|
||||
Phase 1 is complete only when the foundations are usable as dependencies for
|
||||
subsequent domain work, not merely present as isolated code.
|
||||
|
||||
### Phase 2: Inventory & Identity
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase completes the core asset record and connects it to discovery and
|
||||
network observations. It develops practical asset workflows around stable
|
||||
internal identity, operator-facing Asset IDs, lifecycle state, tags, notes, and
|
||||
location relationships.
|
||||
|
||||
Discovery should resolve observations to durable assets without defining an
|
||||
asset by its current IP or MAC address. Network identity history and deliberate
|
||||
handling of address or control board changes preserve continuity. Inventory
|
||||
becomes the dependable source that later operational domains can reference.
|
||||
|
||||
This phase precedes deeper repair and control work because those workflows must
|
||||
act on the correct asset and retain context through component, network, and
|
||||
location changes.
|
||||
|
||||
### Phase 3: Operations
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase develops day-to-day technician and operations workflows around the
|
||||
durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
||||
movement, testing, and return-to-service history become coherent operational
|
||||
records rather than disconnected actions.
|
||||
|
||||
Technician experience is a priority. Responsive and QR-assisted workflows may
|
||||
reduce lookup and data-entry effort where they reinforce the same asset record.
|
||||
Existing repair and location behavior should move incrementally into clear
|
||||
domain ownership while preserving working use cases.
|
||||
|
||||
Operations follows Inventory and Identity so every repair, part, and movement
|
||||
has stable ownership and useful history.
|
||||
|
||||
### Phase 4: Monitoring
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase connects telemetry, alerts, fleet health, and historical trends to
|
||||
the operational record. Monitoring should explain current and historical
|
||||
condition without replacing lifecycle, repair, location, or maintenance state.
|
||||
|
||||
Alerts become more useful when they can account for asset identity, known
|
||||
maintenance, intended downtime, location, and prior behavior. Historical trends
|
||||
should help operators recognize persistent or recurring problems rather than
|
||||
serve as an isolated stream of measurements.
|
||||
|
||||
Monitoring follows core operational records so observations carry enough
|
||||
context to support decisions and avoid treating expected conditions as unknown
|
||||
failures.
|
||||
|
||||
### Phase 5: Fleet Control
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase develops controlled actions for individual miners and groups, then
|
||||
extends them toward scheduling and automation. Permissions, confirmation,
|
||||
scope, staggering, expiry, maintenance awareness, and audit history are product
|
||||
requirements rather than optional polish.
|
||||
|
||||
Bulk operations should remain visible before, during, and after execution.
|
||||
Automation must preserve explicit human control and distinguish planned actions
|
||||
from unexpected device behavior.
|
||||
|
||||
Fleet control follows identity, operations, and monitoring because safe action
|
||||
requires confidence in the target, awareness of maintenance state, and feedback
|
||||
about the result.
|
||||
|
||||
### Phase 6: Infrastructure
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase deepens the model of sites, rooms, racks, positions, electrical
|
||||
relationships, power, and capacity. It connects miners and operational state to
|
||||
the physical constraints under which a mining facility runs.
|
||||
|
||||
Earlier phases may use basic location relationships. Phase 6 develops the wider
|
||||
infrastructure record needed for capacity planning, movement decisions, and
|
||||
understanding how equipment relates to power and space.
|
||||
|
||||
Infrastructure depth should remain progressively adoptable. A small installation
|
||||
should not need to model every electrical relationship before basic Inventory
|
||||
or Operations workflows are useful.
|
||||
|
||||
### Phase 7: Production Readiness
|
||||
|
||||
**Status: Planned direction.**
|
||||
|
||||
This phase prepares MinerOps for sustained operation and supported upgrades. It
|
||||
focuses on performance, broader automated testing, API consistency, complete
|
||||
operator and deployment documentation, reliable upgrade paths, backup and
|
||||
restore procedures, deployment hardening, observability, and long-term support
|
||||
practices.
|
||||
|
||||
Production readiness applies across all product areas. It includes verifying
|
||||
failure behavior, recovery, data migration, and operational maintenance rather
|
||||
than only completing feature surfaces.
|
||||
|
||||
This phase is placed after the principal product domains so readiness work can
|
||||
be measured against realistic workflows. Reliability, upgrade safety, and
|
||||
observability remain ongoing concerns in every earlier phase rather than being
|
||||
deferred entirely to the end.
|
||||
|
||||
## Future Opportunities
|
||||
|
||||
Possible areas for later exploration include additional external integrations,
|
||||
manufacturer APIs, richer reporting, operational analytics, mobile workflow
|
||||
improvements, and a plugin architecture if multiple real integrations justify a
|
||||
stable extension boundary.
|
||||
|
||||
These are opportunities, not commitments. They should be evaluated against the
|
||||
product decision test, self-hosted ownership, safety, maintenance cost, and the
|
||||
need to preserve one coherent operational record.
|
||||
|
||||
## How Priorities Change
|
||||
|
||||
Priorities may change in response to operator feedback, experience from real
|
||||
mining operations, reliability findings, safety concerns, maintenance cost, or
|
||||
new operational requirements. A newly discovered data-integrity or safety need
|
||||
may appropriately move ahead of a visible feature.
|
||||
|
||||
Changes to priority should be explained using the product principles and ADRs.
|
||||
Reordering is appropriate when it improves the product without weakening stable
|
||||
identity, history, operator control, or architectural boundaries. A roadmap
|
||||
change does not silently reverse a long-lived decision; such a change requires a
|
||||
new documented decision.
|
||||
|
||||
## Completed Milestones
|
||||
|
||||
Major completed foundations include:
|
||||
|
||||
- Repository and containerized development foundation
|
||||
- PostgreSQL, Redis, and Alembic migration foundation
|
||||
- Authentication, login gating, and role foundation
|
||||
- Manual Inventory schema and backend domain foundation
|
||||
- Focused Inventory and migration test foundations
|
||||
- MinerOps branding package and project README
|
||||
- Architecture guide
|
||||
- Product guide
|
||||
- Architecture and Product Decision Records
|
||||
|
||||
Completion here means the named foundation exists in the repository. It does not
|
||||
mean every product workflow associated with that area is complete.
|
||||
|
||||
## Reading This Roadmap
|
||||
|
||||
The roadmap communicates intended direction rather than guaranteed delivery
|
||||
order. Features may move between phases when doing so improves the overall
|
||||
product without weakening the architectural, product, or safety principles
|
||||
established elsewhere.
|
||||
|
||||
Use phases to understand dependency and priority, not as fixed release promises.
|
||||
Evaluate actual work against current operational needs and the durable decisions
|
||||
recorded in the project documentation.
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_ASSET_HISTORY_TEST_PROJECT:-minerops-asset-history-$$}"
|
||||
empty_database="minerops_asset_history_empty"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
echo
|
||||
echo "Asset ID history migration test failed. Recent PostgreSQL logs:"
|
||||
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -f .env.dev ]]; then
|
||||
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_alembic() {
|
||||
"${compose[@]}" run --rm --no-deps backend alembic "$@"
|
||||
}
|
||||
|
||||
echo "Starting disposable PostgreSQL..."
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
if "${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
>/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if (( attempt == 60 )); then
|
||||
echo "PostgreSQL did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Building migration image..."
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Upgrading the current head to Asset ID history..."
|
||||
run_alembic upgrade 0008_manual_miner_inventory
|
||||
run_alembic upgrade head
|
||||
|
||||
current_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]]
|
||||
|
||||
echo "Checking history schema, constraints, foreign keys, and indexes..."
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
<<'SQL'
|
||||
DO $$
|
||||
DECLARE
|
||||
v_role_id UUID;
|
||||
BEGIN
|
||||
IF to_regclass('public.miner_asset_id_history') IS NULL THEN
|
||||
RAISE EXCEPTION 'Asset ID history table was not created';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'miner_asset_id_history'
|
||||
AND column_name = 'old_asset_id'
|
||||
AND is_nullable = 'YES'
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'miner_asset_id_history'
|
||||
AND column_name IN ('miner_id', 'new_asset_id', 'changed_at', 'changed_by')
|
||||
AND is_nullable <> 'NO'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Asset ID history nullability is incorrect';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'miner_asset_id_history'
|
||||
AND indexname = 'idx_miner_asset_id_history_miner_changed_at'
|
||||
AND indexdef LIKE '%(miner_id, changed_at, id)%'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Chronological miner history index is missing';
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_role_id FROM roles WHERE name = 'Admin';
|
||||
INSERT INTO users (
|
||||
id, username, display_name, password_hash, role_id
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000901',
|
||||
'asset-history-test',
|
||||
'Asset History Test',
|
||||
'not-used',
|
||||
v_role_id
|
||||
);
|
||||
INSERT INTO miners (id, asset_tag, status)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000902',
|
||||
'ASSET-OLD',
|
||||
'manual'
|
||||
);
|
||||
INSERT INTO miner_asset_id_history (
|
||||
miner_id, old_asset_id, new_asset_id, changed_at, changed_by
|
||||
) VALUES
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000902',
|
||||
'ASSET-OLD',
|
||||
'ASSET-NEW',
|
||||
'2026-01-01 00:00:00+00',
|
||||
'00000000-0000-0000-0000-000000000901'
|
||||
),
|
||||
(
|
||||
'00000000-0000-0000-0000-000000000902',
|
||||
'ASSET-NEW',
|
||||
'ASSET-LATEST',
|
||||
'2026-01-02 00:00:00+00',
|
||||
'00000000-0000-0000-0000-000000000901'
|
||||
);
|
||||
|
||||
BEGIN
|
||||
INSERT INTO miner_asset_id_history (
|
||||
miner_id, old_asset_id, new_asset_id, changed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000902',
|
||||
' ASSET-OLD ',
|
||||
'ASSET-BAD',
|
||||
'00000000-0000-0000-0000-000000000901'
|
||||
);
|
||||
RAISE EXCEPTION 'Unnormalized old Asset ID was accepted';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO miner_asset_id_history (
|
||||
miner_id, old_asset_id, new_asset_id, changed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000999',
|
||||
NULL,
|
||||
'ASSET-NEW',
|
||||
'00000000-0000-0000-0000-000000000901'
|
||||
);
|
||||
RAISE EXCEPTION 'Unknown miner history was accepted';
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO miner_asset_id_history (
|
||||
miner_id, old_asset_id, new_asset_id, changed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000902',
|
||||
'ASSET-OLD',
|
||||
'ASSET-NEW',
|
||||
NULL
|
||||
);
|
||||
RAISE EXCEPTION 'History without an actor was accepted';
|
||||
EXCEPTION WHEN not_null_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
DELETE FROM miners
|
||||
WHERE id = '00000000-0000-0000-0000-000000000902';
|
||||
RAISE EXCEPTION 'Miner with permanent Asset ID history was deleted';
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
NULL;
|
||||
END;
|
||||
END
|
||||
$$;
|
||||
SQL
|
||||
|
||||
echo "Downgrading and re-upgrading Asset ID history..."
|
||||
run_alembic downgrade 0008_manual_miner_inventory
|
||||
table_after_downgrade="$(
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL'
|
||||
SELECT to_regclass('public.miner_asset_id_history');
|
||||
SQL
|
||||
)"
|
||||
if [[ -n "${table_after_downgrade}" ]]; then
|
||||
echo "Asset ID history table remained after downgrade." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_alembic upgrade head
|
||||
|
||||
echo "Migrating an empty database from base through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
|
||||
-c "CREATE DATABASE minerops_asset_history_empty"' >/dev/null
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-e "POSTGRES_DB=${empty_database}" \
|
||||
backend alembic upgrade head
|
||||
|
||||
empty_revision="$(
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-e "POSTGRES_DB=${empty_database}" \
|
||||
backend alembic current 2>/dev/null
|
||||
)"
|
||||
[[ "${empty_revision}" == *"0009_inventory_asset_id_history"* ]]
|
||||
|
||||
echo "Asset ID history migration test passed."
|
||||
@@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0008_manual_miner_inventory"* ]]
|
||||
[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
@@ -6,6 +6,7 @@ WITH expected(table_name) AS (
|
||||
('miners'),
|
||||
('tags'),
|
||||
('miner_tags'),
|
||||
('miner_asset_id_history'),
|
||||
('miner_status_snapshots'),
|
||||
('miner_status_hourly_rollups'),
|
||||
('miner_status_daily_rollups'),
|
||||
|
||||
Reference in new issue
Block a user