8 Commits
Author SHA1 Message Date
lussierj d738b8e27e feat: add asset ID history API 2026-07-28 19:48:57 +00:00
lussierj a87ab2709f feat: add inventory metadata updates 2026-07-28 19:30:51 +00:00
lussierj 51aec1b404 feat: add read-only inventory API 2026-07-28 18:31:57 +00:00
lussierj 979885bbdd docs: add project roadmap 2026-07-28 17:57:43 +00:00
lussierj 93e034ea07 docs: add architecture decision records 2026-07-28 17:51:41 +00:00
lussierj 7280f1dfea docs: add product guide 2026-07-28 17:22:03 +00:00
lussierj fbf0f81df1 docs: add architecture guide 2026-07-28 17:09:09 +00:00
lussierj 1cae60ab46 Add MinerOps branding and project README 2026-07-28 16:49:48 +00:00
26 changed files with 3412 additions and 100 deletions

No files matched your search

+160 -84
View File
@@ -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")
+2
View File
@@ -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",
+8
View File
@@ -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())
+43 -1
View File
@@ -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
+76 -2
View File
@@ -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:
+160
View File
@@ -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)
+99 -4
View File
@@ -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"]
+2
View File
@@ -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)
+1
View File
@@ -11,3 +11,4 @@ email-validator==2.3.0
python-multipart==0.0.20
asyncpg
pycryptodome==3.23.0
httpx==0.28.1
+340 -8
View File
@@ -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()
+604
View File
@@ -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()
+111
View File
@@ -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.
+40
View File
@@ -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.
+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+9
View File
@@ -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

+457
View File
@@ -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.
+372
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+219
View File
@@ -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."
+1 -1
View File
@@ -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 \
+1
View File
@@ -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'),