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.
28 changes: 28 additions & 0 deletions packages/dashmate/docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,31 @@ 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. A command that has to wait for the lock waits milliseconds, since the lock is only
held for a read and a write.

Read-only commands such as `dashmate config get`, `dashmate status` and `dashmate core cli`
never write configuration at all, so they are always safe to run alongside anything else.

### While a node is being reconfigured

`dashmate setup`, `dashmate reset` and `dashmate ssl obtain` change configuration
repeatedly while doing long work, so they take the lock for their whole run. Another
command that changes configuration 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.

Reading is never affected: `dashmate status`, `dashmate config get` and `dashmate core cli`
do not take the lock at all, so a node can still be inspected while it is being set up.

If a command holding the lock is killed, the lock is released. If the machine loses power
mid-command, 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
9 changes: 6 additions & 3 deletions packages/dashmate/scripts/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,14 @@ async function removeOrphanedSslContainers(docker) {

// Persist config if it was migrated
if (configFile.isChanged()) {
// Captured before the write, which clears these flags once the new state is
// on disk.
const changedConfigs = configFile.getAllConfigs()
.filter((config) => config.isChanged());

await configFileRepository.write(configFile);

configFile.getAllConfigs()
.filter((config) => config.isChanged())
.forEach(writeConfigTemplates);
changedConfigs.forEach(writeConfigTemplates);
}

const config = configFile.getConfig(configName);
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
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
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
4 changes: 4 additions & 0 deletions packages/dashmate/src/commands/ssl/obtain.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import LegoCertificate from '../../ssl/letsencrypt/LegoCertificate.js';
import { SSL_PROVIDERS } from '../../constants.js';

export default class ObtainCommand 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 = `Obtain SSL certificate

Create a new SSL certificate or download an already existing one using ZeroSSL or Let's Encrypt as provider
Expand Down
8 changes: 7 additions & 1 deletion packages/dashmate/src/config/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@
assertSafeConfigName(name);

this.name = name;
this.changed = false;

this.setOptions(options, skipValidation);

// Hydration is not a mutation. setOptions() marks the config changed because
// it is a genuine edit when called on an existing config, but a config that
// was just loaded has nothing unsaved. Callers that build a config which must
// reach disk - the default set for a new config file, createConfig() - mark it
// changed themselves.
this.changed = false;
}

/**
Expand Down Expand Up @@ -97,34 +103,34 @@
* @return {boolean} true when the path is allowed by the schema.
*/
static isSchemaPathAllowed(path) {
if (typeof path !== 'string' || path.length === 0) return false;

Check warning on line 106 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition

// Reject empty segments (leading/trailing/double dots, e.g. `a..b` or
// `…buildArgs.`) — an empty key must not slip through a map's
// `additionalProperties` descent.
const pathSegments = path.split('.');
if (pathSegments.some((segment) => segment.length === 0)) return false;

Check warning on line 112 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition

const resolveRef = (node) => {
if (!node || typeof node !== 'object') return node;

Check warning on line 115 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition
if (typeof node.$ref !== 'string') return node;

Check warning on line 116 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition
const ref = node.$ref;
if (!ref.startsWith('#/')) return null;

Check warning on line 118 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition
const segments = ref.slice(2).split('/');
let resolved = configJsonSchema;
for (const seg of segments) {
if (!resolved || typeof resolved !== 'object') return null;

Check warning on line 122 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition
resolved = resolved[seg];
}
return resolveRef(resolved);
};

let node = resolveRef(configJsonSchema);
if (!node) return false;

Check warning on line 129 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition

for (const segment of pathSegments) {
node = resolveRef(node);
if (!node || typeof node !== 'object') return false;

Check warning on line 133 in packages/dashmate/src/config/Config.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

Expected { after 'if' condition

// Typed property.
if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, segment)) {
Expand Down
Loading
Loading