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
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.
31 changes: 23 additions & 8 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 @@ -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 });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice to be logging the current instance here, but, at this point - is it not too late if there is a mistake? It looks like the deploy will continue after logging the instance.

Perhaps we could add a dedicated command to print the resolved instance (if using env vars or cli.yaml). Or add some optional interaction to these commands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe an optional flag like --dry or --checked to halt / pause execution and request the user to confirm the action:

$ powersync deploy --instance-id="6a98319739369b69bd8fc57f" --checked

Found instance: production (id "6a98319739369b69bd8fc57f")
> Confirm "deploy" action for instance "production"? (y/N) n

Operation canceled.

This could also be the default behaviour with an --unchecked flag instead, but that would obviously need a breaking release:

$ powersync deploy --instance-id="6a98319739369b69bd8fc57f"

Found instance: production (id "6a98319739369b69bd8fc57f")
> Confirm "deploy" action for instance "production"? (y/N) y

Deployed instance "production" with ID "6a98319739369b69bd8fc57f".

======

$ powersync deploy --instance-id="6a98319739369b69bd8fc57f" --unchecked

> Warning: Using `--unchecked` with potentially dangerous action (deploy).

Found instance: production (id "6a98319739369b69bd8fc57f")

Deployed instance "production" with ID "6a98319739369b69bd8fc57f".

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the --dry-run like option. We could even perhaps show a basic diff of the config changes which would be deployed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this idea but wouldn't this be a breaking change? Existing CI workflows would just hang if the cli is upgraded.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --dry-run flag would only change behaviour if a dev explicitly added it to the command invocation - so that one would not be a breaking change.

Adding optional interaction, should also only be opt-in, by specifying some flag (like the --checked option mentioned above).

The --unchecked example is a breaking change.

I'm more in favour of the --dry-run option, since it's not breaking and could actually be useful if we showed a diff.


// 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