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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
39 changes: 39 additions & 0 deletions packages/dashmate/docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,42 @@ dashmate config get <option>
# Enable debug logging
dashmate config set core.log.debug.enabled true
```

## Running Dashmate commands concurrently

Dashmate keeps all configuration in a single `config.json` inside its home directory
(`~/.dashmate` by default).

Commands that change a configuration option — `dashmate config set` and friends — read,
change and save that file as one locked step. Two of them running at once cannot lose each
other's work: if one sets a Core RPC port while another pins a Drive image, both settings
survive. When no long-running operation owns the lock, a command waits only for the locked
read and write.

Read-only commands such as `dashmate config get`, `dashmate status` and `dashmate core cli`
normally do not write configuration. The first command after an upgrade may migrate and save
`config.json`; that migration needs the same lock and can time out behind a long-running
configuration change.

### While a node is being reconfigured

`dashmate setup`, `dashmate reset`, `dashmate group reset`, `dashmate ssl obtain`,
`dashmate core reindex` and `dashmate group core reindex` change or render configuration
while doing long work, so they take the lock for their whole run. Another command that
needs the lock waits briefly and then reports that something else is modifying it — nothing
is lost, and running it again once the first command finishes works normally.

The Dashmate helper uses the same whole-operation lock while renewing an SSL certificate.
For ZeroSSL this includes HTTP validation and may take minutes. A configuration-changing
command started during background renewal can therefore reach its 15-second timeout and
report that another Dashmate command is modifying configuration. Retry it after renewal
finishes. Keeping the lock for issuance is intentional: releasing it earlier would require
replaying selected renewal fields later, which could undo an operator's provider switch or
SSL disable.

Ordinary reads remain available: `dashmate status`, `dashmate config get` and
`dashmate core cli` do not take the lock unless loading the configuration discovers a
migration that must be saved.

Graceful termination releases the lock. After `SIGKILL` or a power loss, the next writer
takes over after about a minute.
4 changes: 3 additions & 1 deletion packages/dashmate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,16 @@
"node-graceful": "^3.0.1",
"pretty-bytes": "^5.3.0",
"pretty-ms": "^7.0.0",
"proper-lockfile": "^4.1.2",
"public-ip": "^6.0.1",
"qs": "^6.14.2",
"rxjs": "^6.6.7",
"semver": "^7.5.3",
"systeminformation": "^5.31.1",
"table": "^6.8.1",
"tar": "7.5.10",
"wrap-ansi": "^7.0.0"
"wrap-ansi": "^7.0.0",
"write-file-atomic": "^5.0.1"
},
"devDependencies": {
"@babel/core": "^7.26.10",
Expand Down
56 changes: 35 additions & 21 deletions packages/dashmate/scripts/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import dotenv from 'dotenv';
import { asValue } from 'awilix';
import graceful from 'node-graceful';
import createDIContainer from '../src/createDIContainer.js';
import scheduleRenewCertificate from '../src/helper/scheduleRenewCertificate.js';
import watchCertificateConfig from '../src/helper/watchCertificateConfig.js';

// The ephemeral containers SSL providers bind to port 80 during issuance.
// Either can be left orphaned if a previous helper run crashed mid-renewal.
Expand Down Expand Up @@ -90,16 +92,10 @@ async function removeOrphanedSslContainers(docker) {
*/
const writeConfigTemplates = container.resolve('writeConfigTemplates');

const configFile = await configFileRepository.read();

// Persist config if it was migrated
if (configFile.isChanged()) {
await configFileRepository.write(configFile);

configFile.getAllConfigs()
.filter((config) => config.isChanged())
.forEach(writeConfigTemplates);
}
const { configFile } = configFileRepository.readAndMigrate(
{},
(migratedConfigs) => migratedConfigs.forEach(writeConfigTemplates),
);

const config = configFile.getConfig(configName);

Expand All @@ -119,17 +115,35 @@ async function removeOrphanedSslContainers(docker) {
await removeOrphanedSslContainers(docker);
}

if (isEnabled && provider === 'zerossl') {
const scheduleRenewZeroSslCertificate = container.resolve('scheduleRenewZeroSslCertificate');
await scheduleRenewZeroSslCertificate(config);
} else if (isEnabled && provider === 'letsencrypt') {
const scheduleRenewLetsEncryptCertificate = container.resolve('scheduleRenewLetsEncryptCertificate');
await scheduleRenewLetsEncryptCertificate(config);
} else {
// prevent infinite restarts
setInterval(() => {
}, 60 * 1000);
}
const scheduleRenewZeroSslCertificate = container.resolve('scheduleRenewZeroSslCertificate');
const scheduleRenewLetsEncryptCertificate = container.resolve('scheduleRenewLetsEncryptCertificate');
const watchInactiveConfig = (inactiveConfig, onActivated) => watchCertificateConfig(
inactiveConfig,
null,
configFileRepository,
async (currentConfig) => {
if (!currentConfig) {
return false;
}

return onActivated(currentConfig);
},
(e) => {
// eslint-disable-next-line no-console
console.error(`Failed to check configuration for certificate renewal: ${e.message}`);
},
);
await scheduleRenewCertificate(
config,
scheduleRenewZeroSslCertificate,
scheduleRenewLetsEncryptCertificate,
watchInactiveConfig,
);

// Keep the helper alive when renewal is disabled, the config is removed, or
// a provider change stops the only scheduled job.
setInterval(() => {
}, 60 * 1000);

if (config.get('dashmate.helper.api.enable')) {
const createHttpApiServer = container.resolve('createHttpApiServer');
Expand Down
12 changes: 11 additions & 1 deletion packages/dashmate/src/commands/config/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ export default class ConfigCreateCommand extends BaseCommand {
},
flags,
configFile,
configFileRepository,
writeConfigTemplates,
) {
configFile.createConfig(configName, fromConfigName);
// Read, change and save in one locked step, so a config created here cannot
// revert a change another command saved in the meantime.
configFileRepository.update((updatedConfigFile) => {
updatedConfigFile.createConfig(configName, fromConfigName);
}, {
// The new config needs its service files, and rendering them inside the
// lock keeps them consistent with what was saved.
onSaved: (savedConfigFile) => writeConfigTemplates(savedConfigFile.getConfig(configName)),
});

// eslint-disable-next-line no-console
console.log(`${configName} created`);
Expand Down
7 changes: 6 additions & 1 deletion packages/dashmate/src/commands/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ Shows default config name or sets another config as default
},
flags,
configFile,
configFileRepository,
) {
if (configName === null) {
// eslint-disable-next-line no-console
console.log(configFile.getDefaultConfigName());
} else {
configFile.setDefaultConfigName(configName);
// Read, change and save in one locked step, so pointing the default at a
// config cannot revert a change another command saved in the meantime.
configFileRepository.update((freshConfigFile) => {
freshConfigFile.setDefaultConfigName(configName);
});

// eslint-disable-next-line no-console
console.log(`${configName} config set as default`);
Expand Down
18 changes: 13 additions & 5 deletions packages/dashmate/src/commands/config/remove.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,26 @@ export default class ConfigRemoveCommand extends BaseCommand {
configFile,
defaultConfigs,
homeDir,
configFileRepository,
) {
if (defaultConfigs.has(configName)) {
throw new Error(`system config ${configName} can't be removed.\nPlease use 'dashmate reset --hard --config=${configName}' command to reset the configuration`);
}

const serviceConfigsPath = resolveConfigDirectory(homeDir, configName);

configFile.removeConfig(configName);

fs.rmSync(serviceConfigsPath, {
recursive: true,
force: true,
// Read, change and save in one locked step. Removing from the state loaded
// at startup would revert anything another command saved in the meantime,
// and removing a config another command already removed now fails here
// instead of writing.
configFileRepository.update((freshConfigFile) => {
freshConfigFile.removeConfig(configName);
}, {
// Only once the removal is saved, and while the lock is still held:
// deleting first would leave the service files gone while config.json
// still listed the config if saving failed, and deleting after releasing
// could remove files a concurrent re-creation had just written.
onSaved: () => fs.rmSync(serviceConfigsPath, { recursive: true, force: true }),
});

// eslint-disable-next-line no-console
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/config/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { OUTPUT_FORMATS } from '../../constants.js';
import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js';

export default class ConfigRenderCommand extends ConfigBaseCommand {
// Rendering replaces the service configuration files, so serialize it with
// commands that save and render config.json.
static mutatesConfig = true;

static description = `Render config's service configs

Force dashmate to render all config's service configs
Expand Down
17 changes: 16 additions & 1 deletion packages/dashmate/src/commands/config/set.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
},
flags,
config,
configFileRepository,
writeConfigTemplates,
) {
// Validate the path against the schema, not against the currently-set
// value. `config.get(...)` would throw `InvalidOptionPathError` for any
Expand All @@ -55,11 +57,24 @@

try {
value = JSON.parse(optionValue);
} catch (e) {

Check warning on line 60 in packages/dashmate/src/commands/config/set.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

'e' is defined but never used
value = optionValue;
}

config.set(optionPath, value);
// Read, change and save in one locked step, against the config name resolved
// for this command rather than re-resolving the default, which another
// process may have changed. Mutating the copy loaded at startup and saving it
// on exit would write a snapshot that is already out of date, reverting
// anything saved in between.
const configName = config.getName();

configFileRepository.update((freshConfigFile) => {
freshConfigFile.getConfig(configName).set(optionPath, value);
}, {
// Rendered inside the lock, so two commands changing the same config
// cannot save in one order and render in the other.
onSaved: (savedConfigFile) => writeConfigTemplates(savedConfigFile.getConfig(configName)),
});

// eslint-disable-next-line no-console
console.log(`${optionPath} set to ${optionValue}`);
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/core/reindex.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js';
import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js';

export default class ReindexCommand extends ConfigBaseCommand {
// Reindex temporarily replaces the Core service configuration, so other
// configuration renderers must wait until it restores the ordinary files.
static mutatesConfig = true;

static description = 'Reindex Core data';

static flags = {
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/group/core/reindex.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import GroupBaseCommand from '../../../oclif/command/GroupBaseCommand.js';
import MuteOneLineError from '../../../oclif/errors/MuteOneLineError.js';

export default class GroupReindexCommand extends GroupBaseCommand {
// Reindex temporarily replaces each Core service configuration, so other
// configuration renderers must wait until it restores the ordinary files.
static mutatesConfig = true;

static description = 'Reindex group Core data';

static flags = {
Expand Down
7 changes: 6 additions & 1 deletion packages/dashmate/src/commands/group/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ Shows default group name or sets another group as default
},
flags,
configFile,
configFileRepository,
) {
if (groupName === null) {
// eslint-disable-next-line no-console
console.log(configFile.getDefaultGroupName());
} else {
configFile.setDefaultGroupName(groupName);
// Read, change and save in one locked step, so pointing the default at a
// group cannot revert a change another command saved in the meantime.
configFileRepository.update((freshConfigFile) => {
freshConfigFile.setDefaultGroupName(groupName);
});

// eslint-disable-next-line no-console
console.log(`${groupName} group set as default`);
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/group/reset.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js';
import { PRESET_LOCAL } from '../../constants.js';

export default class GroupResetCommand extends GroupBaseCommand {
// Reconfigures the node: changes configuration repeatedly while doing long,
// partly irreversible work, so it holds the config lock for its whole run.
static mutatesConfig = true;

static description = 'Reset group nodes';

static flags = {
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/reset.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import ConfigBaseCommand from '../oclif/command/ConfigBaseCommand.js';
import MuteOneLineError from '../oclif/errors/MuteOneLineError.js';

export default class ResetCommand extends ConfigBaseCommand {
// Reconfigures the node: changes configuration repeatedly while doing long,
// partly irreversible work, so it holds the config lock for its whole run.
static mutatesConfig = true;

static description = 'Reset node data';

static flags = {
Expand Down
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from '../constants.js';

export default class SetupCommand extends BaseCommand {
// Reconfigures the node: changes configuration repeatedly while doing long,
// partly irreversible work, so it holds the config lock for its whole run.
static mutatesConfig = true;

static description = 'Set up a new Dash node';

static args = {
Expand Down
Loading
Loading