Compare commits
10 Commits
b16a60dc7a
...
fd70a21328
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd70a21328 | ||
|
|
e1d81b40ac | ||
|
|
231154d28b | ||
|
|
e22163c55f | ||
|
|
b1ac1cce24 | ||
|
|
0c1ffe0e39 | ||
|
|
023e45c908 | ||
|
|
a9e3c79cca | ||
|
|
4b0cc056bd | ||
|
|
2cc139bc0e |
87
.github/workflows/release.yml
vendored
Normal file
87
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_PREFIX: ghcr.io/staack/the-other-dude
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Build and push each image sequentially to avoid OOM on the runner.
|
||||
# Each multi-stage build (Go, Python/pip, Node/tsc) peaks at 1-2 GB.
|
||||
|
||||
- name: Build & push API
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: infrastructure/docker/Dockerfile.api
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/api:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_PREFIX }}/api:latest
|
||||
cache-from: type=gha,scope=api
|
||||
cache-to: type=gha,mode=max,scope=api
|
||||
|
||||
- name: Build & push Poller
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./poller
|
||||
file: poller/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/poller:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_PREFIX }}/poller:latest
|
||||
cache-from: type=gha,scope=poller
|
||||
cache-to: type=gha,mode=max,scope=poller
|
||||
|
||||
- name: Build & push Frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: infrastructure/docker/Dockerfile.frontend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/frontend:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_PREFIX }}/frontend:latest
|
||||
cache-from: type=gha,scope=frontend
|
||||
cache-to: type=gha,mode=max,scope=frontend
|
||||
|
||||
- name: Build & push WinBox Worker
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./winbox-worker
|
||||
file: winbox-worker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/winbox-worker:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_PREFIX }}/winbox-worker:latest
|
||||
cache-from: type=gha,scope=winbox-worker
|
||||
cache-to: type=gha,mode=max,scope=winbox-worker
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,6 +42,9 @@ Thumbs.db
|
||||
# Helm local overrides (contain dev credentials)
|
||||
infrastructure/helm/values-local.yaml
|
||||
|
||||
# Incident reports (internal)
|
||||
incidents/
|
||||
|
||||
# Local-only planning and design docs
|
||||
.planning/
|
||||
.superpowers/
|
||||
|
||||
@@ -41,12 +41,8 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE credential_profiles ENABLE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE credential_profiles FORCE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE credential_profiles ENABLE ROW LEVEL SECURITY"))
|
||||
conn.execute(sa.text("ALTER TABLE credential_profiles FORCE ROW LEVEL SECURITY"))
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
@@ -63,20 +59,13 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text("GRANT SELECT ON credential_profiles TO poller_user")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON credential_profiles TO app_user")
|
||||
)
|
||||
conn.execute(sa.text("GRANT SELECT ON credential_profiles TO poller_user"))
|
||||
conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON credential_profiles TO app_user"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DROP POLICY IF EXISTS credential_profiles_tenant_isolation"
|
||||
" ON credential_profiles"
|
||||
)
|
||||
sa.text("DROP POLICY IF EXISTS credential_profiles_tenant_isolation ON credential_profiles")
|
||||
)
|
||||
op.drop_table("credential_profiles")
|
||||
|
||||
@@ -630,12 +630,8 @@ def upgrade() -> None:
|
||||
)
|
||||
|
||||
# -- RLS: system profiles visible to all tenants -----------------------
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE snmp_profiles ENABLE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE snmp_profiles FORCE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE snmp_profiles ENABLE ROW LEVEL SECURITY"))
|
||||
conn.execute(sa.text("ALTER TABLE snmp_profiles FORCE ROW LEVEL SECURITY"))
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
CREATE POLICY snmp_profiles_tenant_isolation
|
||||
@@ -648,12 +644,8 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text("GRANT SELECT ON snmp_profiles TO poller_user")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON snmp_profiles TO app_user")
|
||||
)
|
||||
conn.execute(sa.text("GRANT SELECT ON snmp_profiles TO poller_user"))
|
||||
conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON snmp_profiles TO app_user"))
|
||||
|
||||
# -- Seed 6 system profiles --------------------------------------------
|
||||
for profile in SEED_PROFILES:
|
||||
@@ -679,10 +671,5 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DROP POLICY IF EXISTS snmp_profiles_tenant_isolation"
|
||||
" ON snmp_profiles"
|
||||
)
|
||||
)
|
||||
conn.execute(sa.text("DROP POLICY IF EXISTS snmp_profiles_tenant_isolation ON snmp_profiles"))
|
||||
op.drop_table("snmp_profiles")
|
||||
|
||||
@@ -29,25 +29,12 @@ def upgrade() -> None:
|
||||
conn.execute(sa.text("SET lock_timeout = '3s'"))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE devices"
|
||||
" ADD COLUMN device_type TEXT NOT NULL DEFAULT 'routeros'"
|
||||
)
|
||||
sa.text("ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'routeros'")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE devices"
|
||||
" ADD COLUMN snmp_port INTEGER DEFAULT 161"
|
||||
)
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE devices ADD COLUMN snmp_port INTEGER DEFAULT 161"))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE devices"
|
||||
" ADD COLUMN snmp_version TEXT"
|
||||
)
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE devices ADD COLUMN snmp_version TEXT"))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
@@ -69,18 +56,8 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS credential_profile_id")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_profile_id")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_version")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_port")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS device_type")
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS credential_profile_id"))
|
||||
conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_profile_id"))
|
||||
conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_version"))
|
||||
conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_port"))
|
||||
conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS device_type"))
|
||||
|
||||
@@ -44,11 +44,7 @@ def upgrade() -> None:
|
||||
|
||||
conn.execute(sa.text("SELECT create_hypertable('snmp_metrics', 'time')"))
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT add_retention_policy('snmp_metrics', INTERVAL '90 days')"
|
||||
)
|
||||
)
|
||||
conn.execute(sa.text("SELECT add_retention_policy('snmp_metrics', INTERVAL '90 days')"))
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
@@ -57,12 +53,8 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE snmp_metrics ENABLE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(
|
||||
sa.text("ALTER TABLE snmp_metrics FORCE ROW LEVEL SECURITY")
|
||||
)
|
||||
conn.execute(sa.text("ALTER TABLE snmp_metrics ENABLE ROW LEVEL SECURITY"))
|
||||
conn.execute(sa.text("ALTER TABLE snmp_metrics FORCE ROW LEVEL SECURITY"))
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
@@ -75,9 +67,7 @@ def upgrade() -> None:
|
||||
""")
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text("GRANT SELECT, INSERT ON snmp_metrics TO app_user")
|
||||
)
|
||||
conn.execute(sa.text("GRANT SELECT, INSERT ON snmp_metrics TO app_user"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -144,7 +144,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# App settings
|
||||
APP_NAME: str = "TOD - The Other Dude"
|
||||
APP_VERSION: str = "9.8.0"
|
||||
APP_VERSION: str = "9.8.2"
|
||||
DEBUG: bool = False
|
||||
|
||||
@field_validator("CREDENTIAL_ENCRYPTION_KEY")
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, Rou
|
||||
from app.models.device_interface import DeviceInterface
|
||||
from app.models.wireless_link import WirelessLink, LinkState
|
||||
from app.models.site_alert import SiteAlertRule, SiteAlertEvent
|
||||
from app.models.credential_profile import CredentialProfile
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
@@ -55,4 +56,5 @@ __all__ = [
|
||||
"LinkState",
|
||||
"SiteAlertRule",
|
||||
"SiteAlertEvent",
|
||||
"CredentialProfile",
|
||||
]
|
||||
|
||||
@@ -113,7 +113,10 @@ async def update_profile(
|
||||
"""Update a credential profile. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
return await credential_profile_service.update_profile(
|
||||
db=db, tenant_id=tenant_id, profile_id=profile_id, data=data,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
data=data,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
@@ -136,7 +139,9 @@ async def delete_profile(
|
||||
"""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
await credential_profile_service.delete_profile(
|
||||
db=db, tenant_id=tenant_id, profile_id=profile_id,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above
|
||||
from app.middleware.rbac import (
|
||||
require_operator_or_above,
|
||||
require_scope,
|
||||
require_tenant_admin_or_above,
|
||||
)
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
from app.routers.devices import _check_tenant_access
|
||||
from app.schemas.snmp_profile import (
|
||||
@@ -252,7 +256,7 @@ async def update_profile(
|
||||
|
||||
sql = f"""
|
||||
UPDATE snmp_profiles
|
||||
SET {', '.join(set_clauses)}
|
||||
SET {", ".join(set_clauses)}
|
||||
WHERE id = :profile_id AND tenant_id = :tenant_id
|
||||
RETURNING id, tenant_id, name, description, sys_object_id, vendor,
|
||||
category, is_system, created_at, updated_at
|
||||
|
||||
@@ -46,9 +46,7 @@ class CredentialProfileCreate(BaseModel):
|
||||
@classmethod
|
||||
def validate_credential_type(cls, v: str) -> str:
|
||||
if v not in VALID_CREDENTIAL_TYPES:
|
||||
raise ValueError(
|
||||
f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}"
|
||||
)
|
||||
raise ValueError(f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -141,9 +139,7 @@ class CredentialProfileUpdate(BaseModel):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_CREDENTIAL_TYPES:
|
||||
raise ValueError(
|
||||
f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}"
|
||||
)
|
||||
raise ValueError(f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -151,9 +147,14 @@ class CredentialProfileUpdate(BaseModel):
|
||||
"""Validate credential fields only when credential_type or credential fields change."""
|
||||
# Collect which credential fields were provided
|
||||
cred_fields = {
|
||||
"username", "password", "community",
|
||||
"security_level", "auth_protocol", "auth_passphrase",
|
||||
"priv_protocol", "priv_passphrase",
|
||||
"username",
|
||||
"password",
|
||||
"community",
|
||||
"security_level",
|
||||
"auth_protocol",
|
||||
"auth_passphrase",
|
||||
"priv_protocol",
|
||||
"priv_passphrase",
|
||||
}
|
||||
has_cred_changes = any(getattr(self, f) is not None for f in cred_fields)
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ def _build_credential_json(data: CredentialProfileCreate | CredentialProfileUpda
|
||||
raise ValueError(f"Unknown credential_type: {ct}")
|
||||
|
||||
|
||||
def _profile_response(profile: CredentialProfile, device_count: int = 0) -> CredentialProfileResponse:
|
||||
def _profile_response(
|
||||
profile: CredentialProfile, device_count: int = 0
|
||||
) -> CredentialProfileResponse:
|
||||
"""Build a CredentialProfileResponse from an ORM instance."""
|
||||
return CredentialProfileResponse(
|
||||
id=profile.id,
|
||||
@@ -116,9 +118,11 @@ async def get_profiles(
|
||||
credential_type: str | None = None,
|
||||
) -> CredentialProfileListResponse:
|
||||
"""List all credential profiles for a tenant."""
|
||||
query = select(CredentialProfile).where(
|
||||
CredentialProfile.tenant_id == tenant_id
|
||||
).order_by(CredentialProfile.name)
|
||||
query = (
|
||||
select(CredentialProfile)
|
||||
.where(CredentialProfile.tenant_id == tenant_id)
|
||||
.order_by(CredentialProfile.name)
|
||||
)
|
||||
|
||||
if credential_type:
|
||||
query = query.where(CredentialProfile.credential_type == credential_type)
|
||||
@@ -141,10 +145,7 @@ async def get_profiles(
|
||||
for row in count_result:
|
||||
device_counts[row.credential_profile_id] = row.cnt
|
||||
|
||||
responses = [
|
||||
_profile_response(p, device_count=device_counts.get(p.id, 0))
|
||||
for p in profiles
|
||||
]
|
||||
responses = [_profile_response(p, device_count=device_counts.get(p.id, 0)) for p in profiles]
|
||||
return CredentialProfileListResponse(profiles=responses)
|
||||
|
||||
|
||||
@@ -211,9 +212,14 @@ async def update_profile(
|
||||
|
||||
# Determine if credential re-encryption is needed
|
||||
cred_fields = {
|
||||
"username", "password", "community",
|
||||
"security_level", "auth_protocol", "auth_passphrase",
|
||||
"priv_protocol", "priv_passphrase",
|
||||
"username",
|
||||
"password",
|
||||
"community",
|
||||
"security_level",
|
||||
"auth_protocol",
|
||||
"auth_passphrase",
|
||||
"priv_protocol",
|
||||
"priv_passphrase",
|
||||
}
|
||||
has_cred_changes = any(getattr(data, f) is not None for f in cred_fields)
|
||||
type_changed = data.credential_type is not None
|
||||
@@ -241,13 +247,18 @@ async def update_profile(
|
||||
action="credential_profile.update",
|
||||
resource_type="credential_profile",
|
||||
resource_id=str(profile.id),
|
||||
details={"name": profile.name, "updated_fields": list(data.model_dump(exclude_unset=True).keys())},
|
||||
details={
|
||||
"name": profile.name,
|
||||
"updated_fields": list(data.model_dump(exclude_unset=True).keys()),
|
||||
},
|
||||
)
|
||||
|
||||
return _profile_response(profile, device_count=dc)
|
||||
|
||||
|
||||
def _merge_update(data: CredentialProfileUpdate, profile: CredentialProfile) -> CredentialProfileUpdate:
|
||||
def _merge_update(
|
||||
data: CredentialProfileUpdate, profile: CredentialProfile
|
||||
) -> CredentialProfileUpdate:
|
||||
"""For partial credential updates, overlay data onto existing profile type.
|
||||
|
||||
When credential_type is not changing but individual credential fields are,
|
||||
|
||||
@@ -63,6 +63,7 @@ async def ensure_sse_streams() -> None:
|
||||
name="ALERT_EVENTS",
|
||||
subjects=["alert.fired.>", "alert.resolved.>"],
|
||||
max_age=3600, # 1 hour retention
|
||||
max_bytes=16 * 1024 * 1024, # 16MB cap
|
||||
)
|
||||
)
|
||||
logger.info("nats.stream.ensured", stream="ALERT_EVENTS")
|
||||
@@ -72,6 +73,7 @@ async def ensure_sse_streams() -> None:
|
||||
name="OPERATION_EVENTS",
|
||||
subjects=["firmware.progress.>"],
|
||||
max_age=3600, # 1 hour retention
|
||||
max_bytes=16 * 1024 * 1024, # 16MB cap
|
||||
)
|
||||
)
|
||||
logger.info("nats.stream.ensured", stream="OPERATION_EVENTS")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "the-other-dude-backend"
|
||||
version = "9.8.0"
|
||||
version = "9.8.2"
|
||||
description = "MikroTik Fleet Management Portal - Backend API"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
28
docker-compose.build.yml
Normal file
28
docker-compose.build.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# docker-compose.build.yml -- Build-from-source override
|
||||
#
|
||||
# Adds build contexts so Docker Compose builds images locally instead of
|
||||
# pulling pre-built images from GHCR.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml \
|
||||
# -f docker-compose.build.yml --env-file .env.prod up -d --build
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.api
|
||||
|
||||
poller:
|
||||
build:
|
||||
context: ./poller
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.frontend
|
||||
|
||||
winbox-worker:
|
||||
build:
|
||||
context: ./winbox-worker
|
||||
@@ -1,5 +1,10 @@
|
||||
# docker-compose.prod.yml -- Production environment override
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d
|
||||
#
|
||||
# Pre-built images (recommended):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d
|
||||
#
|
||||
# Build from source:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.build.yml --env-file .env.prod up -d
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -13,9 +18,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.api
|
||||
image: ghcr.io/staack/the-other-dude/api:${TOD_VERSION:-latest}
|
||||
container_name: tod_api
|
||||
env_file: .env.prod
|
||||
environment:
|
||||
@@ -67,9 +70,7 @@ services:
|
||||
- tod_remote_worker
|
||||
|
||||
poller:
|
||||
build:
|
||||
context: ./poller
|
||||
dockerfile: ./Dockerfile
|
||||
image: ghcr.io/staack/the-other-dude/poller:${TOD_VERSION:-latest}
|
||||
container_name: tod_poller
|
||||
env_file: .env.prod
|
||||
cap_add:
|
||||
@@ -135,6 +136,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
winbox-worker:
|
||||
image: ghcr.io/staack/the-other-dude/winbox-worker:${TOD_VERSION:-latest}
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
MAX_CONCURRENT_SESSIONS: 10
|
||||
@@ -146,9 +148,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.frontend
|
||||
image: ghcr.io/staack/the-other-dude/frontend:${TOD_VERSION:-latest}
|
||||
container_name: tod_frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
|
||||
@@ -70,10 +70,11 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
memory: 384M
|
||||
networks:
|
||||
- tod
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ TOD uses Pydantic Settings for configuration. All values can be set via environm
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `APP_NAME` | `TOD - The Other Dude` | Application display name |
|
||||
| `APP_VERSION` | `9.7.2` | Semantic version string (see VERSION file at project root) |
|
||||
| `APP_VERSION` | `9.8.2` | Semantic version string (see VERSION file at project root) |
|
||||
| `TOD_VERSION` | `latest` | Docker image tag for pre-built images (set by setup.py) |
|
||||
| `ENVIRONMENT` | `dev` | Runtime environment: `dev`, `staging`, or `production` |
|
||||
| `DEBUG` | `false` | Enable debug mode |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://localhost:8080` | Comma-separated list of allowed CORS origins |
|
||||
|
||||
@@ -138,6 +138,13 @@
|
||||
<p class="blog-subtitle">Updates, insights, and the occasional rant about MikroTik fleet management.</p>
|
||||
|
||||
<ul class="blog-list">
|
||||
<li>
|
||||
<a href="snmp-works.html">
|
||||
<div class="blog-list-date">March 22, 2026</div>
|
||||
<div class="blog-list-title">SNMP Works (And the UI Got Out of the Way)</div>
|
||||
<div class="blog-list-excerpt">v9.8 adds SNMP device monitoring. It polls real devices, collects real data, and shows it alongside your MikroTik fleet. The UI also got simpler.</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="500-devices-broke-the-api.html">
|
||||
<div class="blog-list-date">March 21, 2026</div>
|
||||
|
||||
210
docs/website/blog/snmp-works.html
Normal file
210
docs/website/blog/snmp-works.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SNMP Works (And the UI Got Out of the Way) — The Other Dude Blog</title>
|
||||
<meta name="description" content="v9.8 adds SNMP device monitoring. It polls real devices, collects real data, and shows it alongside your MikroTik fleet. The UI also got simpler.">
|
||||
<meta name="keywords" content="MikroTik, SNMP monitoring, fleet management, network management, The Other Dude, SNMP poller">
|
||||
<meta name="author" content="The Other Dude">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="theme-color" content="#eae7de">
|
||||
<link rel="canonical" href="https://theotherdude.net/blog/snmp-works.html">
|
||||
<link rel="icon" href="../data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect x='2' y='2' width='60' height='60' rx='8' fill='none' stroke='%238B1A1A' stroke-width='2'/><path d='M32 18 L46 32 L32 46 L18 32 Z' fill='%238B1A1A'/><path d='M32 19 L38 32 L32 45 L26 32 Z' fill='%232A9D8F'/><circle cx='32' cy='32' r='5' fill='%238B1A1A'/><circle cx='32' cy='32' r='2.5' fill='%232A9D8F'/></svg>">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:title" content="SNMP Works (And the UI Got Out of the Way) — The Other Dude">
|
||||
<meta property="og:description" content="v9.8 adds SNMP device monitoring. It polls real devices, collects real data, and shows it alongside your MikroTik fleet. The UI also got simpler.">
|
||||
<meta property="og:url" content="https://theotherdude.net/blog/snmp-works.html">
|
||||
<meta property="og:site_name" content="The Other Dude">
|
||||
<meta property="article:published_time" content="2026-03-22">
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": "SNMP Works (And the UI Got Out of the Way)",
|
||||
"description": "v9.8 adds SNMP device monitoring. It polls real devices, collects real data, and shows it alongside your MikroTik fleet. The UI also got simpler.",
|
||||
"datePublished": "2026-03-22",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "The Other Dude"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "The Other Dude",
|
||||
"url": "https://theotherdude.net"
|
||||
},
|
||||
"mainEntityOfPage": "https://theotherdude.net/blog/snmp-works.html"
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Fonts -->
|
||||
|
||||
<link rel="stylesheet" href="../style.css?v=3">
|
||||
<style>
|
||||
/* Warm Precision overrides */
|
||||
:root {
|
||||
--background: #eae7de;
|
||||
--surface: #f6f4ec;
|
||||
--elevated: #f0ede4;
|
||||
--border: rgba(40,36,28,0.12);
|
||||
--text-primary: #1a1810;
|
||||
--text-secondary: #5e5a4e;
|
||||
--text-muted: #8a8578;
|
||||
--accent: #8a7a48;
|
||||
}
|
||||
body { background-color: #eae7de; color: #1a1810; }
|
||||
.site-nav { background: #e0dcd2 !important; border-bottom: 1px solid rgba(40,36,28,0.12); }
|
||||
.site-nav .nav-logo span, .site-nav .nav-link, .site-nav .nav-cta { color: #1a1810 !important; }
|
||||
.site-nav .nav-link:hover { color: #8a7a48 !important; }
|
||||
.dark { /* prevent dark mode */ }
|
||||
</style>
|
||||
<style>
|
||||
.blog-post {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 80px 24px 120px;
|
||||
}
|
||||
.blog-post-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.blog-post h1 {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.blog-post h2 {
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-primary);
|
||||
margin-top: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.blog-post p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.75;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.blog-post p strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.blog-post a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.blog-post a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.blog-post .back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.blog-post .back-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.blog-post h1 { font-size: 1.8rem; }
|
||||
.blog-post { padding: 60px 20px 80px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="site-nav">
|
||||
<div class="nav-inner container">
|
||||
<a href="../index.html" class="nav-logo">
|
||||
<svg class="nav-logo-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" aria-label="The Other Dude logo">
|
||||
<rect x="2" y="2" width="60" height="60" rx="8" fill="none" stroke="#8B1A1A" stroke-width="2"/>
|
||||
<rect x="6" y="6" width="52" height="52" rx="5" fill="none" stroke="#F5E6C8" stroke-width="1.5"/>
|
||||
<rect x="8" y="8" width="48" height="48" rx="4" fill="#8B1A1A" opacity="0.15"/>
|
||||
<path d="M32 8 L56 32 L32 56 L8 32 Z" fill="none" stroke="#8B1A1A" stroke-width="2"/>
|
||||
<path d="M32 13 L51 32 L32 51 L13 32 Z" fill="none" stroke="#F5E6C8" stroke-width="1.5"/>
|
||||
<path d="M32 18 L46 32 L32 46 L18 32 Z" fill="#8B1A1A"/>
|
||||
<path d="M32 19 L38 32 L32 45 L26 32 Z" fill="#2A9D8F"/>
|
||||
<path d="M19 32 L32 26 L45 32 L32 38 Z" fill="#F5E6C8"/>
|
||||
<circle cx="32" cy="32" r="5" fill="#8B1A1A"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#2A9D8F"/>
|
||||
<path d="M10 10 L16 10 L10 16 Z" fill="#2A9D8F" opacity="0.7"/>
|
||||
<path d="M54 10 L54 16 L48 10 Z" fill="#2A9D8F" opacity="0.7"/>
|
||||
<path d="M10 54 L16 54 L10 48 Z" fill="#2A9D8F" opacity="0.7"/>
|
||||
<path d="M54 54 L48 54 L54 48 Z" fill="#2A9D8F" opacity="0.7"/>
|
||||
</svg>
|
||||
<span>The Other Dude</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="../docs.html" class="nav-link">Docs</a>
|
||||
<a href="index.html" class="nav-link">Blog</a>
|
||||
<a href="https://github.com/staack/the-other-dude" class="nav-link" rel="noopener">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<article class="blog-post">
|
||||
<a href="index.html" class="back-link">← Back to Blog</a>
|
||||
<div class="blog-post-meta">March 22, 2026</div>
|
||||
<h1>SNMP Works (And the UI Got Out of the Way)</h1>
|
||||
|
||||
<p>v9.8 ships SNMP support. You can add an SNMP device to TOD and it will poll it, collect metrics, and show it in the fleet table alongside your MikroTik gear. Interface traffic, CPU, memory, uptime — the same data you see for your Tiks, from any device that speaks SNMP.</p>
|
||||
|
||||
<p>It is early. It works. I am not going to oversell it.</p>
|
||||
|
||||
<h2>What "Working" Means Right Now</h2>
|
||||
|
||||
<p>You create a credential profile — basically a community string and SNMP version — and then add a device by IP. TOD probes the device, figures out what it is from the sysObjectID, assigns a collection profile, and starts polling. Standard MIB-II data flows into the same hypertables the MikroTik poller uses. Same charts, same fleet table, same everything.</p>
|
||||
|
||||
<p>There are seven built-in profiles: generic, switch, router, access point, UPS, MikroTik-over-SNMP, and Ubiquiti. The generic profile covers most devices out of the box. If you need something vendor-specific, you can build a custom profile with your own OIDs.</p>
|
||||
|
||||
<p>SNMPv1, v2c, and v3 are all supported. Counter64 is preferred over Counter32 automatically. BulkWalk is wrapped in timeouts so a misbehaving device does not hang your poller. The basics are covered.</p>
|
||||
|
||||
<h2>Why This Matters</h2>
|
||||
|
||||
<p>Nobody runs a pure MikroTik network. There is always a managed switch, a UPS, an access point from another vendor, a piece of infrastructure that only speaks SNMP. Before v9.8, those devices were invisible to TOD. You had to run a separate monitoring system to see them, or just not monitor them at all.</p>
|
||||
|
||||
<p>Now they show up in the same fleet table. Same status indicators, same interface graphs. You do not have to context-switch between two different tools to understand what your network is doing.</p>
|
||||
|
||||
<p>That is the entire point. Not fancy SNMP features. Just visibility.</p>
|
||||
|
||||
<h2>The UI Got Simpler</h2>
|
||||
|
||||
<p>While working on SNMP, I also stripped a lot of noise out of the interface. Gradients are gone. Shimmer loading placeholders are gone. Visual effects that existed because they looked nice rather than because they communicated something useful — gone.</p>
|
||||
|
||||
<p>The reason is practical. When you are looking at real data from real devices — when a number on the screen represents actual traffic on an actual interface — decorative UI gets in the way. You want to read data, not admire the container it is sitting in.</p>
|
||||
|
||||
<p>The interface should disappear. You should be thinking about your network, not about the tool you are using to look at it. Every visual element that does not carry information is a distraction. So I removed the ones that were not carrying their weight.</p>
|
||||
|
||||
<h2>What Is Not Done</h2>
|
||||
|
||||
<p>SNMP trap reception is not implemented. The poller collects data on a schedule — it does not listen for unsolicited events from devices. That is planned but not built yet.</p>
|
||||
|
||||
<p>SNMP SET operations are not supported. This is read-only monitoring. You cannot push configuration to SNMP devices through TOD. That may never be a feature — SNMP config management is a different problem with a lot of vendor-specific complexity.</p>
|
||||
|
||||
<p>The custom profile builder works but needs more polish. Uploading vendor MIBs and browsing OID trees is functional, but the UX is not where I want it yet. The standard path — add a device, let auto-detection handle it — is solid. The power-user path needs more work.</p>
|
||||
|
||||
<p>Bulk add supports SNMP devices but subnet discovery is still basic. It works, but it is not as smooth as it should be.</p>
|
||||
|
||||
<h2>Where This Is Going</h2>
|
||||
|
||||
<p>This is the version where TOD stopped being a MikroTik-only tool and started being a network management system. The MikroTik support is still the core — that is what the deep integration is for, that is where WinBox and config push and firmware management live. SNMP is the layer that lets everything else in the building show up on the same screen.</p>
|
||||
|
||||
<p>It is not a finished product. It is a system that is getting more useful every week. If you manage MikroTik gear alongside other network hardware and you are tired of running two monitoring systems, this is worth looking at.</p>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Other Dude — MikroTik Fleet Management</title>
|
||||
<meta name="description" content="MikroTik fleet management. Self-hosted. Source-available. Monitor devices, push configuration, track changes in git.">
|
||||
<meta name="keywords" content="MikroTik, RouterOS, fleet management, network management, WinBox browser, MikroTik monitoring, MikroTik configuration, router management, self-hosted, open source, source-available">
|
||||
<meta name="description" content="Network fleet management for MikroTik and SNMP devices. Self-hosted. Source-available. Monitor MikroTik routers alongside switches, APs, and UPSes from a single pane of glass.">
|
||||
<meta name="keywords" content="MikroTik, RouterOS, SNMP monitoring, fleet management, network management, WinBox browser, MikroTik monitoring, MikroTik configuration, SNMP poller, multi-vendor NMS, router management, self-hosted, open source, source-available">
|
||||
<meta name="author" content="The Other Dude">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="google-site-verification" content="d2QVuWrLJlzOQPnA-SAJuvajEHGYbusvJ4eDdZbWSBU">
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="The Other Dude — MikroTik Fleet Management">
|
||||
<meta property="og:description" content="MikroTik fleet management. Self-hosted. Source-available.">
|
||||
<meta property="og:description" content="Network fleet management for MikroTik and SNMP devices. Self-hosted. Source-available.">
|
||||
<meta property="og:url" content="https://theotherdude.net/">
|
||||
<meta property="og:image" content="https://theotherdude.net/assets/og-image.png">
|
||||
<meta property="og:site_name" content="The Other Dude">
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="The Other Dude — MikroTik Fleet Management">
|
||||
<meta name="twitter:description" content="MikroTik fleet management. Self-hosted. Source-available.">
|
||||
<meta name="twitter:description" content="Network fleet management for MikroTik and SNMP devices. Self-hosted. Source-available.">
|
||||
<meta name="twitter:image" content="https://theotherdude.net/assets/og-image.png">
|
||||
|
||||
<!-- Structured Data -->
|
||||
@@ -36,7 +36,7 @@
|
||||
"name": "The Other Dude",
|
||||
"applicationCategory": "NetworkApplication",
|
||||
"operatingSystem": "Linux, Docker",
|
||||
"description": "MikroTik RouterOS fleet management. Self-hosted. Source-available under BSL 1.1.",
|
||||
"description": "Network fleet management for MikroTik and SNMP devices. Self-hosted. Source-available under BSL 1.1.",
|
||||
"url": "https://theotherdude.net",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
@@ -49,12 +49,13 @@
|
||||
"Track config changes in git",
|
||||
"Manage firmware versions",
|
||||
"WinBox in the browser",
|
||||
"SNMP device monitoring alongside MikroTik",
|
||||
"VPN overlay for NAT traversal",
|
||||
"Multi-tenant with row-level security",
|
||||
"Zero-knowledge authentication (SRP-6a)"
|
||||
],
|
||||
"softwareRequirements": "Docker, PostgreSQL 17, Redis, NATS",
|
||||
"softwareVersion": "9.7.2",
|
||||
"softwareVersion": "9.8.2",
|
||||
"license": "https://mariadb.com/bsl11/"
|
||||
}
|
||||
</script>
|
||||
@@ -461,7 +462,7 @@
|
||||
<div class="container">
|
||||
<header class="wp-header">
|
||||
<h1>The Other Dude</h1>
|
||||
<p class="wp-tagline">MikroTik fleet management. Self-hosted. Source-available.</p>
|
||||
<p class="wp-tagline">MikroTik fleet management. Now with SNMP support. Self-hosted. Source-available.</p>
|
||||
<div class="wp-header-links">
|
||||
<a href="https://github.com/staack/the-other-dude" rel="noopener">GitHub</a>
|
||||
<a href="docs.html">Documentation</a>
|
||||
@@ -478,6 +479,7 @@
|
||||
<li>Track config changes in git</li>
|
||||
<li>Manage firmware versions</li>
|
||||
<li>WinBox in the browser</li>
|
||||
<li>SNMP device monitoring — switches, APs, UPSes alongside your Tiks</li>
|
||||
<li>VPN overlay for NAT traversal</li>
|
||||
<li>Multi-tenant with row-level security</li>
|
||||
<li>Zero-knowledge authentication (SRP-6a)</li>
|
||||
@@ -545,7 +547,7 @@
|
||||
<section class="wp-section">
|
||||
<h2>Status</h2>
|
||||
<table class="wp-status-table">
|
||||
<tr><td>Version</td><td>9.7.2</td></tr>
|
||||
<tr><td>Version</td><td>9.8.2</td></tr>
|
||||
<tr><td>License</td><td>BSL 1.1 (converts to Apache 2.0 in 2030)</td></tr>
|
||||
<tr><td>Free tier</td><td>250 devices</td></tr>
|
||||
<tr><td>Stability</td><td>Breaking changes expected before v11</td></tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "9.8.0",
|
||||
"version": "9.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -15,14 +15,14 @@ interface SNMPMetricsSectionProps {
|
||||
* and are shown by InterfaceGauges. Custom OID charting is Phase 20 (PROF-03).
|
||||
*/
|
||||
export function SNMPMetricsSection({ tenantId, snmpProfileId }: SNMPMetricsSectionProps) {
|
||||
if (!snmpProfileId) return null
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['snmp-profile', tenantId, snmpProfileId],
|
||||
queryFn: () => snmpProfilesApi.get(tenantId, snmpProfileId!),
|
||||
enabled: !!snmpProfileId,
|
||||
enabled: !!snmpProfileId && !!tenantId,
|
||||
})
|
||||
|
||||
if (!snmpProfileId) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-border-default bg-panel px-3 py-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
||||
@@ -35,11 +35,6 @@ interface CredentialProfilesPageProps {
|
||||
type CredentialType = 'routeros' | 'snmp_v2c' | 'snmp_v3'
|
||||
type SecurityLevel = 'no_auth_no_priv' | 'auth_no_priv' | 'auth_priv'
|
||||
|
||||
const CREDENTIAL_TYPE_LABELS: Record<CredentialType, string> = {
|
||||
routeros: 'RouterOS',
|
||||
snmp_v2c: 'SNMP v2c',
|
||||
snmp_v3: 'SNMP v3',
|
||||
}
|
||||
|
||||
const SECURITY_LEVELS: { value: SecurityLevel; label: string }[] = [
|
||||
{ value: 'no_auth_no_priv', label: 'No Auth, No Privacy' },
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
X,
|
||||
Network,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
snmpProfilesApi,
|
||||
@@ -33,7 +31,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { OIDTreeBrowser } from '@/components/settings/OIDTreeBrowser'
|
||||
import { ProfileTestPanel } from '@/components/settings/ProfileTestPanel'
|
||||
|
||||
@@ -208,7 +205,6 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
||||
const [pollGroups, setPollGroups] = useState<Record<PollGroupKey, PollGroup>>(buildEmptyPollGroups)
|
||||
const [activePollGroup, setActivePollGroup] = useState<PollGroupKey>('standard')
|
||||
const [selectedOids, setSelectedOids] = useState<Set<string>>(new Set())
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
// ─── MIB state ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ export interface CredentialProfileCreate {
|
||||
priv_passphrase?: string
|
||||
}
|
||||
|
||||
export interface CredentialProfileUpdate extends Partial<CredentialProfileCreate> {}
|
||||
export type CredentialProfileUpdate = Partial<CredentialProfileCreate>
|
||||
|
||||
export const credentialProfilesApi = {
|
||||
list: (tenantId: string, credentialType?: string) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ name: tod
|
||||
description: The Other Dude — MikroTik fleet management platform
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "9.8.0"
|
||||
appVersion: "9.8.2"
|
||||
kubeVersion: ">=1.28.0-0"
|
||||
keywords:
|
||||
- mikrotik
|
||||
|
||||
@@ -163,7 +163,7 @@ func NewPublisher(natsURL string) (*Publisher, error) {
|
||||
Name: "WIRELESS_REGISTRATIONS",
|
||||
Subjects: []string{"wireless.registrations.>"},
|
||||
MaxAge: 30 * 24 * time.Hour, // 30-day retention
|
||||
MaxBytes: 256 * 1024 * 1024, // 256MB cap
|
||||
MaxBytes: 128 * 1024 * 1024, // 128MB cap
|
||||
Discard: jetstream.DiscardOld,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -68,12 +68,14 @@ func (c *SNMPCollector) Collect(ctx context.Context, dev store.Device, pub *bus.
|
||||
profileID := ""
|
||||
if dev.SNMPProfileID != nil {
|
||||
profileID = *dev.SNMPProfileID
|
||||
} else {
|
||||
} else if c.profiles != nil {
|
||||
profileID = c.profiles.GetGenericID()
|
||||
if profileID == "" {
|
||||
return fmt.Errorf("device %s: no SNMP profile assigned and no generic-snmp fallback found", dev.ID)
|
||||
}
|
||||
slog.Debug("using generic-snmp fallback profile", "device_id", dev.ID)
|
||||
} else {
|
||||
return fmt.Errorf("device %s: no SNMP profile assigned and profile cache not available", dev.ID)
|
||||
}
|
||||
profile := c.profiles.Get(profileID)
|
||||
if profile == nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestSNMPCollectorImplementsCollector(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSNMPCollectorCollect_NilProfileID verifies that Collect returns an error
|
||||
// when the device has no SNMPProfileID set.
|
||||
// when the device has no SNMPProfileID and the profile cache is nil.
|
||||
func TestSNMPCollectorCollect_NilProfileID(t *testing.T) {
|
||||
collector := NewSNMPCollector(nil, nil, nil, DefaultSNMPConfig())
|
||||
dev := store.Device{
|
||||
@@ -32,7 +32,7 @@ func TestSNMPCollectorCollect_NilProfileID(t *testing.T) {
|
||||
|
||||
err := collector.Collect(context.Background(), dev, &bus.Publisher{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no SNMP profile")
|
||||
assert.Contains(t, err.Error(), "no SNMP profile assigned")
|
||||
}
|
||||
|
||||
// TestSNMPCollectorCollect_UnknownProfileID verifies that Collect returns an error
|
||||
|
||||
580
setup.py
580
setup.py
@@ -40,10 +40,14 @@ INIT_SQL_TEMPLATE = PROJECT_ROOT / "scripts" / "init-postgres.sql"
|
||||
INIT_SQL_PROD = PROJECT_ROOT / "scripts" / "init-postgres-prod.sql"
|
||||
COMPOSE_BASE = "docker-compose.yml"
|
||||
COMPOSE_PROD = "docker-compose.prod.yml"
|
||||
COMPOSE_BUILD_OVERRIDE = "docker-compose.build.yml"
|
||||
COMPOSE_CMD = [
|
||||
"docker", "compose",
|
||||
"-f", COMPOSE_BASE,
|
||||
"-f", COMPOSE_PROD,
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
COMPOSE_BASE,
|
||||
"-f",
|
||||
COMPOSE_PROD,
|
||||
]
|
||||
|
||||
REQUIRED_PORTS = {
|
||||
@@ -58,20 +62,40 @@ REQUIRED_PORTS = {
|
||||
|
||||
# ── Color helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||||
|
||||
|
||||
_COLOR = _supports_color()
|
||||
|
||||
|
||||
def _c(code: str, text: str) -> str:
|
||||
return f"\033[{code}m{text}\033[0m" if _COLOR else text
|
||||
|
||||
def green(t: str) -> str: return _c("32", t)
|
||||
def yellow(t: str) -> str: return _c("33", t)
|
||||
def red(t: str) -> str: return _c("31", t)
|
||||
def cyan(t: str) -> str: return _c("36", t)
|
||||
def bold(t: str) -> str: return _c("1", t)
|
||||
def dim(t: str) -> str: return _c("2", t)
|
||||
|
||||
def green(t: str) -> str:
|
||||
return _c("32", t)
|
||||
|
||||
|
||||
def yellow(t: str) -> str:
|
||||
return _c("33", t)
|
||||
|
||||
|
||||
def red(t: str) -> str:
|
||||
return _c("31", t)
|
||||
|
||||
|
||||
def cyan(t: str) -> str:
|
||||
return _c("36", t)
|
||||
|
||||
|
||||
def bold(t: str) -> str:
|
||||
return _c("1", t)
|
||||
|
||||
|
||||
def dim(t: str) -> str:
|
||||
return _c("2", t)
|
||||
|
||||
|
||||
def banner(text: str) -> None:
|
||||
@@ -123,7 +147,9 @@ def _collect_environment() -> dict:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "version", "--format", "{{.Server.Version}}"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
env["docker"] = r.stdout.strip()
|
||||
@@ -133,7 +159,9 @@ def _collect_environment() -> dict:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "version", "--short"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
env["compose"] = r.stdout.strip()
|
||||
@@ -144,15 +172,17 @@ def _collect_environment() -> dict:
|
||||
if sys.platform == "darwin":
|
||||
r = subprocess.run(
|
||||
["sysctl", "-n", "hw.memsize"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
env["ram_gb"] = round(int(r.stdout.strip()) / (1024 ** 3))
|
||||
env["ram_gb"] = round(int(r.stdout.strip()) / (1024**3))
|
||||
else:
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal:"):
|
||||
env["ram_gb"] = round(int(line.split()[1]) * 1024 / (1024 ** 3))
|
||||
env["ram_gb"] = round(int(line.split()[1]) * 1024 / (1024**3))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
@@ -166,7 +196,10 @@ def _get_app_version() -> tuple[str, str]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "describe", "--tags", "--always"],
|
||||
capture_output=True, text=True, timeout=5, cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
version = r.stdout.strip()
|
||||
@@ -175,7 +208,10 @@ def _get_app_version() -> tuple[str, str]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5, cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
build_id = r.stdout.strip()
|
||||
@@ -202,9 +238,15 @@ class SetupTelemetry:
|
||||
self._environment = _collect_environment()
|
||||
self._app_version, self._build_id = _get_app_version()
|
||||
|
||||
def step(self, step_name: str, result: str, duration_ms: int | None = None,
|
||||
error_message: str | None = None, error_code: str | None = None,
|
||||
metrics: dict | None = None) -> None:
|
||||
def step(
|
||||
self,
|
||||
step_name: str,
|
||||
result: str,
|
||||
duration_ms: int | None = None,
|
||||
error_message: str | None = None,
|
||||
error_code: str | None = None,
|
||||
metrics: dict | None = None,
|
||||
) -> None:
|
||||
"""Emit a single setup step event. No-op if disabled."""
|
||||
if not self.enabled:
|
||||
return
|
||||
@@ -249,8 +291,14 @@ class SetupTelemetry:
|
||||
|
||||
# ── Input helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def ask(prompt: str, default: str = "", required: bool = False,
|
||||
secret: bool = False, validate=None) -> str:
|
||||
|
||||
def ask(
|
||||
prompt: str,
|
||||
default: str = "",
|
||||
required: bool = False,
|
||||
secret: bool = False,
|
||||
validate=None,
|
||||
) -> str:
|
||||
"""Prompt the user for input with optional default, validation, and secret mode."""
|
||||
suffix = f" [{default}]" if default else ""
|
||||
full_prompt = f" {prompt}{suffix}: "
|
||||
@@ -265,7 +313,9 @@ def ask(prompt: str, default: str = "", required: bool = False,
|
||||
if default:
|
||||
return default
|
||||
if required:
|
||||
raise SystemExit(f"EOF reached and no default for required field: {prompt}")
|
||||
raise SystemExit(
|
||||
f"EOF reached and no default for required field: {prompt}"
|
||||
)
|
||||
return ""
|
||||
|
||||
value = value.strip()
|
||||
@@ -311,6 +361,7 @@ def mask_secret(value: str) -> str:
|
||||
|
||||
# ── Validators ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_password_strength(value: str) -> str | None:
|
||||
if len(value) < 12:
|
||||
return "Password must be at least 12 characters."
|
||||
@@ -333,6 +384,7 @@ def validate_domain(value: str) -> str | None:
|
||||
|
||||
# ── System checks ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def check_python_version() -> bool:
|
||||
if sys.version_info < (3, 10):
|
||||
fail(f"Python 3.10+ required, found {sys.version}")
|
||||
@@ -345,7 +397,9 @@ def check_docker() -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail("Docker is not running. Start Docker and try again.")
|
||||
@@ -361,7 +415,9 @@ def check_docker() -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "version"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail("Docker Compose v2 is not available.")
|
||||
@@ -381,7 +437,9 @@ def check_ram() -> None:
|
||||
if sys.platform == "darwin":
|
||||
result = subprocess.run(
|
||||
["sysctl", "-n", "hw.memsize"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
@@ -395,7 +453,7 @@ def check_ram() -> None:
|
||||
else:
|
||||
return
|
||||
|
||||
ram_gb = ram_bytes / (1024 ** 3)
|
||||
ram_gb = ram_bytes / (1024**3)
|
||||
if ram_gb < 4:
|
||||
warn(f"Only {ram_gb:.1f} GB RAM detected. 4 GB+ recommended for builds.")
|
||||
else:
|
||||
@@ -459,8 +517,8 @@ def preflight(args: argparse.Namespace) -> bool:
|
||||
"""Run all pre-flight checks. Returns True if OK to proceed."""
|
||||
banner("TOD Production Setup")
|
||||
print(" This wizard will configure your production environment,")
|
||||
print(" generate secrets, bootstrap OpenBao, build images, and")
|
||||
print(" start the stack.")
|
||||
print(" generate secrets, bootstrap OpenBao, pull or build images,")
|
||||
print(" and start the stack.")
|
||||
print()
|
||||
|
||||
section("Pre-flight Checks")
|
||||
@@ -483,6 +541,7 @@ def preflight(args: argparse.Namespace) -> bool:
|
||||
|
||||
# ── Secret generation ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_jwt_secret() -> str:
|
||||
return secrets.token_urlsafe(64)
|
||||
|
||||
@@ -501,6 +560,7 @@ def generate_admin_password() -> str:
|
||||
|
||||
# ── Wizard sections ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def wizard_database(config: dict, args: argparse.Namespace) -> None:
|
||||
section("Database")
|
||||
info("PostgreSQL superuser password — used for migrations and admin operations.")
|
||||
@@ -578,8 +638,12 @@ def wizard_admin(config: dict, args: argparse.Namespace) -> None:
|
||||
error = validate_password_strength(password)
|
||||
while error:
|
||||
warn(error)
|
||||
password = ask("Admin password", secret=True, required=True,
|
||||
validate=validate_password_strength)
|
||||
password = ask(
|
||||
"Admin password",
|
||||
secret=True,
|
||||
required=True,
|
||||
validate=validate_password_strength,
|
||||
)
|
||||
error = None # ask() already validated
|
||||
config["admin_password"] = password
|
||||
config["admin_password_generated"] = False
|
||||
@@ -625,7 +689,9 @@ def wizard_email(config: dict, args: argparse.Namespace) -> None:
|
||||
config["smtp_host"] = ask("SMTP host", required=True)
|
||||
config["smtp_port"] = ask("SMTP port", default="587")
|
||||
config["smtp_user"] = ask("SMTP username (optional)")
|
||||
config["smtp_password"] = ask("SMTP password (optional)", secret=True) if config["smtp_user"] else ""
|
||||
config["smtp_password"] = (
|
||||
ask("SMTP password (optional)", secret=True) if config["smtp_user"] else ""
|
||||
)
|
||||
config["smtp_from"] = ask("From address", required=True, validate=validate_email)
|
||||
config["smtp_tls"] = ask_yes_no("Use TLS?", default=True)
|
||||
|
||||
@@ -641,16 +707,22 @@ def wizard_domain(config: dict, args: argparse.Namespace) -> None:
|
||||
raise SystemExit(1)
|
||||
raw = args.domain
|
||||
else:
|
||||
raw = ask("Production domain (e.g. tod.example.com)", required=True, validate=validate_domain)
|
||||
raw = ask(
|
||||
"Production domain (e.g. tod.example.com)",
|
||||
required=True,
|
||||
validate=validate_domain,
|
||||
)
|
||||
|
||||
domain = re.sub(r"^https?://", "", raw).rstrip("/")
|
||||
config["domain"] = domain
|
||||
|
||||
# Determine protocol — default HTTPS for production, allow HTTP for LAN/dev
|
||||
if args.non_interactive:
|
||||
use_https = not getattr(args, 'no_https', False)
|
||||
use_https = not getattr(args, "no_https", False)
|
||||
else:
|
||||
use_https = ask_yes_no("Use HTTPS? (disable for LAN/dev without TLS)", default=True)
|
||||
use_https = ask_yes_no(
|
||||
"Use HTTPS? (disable for LAN/dev without TLS)", default=True
|
||||
)
|
||||
|
||||
protocol = "https" if use_https else "http"
|
||||
config["app_base_url"] = f"{protocol}://{domain}"
|
||||
@@ -659,7 +731,9 @@ def wizard_domain(config: dict, args: argparse.Namespace) -> None:
|
||||
ok(f"APP_BASE_URL={protocol}://{domain}")
|
||||
ok(f"CORS_ORIGINS={protocol}://{domain}")
|
||||
if not use_https:
|
||||
warn("Running without HTTPS — cookies will not be Secure. Fine for LAN, not for public internet.")
|
||||
warn(
|
||||
"Running without HTTPS — cookies will not be Secure. Fine for LAN, not for public internet."
|
||||
)
|
||||
|
||||
|
||||
# ── Reverse proxy ───────────────────────────────────────────────────────────
|
||||
@@ -678,7 +752,7 @@ PROXY_CONFIGS = {
|
||||
"filename": None, # derived from domain
|
||||
"placeholders": {
|
||||
"tod.example.com": None, # replaced with domain
|
||||
"YOUR_TOD_HOST": None, # replaced with host IP
|
||||
"YOUR_TOD_HOST": None, # replaced with host IP
|
||||
},
|
||||
},
|
||||
"nginx": {
|
||||
@@ -783,13 +857,16 @@ def _write_system_file(path: pathlib.Path, content: str) -> bool:
|
||||
# Ensure parent directory exists
|
||||
subprocess.run(
|
||||
["sudo", "mkdir", "-p", str(path.parent)],
|
||||
check=True, timeout=30,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
# Write via sudo tee
|
||||
result = subprocess.run(
|
||||
["sudo", "tee", str(path)],
|
||||
input=content, text=True,
|
||||
capture_output=True, timeout=30,
|
||||
input=content,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail(f"sudo tee failed: {result.stderr.strip()}")
|
||||
@@ -813,7 +890,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||
proxy_val = args.proxy or "skip"
|
||||
if proxy_val == "skip":
|
||||
config["proxy_configured"] = False
|
||||
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
||||
info(
|
||||
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
|
||||
)
|
||||
return
|
||||
valid_proxies = list(PROXY_CONFIGS.keys())
|
||||
if proxy_val not in valid_proxies:
|
||||
@@ -823,7 +902,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||
else:
|
||||
if not ask_yes_no("Configure a reverse proxy now?", default=True):
|
||||
config["proxy_configured"] = False
|
||||
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
||||
info(
|
||||
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
|
||||
)
|
||||
return
|
||||
|
||||
# Detect installed proxies
|
||||
@@ -858,7 +939,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||
idx = int(choice) - 1
|
||||
if idx == len(choices):
|
||||
config["proxy_configured"] = False
|
||||
info("Skipped. Example configs are in infrastructure/reverse-proxy-examples/")
|
||||
info(
|
||||
"Skipped. Example configs are in infrastructure/reverse-proxy-examples/"
|
||||
)
|
||||
return
|
||||
if 0 <= idx < len(choices):
|
||||
break
|
||||
@@ -916,7 +999,7 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||
print(f" {dim('...')}")
|
||||
print()
|
||||
|
||||
custom_path = ask(f"Write config to", default=str(out_path))
|
||||
custom_path = ask("Write config to", default=str(out_path))
|
||||
out_path = pathlib.Path(custom_path)
|
||||
|
||||
if out_path.exists():
|
||||
@@ -958,7 +1041,9 @@ def wizard_reverse_proxy(config: dict, args: argparse.Namespace) -> None:
|
||||
info("Traefik watches for file changes — no reload needed.")
|
||||
|
||||
|
||||
def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespace) -> None:
|
||||
def wizard_telemetry(
|
||||
config: dict, telem: SetupTelemetry, args: argparse.Namespace
|
||||
) -> None:
|
||||
section("Anonymous Diagnostics")
|
||||
info("TOD can send anonymous setup and runtime diagnostics to help")
|
||||
info("identify common failures. No personal data, IPs, hostnames,")
|
||||
@@ -989,8 +1074,60 @@ def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespa
|
||||
info("No diagnostics will be sent.")
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""Read the version string from the VERSION file."""
|
||||
version_file = PROJECT_ROOT / "VERSION"
|
||||
if version_file.exists():
|
||||
return version_file.read_text().strip()
|
||||
return "latest"
|
||||
|
||||
|
||||
def wizard_build_mode(config: dict, args: argparse.Namespace) -> None:
|
||||
"""Ask whether to use pre-built images or build from source."""
|
||||
section("Build Mode")
|
||||
|
||||
version = _read_version()
|
||||
config["tod_version"] = version
|
||||
|
||||
if args.non_interactive:
|
||||
mode = getattr(args, "build_mode", None) or "prebuilt"
|
||||
config["build_mode"] = mode
|
||||
if mode == "source":
|
||||
COMPOSE_CMD.extend(["-f", COMPOSE_BUILD_OVERRIDE])
|
||||
ok(f"Build from source (v{version})")
|
||||
else:
|
||||
ok(f"Pre-built images from GHCR (v{version})")
|
||||
return
|
||||
|
||||
print(f" TOD v{bold(version)} can be installed two ways:")
|
||||
print()
|
||||
print(f" {bold('1.')} {green('Pre-built images')} {dim('(recommended)')}")
|
||||
print(" Pull ready-to-run images from GitHub Container Registry.")
|
||||
print(" Fast install, no compilation needed.")
|
||||
print()
|
||||
print(f" {bold('2.')} Build from source")
|
||||
print(" Compile Go, Python, and Node.js locally.")
|
||||
print(" Requires 4+ GB RAM and takes 5-15 minutes.")
|
||||
print()
|
||||
|
||||
while True:
|
||||
choice = input(" Choice [1/2]: ").strip()
|
||||
if choice in ("1", ""):
|
||||
config["build_mode"] = "prebuilt"
|
||||
ok("Pre-built images from GHCR")
|
||||
break
|
||||
elif choice == "2":
|
||||
config["build_mode"] = "source"
|
||||
COMPOSE_CMD.extend(["-f", COMPOSE_BUILD_OVERRIDE])
|
||||
ok("Build from source")
|
||||
break
|
||||
else:
|
||||
warn("Please enter 1 or 2.")
|
||||
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def show_summary(config: dict, args: argparse.Namespace) -> bool:
|
||||
banner("Configuration Summary")
|
||||
|
||||
@@ -1008,7 +1145,9 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
|
||||
|
||||
print(f" {bold('Admin Account')}")
|
||||
print(f" Email = {config['admin_email']}")
|
||||
print(f" Password = {'(auto-generated)' if config.get('admin_password_generated') else mask_secret(config['admin_password'])}")
|
||||
print(
|
||||
f" Password = {'(auto-generated)' if config.get('admin_password_generated') else mask_secret(config['admin_password'])}"
|
||||
)
|
||||
print()
|
||||
|
||||
print(f" {bold('Email')}")
|
||||
@@ -1041,6 +1180,14 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
|
||||
print(f" TELEMETRY_ENABLED = {dim('false')}")
|
||||
print()
|
||||
|
||||
print(f" {bold('Build Mode')}")
|
||||
if config.get("build_mode") == "source":
|
||||
print(" Mode = Build from source")
|
||||
else:
|
||||
print(f" Mode = {green('Pre-built images')}")
|
||||
print(f" Version = {config.get('tod_version', 'latest')}")
|
||||
print()
|
||||
|
||||
print(f" {bold('OpenBao')}")
|
||||
print(f" {dim('(will be captured automatically during bootstrap)')}")
|
||||
print()
|
||||
@@ -1054,6 +1201,7 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
|
||||
|
||||
# ── File writers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def write_env_prod(config: dict) -> None:
|
||||
"""Write the .env.prod file."""
|
||||
db = config["postgres_db"]
|
||||
@@ -1065,12 +1213,12 @@ def write_env_prod(config: dict) -> None:
|
||||
smtp_block = ""
|
||||
if config.get("smtp_configured"):
|
||||
smtp_block = f"""\
|
||||
SMTP_HOST={config['smtp_host']}
|
||||
SMTP_PORT={config['smtp_port']}
|
||||
SMTP_USER={config.get('smtp_user', '')}
|
||||
SMTP_PASSWORD={config.get('smtp_password', '')}
|
||||
SMTP_USE_TLS={'true' if config.get('smtp_tls') else 'false'}
|
||||
SMTP_FROM_ADDRESS={config['smtp_from']}"""
|
||||
SMTP_HOST={config["smtp_host"]}
|
||||
SMTP_PORT={config["smtp_port"]}
|
||||
SMTP_USER={config.get("smtp_user", "")}
|
||||
SMTP_PASSWORD={config.get("smtp_password", "")}
|
||||
SMTP_USE_TLS={"true" if config.get("smtp_tls") else "false"}
|
||||
SMTP_FROM_ADDRESS={config["smtp_from"]}"""
|
||||
else:
|
||||
smtp_block = """\
|
||||
# Email not configured — re-run setup.py to add SMTP
|
||||
@@ -1097,8 +1245,8 @@ APP_USER_DATABASE_URL=postgresql+asyncpg://app_user:{app_pw}@postgres:5432/{db}
|
||||
POLLER_DATABASE_URL=postgres://poller_user:{poll_pw}@postgres:5432/{db}?sslmode=disable
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET_KEY={config['jwt_secret']}
|
||||
CREDENTIAL_ENCRYPTION_KEY={config['encryption_key']}
|
||||
JWT_SECRET_KEY={config["jwt_secret"]}
|
||||
CREDENTIAL_ENCRYPTION_KEY={config["encryption_key"]}
|
||||
|
||||
# --- OpenBao (KMS) ---
|
||||
OPENBAO_ADDR=http://openbao:8200
|
||||
@@ -1106,21 +1254,22 @@ OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP
|
||||
BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP
|
||||
|
||||
# --- Admin Bootstrap ---
|
||||
FIRST_ADMIN_EMAIL={config['admin_email']}
|
||||
FIRST_ADMIN_PASSWORD={config['admin_password']}
|
||||
FIRST_ADMIN_EMAIL={config["admin_email"]}
|
||||
FIRST_ADMIN_PASSWORD={config["admin_password"]}
|
||||
|
||||
# --- Email ---
|
||||
{smtp_block}
|
||||
|
||||
# --- Web ---
|
||||
APP_BASE_URL={config['app_base_url']}
|
||||
CORS_ORIGINS={config['cors_origins']}
|
||||
APP_BASE_URL={config["app_base_url"]}
|
||||
CORS_ORIGINS={config["cors_origins"]}
|
||||
|
||||
# --- Application ---
|
||||
ENVIRONMENT=production
|
||||
LOG_LEVEL=info
|
||||
DEBUG=false
|
||||
APP_NAME=TOD - The Other Dude
|
||||
TOD_VERSION={config.get("tod_version", "latest")}
|
||||
|
||||
# --- Storage ---
|
||||
GIT_STORE_PATH=/data/git-store
|
||||
@@ -1151,7 +1300,7 @@ CONFIG_BACKUP_MAX_CONCURRENT=10
|
||||
|
||||
# --- Telemetry ---
|
||||
# Opt-in anonymous diagnostics. Set to false to disable.
|
||||
TELEMETRY_ENABLED={'true' if config.get('telemetry_enabled') else 'false'}
|
||||
TELEMETRY_ENABLED={"true" if config.get("telemetry_enabled") else "false"}
|
||||
TELEMETRY_COLLECTOR_URL={_TELEMETRY_COLLECTOR}
|
||||
"""
|
||||
|
||||
@@ -1245,7 +1394,8 @@ def prepare_data_dirs() -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "chown", "-R", f"{APPUSER_UID}:{APPUSER_UID}", str(path)],
|
||||
check=True, timeout=10,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
ok(f"{d} (owned by appuser via sudo)")
|
||||
except Exception:
|
||||
@@ -1261,14 +1411,17 @@ def prepare_data_dirs() -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "chmod", "-R", "777", str(path)],
|
||||
check=True, timeout=10,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
ok(f"{d} (world-writable via sudo)")
|
||||
except Exception:
|
||||
warn(f"{d} — could not set permissions, VPN config sync may fail")
|
||||
|
||||
# Create/update WireGuard forwarding init script (always overwrite for isolation rules)
|
||||
fwd_script = PROJECT_ROOT / "docker-data/wireguard/custom-cont-init.d/10-forwarding.sh"
|
||||
fwd_script = (
|
||||
PROJECT_ROOT / "docker-data/wireguard/custom-cont-init.d/10-forwarding.sh"
|
||||
)
|
||||
fwd_script.write_text("""\
|
||||
#!/bin/sh
|
||||
# Enable forwarding between Docker network and WireGuard tunnel
|
||||
@@ -1299,8 +1452,10 @@ echo "WireGuard forwarding and tenant isolation rules applied"
|
||||
|
||||
# ── Docker operations ────────────────────────────────────────────────────────
|
||||
|
||||
def run_compose(*args, check: bool = True, capture: bool = False,
|
||||
timeout: int = 600) -> subprocess.CompletedProcess:
|
||||
|
||||
def run_compose(
|
||||
*args, check: bool = True, capture: bool = False, timeout: int = 600
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a docker compose command with the prod overlay."""
|
||||
cmd = COMPOSE_CMD + ["--env-file", str(ENV_PROD)] + list(args)
|
||||
return subprocess.run(
|
||||
@@ -1332,8 +1487,16 @@ def bootstrap_openbao(config: dict) -> bool:
|
||||
healthy = False
|
||||
while time.time() < deadline:
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "--format", "{{.State.Health.Status}}", "tod_openbao"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.State.Health.Status}}",
|
||||
"tod_openbao",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status == "healthy":
|
||||
@@ -1365,10 +1528,12 @@ def bootstrap_openbao(config: dict) -> bool:
|
||||
|
||||
# Update .env.prod
|
||||
env_content = ENV_PROD.read_text()
|
||||
env_content = env_content.replace("OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP",
|
||||
f"OPENBAO_TOKEN={root_token}")
|
||||
env_content = env_content.replace("BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP",
|
||||
f"BAO_UNSEAL_KEY={unseal_key}")
|
||||
env_content = env_content.replace(
|
||||
"OPENBAO_TOKEN=PLACEHOLDER_RUN_SETUP", f"OPENBAO_TOKEN={root_token}"
|
||||
)
|
||||
env_content = env_content.replace(
|
||||
"BAO_UNSEAL_KEY=PLACEHOLDER_RUN_SETUP", f"BAO_UNSEAL_KEY={unseal_key}"
|
||||
)
|
||||
ENV_PROD.write_text(env_content)
|
||||
ENV_PROD.chmod(0o600)
|
||||
|
||||
@@ -1380,7 +1545,9 @@ def bootstrap_openbao(config: dict) -> bool:
|
||||
# OpenBao was already initialized — check if .env.prod has real values
|
||||
env_content = ENV_PROD.read_text()
|
||||
if "PLACEHOLDER_RUN_SETUP" in env_content:
|
||||
warn("Could not find credentials in logs (OpenBao may already be initialized).")
|
||||
warn(
|
||||
"Could not find credentials in logs (OpenBao may already be initialized)."
|
||||
)
|
||||
warn("Check 'docker compose logs openbao' and update .env.prod manually.")
|
||||
return False
|
||||
else:
|
||||
@@ -1388,6 +1555,38 @@ def bootstrap_openbao(config: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def pull_images() -> bool:
|
||||
"""Pull pre-built images from GHCR."""
|
||||
section("Pulling Images")
|
||||
info("Downloading pre-built images from GitHub Container Registry...")
|
||||
print()
|
||||
|
||||
services = ["api", "poller", "frontend", "winbox-worker"]
|
||||
|
||||
for i, service in enumerate(services, 1):
|
||||
info(f"[{i}/{len(services)}] Pulling {service}...")
|
||||
try:
|
||||
run_compose("pull", service, timeout=600)
|
||||
ok(f"{service} pulled successfully")
|
||||
except subprocess.CalledProcessError:
|
||||
fail(f"Failed to pull {service}")
|
||||
print()
|
||||
warn("Check your internet connection and that the image exists.")
|
||||
warn("To retry:")
|
||||
info(
|
||||
f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
|
||||
f"--env-file .env.prod pull {service}"
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
fail(f"Pull of {service} timed out (10 min)")
|
||||
return False
|
||||
|
||||
print()
|
||||
ok("All images ready")
|
||||
return True
|
||||
|
||||
|
||||
def build_images() -> bool:
|
||||
"""Build Docker images one at a time to avoid OOM."""
|
||||
section("Building Images")
|
||||
@@ -1405,7 +1604,10 @@ def build_images() -> bool:
|
||||
fail(f"Failed to build {service}")
|
||||
print()
|
||||
warn("To retry this build:")
|
||||
info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} build {service}")
|
||||
info(
|
||||
f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
|
||||
f"-f {COMPOSE_BUILD_OVERRIDE} build {service}"
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
fail(f"Build of {service} timed out (15 min)")
|
||||
@@ -1456,10 +1658,16 @@ def health_check(config: dict) -> None:
|
||||
for container, label in list(pending.items()):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "--format",
|
||||
"{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}",
|
||||
container],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}",
|
||||
container,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status in ("healthy", "running"):
|
||||
@@ -1491,7 +1699,7 @@ def health_check(config: dict) -> None:
|
||||
if config.get("admin_password_generated"):
|
||||
print(f" Password: {bold(config['admin_password'])}")
|
||||
else:
|
||||
print(f" Password: (the password you entered)")
|
||||
print(" Password: (the password you entered)")
|
||||
print()
|
||||
info("Change the admin password after your first login.")
|
||||
else:
|
||||
@@ -1501,6 +1709,7 @@ def health_check(config: dict) -> None:
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _timed(telem: SetupTelemetry, step_name: str, func, *args, **kwargs):
|
||||
"""Run func, emit a telemetry event with timing. Returns func's result."""
|
||||
t0 = time.monotonic()
|
||||
@@ -1512,8 +1721,11 @@ def _timed(telem: SetupTelemetry, step_name: str, func, *args, **kwargs):
|
||||
except Exception as e:
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
telem.step(
|
||||
step_name, "failure", duration_ms=duration_ms,
|
||||
error_message=str(e), error_code=type(e).__name__,
|
||||
step_name,
|
||||
"failure",
|
||||
duration_ms=duration_ms,
|
||||
error_message=str(e),
|
||||
error_code=type(e).__name__,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -1525,41 +1737,93 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--non-interactive", action="store_true",
|
||||
"--non-interactive",
|
||||
action="store_true",
|
||||
help="Skip all prompts, use defaults + provided flags",
|
||||
)
|
||||
parser.add_argument("--postgres-password", type=str, default=None,
|
||||
help="PostgreSQL superuser password")
|
||||
parser.add_argument("--admin-email", type=str, default=None,
|
||||
help="Admin email (default: admin@the-other-dude.dev)")
|
||||
parser.add_argument("--admin-password", type=str, default=None,
|
||||
help="Admin password (auto-generated if not provided)")
|
||||
parser.add_argument("--domain", type=str, default=None,
|
||||
help="Production domain (e.g. tod.example.com)")
|
||||
parser.add_argument("--smtp-host", type=str, default=None,
|
||||
help="SMTP host (skip email config if not provided)")
|
||||
parser.add_argument("--smtp-port", type=str, default=None,
|
||||
help="SMTP port (default: 587)")
|
||||
parser.add_argument("--smtp-user", type=str, default=None,
|
||||
help="SMTP username")
|
||||
parser.add_argument("--smtp-password", type=str, default=None,
|
||||
help="SMTP password")
|
||||
parser.add_argument("--smtp-from", type=str, default=None,
|
||||
help="SMTP from address")
|
||||
parser.add_argument("--smtp-tls", action="store_true", default=False,
|
||||
help="Use TLS for SMTP (default: true in non-interactive)")
|
||||
parser.add_argument("--no-smtp-tls", action="store_true", default=False,
|
||||
help="Disable TLS for SMTP")
|
||||
parser.add_argument("--no-https", action="store_true", default=False,
|
||||
help="Use HTTP instead of HTTPS (for LAN/dev without TLS)")
|
||||
parser.add_argument("--proxy", type=str, default=None,
|
||||
help="Reverse proxy type: caddy, nginx, apache, haproxy, traefik, skip")
|
||||
parser.add_argument("--telemetry", action="store_true", default=False,
|
||||
help="Enable anonymous diagnostics")
|
||||
parser.add_argument("--no-telemetry", action="store_true", default=False,
|
||||
help="Disable anonymous diagnostics")
|
||||
parser.add_argument("--yes", "-y", action="store_true", default=False,
|
||||
help="Auto-confirm summary (don't prompt for confirmation)")
|
||||
parser.add_argument(
|
||||
"--postgres-password",
|
||||
type=str,
|
||||
default=None,
|
||||
help="PostgreSQL superuser password",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--admin-email",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Admin email (default: admin@the-other-dude.dev)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--admin-password",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Admin password (auto-generated if not provided)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--domain",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Production domain (e.g. tod.example.com)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smtp-host",
|
||||
type=str,
|
||||
default=None,
|
||||
help="SMTP host (skip email config if not provided)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smtp-port", type=str, default=None, help="SMTP port (default: 587)"
|
||||
)
|
||||
parser.add_argument("--smtp-user", type=str, default=None, help="SMTP username")
|
||||
parser.add_argument("--smtp-password", type=str, default=None, help="SMTP password")
|
||||
parser.add_argument("--smtp-from", type=str, default=None, help="SMTP from address")
|
||||
parser.add_argument(
|
||||
"--smtp-tls",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Use TLS for SMTP (default: true in non-interactive)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-smtp-tls", action="store_true", default=False, help="Disable TLS for SMTP"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-https",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Use HTTP instead of HTTPS (for LAN/dev without TLS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proxy",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Reverse proxy type: caddy, nginx, apache, haproxy, traefik, skip",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telemetry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable anonymous diagnostics",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-telemetry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Disable anonymous diagnostics",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-mode",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["prebuilt", "source"],
|
||||
help="Image source: prebuilt (pull from GHCR) or source (compile locally)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Auto-confirm summary (don't prompt for confirmation)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -1575,15 +1839,20 @@ def main() -> int:
|
||||
|
||||
def handle_sigint(sig, frame):
|
||||
nonlocal env_written
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="User cancelled (SIGINT)")
|
||||
telem.step(
|
||||
"setup_total",
|
||||
"failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="User cancelled (SIGINT)",
|
||||
)
|
||||
print()
|
||||
if not env_written:
|
||||
info("Aborted before writing .env.prod — no files changed.")
|
||||
else:
|
||||
warn(f".env.prod was already written to {ENV_PROD}")
|
||||
info("OpenBao tokens may still be placeholders if bootstrap didn't complete.")
|
||||
info(
|
||||
"OpenBao tokens may still be placeholders if bootstrap didn't complete."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
@@ -1602,6 +1871,7 @@ def main() -> int:
|
||||
|
||||
# Phase 2: Wizard
|
||||
try:
|
||||
wizard_build_mode(config, args)
|
||||
wizard_database(config, args)
|
||||
wizard_security(config)
|
||||
wizard_admin(config, args)
|
||||
@@ -1610,16 +1880,20 @@ def main() -> int:
|
||||
wizard_reverse_proxy(config, args)
|
||||
telem.step("wizard", "success")
|
||||
except Exception as e:
|
||||
telem.step("wizard", "failure",
|
||||
error_message=str(e), error_code=type(e).__name__)
|
||||
telem.step(
|
||||
"wizard", "failure", error_message=str(e), error_code=type(e).__name__
|
||||
)
|
||||
raise
|
||||
|
||||
# Summary
|
||||
if not show_summary(config, args):
|
||||
info("Setup cancelled.")
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="User cancelled at summary")
|
||||
telem.step(
|
||||
"setup_total",
|
||||
"failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="User cancelled at summary",
|
||||
)
|
||||
return 1
|
||||
|
||||
# Phase 3: Write files and prepare directories
|
||||
@@ -1631,8 +1905,9 @@ def main() -> int:
|
||||
prepare_data_dirs()
|
||||
telem.step("write_config", "success")
|
||||
except Exception as e:
|
||||
telem.step("write_config", "failure",
|
||||
error_message=str(e), error_code=type(e).__name__)
|
||||
telem.step(
|
||||
"write_config", "failure", error_message=str(e), error_code=type(e).__name__
|
||||
)
|
||||
raise
|
||||
|
||||
# Phase 4: OpenBao
|
||||
@@ -1642,36 +1917,63 @@ def main() -> int:
|
||||
if bao_ok:
|
||||
telem.step("openbao_bootstrap", "success", duration_ms=duration_ms)
|
||||
else:
|
||||
telem.step("openbao_bootstrap", "failure", duration_ms=duration_ms,
|
||||
error_message="OpenBao did not become healthy or credentials not found")
|
||||
if not ask_yes_no("Continue without OpenBao credentials? (stack will need manual fix)", default=False):
|
||||
telem.step(
|
||||
"openbao_bootstrap",
|
||||
"failure",
|
||||
duration_ms=duration_ms,
|
||||
error_message="OpenBao did not become healthy or credentials not found",
|
||||
)
|
||||
if not ask_yes_no(
|
||||
"Continue without OpenBao credentials? (stack will need manual fix)",
|
||||
default=False,
|
||||
):
|
||||
warn("Fix OpenBao credentials in .env.prod and re-run setup.py.")
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Aborted after OpenBao failure")
|
||||
telem.step(
|
||||
"setup_total",
|
||||
"failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Aborted after OpenBao failure",
|
||||
)
|
||||
return 1
|
||||
|
||||
# Phase 5: Build
|
||||
# Phase 5: Build or Pull
|
||||
t0 = time.monotonic()
|
||||
if not build_images():
|
||||
if config.get("build_mode") == "source":
|
||||
images_ok = build_images()
|
||||
step_name = "build_images"
|
||||
fail_msg = "Docker build failed"
|
||||
retry_hint = "Fix the build error and re-run setup.py to continue."
|
||||
else:
|
||||
images_ok = pull_images()
|
||||
step_name = "pull_images"
|
||||
fail_msg = "Image pull failed"
|
||||
retry_hint = "Check your connection and re-run setup.py to continue."
|
||||
|
||||
if not images_ok:
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
telem.step("build_images", "failure", duration_ms=duration_ms)
|
||||
warn("Fix the build error and re-run setup.py to continue.")
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Docker build failed")
|
||||
telem.step(step_name, "failure", duration_ms=duration_ms)
|
||||
warn(retry_hint)
|
||||
telem.step(
|
||||
"setup_total",
|
||||
"failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message=fail_msg,
|
||||
)
|
||||
return 1
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
telem.step("build_images", "success", duration_ms=duration_ms)
|
||||
telem.step(step_name, "success", duration_ms=duration_ms)
|
||||
|
||||
# Phase 6: Start
|
||||
t0 = time.monotonic()
|
||||
if not start_stack():
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
telem.step("start_stack", "failure", duration_ms=duration_ms)
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Stack failed to start")
|
||||
telem.step(
|
||||
"setup_total",
|
||||
"failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Stack failed to start",
|
||||
)
|
||||
return 1
|
||||
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||
telem.step("start_stack", "success", duration_ms=duration_ms)
|
||||
|
||||
Reference in New Issue
Block a user