Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ secrets/*
!secrets/README.md
docker-compose.override.yml
!docker-compose.override.yml.example
# Fleet-mode ↔ ams-observability bridge override an operator copies from the committed `.example` (#5805); their
# real host path stays local, only the `.example` template is tracked.
packages/loopover-miner/docker-compose.miner.override.yml
!packages/loopover-miner/docker-compose.miner.override.yml.example
*.local
# A second local-override naming shape beyond bare `*.local` (e.g. alertmanager.local): a host-specific
# compose override named docker-compose.local-<label>.yml (label distinguishes multiple such overrides
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,9 @@ services:
volumes:
# Read-only live-ledger access for the exporter only -- Grafana does not get this mount (mirrors the
# /appdb:ro pattern above). Point this at the SAME host directory your miner uses as LOOPOVER_MINER_CONFIG_DIR.
# LAPTOP mode already writes there. FLEET mode (packages/loopover-miner/docker-compose.miner.yml) keeps state
# in a named volume, so it needs the opt-in bind-mount override + combined command -- see "Running fleet mode
# alongside ORB's ams-observability profile" in packages/loopover-miner/DEPLOYMENT.md (#5805).
- "${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}:/ams-ledgers:ro"
- grafana-reporting-data:/reporting
# Directory bind mount, not a single-file one -- see reporting-exporter's own comment above for why
Expand Down
18 changes: 18 additions & 0 deletions packages/loopover-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ docker compose -f docker-compose.miner.yml up -d --build

**Scaling to N parallel workers.** `docker compose -f docker-compose.miner.yml up -d --scale miner=N` gives every replica the **same** `miner-data` volume — and the miner's SQLite ledgers are **not** safe for concurrent access, so N replicas on one volume will contend/corrupt. To run N **isolated** workers, give each its own state: run N separate compose projects (`docker compose -p miner-1 …`, `-p miner-2 …` — `-p` namespaces the volume) or point each at a distinct `LOOPOVER_MINER_CONFIG_DIR` on its own mount. For built-in isolated horizontal scaling, use the Kubernetes StatefulSet in [`k8s/`](../../k8s/) (per-pod volumes).

### Running fleet mode alongside ORB's `ams-observability` profile

Fleet mode keeps miner state in a named `miner-data` volume, but ORB's `ams-reporting-exporter` (root [`docker-compose.yml`](../../docker-compose.yml), `--profile ams-observability`) reads the miner's ledgers from a **host** directory (default `~/.config/loopover-miner`). A named volume's host path is a Docker-managed internal detail, so the two never line up on their own — the exporter reads an empty directory and the Grafana AMS datasources stay **silently empty**.

To bridge them, relocate the fleet miner's state onto a host directory with the opt-in override, then run both profiles together:

```sh
cp packages/loopover-miner/docker-compose.miner.override.yml.example \
packages/loopover-miner/docker-compose.miner.override.yml # gitignored; edit the host path only if you want a non-default location

docker compose -f docker-compose.yml \
-f packages/loopover-miner/docker-compose.miner.yml \
-f packages/loopover-miner/docker-compose.miner.override.yml \
--profile ams-observability up -d
```

The override bind-mounts `/data/miner` to `${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}` — the **same** variable and default the exporter already uses — so both read one location with no `docker volume inspect` archaeology. Leave both unset for the default, or set `LOOPOVER_MINER_CONFIG_DIR` once and both the fleet miner and the exporter follow it. This override is opt-in and additive: without it, `docker-compose.miner.yml`'s named-volume default is unchanged.

## Bare-host (systemd, no Docker)

To run the miner continuously on a plain Linux host without Docker, supervise `loopover-miner loop` — the autonomous discover → attempt → manage daemon (#5135) — with systemd. [`systemd/loopover-miner.service.example`](../../systemd/loopover-miner.service.example) is a ready-to-adapt persistent unit; its header carries the full install steps:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Optional fleet-mode ↔ ORB `ams-observability` bridge (#5805). Copy to `docker-compose.miner.override.yml`
# (gitignored) and use it ONLY when you run the fleet-mode miner AND ORB's `--profile ams-observability` on the
# SAME host and want the Grafana AMS dashboards populated.
#
# WHY THIS EXISTS: fleet mode (docker-compose.miner.yml) keeps miner state in a Docker-managed NAMED volume
# (`miner-data`), whose real host path is an internal detail (…/var/lib/docker/volumes/<project>_miner-data/_data).
# ORB's `ams-reporting-exporter` (root docker-compose.yml, `ams-observability` profile) reads the miner's ledgers
# via a HOST bind mount defaulting to `~/.config/loopover-miner` (laptop mode's path). Those two never resolve to
# the same location, so with the plain fleet-mode setup the exporter reads an empty directory and the Grafana AMS
# datasources stay silently empty, with no error surfaced anywhere. This override relocates the fleet miner's
# `/data/miner` state onto a real host directory the exporter can read.
#
# HOW IT STAYS IN SYNC: the bind source below uses the SAME `${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}`
# variable and default the exporter already uses, so both read one location automatically — no `docker volume
# inspect` archaeology. Leave both unset to use the default; or set `LOOPOVER_MINER_CONFIG_DIR` once and BOTH the
# fleet miner (via this override) and the exporter follow it.
#
# HOW TO USE — run the two profiles together (see DEPLOYMENT.md's "Running fleet mode alongside ORB's
# ams-observability profile" section for the full walkthrough):
#
# docker compose -f docker-compose.yml \
# -f packages/loopover-miner/docker-compose.miner.yml \
# -f packages/loopover-miner/docker-compose.miner.override.yml \
# --profile ams-observability up -d
#
# Compose merges a service's `volumes` by container path, so this bind mount REPLACES the base file's
# `miner-data:/data/miner` named-volume mount for the same `/data/miner` target — it does not add a second mount.
# The now-unused `miner-data` top-level volume declaration in the base file stays declared but unmounted (harmless).
services:
miner:
volumes:
- "${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}:/data/miner"
89 changes: 89 additions & 0 deletions test/unit/ams-observability-compose-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";

// #5805: the fleet-mode miner (docker-compose.miner.yml, named `miner-data` volume) and ORB's
// `ams-reporting-exporter` (root docker-compose.yml, `ams-observability` profile, host bind mount) were authored in
// the same wave but never cross-checked, so their ledger locations silently didn't line up. The opt-in override
// (docker-compose.miner.override.yml.example) bridges them by relocating the miner's `/data/miner` state onto the
// SAME host path the exporter reads. This static parity check (no docker required) makes sure the override's bind
// source, the exporter's bind source, and the DEPLOYMENT.md instructions can't silently drift apart again.

const ROOT = join(__dirname, "..", "..");
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8");

const rootComposeText = read("docker-compose.yml");
const minerComposeText = read("packages/loopover-miner/docker-compose.miner.yml");
const overrideText = read("packages/loopover-miner/docker-compose.miner.override.yml.example");
const deploymentDoc = read("packages/loopover-miner/DEPLOYMENT.md");

const rootCompose = parse(rootComposeText) as {
services: Record<string, { profiles?: string[]; volumes?: string[] }>;
};
const minerCompose = parse(minerComposeText) as {
services: Record<string, { volumes?: string[] }>;
volumes?: Record<string, unknown>;
};
const override = parse(overrideText) as {
services: Record<string, { volumes?: string[] }>;
volumes?: Record<string, unknown>;
networks?: Record<string, unknown>;
};

/** Strip a known `:TARGET[:MODE]` suffix off a compose short-syntax volume to recover its source expression
* (the source itself contains `${VAR:-default}` colons, so a naive split would break). */
function sourceForTarget(volumes: string[] | undefined, target: string): string {
const match = (volumes ?? []).find((v) => v.includes(`:${target}`));
if (!match) throw new Error(`no volume targeting ${target}`);
return match.slice(0, match.indexOf(`:${target}`));
}

describe("AMS fleet-mode ↔ ams-observability compose bridge (#5805)", () => {
const EXPECTED_SOURCE = "${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}";

it("the override bind source matches the exporter's bind source (single shared location)", () => {
const exporter = rootCompose.services["ams-reporting-exporter"];
expect(exporter?.profiles).toContain("ams-observability");
const exporterSource = sourceForTarget(exporter?.volumes, "/ams-ledgers");
const overrideSource = sourceForTarget(override.services["miner"]?.volumes, "/data/miner");
// Same variable AND same default ⇒ both profiles read one host directory with zero extra config.
expect(exporterSource).toBe(EXPECTED_SOURCE);
expect(overrideSource).toBe(EXPECTED_SOURCE);
expect(overrideSource).toBe(exporterSource);
});

it("the override REPLACES the base /data/miner mount by target (no duplicate-target collision)", () => {
const baseTarget = "/data/miner";
// The base fleet miner mounts the named volume at /data/miner...
expect(sourceForTarget(minerCompose.services["miner"]?.volumes, baseTarget)).toBe("miner-data");
// ...and the override mounts a host dir at the SAME target, so compose merges them to one mount (the override's),
// rather than two mounts fighting over /data/miner.
const overrideMiner = override.services["miner"]?.volumes ?? [];
expect(overrideMiner.filter((v) => v.includes(`:${baseTarget}`))).toHaveLength(1);
expect(sourceForTarget(overrideMiner, baseTarget)).toBe(EXPECTED_SOURCE);
});

it("the override introduces no top-level volume/network/service name that could collide across the three files", () => {
// Purely additive to the miner service's volumes — no new top-level declarations to clash with either file.
expect(override.volumes).toBeUndefined();
expect(override.networks).toBeUndefined();
expect(Object.keys(override.services)).toEqual(["miner"]);
});

it("DEPLOYMENT.md documents the override, the combined three-file command, and the shared variable", () => {
expect(deploymentDoc).toContain("Running fleet mode alongside ORB's `ams-observability` profile");
expect(deploymentDoc).toContain("docker-compose.miner.override.yml");
// the exact combined invocation, all three -f files present
expect(deploymentDoc).toContain("-f docker-compose.yml");
expect(deploymentDoc).toContain("-f packages/loopover-miner/docker-compose.miner.yml");
expect(deploymentDoc).toContain("-f packages/loopover-miner/docker-compose.miner.override.yml");
expect(deploymentDoc).toContain("--profile ams-observability up -d");
expect(deploymentDoc).toContain(EXPECTED_SOURCE);
});

it("the root exporter comment cross-links the DEPLOYMENT.md walkthrough", () => {
expect(rootComposeText).toContain("packages/loopover-miner/DEPLOYMENT.md");
expect(rootComposeText).toMatch(/fleet mode[\s\S]{0,200}ams-observability/i);
});
});
Loading