diff --git a/cli/README.md b/cli/README.md index 46cf4cd..e04f469 100644 --- a/cli/README.md +++ b/cli/README.md @@ -437,11 +437,13 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync- ``` USAGE $ powersync deploy sync-config [--deploy-timeout ] [--directory ] [--instance-id --project-id - ] [--org-id ] + ] [--org-id ] [--sync-config-file-path ] FLAGS - --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling - status (default 300 seconds). + --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while + polling status (default 300 seconds). + --sync-config-file-path= Path to a sync config file. If provided, this file will be validated and deployed + instead of the default sync-config.yaml. PROJECT FLAGS --directory= [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index 7112840..70820f9 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -103,8 +103,8 @@ export default class DeployAll extends CloudInstanceCommand { }); } - override parseConfig(projectDirectory: string): ServiceCloudConfigDecoded { - const config = super.parseConfig(projectDirectory); + override parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded { + const config = super.parseLocalConfig(projectDirectory); /** * This is a temporary hack to maintain compatibilty with the PowerSync Dashboard. @@ -175,7 +175,7 @@ export default class DeployAll extends CloudInstanceCommand { const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; // Parse and store for later - this.parseConfig(project.projectDirectory); + this.parseLocalConfig(project.projectDirectory); // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index 8e0f649..b50be08 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -23,7 +23,7 @@ export default class DeployServiceConfig extends DeployAll { const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; // Parse and store for later - this.parseConfig(project.projectDirectory); + this.parseLocalConfig(project.projectDirectory); // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 0a65b8d..e2e2d11 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -1,6 +1,8 @@ +import { Flags } from '@oclif/core'; import { ux } from '@oclif/core/ux'; import { routes } from '@powersync/management-types'; import { ObjectId } from 'bson'; +import { readFileSync } from 'node:fs'; import { DEFAULT_DEPLOY_TIMEOUT_MS } from '../../api/cloud/wait-for-operation.js'; import DeployAll from './index.js'; @@ -12,7 +14,12 @@ export default class DeploySyncConfig extends DeployAll { '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' ]; static flags = { - ...DeployAll.flags + ...DeployAll.flags, + 'sync-config-file-path': Flags.file({ + description: + 'Path to a sync config file. If provided, this file will be validated and deployed instead of the default sync-config.yaml.', + exists: true + }) }; static summary = '[Cloud only] Deploy only local sync config to the linked Cloud instance.'; @@ -65,18 +72,27 @@ export default class DeploySyncConfig extends DeployAll { }); const { linked } = project; - this.parseConfig(project.projectDirectory); - const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; + const syncConfigFilePath = flags['sync-config-file-path']; + if (syncConfigFilePath) { + project.syncRulesContent = readFileSync(syncConfigFilePath, 'utf8'); + } + // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); + if (!cloudConfigState.config) { + this.styledError({ + message: `No existing cloud config found for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}. A config must be deployed before deploying sync config changes.`, + suggestions: [`Run ${ux.colorize('blue', 'powersync deploy')} to deploy the initial config.`] + }); + } + // We use the cloud config as the "local config for this" this.serviceConfig = { _type: linked.type, name: cloudConfigState.name, - region: cloudConfigState.config!.region!, ...cloudConfigState.config, ...linked }; diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index c3ccb37..a17064f 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -73,7 +73,7 @@ export default class LinkCloud extends CloudInstanceCommand { this.styledError({ message: error instanceof Error ? error.message : String(error) }); } - const config = this.parseConfig(projectDirectory); + const config = this.parseLocalConfig(projectDirectory); const { client } = this; let newInstanceId: string; diff --git a/cli/test/commands/deploy/service-config.test.ts b/cli/test/commands/deploy/service-config.test.ts new file mode 100644 index 0000000..a3cc7b7 --- /dev/null +++ b/cli/test/commands/deploy/service-config.test.ts @@ -0,0 +1,175 @@ +import { Config } from '@oclif/core'; +import { captureOutput } from '@oclif/test'; +import { CLI_FILENAME, env, SERVICE_FILENAME } from '@powersync/cli-core'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import DeployServiceConfig from '../../../src/commands/deploy/service-config.js'; +import { root } from '../../helpers/root.js'; +import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../../setup.js'; + +const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS; + +const MOCK_CLOUD_CONFIG = { + config: { + region: 'us', + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] } + }, + id: INSTANCE_ID, + name: 'test-instance', + sync_rules: /* yaml */ ` +bucket_definitions: + global: + data: + - SELECT * FROM todos +` +}; + +const SERVICE_YAML_CONTENT = /* yaml */ ` +_type: cloud +name: test-instance +region: us +replication: + connections: + - name: default + type: postgresql + uri: postgres://user:pass@host/db +`; + +/** Run deploy:service-config by instantiating the command directly so the managementClientMock spy applies. */ +async function runServiceConfigDirect(args: string[] = []) { + const config = await Config.load({ root }); + const cmd = new DeployServiceConfig(args, config); + cmd.client = managementClientMock as unknown as DeployServiceConfig['client']; + return captureOutput(() => cmd.run()); +} + +function makeProjectDir(tmpDir: string, subDir = 'powersync'): string { + const projectDir = join(tmpDir, subDir); + mkdirSync(projectDir, { recursive: true }); + return projectDir; +} + +function writeServiceYaml(projectDir: string): void { + writeFileSync(join(projectDir, SERVICE_FILENAME), SERVICE_YAML_CONTENT, 'utf8'); +} + +function writeLinkYaml(projectDir: string): void { + const content = `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`; + writeFileSync(join(projectDir, CLI_FILENAME), content, 'utf8'); +} + +describe('deploy:service-config', () => { + let tmpDir: string; + let origCwd: string; + let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string }; + + beforeEach(() => { + resetManagementClientMocks(); + + origCwd = process.cwd(); + origEnv = { + INSTANCE_ID: env.INSTANCE_ID, + ORG_ID: env.ORG_ID, + PROJECT_ID: env.PROJECT_ID, + PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN + }; + + tmpDir = mkdtempSync(join(tmpdir(), 'deploy-service-config-test-')); + process.chdir(tmpDir); + env.PS_ADMIN_TOKEN = 'test-token'; + env.INSTANCE_ID = undefined; + env.ORG_ID = undefined; + env.PROJECT_ID = undefined; + + managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG); + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); + managementClientMock.testConnection.mockResolvedValue({ + configuration: { success: true }, + connection: { reachable: true, success: true }, + success: true + }); + // deployInstance fails by default so tests don't need a real endpoint + managementClientMock.deployInstance.mockRejectedValue(new Error('mock deploy failure')); + }); + + afterEach(() => { + process.chdir(origCwd); + + env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; + env.INSTANCE_ID = origEnv.INSTANCE_ID; + env.ORG_ID = origEnv.ORG_ID; + env.PROJECT_ID = origEnv.PROJECT_ID; + + if (tmpDir && existsSync(tmpDir)) rmSync(tmpDir, { recursive: true }); + }); + + describe('succeeds without sync-config.yaml present', () => { + it('works with cli.yaml link file (no sync-config.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + writeServiceYaml(projectDir); + writeLinkYaml(projectDir); + // Intentionally no sync-config.yaml written + + const result = await runServiceConfigDirect(); + + // deployInstance is the last step; it failing means all prior validations passed + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + + it('works with --instance-id / --project-id / --org-id flags (no sync-config.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + writeServiceYaml(projectDir); + // No cli.yaml, no sync-config.yaml + + const result = await runServiceConfigDirect([ + '--instance-id', + INSTANCE_ID, + '--project-id', + PROJECT_ID, + '--org-id', + ORG_ID + ]); + + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + + it('works with INSTANCE_ID / ORG_ID / PROJECT_ID env vars (no sync-config.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + writeServiceYaml(projectDir); + // No cli.yaml, no sync-config.yaml + + env.INSTANCE_ID = INSTANCE_ID; + env.ORG_ID = ORG_ID; + env.PROJECT_ID = PROJECT_ID; + + const result = await runServiceConfigDirect(); + + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + }); + + it('errors when service.yaml is missing', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + // No service.yaml + + const result = await runServiceConfigDirect(); + + expect(result.error?.message).toMatch(/service\.yaml/); + expect(result.error?.oclif?.exit).toBe(1); + }); + + it('errors when linking info is absent entirely', async () => { + const projectDir = makeProjectDir(tmpDir); + writeServiceYaml(projectDir); + // No cli.yaml, no flags, no env vars + + const result = await runServiceConfigDirect(); + + expect(result.error?.message).toMatch(/Linking is required/); + expect(result.error?.oclif?.exit).toBe(1); + }); +}); diff --git a/cli/test/commands/deploy/sync-config.test.ts b/cli/test/commands/deploy/sync-config.test.ts new file mode 100644 index 0000000..800dc38 --- /dev/null +++ b/cli/test/commands/deploy/sync-config.test.ts @@ -0,0 +1,209 @@ +import { Config } from '@oclif/core'; +import { captureOutput } from '@oclif/test'; +import { CLI_FILENAME, env, SYNC_FILENAME } from '@powersync/cli-core'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import DeploySyncConfig from '../../../src/commands/deploy/sync-config.js'; +import { root } from '../../helpers/root.js'; +import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../../setup.js'; + +const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS; + +const MOCK_CLOUD_CONFIG = { + config: { + region: 'us', + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] } + }, + name: 'test-instance', + sync_rules: '' +}; + +const SYNC_CONFIG_CONTENT = /* yaml */ ` +bucket_definitions: + global: + data: + - SELECT * FROM todos +`; + +/** Run deploy:sync-config by instantiating the command directly so the managementClientMock spy applies. */ +async function runSyncConfigDirect(args: string[] = []) { + const config = await Config.load({ root }); + const cmd = new DeploySyncConfig(args, config); + cmd.client = managementClientMock as unknown as DeploySyncConfig['client']; + return captureOutput(() => cmd.run()); +} + +function makeProjectDir(tmpDir: string, subDir = 'powersync'): string { + const projectDir = join(tmpDir, subDir); + mkdirSync(projectDir, { recursive: true }); + return projectDir; +} + +function writeLinkYaml(projectDir: string): void { + const content = `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`; + writeFileSync(join(projectDir, CLI_FILENAME), content, 'utf8'); +} + +describe('deploy:sync-config', () => { + let tmpDir: string; + let origCwd: string; + let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string }; + + beforeEach(() => { + resetManagementClientMocks(); + + origCwd = process.cwd(); + origEnv = { + INSTANCE_ID: env.INSTANCE_ID, + ORG_ID: env.ORG_ID, + PROJECT_ID: env.PROJECT_ID, + PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN + }; + + tmpDir = mkdtempSync(join(tmpdir(), 'deploy-sync-config-test-')); + process.chdir(tmpDir); + env.PS_ADMIN_TOKEN = 'test-token'; + env.INSTANCE_ID = undefined; + env.ORG_ID = undefined; + env.PROJECT_ID = undefined; + + managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG); + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); + managementClientMock.validateSyncRules.mockResolvedValue({ errors: [] }); + // deployInstance fails by default so tests don't need a real endpoint + managementClientMock.deployInstance.mockRejectedValue(new Error('mock deploy failure')); + }); + + afterEach(() => { + process.chdir(origCwd); + + env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; + env.INSTANCE_ID = origEnv.INSTANCE_ID; + env.ORG_ID = origEnv.ORG_ID; + env.PROJECT_ID = origEnv.PROJECT_ID; + + if (tmpDir && existsSync(tmpDir)) rmSync(tmpDir, { recursive: true }); + }); + + describe('succeeds without service.yaml present', () => { + it('works with cli.yaml link file (no service.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + // Intentionally no service.yaml written + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + const result = await runSyncConfigDirect(); + + // deployInstance is the last step; it failing means all prior validations passed + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + + it('works with --instance-id / --project-id / --org-id flags (no service.yaml, no cli.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + // No service.yaml, no cli.yaml + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + const result = await runSyncConfigDirect([ + '--instance-id', + INSTANCE_ID, + '--project-id', + PROJECT_ID, + '--org-id', + ORG_ID + ]); + + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + + it('works with INSTANCE_ID / ORG_ID / PROJECT_ID env vars (no service.yaml, no cli.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + // No service.yaml, no cli.yaml + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + env.INSTANCE_ID = INSTANCE_ID; + env.ORG_ID = ORG_ID; + env.PROJECT_ID = PROJECT_ID; + const result = await runSyncConfigDirect(); + + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + }); + + it('errors when linking info is absent entirely', async () => { + const projectDir = makeProjectDir(tmpDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + const result = await runSyncConfigDirect(); + + expect(result.error?.message).toMatch(/Linking is required/); + expect(result.error?.oclif?.exit).toBe(1); + }); + + it('errors when no existing cloud config is found', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + managementClientMock.getInstanceConfig.mockResolvedValue({ config: null, name: 'test-instance', sync_rules: '' }); + + const result = await runSyncConfigDirect(); + + expect(result.error?.message).toMatch(/No existing cloud config found/); + expect(result.error?.oclif?.exit).toBe(1); + }); + + it('errors when sync-config.yaml is missing', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + // No sync-config.yaml + + const result = await runSyncConfigDirect(); + + expect(result.error?.message).toMatch(/Sync config content not loaded/); + expect(result.error?.oclif?.exit).toBe(1); + }); + + it('reads sync config from --sync-config-file-path when provided (no default sync-config.yaml)', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + // No default sync-config.yaml; write the config at a custom path instead + const customSyncConfigPath = join(tmpDir, 'my-custom-sync.yaml'); + writeFileSync(customSyncConfigPath, SYNC_CONFIG_CONTENT, 'utf8'); + + const result = await runSyncConfigDirect(['--sync-config-file-path', customSyncConfigPath]); + + expect(result.error?.message).toMatch(/mock deploy failure/); + }); + + it('--sync-config-file-path takes precedence over default sync-config.yaml', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + // Write a different sync config at the default location + writeFileSync(join(projectDir, SYNC_FILENAME), 'bucket_definitions: {}\n', 'utf8'); + // Write the expected content at the custom path + const customSyncConfigPath = join(tmpDir, 'override-sync.yaml'); + writeFileSync(customSyncConfigPath, SYNC_CONFIG_CONTENT, 'utf8'); + + managementClientMock.validateSyncRules.mockImplementation(({ sync_rules }) => { + expect(sync_rules).toBe(SYNC_CONFIG_CONTENT); + return Promise.resolve({ errors: [] }); + }); + + const result = await runSyncConfigDirect(['--sync-config-file-path', customSyncConfigPath]); + + expect(result.error?.message).toMatch(/mock deploy failure/); + expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); + }); + + it('errors when --sync-config-file-path points to a non-existent file', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + + const result = await runSyncConfigDirect(['--sync-config-file-path', '/nonexistent/path/sync.yaml']); + + expect(result.error?.message).toMatch(/nonexistent/); + }); +}); diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 771f0db..b15e3e9 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -80,7 +80,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { */ client: PowerSyncManagementClient = createCloudClient(); /** - * The parsed service config from the service.yaml file. Call parseConfig() before accessing this property. This is set to the parsed config after calling parseConfig() to avoid multiple parses of the same config. + * The parsed service config from the service.yaml file. Call parseLocalConfig() before accessing this property. This is set to the parsed config after calling parseLocalConfig() to avoid multiple parses of the same config. */ protected serviceConfig: null | ServiceCloudConfigDecoded = null; @@ -208,7 +208,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { return this._project; } - parseConfig(projectDirectory: string): ServiceCloudConfigDecoded { + parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded { const servicePath = join(projectDirectory, SERVICE_FILENAME); const doc = parseYamlFile(servicePath); diff --git a/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts b/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts index 92145b6..47247a1 100644 --- a/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts @@ -113,7 +113,7 @@ export abstract class SelfHostedInstanceCommand extends InstanceCommand { return this._project; } - parseConfig(projectDirectory: string): ServiceSelfHostedConfigDecoded { + parseLocalConfig(projectDirectory: string): ServiceSelfHostedConfigDecoded { const servicePath = join(projectDirectory, SERVICE_FILENAME); const doc = parseYamlFile(servicePath);