mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-15 02:30:12 +00:00
* fix(api): allowlist public node status fields * fix(ops): resolve split infrastructure containers exactly * docs: add C5a mission handoff --------- Co-authored-by: gadgethd <111318106+gadgethd@users.noreply.github.com>
This commit is contained in:
co-authored by
gadgethd
parent
27f1822e75
commit
f445a24467
@@ -393,6 +393,8 @@ jobs:
|
||||
run: scripts/test-vacuum-maintenance.sh
|
||||
- name: Test immutable release rollback and schema compatibility gates
|
||||
run: scripts/test-replace-container.sh
|
||||
- name: Test split infrastructure resolver dry run
|
||||
run: scripts/test-infra-compose.sh
|
||||
|
||||
secrets:
|
||||
name: Secret scan
|
||||
|
||||
+25
-63
@@ -1,76 +1,38 @@
|
||||
# B1 owner baseline auto-rebaseline handoff
|
||||
# Mission C5a handoff
|
||||
|
||||
## Status
|
||||
Status: done
|
||||
Branch: `fix/ukmesh-highs-a`
|
||||
Push state: not pushed; coordinator relays this branch.
|
||||
|
||||
The refresh-script dependency is resolved. The reviewed script and operations
|
||||
documentation from `4be9542` are now on this branch as cherry-pick `7f8f4b8`.
|
||||
The script MD5 is `5479d7aa1fee57d4f81d35e4fdf69699`, matching the requested
|
||||
deployed version. The parser's expected summary format matches the checked-in
|
||||
script's validation output emitter, and a fixture-based parser test covers that
|
||||
format. The fixture uses synthetic values; the full refresh validation pass has
|
||||
not been run.
|
||||
## Commits
|
||||
|
||||
## Branch and commits
|
||||
- `865b5dd` — `fix(api): allowlist public node status fields`
|
||||
- `794ee7d` — `fix(ops): resolve split infrastructure containers exactly`
|
||||
|
||||
- Branch: `fix/baseline-auto-rebaseline` (initial B1 base: `d44ef5e`).
|
||||
- Prior B1 commits: `4325f98` (`feat(ops): auto-rebaseline owner ACL inventory`)
|
||||
and `ead7ee9` (`docs(ops): handoff owner baseline autorebaseline`).
|
||||
- Dependency commit: `7f8f4b8`, cherry-picked from reviewed source commit
|
||||
`4be9542` (`ops: add reviewed owner-authorization baseline refresh script`).
|
||||
- This B1b handoff, evidence, and parser fixture update is committed separately.
|
||||
- Push state: not pushed; coordinator relays the branch.
|
||||
## Changes
|
||||
|
||||
## Files changed
|
||||
- #42: `/node-status/latest` and `/mqtt-nodes` return explicit public fields. Raw status JSON, hardware/firmware details, and unknown fields are omitted. Existing historical rows remain unchanged; no database sanitisation is needed because these public responses now use field-by-field projections. Owner-authenticated status handling is unchanged.
|
||||
- #46: Added a shared resolver for exact `timescaledb`, `redis`, and `mosquitto` services in an external Compose project/file. Backup, release migration verification, provisioning, network relabeling, and chunk maintenance use it. Backup archives the Compose file it actually selected. The isolated restore drill continues to target its generated disposable containers on a private network.
|
||||
- Updated the privacy and operations runbooks and added a non-destructive resolver dry-run to CI.
|
||||
|
||||
- `backend/src/owner/autoRebaseline.ts` — readiness decisions and refresh
|
||||
validation summary parsing.
|
||||
- `backend/src/tools/autoRebaseline.ts` — read-only check and timer wrapper.
|
||||
- `deploy/systemd/user/owner-baseline-autorebaseline.service` and
|
||||
`deploy/systemd/user/owner-baseline-autorebaseline.timer` — user service and
|
||||
ten-minute timer units.
|
||||
- `INSTALL.md` — VPS install, verification, and disable instructions for Ben.
|
||||
- `scripts/refresh-owner-baseline.sh` — reviewed validation and optional apply
|
||||
script; executable bit preserved.
|
||||
- `docs/operations.md` — reviewed owner-baseline refresh procedure.
|
||||
- `backend/src/owner/autoRebaseline.test.ts` — fixture-backed validation output
|
||||
parser test.
|
||||
- `backend/src/owner/fixtures/refresh-owner-baseline-output.txt` — sample
|
||||
stdout matching the script's summary emitter; fixture values are synthetic.
|
||||
- `evidence/owner-baseline-autorebaseline-check.txt` — latest read-only check
|
||||
output.
|
||||
- `HANDOFF.md` — this report.
|
||||
## Tests and checks
|
||||
|
||||
## Tests and evidence
|
||||
- `cd backend && TMPDIR="$OLDPWD/.tmp-c5a" node --import tsx --test src/api/routes/misc.test.ts src/api/routes/nodeStatus.test.ts` — passed, 3/3.
|
||||
- `cd backend && npm run typecheck` — passed.
|
||||
- `TMPDIR="$PWD/.tmp-c5a" npm --prefix scripts test --cache="$PWD/.tmp-c5a/npm-cache"` — passed (observer-key, resolver dry-run, and newuser tests).
|
||||
- `TMPDIR="$PWD/.tmp-c5a" scripts/test-replace-container.sh` — passed.
|
||||
- `bash -n` on modified shell scripts and `git diff --check` — passed.
|
||||
- `scripts/test-vacuum-maintenance.sh` was not run because it starts a Docker test container, prohibited by the VPS constraints. CI retains this existing integration test.
|
||||
|
||||
- Focused test: `cd backend && node --import tsx --test src/owner/autoRebaseline.test.ts` — passed, 6 tests.
|
||||
- Backend type check: `npm --prefix backend run typecheck` — passed.
|
||||
- Backend build: `npm --prefix backend run build` — passed.
|
||||
- Read-only check: `node backend/dist/tools/autoRebaseline.js --check` — passed
|
||||
with `action=skip`, `reason=owner-baseline-current`, `mismatches=[]`,
|
||||
`refreshScriptAvailable=true`, `scriptValidation=not-run-in-check-mode`, and
|
||||
`applied=false`.
|
||||
Backend and scripts dependencies were installed from their lockfiles with npm caches inside this worktree.
|
||||
|
||||
Exact `--check` output:
|
||||
## Gated items for Ben
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "check",
|
||||
"action": "skip",
|
||||
"reason": "owner-baseline-current",
|
||||
"mismatches": [],
|
||||
"refreshScriptAvailable": true,
|
||||
"scriptValidation": "not-run-in-check-mode",
|
||||
"applied": false
|
||||
}
|
||||
```
|
||||
- Run the real encrypted backup and isolated restore drill on an approved operations host before relying on the updated backup chain. Follow [the backup/restore runbook](docs/runbook-backup-restore.md).
|
||||
- Deployment and any production migration remain for the coordinator/Ben. No live database writes or service/container operations were performed here.
|
||||
|
||||
No refresh script invocation, `--apply`, service/container action, timer
|
||||
installation, or timer enablement was performed.
|
||||
## Workspace note
|
||||
|
||||
## Ben-gated next steps
|
||||
The temporary npm cache directory `.tmp-c5a/` remains untracked in this worktree. The cleanup command was rejected by the execution guard with: “rm -f style commands are not permitted. Use a safer approach”.
|
||||
|
||||
- Run the full no-apply refresh validation pass and review its result. This was
|
||||
not run here because the brief gates it to Ben; the script's validation
|
||||
requires starting a one-shot container and writes a candidate baseline.
|
||||
- After review, install and enable the user timer using the instructions in
|
||||
`INSTALL.md`. No unit was installed or enabled by this task.
|
||||
OpenViking and Tokensave tools were not exposed in this session, so shared-memory and semantic-index actions were unavailable.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Approved fields for the unauthenticated node status response contracts. */
|
||||
export type PublicStatusCounters = {
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
};
|
||||
|
||||
export type PublicLatestNodeStatusDTO = PublicStatusCounters & {
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
};
|
||||
|
||||
export type PublicMqttNodeStatusDTO = PublicStatusCounters & {
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
last_seen: string;
|
||||
packets_24h: string;
|
||||
};
|
||||
|
||||
type PublicStatusCounterSource = Partial<PublicStatusCounters>;
|
||||
|
||||
function publicStatusCounters(row: PublicStatusCounterSource): PublicStatusCounters {
|
||||
return {
|
||||
battery_mv: row.battery_mv ?? null,
|
||||
uptime_secs: row.uptime_secs ?? null,
|
||||
tx_air_secs: row.tx_air_secs ?? null,
|
||||
rx_air_secs: row.rx_air_secs ?? null,
|
||||
channel_utilization: row.channel_utilization ?? null,
|
||||
air_util_tx: row.air_util_tx ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicLatestNodeStatusDTO(
|
||||
row: PublicLatestNodeStatusDTO,
|
||||
): PublicLatestNodeStatusDTO {
|
||||
return {
|
||||
time: row.time,
|
||||
node_id: row.node_id,
|
||||
network: row.network,
|
||||
name: row.name,
|
||||
iata: row.iata,
|
||||
...publicStatusCounters(row),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicMqttNodeStatusDTO(
|
||||
row: PublicMqttNodeStatusDTO,
|
||||
): PublicMqttNodeStatusDTO {
|
||||
return {
|
||||
node_id: row.node_id,
|
||||
name: row.name,
|
||||
last_seen: row.last_seen,
|
||||
...publicStatusCounters(row),
|
||||
packets_24h: row.packets_24h,
|
||||
};
|
||||
}
|
||||
@@ -37,3 +37,64 @@ test('channel feed history route forwards its bounded channel and scope', async
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('public MQTT node status omits owner-only and unknown telemetry fields', async () => {
|
||||
const storedRow = {
|
||||
node_id: 'b'.repeat(64),
|
||||
name: 'Public MQTT node',
|
||||
last_seen: '2026-09-23T12:00:00.000Z',
|
||||
battery_mv: 3900,
|
||||
uptime_secs: 4200,
|
||||
channel_utilization: 8,
|
||||
air_util_tx: 4,
|
||||
rx_air_secs: 18,
|
||||
tx_air_secs: 9,
|
||||
packets_24h: '12',
|
||||
hardware_model: 'private model',
|
||||
firmware_version: 'private firmware',
|
||||
stats: {
|
||||
wifi_ssid: 'private wifi',
|
||||
reset_reason: 'private reset detail',
|
||||
fs_free_bytes: 1234,
|
||||
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
|
||||
future_owner_field: 'private future value',
|
||||
},
|
||||
future_status_field: 'private future value',
|
||||
};
|
||||
const router = Router();
|
||||
registerMiscRoutes(router, {
|
||||
query: async <T extends import('pg').QueryResultRow = import('pg').QueryResultRow>(text) => {
|
||||
assert.doesNotMatch(text, /nss\.stats/);
|
||||
return { rows: [storedRow as unknown as T] };
|
||||
},
|
||||
getRecentPackets: async () => [],
|
||||
getRecentPacketEvents: async () => [],
|
||||
getPacketDetail: async () => null,
|
||||
getChannelMessageHistory: async () => [],
|
||||
getPublicVisibilityGeneration: async () => 1,
|
||||
packetDetailLimiter: passThroughLimiter as Parameters<typeof registerMiscRoutes>[1]['packetDetailLimiter'],
|
||||
});
|
||||
const app = express();
|
||||
app.use(router);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/mqtt-nodes?network=ukmesh`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), [{
|
||||
node_id: storedRow.node_id,
|
||||
name: storedRow.name,
|
||||
last_seen: storedRow.last_seen,
|
||||
battery_mv: storedRow.battery_mv,
|
||||
uptime_secs: storedRow.uptime_secs,
|
||||
channel_utilization: storedRow.channel_utilization,
|
||||
air_util_tx: storedRow.air_util_tx,
|
||||
rx_air_secs: storedRow.rx_air_secs,
|
||||
tx_air_secs: storedRow.tx_air_secs,
|
||||
packets_24h: storedRow.packets_24h,
|
||||
}]);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
decodePlannedNodeCursor,
|
||||
listPublicPlannedNodes,
|
||||
} from '../../repositories/plannedNodes.js';
|
||||
import { toPublicMqttNodeStatusDTO } from '../publicStatus.js';
|
||||
|
||||
type QueryFn = <T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
@@ -218,7 +219,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
air_util_tx: number | null;
|
||||
rx_air_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
packets_24h: string;
|
||||
}>(
|
||||
|
||||
@@ -232,7 +232,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
nss.air_util_tx,
|
||||
nss.rx_air_secs,
|
||||
nss.tx_air_secs,
|
||||
nss.stats,
|
||||
COALESCE(pc.packet_count, 0) AS packets_24h
|
||||
FROM node_identity_status_samples nss
|
||||
LEFT JOIN node_identity_nodes n ON n.node_id = nss.node_id
|
||||
@@ -250,7 +249,9 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
ORDER BY nss.node_id, COALESCE(nss.uptime_secs, 0) DESC, nss.time DESC`,
|
||||
params,
|
||||
);
|
||||
res.json(result.rows.filter((r) => Number(r.packets_24h) > 0));
|
||||
res.json(result.rows
|
||||
.filter((r) => Number(r.packets_24h) > 0)
|
||||
.map(toPublicMqttNodeStatusDTO));
|
||||
} catch (err) {
|
||||
console.error('[api] GET /mqtt-nodes', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { toPublicLatestNodeStatusDTO } from '../publicStatus.js';
|
||||
|
||||
test('latest node status DTO omits owner-only and unknown telemetry fields', () => {
|
||||
const storedRow = {
|
||||
time: '2026-09-23T12:00:00.000Z',
|
||||
node_id: 'a'.repeat(64),
|
||||
network: 'ukmesh',
|
||||
name: 'Public node',
|
||||
iata: 'NCL',
|
||||
battery_mv: 3910,
|
||||
uptime_secs: 7200,
|
||||
tx_air_secs: 14,
|
||||
rx_air_secs: 28,
|
||||
channel_utilization: 7,
|
||||
air_util_tx: 3,
|
||||
hardware_model: 'private model',
|
||||
firmware_version: 'private firmware',
|
||||
stats: {
|
||||
wifi_ssid: 'private wifi',
|
||||
reset_reason: 'private reset detail',
|
||||
fs_free_bytes: 1234,
|
||||
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
|
||||
future_owner_field: 'private future value',
|
||||
},
|
||||
future_status_field: 'private future value',
|
||||
};
|
||||
|
||||
assert.deepEqual(toPublicLatestNodeStatusDTO(storedRow), {
|
||||
time: storedRow.time,
|
||||
node_id: storedRow.node_id,
|
||||
network: storedRow.network,
|
||||
name: storedRow.name,
|
||||
iata: storedRow.iata,
|
||||
battery_mv: storedRow.battery_mv,
|
||||
uptime_secs: storedRow.uptime_secs,
|
||||
tx_air_secs: storedRow.tx_air_secs,
|
||||
rx_air_secs: storedRow.rx_air_secs,
|
||||
channel_utilization: storedRow.channel_utilization,
|
||||
air_util_tx: storedRow.air_util_tx,
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
parseBoundedInteger,
|
||||
parseBoundedString,
|
||||
} from '../utils/input.js';
|
||||
import {
|
||||
toPublicLatestNodeStatusDTO,
|
||||
type PublicLatestNodeStatusDTO,
|
||||
} from '../publicStatus.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -26,28 +30,11 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
}
|
||||
|
||||
const identityWhereClause = `WHERE ${identityConditions.join(' AND ')}`;
|
||||
const result = await query<{
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
hardware_model: string | null;
|
||||
firmware_version: string | null;
|
||||
}>(
|
||||
const result = await query<PublicLatestNodeStatusDTO>(
|
||||
`WITH identity_sources AS MATERIALIZED (
|
||||
SELECT n.node_id AS canonical_id,
|
||||
n.name,
|
||||
n.iata,
|
||||
n.hardware_model,
|
||||
n.firmware_version,
|
||||
source.source_node_id
|
||||
FROM node_identity_nodes n
|
||||
CROSS JOIN LATERAL unnest(n.identity_source_ids) AS source(source_node_id)
|
||||
@@ -63,15 +50,12 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
nss.rx_air_secs,
|
||||
nss.channel_utilization,
|
||||
nss.air_util_tx,
|
||||
nss.stats,
|
||||
s.name,
|
||||
s.iata,
|
||||
s.hardware_model,
|
||||
s.firmware_version
|
||||
s.iata
|
||||
FROM identity_sources s
|
||||
JOIN LATERAL (
|
||||
SELECT time, network, battery_mv, uptime_secs, tx_air_secs,
|
||||
rx_air_secs, channel_utilization, air_util_tx, stats
|
||||
rx_air_secs, channel_utilization, air_util_tx
|
||||
FROM node_status_samples
|
||||
WHERE node_id = s.source_node_id
|
||||
AND network = ANY($1::text[])
|
||||
@@ -85,7 +69,7 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
params,
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
res.json(result.rows.map(toPublicLatestNodeStatusDTO));
|
||||
} catch (err) {
|
||||
console.error('[api] GET /node-status/latest', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
+17
-1
@@ -64,7 +64,23 @@ The service stores radio traffic observed on the MeshCore network.
|
||||
deletion procedure in the ops playbook (rows by src/rx node id across
|
||||
packets, packet_paths, decryptions, path facts).
|
||||
|
||||
## 3. Cookies
|
||||
## 3. Device status telemetry
|
||||
|
||||
Status samples retain a JSON telemetry object for the authenticated owner
|
||||
portal. The unauthenticated `/node-status/latest` and `/mqtt-nodes` APIs expose
|
||||
only their explicit public fields: node identity, sample/availability data,
|
||||
battery, uptime, airtime, channel utilization, and recent packet counts. They
|
||||
do not return the telemetry object or forward unrecognized fields. Wi-Fi
|
||||
identifiers, MQTT broker/account details, reset and configuration fields,
|
||||
firmware metadata, and filesystem details are available only through
|
||||
owner-authenticated endpoints. Unrecognized keys stay in storage until they
|
||||
are deliberately added to a response contract.
|
||||
|
||||
Existing status samples do not need a database rewrite: public responses are
|
||||
projected through the same field allowlist regardless of when the sample was
|
||||
stored.
|
||||
|
||||
## 4. Cookies
|
||||
|
||||
LocalStorage-only choices (cookie consent, theme). No third-party cookies.
|
||||
PECR does not apply to the mesh radio data (not a public electronic
|
||||
|
||||
@@ -59,6 +59,12 @@ export MESHCORE_INFRA_PROJECT_NAME=meshcore-infra
|
||||
scripts/backup.sh
|
||||
```
|
||||
|
||||
`MESHCORE_INFRA_COMPOSE_FILE` can select a non-default external Compose file;
|
||||
`MESHCORE_INFRA_PROJECT_DIR` can set its project directory explicitly. The
|
||||
shared resolver checks that Compose project and requires one running container
|
||||
for each exact `timescaledb`, `redis`, and `mosquitto` service. It does not
|
||||
search container names across projects.
|
||||
|
||||
For the returned receipt and archive:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -45,6 +45,8 @@ export COSIGN_CERTIFICATE_OIDC_ISSUER='https://token.actions.githubusercontent.c
|
||||
export RESTORE_RECEIPT_PATH=/secure/receipts/latest.json
|
||||
export RESTORE_RECEIPT_VERIFY_KEY=/secure/receipts/verify.pem
|
||||
export RELEASE_RECEIPT_SIGNING_KEY=/secure/release/receipt-signing.pem
|
||||
export MESHCORE_INFRA_DIR=/home/ben/ukmesh/meshcore-infra
|
||||
export MESHCORE_INFRA_PROJECT_NAME=meshcore-infra
|
||||
|
||||
scripts/replace-container.sh backend \
|
||||
--image="$BACKEND_IMAGE" \
|
||||
@@ -52,6 +54,13 @@ scripts/replace-container.sh backend \
|
||||
--source-revision="$SOURCE_REVISION"
|
||||
```
|
||||
|
||||
If the infrastructure Compose file has another path, set
|
||||
`MESHCORE_INFRA_COMPOSE_FILE` and, when needed,
|
||||
`MESHCORE_INFRA_PROJECT_DIR`. The release script validates that external
|
||||
project and resolves its single running `timescaledb` container before it
|
||||
starts the migration job; the post-migration schema check uses that exact
|
||||
container ID.
|
||||
|
||||
Repeat for each application service, passing its digest as `--image` and the
|
||||
same signed backend digest as `--backend-image`. The script verifies signatures
|
||||
and revision labels, runs migrations, starts the prior backend against the
|
||||
|
||||
+33
-19
@@ -5,8 +5,18 @@ umask 077
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
project_dir="$(cd -- "${script_dir}/.." && pwd)"
|
||||
app_project_name="${COMPOSE_PROJECT_NAME:-meshcore-analytics}"
|
||||
infra_project_dir="${MESHCORE_INFRA_DIR:-${project_dir}/../meshcore-infra}"
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
else
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-${project_dir}/../meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
fi
|
||||
infra_project_name="${MESHCORE_INFRA_PROJECT_NAME:-meshcore-infra}"
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
export MESHCORE_INFRA_PROJECT_NAME="$infra_project_name"
|
||||
source "$script_dir/lib/infra-compose.sh"
|
||||
output_dir="${BACKUP_OUTPUT_DIR:?BACKUP_OUTPUT_DIR must name an encrypted backup target}"
|
||||
encryption_cert="${BACKUP_ENCRYPTION_CERT:?BACKUP_ENCRYPTION_CERT must name a public X.509 certificate}"
|
||||
signing_key="${BACKUP_RECEIPT_SIGNING_KEY:?BACKUP_RECEIPT_SIGNING_KEY must name the offline receipt signing key}"
|
||||
@@ -29,8 +39,8 @@ for path in "$encryption_cert" "$signing_key"; do
|
||||
exit 66
|
||||
}
|
||||
done
|
||||
test -f "$infra_project_dir/docker-compose.yml" || {
|
||||
echo "meshcore-infra compose file is missing: $infra_project_dir/docker-compose.yml" >&2
|
||||
test -f "$infra_compose_file" || {
|
||||
echo "infrastructure Compose file is missing: $infra_compose_file" >&2
|
||||
exit 66
|
||||
}
|
||||
openssl x509 -in "$encryption_cert" -noout >/dev/null
|
||||
@@ -66,30 +76,27 @@ if (( (8#$env_mode & 8#077) != 0 )); then
|
||||
echo "protected runtime configuration .env must not be group/world accessible" >&2
|
||||
exit 66
|
||||
fi
|
||||
infra_env_file="$infra_project_dir/.env"
|
||||
if [ ! -f "$infra_env_file" ] || [ -L "$infra_env_file" ]; then
|
||||
echo "protected infrastructure configuration .env is missing or unsafe" >&2
|
||||
exit 66
|
||||
fi
|
||||
infra_env_mode="$(stat -c '%a' "$infra_env_file")"
|
||||
if (( (8#$infra_env_mode & 8#077) != 0 )); then
|
||||
echo "protected infrastructure configuration .env must not be group/world accessible" >&2
|
||||
exit 66
|
||||
fi
|
||||
app_compose=(
|
||||
docker compose --project-directory "$project_dir"
|
||||
-f "$project_dir/docker-compose.yml"
|
||||
-f "$project_dir/docker-compose.live.yml"
|
||||
--project-name "$app_project_name"
|
||||
)
|
||||
infra_compose=(
|
||||
docker compose --project-directory "$infra_project_dir"
|
||||
-f "$infra_project_dir/docker-compose.yml"
|
||||
--project-name "$infra_project_name"
|
||||
)
|
||||
"${app_compose[@]}" config -q
|
||||
"${infra_compose[@]}" config -q
|
||||
meshcore_infra_compose config -q
|
||||
declare -A service_container_ids=()
|
||||
for service in timescaledb redis mosquitto; do
|
||||
container_id="$("${infra_compose[@]}" ps -q "$service")"
|
||||
test -n "$container_id" || {
|
||||
echo "required service is not running: $service" >&2
|
||||
exit 69
|
||||
}
|
||||
if [ "$(docker inspect "$container_id" --format '{{.State.Status}}')" != "running" ]; then
|
||||
echo "required service is not running: $service" >&2
|
||||
exit 69
|
||||
fi
|
||||
container_id="$(resolve_infra_container "$service")"
|
||||
service_container_ids["$service"]="$container_id"
|
||||
done
|
||||
timescaledb_container="${service_container_ids[timescaledb]}"
|
||||
@@ -190,8 +197,15 @@ tar -C "$project_dir" -czf "$payload_dir/configuration.tgz" \
|
||||
logging \
|
||||
backend/src/db/migrations backend/src/db/schema backend/src/db/owner-auth.sql \
|
||||
scripts/backup.sh scripts/restore-drill.sh scripts/sync-latest.sh scripts/replace-container.sh \
|
||||
scripts/lib/infra-compose.sh scripts/unify-networks.sh scripts/relabel-packets-per-chunk.sh \
|
||||
scripts/bootstrap-mosquitto.sh vacuum-compressed-chunks.sh
|
||||
tar -C "$infra_project_dir" -czf "$payload_dir/infra-configuration.tgz" \
|
||||
infra_configuration_stage="$tmp_dir/infra-configuration"
|
||||
mkdir -p "$infra_configuration_stage"
|
||||
cp -- "$infra_project_dir/.env" "$infra_configuration_stage/.env"
|
||||
cp -- "$infra_compose_file" "$infra_configuration_stage/docker-compose.yml"
|
||||
cp -- "$infra_project_dir/README.md" "$infra_configuration_stage/README.md"
|
||||
cp -- "$infra_project_dir/ROLLBACK.md" "$infra_configuration_stage/ROLLBACK.md"
|
||||
tar -C "$infra_configuration_stage" -czf "$payload_dir/infra-configuration.tgz" \
|
||||
.env docker-compose.yml README.md ROLLBACK.md
|
||||
|
||||
completed_at="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
meshcore_infra_compose() {
|
||||
local project_name="${MESHCORE_INFRA_PROJECT_NAME:-meshcore-infra}"
|
||||
local project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-}}"
|
||||
local compose_file="${MESHCORE_INFRA_COMPOSE_FILE:-}"
|
||||
|
||||
if [ -z "$compose_file" ]; then
|
||||
if [ -z "$project_dir" ]; then
|
||||
echo 'MESHCORE_INFRA_PROJECT_DIR or MESHCORE_INFRA_COMPOSE_FILE is required' >&2
|
||||
return 64
|
||||
fi
|
||||
compose_file="$project_dir/docker-compose.yml"
|
||||
fi
|
||||
if [ -z "$project_dir" ]; then
|
||||
project_dir="$(dirname -- "$compose_file")"
|
||||
fi
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
echo "infrastructure Compose file is missing: $compose_file" >&2
|
||||
return 66
|
||||
fi
|
||||
|
||||
docker compose \
|
||||
--project-directory "$project_dir" \
|
||||
--project-name "$project_name" \
|
||||
-f "$compose_file" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
resolve_infra_container() {
|
||||
local service="${1:-}"
|
||||
case "$service" in
|
||||
timescaledb|redis|mosquitto) ;;
|
||||
*)
|
||||
echo "unsupported infrastructure service: ${service:-<empty>}" >&2
|
||||
return 64
|
||||
;;
|
||||
esac
|
||||
|
||||
local raw_ids
|
||||
if ! raw_ids="$(meshcore_infra_compose ps -q "$service")"; then
|
||||
echo "could not resolve infrastructure service: $service" >&2
|
||||
return 69
|
||||
fi
|
||||
|
||||
local -a ids=()
|
||||
local id
|
||||
while IFS= read -r id; do
|
||||
id="${id//$'\r'/}"
|
||||
if [ -n "${id//[[:space:]]/}" ]; then
|
||||
ids+=("$id")
|
||||
fi
|
||||
done <<<"$raw_ids"
|
||||
if [ "${#ids[@]}" -ne 1 ]; then
|
||||
echo "expected one infrastructure container for $service; found ${#ids[@]}" >&2
|
||||
return 69
|
||||
fi
|
||||
|
||||
local state
|
||||
if ! state="$(docker inspect --format '{{.State.Status}}' "${ids[0]}")"; then
|
||||
echo "could not inspect infrastructure container for $service" >&2
|
||||
return 69
|
||||
fi
|
||||
if [ "$state" != 'running' ]; then
|
||||
echo "infrastructure container for $service is not running" >&2
|
||||
return 69
|
||||
fi
|
||||
|
||||
printf '%s\n' "${ids[0]}"
|
||||
}
|
||||
+13
-2
@@ -3,11 +3,22 @@
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly REPO_DIR="${NEWUSER_REPO_DIR:-/home/ben/ukmesh/meshcore-analytics}"
|
||||
readonly ENV_FILE="${REPO_DIR}/.env"
|
||||
readonly MOSQUITTO_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E '^meshcore-infra-mosquitto-1$|mosquitto-1$' | head -1)"
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
else
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$REPO_DIR")/meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
fi
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
source "$SCRIPT_DIR/lib/infra-compose.sh"
|
||||
readonly MOSQUITTO_CONTAINER="$(resolve_infra_container mosquitto)"
|
||||
readonly BACKEND_CONTAINER='meshcore-analytics-backend-1'
|
||||
readonly TIMESCALEDB_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E '^meshcore-infra-timescaledb-1$|timescaledb-1$' | head -1)"
|
||||
readonly TIMESCALEDB_CONTAINER="$(resolve_infra_container timescaledb)"
|
||||
readonly OWNER_DATABASE='meshcore_owner_auth'
|
||||
readonly POSTGRES_USER='meshcore'
|
||||
readonly BROKER_URL='wss://mqtt.ukmesh.com:443'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"generate-key": "tsx generate-observer-key.ts",
|
||||
"register-observer": "tsx register-observer.ts",
|
||||
"test": "bash test-generate-observer-key.sh && bash test-newuser.sh",
|
||||
"test": "bash test-generate-observer-key.sh && bash test-infra-compose.sh && bash test-newuser.sh",
|
||||
"typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --types node generate-observer-key.ts register-observer.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,11 +3,25 @@ set -Eeuo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
project_dir="$(cd -- "$script_dir/.." && pwd)"
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
else
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-${project_dir}/../meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
fi
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
source "$script_dir/lib/infra-compose.sh"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date -Is)" "$*"
|
||||
}
|
||||
|
||||
PSQL=(docker compose exec -T timescaledb psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -v ON_ERROR_STOP=1 -X)
|
||||
timescaledb_container="$(resolve_infra_container timescaledb)"
|
||||
PSQL=(docker exec -i "$timescaledb_container" psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -v ON_ERROR_STOP=1 -X)
|
||||
# `test` remains an isolated network. This maintenance operation only folds
|
||||
# historical production labels into the unified UKMesh scope.
|
||||
LEGACY_SQL="'teesside','northeast'"
|
||||
|
||||
@@ -49,6 +49,16 @@ done
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
project_dir="$(cd -- "${script_dir}/.." && pwd)"
|
||||
project_name="${COMPOSE_PROJECT_NAME:-meshcore-analytics}"
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
else
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-${project_dir}/../meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
fi
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
source "$script_dir/lib/infra-compose.sh"
|
||||
release_dir="${RELEASE_STATUS_DIR:-/home/ben/meshcore-releases}"
|
||||
cosign_key="${COSIGN_PUBLIC_KEY:-}"
|
||||
cosign_identity_regexp="${COSIGN_CERTIFICATE_IDENTITY_REGEXP:-}"
|
||||
@@ -127,6 +137,8 @@ if ! docker compose --project-name "$project_name" config --services | grep -Fxq
|
||||
echo "unknown Compose service: $service" >&2
|
||||
exit 65
|
||||
fi
|
||||
meshcore_infra_compose config -q
|
||||
timescaledb_container="$(resolve_infra_container timescaledb)"
|
||||
if [ "$(git rev-parse HEAD)" != "$source_revision" ]; then
|
||||
echo "working checkout does not match the release source revision" >&2
|
||||
exit 65
|
||||
@@ -256,7 +268,7 @@ echo "Running the required migration job with the signed backend image..."
|
||||
BACKEND_IMAGE="$backend_image" \
|
||||
docker compose --project-name "$project_name" run --rm db-migrate
|
||||
schema_version="$(
|
||||
docker compose --project-name "$project_name" exec -T timescaledb \
|
||||
docker exec -i "$timescaledb_container" \
|
||||
psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -Atc \
|
||||
"SELECT COALESCE(MAX(((regexp_match(name, '^([0-9]+)_'))[1])::int), 0) FROM schema_migrations"
|
||||
)"
|
||||
|
||||
@@ -53,6 +53,8 @@ test "$actual_archive_sha" = "$expected_archive_sha" || {
|
||||
exit 65
|
||||
}
|
||||
|
||||
# This drill uses generated container names on a private network; all restore
|
||||
# and smoke commands target those disposable containers directly.
|
||||
run_id="meshcore-restore-$$"
|
||||
network_name="${run_id}-network"
|
||||
postgres_name="${run_id}-postgres"
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
test_root="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$test_root"' EXIT
|
||||
|
||||
mkdir -p "$test_root/bin" "$test_root/external-infra"
|
||||
cat >"$test_root/external-infra/compose.production.yml" <<'YAML'
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:fixture
|
||||
redis:
|
||||
image: redis:fixture
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:fixture
|
||||
YAML
|
||||
cat >"$test_root/bin/docker" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >>"$MOCK_DOCKER_LOG"
|
||||
|
||||
command_name="${1:-}"
|
||||
shift || true
|
||||
case "$command_name" in
|
||||
compose)
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--project-directory|--project-name|-f)
|
||||
shift 2
|
||||
;;
|
||||
config)
|
||||
exit 0
|
||||
;;
|
||||
ps)
|
||||
shift
|
||||
test "${1:-}" = '-q'
|
||||
service="${2:-}"
|
||||
if [ "${MOCK_PS_OUTPUT+x}" = x ]; then
|
||||
printf '%s\n' "$MOCK_PS_OUTPUT"
|
||||
else
|
||||
printf 'container-%s\n' "$service"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unexpected mock Compose command: $*" >&2
|
||||
exit 90
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
inspect)
|
||||
if [ "${MOCK_STATE:-running}" = 'running' ]; then
|
||||
printf 'running\n'
|
||||
else
|
||||
printf '%s\n' "$MOCK_STATE"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "unexpected mock Docker command: $command_name $*" >&2
|
||||
exit 91
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod 0755 "$test_root/bin/docker"
|
||||
|
||||
export PATH="$test_root/bin:$PATH"
|
||||
export MOCK_DOCKER_LOG="$test_root/docker.log"
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$test_root/external-infra"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$test_root/external-infra/compose.production.yml"
|
||||
export MESHCORE_INFRA_PROJECT_NAME='meshcore-infra-test'
|
||||
source "$script_dir/lib/infra-compose.sh"
|
||||
|
||||
meshcore_infra_compose config -q
|
||||
for service in timescaledb redis mosquitto; do
|
||||
resolved="$(resolve_infra_container "$service")"
|
||||
test "$resolved" = "container-$service"
|
||||
grep -Fq -- \
|
||||
"compose --project-directory $MESHCORE_INFRA_PROJECT_DIR --project-name $MESHCORE_INFRA_PROJECT_NAME -f $MESHCORE_INFRA_COMPOSE_FILE ps -q $service" \
|
||||
"$MOCK_DOCKER_LOG"
|
||||
done
|
||||
|
||||
unset MESHCORE_INFRA_PROJECT_DIR MESHCORE_INFRA_DIR
|
||||
test "$(resolve_infra_container timescaledb)" = 'container-timescaledb'
|
||||
grep -Fq -- \
|
||||
"compose --project-directory $(dirname -- "$MESHCORE_INFRA_COMPOSE_FILE") --project-name $MESHCORE_INFRA_PROJECT_NAME -f $MESHCORE_INFRA_COMPOSE_FILE ps -q timescaledb" \
|
||||
"$MOCK_DOCKER_LOG"
|
||||
|
||||
if MOCK_PS_OUTPUT=$'container-one\ncontainer-two' resolve_infra_container timescaledb >/dev/null 2>&1; then
|
||||
echo 'ambiguous service resolution unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
fi
|
||||
if MOCK_PS_OUTPUT='' resolve_infra_container redis >/dev/null 2>&1; then
|
||||
echo 'missing service resolution unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
fi
|
||||
if MOCK_STATE=exited resolve_infra_container mosquitto >/dev/null 2>&1; then
|
||||
echo 'stopped service resolution unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
fi
|
||||
if resolve_infra_container backend >/dev/null 2>&1; then
|
||||
echo 'unsupported service resolution unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'split infrastructure resolver dry-run tests passed\n'
|
||||
+13
-4
@@ -9,7 +9,8 @@ readonly NODE_C='C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C
|
||||
|
||||
test_root="$(mktemp -d)"
|
||||
trap 'rm -rf -- "$test_root"' EXIT
|
||||
mkdir -p "$test_root/bin" "$test_root/repo"
|
||||
mkdir -p "$test_root/bin" "$test_root/repo" "$test_root/infra"
|
||||
touch "$test_root/infra/docker-compose.yml"
|
||||
printf 'OWNER_MQTT_USERNAME_MAP=alice=%s,bob=%s\n' "$NODE_A" "$NODE_C" \
|
||||
>"$test_root/repo/.env"
|
||||
|
||||
@@ -20,9 +21,6 @@ set -Eeuo pipefail
|
||||
printf 'ARGS %s\n' "$*" >>"$FAKE_DOCKER_LOG"
|
||||
|
||||
case ${1:-} in
|
||||
ps)
|
||||
printf '%s\n' meshcore-infra-mosquitto-1 meshcore-infra-timescaledb-1
|
||||
;;
|
||||
inspect)
|
||||
if [[ $* == *State.Running* ]]; then
|
||||
printf 'true\n'
|
||||
@@ -31,6 +29,12 @@ case ${1:-} in
|
||||
fi
|
||||
;;
|
||||
compose)
|
||||
case "$*" in
|
||||
*'ps -q mosquitto') printf 'infra-mosquitto-id\n' ;;
|
||||
*'ps -q timescaledb') printf 'infra-timescaledb-id\n' ;;
|
||||
*' up -d --no-deps backend') ;;
|
||||
*) printf 'unexpected docker compose invocation: %s\n' "$*" >&2; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
exec)
|
||||
sql=''
|
||||
@@ -60,6 +64,7 @@ export FAKE_DOCKER_LOG="$test_root/docker.log"
|
||||
output="$test_root/output.log"
|
||||
PATH="$test_root/bin:$PATH" \
|
||||
NEWUSER_REPO_DIR="$test_root/repo" \
|
||||
MESHCORE_INFRA_PROJECT_DIR="$test_root/infra" \
|
||||
TMPDIR="$test_root" \
|
||||
bash "$SCRIPT_DIR/newuser.sh" --link alice "$NODE_B" >"$output" 2>&1
|
||||
|
||||
@@ -70,6 +75,10 @@ grep -Fq 'Password: unchanged (existing credential)' "$output"
|
||||
grep -Fq 'INSERT INTO owner_account_nodes' "$FAKE_DOCKER_LOG"
|
||||
grep -Fq 'compose -f docker-compose.yml -f docker-compose.live.yml up -d --no-deps backend' \
|
||||
"$FAKE_DOCKER_LOG"
|
||||
grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q mosquitto" \
|
||||
"$FAKE_DOCKER_LOG"
|
||||
grep -Fq "compose --project-directory $test_root/infra --project-name meshcore-infra -f $test_root/infra/docker-compose.yml ps -q timescaledb" \
|
||||
"$FAKE_DOCKER_LOG"
|
||||
if grep -Fq 'mosquitto_passwd -b' "$FAKE_DOCKER_LOG"; then
|
||||
printf 'link flow unexpectedly changed the existing credential\n' >&2
|
||||
exit 1
|
||||
|
||||
@@ -61,46 +61,64 @@ command_name="${1:-}"
|
||||
shift || true
|
||||
case "$command_name" in
|
||||
compose)
|
||||
if [ "${1:-}" = "--project-name" ]; then
|
||||
shift 2
|
||||
fi
|
||||
case "${1:-}" in
|
||||
config)
|
||||
if [ "${2:-}" = "--services" ]; then
|
||||
printf '%s\n' backend db-migrate timescaledb
|
||||
elif [ "${2:-}" != "-q" ]; then
|
||||
printf 'services: {}\n'
|
||||
fi
|
||||
;;
|
||||
ps)
|
||||
printf 'current-backend\n'
|
||||
;;
|
||||
run)
|
||||
test "${*: -1}" = "db-migrate"
|
||||
;;
|
||||
exec)
|
||||
if [[ " $* " == *" timescaledb "* ]]; then
|
||||
printf '30\n'
|
||||
elif [[ "$*" == *"/readyz"* ]]; then
|
||||
printf '{"status":"ready"}\n'
|
||||
elif [[ "$*" == *"/metrics"* ]]; then
|
||||
printf 'meshcore_process_start_time_seconds 1\n'
|
||||
else
|
||||
printf 'unexpected docker compose exec invocation: %s\n' "$*" >&2
|
||||
exit 91
|
||||
fi
|
||||
;;
|
||||
up)
|
||||
if [ "${MOCK_FAIL_DESIRED_DEPLOY:-false}" = "true" ] \
|
||||
&& [ "${BACKEND_IMAGE:-}" = "$MOCK_DESIRED_IMAGE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected docker compose invocation: %s\n' "$*" >&2
|
||||
exit 92
|
||||
;;
|
||||
esac
|
||||
project_name=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--project-name)
|
||||
project_name="$2"
|
||||
shift 2
|
||||
;;
|
||||
--project-directory|-f)
|
||||
shift 2
|
||||
;;
|
||||
config)
|
||||
if [ "${2:-}" = "--services" ]; then
|
||||
printf '%s\n' backend db-migrate
|
||||
elif [ "${2:-}" != "-q" ]; then
|
||||
printf 'services: {}\n'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
ps)
|
||||
shift
|
||||
test "${1:-}" = '-q'
|
||||
service="${2:-}"
|
||||
if [ "$project_name" = 'meshcore-infra' ]; then
|
||||
test "$service" = 'timescaledb'
|
||||
printf 'infra-timescaledb\n'
|
||||
else
|
||||
printf 'current-backend\n'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
test "${*: -1}" = "db-migrate"
|
||||
exit 0
|
||||
;;
|
||||
exec)
|
||||
if [[ "$*" == *"/readyz"* ]]; then
|
||||
printf '{"status":"ready"}\n'
|
||||
elif [[ "$*" == *"/metrics"* ]]; then
|
||||
printf 'meshcore_process_start_time_seconds 1\n'
|
||||
else
|
||||
printf 'unexpected docker compose exec invocation: %s\n' "$*" >&2
|
||||
exit 91
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
up)
|
||||
if [ "${MOCK_FAIL_DESIRED_DEPLOY:-false}" = "true" ] \
|
||||
&& [ "${BACKEND_IMAGE:-}" = "$MOCK_DESIRED_IMAGE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected docker compose invocation: %s\n' "$*" >&2
|
||||
exit 92
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
pull)
|
||||
;;
|
||||
@@ -124,6 +142,9 @@ case "$command_name" in
|
||||
*'.State.Running'*)
|
||||
printf 'true\n'
|
||||
;;
|
||||
*'.State.Status'*)
|
||||
printf 'running\n'
|
||||
;;
|
||||
*'.State.Health'*)
|
||||
printf 'healthy\n'
|
||||
;;
|
||||
@@ -137,6 +158,10 @@ case "$command_name" in
|
||||
printf 'compat-container-id\n'
|
||||
;;
|
||||
exec)
|
||||
if [[ "$*" == *"infra-timescaledb"*"psql"* ]]; then
|
||||
printf '30\n'
|
||||
exit 0
|
||||
fi
|
||||
if [ "${MOCK_COMPAT_READY:-true}" != "true" ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -178,12 +203,15 @@ run_case() {
|
||||
trap 'rm -rf -- "$test_root"' RETURN
|
||||
|
||||
mkdir -p \
|
||||
"${test_root}/project/scripts" \
|
||||
"${test_root}/project/scripts/lib" \
|
||||
"${test_root}/infra" \
|
||||
"${test_root}/fake-bin" \
|
||||
"${test_root}/releases"
|
||||
cp "$replace_script" "${test_root}/project/scripts/replace-container.sh"
|
||||
cp "${script_dir}/lib/infra-compose.sh" "${test_root}/project/scripts/lib/infra-compose.sh"
|
||||
chmod 0755 "${test_root}/project/scripts/replace-container.sh"
|
||||
printf 'services: {}\n' >"${test_root}/project/docker-compose.yml"
|
||||
printf 'services: {}\n' >"${test_root}/infra/docker-compose.yml"
|
||||
|
||||
local now
|
||||
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
@@ -228,6 +256,7 @@ run_case() {
|
||||
RESTORE_RECEIPT_PATH="${test_root}/restore-receipt.json" \
|
||||
RESTORE_RECEIPT_VERIFY_KEY="${test_root}/receipt-verify.pem" \
|
||||
RELEASE_STATUS_DIR="${test_root}/releases" \
|
||||
MESHCORE_INFRA_PROJECT_DIR="${test_root}/infra" \
|
||||
COMPATIBILITY_TIMEOUT_SECONDS=1 \
|
||||
"${test_root}/project/scripts/replace-container.sh" \
|
||||
backend \
|
||||
@@ -252,6 +281,12 @@ run_case() {
|
||||
test "$(jq -r '.status' "$release_status")" = "$expected_status"
|
||||
test "$(jq -r '.schema_version' "$release_status")" = "30"
|
||||
test "$(jq -r '.prior_image' "$release_status")" = "$prior_image"
|
||||
local resolver_line migration_line
|
||||
resolver_line="$(grep -nF 'ps -q timescaledb' "${test_root}/docker.log" | cut -d: -f1)"
|
||||
migration_line="$(grep -nF 'compose --project-name meshcore-analytics run --rm db-migrate' "${test_root}/docker.log" | cut -d: -f1)"
|
||||
test "$resolver_line" -lt "$migration_line"
|
||||
grep -Fq 'exec -i infra-timescaledb psql -U meshcore -d meshcore -Atc' \
|
||||
"${test_root}/docker.log"
|
||||
|
||||
local up_count
|
||||
up_count="$(
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
project_dir="$(cd -- "$script_dir/.." && pwd)"
|
||||
cd "$project_dir"
|
||||
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
else
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-${project_dir}/../meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
fi
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
source "$script_dir/lib/infra-compose.sh"
|
||||
|
||||
MODE="${1:-audit}"
|
||||
PSQL=(docker compose exec -T timescaledb psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -v ON_ERROR_STOP=1 -X)
|
||||
timescaledb_container="$(resolve_infra_container timescaledb)"
|
||||
PSQL=(docker exec -i "$timescaledb_container" psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -v ON_ERROR_STOP=1 -X)
|
||||
LEGACY_SQL="'teesside','northeast'"
|
||||
RUN_ID="${NETWORK_UNIFICATION_RUN_ID:-network-unification-$(date -u +%Y%m%dT%H%M%SZ)}"
|
||||
|
||||
|
||||
+19
-11
@@ -59,18 +59,26 @@ if [ -n "$chunk" ]; then
|
||||
fi
|
||||
|
||||
cd "$repo_dir"
|
||||
# Resolve the compose project that owns the timescaledb service. The test harness
|
||||
# drives the script with COMPOSE_FILE/COMPOSE_PROJECT_NAME set (its own fixture);
|
||||
# in the two-project production layout the database lives in the separate
|
||||
# meshcore-infra project (mirrors scripts/backup.sh).
|
||||
if [ -z "${COMPOSE_FILE:-}" ]; then
|
||||
infra_dir="${MESHCORE_INFRA_DIR:-${repo_dir}/../meshcore-infra}"
|
||||
compose_base=(docker compose --project-directory "$infra_dir" -f "$infra_dir/docker-compose.yml" --project-name "${MESHCORE_INFRA_PROJECT_NAME:-meshcore-infra}")
|
||||
if [ -n "${MESHCORE_INFRA_COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$MESHCORE_INFRA_COMPOSE_FILE"
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-$(dirname -- "$infra_compose_file")}}"
|
||||
infra_project_name="${MESHCORE_INFRA_PROJECT_NAME:-meshcore-infra}"
|
||||
elif [ -n "${COMPOSE_FILE:-}" ]; then
|
||||
infra_compose_file="$COMPOSE_FILE"
|
||||
infra_project_dir="$(dirname -- "$infra_compose_file")"
|
||||
infra_project_name="${COMPOSE_PROJECT_NAME:-meshcore-infra}"
|
||||
else
|
||||
compose_base=(docker compose)
|
||||
infra_project_dir="${MESHCORE_INFRA_PROJECT_DIR:-${MESHCORE_INFRA_DIR:-${repo_dir}/../meshcore-infra}}"
|
||||
infra_compose_file="$infra_project_dir/docker-compose.yml"
|
||||
infra_project_name="${MESHCORE_INFRA_PROJECT_NAME:-meshcore-infra}"
|
||||
fi
|
||||
"${compose_base[@]}" config -q
|
||||
psql_command=("${compose_base[@]}" exec -T timescaledb psql -X -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-meshcore}" -d "$database")
|
||||
export MESHCORE_INFRA_PROJECT_DIR="$infra_project_dir"
|
||||
export MESHCORE_INFRA_COMPOSE_FILE="$infra_compose_file"
|
||||
export MESHCORE_INFRA_PROJECT_NAME="$infra_project_name"
|
||||
source "$repo_dir/scripts/lib/infra-compose.sh"
|
||||
meshcore_infra_compose config -q
|
||||
timescaledb_container="$(resolve_infra_container timescaledb)"
|
||||
psql_command=(docker exec -i "$timescaledb_container" psql -X -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-meshcore}" -d "$database")
|
||||
|
||||
database_exists="$("${psql_command[@]}" -Atc \
|
||||
"SELECT 1 FROM pg_database WHERE datname = current_database() AND NOT datistemplate")"
|
||||
@@ -138,7 +146,7 @@ case "$action" in
|
||||
;;
|
||||
esac
|
||||
available_bytes="$(
|
||||
"${compose_base[@]}" exec -T timescaledb \
|
||||
docker exec "$timescaledb_container" \
|
||||
df -PB1 /var/lib/postgresql/data | awk 'NR==2 {print $4}'
|
||||
)"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user