diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index e21b2111e8..eb75c00722 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -268,6 +268,7 @@ export default function getBaseConfigFactory() { context: path.join(PACKAGE_ROOT_DIR, '..', '..'), dockerFile: path.join(PACKAGE_ROOT_DIR, '..', '..', 'Dockerfile'), target: 'rs-dapi', + buildArgs: {}, }, }, metrics: { @@ -293,6 +294,9 @@ export default function getBaseConfigFactory() { context: path.join(PACKAGE_ROOT_DIR, '..', '..'), dockerFile: path.join(PACKAGE_ROOT_DIR, '..', '..', 'Dockerfile'), target: 'drive-abci', + // Extra Docker build args — see the `buildArgs` field on + // `dockerBuild` in the config schema. + buildArgs: {}, }, }, logs: { diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index adaa072685..df42492a30 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1433,6 +1433,17 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) .get('platform.drive.tenderdash.docker.image'); } + // Backfill the new `buildArgs: {}` field on each build block — + // forwarded into `dynamic-compose.yml` as `build.args` entries. + if (options.platform?.drive?.abci?.docker?.build + && typeof options.platform.drive.abci.docker.build.buildArgs === 'undefined') { + options.platform.drive.abci.docker.build.buildArgs = {}; + } + if (options.platform?.dapi?.rsDapi?.docker?.build + && typeof options.platform.dapi.rsDapi.docker.build.buildArgs === 'undefined') { + options.platform.dapi.rsDapi.docker.build.buildArgs = {}; + } + if (options.platform?.drive?.tenderdash?.p2p && typeof options.platform.drive.tenderdash.p2p.allowlistOnly === 'undefined') { options.platform.drive.tenderdash.p2p.allowlistOnly = defaultConfig diff --git a/packages/dashmate/docker-compose.build.drive_abci.yml b/packages/dashmate/docker-compose.build.drive_abci.yml index 480f49eda2..aec9498aca 100644 --- a/packages/dashmate/docker-compose.build.drive_abci.yml +++ b/packages/dashmate/docker-compose.build.drive_abci.yml @@ -15,7 +15,10 @@ services: SCCACHE_BUCKET: ${SCCACHE_BUCKET} SCCACHE_REGION: ${SCCACHE_REGION} SCCACHE_S3_KEY_PREFIX: ${SCCACHE_S3_KEY_PREFIX} - SDK_TEST_DATA: ${SDK_TEST_DATA} + # Additional build args (SDK_TEST_DATA, CARGO_BUILD_PROFILE, …) are + # injected per-config by dashmate from + # `platform.drive.abci.docker.build.buildArgs`, rendered into + # `dynamic-compose.yml`, and merged with this file at compose time. secrets: - GITHUB_TOKEN cache_from: diff --git a/packages/dashmate/docker-compose.build.rs-dapi.yml b/packages/dashmate/docker-compose.build.rs-dapi.yml index 82192f13bc..95920dcadf 100644 --- a/packages/dashmate/docker-compose.build.rs-dapi.yml +++ b/packages/dashmate/docker-compose.build.rs-dapi.yml @@ -15,6 +15,8 @@ services: SCCACHE_BUCKET: ${SCCACHE_BUCKET} SCCACHE_REGION: ${SCCACHE_REGION} SCCACHE_S3_KEY_PREFIX: ${SCCACHE_S3_KEY_PREFIX} + # Additional build args are injected per-config by dashmate from + # `platform.dapi.rsDapi.docker.build.buildArgs` via dynamic-compose. secrets: - GITHUB_TOKEN cache_from: diff --git a/packages/dashmate/src/commands/config/set.js b/packages/dashmate/src/commands/config/set.js index aae44f9ef7..4f6e27992c 100644 --- a/packages/dashmate/src/commands/config/set.js +++ b/packages/dashmate/src/commands/config/set.js @@ -1,5 +1,7 @@ import { Args } from '@oclif/core'; import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js'; +import Config from '../../config/Config.js'; +import InvalidOptionPathError from '../../config/errors/InvalidOptionPathError.js'; export default class ConfigSetCommand extends ConfigBaseCommand { static description = `Set config option @@ -38,8 +40,16 @@ Sets a configuration option in the default config flags, config, ) { - // check for existence - config.get(optionPath); + // Validate the path against the schema, not against the currently-set + // value. `config.get(...)` would throw `InvalidOptionPathError` for any + // key inside a map-shaped property (e.g. `…buildArgs.SDK_TEST_DATA`) + // because the value doesn't exist yet — that gate is the wrong shape for + // schemas that use `additionalProperties: ` to model open maps. + // `Config.isSchemaPathAllowed` walks the schema and permits descent into + // both typed `properties` and `additionalProperties` value schemas. + if (!Config.isSchemaPathAllowed(optionPath)) { + throw new InvalidOptionPathError(optionPath); + } let value; diff --git a/packages/dashmate/src/config/Config.js b/packages/dashmate/src/config/Config.js index 2644bef698..80dbb95b29 100644 --- a/packages/dashmate/src/config/Config.js +++ b/packages/dashmate/src/config/Config.js @@ -45,6 +45,74 @@ export default class Config { return lodashGet(this.options, path) !== undefined; } + /** + * Check whether a path is reachable per the config JSON schema (regardless + * of whether a value is currently set there). + * + * Use this when checking the legality of a `set` to a path that doesn't yet + * have a value — notably under map-shaped properties whose schema uses + * `additionalProperties: ` (e.g. `…build.buildArgs.`), where + * `config.has(...)` will return `false` even though `config.set(...)` is + * semantically legal. + * + * `configJsonSchema` IS the per-config schema — the top-level + * `properties: { description, group, docker, core, platform, … }` describes + * one config entry. Walks it descending through: + * - `properties[segment]` (typed field), + * - `additionalProperties` (variable-key map, only when the value is a + * schema object — `additionalProperties: false` blocks the descent), + * - `$ref` references into `#/definitions/...`. + * + * @param {string} path - dot-separated option path (e.g. + * `'platform.drive.abci.docker.build.buildArgs.SDK_TEST_DATA'`). + * @return {boolean} true when the path is allowed by the schema. + */ + static isSchemaPathAllowed(path) { + if (typeof path !== 'string' || path.length === 0) return false; + + const resolveRef = (node) => { + if (!node || typeof node !== 'object') return node; + if (typeof node.$ref !== 'string') return node; + const ref = node.$ref; + if (!ref.startsWith('#/')) return null; + const segments = ref.slice(2).split('/'); + let resolved = configJsonSchema; + for (const seg of segments) { + if (!resolved || typeof resolved !== 'object') return null; + resolved = resolved[seg]; + } + return resolveRef(resolved); + }; + + let node = resolveRef(configJsonSchema); + if (!node) return false; + + for (const segment of path.split('.')) { + node = resolveRef(node); + if (!node || typeof node !== 'object') return false; + + // Typed property. + if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, segment)) { + node = node.properties[segment]; + continue; + } + + // Map with a schema for extra keys — descend into the value schema. + if ( + node.additionalProperties + && typeof node.additionalProperties === 'object' + ) { + node = node.additionalProperties; + continue; + } + + // No match and no permissive additionalProperties — path not allowed. + return false; + } + + return true; + } + /** * Get config option * diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index ecf969bde6..aa34d870e9 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -32,6 +32,15 @@ export default { target: { type: ['string', 'null'], }, + // Extra build args forwarded to `docker compose build` for this + // image. Each key/value pair becomes a `build.args` entry rendered + // into the per-config `dynamic-compose.yml` and picked up by + // compose at build time. Open-ended — image-specific keys live + // here (`CARGO_BUILD_PROFILE`, `SDK_TEST_DATA`, …). + buildArgs: { + type: 'object', + additionalProperties: { type: 'string' }, + }, }, required: ['enabled', 'context', 'dockerFile', 'target'], additionalProperties: false, diff --git a/packages/dashmate/templates/dynamic-compose.yml.dot b/packages/dashmate/templates/dynamic-compose.yml.dot index b0e723cfac..6b3e90302e 100644 --- a/packages/dashmate/templates/dynamic-compose.yml.dot +++ b/packages/dashmate/templates/dynamic-compose.yml.dot @@ -13,13 +13,23 @@ services: {{?}} {{ driveLogs = Object.entries(it.platform.drive.abci.logs).filter(([, settings]) => settings.destination !== 'stderr' && settings.destination !== 'stdout'); }} - {{? driveLogs.length > 0 }} + {{ driveBuildArgs = Object.entries((it.platform.drive.abci.docker.build && it.platform.drive.abci.docker.build.buildArgs) || {}); }} + {{? driveLogs.length > 0 || driveBuildArgs.length > 0 }} drive_abci: + {{? driveLogs.length > 0 }} volumes: {{~ driveLogs :logger }} {{ [name, settings] = logger; }} - {{=settings.destination}}:/var/log/dash/drive/{{=name}}/{{=settings.destination.split('/').reverse()[0]}} {{~}} + {{?}} + {{? driveBuildArgs.length > 0 }} + build: + args: + {{~ driveBuildArgs :pair }} + {{=pair[0]}}: "{{=pair[1]}}" + {{~}} + {{?}} {{?}} {{? it.platform.drive.tenderdash.log.path !== null }} @@ -29,6 +39,7 @@ services: {{?}} {{? it.platform.dapi && it.platform.dapi.rsDapi }} + {{ rsDapiBuildArgs = Object.entries((it.platform.dapi.rsDapi.docker.build && it.platform.dapi.rsDapi.docker.build.buildArgs) || {}); }} rs_dapi: expose: - 3009 @@ -40,6 +51,13 @@ services: ports: - {{=it.platform.dapi.rsDapi.metrics.host}}:{{=it.platform.dapi.rsDapi.metrics.port}}:{{=it.platform.dapi.rsDapi.metrics.port}} {{?}} + {{? rsDapiBuildArgs.length > 0 }} + build: + args: + {{~ rsDapiBuildArgs :pair }} + {{=pair[0]}}: "{{=pair[1]}}" + {{~}} + {{?}} {{?}} {{ gatewayLogs = it.platform.gateway.log.accessLogs.filter((l) => l.type === 'file'); }} diff --git a/packages/dashmate/test/unit/config/Config.spec.js b/packages/dashmate/test/unit/config/Config.spec.js new file mode 100644 index 0000000000..0d10b89d6f --- /dev/null +++ b/packages/dashmate/test/unit/config/Config.spec.js @@ -0,0 +1,143 @@ +import { expect } from 'chai'; +import Config from '../../../src/config/Config.js'; + +describe('Config', () => { + describe('.isSchemaPathAllowed', () => { + // The bug that triggered this method: `dashmate config set + // platform.drive.abci.docker.build.buildArgs.SDK_TEST_DATA true` + // was failing because the old `config.get(path)` pre-check rejected + // any path whose value isn't already stored — including new keys + // inside map-shaped properties whose schema legally accepts them + // via `additionalProperties: `. Each test pins one slice + // of the schema-walk so a regression surfaces fast. + + describe('typed properties', () => { + it('accepts a deeply-nested path that traverses only `properties`', () => { + expect( + Config.isSchemaPathAllowed('platform.drive.abci.docker.build.enabled'), + ).to.be.true(); + }); + + it('accepts a top-level property', () => { + expect(Config.isSchemaPathAllowed('network')).to.be.true(); + }); + + it('rejects a top-level typo', () => { + // `platfom` (typo) is not in top-level `properties` and the schema + // has `additionalProperties: false`, so it must not be allowed. + expect( + Config.isSchemaPathAllowed('platfom.drive.abci.docker.build.enabled'), + ).to.be.false(); + }); + + it('rejects a typo in the middle of the path', () => { + expect( + Config.isSchemaPathAllowed('platform.drive.abc.docker.build.enabled'), + ).to.be.false(); + }); + }); + + describe('map-shaped properties (additionalProperties: )', () => { + it('accepts a new key inside a value-keyed map', () => { + // The original failure. `buildArgs` is defined as + // `{ type: 'object', additionalProperties: { type: 'string' } }` + // so any key with a string value is schema-legal even if not yet set. + expect( + Config.isSchemaPathAllowed( + 'platform.drive.abci.docker.build.buildArgs.SDK_TEST_DATA', + ), + ).to.be.true(); + }); + + it('accepts an arbitrary key inside the map (the whole point)', () => { + expect( + Config.isSchemaPathAllowed( + 'platform.drive.abci.docker.build.buildArgs.ANY_KEY_AT_ALL', + ), + ).to.be.true(); + }); + + it('accepts the map property itself', () => { + expect( + Config.isSchemaPathAllowed('platform.drive.abci.docker.build.buildArgs'), + ).to.be.true(); + }); + }); + + describe('$ref traversal', () => { + it('descends through a $ref to `#/definitions/dockerBuild`', () => { + // `platform.drive.abci.docker.build` resolves via $ref to the shared + // `dockerBuild` definition — buildArgs is defined there. + expect( + Config.isSchemaPathAllowed( + 'platform.drive.abci.docker.build.buildArgs.X', + ), + ).to.be.true(); + }); + + it('descends through a $ref for a sibling Rust build (rs-dapi)', () => { + expect( + Config.isSchemaPathAllowed( + 'platform.dapi.rsDapi.docker.build.buildArgs.X', + ), + ).to.be.true(); + }); + }); + + describe('edge cases', () => { + it('rejects an empty path', () => { + expect(Config.isSchemaPathAllowed('')).to.be.false(); + }); + + it('rejects a non-string path', () => { + expect(Config.isSchemaPathAllowed(null)).to.be.false(); + expect(Config.isSchemaPathAllowed(undefined)).to.be.false(); + expect(Config.isSchemaPathAllowed(42)).to.be.false(); + }); + + it('rejects descending past a leaf primitive', () => { + // `network` is a string at top level; you cannot index further. + expect(Config.isSchemaPathAllowed('network.something')).to.be.false(); + }); + }); + }); + + describe('regression: paths the buggy pre-check rejected are now permitted', () => { + // Before the fix, `dashmate config set` did a value-existence pre-check + // (`config.get(path)`) that threw `InvalidOptionPathError` for any path + // whose value wasn't already stored. That blocked legal sets under + // map-shaped properties (`additionalProperties: `). The fix + // replaced the pre-check with `isSchemaPathAllowed`. These tests pin + // each path the original failure surfaced through. + + it('permits the original failing path (`…buildArgs.SDK_TEST_DATA`)', () => { + expect( + Config.isSchemaPathAllowed( + 'platform.drive.abci.docker.build.buildArgs.SDK_TEST_DATA', + ), + ).to.be.true(); + }); + + it('permits the same shape for rs-dapi (parallel Rust build)', () => { + expect( + Config.isSchemaPathAllowed( + 'platform.dapi.rsDapi.docker.build.buildArgs.CARGO_BUILD_PROFILE', + ), + ).to.be.true(); + }); + + it('still permits the canonical typed paths that the pre-check used to handle', () => { + // Sanity: paths whose values DO exist after `dashmate setup local` — + // the original pre-check used to gate these via `config.get`. The + // schema walker must accept them too, or the CLI breaks for everyone. + for (const path of [ + 'platform.drive.abci.docker.build.enabled', + 'platform.drive.abci.docker.image', + 'platform.dapi.rsDapi.docker.build.enabled', + 'core.insight.enabled', + ]) { + expect(Config.isSchemaPathAllowed(path), `path: ${path}`).to.be.true(); + } + }); + }); +}); diff --git a/packages/dashmate/test/unit/templates/dynamicComposeBuildArgs.spec.js b/packages/dashmate/test/unit/templates/dynamicComposeBuildArgs.spec.js new file mode 100644 index 0000000000..5434cfdb16 --- /dev/null +++ b/packages/dashmate/test/unit/templates/dynamicComposeBuildArgs.spec.js @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dot from 'dot'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const TEMPLATE_PATH = path.resolve( + __dirname, + '../../../templates/dynamic-compose.yml.dot', +); + +function render(buildArgsByService) { + dot.templateSettings.strip = false; + const tpl = fs.readFileSync(TEMPLATE_PATH, 'utf8'); + const fn = dot.template(tpl); + return fn({ + core: { docker: { commandArgs: [] }, log: { filePath: null } }, + platform: { + drive: { + abci: { + logs: {}, + docker: { build: { buildArgs: buildArgsByService.drive || {} } }, + }, + tenderdash: { log: { path: null } }, + }, + dapi: { + rsDapi: { + metrics: { enabled: false }, + docker: { build: { buildArgs: buildArgsByService.rsDapi || {} } }, + }, + }, + gateway: { log: { accessLogs: [] } }, + }, + }); +} + +describe('dynamic-compose buildArgs rendering', () => { + it('emits drive_abci build.args from drive.abci.docker.build.buildArgs', () => { + const out = render({ + drive: { SDK_TEST_DATA: 'true', CARGO_BUILD_PROFILE: 'release' }, + }); + expect(out).to.match(/drive_abci:[\s\S]*build:[\s\S]*args:[\s\S]*SDK_TEST_DATA:\s*"true"/); + expect(out).to.match(/drive_abci:[\s\S]*CARGO_BUILD_PROFILE:\s*"release"/); + }); + + it('emits rs_dapi build.args from dapi.rsDapi.docker.build.buildArgs', () => { + const out = render({ + rsDapi: { CARGO_BUILD_PROFILE: 'release' }, + }); + expect(out).to.match(/rs_dapi:[\s\S]*build:[\s\S]*args:[\s\S]*CARGO_BUILD_PROFILE:\s*"release"/); + }); + + it('omits the build block entirely when buildArgs is empty', () => { + const out = render({}); + // The drive_abci service block is only emitted by this template when + // either driveLogs or driveBuildArgs has entries — empty buildArgs + + // empty logs ⇒ no drive_abci section at all. + expect(out).to.not.match(/drive_abci:\s*\n\s*build:/); + // rs_dapi is emitted regardless (it carries the expose stanza); but it + // must NOT carry a build section when buildArgs is empty. + expect(out).to.not.match(/rs_dapi:[\s\S]*build:\s*\n\s*args:/); + }); +}); diff --git a/scripts/setup_local_network.sh b/scripts/setup_local_network.sh index 543262b3ab..6676f9d889 100755 --- a/scripts/setup_local_network.sh +++ b/scripts/setup_local_network.sh @@ -16,3 +16,10 @@ yarn run dashmate setup local --verbose \ # enable insight yarn dashmate config set core.insight.enabled true --config local_seed + +# Enable SDK_TEST_DATA in drive-abci builds for each local masternode so the +# genesis shielded-pool seeder + identity/contract fixtures run at bring-up. +for i in $(seq 1 ${MASTERNODES_COUNT}); do + yarn dashmate config set --config=local_${i} \ + platform.drive.abci.docker.build.buildArgs.SDK_TEST_DATA "true" +done