842 lines
29 KiB
TypeScript
842 lines
29 KiB
TypeScript
import { storeJson } from './fileModels/storeJson'
|
|
import { i18n } from './i18n'
|
|
import { sdk } from './sdk'
|
|
import {
|
|
APP_USER,
|
|
BITCOIND_RPC_HOSTNAME,
|
|
BITCOIND_RPC_PORT,
|
|
dataDir,
|
|
EXPLORER_BUILD_DIR,
|
|
EXPLORER_BUILD_ID,
|
|
EXPLORER_DIR,
|
|
EXPLORER_MARKER,
|
|
EXPLORER_NETWORK,
|
|
EXPLORER_PORT,
|
|
EXPLORER_TARBALL_URL,
|
|
INDEXER_ACTIVATION_HEIGHT,
|
|
INDEXER_BIN_DIR,
|
|
INDEXER_BUILD_ID,
|
|
INDEXER_DIR,
|
|
INDEXER_FAST_SYNC_HEIGHT,
|
|
INDEXER_GOOSE_BIN,
|
|
INDEXER_MARKER,
|
|
INDEXER_MEMPOOL_CHUNK_SIZE,
|
|
INDEXER_SCHEMA_DIR,
|
|
INDEXER_SYNC_BIN,
|
|
INDEXER_TARBALL_URL,
|
|
INDEXER_UPDATE_INTERVAL,
|
|
pgDataDir,
|
|
POSTGRES_DB,
|
|
POSTGRES_PORT,
|
|
POSTGRES_USER,
|
|
SPACED_CHAIN,
|
|
spacedRpcPort,
|
|
uiPort,
|
|
} from './utils'
|
|
|
|
export const main = sdk.setupMain(async ({ effects }) => {
|
|
console.info(i18n('Starting Spaces!'))
|
|
|
|
const store = await storeJson.read().const(effects)
|
|
if (
|
|
!store?.password ||
|
|
!store?.btcAuth ||
|
|
!store?.dbAuth ||
|
|
!store?.spacedAuth
|
|
) {
|
|
// taskSetPassword + taskBtcAuth + taskSeedDb + taskSeedSpacedAuth all seed
|
|
// these in init; if they aren't populated yet, init hasn't finished — let
|
|
// StartOS restart us.
|
|
throw new Error('Spaces store.json is not yet populated.')
|
|
}
|
|
|
|
const { password: APP_PASSWORD, btcAuth, dbAuth, spacedAuth } = store
|
|
const enableExplorer = store.enableExplorer === true
|
|
|
|
const spacedEnv = {
|
|
SPACED_CHAIN,
|
|
SPACED_DATA_DIR: dataDir,
|
|
SPACED_RPC_BIND: '127.0.0.1',
|
|
SPACED_RPC_PORT: String(spacedRpcPort),
|
|
SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`,
|
|
SPACED_BLOCK_INDEX: 'true',
|
|
SPACED_BITCOIN_RPC_URL: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`,
|
|
SPACED_BITCOIN_RPC_USER: btcAuth.username,
|
|
SPACED_BITCOIN_RPC_PASSWORD: btcAuth.password,
|
|
SPACED_RPC_USER: spacedAuth.username,
|
|
SPACED_RPC_PASSWORD: spacedAuth.password,
|
|
// legacy aliases for `bitcoin-cli` / shell helpers that read these names
|
|
BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME,
|
|
BTC_RPC_PORT: String(BITCOIND_RPC_PORT),
|
|
BTC_RPC_USER: btcAuth.username,
|
|
BTC_RPC_PASSWORD: btcAuth.password,
|
|
APP_USER,
|
|
APP_PASSWORD,
|
|
DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`,
|
|
}
|
|
|
|
const mounts = sdk.Mounts.of().mountVolume({
|
|
volumeId: 'main',
|
|
subpath: null,
|
|
mountpoint: dataDir,
|
|
readonly: false,
|
|
})
|
|
|
|
const spacedSub = await sdk.SubContainer.of(
|
|
effects,
|
|
{ imageId: 'spaces' },
|
|
mounts,
|
|
'spaced-sub',
|
|
)
|
|
|
|
const termSub = await sdk.SubContainer.of(
|
|
effects,
|
|
{ imageId: 'spaces' },
|
|
mounts,
|
|
'terminal-sub',
|
|
)
|
|
|
|
const postgresSub = await sdk.SubContainer.of(
|
|
effects,
|
|
{ imageId: 'postgres' },
|
|
mounts,
|
|
'postgres-sub',
|
|
)
|
|
|
|
const postgresEnv = {
|
|
POSTGRES_USER: dbAuth.username,
|
|
POSTGRES_PASSWORD: dbAuth.password,
|
|
POSTGRES_DB: dbAuth.database,
|
|
PGDATA: pgDataDir,
|
|
}
|
|
|
|
const indexerSub = await sdk.SubContainer.of(
|
|
effects,
|
|
{ imageId: 'indexer-go' },
|
|
mounts,
|
|
'indexer-sub',
|
|
)
|
|
|
|
const explorerSub = await sdk.SubContainer.of(
|
|
effects,
|
|
{ imageId: 'explorer-ui' },
|
|
mounts,
|
|
'explorer-sub',
|
|
)
|
|
|
|
const explorerEnv = {
|
|
DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}?sslmode=disable`,
|
|
PUBLIC_BTC_NETWORK: EXPLORER_NETWORK,
|
|
PORT: String(EXPLORER_PORT),
|
|
HOME: '/root',
|
|
}
|
|
|
|
const postgresUri = `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}?sslmode=disable`
|
|
|
|
const indexerEnv = {
|
|
POSTGRES_URI: postgresUri,
|
|
BITCOIN_NODE_URI: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`,
|
|
BITCOIN_NODE_USER: btcAuth.username,
|
|
BITCOIN_NODE_PASSWORD: btcAuth.password,
|
|
SPACES_NODE_URI: `http://127.0.0.1:${spacedRpcPort}`,
|
|
RPC_USER: spacedAuth.username,
|
|
RPC_PASSWORD: spacedAuth.password,
|
|
ACTIVATION_BLOCK_HEIGHT: INDEXER_ACTIVATION_HEIGHT,
|
|
FAST_SYNC_BLOCK_HEIGHT: INDEXER_FAST_SYNC_HEIGHT,
|
|
UPDATE_DB_INTERVAL: INDEXER_UPDATE_INTERVAL,
|
|
MEMPOOL_CHUNK_SIZE: INDEXER_MEMPOOL_CHUNK_SIZE,
|
|
PATH: `${INDEXER_BIN_DIR}:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin`,
|
|
HOME: '/root',
|
|
GOPATH: '/root/go',
|
|
GOBIN: INDEXER_BIN_DIR,
|
|
}
|
|
|
|
const bashrc = [
|
|
'export PATH=/root/.cargo/bin:/data/bin:/usr/local/bin:/usr/bin:/bin',
|
|
"export PS1='spaces:\\w$ '",
|
|
`alias spaces='space-cli --chain ${SPACED_CHAIN} --rpc-user "$SPACED_RPC_USER" --rpc-password "$SPACED_RPC_PASSWORD" '`,
|
|
'cat <<EOF',
|
|
'',
|
|
'┌─ Spaces ─────────────────────────────────────────────────┐',
|
|
'│ spaced is managed by StartOS — do not run it manually. │',
|
|
'│ Use the `spaces` alias to call space-cli, e.g. │',
|
|
'│ spaces getserverinfo │',
|
|
'│ Docs: https://docs.spacesprotocol.org/ │',
|
|
'└──────────────────────────────────────────────────────────┘',
|
|
'',
|
|
'EOF',
|
|
].join('\n')
|
|
|
|
if (!enableExplorer) {
|
|
// Spaces-only mode: spaced + gotty terminal. No PostgreSQL, no indexer.
|
|
// User can flip the toggle via the Enable Embedded Explorer action;
|
|
// store.json.enableExplorer is read via .const() so the merge triggers
|
|
// an automatic service restart and the full chain takes over.
|
|
return sdk.Daemons.of(effects)
|
|
.addOneshot('bashrc', {
|
|
subcontainer: termSub,
|
|
exec: {
|
|
command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF'
|
|
${bashrc}
|
|
SPACES_BASHRC_EOF`],
|
|
user: 'root',
|
|
},
|
|
requires: [],
|
|
})
|
|
.addDaemon('spaced', {
|
|
subcontainer: spacedSub,
|
|
exec: {
|
|
command: ['/root/.cargo/bin/spaced'],
|
|
env: spacedEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Spaced RPC'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, spacedRpcPort, {
|
|
successMessage: i18n('spaced RPC is ready'),
|
|
errorMessage: i18n('spaced RPC is not ready'),
|
|
}),
|
|
gracePeriod: 120_000,
|
|
},
|
|
requires: [],
|
|
})
|
|
.addDaemon('web-terminal', {
|
|
subcontainer: termSub,
|
|
exec: {
|
|
command: [
|
|
'gotty',
|
|
'--port',
|
|
String(uiPort),
|
|
'-c',
|
|
`${APP_USER}:${APP_PASSWORD}`,
|
|
'--permit-write',
|
|
'--reconnect',
|
|
'/bin/bash',
|
|
],
|
|
env: spacedEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Web Interface'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, uiPort, {
|
|
successMessage: i18n('The web terminal is ready'),
|
|
errorMessage: i18n('The web terminal is not ready'),
|
|
}),
|
|
},
|
|
requires: ['bashrc'],
|
|
})
|
|
.addHealthCheck('sync', {
|
|
ready: {
|
|
display: i18n('Spaced Sync'),
|
|
fn: async () => {
|
|
try {
|
|
const probe = await spacedSub.exec(
|
|
[
|
|
'/root/.cargo/bin/space-cli',
|
|
'--chain',
|
|
SPACED_CHAIN,
|
|
'--rpc-user',
|
|
spacedAuth.username,
|
|
'--rpc-password',
|
|
spacedAuth.password,
|
|
'--output-format',
|
|
'json',
|
|
'getserverinfo',
|
|
],
|
|
{},
|
|
)
|
|
const stderr = (probe.stderr ?? '').toString().trim()
|
|
const stdoutText = (probe.stdout ?? '').toString()
|
|
const stdoutTrimmed = stdoutText.trim()
|
|
if (probe.exitCode !== 0) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n('space-cli exited ${code}: ${error}', {
|
|
code: String(probe.exitCode),
|
|
error: (stderr || stdoutTrimmed || '<no output>').slice(0, 200),
|
|
}),
|
|
}
|
|
}
|
|
let parsed: {
|
|
ready?: boolean
|
|
progress?: number
|
|
chain?: { blocks?: number; headers?: number }
|
|
}
|
|
try {
|
|
parsed = JSON.parse(stdoutText)
|
|
} catch {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n(
|
|
'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}',
|
|
{
|
|
stdout: (stdoutTrimmed || '<empty>').slice(0, 160),
|
|
stderr: (stderr || '<empty>').slice(0, 160),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
const progress = Math.min(
|
|
100,
|
|
Math.max(0, Math.round((parsed.progress ?? 0) * 100)),
|
|
)
|
|
const blocks = parsed.chain?.blocks ?? 0
|
|
const headers = parsed.chain?.headers ?? 0
|
|
if (parsed.ready === true && progress >= 100) {
|
|
return {
|
|
result: 'success',
|
|
message: i18n(
|
|
'spaced is fully synced (blocks ${blocks} / headers ${headers}).',
|
|
{ blocks: String(blocks), headers: String(headers) },
|
|
),
|
|
}
|
|
}
|
|
return {
|
|
result: 'loading',
|
|
message: i18n(
|
|
'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).',
|
|
{
|
|
pct: String(progress),
|
|
blocks: String(blocks),
|
|
headers: String(headers),
|
|
},
|
|
),
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n('Spaced Sync health check crashed: ${error}', {
|
|
error: (e as Error)?.message ?? String(e),
|
|
}),
|
|
}
|
|
}
|
|
},
|
|
gracePeriod: 30_000,
|
|
trigger: sdk.trigger.cooldownTrigger(30_000),
|
|
},
|
|
requires: ['spaced'],
|
|
})
|
|
}
|
|
|
|
// enableExplorer = true: full chain — spaced + terminal + embedded
|
|
// PostgreSQL + Go indexer + indexer-sync health check.
|
|
return sdk.Daemons.of(effects)
|
|
.addOneshot('bashrc', {
|
|
subcontainer: termSub,
|
|
exec: {
|
|
command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF'
|
|
${bashrc}
|
|
SPACES_BASHRC_EOF`],
|
|
user: 'root',
|
|
},
|
|
requires: [],
|
|
})
|
|
.addOneshot('postgres-chown', {
|
|
subcontainer: postgresSub,
|
|
exec: {
|
|
command: [
|
|
'bash',
|
|
'-c',
|
|
`mkdir -p ${pgDataDir} && chown -R postgres:postgres ${pgDataDir} && chmod 700 ${pgDataDir}`,
|
|
],
|
|
user: 'root',
|
|
},
|
|
requires: [],
|
|
})
|
|
.addDaemon('postgres', {
|
|
subcontainer: postgresSub,
|
|
exec: {
|
|
command: [
|
|
'docker-entrypoint.sh',
|
|
'postgres',
|
|
'-c',
|
|
'listen_addresses=127.0.0.1',
|
|
],
|
|
env: postgresEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Database'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, POSTGRES_PORT, {
|
|
successMessage: i18n('postgres is ready'),
|
|
errorMessage: i18n('postgres is not ready'),
|
|
}),
|
|
gracePeriod: 60_000,
|
|
},
|
|
requires: ['postgres-chown'],
|
|
})
|
|
.addDaemon('spaced', {
|
|
subcontainer: spacedSub,
|
|
exec: {
|
|
command: ['/root/.cargo/bin/spaced'],
|
|
env: spacedEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Spaced RPC'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, spacedRpcPort, {
|
|
successMessage: i18n('spaced RPC is ready'),
|
|
errorMessage: i18n('spaced RPC is not ready'),
|
|
}),
|
|
gracePeriod: 120_000,
|
|
},
|
|
requires: [],
|
|
})
|
|
.addOneshot('indexer-fetch', {
|
|
subcontainer: indexerSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
echo "indexer-fetch: image diagnostics..."; \
|
|
cat /etc/os-release 2>/dev/null | head -3 || echo "no /etc/os-release"; \
|
|
echo " go: $(go version 2>/dev/null || echo MISSING)"; \
|
|
echo " git: $(git --version 2>/dev/null || echo MISSING)"; \
|
|
echo " curl: $(curl --version 2>/dev/null | head -1 || echo MISSING)"; \
|
|
echo " wget: $(wget --version 2>/dev/null | head -1 || echo MISSING)"; \
|
|
echo " tar: $(tar --version 2>/dev/null | head -1 || echo MISSING)"; \
|
|
mkdir -p ${INDEXER_DIR} ${INDEXER_BIN_DIR}; \
|
|
if [ -f ${INDEXER_MARKER} ] && [ "$(cat ${INDEXER_MARKER})" = "${INDEXER_BUILD_ID}" ] && [ -x ${INDEXER_SYNC_BIN} ] && [ -x ${INDEXER_GOOSE_BIN} ]; then \
|
|
echo "indexer-fetch: ${INDEXER_BUILD_ID} already present, skipping."; \
|
|
exit 0; \
|
|
fi; \
|
|
echo "indexer-fetch: downloading ${INDEXER_BUILD_ID}..."; \
|
|
rm -rf ${INDEXER_DIR}; \
|
|
mkdir -p ${INDEXER_DIR} ${INDEXER_BIN_DIR}; \
|
|
if command -v curl >/dev/null 2>&1; then \
|
|
curl -fsSL '${INDEXER_TARBALL_URL}' -o /tmp/explorer-indexer.tar.gz; \
|
|
elif command -v wget >/dev/null 2>&1; then \
|
|
wget -q -O /tmp/explorer-indexer.tar.gz '${INDEXER_TARBALL_URL}'; \
|
|
else \
|
|
echo "indexer-fetch: ERROR no curl or wget available"; exit 1; \
|
|
fi; \
|
|
tar -xzf /tmp/explorer-indexer.tar.gz --strip-components=1 -C ${INDEXER_DIR}; \
|
|
rm -f /tmp/explorer-indexer.tar.gz; \
|
|
echo "indexer-fetch: patching types.go to make ptrs_root optional (older spaced compatibility)..."; \
|
|
awk 'BEGIN{patched=0} /if aux.PointersRoot == nil \\{/ {skip=2; patched++; next} skip>0 {skip--; next} /ra\\.PointersRoot = \\*aux\\.PointersRoot/ {print "\\tif aux.PointersRoot != nil { ra.PointersRoot = *aux.PointersRoot }"; next} {print} END{if(patched<2){print "PATCH-FAIL: expected 2 ptrs_root blocks, got " patched > "/dev/stderr"; exit 1}}' ${INDEXER_DIR}/pkg/node/types.go > ${INDEXER_DIR}/pkg/node/types.go.new; \
|
|
mv ${INDEXER_DIR}/pkg/node/types.go.new ${INDEXER_DIR}/pkg/node/types.go; \
|
|
if grep -q 'missing required field: ptrs_root' ${INDEXER_DIR}/pkg/node/types.go; then \
|
|
echo "indexer-fetch: ERROR ptrs_root check still present after patch"; exit 1; \
|
|
fi; \
|
|
echo "indexer-fetch: patching store.go to skip getptrblockmeta (older spaced has no pointer-block RPC)..."; \
|
|
awk 'BEGIN{patched=0} /spacesPtrBlock, err := sc.GetPtrBlockMeta/ {skip=8; patched++; next} skip>0 {skip--; next} {print} END{if(patched<1){print "PATCH-FAIL: expected GetPtrBlockMeta block, got " patched > "/dev/stderr"; exit 1}}' ${INDEXER_DIR}/pkg/store/store.go > ${INDEXER_DIR}/pkg/store/store.go.new; \
|
|
mv ${INDEXER_DIR}/pkg/store/store.go.new ${INDEXER_DIR}/pkg/store/store.go; \
|
|
if grep -q 'GetPtrBlockMeta' ${INDEXER_DIR}/pkg/store/store.go; then \
|
|
echo "indexer-fetch: ERROR GetPtrBlockMeta call still present after patch"; exit 1; \
|
|
fi; \
|
|
echo "${INDEXER_BUILD_ID}" > ${INDEXER_MARKER}; \
|
|
echo "indexer-fetch: done."`,
|
|
],
|
|
user: 'root',
|
|
},
|
|
requires: [],
|
|
})
|
|
.addOneshot('indexer-build', {
|
|
subcontainer: indexerSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
if [ -x ${INDEXER_SYNC_BIN} ] && [ -x ${INDEXER_GOOSE_BIN} ]; then \
|
|
echo "indexer-build: sync + goose already built, skipping."; \
|
|
exit 0; \
|
|
fi; \
|
|
cd ${INDEXER_DIR}; \
|
|
mkdir -p ${INDEXER_BIN_DIR}; \
|
|
echo "indexer-build: building sync binary..."; \
|
|
CGO_ENABLED=0 go build -o ${INDEXER_SYNC_BIN} ./cmd/sync; \
|
|
echo "indexer-build: installing goose..."; \
|
|
CGO_ENABLED=0 GOBIN=${INDEXER_BIN_DIR} go install github.com/pressly/goose/v3/cmd/goose@v3.24.3; \
|
|
ls -l ${INDEXER_BIN_DIR}; \
|
|
echo "indexer-build: done."`,
|
|
],
|
|
env: indexerEnv,
|
|
user: 'root',
|
|
},
|
|
requires: ['indexer-fetch'],
|
|
})
|
|
.addOneshot('indexer-cleanup-legacy', {
|
|
subcontainer: postgresSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
PSQL="psql -U $POSTGRES_USER -h 127.0.0.1 -p 5432 -d $POSTGRES_DB -tA"; \
|
|
HAS_LEGACY=$($PSQL -c "SELECT (to_regclass('public.block_stats') IS NOT NULL) OR (to_regclass('public.spaces_history') IS NOT NULL)" 2>/dev/null || echo f); \
|
|
HAS_GOOSE=$($PSQL -c "SELECT to_regclass('public.goose_db_version') IS NOT NULL" 2>/dev/null || echo f); \
|
|
if [ "$HAS_GOOSE" != "t" ] && [ "$HAS_LEGACY" = "t" ]; then \
|
|
echo "indexer-cleanup-legacy: dropping leftover TS indexer schema..."; \
|
|
$PSQL -c "DROP TABLE IF EXISTS block_stats, syncs, spaces_history, spaces, transactions, blocks CASCADE; DROP EXTENSION IF EXISTS pg_trgm;"; \
|
|
echo "indexer-cleanup-legacy: done."; \
|
|
else \
|
|
echo "indexer-cleanup-legacy: nothing to clean (HAS_GOOSE=$HAS_GOOSE HAS_LEGACY=$HAS_LEGACY)."; \
|
|
fi`,
|
|
],
|
|
env: {
|
|
...postgresEnv,
|
|
PGPASSWORD: dbAuth.password,
|
|
},
|
|
user: 'postgres',
|
|
},
|
|
requires: ['postgres'],
|
|
})
|
|
.addOneshot('indexer-migrate', {
|
|
subcontainer: indexerSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
echo "indexer-migrate: applying goose migrations..."; \
|
|
${INDEXER_GOOSE_BIN} -dir ${INDEXER_SCHEMA_DIR} postgres "$POSTGRES_URI" up; \
|
|
echo "indexer-migrate: done."`,
|
|
],
|
|
env: indexerEnv,
|
|
user: 'root',
|
|
},
|
|
requires: ['indexer-build', 'postgres', 'indexer-cleanup-legacy'],
|
|
})
|
|
.addDaemon('indexer', {
|
|
subcontainer: indexerSub,
|
|
exec: {
|
|
command: [INDEXER_SYNC_BIN],
|
|
env: indexerEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Indexer Process'),
|
|
fn: () => ({
|
|
result: 'success',
|
|
message: i18n('indexer process is running'),
|
|
}),
|
|
gracePeriod: 60_000,
|
|
},
|
|
requires: ['indexer-migrate', 'postgres', 'spaced'],
|
|
})
|
|
.addOneshot('explorer-fetch', {
|
|
subcontainer: explorerSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
mkdir -p ${EXPLORER_DIR}; \
|
|
if [ -f ${EXPLORER_MARKER} ] && [ "$(cat ${EXPLORER_MARKER})" = "${EXPLORER_BUILD_ID}" ] && [ -d ${EXPLORER_BUILD_DIR} ]; then \
|
|
echo "explorer-fetch: ${EXPLORER_BUILD_ID} already present, skipping."; \
|
|
exit 0; \
|
|
fi; \
|
|
echo "explorer-fetch: downloading ${EXPLORER_BUILD_ID}..."; \
|
|
rm -rf ${EXPLORER_DIR}; \
|
|
mkdir -p ${EXPLORER_DIR}; \
|
|
if command -v curl >/dev/null 2>&1; then \
|
|
curl -fsSL '${EXPLORER_TARBALL_URL}' -o /tmp/explorer-ui.tar.gz; \
|
|
elif command -v wget >/dev/null 2>&1; then \
|
|
wget -q -O /tmp/explorer-ui.tar.gz '${EXPLORER_TARBALL_URL}'; \
|
|
else \
|
|
echo "explorer-fetch: ERROR no curl or wget"; exit 1; \
|
|
fi; \
|
|
tar -xzf /tmp/explorer-ui.tar.gz --strip-components=1 -C ${EXPLORER_DIR}; \
|
|
rm -f /tmp/explorer-ui.tar.gz; \
|
|
echo "${EXPLORER_BUILD_ID}" > ${EXPLORER_MARKER}; \
|
|
echo "explorer-fetch: done."`,
|
|
],
|
|
user: 'root',
|
|
},
|
|
requires: [],
|
|
})
|
|
.addOneshot('explorer-install', {
|
|
subcontainer: explorerSub,
|
|
exec: {
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`set -eu; \
|
|
cd ${EXPLORER_DIR}; \
|
|
if [ -d node_modules ] && [ -d build ] && [ -f build/index.js ]; then \
|
|
echo "explorer-install: already built, skipping."; \
|
|
exit 0; \
|
|
fi; \
|
|
echo "explorer-install: npm install (this may take a few minutes)..."; \
|
|
npm install --no-audit --no-fund; \
|
|
echo "explorer-install: building (PUBLIC_BTC_NETWORK=${EXPLORER_NETWORK})..."; \
|
|
PUBLIC_BTC_NETWORK=${EXPLORER_NETWORK} npm run build; \
|
|
if [ ! -f ${EXPLORER_BUILD_DIR}/index.js ]; then \
|
|
echo "explorer-install: ERROR build/index.js not produced"; \
|
|
ls -la ${EXPLORER_BUILD_DIR} || true; \
|
|
exit 1; \
|
|
fi; \
|
|
echo "explorer-install: done."`,
|
|
],
|
|
env: explorerEnv,
|
|
user: 'root',
|
|
},
|
|
requires: ['explorer-fetch'],
|
|
})
|
|
.addDaemon('explorer-ui', {
|
|
subcontainer: explorerSub,
|
|
exec: {
|
|
command: ['node', EXPLORER_BUILD_DIR],
|
|
env: explorerEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Explorer Web UI'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, EXPLORER_PORT, {
|
|
successMessage: i18n('explorer UI is ready'),
|
|
errorMessage: i18n('explorer UI is not ready'),
|
|
}),
|
|
gracePeriod: 60_000,
|
|
},
|
|
requires: ['explorer-install', 'postgres'],
|
|
})
|
|
.addDaemon('web-terminal', {
|
|
subcontainer: termSub,
|
|
exec: {
|
|
command: [
|
|
'gotty',
|
|
'--port',
|
|
String(uiPort),
|
|
'-c',
|
|
`${APP_USER}:${APP_PASSWORD}`,
|
|
'--permit-write',
|
|
'--reconnect',
|
|
'/bin/bash',
|
|
],
|
|
env: spacedEnv,
|
|
},
|
|
ready: {
|
|
display: i18n('Web Interface'),
|
|
fn: () =>
|
|
sdk.healthCheck.checkPortListening(effects, uiPort, {
|
|
successMessage: i18n('The web terminal is ready'),
|
|
errorMessage: i18n('The web terminal is not ready'),
|
|
}),
|
|
},
|
|
requires: ['bashrc'],
|
|
})
|
|
.addHealthCheck('sync', {
|
|
ready: {
|
|
display: i18n('Spaced Sync'),
|
|
fn: async () => {
|
|
try {
|
|
const probe = await spacedSub.exec(
|
|
[
|
|
'/root/.cargo/bin/space-cli',
|
|
'--chain',
|
|
SPACED_CHAIN,
|
|
'--rpc-user',
|
|
spacedAuth.username,
|
|
'--rpc-password',
|
|
spacedAuth.password,
|
|
'--output-format',
|
|
'json',
|
|
'getserverinfo',
|
|
],
|
|
{},
|
|
)
|
|
|
|
const stderr = (probe.stderr ?? '').toString().trim()
|
|
const stdoutText = (probe.stdout ?? '').toString()
|
|
const stdoutTrimmed = stdoutText.trim()
|
|
|
|
if (probe.exitCode !== 0) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n('space-cli exited ${code}: ${error}', {
|
|
code: String(probe.exitCode),
|
|
error: (stderr || stdoutTrimmed || '<no output>').slice(0, 200),
|
|
}),
|
|
}
|
|
}
|
|
|
|
let parsed: {
|
|
ready?: boolean
|
|
progress?: number
|
|
chain?: { blocks?: number; headers?: number }
|
|
}
|
|
try {
|
|
parsed = JSON.parse(stdoutText)
|
|
} catch {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n(
|
|
'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}',
|
|
{
|
|
stdout: (stdoutTrimmed || '<empty>').slice(0, 160),
|
|
stderr: (stderr || '<empty>').slice(0, 160),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
const progress = Math.min(
|
|
100,
|
|
Math.max(0, Math.round((parsed.progress ?? 0) * 100)),
|
|
)
|
|
const blocks = parsed.chain?.blocks ?? 0
|
|
const headers = parsed.chain?.headers ?? 0
|
|
|
|
if (parsed.ready === true && progress >= 100) {
|
|
return {
|
|
result: 'success',
|
|
message: i18n(
|
|
'spaced is fully synced (blocks ${blocks} / headers ${headers}).',
|
|
{ blocks: String(blocks), headers: String(headers) },
|
|
),
|
|
}
|
|
}
|
|
|
|
return {
|
|
result: 'loading',
|
|
message: i18n(
|
|
'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).',
|
|
{
|
|
pct: String(progress),
|
|
blocks: String(blocks),
|
|
headers: String(headers),
|
|
},
|
|
),
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n(
|
|
'Spaced Sync health check crashed: ${error}',
|
|
{ error: (e as Error)?.message ?? String(e) },
|
|
),
|
|
}
|
|
}
|
|
},
|
|
gracePeriod: 30_000,
|
|
trigger: sdk.trigger.cooldownTrigger(30_000),
|
|
},
|
|
requires: ['spaced'],
|
|
})
|
|
.addHealthCheck('indexer-sync', {
|
|
ready: {
|
|
display: i18n('Indexer Sync'),
|
|
fn: async () => {
|
|
try {
|
|
// Go indexer has no syncs table; track progress by max(blocks.height)
|
|
// of non-orphan rows.
|
|
const psql = await postgresSub.exec(
|
|
[
|
|
'psql',
|
|
'-U',
|
|
POSTGRES_USER,
|
|
'-d',
|
|
POSTGRES_DB,
|
|
'-h',
|
|
'127.0.0.1',
|
|
'-p',
|
|
String(POSTGRES_PORT),
|
|
'-t',
|
|
'-A',
|
|
'-F',
|
|
'|',
|
|
'-c',
|
|
"SELECT COALESCE(MAX(height), 0) FROM blocks WHERE orphan = FALSE AND height >= 0;",
|
|
],
|
|
{ env: { PGPASSWORD: dbAuth.password } as Record<string, string> },
|
|
)
|
|
|
|
if (psql.exitCode !== 0) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n('indexer psql exited ${code}: ${error}', {
|
|
code: String(psql.exitCode),
|
|
error: ((psql.stderr ?? '') as string).toString().slice(0, 200) ||
|
|
'<no output>',
|
|
}),
|
|
}
|
|
}
|
|
|
|
const out = (psql.stdout ?? '').toString().trim()
|
|
if (!out) {
|
|
return {
|
|
result: 'loading',
|
|
message: i18n('indexer has not run a sync cycle yet.'),
|
|
}
|
|
}
|
|
const parts = out.split('|')
|
|
const endBlockHeight = parseInt(parts[0] ?? '0', 10) || 0
|
|
const ageSec = -1 // Go indexer has no per-sync timestamp surface
|
|
|
|
const probe = await spacedSub.exec(
|
|
[
|
|
'/root/.cargo/bin/space-cli',
|
|
'--chain',
|
|
SPACED_CHAIN,
|
|
'--rpc-user',
|
|
spacedAuth.username,
|
|
'--rpc-password',
|
|
spacedAuth.password,
|
|
'--output-format',
|
|
'json',
|
|
'getserverinfo',
|
|
],
|
|
{},
|
|
)
|
|
let spacedBlocks = 0
|
|
if (probe.exitCode === 0) {
|
|
try {
|
|
const parsed = JSON.parse((probe.stdout ?? '').toString())
|
|
spacedBlocks = parsed?.chain?.blocks ?? 0
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
if (endBlockHeight === 0) {
|
|
return {
|
|
result: 'loading',
|
|
message: i18n(
|
|
'indexer has not yet committed any blocks (spaced tip ${tip}).',
|
|
{ tip: String(spacedBlocks) },
|
|
),
|
|
}
|
|
}
|
|
|
|
const lag = Math.max(0, spacedBlocks - endBlockHeight)
|
|
if (spacedBlocks > 0 && lag <= 5) {
|
|
return {
|
|
result: 'success',
|
|
message: i18n(
|
|
'indexer caught up at block ${end} (spaced tip ${tip}).',
|
|
{
|
|
end: String(endBlockHeight),
|
|
tip: String(spacedBlocks),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
return {
|
|
result: 'loading',
|
|
message: i18n(
|
|
'indexer at block ${end}, ${lag} behind spaced tip ${tip}.',
|
|
{
|
|
end: String(endBlockHeight),
|
|
lag: String(lag),
|
|
tip: String(spacedBlocks),
|
|
},
|
|
),
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
result: 'failure',
|
|
message: i18n('Indexer Sync health check crashed: ${error}', {
|
|
error: (e as Error)?.message ?? String(e),
|
|
}),
|
|
}
|
|
}
|
|
},
|
|
gracePeriod: 60_000,
|
|
trigger: sdk.trigger.cooldownTrigger(30_000),
|
|
},
|
|
requires: ['indexer', 'postgres'],
|
|
})
|
|
})
|