From 10331dee0efaf2bdec6c66f874a225777cc64a3e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 07:32:03 -0700 Subject: [PATCH 1/8] Split config path and naming helpers Move path and naming behavior behind configured modules while keeping the config service facade stable. Add direct unit coverage in a nested test directory. --- src/services/config_service.js | 133 +++--------------- src/services/config_service/naming.js | 81 +++++++++++ src/services/config_service/paths.js | 81 +++++++++++ .../paths_naming/naming_module.test.js | 116 +++++++++++++++ .../paths_naming/paths_module.test.js | 109 ++++++++++++++ 5 files changed, 405 insertions(+), 115 deletions(-) create mode 100644 src/services/config_service/naming.js create mode 100644 src/services/config_service/paths.js create mode 100644 test/unit/config_service.test/paths_naming/naming_module.test.js create mode 100644 test/unit/config_service.test/paths_naming/paths_module.test.js diff --git a/src/services/config_service.js b/src/services/config_service.js index 9e74689c..72269450 100644 --- a/src/services/config_service.js +++ b/src/services/config_service.js @@ -46,124 +46,27 @@ const database = require('./config_service/database') const networks = require('./config_service/networks') const filters = require('./config_service/filters') const argumentsService = require('./config_service/arguments') +const paths = require('./config_service/paths') +const naming = require('./config_service/naming') const { validatePort } = require('./config_service/validation') -function getModuleDir(module) { - return moduleDir + "/" + module -} - -function getModuleTmpDir(module) { - return tmpDir + "/" + module -} - -function getCryptoNodeDir(coin) { - if (!(coin in Coin)) { - coin = stringToCoin(coin) - } - return cryptoNodesDir + "/" + Coin[coin] -} - -function removeModuleDir(module) { - fs.rmSync(getModuleDir(module), { recursive: true }) -} - -function removeModuleTmpDir(module) { - const dir = getModuleTmpDir(module) - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true }) - } -} - -function createModuleTmpDir(module) { - const dir = getModuleTmpDir(module) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir) - } -} - -function moduleDirExists(module) { - return fs.existsSync(getModuleDir(module)) -} - -function checkIfModuleExists(module) { - const dir = getModuleDir(module) - return fs.existsSync(dir) - && fs.existsSync(dir + "/Dockerfile") - && fs.existsSync(dir + "/src") - && fs.existsSync(dir + "/package.json") -} - -function checkIfCryptoNodeSourceExists(coin) { - const dir = getCryptoNodeDir(coin) - return fs.existsSync(dir) - && fs.existsSync(dir + "/Dockerfile") - && fs.existsSync(dir + "/src") -} - -function getDockerContainerImageNamePrefix(module, coin, network) { - if (module === DB_MODULE_NAME || module === HUB_MODULE_NAME || module === EXPLORER_MODULE_NAME || module === SYNC_MODULE_NAME) { - return NODE_PREFIX - } - return NODE_PREFIX + SEP + coin + SEP + network -} - -function getDockerContainerImageName(module, coin, network) { - return getDockerContainerImageNamePrefix(module, coin, network) + SEP + module -} - -// Single source of truth for the UTXO tracker's Docker volume name. Two -// NODE_PREFIX stacks of the same coin+network must not share one volume, so -// non-default prefixes get a prefixed volume name; the default prefix keeps -// the legacy unprefixed name (renaming it would orphan every existing -// deployment's tracker data, forcing a fleet-wide resync). Previously this -// rule lived only in ModuleService.buildAndUp; moduleOperations.resetModules -// and all three BootstrapService sites re-derived the unprefixed name -// independently, so a non-default NODE_PREFIX made reset/bootstrap/restore -// silently operate on the wrong stack's volume (uuid:7523dd94, uuid:a61fc673). -function getUtxoTrackerVolumeName(coin, network) { - const prefix = NODE_PREFIX === DEFAULT_NODE_PREFIX ? '' : `${NODE_PREFIX}${SEP}` - return `${prefix}${XChainService.XCHAIN_UTXO_TRACKER}${SEP}${coin}-${network}-data` -} - -function getDockerNetwork(coin, network) { - return NODE_PREFIX - + (coin !== "" ? SEP + coin : "") - + (network !== "" ? SEP + network : "") -} - -function getModuleDatabaseName(module, coin, network) { - // Defense in depth: an unknown coin (e.g. 'all' or a typo passed straight - // from a raw CLI arg) yields CoinTickerSymbol[coin] === undefined, which - // would otherwise produce a junk `XChain_undefined_*` name that reaches - // CREATE DATABASE on the live MariaDB while the command still exits 0. - // Fail loud so an unresolved coin never materializes a database. - if (coin !== "" && CoinTickerSymbol[coin] === undefined) { - throw new Error("Unknown coin '" + coin + "'; cannot derive a database name") - } - // Mirror the coin guard above: a typo'd network (e.g. from a raw CLI arg - // on the reset/bootstrap paths, which bypass filterCommandParameters) - // would otherwise derive a well-formed-but-bogus name that still reaches - // CREATE DATABASE while the command exits 0. - if (network !== "" && !Object.values(Network).includes(network)) { - throw new Error("Unknown network '" + network + "'; cannot derive a database name") - } - const moduleName = module.slice("xchain-".length) - return "XChain" + DB_SEP - + scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) - + CoinTickerSymbol[coin] + DB_SEP - + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP - + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) -} +paths.configure({ fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin }) +naming.configure({ + NODE_PREFIX, DEFAULT_NODE_PREFIX, SEP, DB_SEP, + DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, + CoinTickerSymbol, Network, XChainService, scopedNodePrefixSegment +}) -// Module DB account names carry the NODE_PREFIX like the database names, so two -// stacks sharing one MariaDB never contend for one account. Default prefix: unchanged. -function applyPrefixedModuleDbUsers(defaultValues, coin, network) { - const segment = scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) - if (segment === '') return - for (const [key, name] of [["DECODER_DB_USER", "decoder"], ["INDEXER_DB_USER", "indexer"]]) { - defaultValues[key] = "xchain" + DB_SEP + segment + name + DB_SEP + coin + DB_SEP + network - } -} +const { + getModuleDir, getModuleTmpDir, getCryptoNodeDir, removeModuleDir, + removeModuleTmpDir, createModuleTmpDir, moduleDirExists, + checkIfModuleExists, checkIfCryptoNodeSourceExists +} = paths +const { + getDockerContainerImageNamePrefix, getDockerContainerImageName, + getUtxoTrackerVolumeName, getDockerNetwork, getModuleDatabaseName, + applyPrefixedModuleDbUsers +} = naming sidecars.configure({ crypto, fs, path, readline, configDir, preferredSecretEnvName, diff --git a/src/services/config_service/naming.js b/src/services/config_service/naming.js new file mode 100644 index 00000000..ca41c84b --- /dev/null +++ b/src/services/config_service/naming.js @@ -0,0 +1,81 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Config Service Naming Helpers + ********************************************************************/ + +'use strict' + +function configure(dependencies) { + const { + NODE_PREFIX, DEFAULT_NODE_PREFIX, SEP, DB_SEP, + DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, + CoinTickerSymbol, Network, XChainService, scopedNodePrefixSegment + } = dependencies + + function getDockerContainerImageNamePrefix(module, coin, network) { + if ([DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME].includes(module)) { + return NODE_PREFIX + } + return NODE_PREFIX + SEP + coin + SEP + network + } + + function getDockerContainerImageName(module, coin, network) { + return getDockerContainerImageNamePrefix(module, coin, network) + SEP + module + } + + function getUtxoTrackerVolumeName(coin, network) { + const prefix = NODE_PREFIX === DEFAULT_NODE_PREFIX ? '' : `${NODE_PREFIX}${SEP}` + return `${prefix}${XChainService.XCHAIN_UTXO_TRACKER}${SEP}${coin}-${network}-data` + } + + function getDockerNetwork(coin, network) { + return NODE_PREFIX + + (coin !== "" ? SEP + coin : "") + + (network !== "" ? SEP + network : "") + } + + const getModuleDatabaseName = function (module, coin, network) { + if (coin !== "" && CoinTickerSymbol[coin] === undefined) { + throw new Error("Unknown coin '" + coin + "'; cannot derive a database name") + } + if (network !== "" && !Object.values(Network).includes(network)) { + throw new Error("Unknown network '" + network + "'; cannot derive a database name") + } + const moduleName = module.slice("xchain-".length) + return "XChain" + DB_SEP + + scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + + CoinTickerSymbol[coin] + DB_SEP + + network.charAt(0).toUpperCase() + network.slice(1) + DB_SEP + + moduleName.charAt(0).toUpperCase() + moduleName.slice(1) + } + + function applyPrefixedModuleDbUsers(defaultValues, coin, network) { + const segment = scopedNodePrefixSegment(NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP) + if (segment === '') return + for (const [key, name] of [["DECODER_DB_USER", "decoder"], ["INDEXER_DB_USER", "indexer"]]) { + defaultValues[key] = "xchain" + DB_SEP + segment + name + DB_SEP + coin + DB_SEP + network + } + } + + Object.assign(module.exports, { + getDockerContainerImageNamePrefix, + getDockerContainerImageName, + getUtxoTrackerVolumeName, + getDockerNetwork, + getModuleDatabaseName, + applyPrefixedModuleDbUsers + }) +} + +module.exports = { configure } diff --git a/src/services/config_service/paths.js b/src/services/config_service/paths.js new file mode 100644 index 00000000..4c7ae758 --- /dev/null +++ b/src/services/config_service/paths.js @@ -0,0 +1,81 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Config Service Path Helpers + ********************************************************************/ + +'use strict' + +function configure(dependencies) { + const { fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin } = dependencies + + function getModuleDir(module) { + return moduleDir + "/" + module + } + + function getModuleTmpDir(module) { + return tmpDir + "/" + module + } + + function getCryptoNodeDir(coin) { + if (!(coin in Coin)) coin = stringToCoin(coin) + return cryptoNodesDir + "/" + Coin[coin] + } + + function removeModuleDir(module) { + fs.rmSync(getModuleDir(module), { recursive: true }) + } + + function removeModuleTmpDir(module) { + const dir = getModuleTmpDir(module) + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }) + } + + function createModuleTmpDir(module) { + const dir = getModuleTmpDir(module) + if (!fs.existsSync(dir)) fs.mkdirSync(dir) + } + + function moduleDirExists(module) { + return fs.existsSync(getModuleDir(module)) + } + + function checkIfModuleExists(module) { + const dir = getModuleDir(module) + return fs.existsSync(dir) + && fs.existsSync(dir + "/Dockerfile") + && fs.existsSync(dir + "/src") + && fs.existsSync(dir + "/package.json") + } + + function checkIfCryptoNodeSourceExists(coin) { + const dir = getCryptoNodeDir(coin) + return fs.existsSync(dir) + && fs.existsSync(dir + "/Dockerfile") + && fs.existsSync(dir + "/src") + } + + Object.assign(module.exports, { + getModuleDir, + getModuleTmpDir, + getCryptoNodeDir, + removeModuleDir, + removeModuleTmpDir, + createModuleTmpDir, + moduleDirExists, + checkIfModuleExists, + checkIfCryptoNodeSourceExists + }) +} + +module.exports = { configure } diff --git a/test/unit/config_service.test/paths_naming/naming_module.test.js b/test/unit/config_service.test/paths_naming/naming_module.test.js new file mode 100644 index 00000000..828d0cb9 --- /dev/null +++ b/test/unit/config_service.test/paths_naming/naming_module.test.js @@ -0,0 +1,116 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const naming = require('../../../../src/services/config_service/naming') +const config = require('../../../../src/config') +const { scopedNodePrefixSegment } = require('../../../../src/utils/module_database_name') + +function configure(overrides = {}) { + naming.configure({ + NODE_PREFIX: 'xchain-node', + DEFAULT_NODE_PREFIX: 'xchain-node', + SEP: '-', + DB_SEP: '_', + DB_MODULE_NAME: 'database', + HUB_MODULE_NAME: 'xchain-hub', + EXPLORER_MODULE_NAME: 'xchain-explorer', + SYNC_MODULE_NAME: 'xchain-sync', + CoinTickerSymbol: { bitcoin: 'BTC', dogecoin: 'DOGE' }, + Network: { MAINNET: 'mainnet', TESTNET: 'testnet', REGTEST: 'regtest' }, + XChainService: { XCHAIN_UTXO_TRACKER: 'xchain-utxo-tracker' }, + scopedNodePrefixSegment, + ...overrides + }) +} + +describe('config_service/naming', function () { + after(function () { + naming.configure({ + NODE_PREFIX: config.NODE_PREFIX, + DEFAULT_NODE_PREFIX: config.DEFAULT_NODE_PREFIX, + SEP: config.SEP, + DB_SEP: config.DB_SEP, + DB_MODULE_NAME: config.DB_MODULE_NAME, + HUB_MODULE_NAME: config.HUB_MODULE_NAME, + EXPLORER_MODULE_NAME: config.EXPLORER_MODULE_NAME, + SYNC_MODULE_NAME: config.SYNC_MODULE_NAME, + CoinTickerSymbol: config.CoinTickerSymbol, + Network: config.Network, + XChainService: config.XChainService, + scopedNodePrefixSegment + }) + }) + + it('uses a shared prefix for shared images and a scoped prefix for coin images', function () { + configure() + + expect(naming.getDockerContainerImageNamePrefix('database', 'bitcoin', 'mainnet')) + .to.equal('xchain-node') + expect(naming.getDockerContainerImageNamePrefix('xchain-decoder', 'bitcoin', 'mainnet')) + .to.equal('xchain-node-bitcoin-mainnet') + expect(naming.getDockerContainerImageName('xchain-decoder', 'bitcoin', 'mainnet')) + .to.equal('xchain-node-bitcoin-mainnet-xchain-decoder') + }) + + it('keeps the default tracker volume name and scopes non-default stacks', function () { + configure() + expect(naming.getUtxoTrackerVolumeName('bitcoin', 'mainnet')) + .to.equal('xchain-utxo-tracker-bitcoin-mainnet-data') + + configure({ NODE_PREFIX: 'xchain-blue' }) + expect(naming.getUtxoTrackerVolumeName('bitcoin', 'mainnet')) + .to.equal('xchain-blue-xchain-utxo-tracker-bitcoin-mainnet-data') + }) + + it('builds Docker networks with optional coin and network segments', function () { + configure() + + expect(naming.getDockerNetwork('', '')).to.equal('xchain-node') + expect(naming.getDockerNetwork('bitcoin', '')).to.equal('xchain-node-bitcoin') + expect(naming.getDockerNetwork('bitcoin', 'mainnet')).to.equal('xchain-node-bitcoin-mainnet') + }) + + it('builds database names and scopes them for non-default stacks', function () { + configure() + expect(naming.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) + .to.equal('XChain_BTC_Mainnet_Decoder') + + configure({ NODE_PREFIX: 'xchain-blue' }) + expect(naming.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) + .to.equal('XChain_' + scopedNodePrefixSegment('xchain-blue', 'xchain-node', '_') + 'BTC_Mainnet_Decoder') + }) + + it('rejects unknown coins and networks', function () { + configure() + + expect(() => naming.getModuleDatabaseName('xchain-decoder', 'unknown', 'mainnet')) + .to.throw("Unknown coin 'unknown'") + expect(() => naming.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'unknown')) + .to.throw("Unknown network 'unknown'") + }) + + it('scopes module database users only for non-default stacks', function () { + const defaults = { DECODER_DB_USER: 'decoder', INDEXER_DB_USER: 'indexer' } + configure() + naming.applyPrefixedModuleDbUsers(defaults, 'bitcoin', 'mainnet') + expect(defaults).to.deep.equal({ DECODER_DB_USER: 'decoder', INDEXER_DB_USER: 'indexer' }) + + configure({ NODE_PREFIX: 'xchain-blue' }) + naming.applyPrefixedModuleDbUsers(defaults, 'bitcoin', 'mainnet') + const segment = scopedNodePrefixSegment('xchain-blue', 'xchain-node', '_') + expect(defaults).to.deep.equal({ + DECODER_DB_USER: 'xchain_' + segment + 'decoder_bitcoin_mainnet', + INDEXER_DB_USER: 'xchain_' + segment + 'indexer_bitcoin_mainnet' + }) + }) +}) diff --git a/test/unit/config_service.test/paths_naming/paths_module.test.js b/test/unit/config_service.test/paths_naming/paths_module.test.js new file mode 100644 index 00000000..3e1a107c --- /dev/null +++ b/test/unit/config_service.test/paths_naming/paths_module.test.js @@ -0,0 +1,109 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { expect } = require('chai') +const sinon = require('sinon') +const fs = require('fs') +const paths = require('../../../../src/services/config_service/paths') +const { moduleDir, tmpDir, cryptoNodesDir, Coin } = require('../../../../src/config') +const { stringToCoin } = require('../../../../src/utils/helpers') + +function configure(overrides = {}) { + const dependencies = { + fs: { existsSync: sinon.stub(), rmSync: sinon.stub(), mkdirSync: sinon.stub() }, + moduleDir: '/modules', + tmpDir: '/temporary', + cryptoNodesDir: '/crypto-nodes', + Coin: { BITCOIN: 'bitcoin', DOGECOIN: 'dogecoin' }, + stringToCoin: sinon.stub().callsFake(coin => coin.toUpperCase()), + ...overrides + } + paths.configure(dependencies) + return dependencies +} + +describe('config_service/paths', function () { + after(function () { + paths.configure({ fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin }) + }) + + it('derives module, temporary, and crypto-node directories', function () { + const dependencies = configure() + + expect(paths.getModuleDir('xchain-decoder')).to.equal('/modules/xchain-decoder') + expect(paths.getModuleTmpDir('xchain-indexer')).to.equal('/temporary/xchain-indexer') + expect(paths.getCryptoNodeDir('BITCOIN')).to.equal('/crypto-nodes/bitcoin') + expect(paths.getCryptoNodeDir('dogecoin')).to.equal('/crypto-nodes/dogecoin') + expect(dependencies.stringToCoin.calledOnceWithExactly('dogecoin')).to.equal(true) + }) + + it('removes module directories and only removes existing temporary directories', function () { + const dependencies = configure() + dependencies.fs.existsSync.onFirstCall().returns(false).onSecondCall().returns(true) + + paths.removeModuleDir('xchain-decoder') + paths.removeModuleTmpDir('xchain-decoder') + paths.removeModuleTmpDir('xchain-indexer') + + expect(dependencies.fs.rmSync.firstCall.args).to.deep.equal([ + '/modules/xchain-decoder', { recursive: true } + ]) + expect(dependencies.fs.rmSync.secondCall.args).to.deep.equal([ + '/temporary/xchain-indexer', { recursive: true } + ]) + expect(dependencies.fs.rmSync.callCount).to.equal(2) + }) + + it('creates a missing temporary directory and preserves an existing one', function () { + const dependencies = configure() + dependencies.fs.existsSync.onFirstCall().returns(false).onSecondCall().returns(true) + + paths.createModuleTmpDir('xchain-decoder') + paths.createModuleTmpDir('xchain-indexer') + + expect(dependencies.fs.mkdirSync.calledOnceWithExactly('/temporary/xchain-decoder')).to.equal(true) + }) + + it('reports module directory existence', function () { + const dependencies = configure() + dependencies.fs.existsSync.returns(false) + dependencies.fs.existsSync.withArgs('/modules/xchain-decoder').returns(true) + + expect(paths.moduleDirExists('xchain-decoder')).to.equal(true) + expect(paths.moduleDirExists('xchain-indexer')).to.equal(false) + }) + + it('requires every expected module source path', function () { + const dependencies = configure() + dependencies.fs.existsSync.callsFake(candidate => !candidate.endsWith('/package.json')) + + expect(paths.checkIfModuleExists('xchain-decoder')).to.equal(false) + expect(dependencies.fs.existsSync.args.map(args => args[0])).to.deep.equal([ + '/modules/xchain-decoder', + '/modules/xchain-decoder/Dockerfile', + '/modules/xchain-decoder/src', + '/modules/xchain-decoder/package.json' + ]) + }) + + it('requires the crypto-node directory, Dockerfile, and source directory', function () { + const dependencies = configure() + dependencies.fs.existsSync.returns(true) + + expect(paths.checkIfCryptoNodeSourceExists('BITCOIN')).to.equal(true) + expect(dependencies.fs.existsSync.args.map(args => args[0])).to.deep.equal([ + '/crypto-nodes/bitcoin', + '/crypto-nodes/bitcoin/Dockerfile', + '/crypto-nodes/bitcoin/src' + ]) + }) +}) From e23a50e707282daa238149b94ad05bed004b78b9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 07:33:35 -0700 Subject: [PATCH 2/8] Keep extracted helpers within structure limits Split configured helper factories and direct test groups into smaller functions while preserving isolated dependency bindings. --- src/services/config_service/naming.js | 33 ++++++++++------ src/services/config_service/paths.js | 30 +++++++++------ .../paths_naming/naming_module.test.js | 38 ++++++++++--------- .../paths_naming/paths_module.test.js | 12 +++--- 4 files changed, 68 insertions(+), 45 deletions(-) diff --git a/src/services/config_service/naming.js b/src/services/config_service/naming.js index ca41c84b..88afd1cc 100644 --- a/src/services/config_service/naming.js +++ b/src/services/config_service/naming.js @@ -16,11 +16,11 @@ 'use strict' -function configure(dependencies) { +function createContainerNames(dependencies) { const { - NODE_PREFIX, DEFAULT_NODE_PREFIX, SEP, DB_SEP, + NODE_PREFIX, DEFAULT_NODE_PREFIX, SEP, DB_MODULE_NAME, HUB_MODULE_NAME, EXPLORER_MODULE_NAME, SYNC_MODULE_NAME, - CoinTickerSymbol, Network, XChainService, scopedNodePrefixSegment + XChainService } = dependencies function getDockerContainerImageNamePrefix(module, coin, network) { @@ -45,6 +45,20 @@ function configure(dependencies) { + (network !== "" ? SEP + network : "") } + return { + getDockerContainerImageNamePrefix, + getDockerContainerImageName, + getUtxoTrackerVolumeName, + getDockerNetwork + } +} + +function createDatabaseNames(dependencies) { + const { + NODE_PREFIX, DEFAULT_NODE_PREFIX, DB_SEP, + CoinTickerSymbol, Network, scopedNodePrefixSegment + } = dependencies + const getModuleDatabaseName = function (module, coin, network) { if (coin !== "" && CoinTickerSymbol[coin] === undefined) { throw new Error("Unknown coin '" + coin + "'; cannot derive a database name") @@ -68,14 +82,11 @@ function configure(dependencies) { } } - Object.assign(module.exports, { - getDockerContainerImageNamePrefix, - getDockerContainerImageName, - getUtxoTrackerVolumeName, - getDockerNetwork, - getModuleDatabaseName, - applyPrefixedModuleDbUsers - }) + return { getModuleDatabaseName, applyPrefixedModuleDbUsers } +} + +function configure(dependencies) { + Object.assign(module.exports, createContainerNames(dependencies), createDatabaseNames(dependencies)) } module.exports = { configure } diff --git a/src/services/config_service/paths.js b/src/services/config_service/paths.js index 4c7ae758..6513a0e8 100644 --- a/src/services/config_service/paths.js +++ b/src/services/config_service/paths.js @@ -16,7 +16,7 @@ 'use strict' -function configure(dependencies) { +function createPathReaders(dependencies) { const { fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin } = dependencies function getModuleDir(module) { @@ -32,6 +32,17 @@ function configure(dependencies) { return cryptoNodesDir + "/" + Coin[coin] } + function moduleDirExists(module) { + return fs.existsSync(getModuleDir(module)) + } + + return { getModuleDir, getModuleTmpDir, getCryptoNodeDir, moduleDirExists } +} + +function createPathOperations(dependencies, readers) { + const { fs } = dependencies + const { getModuleDir, getModuleTmpDir, getCryptoNodeDir } = readers + function removeModuleDir(module) { fs.rmSync(getModuleDir(module), { recursive: true }) } @@ -46,10 +57,6 @@ function configure(dependencies) { if (!fs.existsSync(dir)) fs.mkdirSync(dir) } - function moduleDirExists(module) { - return fs.existsSync(getModuleDir(module)) - } - function checkIfModuleExists(module) { const dir = getModuleDir(module) return fs.existsSync(dir) @@ -65,17 +72,18 @@ function configure(dependencies) { && fs.existsSync(dir + "/src") } - Object.assign(module.exports, { - getModuleDir, - getModuleTmpDir, - getCryptoNodeDir, + return { removeModuleDir, removeModuleTmpDir, createModuleTmpDir, - moduleDirExists, checkIfModuleExists, checkIfCryptoNodeSourceExists - }) + } +} + +function configure(dependencies) { + const readers = createPathReaders(dependencies) + Object.assign(module.exports, readers, createPathOperations(dependencies, readers)) } module.exports = { configure } diff --git a/test/unit/config_service.test/paths_naming/naming_module.test.js b/test/unit/config_service.test/paths_naming/naming_module.test.js index 828d0cb9..fc6db9fb 100644 --- a/test/unit/config_service.test/paths_naming/naming_module.test.js +++ b/test/unit/config_service.test/paths_naming/naming_module.test.js @@ -33,24 +33,7 @@ function configure(overrides = {}) { }) } -describe('config_service/naming', function () { - after(function () { - naming.configure({ - NODE_PREFIX: config.NODE_PREFIX, - DEFAULT_NODE_PREFIX: config.DEFAULT_NODE_PREFIX, - SEP: config.SEP, - DB_SEP: config.DB_SEP, - DB_MODULE_NAME: config.DB_MODULE_NAME, - HUB_MODULE_NAME: config.HUB_MODULE_NAME, - EXPLORER_MODULE_NAME: config.EXPLORER_MODULE_NAME, - SYNC_MODULE_NAME: config.SYNC_MODULE_NAME, - CoinTickerSymbol: config.CoinTickerSymbol, - Network: config.Network, - XChainService: config.XChainService, - scopedNodePrefixSegment - }) - }) - +describe('config_service/naming containers and networks', function () { it('uses a shared prefix for shared images and a scoped prefix for coin images', function () { configure() @@ -79,7 +62,9 @@ describe('config_service/naming', function () { expect(naming.getDockerNetwork('bitcoin', '')).to.equal('xchain-node-bitcoin') expect(naming.getDockerNetwork('bitcoin', 'mainnet')).to.equal('xchain-node-bitcoin-mainnet') }) +}) +describe('config_service/naming database identities', function () { it('builds database names and scopes them for non-default stacks', function () { configure() expect(naming.getModuleDatabaseName('xchain-decoder', 'bitcoin', 'mainnet')) @@ -114,3 +99,20 @@ describe('config_service/naming', function () { }) }) }) + +after(function () { + naming.configure({ + NODE_PREFIX: config.NODE_PREFIX, + DEFAULT_NODE_PREFIX: config.DEFAULT_NODE_PREFIX, + SEP: config.SEP, + DB_SEP: config.DB_SEP, + DB_MODULE_NAME: config.DB_MODULE_NAME, + HUB_MODULE_NAME: config.HUB_MODULE_NAME, + EXPLORER_MODULE_NAME: config.EXPLORER_MODULE_NAME, + SYNC_MODULE_NAME: config.SYNC_MODULE_NAME, + CoinTickerSymbol: config.CoinTickerSymbol, + Network: config.Network, + XChainService: config.XChainService, + scopedNodePrefixSegment + }) +}) diff --git a/test/unit/config_service.test/paths_naming/paths_module.test.js b/test/unit/config_service.test/paths_naming/paths_module.test.js index 3e1a107c..d31e58fa 100644 --- a/test/unit/config_service.test/paths_naming/paths_module.test.js +++ b/test/unit/config_service.test/paths_naming/paths_module.test.js @@ -31,11 +31,7 @@ function configure(overrides = {}) { return dependencies } -describe('config_service/paths', function () { - after(function () { - paths.configure({ fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin }) - }) - +describe('config_service/paths directory operations', function () { it('derives module, temporary, and crypto-node directories', function () { const dependencies = configure() @@ -72,7 +68,9 @@ describe('config_service/paths', function () { expect(dependencies.fs.mkdirSync.calledOnceWithExactly('/temporary/xchain-decoder')).to.equal(true) }) +}) +describe('config_service/paths source checks', function () { it('reports module directory existence', function () { const dependencies = configure() dependencies.fs.existsSync.returns(false) @@ -107,3 +105,7 @@ describe('config_service/paths', function () { ]) }) }) + +after(function () { + paths.configure({ fs, moduleDir, tmpDir, cryptoNodesDir, Coin, stringToCoin }) +}) From da99c88d4d7bf3f52313b8948a81df1b7ea92391 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 10:15:13 -0700 Subject: [PATCH 3/8] Raise provisioned MariaDB image to 11.8 Update the mirror workflow and integration expectations to match the new LTS image. Remove the stale version-specific healthcheck verification claim. --- .github/workflows/attest-mirror-leg.yml | 2 +- src/services/database_service/database_module.js | 4 ++-- test/integration/database_setup.test.js | 4 ++-- .../build_module/build_database_module.test.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/attest-mirror-leg.yml b/.github/workflows/attest-mirror-leg.yml index e056520e..60542ce1 100644 --- a/.github/workflows/attest-mirror-leg.yml +++ b/.github/workflows/attest-mirror-leg.yml @@ -159,7 +159,7 @@ jobs: node src/index.js install "$STACK_REF" all dogecoin regtest # The standing stacks stay up: legs read the standing # explorer (18080) and the standing LTC/DOGE indexers (at0-anti-wedge reads 3224). - docker pull -q mariadb:10.11 && docker tag mariadb:10.11 xchain-node-database + docker pull -q mariadb:11.8 && docker tag mariadb:11.8 xchain-node-database docker images --format '{{.Repository}}' | grep '^xchain-node-' | sort docker ps --format '{{.Names}} {{.Status}}' diff --git a/src/services/database_service/database_module.js b/src/services/database_service/database_module.js index c033a4e9..48a9f183 100644 --- a/src/services/database_service/database_module.js +++ b/src/services/database_service/database_module.js @@ -238,8 +238,8 @@ async function createDatabaseModule(coin, network) { const containerPrefix = getDockerContainerImageName(DB_MODULE_NAME, coin, network) logger.info("Building image of database") - await execFileAsync('docker', ['pull', 'mariadb:10.11']) - await execFileAsync('docker', ['tag', 'mariadb:10.11', containerPrefix]) + await execFileAsync('docker', ['pull', 'mariadb:11.8']) + await execFileAsync('docker', ['tag', 'mariadb:11.8', containerPrefix]) const { runArgs, dbHostPort } = createDatabaseRunArgs(containerPrefix, environmentVariables, coin, network) addDatabaseTuningArgs(runArgs) await prepareDatabaseContainer(containerPrefix, dbHostPort) diff --git a/test/integration/database_setup.test.js b/test/integration/database_setup.test.js index b1f115ee..3ecf5fe8 100644 --- a/test/integration/database_setup.test.js +++ b/test/integration/database_setup.test.js @@ -26,8 +26,8 @@ databaseSuite('buildDatabaseModule (first install)', function (fixture) { // Pin tracks src/services/database_service.js's actual `docker pull`/ // `docker tag` target; the image tag itself is not a secret. - capture.assertCalled(/docker pull mariadb:10.11/) - capture.assertCalled(/docker tag mariadb:10.11 xchain-node-database/) + capture.assertCalled(/docker pull mariadb:11.8/) + capture.assertCalled(/docker tag mariadb:11.8 xchain-node-database/) const runCmds = capture.findCommands(/docker run/) expect(runCmds).to.have.length(1) diff --git a/test/unit/database_service.test/build_module/build_database_module.test.js b/test/unit/database_service.test/build_module/build_database_module.test.js index 7aea2c3d..1989623a 100644 --- a/test/unit/database_service.test/build_module/build_database_module.test.js +++ b/test/unit/database_service.test/build_module/build_database_module.test.js @@ -224,7 +224,7 @@ describe('DatabaseService', function () { expect(runArgs, 'docker run call not found').to.not.be.null const cmdIdx = runArgs.indexOf('--health-cmd') expect(cmdIdx, 'no --health-cmd on the DB container').to.be.greaterThan(-1) - // The image ships /usr/local/bin/healthcheck.sh (verified on mariadb:10.11). + // The probe uses the image's /usr/local/bin/healthcheck.sh entry point. expect(String(runArgs[cmdIdx + 1])).to.include('healthcheck.sh') expect(runArgs).to.include('--health-interval') expect(runArgs).to.include('--health-start-period') From ee2f7ab6189e738d2dc9f334185a65ab594b91d2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 12:08:06 -0700 Subject: [PATCH 4/8] test: pin train activation halt stall handling Cover bootstrap refusal boundaries and status reduction for the train activation halt stall class. --- .../train_activation_halt_stall_class.test.js | 51 +++++++++++++++++++ ...ndexer_stall_train_activation_halt.test.js | 42 +++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 test/unit/bootstrap_health_gate.test/train_activation_halt_stall_class.test.js create mode 100644 test/unit/status_service.test/indexer_stall_train_activation_halt.test.js diff --git a/test/unit/bootstrap_health_gate.test/train_activation_halt_stall_class.test.js b/test/unit/bootstrap_health_gate.test/train_activation_halt_stall_class.test.js new file mode 100644 index 00000000..854dc48c --- /dev/null +++ b/test/unit/bootstrap_health_gate.test/train_activation_halt_stall_class.test.js @@ -0,0 +1,51 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { + expect, + installEnvironmentHooks, + loadGate, +} = require('./support/bootstrap_health_gate') + +describe('BootstrapHealthGate: train_activation_halt stall class', function () { + + installEnvironmentHooks() + + const stallReason = 'train_activation_halt: rule set 3 required at 900' + const payload = (trainActivation) => Object.assign({ + status: 'healthy', + lag: 3, + degraded: true, + stallClass: 'train_activation_halt', + stallReason + }, trainActivation === undefined ? {} : { train_activation: trainActivation }) + + it('does not refuse the stall class by itself', function () { + const { evaluateStatusPayload } = loadGate() + const cases = [ + ['absent', undefined], + ['pending', { status: 'pending' }], + ['unevaluated', { status: 'unevaluated' }] + ] + + for (const [label, trainActivation] of cases) + expect(evaluateStatusPayload(payload(trainActivation)), label).to.deep.equal([]) + }) + + it('refuses a halt exactly once through the train-activation leg', function () { + const { evaluateStatusPayload } = loadGate() + const reasons = evaluateStatusPayload(payload({ + status: 'halt', + required_rule_set: '3', + required_at_height: 900 + })) + + expect(reasons).to.have.lengthOf(1) + expect(reasons[0]).to.match(/train-activation halt/) + expect(reasons[0]).to.not.match(/WEDGED/) + }) +}) diff --git a/test/unit/status_service.test/indexer_stall_train_activation_halt.test.js b/test/unit/status_service.test/indexer_stall_train_activation_halt.test.js new file mode 100644 index 00000000..3e880ed9 --- /dev/null +++ b/test/unit/status_service.test/indexer_stall_train_activation_halt.test.js @@ -0,0 +1,42 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const { expect } = require('chai') +const proxyquire = require('proxyquire').noCallThru() + +const StatusService = proxyquire('../../../src/services/status_service', { + './docker_service': { getStatusFromContainer: async () => { throw new Error('not used') } }, + './version_service': { + checkRemoteNodeVersion: async () => {}, getLocalNodeVersion: async () => '0', getContainerNodeVersion: async () => '0', + getLocalModuleVersion: async () => '0', getContainerModuleVersion: async () => '0' + } +}) +const { reduceIndexerStall, describeIndexerStallNote } = StatusService + +describe('StatusService: train_activation_halt stall class', function () { + + const payload = { + status: 'healthy', + lag: 3, + degraded: true, + stallClass: 'train_activation_halt', + stallReason: 'train_activation_halt: rule set 3 required at 900' + } + + it('reduces the payload to a named stall', function () { + expect(reduceIndexerStall(payload)).to.include({ + stalled: true, + word: 'train_activation_halt', + stall_class: 'train_activation_halt' + }) + }) + + it('names the indexer update command in the note', function () { + const note = describeIndexerStallNote('bitcoin', 'mainnet', reduceIndexerStall(payload)) + expect(note).to.include('xchain-node update xchain-indexer bitcoin mainnet') + }) +}) From bd3ce57f4b194d0c87efea2d9c547d9d067b96b4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 13:41:02 -0700 Subject: [PATCH 5/8] Clarify train halt stall comments Document the dedicated refusal path and the status reducer's treatment of the train activation halt class. --- src/services/bootstrap_health_gate/status_evaluation.js | 8 +++++--- src/services/status_service/status_reducers.js | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/bootstrap_health_gate/status_evaluation.js b/src/services/bootstrap_health_gate/status_evaluation.js index 38586a1a..3d7dbd19 100644 --- a/src/services/bootstrap_health_gate/status_evaluation.js +++ b/src/services/bootstrap_health_gate/status_evaluation.js @@ -135,9 +135,11 @@ function stallReasons(payload) { // The indexer's own single-field verdict on its block counter: 'none' | // 'future_block_wait' | 'bridge_proof_wait' | 'price_landing_wait' | 'barrier_defer' | // 'wedged', plus the published refinements 'upstream_halted' (only beside - // decoderReorgHalted: true, refused above) | 'rollcall_proof_wait' | 'rollcall_proof_stuck'. - // The set is defined in xchain-indexer src/XChainIndexer/stall_health.js (stallClassOf, - // publishedStallClass). Only 'wedged' is a refusal, and it needs its own + // decoderReorgHalted: true, refused above) | 'rollcall_proof_wait' | 'rollcall_proof_stuck' | + // 'train_activation_halt'. The set is defined in xchain-indexer + // src/XChainIndexer/stall_health.js (stallClassOf, publishedStallClass). Only 'wedged' + // is a refusal by itself; 'train_activation_halt' never is because the + // train_activation.status === 'halt' leg below already refuses it. 'wedged' needs its own // leg: the indexer reports status "healthy" // whenever its process is up and its DB circuit is closed, so a freshly-wedged // indexer whose lag is still inside the ceiling passes every other check here. diff --git a/src/services/status_service/status_reducers.js b/src/services/status_service/status_reducers.js index 25137337..77cedf8e 100644 --- a/src/services/status_service/status_reducers.js +++ b/src/services/status_service/status_reducers.js @@ -191,6 +191,8 @@ function describeReorgHaltNote(coin, network, reorgHalt) { // missing DOGE read, a VM executor down) shows at once, and so does any stall // the indexer graded wedged, or one an older image left ungraded. A bridge // proof wait shows too: it never wedges, so nothing else would surface it. +// A train activation halt also reads stalled: its `train_activation_halt` +// class is a diagnostic hint, not a healthy defer. function reduceIndexerStall(payload) { if (!payload || typeof payload !== 'object') return null const reason = typeof payload.stallReason === 'string' && payload.stallReason.trim() ? payload.stallReason.trim() : null From 5d312d16bca760c9ab44f1ed8d5b9b6db382b9a9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 17 Oct 2026 14:08:04 -0700 Subject: [PATCH 6/8] test stop-budget service restarts Add restart budget and failure-path coverage. Clarify the archive identity normalization contract in service and test comments. --- src/services/bootstrap_archive_meta.js | 5 +- test/unit/bootstrap_archive_meta.test.js | 5 +- test/unit/stop_budget_service_restart.test.js | 121 ++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 test/unit/stop_budget_service_restart.test.js diff --git a/src/services/bootstrap_archive_meta.js b/src/services/bootstrap_archive_meta.js index cd2a82fc..c3ea045e 100644 --- a/src/services/bootstrap_archive_meta.js +++ b/src/services/bootstrap_archive_meta.js @@ -171,8 +171,9 @@ async function readBootstrapArchiveMeta(archivePath) { const ARCHIVE_IDENTITY_FIELDS = ['module', 'coin', 'network'] -// An identity value trimmed and lowercased, or null when it is absent or blank: the rule the tracker's -// restore_validation.js applies to the same bootstrap.json, so both readers judge one archive alike. +// An identity value trimmed and lowercased, or null when it is absent or blank: the same normalization the tracker's +// restore_validation.js applies to bootstrap.json. Only the normalization is shared: a target lacking a value for a +// declared field is a mismatch here but unchecked in the tracker, so the two readers can reach different verdicts. function identityValue(value) { if (typeof value !== 'string') return null const v = value.trim().toLowerCase() diff --git a/test/unit/bootstrap_archive_meta.test.js b/test/unit/bootstrap_archive_meta.test.js index 61349513..45b47976 100644 --- a/test/unit/bootstrap_archive_meta.test.js +++ b/test/unit/bootstrap_archive_meta.test.js @@ -267,8 +267,9 @@ describe('BootstrapArchiveMeta', function () { }) }) -// The tracker's restorebootstrap reads the same bootstrap.json trimmed and lowercased; node must -// reach the same verdict on it, or one archive restores on one path and is refused on the other. +// The tracker's restorebootstrap reads the same bootstrap.json trimmed and lowercased; node must normalize identity +// values the same way (case, padding, blank as undeclared). The one intended difference, a target with no value for +// a declared field, is a mismatch here but unchecked in the tracker, and the last test below pins it. describe('BootstrapArchiveMeta identity normalization', function () { describe('compareArchiveIdentity() normalization shared with the tracker', function () { const target = { module: 'xchain-utxo-tracker', coin: 'bitcoin', network: 'mainnet' } diff --git a/test/unit/stop_budget_service_restart.test.js b/test/unit/stop_budget_service_restart.test.js new file mode 100644 index 00000000..4eb6cf87 --- /dev/null +++ b/test/unit/stop_budget_service_restart.test.js @@ -0,0 +1,121 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + **********************************************************************/ + +// Pins the restart path to the same stop budget as stop, update and reset. A bare +// `docker restart` gave an unstamped decoder docker's ten seconds and ignored a raised override. + +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') +const sinon = require('sinon') +const sbs = require('../../src/services/stop_budget_service') + +const SRC_DIR = path.join(__dirname, '..', '..', 'src') +const DECODER_300 = { XCHAIN_NODE_MODULE_STOP_TIMEOUT_SECONDS_XCHAIN_DECODER: '300' } + +let logStub, warnStub + +function prepareConsoleStubs() { + logStub = sinon.stub(console, 'log') + warnStub = sinon.stub(console, 'warn') +} + +function restoreConsoleStubs() { + logStub.restore() + warnStub.restore() +} + +// Return every .js file under a directory, relative to src/. +function listSourceFiles(dir) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...listSourceFiles(full)) + else if (entry.name.endsWith('.js')) out.push(path.relative(SRC_DIR, full)) + } + return out +} + +describe('StopBudgetService restartModuleContainer()', function () { + beforeEach(prepareConsoleStubs) + afterEach(restoreConsoleStubs) + + it('stops an unstamped decoder with its 120 s budget, then starts it', async function () { + const stop = sinon.stub().resolves({ stopped: true, seconds: 4, killed: false, exitCode: 0 }) + const start = sinon.stub().resolves(true) + const read = sinon.stub().resolves({ stopTimeout: null, shutdownTimeoutMs: null }) + await sbs.restartModuleContainer(stop, start, 'xchain-decoder', 'bitcoin', 'mainnet', 'abc123', {}, read) + expect(stop.calledOnceWith('abc123', 120)).to.equal(true) + expect(start.calledOnceWith('abc123')).to.equal(true) + expect(stop.calledBefore(start)).to.equal(true) + }) + + it('honours a raised override on a container stamped with the old budget', async function () { + const stop = sinon.stub().resolves({ stopped: true, seconds: 4, killed: false, exitCode: 0 }) + const read = sinon.stub().resolves({ stopTimeout: 120, shutdownTimeoutMs: '100000' }) + await sbs.restartModuleContainer(stop, sinon.stub().resolves(true), 'xchain-decoder', 'bitcoin', 'mainnet', + 'abc123', DECODER_300, read) + expect(stop.firstCall.args[1]).to.equal(300) + }) + + it('gives the coin node its own flush budget without reading service stop settings', async function () { + const stop = sinon.stub().resolves({ stopped: true, seconds: 4, killed: false, exitCode: 0 }) + const read = sinon.stub().resolves(null) + await sbs.restartModuleContainer(stop, sinon.stub().resolves(true), 'node', 'bitcoin', 'mainnet', 'n1', {}, read) + expect(stop.firstCall.args[1]).to.equal(require('../../src/services/node_service').nodeStopTimeoutSeconds()) + expect(read.called).to.equal(false) + }) + + it('returns the stop outcome, so a restart that ended in a kill is visible', async function () { + const stop = sinon.stub().resolves({ stopped: true, seconds: 120, killed: true, exitCode: 137 }) + const outcome = await sbs.restartModuleContainer(stop, sinon.stub().resolves(true), 'xchain-decoder', 'bitcoin', + 'mainnet', 'abc123', {}, null) + expect(outcome.killed).to.equal(true) + expect(outcome.budget).to.equal(120) + expect(warnStub.args.some(a => /did not exit within the 120 s budget and was killed/.test(String(a[0])))).to.equal(true) + }) +}) + +describe('StopBudgetService restartModuleContainer() failures', function () { + beforeEach(prepareConsoleStubs) + afterEach(restoreConsoleStubs) + + it('says the service was stopped but did not start again when the start fails', async function () { + const stop = sinon.stub().resolves({ stopped: true, seconds: 2, killed: false, exitCode: 0 }) + const start = sinon.stub().rejects(new Error('port is already allocated')) + const err = await sbs.restartModuleContainer(stop, start, 'xchain-indexer', 'bitcoin', 'mainnet', 'i1', {}, null) + .then(() => null, e => e) + expect(err).to.be.an('error') + expect(err.message).to.equal('xchain-indexer was stopped but failed to start again (port is already allocated)') + }) + + it('never starts a container whose stop failed, and passes docker\'s error through', async function () { + const gone = new Error('Error response from daemon: No such container: i1') + const stop = sinon.stub().rejects(gone) + const start = sinon.stub().resolves(true) + const err = await sbs.restartModuleContainer(stop, start, 'xchain-indexer', 'bitcoin', 'mainnet', 'i1', {}, null) + .then(() => null, e => e) + expect(err).to.equal(gone) + expect(start.called).to.equal(false) + }) +}) + +describe('StopBudgetService restart callers', function () { + it('leaves the unbudgeted docker restart primitive to the exited-hub restart alone', function () { + const callers = listSourceFiles(SRC_DIR) + .filter(rel => /\brestartContainer\(/.test(fs.readFileSync(path.join(SRC_DIR, rel), 'utf8'))) + .sort() + expect(callers).to.deep.equal(['services/docker_service.js', 'services/hub_service.js']) + }) +}) From 2b50025121ff1a738bfd00e08b469e651d074232 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 18 Oct 2026 13:37:59 -0700 Subject: [PATCH 7/8] Release v0.25.0 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc8cf4c..fea6d182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-10-18 + +### Changed +- Raised the provisioned MariaDB image to the 11 line. +- Split the config path and naming helpers into their own modules. +- Clarified the train activation halt stall comments and added tests for its stall handling and for service restarts. + ## [0.24.0] - 2026-10-17 ### Added diff --git a/package-lock.json b/package-lock.json index 34419d64..fa9727b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-node", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-node", - "version": "0.24.0", + "version": "0.25.0", "license": "AGPL-3.0-or-later", "dependencies": { "@dankest-llc/xchain-sdk": "^0.18.0", diff --git a/package.json b/package.json index a7c87548..cdad2486 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.24.0", + "version": "0.25.0", "private": true, "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", From e0a0385e24230074467cc2a7374889b9cc16fbab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 18 Oct 2026 23:24:26 -0700 Subject: [PATCH 8/8] Release v0.25.0 manifest --- src/release-manifest.json | 42 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index 941ddb8e..52821221 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,6 +1,6 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.24.0.", + "Pinned component set for XChain Platform v0.25.0.", "Generated by bin/write-release-manifest.js from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", "A train tags only the repos it touches. This one moves xchain-vm, xchain-decoder, xchain-indexer, xchain-hub, xchain-sync, xchain-encoder, xchain-utxo-tracker, xchain-explorer and xchain-sdk.", @@ -12,44 +12,44 @@ "repo's origin/master, and is a GPG-signed annotated tag verifying against the", "platform release key (fingerprint 1DA7C4896F56EA22CF491EDF4361611A82F90B70)." ], - "platform_version": "0.24.0", - "released": "2026-10-17", + "platform_version": "0.25.0", + "released": "2026-10-19", "components": { "xchain-vm": { - "tag": "v0.24.0", - "commit": "67b41add8817db1523e0add492a872746952a874" + "tag": "v0.25.0", + "commit": "a415f304c02207763d4bd4565a4be9e749846331" }, "xchain-decoder": { - "tag": "v0.24.0", - "commit": "800cb43bac94d5d8b7632181e935fb3668e0c12c" + "tag": "v0.25.0", + "commit": "509374321ab7076f22662f9768e01556d856d37d" }, "xchain-indexer": { - "tag": "v0.24.0", - "commit": "0b399defc7f44e2794379e6ddf37b803e282847b" + "tag": "v0.25.0", + "commit": "64a281981203572c3e1f85f43150b9dcf224f2f5" }, "xchain-hub": { - "tag": "v0.24.0", - "commit": "fea89a7ff6bfa94ad6dede2318696acc7a2f66e8" + "tag": "v0.25.0", + "commit": "86f959e6d4683b0c1f79cab97af72f5aab4ced10" }, "xchain-sync": { - "tag": "v0.24.0", - "commit": "0ef3ba957b8f7418f0e54e80df831270c010d665" + "tag": "v0.25.0", + "commit": "9a52ba21dd9f0de92b7875ef1854e4c2ceeca8f8" }, "xchain-encoder": { - "tag": "v0.24.0", - "commit": "c246a23ba17710337724fb45fee941cb8e58ca6d" + "tag": "v0.25.0", + "commit": "9253069e64aaf5b69f6e1c01fad743b6b46167c8" }, "xchain-utxo-tracker": { - "tag": "v0.24.0", - "commit": "8e15897e25ab483acd0981dda193227b7a276402" + "tag": "v0.25.0", + "commit": "9c6941bdddaa53a71f4f2fe273753da4389e17e2" }, "xchain-explorer": { - "tag": "v0.24.0", - "commit": "689949a8dcbdf79b037288f3efffecc0537857c0" + "tag": "v0.25.0", + "commit": "166f9f17edf2fd02881840a6fa4fe66f085c75ee" }, "xchain-sdk": { - "tag": "v0.24.0", - "commit": "b521fa41587495529df50e5b7f1e3c6a911e4a15" + "tag": "v0.25.0", + "commit": "0e34a9da6ac6875bb2cc4184b1ee744d42dde927" }, "xchain-e2e-test": { "tag": "v0.21.0",