Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/show-target-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@powersync/cli-core': patch
'powersync': patch
---

Show the target instance name and IDs before `deploy`, `deploy sync-config`, `deploy service-config`, `stop`, `destroy` and `compact` do anything, so it is clear which instance is about to be changed. `status` shows the target first as well, with the API URL for self-hosted instances. `deploy` and `deploy service-config` now also warn when the local `service.yaml` `name` differs from the instance name, since deploying renames the instance.

All deploy commands accept `--dry-run`, which prints the target instance, runs the validations, shows a diff of the sync config and the changed service config sections, and stops without deploying.
33 changes: 24 additions & 9 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ powersync link cloud --create --project-id=<project-id> # add --org-id if toke
powersync deploy
```

Use `--directory` for a different config folder. The **powersync init cloud** command has a `--vscode` flag to configure your workspace for YAML custom tag support.
Use `--directory` for a different config folder. Add `--dry-run` to a deploy command to print the target instance, run the validations and see what would change, without deploying. The **powersync init cloud** command has a `--vscode` flag to configure your workspace for YAML custom tag support.

## Cloud secrets format (`service.yaml`)

Expand Down Expand Up @@ -261,7 +261,7 @@ Example (Cloud):
PS_ADMIN_TOKEN=your-token INSTANCE_ID=123 powersync status
```

See [docs/usage.md](../docs/usage.md) for full usage and resolution order (flags, env, cli.yaml).
See [docs/usage.md](../docs/usage.md) for full usage and resolution order (flags, cli.yaml, env).

# Commands

Expand Down Expand Up @@ -429,12 +429,14 @@ _See code: [src/commands/configure/ide.ts](https://github.com/powersync-ja/power

```
USAGE
$ powersync deploy [--deploy-timeout <value>] [--directory <value>] [--instance-id <value>]
$ powersync deploy [--deploy-timeout <value>] [--dry-run] [--directory <value>] [--instance-id <value>]
[--sync-config-file-path <value>] [--skip-validations <value> | --validate-only <value>]

FLAGS
--deploy-timeout=<value> [default: 300] Seconds to wait after scheduling a deploy before timing out while polling
status (default 300 seconds).
--dry-run Show the target instance, run the validations and print what would change, then exit
without deploying.
--skip-validations=<value> Comma-separated list of validation tests to skip. Options: configuration, connections,
sync-config. Example: --skip-validations="configuration"
--validate-only=<value> Comma-separated list of validation tests to run, skipping all others. Options:
Expand All @@ -457,10 +459,13 @@ DESCRIPTION
Validates connections and sync config before deploying.
See also powersync deploy sync-config to deploy only sync config changes.
See also powersync deploy service-config to deploy only service config changes.
Use --dry-run to show the target instance, the validation results and what would change, without deploying.

EXAMPLES
$ powersync deploy

$ powersync deploy --dry-run

$ powersync deploy --instance-id=<id>
```

Expand All @@ -472,12 +477,14 @@ _See code: [src/commands/deploy/index.ts](https://github.com/powersync-ja/powers

```
USAGE
$ powersync deploy service-config [--deploy-timeout <value>] [--directory <value>] [--instance-id <value>]
$ powersync deploy service-config [--deploy-timeout <value>] [--dry-run] [--directory <value>] [--instance-id <value>]
[--skip-validations <value> | --validate-only <value>]

FLAGS
--deploy-timeout=<value> [default: 300] Seconds to wait after scheduling a deploy before timing out while polling
status (default 300 seconds).
--dry-run Show the target instance, run the validations and print what would change, then exit
without deploying.
--skip-validations=<value> Comma-separated list of validation tests to skip. Options: configuration, connections.
Example: --skip-validations="configuration"
--validate-only=<value> Comma-separated list of validation tests to run, skipping all others. Options:
Expand All @@ -494,11 +501,14 @@ CLOUD_PROJECT FLAGS
DESCRIPTION
[Cloud only] Deploy only local service config to the linked Cloud instance.

Deploy only service config changes (without sync config updates).
Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance, the
validation results and what would change, without deploying.

EXAMPLES
$ powersync deploy service-config

$ powersync deploy service-config --dry-run

$ powersync deploy service-config --instance-id=<id>
```

Expand All @@ -510,12 +520,14 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync-

```
USAGE
$ powersync deploy sync-config [--deploy-timeout <value>] [--directory <value>] [--instance-id <value>]
$ powersync deploy sync-config [--deploy-timeout <value>] [--dry-run] [--directory <value>] [--instance-id <value>]
[--sync-config-file-path <value>] [--skip-validations <value> | ]

FLAGS
--deploy-timeout=<value> [default: 300] Seconds to wait after scheduling a deploy before timing out while polling
status (default 300 seconds).
--dry-run Show the target instance, run the validations and print what would change, then exit
without deploying.
--skip-validations=<value> Comma-separated list of validation tests to skip. Options: sync-config. Example:
--skip-validations="sync-config"

Expand All @@ -532,11 +544,14 @@ CLOUD_PROJECT FLAGS
DESCRIPTION
[Cloud only] Deploy only local sync config to the linked Cloud instance.

Deploy only sync config changes.
Deploy only sync config changes. Use --dry-run to show the target instance, the validation results and what would
change, without deploying.

EXAMPLES
$ powersync deploy sync-config

$ powersync deploy sync-config --dry-run

$ powersync deploy sync-config --instance-id=<id>
```

Expand Down Expand Up @@ -842,7 +857,7 @@ DESCRIPTION
Show instance diagnostics (connections, sync config, replication).

Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as
human-readable, JSON, or YAML. Cloud and self-hosted.
human-readable, JSON, or YAML. Human output starts with the target instance. Cloud and self-hosted.

EXAMPLES
$ powersync fetch status
Expand Down Expand Up @@ -1509,7 +1524,7 @@ DESCRIPTION
Show instance diagnostics (connections, sync config, replication).

Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as
human-readable, JSON, or YAML. Cloud and self-hosted.
human-readable, JSON, or YAML. Human output starts with the target instance. Cloud and self-hosted.

EXAMPLES
$ powersync status
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@powersync/service-types": "catalog:",
"@powersync/sync-config-tools": "^0.1.2",
"bson": "^7.2.0",
"diff": "^8.0.4",
"fastify": "^5.8.5",
"jose": "^6.2.3",
"lodash": "^4.18.1",
Expand Down
76 changes: 76 additions & 0 deletions cli/src/api/BaseDeployCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { routes } from '@powersync/management-types';
import ora from 'ora';

import { DEFAULT_DEPLOY_TIMEOUT_MS, waitForOperationStatusChange } from './cloud/wait-for-operation.js';
import { changedServiceConfigSections, formatSyncConfigDiff } from './dry-run.js';
import { parseLocalCloudServiceConfig } from './parse-local-cloud-service-config.js';

export default abstract class BaseDeployCommand extends CloudInstanceCommand {
Expand All @@ -26,6 +27,11 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
return value;
}
}),
'dry-run': Flags.boolean({
default: false,
description:
'Show the target instance, run the validations and print what would change, then exit without deploying.'
}),
...CloudInstanceCommand.baseFlags
};

Expand Down Expand Up @@ -75,6 +81,22 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
});
}

protected describeServiceConfigChanges(cloudConfigState: routes.InstanceConfigResponse): string {
const summary = `would deploy ${SERVICE_FILENAME}.`;
if (!cloudConfigState.config) {
return `${summary} No config is deployed yet.`;
}

const sections = changedServiceConfigSections(this.serviceConfig!, cloudConfigState);
if (!sections) {
return `${summary} Could not compare with the deployed config.`;
}

return sections.length > 0
? `${summary} Changes in: ${sections.join(', ')}.`
: `${summary} No changes compared to the deployed config.`;
}

protected async loadCloudConfigState(): Promise<routes.InstanceConfigResponse> {
const { client, project } = this;
const { linked } = project;
Expand All @@ -92,6 +114,45 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
});
}

/**
* Ends a --dry-run once the target and validation results are shown: reports what a real run would deploy.
* Set provisionFirst when the instance is deprovisioned, since a real run would provision it before deploying.
*/
protected logDryRun(params: {
cloudConfigState: routes.InstanceConfigResponse;
provisionFirst?: boolean;
/** Whether the command sends service.yaml. */
serviceConfig: boolean;
/** Whether the command sends the local sync config. */
syncConfig: boolean;
}): void {
const { cloudConfigState, provisionFirst = false, serviceConfig, syncConfig } = params;
const { syncRulesContent } = this.project;

this.log('');
if (provisionFirst) {
this.log(
`The instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.`
);
}

this.log(ux.colorize('yellow', 'Dry run: nothing was deployed.'));
this.log(
`\tService config: ${serviceConfig ? this.describeServiceConfigChanges(cloudConfigState) : 'not changed by this command.'}`
);

if (!syncConfig) {
this.log('\tSync config: not changed by this command.');
} else if (syncRulesContent === cloudConfigState.sync_rules) {
this.log('\tSync config: matches the deployed sync config, nothing to update.');
} else {
this.log('\tSync config: would deploy the local sync config. Diff against the deployed sync config:');
for (const line of formatSyncConfigDiff(cloudConfigState.sync_rules ?? '', syncRulesContent ?? '')) {
this.log(`\t\t${line}`);
}
}
}

override parseLocalConfig(projectDirectory: string, useRawConfig?: boolean): ServiceCloudConfigDecoded {
const config = parseLocalCloudServiceConfig(projectDirectory, useRawConfig ?? false);
if (!config) {
Expand Down Expand Up @@ -272,6 +333,21 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
}
}

/**
* Deploying sends the local service.yaml `name` as the instance name, so a deploy renames the
* instance if the two differ. Warn so users targeting several instances from one config notice.
*/
protected warnIfDeployRenamesInstance(cloudConfigState: routes.InstanceConfigResponse): void {
const localName = this.serviceConfig?.name;
if (!localName || localName === cloudConfigState.name) {
return;
}

this.warn(
`Deploying will rename the instance from "${cloudConfigState.name}" to "${localName}" because ${SERVICE_FILENAME} has name: ${localName}.`
);
}

protected async withDeploy(timeoutMs: number, fn: () => Promise<routes.DeployInstanceResponse>): Promise<void> {
const { project } = this;

Expand Down
46 changes: 46 additions & 0 deletions cli/src/api/dry-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ux } from '@oclif/core';
import { AdditionalCloudConfigFields, ServiceCloudConfigDecoded } from '@powersync/cli-schemas';
import { routes } from '@powersync/management-types';
import { structuredPatch } from 'diff';
import isEqual from 'lodash/isEqual.js';

import { decodeFetchedCloudConfig } from './cloud/fetch-cloud-config.js';

const CLI_ONLY_FIELDS = new Set(Object.keys(AdditionalCloudConfigFields.props.shape));

function colorizeDiffLine(line: string): string {
if (line.startsWith('+')) return ux.colorize('green', line);
if (line.startsWith('-')) return ux.colorize('red', line);
return line;
}

/**
* Names the top-level service config sections whose local value differs from the deployed one.
* Returns undefined when the deployed config cannot be decoded for comparison.
*/
export function changedServiceConfigSections(
localConfig: ServiceCloudConfigDecoded,
cloudConfigState: routes.InstanceConfigResponse
): string[] | undefined {
let deployed: Record<string, unknown>;
try {
deployed = decodeFetchedCloudConfig(cloudConfigState).config as Record<string, unknown>;
} catch {
return undefined;
}

const local = localConfig as Record<string, unknown>;
const sections = new Set([...Object.keys(deployed), ...Object.keys(local)]);
return [...sections]
.filter((section) => !CLI_ONLY_FIELDS.has(section) && !isEqual(local[section], deployed[section]))
.sort();
}

/** Unified diff of the deployed sync config against the local one, one colorized entry per line. Empty when identical. */
export function formatSyncConfigDiff(deployed: string, local: string): string[] {
const { hunks } = structuredPatch('deployed', 'local', deployed, local);
return hunks.flatMap((hunk) => [
ux.colorize('cyan', `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`),
...hunk.lines.map((line) => colorizeDiffLine(line))
]);
}
3 changes: 2 additions & 1 deletion cli/src/commands/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ export default class Compact extends CloudInstanceCommand {
async run(): Promise<void> {
const { flags } = await this.parse(Compact);
const { linked } = await this.loadProject(flags);
const instanceLabel = await this.logTargetInstance();
const { client } = this;
const timeoutMs = flags.timeout === 0 ? Number.POSITIVE_INFINITY : flags.timeout * 60 * 1000;

const spinner = ora({
discardStdin: false,
prefixText: `\n${ux.colorize('yellow', 'Compacting')} instance ${ux.colorize('blue', linked.instance_id)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`,
prefixText: `\n${ux.colorize('yellow', 'Compacting')} instance ${ux.colorize('blue', instanceLabel)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`,
spinner: 'moon',
suffixText: '\nThis may take a few minutes.\n'
});
Expand Down
23 changes: 21 additions & 2 deletions cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand)
'Deploy local config (service.yaml, sync config) to the linked PowerSync Cloud instance.',
'Validates connections and sync config before deploying.',
`See also ${ux.colorize('blue', 'powersync deploy sync-config')} to deploy only sync config changes.`,
`See also ${ux.colorize('blue', 'powersync deploy service-config')} to deploy only service config changes.`
`See also ${ux.colorize('blue', 'powersync deploy service-config')} to deploy only service config changes.`,
'Use --dry-run to show the target instance, the validation results and what would change, without deploying.'
].join('\n');
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=<id>'];
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --dry-run',
'<%= config.bin %> <%= command.id %> --instance-id=<id>'
];
static flags = {
...GENERAL_VALIDATION_FLAG_HELPERS.flags
};
Expand All @@ -30,16 +35,19 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand)
});

const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000;
const dryRun = flags['dry-run'];

const validationTestsFilter = GENERAL_VALIDATION_FLAG_HELPERS.parseValidationTestFlags(flags);

const cloudConfigState = await this.loadCloudConfigState();
await this.logTargetInstance({ instanceName: cloudConfigState.name });
Comment thread
bean1352 marked this conversation as resolved.

// Parse and store for later
this.parseLocalConfig(
project.projectDirectory,
validationTestsFilter.skipped.includes(ValidationTest.CONFIGURATION)
);
this.warnIfDeployRenamesInstance(cloudConfigState);

// Start of validations
this.log('Performing validations before deploy...');
Expand All @@ -60,6 +68,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand)

const requiresReprovision = instanceStatus.provisioned === false;
const syncConfigHasChanges = project.syncRulesContent !== cloudConfigState.sync_rules;
const dryRunSummary = { cloudConfigState, serviceConfig: true, syncConfig: true };

let didReprovision = false;

Expand Down Expand Up @@ -90,6 +99,11 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand)
});
}

if (dryRun) {
this.logDryRun({ ...dryRunSummary, provisionFirst: true });
return;
}

/**
* The non-sync-config validations passed. Reprovision now so that the instance is active
* and we can validate the sync config against it in the second pass below.
Expand Down Expand Up @@ -139,6 +153,11 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand)
});
}

if (dryRun) {
this.logDryRun(dryRunSummary);
return;
}

await this.deployAll({ cloudConfigState, deployTimeoutMs, updateSyncConfig: syncConfigHasChanges });
}
}
Loading
Loading