neonctl 1.29.1 → 1.29.4
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.
- package/analytics.js +78 -0
- package/api.js +35 -0
- package/auth.js +101 -0
- package/{src/cli.ts → cli.js} +0 -1
- package/commands/auth.js +102 -0
- package/commands/auth.test.js +42 -0
- package/commands/branches.js +303 -0
- package/commands/branches.test.js +321 -0
- package/commands/connection_string.js +158 -0
- package/commands/connection_string.test.js +253 -0
- package/commands/databases.js +79 -0
- package/commands/databases.test.js +51 -0
- package/commands/help.test.js +11 -0
- package/{src/commands/index.ts → commands/index.js} +10 -11
- package/commands/ip_allow.js +135 -0
- package/commands/ip_allow.test.js +78 -0
- package/commands/operations.js +28 -0
- package/commands/operations.test.js +11 -0
- package/commands/projects.js +186 -0
- package/commands/projects.test.js +132 -0
- package/commands/roles.js +57 -0
- package/commands/roles.test.js +42 -0
- package/commands/set_context.js +22 -0
- package/commands/set_context.test.js +53 -0
- package/commands/user.js +15 -0
- package/config.js +11 -0
- package/context.js +48 -0
- package/env.js +6 -0
- package/errors.js +16 -0
- package/help.js +146 -0
- package/index.js +168 -0
- package/log.js +15 -0
- package/package.json +1 -1
- package/parameters.gen.js +322 -0
- package/pkg.js +3 -45
- package/test_utils/mock_server.js +16 -0
- package/test_utils/oauth_server.js +9 -0
- package/test_utils/test_cli_command.js +80 -0
- package/types.js +1 -0
- package/utils/enrichers.js +49 -0
- package/utils/formats.js +5 -0
- package/utils/formats.test.js +32 -0
- package/utils/middlewares.js +20 -0
- package/utils/point_in_time.js +50 -0
- package/utils/psql.js +24 -0
- package/utils/string.js +5 -0
- package/utils/ui.js +59 -0
- package/writer.js +87 -0
- package/writer.test.js +86 -0
- package/.bump +0 -1
- package/.editorconfig +0 -7
- package/.eslintrc.cjs +0 -15
- package/.github/workflows/commitlint.yml +0 -46
- package/.github/workflows/pr.yml +0 -25
- package/.github/workflows/release.yml +0 -30
- package/.husky/commit-msg +0 -4
- package/.husky/pre-commit +0 -4
- package/.nvmrc +0 -1
- package/.prettierignore +0 -3
- package/.prettierrc.json +0 -3
- package/.releaserc.json +0 -47
- package/LICENSE +0 -202
- package/commitlint.config.cjs +0 -7
- package/generateOptionsFromSpec.ts +0 -68
- package/jest/setup.js +0 -5
- package/jest.config.ts +0 -199
- package/mocks/bin/psql.cjs +0 -9
- package/mocks/main/projects/GET.js +0 -27
- package/mocks/main/projects/POST.js +0 -22
- package/mocks/main/projects/shared/GET.js +0 -16
- package/mocks/main/projects/test/DELETE.json +0 -7
- package/mocks/main/projects/test/GET.json +0 -13
- package/mocks/main/projects/test/PATCH.js +0 -18
- package/mocks/main/projects/test/branches/GET.json +0 -25
- package/mocks/main/projects/test/branches/POST.js +0 -83
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/DELETE.json +0 -7
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/GET.json +0 -9
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/PATCH.js +0 -14
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/GET.json +0 -6
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/POST.js +0 -13
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/test_db/DELETE.json +0 -6
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/endpoints/GET.json +0 -26
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/endpoints/POST.json +0 -6
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/GET.json +0 -3
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/POST.js +0 -14
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/test_role/DELETE.json +0 -6
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/test_role/reveal_password/GET.json +0 -3
- package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/set_as_primary/POST.json +0 -9
- package/mocks/main/projects/test/branches/br-numbered-branch-123456/GET.json +0 -10
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/DELETE.json +0 -7
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/GET.json +0 -10
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/PATCH.js +0 -14
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/GET.json +0 -6
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/POST.js +0 -13
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/test_db/DELETE.json +0 -6
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/endpoints/GET.json +0 -26
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/endpoints/POST.json +0 -6
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/restore/POST.js +0 -16
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/GET.json +0 -3
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/POST.js +0 -14
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/test_role/DELETE.json +0 -6
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/test_role/reveal_password/GET.json +0 -3
- package/mocks/main/projects/test/branches/br-sunny-branch-123456/set_as_primary/POST.json +0 -9
- package/mocks/main/projects/test/endpoints/GET.json +0 -9
- package/mocks/main/projects/test/endpoints/POST.js +0 -32
- package/mocks/main/projects/test/endpoints/test_endpoint_id/DELETE.json +0 -7
- package/mocks/main/projects/test/endpoints/test_endpoint_id/GET.json +0 -9
- package/mocks/main/projects/test/endpoints/test_endpoint_id/PATCH.js +0 -17
- package/mocks/main/projects/test/operations/GET.json +0 -22
- package/mocks/main/users/me/GET.json +0 -5
- package/mocks/restore/projects/test/branches/GET.json +0 -21
- package/mocks/restore/projects/test/branches/br-another-branch-123456/GET.json +0 -6
- package/mocks/restore/projects/test/branches/br-another-branch-123456/restore/POST.js +0 -13
- package/mocks/restore/projects/test/branches/br-any-branch-123456/GET.json +0 -6
- package/mocks/restore/projects/test/branches/br-parent-tots-123456/GET.json +0 -7
- package/mocks/restore/projects/test/branches/br-parent-tots-123456/restore/POST.js +0 -14
- package/mocks/restore/projects/test/branches/br-self-tolsn-123456/GET.json +0 -6
- package/mocks/restore/projects/test/branches/br-self-tolsn-123456/restore/POST.js +0 -15
- package/mocks/single_project/projects/GET.json +0 -10
- package/mocks/single_project/projects/test-project-123456/GET.json +0 -14
- package/mocks/single_project/projects/test-project-123456/branches/GET.json +0 -11
- package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/databases/GET.json +0 -3
- package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/endpoints/GET.json +0 -10
- package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/roles/GET.json +0 -3
- package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/roles/test_role/reveal_password/GET.json +0 -3
- package/rollup.config.js +0 -20
- package/snapshots/commands/branches.test.snap +0 -221
- package/snapshots/commands/connection_string.test.snap +0 -70
- package/snapshots/commands/databases.test.snap +0 -20
- package/snapshots/commands/ip_allow.test.snap +0 -55
- package/snapshots/commands/operations.test.snap +0 -17
- package/snapshots/commands/projects.test.snap +0 -141
- package/snapshots/commands/roles.test.snap +0 -19
- package/snapshots/commands/set_context.test.snap +0 -30
- package/snapshots/writer.test.snap +0 -60
- package/snapshotsResolver.cjs +0 -32
- package/src/analytics.ts +0 -95
- package/src/api.ts +0 -44
- package/src/auth.ts +0 -137
- package/src/commands/auth.test.ts +0 -62
- package/src/commands/auth.ts +0 -148
- package/src/commands/branches.test.ts +0 -354
- package/src/commands/branches.ts +0 -451
- package/src/commands/connection_string.test.ts +0 -250
- package/src/commands/connection_string.ts +0 -210
- package/src/commands/databases.test.ts +0 -55
- package/src/commands/databases.ts +0 -129
- package/src/commands/help.test.ts +0 -13
- package/src/commands/ip_allow.test.ts +0 -86
- package/src/commands/ip_allow.ts +0 -202
- package/src/commands/operations.test.ts +0 -13
- package/src/commands/operations.ts +0 -41
- package/src/commands/projects.test.ts +0 -147
- package/src/commands/projects.ts +0 -275
- package/src/commands/roles.test.ts +0 -46
- package/src/commands/roles.ts +0 -100
- package/src/commands/set_context.test.ts +0 -64
- package/src/commands/set_context.ts +0 -27
- package/src/commands/user.ts +0 -21
- package/src/config.ts +0 -22
- package/src/context.ts +0 -61
- package/src/env.ts +0 -7
- package/src/errors.ts +0 -24
- package/src/help.ts +0 -185
- package/src/index.ts +0 -180
- package/src/log.ts +0 -16
- package/src/parameters.gen.ts +0 -332
- package/src/pkg.ts +0 -9
- package/src/test_utils/mock_server.ts +0 -27
- package/src/test_utils/oauth_server.ts +0 -10
- package/src/test_utils/test_cli_command.ts +0 -117
- package/src/types.ts +0 -25
- package/src/utils/enrichers.ts +0 -73
- package/src/utils/formats.test.ts +0 -41
- package/src/utils/formats.ts +0 -11
- package/src/utils/middlewares.ts +0 -23
- package/src/utils/point_in_time.ts +0 -86
- package/src/utils/psql.ts +0 -29
- package/src/utils/string.ts +0 -8
- package/src/utils/ui.ts +0 -64
- package/src/writer.test.ts +0 -98
- package/src/writer.ts +0 -131
- package/tsconfig.json +0 -17
- /package/{src/callback.html → callback.html} +0 -0
package/analytics.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Analytics } from '@segment/analytics-node';
|
|
4
|
+
import { isAxiosError } from 'axios';
|
|
5
|
+
import { CREDENTIALS_FILE } from './config.js';
|
|
6
|
+
import { isCi } from './env.js';
|
|
7
|
+
import { log } from './log.js';
|
|
8
|
+
import pkg from './pkg.js';
|
|
9
|
+
import { getApiClient } from './api.js';
|
|
10
|
+
const WRITE_KEY = '3SQXn5ejjXWLEJ8xU2PRYhAotLtTaeeV';
|
|
11
|
+
let client;
|
|
12
|
+
let userId = '';
|
|
13
|
+
export const analyticsMiddleware = async (args) => {
|
|
14
|
+
if (!args.analytics) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const credentialsPath = join(args.configDir, CREDENTIALS_FILE);
|
|
19
|
+
const credentials = readFileSync(credentialsPath, { encoding: 'utf-8' });
|
|
20
|
+
userId = JSON.parse(credentials).user_id;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
log.debug('Failed to read credentials file', err);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
if (!userId && args.apiKey) {
|
|
27
|
+
const apiClient = getApiClient({
|
|
28
|
+
apiKey: args.apiKey,
|
|
29
|
+
apiHost: args.apiHost,
|
|
30
|
+
});
|
|
31
|
+
const resp = await apiClient?.getCurrentUserInfo?.();
|
|
32
|
+
userId = resp?.data?.id;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
log.debug('Failed to get user id from api', err);
|
|
37
|
+
}
|
|
38
|
+
client = new Analytics({
|
|
39
|
+
writeKey: WRITE_KEY,
|
|
40
|
+
host: 'https://track.neon.tech',
|
|
41
|
+
});
|
|
42
|
+
client.identify({
|
|
43
|
+
userId: userId?.toString() ?? 'anonymous',
|
|
44
|
+
});
|
|
45
|
+
client.track({
|
|
46
|
+
userId: userId ?? 'anonymous',
|
|
47
|
+
event: 'CLI Started',
|
|
48
|
+
properties: {
|
|
49
|
+
version: pkg.version,
|
|
50
|
+
command: args._.join(' '),
|
|
51
|
+
flags: {
|
|
52
|
+
output: args.output,
|
|
53
|
+
},
|
|
54
|
+
ci: isCi(),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
log.debug('Flushing CLI started event with userId: %s', userId);
|
|
58
|
+
await client.closeAndFlush();
|
|
59
|
+
log.debug('Flushed CLI started event with userId: %s', userId);
|
|
60
|
+
};
|
|
61
|
+
export const sendError = (err, errCode) => {
|
|
62
|
+
if (!client) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const axiosError = isAxiosError(err) ? err : undefined;
|
|
66
|
+
client.track({
|
|
67
|
+
event: 'CLI Error',
|
|
68
|
+
userId: userId ?? 'anonymous',
|
|
69
|
+
properties: {
|
|
70
|
+
message: err.message,
|
|
71
|
+
stack: err.stack,
|
|
72
|
+
errCode,
|
|
73
|
+
statusCode: axiosError?.response?.status,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
client.closeAndFlush();
|
|
77
|
+
log.debug('Sent CLI error event: %s', errCode);
|
|
78
|
+
};
|
package/api.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createApiClient } from '@neondatabase/api-client';
|
|
2
|
+
import { isAxiosError } from 'axios';
|
|
3
|
+
import { log } from './log.js';
|
|
4
|
+
import pkg from './pkg.js';
|
|
5
|
+
export const getApiClient = ({ apiKey, apiHost }) => createApiClient({
|
|
6
|
+
apiKey,
|
|
7
|
+
baseURL: apiHost,
|
|
8
|
+
timeout: 60000,
|
|
9
|
+
headers: {
|
|
10
|
+
'User-Agent': `neonctl v${pkg.version}`,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
const RETRY_COUNT = 5;
|
|
14
|
+
const RETRY_DELAY = 3000;
|
|
15
|
+
export const retryOnLock = async (fn) => {
|
|
16
|
+
let attempt = 0;
|
|
17
|
+
let errOut;
|
|
18
|
+
while (attempt < RETRY_COUNT) {
|
|
19
|
+
try {
|
|
20
|
+
return await fn();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
errOut = err;
|
|
24
|
+
if (isAxiosError(err) && err.response?.status === 423) {
|
|
25
|
+
attempt++;
|
|
26
|
+
log.info(`Resource is locked. Waiting ${RETRY_DELAY}ms before retrying...`);
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw errOut;
|
|
35
|
+
};
|
package/auth.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { custom, generators, Issuer } from 'openid-client';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import { log } from './log.js';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
// oauth server timeouts
|
|
9
|
+
const SERVER_TIMEOUT = 10000;
|
|
10
|
+
// where to wait for incoming redirect request from oauth server to arrive
|
|
11
|
+
const REDIRECT_URI = (port) => `http://127.0.0.1:${port}/callback`;
|
|
12
|
+
// These scopes cannot be cancelled, they are always needed.
|
|
13
|
+
const ALWAYS_PRESENT_SCOPES = ['openid', 'offline', 'offline_access'];
|
|
14
|
+
const NEONCTL_SCOPES = [
|
|
15
|
+
...ALWAYS_PRESENT_SCOPES,
|
|
16
|
+
'urn:neoncloud:projects:create',
|
|
17
|
+
'urn:neoncloud:projects:read',
|
|
18
|
+
'urn:neoncloud:projects:update',
|
|
19
|
+
'urn:neoncloud:projects:delete',
|
|
20
|
+
];
|
|
21
|
+
export const defaultClientID = 'neonctl';
|
|
22
|
+
custom.setHttpOptionsDefaults({
|
|
23
|
+
timeout: SERVER_TIMEOUT,
|
|
24
|
+
});
|
|
25
|
+
export const refreshToken = async ({ oauthHost, clientId }, tokenSet) => {
|
|
26
|
+
log.debug('Discovering oauth server');
|
|
27
|
+
const issuer = await Issuer.discover(oauthHost);
|
|
28
|
+
const neonOAuthClient = new issuer.Client({
|
|
29
|
+
token_endpoint_auth_method: 'none',
|
|
30
|
+
client_id: clientId,
|
|
31
|
+
response_types: ['code'],
|
|
32
|
+
});
|
|
33
|
+
return await neonOAuthClient.refresh(tokenSet);
|
|
34
|
+
};
|
|
35
|
+
export const auth = async ({ oauthHost, clientId }) => {
|
|
36
|
+
log.debug('Discovering oauth server');
|
|
37
|
+
const issuer = await Issuer.discover(oauthHost);
|
|
38
|
+
//
|
|
39
|
+
// Start HTTP server and wait till /callback is hit
|
|
40
|
+
//
|
|
41
|
+
const server = createServer();
|
|
42
|
+
server.listen(0, '127.0.0.1', function () {
|
|
43
|
+
log.debug(`Listening on port ${this.address().port}`);
|
|
44
|
+
});
|
|
45
|
+
await new Promise((resolve) => server.once('listening', resolve));
|
|
46
|
+
const listen_port = server.address().port;
|
|
47
|
+
const neonOAuthClient = new issuer.Client({
|
|
48
|
+
token_endpoint_auth_method: 'none',
|
|
49
|
+
client_id: clientId,
|
|
50
|
+
redirect_uris: [REDIRECT_URI(listen_port)],
|
|
51
|
+
response_types: ['code'],
|
|
52
|
+
});
|
|
53
|
+
// https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.8
|
|
54
|
+
const state = generators.state();
|
|
55
|
+
// we store the code_verifier in memory
|
|
56
|
+
const codeVerifier = generators.codeVerifier();
|
|
57
|
+
const codeChallenge = generators.codeChallenge(codeVerifier);
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
server.on('request', async (request, response) => {
|
|
60
|
+
//
|
|
61
|
+
// Wait for callback and follow oauth flow.
|
|
62
|
+
//
|
|
63
|
+
if (!request.url?.startsWith('/callback')) {
|
|
64
|
+
response.writeHead(404);
|
|
65
|
+
response.end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// process the CORS preflight OPTIONS request
|
|
69
|
+
if (request.method === 'OPTIONS') {
|
|
70
|
+
response.writeHead(200, {
|
|
71
|
+
'Access-Control-Allow-Origin': '*',
|
|
72
|
+
'Access-Control-Allow-Methods': 'GET, POST',
|
|
73
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
74
|
+
});
|
|
75
|
+
response.end();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
log.debug(`Callback received: ${request.url}`);
|
|
79
|
+
const params = neonOAuthClient.callbackParams(request);
|
|
80
|
+
const tokenSet = await neonOAuthClient.callback(REDIRECT_URI(listen_port), params, {
|
|
81
|
+
code_verifier: codeVerifier,
|
|
82
|
+
state,
|
|
83
|
+
});
|
|
84
|
+
response.writeHead(200, { 'Content-Type': 'text/html' });
|
|
85
|
+
createReadStream(join(fileURLToPath(new URL('.', import.meta.url)), './callback.html')).pipe(response);
|
|
86
|
+
resolve(tokenSet);
|
|
87
|
+
server.close();
|
|
88
|
+
});
|
|
89
|
+
//
|
|
90
|
+
// Open browser to let user authenticate
|
|
91
|
+
//
|
|
92
|
+
const scopes = clientId == defaultClientID ? NEONCTL_SCOPES : ALWAYS_PRESENT_SCOPES;
|
|
93
|
+
const authUrl = neonOAuthClient.authorizationUrl({
|
|
94
|
+
scope: scopes.join(' '),
|
|
95
|
+
state,
|
|
96
|
+
code_challenge: codeChallenge,
|
|
97
|
+
code_challenge_method: 'S256',
|
|
98
|
+
});
|
|
99
|
+
open(authUrl);
|
|
100
|
+
});
|
|
101
|
+
};
|
package/{src/cli.ts → cli.js}
RENAMED
package/commands/auth.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { TokenSet } from 'openid-client';
|
|
4
|
+
import { auth, refreshToken } from '../auth.js';
|
|
5
|
+
import { log } from '../log.js';
|
|
6
|
+
import { getApiClient } from '../api.js';
|
|
7
|
+
import { isCi } from '../env.js';
|
|
8
|
+
import { CREDENTIALS_FILE } from '../config.js';
|
|
9
|
+
export const command = 'auth';
|
|
10
|
+
export const aliases = ['login'];
|
|
11
|
+
export const describe = 'Authenticate';
|
|
12
|
+
export const builder = (yargs) => yargs.option('context-file', {
|
|
13
|
+
hidden: true,
|
|
14
|
+
});
|
|
15
|
+
export const handler = async (args) => {
|
|
16
|
+
await authFlow(args);
|
|
17
|
+
};
|
|
18
|
+
export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, }) => {
|
|
19
|
+
if (!forceAuth && isCi()) {
|
|
20
|
+
throw new Error('Cannot run interactive auth in CI');
|
|
21
|
+
}
|
|
22
|
+
const tokenSet = await auth({
|
|
23
|
+
oauthHost: oauthHost,
|
|
24
|
+
clientId: clientId,
|
|
25
|
+
});
|
|
26
|
+
const credentialsPath = join(configDir, CREDENTIALS_FILE);
|
|
27
|
+
await preserveCredentials(credentialsPath, tokenSet, getApiClient({
|
|
28
|
+
apiKey: tokenSet.access_token || '',
|
|
29
|
+
apiHost,
|
|
30
|
+
}));
|
|
31
|
+
log.info(`Saved credentials to ${credentialsPath}`);
|
|
32
|
+
log.info('Auth complete');
|
|
33
|
+
return tokenSet.access_token || '';
|
|
34
|
+
};
|
|
35
|
+
const preserveCredentials = async (path, credentials, apiClient) => {
|
|
36
|
+
const { data: { id }, } = await apiClient.getCurrentUserInfo();
|
|
37
|
+
const contents = JSON.stringify({
|
|
38
|
+
...credentials,
|
|
39
|
+
user_id: id,
|
|
40
|
+
});
|
|
41
|
+
// correctly sets needed permissions for the credentials file
|
|
42
|
+
writeFileSync(path, contents, {
|
|
43
|
+
mode: 0o700,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
export const ensureAuth = async (props) => {
|
|
47
|
+
if (props._.length === 0 || props.help) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (props.apiKey || props._[0] === 'auth') {
|
|
51
|
+
props.apiClient = getApiClient({
|
|
52
|
+
apiKey: props.apiKey,
|
|
53
|
+
apiHost: props.apiHost,
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const credentialsPath = join(props.configDir, CREDENTIALS_FILE);
|
|
58
|
+
if (existsSync(credentialsPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const tokenSetContents = await JSON.parse(readFileSync(credentialsPath, 'utf8'));
|
|
61
|
+
const tokenSet = new TokenSet(tokenSetContents);
|
|
62
|
+
if (tokenSet.expired()) {
|
|
63
|
+
log.debug('using refresh token to update access token');
|
|
64
|
+
const refreshedTokenSet = await refreshToken({
|
|
65
|
+
oauthHost: props.oauthHost,
|
|
66
|
+
clientId: props.clientId,
|
|
67
|
+
}, tokenSet).catch((e) => {
|
|
68
|
+
log.error('failed to refresh token\n%s', e?.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
|
71
|
+
props.apiKey = refreshedTokenSet.access_token || 'UNKNOWN';
|
|
72
|
+
props.apiClient = getApiClient({
|
|
73
|
+
apiKey: props.apiKey,
|
|
74
|
+
apiHost: props.apiHost,
|
|
75
|
+
});
|
|
76
|
+
await preserveCredentials(credentialsPath, refreshedTokenSet, props.apiClient);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const token = tokenSet.access_token || 'UNKNOWN';
|
|
80
|
+
props.apiKey = token;
|
|
81
|
+
props.apiClient = getApiClient({
|
|
82
|
+
apiKey: props.apiKey,
|
|
83
|
+
apiHost: props.apiHost,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e.code !== 'ENOENT') {
|
|
89
|
+
// not a "file does not exist" error
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
props.apiKey = await authFlow(props);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
props.apiKey = await authFlow(props);
|
|
97
|
+
}
|
|
98
|
+
props.apiClient = getApiClient({
|
|
99
|
+
apiKey: props.apiKey,
|
|
100
|
+
apiHost: props.apiHost,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { beforeAll, describe, test, jest, afterAll, expect, } from '@jest/globals';
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { startOauthServer } from '../test_utils/oauth_server';
|
|
5
|
+
import { runMockServer } from '../test_utils/mock_server';
|
|
6
|
+
jest.unstable_mockModule('open', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: jest.fn((url) => {
|
|
9
|
+
axios.get(url);
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
// "open" module should be imported after mocking
|
|
13
|
+
const authModule = await import('./auth');
|
|
14
|
+
describe('auth', () => {
|
|
15
|
+
let configDir = '';
|
|
16
|
+
let oauthServer;
|
|
17
|
+
let mockServer;
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
configDir = mkdtempSync('test-config');
|
|
20
|
+
oauthServer = await startOauthServer();
|
|
21
|
+
mockServer = await runMockServer('main');
|
|
22
|
+
});
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
rmSync(configDir, { recursive: true });
|
|
25
|
+
await oauthServer.stop();
|
|
26
|
+
await new Promise((resolve) => mockServer.close(resolve));
|
|
27
|
+
});
|
|
28
|
+
test('should auth', async () => {
|
|
29
|
+
await authModule.authFlow({
|
|
30
|
+
_: ['auth'],
|
|
31
|
+
apiHost: `http://localhost:${mockServer.address().port}`,
|
|
32
|
+
clientId: 'test-client-id',
|
|
33
|
+
configDir,
|
|
34
|
+
forceAuth: true,
|
|
35
|
+
oauthHost: `http://localhost:${oauthServer.address().port}`,
|
|
36
|
+
});
|
|
37
|
+
const credentials = JSON.parse(readFileSync(`${configDir}/credentials.json`, 'utf-8'));
|
|
38
|
+
expect(credentials.access_token).toEqual(expect.any(String));
|
|
39
|
+
expect(credentials.refresh_token).toEqual(expect.any(String));
|
|
40
|
+
expect(credentials.user_id).toEqual(expect.any(String));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { EndpointType } from '@neondatabase/api-client';
|
|
2
|
+
import { writer } from '../writer.js';
|
|
3
|
+
import { branchCreateRequest } from '../parameters.gen.js';
|
|
4
|
+
import { retryOnLock } from '../api.js';
|
|
5
|
+
import { branchIdFromProps, branchIdResolve, fillSingleProject, } from '../utils/enrichers.js';
|
|
6
|
+
import { looksLikeBranchId, looksLikeLSN, looksLikeTimestamp, } from '../utils/formats.js';
|
|
7
|
+
import { psql } from '../utils/psql.js';
|
|
8
|
+
import { parsePointInTime } from '../utils/point_in_time.js';
|
|
9
|
+
import { log } from '../log.js';
|
|
10
|
+
const BRANCH_FIELDS = [
|
|
11
|
+
'id',
|
|
12
|
+
'name',
|
|
13
|
+
'primary',
|
|
14
|
+
'created_at',
|
|
15
|
+
'updated_at',
|
|
16
|
+
];
|
|
17
|
+
const BRANCH_FIELDS_RESET = [
|
|
18
|
+
'id',
|
|
19
|
+
'name',
|
|
20
|
+
'primary',
|
|
21
|
+
'created_at',
|
|
22
|
+
'last_reset_at',
|
|
23
|
+
];
|
|
24
|
+
export const command = 'branches';
|
|
25
|
+
export const describe = 'Manage branches';
|
|
26
|
+
export const aliases = ['branch'];
|
|
27
|
+
export const builder = (argv) => argv
|
|
28
|
+
.usage('$0 branches <sub-command> [options]')
|
|
29
|
+
.options({
|
|
30
|
+
'project-id': {
|
|
31
|
+
describe: 'Project ID',
|
|
32
|
+
type: 'string',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
.middleware(fillSingleProject)
|
|
36
|
+
.command('list', 'List branches', (yargs) => yargs, async (args) => await list(args))
|
|
37
|
+
.command('create', 'Create a branch', (yargs) => yargs.options({
|
|
38
|
+
name: branchCreateRequest['branch.name'],
|
|
39
|
+
parent: {
|
|
40
|
+
describe: 'Parent branch name or id or timestamp or LSN. Defaults to the primary branch',
|
|
41
|
+
type: 'string',
|
|
42
|
+
},
|
|
43
|
+
compute: {
|
|
44
|
+
describe: 'Create a branch with or without a compute. By default branch is created with a read-write compute. To create a branch without compute use --no-compute',
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
default: true,
|
|
47
|
+
},
|
|
48
|
+
type: {
|
|
49
|
+
describe: 'Type of compute to add',
|
|
50
|
+
type: 'string',
|
|
51
|
+
implies: 'compute',
|
|
52
|
+
default: EndpointType.ReadWrite,
|
|
53
|
+
choices: Object.values(EndpointType),
|
|
54
|
+
},
|
|
55
|
+
'suspend-timeout': {
|
|
56
|
+
describe: 'Duration of inactivity in seconds after which the compute endpoint is\nautomatically suspended. The value `0` means use the global default.\nThe value `-1` means never suspend. The default value is `300` seconds (5 minutes).\nThe maximum value is `604800` seconds (1 week).',
|
|
57
|
+
type: 'number',
|
|
58
|
+
implies: 'compute',
|
|
59
|
+
default: 0,
|
|
60
|
+
},
|
|
61
|
+
psql: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
describe: 'Connect to a new branch via psql',
|
|
64
|
+
default: false,
|
|
65
|
+
},
|
|
66
|
+
}), async (args) => await create(args))
|
|
67
|
+
.command('reset <id|name>', 'Reset a branch', (yargs) => yargs.options({
|
|
68
|
+
parent: {
|
|
69
|
+
describe: 'Reset to a parent branch',
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
default: false,
|
|
72
|
+
},
|
|
73
|
+
'preserve-under-name': {
|
|
74
|
+
describe: 'Name under which to preserve the old branch',
|
|
75
|
+
},
|
|
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
|
+
'Restores 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
|
+
'Restores 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
|
+
'Restores my-branch to the LSN 0/123456 from its own history',
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
'$0 branches restore my-branch ^parent',
|
|
106
|
+
'Restore my-branch to the head of its parent branch',
|
|
107
|
+
],
|
|
108
|
+
]), async (args) => await restore(args))
|
|
109
|
+
.command('rename <id|name> <new-name>', 'Rename a branch', (yargs) => yargs, async (args) => await rename(args))
|
|
110
|
+
.command('set-primary <id|name>', 'Set a branch as primary', (yargs) => yargs, async (args) => await setPrimary(args))
|
|
111
|
+
.command('add-compute <id|name>', 'Add a compute to a branch', (yargs) => yargs.options({
|
|
112
|
+
type: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
choices: Object.values(EndpointType),
|
|
115
|
+
describe: 'Type of compute to add',
|
|
116
|
+
default: EndpointType.ReadOnly,
|
|
117
|
+
},
|
|
118
|
+
}), async (args) => await addCompute(args))
|
|
119
|
+
.command('delete <id|name>', 'Delete a branch', (yargs) => yargs, async (args) => await deleteBranch(args))
|
|
120
|
+
.command('get <id|name>', 'Get a branch', (yargs) => yargs, async (args) => await get(args));
|
|
121
|
+
export const handler = (args) => {
|
|
122
|
+
return args;
|
|
123
|
+
};
|
|
124
|
+
const list = async (props) => {
|
|
125
|
+
const { data } = await props.apiClient.listProjectBranches(props.projectId);
|
|
126
|
+
writer(props).end(data.branches, {
|
|
127
|
+
fields: BRANCH_FIELDS,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
const create = async (props) => {
|
|
131
|
+
const parentProps = await (() => {
|
|
132
|
+
if (!props.parent) {
|
|
133
|
+
return props.apiClient
|
|
134
|
+
.listProjectBranches(props.projectId)
|
|
135
|
+
.then(({ data }) => {
|
|
136
|
+
const branch = data.branches.find((b) => b.primary);
|
|
137
|
+
if (!branch) {
|
|
138
|
+
throw new Error('No primary branch found');
|
|
139
|
+
}
|
|
140
|
+
return { parent_id: branch.id };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (looksLikeLSN(props.parent)) {
|
|
144
|
+
return { parent_lsn: props.parent };
|
|
145
|
+
}
|
|
146
|
+
if (looksLikeTimestamp(props.parent)) {
|
|
147
|
+
return { parent_timestamp: props.parent };
|
|
148
|
+
}
|
|
149
|
+
if (looksLikeBranchId(props.parent)) {
|
|
150
|
+
return { parent_id: props.parent };
|
|
151
|
+
}
|
|
152
|
+
return props.apiClient
|
|
153
|
+
.listProjectBranches(props.projectId)
|
|
154
|
+
.then(({ data }) => {
|
|
155
|
+
const branch = data.branches.find((b) => b.name === props.parent);
|
|
156
|
+
if (!branch) {
|
|
157
|
+
throw new Error(`Branch ${props.parent} not found`);
|
|
158
|
+
}
|
|
159
|
+
return { parent_id: branch.id };
|
|
160
|
+
});
|
|
161
|
+
})();
|
|
162
|
+
const { data } = await retryOnLock(() => props.apiClient.createProjectBranch(props.projectId, {
|
|
163
|
+
branch: {
|
|
164
|
+
name: props.name,
|
|
165
|
+
...parentProps,
|
|
166
|
+
},
|
|
167
|
+
endpoints: props.compute
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
type: props.type,
|
|
171
|
+
suspend_timeout_seconds: props.suspendTimeout === 0 ? undefined : props.suspendTimeout,
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
: [],
|
|
175
|
+
}));
|
|
176
|
+
const out = writer(props);
|
|
177
|
+
out.write(data.branch, {
|
|
178
|
+
fields: BRANCH_FIELDS,
|
|
179
|
+
title: 'branch',
|
|
180
|
+
});
|
|
181
|
+
if (data.endpoints?.length > 0) {
|
|
182
|
+
out.write(data.endpoints, {
|
|
183
|
+
fields: ['id', 'created_at'],
|
|
184
|
+
title: 'endpoints',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (data.connection_uris && data.connection_uris?.length > 0) {
|
|
188
|
+
out.write(data.connection_uris, {
|
|
189
|
+
fields: ['connection_uri'],
|
|
190
|
+
title: 'connection_uris',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
out.end();
|
|
194
|
+
if (props.psql) {
|
|
195
|
+
if (!data.connection_uris || !data.connection_uris?.length) {
|
|
196
|
+
throw new Error(`Branch ${data.branch.id} doesn't have a connection uri`);
|
|
197
|
+
}
|
|
198
|
+
const connection_uri = data.connection_uris[0].connection_uri;
|
|
199
|
+
const psqlArgs = props['--'];
|
|
200
|
+
await psql(connection_uri, psqlArgs);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const rename = async (props) => {
|
|
204
|
+
const branchId = await branchIdFromProps(props);
|
|
205
|
+
const { data } = await retryOnLock(() => props.apiClient.updateProjectBranch(props.projectId, branchId, {
|
|
206
|
+
branch: {
|
|
207
|
+
name: props.newName,
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
writer(props).end(data.branch, {
|
|
211
|
+
fields: BRANCH_FIELDS,
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
const setPrimary = async (props) => {
|
|
215
|
+
const branchId = await branchIdFromProps(props);
|
|
216
|
+
const { data } = await retryOnLock(() => props.apiClient.setPrimaryProjectBranch(props.projectId, branchId));
|
|
217
|
+
writer(props).end(data.branch, {
|
|
218
|
+
fields: BRANCH_FIELDS,
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
const deleteBranch = async (props) => {
|
|
222
|
+
const branchId = await branchIdFromProps(props);
|
|
223
|
+
const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranch(props.projectId, branchId));
|
|
224
|
+
writer(props).end(data.branch, {
|
|
225
|
+
fields: BRANCH_FIELDS,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
const get = async (props) => {
|
|
229
|
+
const branchId = await branchIdFromProps(props);
|
|
230
|
+
const { data } = await props.apiClient.getProjectBranch(props.projectId, branchId);
|
|
231
|
+
writer(props).end(data.branch, {
|
|
232
|
+
fields: BRANCH_FIELDS,
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
const addCompute = async (props) => {
|
|
236
|
+
const branchId = await branchIdFromProps(props);
|
|
237
|
+
const { data } = await retryOnLock(() => props.apiClient.createProjectEndpoint(props.projectId, {
|
|
238
|
+
endpoint: {
|
|
239
|
+
branch_id: branchId,
|
|
240
|
+
type: props.type,
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
writer(props).end(data.endpoint, {
|
|
244
|
+
fields: ['id', 'host'],
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
const reset = async (props) => {
|
|
248
|
+
if (!props.parent) {
|
|
249
|
+
throw new Error('Only resetting to parent is supported for now');
|
|
250
|
+
}
|
|
251
|
+
const branchId = await branchIdFromProps(props);
|
|
252
|
+
const { data: { branch: { parent_id }, }, } = await props.apiClient.getProjectBranch(props.projectId, branchId);
|
|
253
|
+
if (!parent_id) {
|
|
254
|
+
throw new Error('Branch has no parent');
|
|
255
|
+
}
|
|
256
|
+
const { data } = await retryOnLock(() => props.apiClient.restoreProjectBranch(props.projectId, branchId, {
|
|
257
|
+
source_branch_id: parent_id,
|
|
258
|
+
preserve_under_name: props.preserveUnderName || undefined,
|
|
259
|
+
}));
|
|
260
|
+
const resultBranch = data.branch;
|
|
261
|
+
writer(props).end(resultBranch, {
|
|
262
|
+
// need to reset types until we expose reset api
|
|
263
|
+
fields: BRANCH_FIELDS_RESET,
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
const restore = async (props) => {
|
|
267
|
+
const targetBranchId = await branchIdResolve({
|
|
268
|
+
branch: props.id,
|
|
269
|
+
projectId: props.projectId,
|
|
270
|
+
apiClient: props.apiClient,
|
|
271
|
+
});
|
|
272
|
+
const pointInTime = await parsePointInTime({
|
|
273
|
+
pointInTime: props.pointInTime,
|
|
274
|
+
targetBranchId,
|
|
275
|
+
projectId: props.projectId,
|
|
276
|
+
api: props.apiClient,
|
|
277
|
+
});
|
|
278
|
+
log.info(`Restoring branch ${targetBranchId} to the branch ${pointInTime.branchId} ${(pointInTime.tag === 'lsn' && 'LSN ' + pointInTime.lsn) ||
|
|
279
|
+
(pointInTime.tag === 'timestamp' &&
|
|
280
|
+
'timestamp ' + pointInTime.timestamp) ||
|
|
281
|
+
'head'}`);
|
|
282
|
+
const { data } = await retryOnLock(() => props.apiClient.restoreProjectBranch(props.projectId, targetBranchId, {
|
|
283
|
+
source_branch_id: pointInTime.branchId,
|
|
284
|
+
preserve_under_name: props.preserveUnderName || undefined,
|
|
285
|
+
...(pointInTime.tag === 'lsn' && { source_lsn: pointInTime.lsn }),
|
|
286
|
+
...(pointInTime.tag === 'timestamp' && {
|
|
287
|
+
source_timestamp: pointInTime.timestamp,
|
|
288
|
+
}),
|
|
289
|
+
}));
|
|
290
|
+
const branch = data.branch;
|
|
291
|
+
const writeInst = writer(props).write(branch, {
|
|
292
|
+
title: 'Restored branch',
|
|
293
|
+
fields: ['id', 'name', 'last_reset_at'],
|
|
294
|
+
});
|
|
295
|
+
if (props.preserveUnderName && branch.parent_id) {
|
|
296
|
+
const { data } = await props.apiClient.getProjectBranch(props.projectId, branch.parent_id);
|
|
297
|
+
writeInst.write(data.branch, {
|
|
298
|
+
title: 'Backup branch',
|
|
299
|
+
fields: ['id', 'name'],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
writeInst.end();
|
|
303
|
+
};
|