neonctl 1.26.2 → 1.27.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,11 @@ import { EndpointType } from '@neondatabase/api-client';
2
2
  import { writer } from '../writer.js';
3
3
  import { branchCreateRequest } from '../parameters.gen.js';
4
4
  import { retryOnLock } from '../api.js';
5
- import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
5
+ import { branchIdFromProps, branchIdResolve, fillSingleProject, } from '../utils/enrichers.js';
6
6
  import { looksLikeBranchId, looksLikeLSN, looksLikeTimestamp, } from '../utils/formats.js';
7
7
  import { psql } from '../utils/psql.js';
8
+ import { parsePointInTime } from '../utils/point_in_time.js';
9
+ import { log } from '../log.js';
8
10
  const BRANCH_FIELDS = [
9
11
  'id',
10
12
  'name',
@@ -72,6 +74,38 @@ export const builder = (argv) => argv
72
74
  describe: 'Name under which to preserve the old branch',
73
75
  },
74
76
  }), async (args) => await reset(args))
77
+ .command('restore <target-id|name> <source>[@(timestamp|lsn)]', 'Restores a branch to a specific point in time\n<source> can be: ^self, ^parent, or <source-branch-id|name>', (yargs) => yargs
78
+ // we want to show meaningful help for the command
79
+ // but it makes yargs to fail on parsing the command
80
+ // so we need to fill in the missing args manually
81
+ .middleware((args) => {
82
+ args.id = args.targetId;
83
+ args.pointInTime = args['source@(timestamp'];
84
+ })
85
+ .usage('$0 branches restore <target-id|name> <source>[@(timestamp|lsn)]')
86
+ .options({
87
+ 'preserve-under-name': {
88
+ describe: 'Name under which to preserve the old branch',
89
+ },
90
+ })
91
+ .example([
92
+ [
93
+ '$0 branches restore main br-source-branch-123456',
94
+ 'Restore main to the head of the branch with id br-source-branch-123456',
95
+ ],
96
+ [
97
+ '$0 branches restore main source@2021-01-01T00:00:00Z',
98
+ 'Restore main to the timestamp 2021-01-01T00:00:00Z of the source branch',
99
+ ],
100
+ [
101
+ '$0 branches restore my-branch ^self@0/123456',
102
+ 'Restore my-branch to the LSN 0/123456 of the branch itself',
103
+ ],
104
+ [
105
+ '$0 branches restore my-branch ^parent',
106
+ 'Restore my-branch to the head of the parent branch',
107
+ ],
108
+ ]), async (args) => await restore(args))
75
109
  .command('rename <id|name> <new-name>', 'Rename a branch', (yargs) => yargs, async (args) => await rename(args))
76
110
  .command('set-primary <id|name>', 'Set a branch as primary', (yargs) => yargs, async (args) => await setPrimary(args))
77
111
  .command('add-compute <id|name>', 'Add a compute to a branch', (yargs) => yargs.options({
@@ -233,3 +267,45 @@ const reset = async (props) => {
233
267
  fields: BRANCH_FIELDS_RESET,
234
268
  });
235
269
  };
270
+ const restore = async (props) => {
271
+ const targetBranchId = await branchIdResolve({
272
+ branch: props.id,
273
+ projectId: props.projectId,
274
+ apiClient: props.apiClient,
275
+ });
276
+ const pointInTime = await parsePointInTime({
277
+ pointInTime: props.pointInTime,
278
+ targetBranchId,
279
+ projectId: props.projectId,
280
+ api: props.apiClient,
281
+ });
282
+ log.info(`Restoring branch ${targetBranchId} to the branch ${pointInTime.branchId} ${(pointInTime.tag === 'lsn' && 'LSN ' + pointInTime.lsn) ||
283
+ (pointInTime.tag === 'timestamp' &&
284
+ 'timestamp ' + pointInTime.timestamp) ||
285
+ 'head'}`);
286
+ const { data } = await retryOnLock(() => props.apiClient.request({
287
+ method: 'POST',
288
+ path: `/projects/${props.projectId}/branches/${targetBranchId}/reset`,
289
+ body: {
290
+ source_branch_id: pointInTime.branchId,
291
+ preserve_under_name: props.preserveUnderName || undefined,
292
+ ...(pointInTime.tag === 'lsn' && { source_lsn: pointInTime.lsn }),
293
+ ...(pointInTime.tag === 'timestamp' && {
294
+ source_timestamp: pointInTime.timestamp,
295
+ }),
296
+ },
297
+ }));
298
+ const branch = data.branch;
299
+ const writeInst = writer(props).write(branch, {
300
+ title: 'Restored branch',
301
+ fields: ['id', 'name', 'last_reset_at'],
302
+ });
303
+ if (props.preserveUnderName && branch.parent_id) {
304
+ const { data } = await props.apiClient.getProjectBranch(props.projectId, branch.parent_id);
305
+ writeInst.write(data.branch, {
306
+ title: 'Backup branch',
307
+ fields: ['id', 'name'],
308
+ });
309
+ }
310
+ writeInst.end();
311
+ };
@@ -1,6 +1,7 @@
1
1
  import { describe } from '@jest/globals';
2
2
  import { testCliCommand } from '../test_utils/test_cli_command.js';
3
3
  describe('branches', () => {
4
+ /* list */
4
5
  testCliCommand({
5
6
  name: 'list',
6
7
  args: ['branches', 'list', '--project-id', 'test'],
@@ -8,6 +9,7 @@ describe('branches', () => {
8
9
  snapshot: true,
9
10
  },
10
11
  });
12
+ /* create */
11
13
  testCliCommand({
12
14
  name: 'create by default with r/w endpoint',
13
15
  args: [
@@ -150,6 +152,7 @@ describe('branches', () => {
150
152
  snapshot: true,
151
153
  },
152
154
  });
155
+ /* delete */
153
156
  testCliCommand({
154
157
  name: 'delete by id',
155
158
  args: [
@@ -163,19 +166,7 @@ describe('branches', () => {
163
166
  snapshot: true,
164
167
  },
165
168
  });
166
- testCliCommand({
167
- name: 'delete by id',
168
- args: [
169
- 'branches',
170
- 'delete',
171
- 'br-cloudy-branch-12345678',
172
- '--project-id',
173
- 'test',
174
- ],
175
- expected: {
176
- snapshot: true,
177
- },
178
- });
169
+ /* rename */
179
170
  testCliCommand({
180
171
  name: 'rename',
181
172
  args: [
@@ -190,6 +181,7 @@ describe('branches', () => {
190
181
  snapshot: true,
191
182
  },
192
183
  });
184
+ /* set primary */
193
185
  testCliCommand({
194
186
  name: 'set primary by id',
195
187
  args: [
@@ -203,19 +195,7 @@ describe('branches', () => {
203
195
  snapshot: true,
204
196
  },
205
197
  });
206
- testCliCommand({
207
- name: 'set primary by id',
208
- args: [
209
- 'branches',
210
- 'set-primary',
211
- 'br-cloudy-branch-12345678',
212
- '--project-id',
213
- 'test',
214
- ],
215
- expected: {
216
- snapshot: true,
217
- },
218
- });
198
+ /* get */
219
199
  testCliCommand({
220
200
  name: 'get by id',
221
201
  args: ['branches', 'get', 'br-sunny-branch-123456', '--project-id', 'test'],
@@ -250,6 +230,7 @@ describe('branches', () => {
250
230
  snapshot: true,
251
231
  },
252
232
  });
233
+ /* add compute */
253
234
  testCliCommand({
254
235
  name: 'add compute',
255
236
  args: ['branches', 'add-compute', 'test_branch', '--project-id', 'test'],
@@ -257,6 +238,7 @@ describe('branches', () => {
257
238
  snapshot: true,
258
239
  },
259
240
  });
241
+ /* reset */
260
242
  testCliCommand({
261
243
  name: 'reset branch to parent',
262
244
  args: [
@@ -271,4 +253,69 @@ describe('branches', () => {
271
253
  snapshot: true,
272
254
  },
273
255
  });
256
+ /* restore */
257
+ testCliCommand({
258
+ name: 'restore branch to lsn',
259
+ args: [
260
+ 'branches',
261
+ 'restore',
262
+ 'br-self-tolsn-123456',
263
+ '^self@0/123ABC',
264
+ '--project-id',
265
+ 'test',
266
+ '--preserve-under-name',
267
+ 'backup',
268
+ ],
269
+ mockDir: 'restore',
270
+ expected: {
271
+ snapshot: true,
272
+ },
273
+ });
274
+ testCliCommand({
275
+ name: 'restore to parent branch timestamp by name',
276
+ args: [
277
+ 'branches',
278
+ 'restore',
279
+ 'parent-tots',
280
+ '^parent@2021-01-01T00:00:00.000Z',
281
+ '--project-id',
282
+ 'test',
283
+ ],
284
+ mockDir: 'restore',
285
+ expected: {
286
+ snapshot: true,
287
+ },
288
+ });
289
+ testCliCommand({
290
+ name: 'restore to another branch head',
291
+ args: [
292
+ 'branches',
293
+ 'restore',
294
+ 'br-another-branch-123456',
295
+ 'br-any-branch-123456',
296
+ '--project-id',
297
+ 'test',
298
+ ],
299
+ mockDir: 'restore',
300
+ expected: {
301
+ snapshot: true,
302
+ },
303
+ });
304
+ testCliCommand({
305
+ name: 'restore with unexisted branch outputs error',
306
+ args: [
307
+ 'branches',
308
+ 'restore',
309
+ 'unexisting-branch',
310
+ '^parent',
311
+ '--project-id',
312
+ 'test',
313
+ ],
314
+ mockDir: 'restore',
315
+ expected: {
316
+ code: 1,
317
+ stderr: `ERROR: Branch unexisting-branch not found.
318
+ Available branches: self-tolsn-123456, any-branch, parent-tots, another-branch`,
319
+ },
320
+ });
274
321
  });
@@ -49,6 +49,11 @@ export const builder = (argv) => {
49
49
  describe: 'Connect to a database via psql using connection string',
50
50
  default: false,
51
51
  },
52
+ ssl: {
53
+ type: 'boolean',
54
+ describe: 'Add sslmode=require to the connection string',
55
+ default: true,
56
+ },
52
57
  })
53
58
  .middleware(fillSingleProject);
54
59
  };
@@ -107,6 +112,9 @@ export const handler = async (props) => {
107
112
  connectionString.searchParams.set('pgbouncer', 'true');
108
113
  }
109
114
  }
115
+ if (props.ssl) {
116
+ connectionString.searchParams.set('sslmode', 'require');
117
+ }
110
118
  if (props.psql) {
111
119
  const psqlArgs = props['--'];
112
120
  await psql(connectionString.toString(), psqlArgs);
@@ -165,4 +165,22 @@ describe('connection_string', () => {
165
165
  snapshot: true,
166
166
  },
167
167
  });
168
+ testCliCommand({
169
+ name: 'connection_string without ssl',
170
+ args: [
171
+ 'connection-string',
172
+ 'test_branch',
173
+ '--project-id',
174
+ 'test',
175
+ '--database-name',
176
+ 'test_db',
177
+ '--role-name',
178
+ 'test_role',
179
+ '--ssl',
180
+ 'false',
181
+ ],
182
+ expected: {
183
+ snapshot: true,
184
+ },
185
+ });
168
186
  });
@@ -21,8 +21,7 @@ describe('ip-allow', () => {
21
21
  args: ['ip-allow', 'add', '--projectId', 'test'],
22
22
  expected: {
23
23
  stderr: `ERROR: Enter individual IP addresses, define ranges with a dash, or use CIDR notation for more flexibility.
24
- Example: neonctl ip-allow add 192.168.1.1, 192.168.1.20-192.168.1.50, 192.168.1.0/24 --project-id <id>
25
- `,
24
+ Example: neonctl ip-allow add 192.168.1.1, 192.168.1.20-192.168.1.50, 192.168.1.0/24 --project-id <id>`,
26
25
  },
27
26
  });
28
27
  testCliCommand({
@@ -44,8 +43,7 @@ describe('ip-allow', () => {
44
43
  name: 'Remove IP allow - Error',
45
44
  args: ['ip-allow', 'remove', '--project-id', 'test'],
46
45
  expected: {
47
- stderr: `ERROR: Remove individual IP addresses and ranges. Example: neonctl ip-allow remove 192.168.1.1 --project-id <id>
48
- `,
46
+ stderr: `ERROR: Remove individual IP addresses and ranges. Example: neonctl ip-allow remove 192.168.1.1 --project-id <id>`,
49
47
  },
50
48
  });
51
49
  testCliCommand({
@@ -65,8 +63,7 @@ name: test_project
65
63
  IP_addresses: []
66
64
  primary_branch_only: false
67
65
  `,
68
- stderr: `INFO: The IP allowlist has been reset. All databases on project "test_project" are now exposed to the internet
69
- `,
66
+ stderr: `INFO: The IP allowlist has been reset. All databases on project "test_project" are now exposed to the internet`,
70
67
  },
71
68
  });
72
69
  testCliCommand({
package/help.js CHANGED
@@ -22,6 +22,14 @@ const formatHelp = (help) => {
22
22
  width: 0,
23
23
  });
24
24
  commandsBlock.forEach((line) => {
25
+ if (line.match(/^\s{3,}/)) {
26
+ ui.div({
27
+ text: '',
28
+ width: SPACE_WIDTH,
29
+ padding: [0, 0, 0, 0],
30
+ }, { text: line.trim(), padding: [0, 0, 0, 0] });
31
+ return;
32
+ }
25
33
  const [command, description] = splitColumns(line);
26
34
  // patch the previous command if it was multiline
27
35
  if (!description && ui.rows.length > 1) {
@@ -65,7 +73,7 @@ const formatHelp = (help) => {
65
73
  // example command to see: neonctl projects list
66
74
  const descritpionBlock = consumeBlockIfMatches(lines, /^(?!.*options:)/i);
67
75
  if (descritpionBlock.length > 0) {
68
- result.push(descritpionBlock.shift());
76
+ result.push(...descritpionBlock);
69
77
  result.push('');
70
78
  }
71
79
  while (true) {
@@ -109,11 +117,30 @@ const formatHelp = (help) => {
109
117
  });
110
118
  result.push('');
111
119
  }
120
+ const exampleBlock = consumeBlockIfMatches(lines, /Examples:/);
121
+ if (exampleBlock.length > 0) {
122
+ result.push(exampleBlock.shift());
123
+ const ui = cliui({
124
+ width: 0,
125
+ });
126
+ for (const line of exampleBlock) {
127
+ const [command, description] = splitColumns(line);
128
+ ui.div({
129
+ text: chalk.bold(command),
130
+ padding: [0, 0, 0, 0],
131
+ });
132
+ ui.div({
133
+ text: chalk.reset(description),
134
+ padding: [0, 0, 0, 2],
135
+ });
136
+ }
137
+ result.push(ui.toString());
138
+ }
112
139
  return [...result, ...lines];
113
140
  };
114
141
  export const showHelp = async (argv) => {
115
142
  // add wrap to ensure that there are no line breaks
116
- const help = await argv.wrap(500).getHelp();
143
+ const help = await argv.getHelp();
117
144
  process.stderr.write(formatHelp(help).join('\n') + '\n');
118
145
  process.exit(0);
119
146
  };
package/index.js CHANGED
@@ -129,6 +129,7 @@ builder = builder
129
129
  .completion()
130
130
  .scriptName(basename(process.argv[1]) === 'neon' ? 'neon' : 'neonctl')
131
131
  .epilog('For more information, visit https://neon.tech/docs/reference/neon-cli')
132
+ .wrap(null)
132
133
  .fail(async (msg, err) => {
133
134
  if (process.argv.some((arg) => arg === '--help' || arg === '-h')) {
134
135
  await showHelp(builder);
@@ -144,8 +145,10 @@ builder = builder
144
145
  log.error('Authentication failed, please run `neonctl auth`');
145
146
  }
146
147
  else {
147
- log.debug('Fail: %d | %s', err.response?.status, err.response?.statusText);
148
- log.error(err.response?.data?.message);
148
+ if (err.response?.data?.message) {
149
+ log.error(err.response?.data?.message);
150
+ }
151
+ log.debug('status: %d %s | path: %s', err.response?.status, err.response?.statusText, err.request?.path);
149
152
  sendError(err, 'API_ERROR');
150
153
  }
151
154
  }
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:neondatabase/neonctl.git"
6
6
  },
7
7
  "type": "module",
8
- "version": "1.26.2",
8
+ "version": "1.27.0-beta.1",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -5,7 +5,9 @@ import { log } from '../log';
5
5
  export const runMockServer = async (mockDir) => new Promise((resolve) => {
6
6
  const app = express();
7
7
  app.use(express.json());
8
- app.use('/', emocks(join(process.cwd(), 'mocks', mockDir)));
8
+ app.use('/', emocks(join(process.cwd(), 'mocks', mockDir), {
9
+ '404': (req, res) => res.status(404).send({ message: 'Not Found' }),
10
+ }));
9
11
  const server = app.listen(0);
10
12
  server.on('listening', () => {
11
13
  resolve(server);
@@ -33,6 +33,7 @@ export const testCliCommand = ({ args, name, expected, before, after, mockDir =
33
33
  'yaml',
34
34
  '--api-key',
35
35
  'test-key',
36
+ '--no-analytics',
36
37
  ...args,
37
38
  ], {
38
39
  stdio: 'pipe',
@@ -53,8 +54,8 @@ export const testCliCommand = ({ args, name, expected, before, after, mockDir =
53
54
  });
54
55
  cp.on('close', (code) => {
55
56
  try {
56
- expect(code).toBe(0);
57
- if (code === 0 && expected) {
57
+ expect(code).toBe(expected?.code ?? 0);
58
+ if (expected) {
58
59
  if (expected.snapshot) {
59
60
  expect(output).toMatchSnapshot();
60
61
  }
@@ -62,7 +63,9 @@ export const testCliCommand = ({ args, name, expected, before, after, mockDir =
62
63
  expect(strip(output)).toEqual(expected.stdout);
63
64
  }
64
65
  if (expected.stderr !== undefined) {
65
- expect(strip(error)).toEqual(expected.stderr);
66
+ expect(strip(error).replace(/\s+/g, ' ').trim()).toEqual(typeof expected.stderr === 'string'
67
+ ? expected.stderr.toString().replace(/\s+/g, ' ')
68
+ : expected.stderr);
66
69
  }
67
70
  }
68
71
  resolve();
@@ -0,0 +1,44 @@
1
+ import { looksLikeLSN, looksLikeTimestamp } from './formats.js';
2
+ import { branchIdResolve } from './enrichers.js';
3
+ export class PointInTimeParseError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'PointInTimeParseError';
7
+ }
8
+ }
9
+ export const parsePointInTime = async ({ pointInTime, targetBranchId, projectId, api, }) => {
10
+ const splitIndex = pointInTime.lastIndexOf('@');
11
+ const sourceBranch = splitIndex === -1 ? pointInTime : pointInTime.slice(0, splitIndex);
12
+ const exactPIT = splitIndex === -1 ? null : pointInTime.slice(splitIndex + 1);
13
+ const result = {
14
+ branchId: '',
15
+ ...(exactPIT === null
16
+ ? { tag: 'head' }
17
+ : looksLikeLSN(exactPIT)
18
+ ? { tag: 'lsn', lsn: exactPIT }
19
+ : { tag: 'timestamp', timestamp: exactPIT }),
20
+ };
21
+ if (result.tag === 'timestamp' && !looksLikeTimestamp(result.timestamp)) {
22
+ throw new PointInTimeParseError('Invalid source branch format');
23
+ }
24
+ if (sourceBranch === '^self') {
25
+ return {
26
+ ...result,
27
+ branchId: targetBranchId,
28
+ };
29
+ }
30
+ if (sourceBranch === '^parent') {
31
+ const { data } = await api.getProjectBranch(projectId, targetBranchId);
32
+ const { parent_id: parentId } = data.branch;
33
+ if (parentId == null) {
34
+ throw new PointInTimeParseError('Branch has no parent');
35
+ }
36
+ return { ...result, branchId: parentId };
37
+ }
38
+ const branchId = await branchIdResolve({
39
+ branch: sourceBranch,
40
+ projectId,
41
+ apiClient: api,
42
+ });
43
+ return { ...result, branchId };
44
+ };
package/utils/ui.js CHANGED
@@ -43,6 +43,9 @@ export const consumeBlockIfMatches = (lines, matcher) => {
43
43
  export const splitColumns = (line) => {
44
44
  const result = line.trim().split(/\s{2,}/);
45
45
  result[1] = result[1] ?? '';
46
+ if (result.length > 2) {
47
+ result[1] = result.slice(1).join(' ');
48
+ }
46
49
  return result;
47
50
  };
48
51
  export const drawPointer = (width) => {