@heroku/heroku-cli-util 10.1.3-beta.0 → 10.2.0
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/README.md +45 -14
- package/dist/errors/ambiguous.d.ts +3 -3
- package/dist/index.d.ts +19 -17
- package/dist/index.js +10 -20
- package/dist/types/pg/{data-api.d.ts → platform-api.d.ts} +12 -0
- package/dist/types/pg/tunnel.d.ts +2 -4
- package/dist/utils/addons/addon-resolver.d.ts +8 -0
- package/dist/utils/addons/addon-resolver.js +28 -0
- package/dist/utils/addons/{resolve.d.ts → attachment-resolver.d.ts} +1 -1
- package/dist/utils/addons/helpers.d.ts +8 -0
- package/dist/utils/addons/helpers.js +7 -0
- package/dist/utils/pg/bastion.d.ts +1 -1
- package/dist/utils/pg/config-vars.d.ts +1 -1
- package/dist/utils/pg/databases.d.ts +61 -13
- package/dist/utils/pg/databases.js +138 -52
- package/dist/utils/pg/psql.d.ts +52 -9
- package/dist/utils/pg/psql.js +137 -41
- package/package.json +3 -1
- /package/dist/types/pg/{data-api.js → platform-api.js} +0 -0
- /package/dist/utils/addons/{resolve.js → attachment-resolver.js} +0 -0
package/README.md
CHANGED
|
@@ -80,35 +80,66 @@ testHelpers.expectOutput(output, 'expected output');
|
|
|
80
80
|
|
|
81
81
|
### Types
|
|
82
82
|
|
|
83
|
+
#### Error Classes
|
|
84
|
+
|
|
83
85
|
```js
|
|
84
|
-
import {
|
|
86
|
+
import { utils } from '@heroku/heroku-cli-util';
|
|
85
87
|
|
|
86
88
|
// Error types
|
|
87
89
|
try {
|
|
88
|
-
throw new
|
|
90
|
+
throw new utils.errors.AmbiguousError([{ name: 'foo' }, { name: 'bar' }], 'addon');
|
|
89
91
|
} catch (err) {
|
|
90
|
-
if (err instanceof
|
|
92
|
+
if (err instanceof utils.errors.AmbiguousError) {
|
|
91
93
|
console.error('Ambiguous:', err.message);
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
try {
|
|
96
|
-
throw new
|
|
98
|
+
throw new utils.errors.NotFound();
|
|
97
99
|
} catch (err) {
|
|
98
|
-
if (err instanceof
|
|
100
|
+
if (err instanceof utils.errors.NotFound) {
|
|
99
101
|
console.error('Not found:', err.message);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### PostgreSQL Types (TypeScript)
|
|
107
|
+
|
|
108
|
+
Import PG types using the `pg` namespace:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import type { pg } from '@heroku/heroku-cli-util';
|
|
112
|
+
|
|
113
|
+
// Use the types
|
|
114
|
+
const connection: pg.ConnectionDetails = {
|
|
115
|
+
database: 'mydb',
|
|
116
|
+
host: 'localhost',
|
|
117
|
+
password: 'pass',
|
|
118
|
+
pathname: '/mydb',
|
|
119
|
+
port: '5432',
|
|
120
|
+
url: 'postgres://...',
|
|
121
|
+
user: 'admin'
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function processDatabase(details: pg.ConnectionDetailsWithAttachment) {
|
|
125
|
+
// ...
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const addon: pg.AddOnWithRelatedData = { /* ... */ };
|
|
129
|
+
const link: pg.Link = { /* ... */ };
|
|
130
|
+
const tunnel: pg.TunnelConfig = { /* ... */ };
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Alternatively, you can import types directly:
|
|
102
134
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
*/
|
|
135
|
+
```typescript
|
|
136
|
+
import type {
|
|
137
|
+
ConnectionDetails,
|
|
138
|
+
AddOnWithRelatedData,
|
|
139
|
+
ExtendedAddonAttachment,
|
|
140
|
+
Link,
|
|
141
|
+
TunnelConfig
|
|
142
|
+
} from '@heroku/heroku-cli-util';
|
|
112
143
|
```
|
|
113
144
|
|
|
114
145
|
### Database and Utility Helpers
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { ExtendedAddonAttachment } from '../types/pg/
|
|
1
|
+
import type { ExtendedAddon, ExtendedAddonAttachment } from '../types/pg/platform-api.js';
|
|
2
2
|
/**
|
|
3
3
|
* This error is used internally to signal when the `AddonAttachmentResolver` cannot resolve
|
|
4
4
|
* to a single attachment.
|
|
5
5
|
*/
|
|
6
6
|
export declare class AmbiguousError extends Error {
|
|
7
|
-
readonly matches: ExtendedAddonAttachment[];
|
|
7
|
+
readonly matches: ExtendedAddon[] | ExtendedAddonAttachment[];
|
|
8
8
|
readonly type: string;
|
|
9
9
|
readonly body: {
|
|
10
10
|
id: string;
|
|
@@ -12,5 +12,5 @@ export declare class AmbiguousError extends Error {
|
|
|
12
12
|
};
|
|
13
13
|
readonly message: string;
|
|
14
14
|
readonly statusCode = 422;
|
|
15
|
-
constructor(matches: ExtendedAddonAttachment[], type: string);
|
|
15
|
+
constructor(matches: ExtendedAddon[] | ExtendedAddonAttachment[], type: string);
|
|
16
16
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type * as DataApiTypes from './types/pg/platform-api.js';
|
|
2
|
+
import type * as TunnelTypes from './types/pg/tunnel.js';
|
|
2
3
|
import { AmbiguousError } from './errors/ambiguous.js';
|
|
3
4
|
import { NotFound } from './errors/not-found.js';
|
|
4
|
-
import
|
|
5
|
-
import { ConnectionDetails, ConnectionDetailsWithAttachment, TunnelConfig } from './types/pg/tunnel.js';
|
|
5
|
+
import AddonResolver from './utils/addons/addon-resolver.js';
|
|
6
6
|
import { getPsqlConfigs, sshTunnel } from './utils/pg/bastion.js';
|
|
7
7
|
import { getConfigVarNameFromAttachment } from './utils/pg/config-vars.js';
|
|
8
8
|
import DatabaseResolver from './utils/pg/databases.js';
|
|
@@ -14,17 +14,16 @@ import { styledJSON } from './ux/styled-json.js';
|
|
|
14
14
|
import { styledObject } from './ux/styled-object.js';
|
|
15
15
|
import { table } from './ux/table.js';
|
|
16
16
|
import { wait } from './ux/wait.js';
|
|
17
|
-
export declare
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
};
|
|
17
|
+
export declare namespace pg {
|
|
18
|
+
type AddOnWithRelatedData = DataApiTypes.AddOnWithRelatedData;
|
|
19
|
+
type ExtendedAddon = DataApiTypes.ExtendedAddon;
|
|
20
|
+
type ExtendedAddonAttachment = DataApiTypes.ExtendedAddonAttachment;
|
|
21
|
+
type Link = DataApiTypes.Link;
|
|
22
|
+
type ConnectionDetails = TunnelTypes.ConnectionDetails;
|
|
23
|
+
type TunnelConfig = TunnelTypes.TunnelConfig;
|
|
24
|
+
}
|
|
27
25
|
export declare const utils: {
|
|
26
|
+
AddonResolver: typeof AddonResolver;
|
|
28
27
|
errors: {
|
|
29
28
|
AmbiguousError: typeof AmbiguousError;
|
|
30
29
|
NotFound: typeof NotFound;
|
|
@@ -32,12 +31,15 @@ export declare const utils: {
|
|
|
32
31
|
pg: {
|
|
33
32
|
DatabaseResolver: typeof DatabaseResolver;
|
|
34
33
|
PsqlService: typeof PsqlService;
|
|
35
|
-
|
|
36
|
-
database(heroku: APIClient, appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
|
|
37
|
-
};
|
|
34
|
+
addonService: () => string;
|
|
38
35
|
host: typeof getHost;
|
|
36
|
+
isAdvancedDatabase: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
37
|
+
isAdvancedPrivateDatabase: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
38
|
+
isEssentialDatabase: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
39
|
+
isLegacyDatabase: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
40
|
+
isLegacyEssentialDatabase: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
41
|
+
isPostgresAddon: (addon: DataApiTypes.ExtendedAddon | DataApiTypes.ExtendedAddonAttachment["addon"]) => boolean;
|
|
39
42
|
psql: {
|
|
40
|
-
exec(connectionDetails: ConnectionDetailsWithAttachment, query: string, psqlCmdArgs?: string[]): Promise<string>;
|
|
41
43
|
getConfigVarNameFromAttachment: typeof getConfigVarNameFromAttachment;
|
|
42
44
|
getPsqlConfigs: typeof getPsqlConfigs;
|
|
43
45
|
sshTunnel: typeof sshTunnel;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AmbiguousError } from './errors/ambiguous.js';
|
|
2
2
|
import { NotFound } from './errors/not-found.js';
|
|
3
|
+
import AddonResolver from './utils/addons/addon-resolver.js';
|
|
4
|
+
import { getAddonService, isAdvancedDatabase, isAdvancedPrivateDatabase, isEssentialDatabase, isLegacyDatabase, isLegacyEssentialDatabase, isPostgresAddon, } from './utils/addons/helpers.js';
|
|
3
5
|
import { getPsqlConfigs, sshTunnel } from './utils/pg/bastion.js';
|
|
4
6
|
import { getConfigVarNameFromAttachment } from './utils/pg/config-vars.js';
|
|
5
7
|
import DatabaseResolver from './utils/pg/databases.js';
|
|
@@ -12,17 +14,8 @@ import { styledJSON } from './ux/styled-json.js';
|
|
|
12
14
|
import { styledObject } from './ux/styled-object.js';
|
|
13
15
|
import { table } from './ux/table.js';
|
|
14
16
|
import { wait } from './ux/wait.js';
|
|
15
|
-
export const types = {
|
|
16
|
-
pg: {
|
|
17
|
-
AddOnWithRelatedData: {},
|
|
18
|
-
ConnectionDetails: {},
|
|
19
|
-
ConnectionDetailsWithAttachment: {},
|
|
20
|
-
ExtendedAddonAttachment: {},
|
|
21
|
-
Link: {},
|
|
22
|
-
TunnelConfig: {},
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
17
|
export const utils = {
|
|
18
|
+
AddonResolver,
|
|
26
19
|
errors: {
|
|
27
20
|
AmbiguousError,
|
|
28
21
|
NotFound, // This should be NotFoundError for consistency, but we're keeping it for backwards compatibility
|
|
@@ -30,18 +23,15 @@ export const utils = {
|
|
|
30
23
|
pg: {
|
|
31
24
|
DatabaseResolver,
|
|
32
25
|
PsqlService,
|
|
33
|
-
|
|
34
|
-
database(heroku, appId, attachmentId, namespace) {
|
|
35
|
-
const databaseResolver = new DatabaseResolver(heroku);
|
|
36
|
-
return databaseResolver.getDatabase(appId, attachmentId, namespace);
|
|
37
|
-
},
|
|
38
|
-
},
|
|
26
|
+
addonService: getAddonService,
|
|
39
27
|
host: getHost,
|
|
28
|
+
isAdvancedDatabase,
|
|
29
|
+
isAdvancedPrivateDatabase,
|
|
30
|
+
isEssentialDatabase,
|
|
31
|
+
isLegacyDatabase,
|
|
32
|
+
isLegacyEssentialDatabase,
|
|
33
|
+
isPostgresAddon,
|
|
40
34
|
psql: {
|
|
41
|
-
exec(connectionDetails, query, psqlCmdArgs = []) {
|
|
42
|
-
const psqlService = new PsqlService(connectionDetails);
|
|
43
|
-
return psqlService.execQuery(query, psqlCmdArgs);
|
|
44
|
-
},
|
|
45
35
|
getConfigVarNameFromAttachment,
|
|
46
36
|
getPsqlConfigs,
|
|
47
37
|
sshTunnel,
|
|
@@ -31,6 +31,18 @@ type AddonAttachmentWithConfigVarsInclusion = {
|
|
|
31
31
|
export type ExtendedAddonAttachment = {
|
|
32
32
|
addon: AddonDescriptorWithPlanInclusion;
|
|
33
33
|
} & AddonAttachmentWithConfigVarsInclusion;
|
|
34
|
+
/**
|
|
35
|
+
* This is the modified type for the `AddOn` we use on these lib functions because all requests made to
|
|
36
|
+
* Platform API to get add-ons, either through the Add-on List endpoint or the add-on resolver action endpoint,
|
|
37
|
+
* include the header `Accept-Expansion: addon_service,plan`.
|
|
38
|
+
*/
|
|
39
|
+
export type ExtendedAddon = {
|
|
40
|
+
addon_service: DeepRequired<Heroku.AddOnService>;
|
|
41
|
+
plan: DeepRequired<Heroku.Plan>;
|
|
42
|
+
} & DeepRequired<Heroku.AddOn>;
|
|
43
|
+
/**
|
|
44
|
+
* The next two types need review and cleanup. They're not used anywhere in this codebase yet.
|
|
45
|
+
*/
|
|
34
46
|
export type AddOnWithRelatedData = {
|
|
35
47
|
attachment_names?: string[];
|
|
36
48
|
links?: Link[];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Server } from 'node:net';
|
|
2
|
-
import type { ExtendedAddonAttachment } from './
|
|
2
|
+
import type { ExtendedAddonAttachment } from './platform-api.js';
|
|
3
3
|
export type ConnectionDetails = {
|
|
4
4
|
_tunnel?: Server;
|
|
5
|
+
attachment?: ExtendedAddonAttachment;
|
|
5
6
|
database: string;
|
|
6
7
|
host: string;
|
|
7
8
|
password: string;
|
|
@@ -10,9 +11,6 @@ export type ConnectionDetails = {
|
|
|
10
11
|
url: string;
|
|
11
12
|
user: string;
|
|
12
13
|
} & BastionConfig;
|
|
13
|
-
export type ConnectionDetailsWithAttachment = {
|
|
14
|
-
attachment: ExtendedAddonAttachment;
|
|
15
|
-
} & ConnectionDetails;
|
|
16
14
|
export interface TunnelConfig {
|
|
17
15
|
dstHost: string;
|
|
18
16
|
dstPort: number;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { APIClient } from '@heroku-cli/command';
|
|
2
|
+
import type { ExtendedAddon } from '../../types/pg/platform-api.js';
|
|
3
|
+
export default class AddonResolver {
|
|
4
|
+
private readonly heroku;
|
|
5
|
+
private readonly addonHeaders;
|
|
6
|
+
constructor(heroku: APIClient);
|
|
7
|
+
resolve(addon: string, app?: string, addonService?: string): Promise<ExtendedAddon>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AmbiguousError } from '../../errors/ambiguous.js';
|
|
2
|
+
export default class AddonResolver {
|
|
3
|
+
heroku;
|
|
4
|
+
addonHeaders = {
|
|
5
|
+
Accept: 'application/vnd.heroku+json; version=3.sdk',
|
|
6
|
+
'Accept-Expansion': 'addon_service,plan',
|
|
7
|
+
};
|
|
8
|
+
constructor(heroku) {
|
|
9
|
+
this.heroku = heroku;
|
|
10
|
+
}
|
|
11
|
+
async resolve(addon, app, addonService) {
|
|
12
|
+
const [appPart, addonPart] = addon.match(/^(.+)::(.+)$/)?.slice(1) ?? [app, addon];
|
|
13
|
+
console.log('appPart', appPart);
|
|
14
|
+
console.log('addonPart', addonPart);
|
|
15
|
+
const { body: addons } = await this.heroku.post('/actions/addons/resolve', {
|
|
16
|
+
body: {
|
|
17
|
+
addon: addonPart,
|
|
18
|
+
addon_service: addonService,
|
|
19
|
+
app: appPart,
|
|
20
|
+
},
|
|
21
|
+
headers: this.addonHeaders,
|
|
22
|
+
});
|
|
23
|
+
if (addons.length === 1) {
|
|
24
|
+
return addons[0];
|
|
25
|
+
}
|
|
26
|
+
throw new AmbiguousError(addons, 'addon');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { APIClient } from '@heroku-cli/command';
|
|
2
|
-
import type { ExtendedAddonAttachment } from '../../types/pg/
|
|
2
|
+
import type { ExtendedAddonAttachment } from '../../types/pg/platform-api.js';
|
|
3
3
|
export interface AddonAttachmentResolverOptions {
|
|
4
4
|
addonService?: string;
|
|
5
5
|
namespace?: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ExtendedAddon, ExtendedAddonAttachment } from '../../types/pg/platform-api.js';
|
|
2
|
+
export declare const getAddonService: () => string;
|
|
3
|
+
export declare const isPostgresAddon: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
4
|
+
export declare const isAdvancedDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
5
|
+
export declare const isAdvancedPrivateDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
6
|
+
export declare const isLegacyDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
7
|
+
export declare const isLegacyEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
8
|
+
export declare const isEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const getAddonService = () => process.env.HEROKU_POSTGRESQL_ADDON_NAME || process.env.HEROKU_DATA_SERVICE || 'heroku-postgresql';
|
|
2
|
+
export const isPostgresAddon = (addon) => addon.plan.name.split(':', 2)[0] === getAddonService();
|
|
3
|
+
export const isAdvancedDatabase = (addon) => isPostgresAddon(addon) && /^(advanced|performance)/.test(addon.plan.name.split(':', 2)[1]);
|
|
4
|
+
export const isAdvancedPrivateDatabase = (addon) => isAdvancedDatabase(addon) && /(private|shield)/.test(addon.plan.name.split(':', 2)[1]);
|
|
5
|
+
export const isLegacyDatabase = (addon) => isPostgresAddon(addon) && !isAdvancedDatabase(addon);
|
|
6
|
+
export const isLegacyEssentialDatabase = (addon) => isLegacyDatabase(addon) && /^(dev|basic|mini)/.test(addon.plan.name.split(':', 2)[1]);
|
|
7
|
+
export const isEssentialDatabase = (addon) => isLegacyDatabase(addon) && addon.plan.name.split(':', 2)[1].startsWith('essential');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { APIClient } from '@heroku-cli/command';
|
|
2
2
|
import { Server } from 'node:net';
|
|
3
|
-
import { ExtendedAddonAttachment } from '../../types/pg/
|
|
3
|
+
import { ExtendedAddonAttachment } from '../../types/pg/platform-api.js';
|
|
4
4
|
import { BastionConfig, ConnectionDetails, TunnelConfig } from '../../types/pg/tunnel.js';
|
|
5
5
|
/**
|
|
6
6
|
* Determines whether the attachment belongs to an add-on installed onto a non-shield Private Space.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HTTP } from '@heroku/http-call';
|
|
2
2
|
import type { APIClient } from '@heroku-cli/command';
|
|
3
|
-
import type { ExtendedAddonAttachment } from '../../types/pg/
|
|
3
|
+
import type { ExtendedAddonAttachment } from '../../types/pg/platform-api.js';
|
|
4
4
|
/**
|
|
5
5
|
* Cache of app config vars.
|
|
6
6
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { APIClient } from '@heroku-cli/command';
|
|
2
|
-
import type { ExtendedAddonAttachment } from '../../types/pg/
|
|
3
|
-
import type { ConnectionDetails
|
|
2
|
+
import type { ExtendedAddon, ExtendedAddonAttachment } from '../../types/pg/platform-api.js';
|
|
3
|
+
import type { ConnectionDetails } from '../../types/pg/tunnel.js';
|
|
4
4
|
import { fetchBastionConfig } from './bastion.js';
|
|
5
5
|
import { getConfig } from './config-vars.js';
|
|
6
6
|
export default class DatabaseResolver {
|
|
@@ -8,8 +8,34 @@ export default class DatabaseResolver {
|
|
|
8
8
|
private readonly getConfigFn;
|
|
9
9
|
private readonly fetchBastionConfigFn;
|
|
10
10
|
private readonly addonAttachmentResolver;
|
|
11
|
+
private readonly addonHeaders;
|
|
11
12
|
private readonly attachmentHeaders;
|
|
12
13
|
constructor(heroku: APIClient, getConfigFn?: typeof getConfig, fetchBastionConfigFn?: typeof fetchBastionConfig);
|
|
14
|
+
/**
|
|
15
|
+
* Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
|
|
16
|
+
*
|
|
17
|
+
* @param connStringOrDbName - PostgreSQL connection string or local database name
|
|
18
|
+
* @returns Connection details object with parsed connection information
|
|
19
|
+
*/
|
|
20
|
+
static parsePostgresConnectionString(connStringOrDbName: string): ConnectionDetails;
|
|
21
|
+
/**
|
|
22
|
+
* Return all Heroku Postgres databases on the Legacy tiers for a given app.
|
|
23
|
+
*
|
|
24
|
+
* @param app - The name of the app to get the databases for
|
|
25
|
+
* @returns Promise resolving to all Heroku Postgres databases
|
|
26
|
+
* @throws {Error} When no legacy database add-on exists on the app
|
|
27
|
+
*/
|
|
28
|
+
getAllLegacyDatabases(app: string): Promise<Array<{
|
|
29
|
+
attachment_names?: string[];
|
|
30
|
+
} & ExtendedAddonAttachment['addon']>>;
|
|
31
|
+
/**
|
|
32
|
+
* Resolves an arbitrary legacy database add-on based on the provided app name.
|
|
33
|
+
*
|
|
34
|
+
* @param app - The name of the app to get the arbitrary legacy database for
|
|
35
|
+
* @returns Promise resolving to the arbitrary legacy database add-on
|
|
36
|
+
* @throws {Error} When no legacy database add-on exists on the app
|
|
37
|
+
*/
|
|
38
|
+
getArbitraryLegacyDB(app: string): Promise<ExtendedAddon>;
|
|
13
39
|
/**
|
|
14
40
|
* Resolves a database attachment based on the provided database identifier
|
|
15
41
|
* (attachment name, id, or config var name) and namespace (credential).
|
|
@@ -22,6 +48,14 @@ export default class DatabaseResolver {
|
|
|
22
48
|
* @throws {AmbiguousError} When multiple matching attachments are found
|
|
23
49
|
*/
|
|
24
50
|
getAttachment(appId: string, attachmentId?: string, namespace?: string): Promise<ExtendedAddonAttachment>;
|
|
51
|
+
/**
|
|
52
|
+
* Returns the connection details for a database attachment according to the app config vars.
|
|
53
|
+
*
|
|
54
|
+
* @param attachment - The attachment to get the connection details for
|
|
55
|
+
* @param config - The record of app config vars with their values
|
|
56
|
+
* @returns Connection details with attachment information
|
|
57
|
+
*/
|
|
58
|
+
getConnectionDetails(attachment: ExtendedAddonAttachment, config?: Record<string, string>): ConnectionDetails;
|
|
25
59
|
/**
|
|
26
60
|
* Returns the connection details for a database attachment resolved through the identifiers passed as
|
|
27
61
|
* arguments: appId, attachmentId and namespace (credential).
|
|
@@ -31,14 +65,14 @@ export default class DatabaseResolver {
|
|
|
31
65
|
* @param namespace - Optional namespace/credential for the attachment
|
|
32
66
|
* @returns Promise resolving to connection details with attachment information
|
|
33
67
|
*/
|
|
34
|
-
getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<
|
|
68
|
+
getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetails>;
|
|
35
69
|
/**
|
|
36
|
-
*
|
|
70
|
+
* Helper function that attempts to find all Heroku Postgres attachments on a given app.
|
|
37
71
|
*
|
|
38
|
-
* @param
|
|
39
|
-
* @returns
|
|
72
|
+
* @param app - The name of the app to get the attachments for
|
|
73
|
+
* @returns Promise resolving to an array of all Heroku Postgres attachments on the app
|
|
40
74
|
*/
|
|
41
|
-
|
|
75
|
+
private allLegacyDatabaseAttachments;
|
|
42
76
|
/**
|
|
43
77
|
* Fetches all Heroku PostgreSQL add-on attachments for a given app.
|
|
44
78
|
*
|
|
@@ -51,15 +85,29 @@ export default class DatabaseResolver {
|
|
|
51
85
|
*/
|
|
52
86
|
private allPostgresAttachments;
|
|
53
87
|
/**
|
|
54
|
-
*
|
|
88
|
+
* Helper function that groups Heroku Postgres attachments by addon.
|
|
55
89
|
*
|
|
56
|
-
* @param
|
|
57
|
-
* @
|
|
58
|
-
|
|
90
|
+
* @param attachments - The attachments to group by addon
|
|
91
|
+
* @returns A record of addon IDs with their attachment names
|
|
92
|
+
*/
|
|
93
|
+
private getAttachmentNamesByAddon;
|
|
94
|
+
/**
|
|
95
|
+
* Helper function that attempts to find a single addon attachment matching the given database identifier
|
|
96
|
+
* by comparing the identifier to the config var names of all attachments on the app
|
|
97
|
+
* (attachment name, id, or config var name).
|
|
98
|
+
*
|
|
99
|
+
* This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
|
|
100
|
+
* It returns either an array with a single match or an empty array if no matches are found.
|
|
101
|
+
*
|
|
102
|
+
* @param attachments - Array of attachments for the specified app ID
|
|
103
|
+
* @param appId - The ID of the app to search for attachments
|
|
104
|
+
* @param attachmentId - The database identifier to match
|
|
105
|
+
* @returns Promise resolving to either a single match or no matches
|
|
59
106
|
*/
|
|
60
|
-
|
|
107
|
+
private getAttachmentsViaConfigVarNames;
|
|
61
108
|
/**
|
|
62
109
|
* Helper function that attempts to find a single addon attachment matching the given database identifier
|
|
110
|
+
* via the add-on attachments resolver
|
|
63
111
|
* (attachment name, id, or config var name).
|
|
64
112
|
*
|
|
65
113
|
* This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
|
|
@@ -72,5 +120,5 @@ export default class DatabaseResolver {
|
|
|
72
120
|
* @param namespace - Optional namespace/credential filter
|
|
73
121
|
* @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
|
|
74
122
|
*/
|
|
75
|
-
private
|
|
123
|
+
private getAttachmentsViaResolver;
|
|
76
124
|
}
|
|
@@ -2,7 +2,8 @@ import { color } from '@heroku-cli/color';
|
|
|
2
2
|
import { HerokuAPIError } from '@heroku-cli/command/lib/api-client.js';
|
|
3
3
|
import debug from 'debug';
|
|
4
4
|
import { AmbiguousError } from '../../errors/ambiguous.js';
|
|
5
|
-
import AddonAttachmentResolver from '../addons/
|
|
5
|
+
import AddonAttachmentResolver from '../addons/attachment-resolver.js';
|
|
6
|
+
import { getAddonService, isLegacyDatabase } from '../addons/helpers.js';
|
|
6
7
|
import { bastionKeyPlan, fetchBastionConfig, getBastionConfig } from './bastion.js';
|
|
7
8
|
import { getConfig, getConfigVarName, getConfigVarNameFromAttachment } from './config-vars.js';
|
|
8
9
|
const pgDebug = debug('pg');
|
|
@@ -11,6 +12,10 @@ export default class DatabaseResolver {
|
|
|
11
12
|
getConfigFn;
|
|
12
13
|
fetchBastionConfigFn;
|
|
13
14
|
addonAttachmentResolver;
|
|
15
|
+
addonHeaders = {
|
|
16
|
+
Accept: 'application/vnd.heroku+json; version=3.sdk',
|
|
17
|
+
'Accept-Expansion': 'addon_service,plan',
|
|
18
|
+
};
|
|
14
19
|
attachmentHeaders = {
|
|
15
20
|
Accept: 'application/vnd.heroku+json; version=3.sdk',
|
|
16
21
|
'Accept-Inclusion': 'addon:plan,config_vars',
|
|
@@ -21,6 +26,64 @@ export default class DatabaseResolver {
|
|
|
21
26
|
this.fetchBastionConfigFn = fetchBastionConfigFn;
|
|
22
27
|
this.addonAttachmentResolver = new AddonAttachmentResolver(this.heroku);
|
|
23
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
|
|
31
|
+
*
|
|
32
|
+
* @param connStringOrDbName - PostgreSQL connection string or local database name
|
|
33
|
+
* @returns Connection details object with parsed connection information
|
|
34
|
+
*/
|
|
35
|
+
static parsePostgresConnectionString(connStringOrDbName) {
|
|
36
|
+
const dbPath = /:\/\//.test(connStringOrDbName) ? connStringOrDbName : `postgres:///${connStringOrDbName}`;
|
|
37
|
+
const url = new URL(dbPath);
|
|
38
|
+
const { hostname, password, pathname, port, username } = url;
|
|
39
|
+
return {
|
|
40
|
+
database: pathname.slice(1), // remove the leading slash from the pathname
|
|
41
|
+
host: hostname,
|
|
42
|
+
password,
|
|
43
|
+
pathname,
|
|
44
|
+
port: port || process.env.PGPORT || (hostname && '5432'),
|
|
45
|
+
url: dbPath,
|
|
46
|
+
user: username,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Return all Heroku Postgres databases on the Legacy tiers for a given app.
|
|
51
|
+
*
|
|
52
|
+
* @param app - The name of the app to get the databases for
|
|
53
|
+
* @returns Promise resolving to all Heroku Postgres databases
|
|
54
|
+
* @throws {Error} When no legacy database add-on exists on the app
|
|
55
|
+
*/
|
|
56
|
+
async getAllLegacyDatabases(app) {
|
|
57
|
+
pgDebug(`fetching all legacy databases on ${app}`);
|
|
58
|
+
const allAttachments = await this.allLegacyDatabaseAttachments(app);
|
|
59
|
+
const addons = [];
|
|
60
|
+
for (const attachment of allAttachments) {
|
|
61
|
+
if (!addons.some(a => a.id === attachment.addon.id)) {
|
|
62
|
+
addons.push(attachment.addon);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const attachmentNamesByAddon = this.getAttachmentNamesByAddon(allAttachments);
|
|
66
|
+
for (const addon of addons) {
|
|
67
|
+
// eslint-disable-next-line camelcase
|
|
68
|
+
addon.attachment_names = attachmentNamesByAddon[addon.id];
|
|
69
|
+
}
|
|
70
|
+
return addons;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolves an arbitrary legacy database add-on based on the provided app name.
|
|
74
|
+
*
|
|
75
|
+
* @param app - The name of the app to get the arbitrary legacy database for
|
|
76
|
+
* @returns Promise resolving to the arbitrary legacy database add-on
|
|
77
|
+
* @throws {Error} When no legacy database add-on exists on the app
|
|
78
|
+
*/
|
|
79
|
+
async getArbitraryLegacyDB(app) {
|
|
80
|
+
pgDebug(`fetching arbitrary legacy database on ${app}`);
|
|
81
|
+
const { body: addons } = await this.heroku.get(`/apps/${app}/addons`, { headers: this.addonHeaders });
|
|
82
|
+
const addon = addons.find(a => a.app.name === app && isLegacyDatabase(a));
|
|
83
|
+
if (!addon)
|
|
84
|
+
throw new Error(`No Heroku Postgres legacy database on ${app}`);
|
|
85
|
+
return addon;
|
|
86
|
+
}
|
|
24
87
|
/**
|
|
25
88
|
* Resolves a database attachment based on the provided database identifier
|
|
26
89
|
* (attachment name, id, or config var name) and namespace (credential).
|
|
@@ -39,20 +102,24 @@ export default class DatabaseResolver {
|
|
|
39
102
|
appId = appConfigMatch[1];
|
|
40
103
|
attachmentId = appConfigMatch[2];
|
|
41
104
|
}
|
|
42
|
-
|
|
105
|
+
let { error, matches } = await this.getAttachmentsViaResolver(appId, attachmentId, namespace);
|
|
43
106
|
// happy path where the resolver matches just one
|
|
44
107
|
if (matches && matches.length === 1) {
|
|
45
108
|
return matches[0];
|
|
46
109
|
}
|
|
47
|
-
// handle the case where the resolver didn't find any matches for the given database
|
|
110
|
+
// handle the case where the resolver didn't find any matches for the given database.
|
|
48
111
|
if (!matches) {
|
|
49
112
|
const attachments = await this.allPostgresAttachments(appId);
|
|
50
113
|
if (attachments.length === 0) {
|
|
51
114
|
throw new Error(`${color.app(appId)} has no databases`);
|
|
52
115
|
}
|
|
53
|
-
|
|
116
|
+
// attempt to find a match using config var names
|
|
117
|
+
matches = await this.getAttachmentsViaConfigVarNames(attachments, appId, attachmentId);
|
|
118
|
+
if (matches.length === 0) {
|
|
119
|
+
const databaseName = attachmentId.endsWith('_URL') ? attachmentId.slice(0, -4) : attachmentId;
|
|
54
120
|
const validOptions = attachments.map(attachment => getConfigVarName(attachment.config_vars));
|
|
55
|
-
|
|
121
|
+
const validOptionsString = validOptions.map(option => option.endsWith('_URL') ? option.slice(0, -4) : option).join(', ');
|
|
122
|
+
throw new Error(`Unknown database: ${databaseName}. Valid options are: ${validOptionsString}`);
|
|
56
123
|
}
|
|
57
124
|
}
|
|
58
125
|
// handle the case where the resolver found multiple matches for the given database.
|
|
@@ -66,6 +133,36 @@ export default class DatabaseResolver {
|
|
|
66
133
|
}
|
|
67
134
|
throw error;
|
|
68
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Returns the connection details for a database attachment according to the app config vars.
|
|
138
|
+
*
|
|
139
|
+
* @param attachment - The attachment to get the connection details for
|
|
140
|
+
* @param config - The record of app config vars with their values
|
|
141
|
+
* @returns Connection details with attachment information
|
|
142
|
+
*/
|
|
143
|
+
getConnectionDetails(attachment, config = {}) {
|
|
144
|
+
const connStringVar = getConfigVarNameFromAttachment(attachment, config);
|
|
145
|
+
// build the default payload for non-bastion dbs
|
|
146
|
+
pgDebug(`Using "${connStringVar}" to connect to your database…`);
|
|
147
|
+
const conn = DatabaseResolver.parsePostgresConnectionString(config[connStringVar]);
|
|
148
|
+
const payload = {
|
|
149
|
+
attachment,
|
|
150
|
+
database: conn.database,
|
|
151
|
+
host: conn.host,
|
|
152
|
+
password: conn.password,
|
|
153
|
+
pathname: conn.pathname,
|
|
154
|
+
port: conn.port,
|
|
155
|
+
url: conn.url,
|
|
156
|
+
user: conn.user,
|
|
157
|
+
};
|
|
158
|
+
// This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
|
|
159
|
+
const baseName = connStringVar.slice(0, -4);
|
|
160
|
+
const bastion = getBastionConfig(config, baseName);
|
|
161
|
+
if (bastion) {
|
|
162
|
+
Object.assign(payload, bastion);
|
|
163
|
+
}
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
69
166
|
/**
|
|
70
167
|
* Returns the connection details for a database attachment resolved through the identifiers passed as
|
|
71
168
|
* arguments: appId, attachmentId and namespace (credential).
|
|
@@ -87,25 +184,14 @@ export default class DatabaseResolver {
|
|
|
87
184
|
return database;
|
|
88
185
|
}
|
|
89
186
|
/**
|
|
90
|
-
*
|
|
187
|
+
* Helper function that attempts to find all Heroku Postgres attachments on a given app.
|
|
91
188
|
*
|
|
92
|
-
* @param
|
|
93
|
-
* @returns
|
|
189
|
+
* @param app - The name of the app to get the attachments for
|
|
190
|
+
* @returns Promise resolving to an array of all Heroku Postgres attachments on the app
|
|
94
191
|
*/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const url = new URL(dbPath);
|
|
99
|
-
const { hostname, password, pathname, port, username } = url;
|
|
100
|
-
return {
|
|
101
|
-
database: pathname.slice(1), // remove the leading slash from the pathname
|
|
102
|
-
host: hostname,
|
|
103
|
-
password,
|
|
104
|
-
pathname,
|
|
105
|
-
port: port || process.env.PGPORT || (hostname && '5432'),
|
|
106
|
-
url: dbPath,
|
|
107
|
-
user: username,
|
|
108
|
-
};
|
|
192
|
+
async allLegacyDatabaseAttachments(app) {
|
|
193
|
+
const { body: attachments } = await this.heroku.get(`/apps/${app}/addon-attachments`, { headers: this.attachmentHeaders });
|
|
194
|
+
return attachments.filter(a => isLegacyDatabase(a.addon));
|
|
109
195
|
}
|
|
110
196
|
/**
|
|
111
197
|
* Fetches all Heroku PostgreSQL add-on attachments for a given app.
|
|
@@ -118,45 +204,45 @@ export default class DatabaseResolver {
|
|
|
118
204
|
* @returns Promise resolving to array of PostgreSQL add-on attachments
|
|
119
205
|
*/
|
|
120
206
|
async allPostgresAttachments(appId) {
|
|
121
|
-
const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
|
|
122
207
|
const { body: attachments } = await this.heroku.get(`/apps/${appId}/addon-attachments`, {
|
|
123
208
|
headers: this.attachmentHeaders,
|
|
124
209
|
});
|
|
125
|
-
return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] ===
|
|
210
|
+
return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] === getAddonService());
|
|
126
211
|
}
|
|
127
212
|
/**
|
|
128
|
-
*
|
|
213
|
+
* Helper function that groups Heroku Postgres attachments by addon.
|
|
129
214
|
*
|
|
130
|
-
* @param
|
|
131
|
-
* @
|
|
132
|
-
* @returns Connection details with attachment information
|
|
215
|
+
* @param attachments - The attachments to group by addon
|
|
216
|
+
* @returns A record of addon IDs with their attachment names
|
|
133
217
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
pgDebug(`Using "${connStringVar}" to connect to your database…`);
|
|
139
|
-
const conn = DatabaseResolver.parsePostgresConnectionString(config[connStringVar]);
|
|
140
|
-
const payload = {
|
|
141
|
-
attachment,
|
|
142
|
-
database: conn.database,
|
|
143
|
-
host: conn.host,
|
|
144
|
-
password: conn.password,
|
|
145
|
-
pathname: conn.pathname,
|
|
146
|
-
port: conn.port,
|
|
147
|
-
url: conn.url,
|
|
148
|
-
user: conn.user,
|
|
149
|
-
};
|
|
150
|
-
// This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
|
|
151
|
-
const baseName = connStringVar.slice(0, -4);
|
|
152
|
-
const bastion = getBastionConfig(config, baseName);
|
|
153
|
-
if (bastion) {
|
|
154
|
-
Object.assign(payload, bastion);
|
|
218
|
+
getAttachmentNamesByAddon(attachments) {
|
|
219
|
+
const addons = {};
|
|
220
|
+
for (const attachment of attachments) {
|
|
221
|
+
addons[attachment.addon.id] = [...(addons[attachment.addon.id] || []), attachment.name];
|
|
155
222
|
}
|
|
156
|
-
return
|
|
223
|
+
return addons;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Helper function that attempts to find a single addon attachment matching the given database identifier
|
|
227
|
+
* by comparing the identifier to the config var names of all attachments on the app
|
|
228
|
+
* (attachment name, id, or config var name).
|
|
229
|
+
*
|
|
230
|
+
* This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
|
|
231
|
+
* It returns either an array with a single match or an empty array if no matches are found.
|
|
232
|
+
*
|
|
233
|
+
* @param attachments - Array of attachments for the specified app ID
|
|
234
|
+
* @param appId - The ID of the app to search for attachments
|
|
235
|
+
* @param attachmentId - The database identifier to match
|
|
236
|
+
* @returns Promise resolving to either a single match or no matches
|
|
237
|
+
*/
|
|
238
|
+
async getAttachmentsViaConfigVarNames(attachments, appId, attachmentId) {
|
|
239
|
+
const targetConfigVarName = attachmentId.endsWith('_URL') ? attachmentId : `${attachmentId}_URL`;
|
|
240
|
+
const config = await this.getConfigFn(this.heroku, appId);
|
|
241
|
+
return attachments.filter(attachment => config[targetConfigVarName] && config[targetConfigVarName] === config[getConfigVarName(attachment.config_vars)]);
|
|
157
242
|
}
|
|
158
243
|
/**
|
|
159
244
|
* Helper function that attempts to find a single addon attachment matching the given database identifier
|
|
245
|
+
* via the add-on attachments resolver
|
|
160
246
|
* (attachment name, id, or config var name).
|
|
161
247
|
*
|
|
162
248
|
* This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
|
|
@@ -169,9 +255,9 @@ export default class DatabaseResolver {
|
|
|
169
255
|
* @param namespace - Optional namespace/credential filter
|
|
170
256
|
* @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
|
|
171
257
|
*/
|
|
172
|
-
async
|
|
258
|
+
async getAttachmentsViaResolver(appId, attachmentId, namespace) {
|
|
173
259
|
debug(`fetching ${attachmentId} on ${appId}`);
|
|
174
|
-
const addonService =
|
|
260
|
+
const addonService = getAddonService();
|
|
175
261
|
debug(`addon service: ${addonService}`);
|
|
176
262
|
try {
|
|
177
263
|
const attached = await this.addonAttachmentResolver.resolve(appId, attachmentId, { addonService, namespace });
|
package/dist/utils/pg/psql.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { Server } from 'node:net';
|
|
3
|
-
import { ConnectionDetails, TunnelConfig } from '../../types/pg/tunnel.js';
|
|
3
|
+
import type { ConnectionDetails, TunnelConfig } from '../../types/pg/tunnel.js';
|
|
4
4
|
import { getPsqlConfigs, sshTunnel } from './bastion.js';
|
|
5
5
|
/**
|
|
6
6
|
* A small wrapper around tunnel-ssh so that other code doesn't have to worry about whether there is or is not a tunnel.
|
|
@@ -43,6 +43,16 @@ export default class PsqlService {
|
|
|
43
43
|
private readonly spawnFn;
|
|
44
44
|
private readonly tunnelFn;
|
|
45
45
|
constructor(connectionDetails: ConnectionDetails, getPsqlConfigsFn?: typeof getPsqlConfigs, spawnFn?: typeof spawn, tunnelFn?: typeof sshTunnel);
|
|
46
|
+
/**
|
|
47
|
+
* Executes a file containing SQL commands using the instance's database connection details.
|
|
48
|
+
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
49
|
+
* and then calls the `runWithTunnel` function to execute the file.
|
|
50
|
+
*
|
|
51
|
+
* @param file - The path to the SQL file to execute
|
|
52
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
53
|
+
* @returns Promise that resolves to the query result as a string
|
|
54
|
+
*/
|
|
55
|
+
execFile(file: string, psqlCmdArgs?: string[]): Promise<string>;
|
|
46
56
|
/**
|
|
47
57
|
* Executes a PostgreSQL query using the instance's database connection details.
|
|
48
58
|
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
@@ -53,6 +63,29 @@ export default class PsqlService {
|
|
|
53
63
|
* @returns Promise that resolves to the query result as a string
|
|
54
64
|
*/
|
|
55
65
|
execQuery(query: string, psqlCmdArgs?: string[]): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Fetches the PostgreSQL version from the database by executing the `SHOW server_version` query.
|
|
68
|
+
*
|
|
69
|
+
* @returns Promise that resolves to the PostgreSQL version as a string (or undefined).
|
|
70
|
+
*/
|
|
71
|
+
fetchVersion(): Promise<string | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Executes a PostgreSQL interactive session using the instance's database connection details.
|
|
74
|
+
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
75
|
+
* and then calls the `runWithTunnel` function to execute the query.
|
|
76
|
+
*
|
|
77
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
78
|
+
* @returns Promise that resolves to the query result as a string
|
|
79
|
+
*/
|
|
80
|
+
interactiveSession(psqlCmdArgs?: string[]): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Runs the psql command with tunnel support.
|
|
83
|
+
*
|
|
84
|
+
* @param tunnelConfig - The tunnel configuration object
|
|
85
|
+
* @param options - The options for spawning the psql process
|
|
86
|
+
* @returns Promise that resolves to the query result as a string
|
|
87
|
+
*/
|
|
88
|
+
runWithTunnel(tunnelConfig: TunnelConfig, options: Parameters<typeof this.spawnPsql>[0]): Promise<string>;
|
|
56
89
|
/**
|
|
57
90
|
* Consumes a stream and returns its content as a string.
|
|
58
91
|
*
|
|
@@ -72,22 +105,32 @@ export default class PsqlService {
|
|
|
72
105
|
*/
|
|
73
106
|
private kill;
|
|
74
107
|
/**
|
|
75
|
-
* Creates the options for spawning the psql process.
|
|
108
|
+
* Creates the options for spawning the psql process for a SQL file execution.
|
|
76
109
|
*
|
|
77
|
-
* @param
|
|
110
|
+
* @param file - The path to the SQL file to execute
|
|
78
111
|
* @param dbEnv - The database environment variables
|
|
79
112
|
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
80
113
|
* @returns Object containing child process options, database environment, and psql arguments
|
|
81
114
|
*/
|
|
82
|
-
private
|
|
115
|
+
private psqlFileOptions;
|
|
83
116
|
/**
|
|
84
|
-
*
|
|
117
|
+
* Creates the options for spawning the psql process for an interactive psql session.
|
|
85
118
|
*
|
|
86
|
-
* @param
|
|
87
|
-
* @param
|
|
88
|
-
* @
|
|
119
|
+
* @param prompt - The prompt to use for the interactive psql session
|
|
120
|
+
* @param dbEnv - The database environment variables
|
|
121
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
122
|
+
* @returns Object containing child process options, database environment, and psql arguments
|
|
89
123
|
*/
|
|
90
|
-
|
|
124
|
+
private psqlInteractiveOptions;
|
|
125
|
+
/**
|
|
126
|
+
* Creates the options for spawning the psql process for a single query execution.
|
|
127
|
+
*
|
|
128
|
+
* @param query - The SQL query to execute
|
|
129
|
+
* @param dbEnv - The database environment variables
|
|
130
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
131
|
+
* @returns Object containing child process options, database environment, and psql arguments
|
|
132
|
+
*/
|
|
133
|
+
private psqlQueryOptions;
|
|
91
134
|
/**
|
|
92
135
|
* Spawns the psql process with the given options.
|
|
93
136
|
*
|
package/dist/utils/pg/psql.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { ux } from '@oclif/core';
|
|
1
2
|
import debug from 'debug';
|
|
2
3
|
import { spawn, } from 'node:child_process';
|
|
3
4
|
import { EventEmitter, once } from 'node:events';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
4
7
|
import { Stream } from 'node:stream';
|
|
5
8
|
import { finished } from 'node:stream/promises';
|
|
6
9
|
import { getPsqlConfigs, sshTunnel } from './bastion.js';
|
|
@@ -83,6 +86,20 @@ export default class PsqlService {
|
|
|
83
86
|
this.spawnFn = spawnFn;
|
|
84
87
|
this.tunnelFn = tunnelFn;
|
|
85
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Executes a file containing SQL commands using the instance's database connection details.
|
|
91
|
+
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
92
|
+
* and then calls the `runWithTunnel` function to execute the file.
|
|
93
|
+
*
|
|
94
|
+
* @param file - The path to the SQL file to execute
|
|
95
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
96
|
+
* @returns Promise that resolves to the query result as a string
|
|
97
|
+
*/
|
|
98
|
+
async execFile(file, psqlCmdArgs = []) {
|
|
99
|
+
const configs = this.getPsqlConfigsFn(this.connectionDetails);
|
|
100
|
+
const options = this.psqlFileOptions(file, configs.dbEnv, psqlCmdArgs);
|
|
101
|
+
return this.runWithTunnel(configs.dbTunnelConfig, options);
|
|
102
|
+
}
|
|
86
103
|
/**
|
|
87
104
|
* Executes a PostgreSQL query using the instance's database connection details.
|
|
88
105
|
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
@@ -97,6 +114,71 @@ export default class PsqlService {
|
|
|
97
114
|
const options = this.psqlQueryOptions(query, configs.dbEnv, psqlCmdArgs);
|
|
98
115
|
return this.runWithTunnel(configs.dbTunnelConfig, options);
|
|
99
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Fetches the PostgreSQL version from the database by executing the `SHOW server_version` query.
|
|
119
|
+
*
|
|
120
|
+
* @returns Promise that resolves to the PostgreSQL version as a string (or undefined).
|
|
121
|
+
*/
|
|
122
|
+
async fetchVersion() {
|
|
123
|
+
const output = await this.execQuery('SHOW server_version', ['-X', '-q']);
|
|
124
|
+
return output.match(/\d+\.\d+/)?.[0];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Executes a PostgreSQL interactive session using the instance's database connection details.
|
|
128
|
+
* It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
|
|
129
|
+
* and then calls the `runWithTunnel` function to execute the query.
|
|
130
|
+
*
|
|
131
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
132
|
+
* @returns Promise that resolves to the query result as a string
|
|
133
|
+
*/
|
|
134
|
+
async interactiveSession(psqlCmdArgs = []) {
|
|
135
|
+
const attachmentName = this.connectionDetails.attachment.name;
|
|
136
|
+
const prompt = `${this.connectionDetails.attachment.app.name}::${attachmentName}%R%# `;
|
|
137
|
+
const configs = this.getPsqlConfigsFn(this.connectionDetails);
|
|
138
|
+
configs.dbEnv.PGAPPNAME = 'psql interactive'; // default was 'psql non-interactive`
|
|
139
|
+
const options = this.psqlInteractiveOptions(prompt, configs.dbEnv, psqlCmdArgs);
|
|
140
|
+
return this.runWithTunnel(configs.dbTunnelConfig, options);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Runs the psql command with tunnel support.
|
|
144
|
+
*
|
|
145
|
+
* @param tunnelConfig - The tunnel configuration object
|
|
146
|
+
* @param options - The options for spawning the psql process
|
|
147
|
+
* @returns Promise that resolves to the query result as a string
|
|
148
|
+
*/
|
|
149
|
+
async runWithTunnel(tunnelConfig, options) {
|
|
150
|
+
const tunnel = await Tunnel.connect(this.connectionDetails, tunnelConfig, this.tunnelFn);
|
|
151
|
+
pgDebug('after create tunnel');
|
|
152
|
+
const psql = this.spawnPsql(options);
|
|
153
|
+
// Note: In non-interactive mode, psql.stdout is available for capturing output.
|
|
154
|
+
// In interactive mode, stdio: 'inherit' would make psql.stdout null.
|
|
155
|
+
// Return a string for consistency but ideally we should return the child process from this function
|
|
156
|
+
// and let the caller decide what to do with stdin/stdout/stderr
|
|
157
|
+
const stdoutPromise = psql.stdout ? this.consumeStream(psql.stdout) : Promise.resolve('');
|
|
158
|
+
const cleanupSignalTraps = this.trapAndForwardSignalsToChildProcess(psql);
|
|
159
|
+
try {
|
|
160
|
+
pgDebug('waiting for psql or tunnel to exit');
|
|
161
|
+
// wait for either psql or tunnel to exit;
|
|
162
|
+
// the important bit is that we ensure both processes are
|
|
163
|
+
// always cleaned up in the `finally` block below
|
|
164
|
+
await Promise.race([
|
|
165
|
+
this.waitForPSQLExit(psql),
|
|
166
|
+
tunnel.waitForClose(),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
pgDebug('wait for psql or tunnel error', error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
pgDebug('begin tunnel cleanup');
|
|
175
|
+
cleanupSignalTraps();
|
|
176
|
+
tunnel.close();
|
|
177
|
+
this.kill(psql, 'SIGKILL');
|
|
178
|
+
pgDebug('end tunnel cleanup');
|
|
179
|
+
}
|
|
180
|
+
return stdoutPromise;
|
|
181
|
+
}
|
|
100
182
|
/**
|
|
101
183
|
* Consumes a stream and returns its content as a string.
|
|
102
184
|
*
|
|
@@ -138,19 +220,19 @@ export default class PsqlService {
|
|
|
138
220
|
}
|
|
139
221
|
}
|
|
140
222
|
/**
|
|
141
|
-
* Creates the options for spawning the psql process.
|
|
223
|
+
* Creates the options for spawning the psql process for a SQL file execution.
|
|
142
224
|
*
|
|
143
|
-
* @param
|
|
225
|
+
* @param file - The path to the SQL file to execute
|
|
144
226
|
* @param dbEnv - The database environment variables
|
|
145
227
|
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
146
228
|
* @returns Object containing child process options, database environment, and psql arguments
|
|
147
229
|
*/
|
|
148
|
-
|
|
149
|
-
pgDebug('Running
|
|
150
|
-
const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...psqlCmdArgs];
|
|
230
|
+
psqlFileOptions(file, dbEnv, psqlCmdArgs = []) {
|
|
231
|
+
pgDebug('Running SQL file: %s', file.trim());
|
|
151
232
|
const childProcessOptions = {
|
|
152
233
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
153
234
|
};
|
|
235
|
+
const psqlArgs = ['-f', file, '--set', 'sslmode=require', ...psqlCmdArgs];
|
|
154
236
|
return {
|
|
155
237
|
childProcessOptions,
|
|
156
238
|
dbEnv,
|
|
@@ -158,45 +240,59 @@ export default class PsqlService {
|
|
|
158
240
|
};
|
|
159
241
|
}
|
|
160
242
|
/**
|
|
161
|
-
*
|
|
243
|
+
* Creates the options for spawning the psql process for an interactive psql session.
|
|
162
244
|
*
|
|
163
|
-
* @param
|
|
164
|
-
* @param
|
|
165
|
-
* @
|
|
245
|
+
* @param prompt - The prompt to use for the interactive psql session
|
|
246
|
+
* @param dbEnv - The database environment variables
|
|
247
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
248
|
+
* @returns Object containing child process options, database environment, and psql arguments
|
|
166
249
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await Promise.race([
|
|
184
|
-
this.waitForPSQLExit(psql),
|
|
185
|
-
tunnel.waitForClose(),
|
|
186
|
-
]);
|
|
187
|
-
}
|
|
188
|
-
catch (error) {
|
|
189
|
-
pgDebug('wait for psql or tunnel error', error);
|
|
190
|
-
throw error;
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
pgDebug('begin tunnel cleanup');
|
|
194
|
-
cleanupSignalTraps();
|
|
195
|
-
tunnel.close();
|
|
196
|
-
this.kill(psql, 'SIGKILL');
|
|
197
|
-
pgDebug('end tunnel cleanup');
|
|
250
|
+
psqlInteractiveOptions(prompt, dbEnv, psqlCmdArgs = []) {
|
|
251
|
+
let psqlArgs = ['--set', `PROMPT1=${prompt}`, '--set', `PROMPT2=${prompt}`];
|
|
252
|
+
const psqlHistoryPath = process.env.HEROKU_PSQL_HISTORY;
|
|
253
|
+
if (psqlHistoryPath) {
|
|
254
|
+
if (fs.existsSync(psqlHistoryPath) && fs.statSync(psqlHistoryPath).isDirectory()) {
|
|
255
|
+
const appLogFile = `${psqlHistoryPath}/${prompt.split(':')[0]}`;
|
|
256
|
+
pgDebug('Logging psql history to %s', appLogFile);
|
|
257
|
+
psqlArgs = [...psqlArgs, '--set', `HISTFILE=${appLogFile}`];
|
|
258
|
+
}
|
|
259
|
+
else if (fs.existsSync(path.dirname(psqlHistoryPath))) {
|
|
260
|
+
pgDebug('Logging psql history to %s', psqlHistoryPath);
|
|
261
|
+
psqlArgs = [...psqlArgs, '--set', `HISTFILE=${psqlHistoryPath}`];
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
ux.warn(`HEROKU_PSQL_HISTORY is set but is not a valid path (${psqlHistoryPath})\n`);
|
|
265
|
+
}
|
|
198
266
|
}
|
|
199
|
-
|
|
267
|
+
psqlArgs = [...psqlArgs, '--set', 'sslmode=require', ...psqlCmdArgs];
|
|
268
|
+
const childProcessOptions = {
|
|
269
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
childProcessOptions,
|
|
273
|
+
dbEnv,
|
|
274
|
+
psqlArgs,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Creates the options for spawning the psql process for a single query execution.
|
|
279
|
+
*
|
|
280
|
+
* @param query - The SQL query to execute
|
|
281
|
+
* @param dbEnv - The database environment variables
|
|
282
|
+
* @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
|
|
283
|
+
* @returns Object containing child process options, database environment, and psql arguments
|
|
284
|
+
*/
|
|
285
|
+
psqlQueryOptions(query, dbEnv, psqlCmdArgs = []) {
|
|
286
|
+
pgDebug('Running query: %s', query.trim());
|
|
287
|
+
const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...psqlCmdArgs];
|
|
288
|
+
const childProcessOptions = {
|
|
289
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
childProcessOptions,
|
|
293
|
+
dbEnv,
|
|
294
|
+
psqlArgs,
|
|
295
|
+
};
|
|
200
296
|
}
|
|
201
297
|
/**
|
|
202
298
|
* Spawns the psql process with the given options.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@heroku/heroku-cli-util",
|
|
4
|
-
"version": "10.
|
|
4
|
+
"version": "10.2.0",
|
|
5
5
|
"description": "Set of helpful CLI utilities",
|
|
6
6
|
"author": "Heroku",
|
|
7
7
|
"license": "ISC",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@types/node": "^22.15.3",
|
|
22
22
|
"@types/sinon": "^17.0.4",
|
|
23
23
|
"@types/sinon-chai": "^4.0.0",
|
|
24
|
+
"@types/tmp": "^0.2.6",
|
|
24
25
|
"@types/tunnel-ssh": "4.1.1",
|
|
25
26
|
"c8": "^7.7.0",
|
|
26
27
|
"chai": "^4.4.1",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"sinon": "^18.0.1",
|
|
37
38
|
"sinon-chai": "^3.7.0",
|
|
38
39
|
"strip-ansi": "^6",
|
|
40
|
+
"tmp": "^0.2.5",
|
|
39
41
|
"ts-node": "^10.9.2",
|
|
40
42
|
"tsconfig-paths": "^4.2.0",
|
|
41
43
|
"tsheredoc": "^1.0.1",
|
|
File without changes
|
|
File without changes
|