@heroku/heroku-cli-util 10.0.0-beta.2 → 10.0.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.
@@ -1,17 +1,95 @@
1
- import { ux } from '@oclif/core';
2
1
  import debug from 'debug';
3
- import * as EventEmitter from 'node:events';
2
+ import { EventEmitter } from 'node:events';
4
3
  import { promisify } from 'node:util';
5
4
  import * as createTunnel from 'tunnel-ssh';
6
5
  import host from './host.js';
7
6
  const pgDebug = debug('pg');
8
- export const bastionKeyPlan = (a) => Boolean(/private/.test(a.addon.plan.name));
9
- export const env = (db) => {
10
- const baseEnv = {
11
- PGAPPNAME: 'psql non-interactive',
12
- PGSSLMODE: (!db.host || db.host === 'localhost') ? 'prefer' : 'require',
13
- ...process.env,
7
+ /**
8
+ * Determines whether the attachment belongs to an add-on installed onto a non-shield Private Space.
9
+ * If true, the bastion information needs to be fetched from the Data API.
10
+ * For add-ons installed onto a Shield Private Space, the bastion information should be fetched from config vars.
11
+ *
12
+ * @param attachment - The add-on attachment to check
13
+ * @returns True if the attachment belongs to a non-shield Private Space, false otherwise
14
+ */
15
+ export function bastionKeyPlan(attachment) {
16
+ return Boolean(/private/.test(attachment.addon.plan.name.split(':', 2)[1]));
17
+ }
18
+ /**
19
+ * Fetches the bastion configuration from the Data API (only relevant for add-ons installed onto a
20
+ * non-shield Private Space).
21
+ * For add-ons installed onto a Shield Private Space, the bastion information is stored in the config vars.
22
+ *
23
+ * @param heroku - The Heroku API client
24
+ * @param addon - The add-on information
25
+ * @returns Promise that resolves to the bastion configuration
26
+ */
27
+ export async function fetchBastionConfig(heroku, addon) {
28
+ const { body: bastionConfig } = await heroku.get(`/client/v11/databases/${encodeURIComponent(addon.id)}/bastion`, { hostname: host() });
29
+ if (bastionConfig.host && bastionConfig.private_key) {
30
+ return {
31
+ bastionHost: bastionConfig.host,
32
+ bastionKey: bastionConfig.private_key,
33
+ };
34
+ }
35
+ return {};
36
+ }
37
+ /**
38
+ * Returns the bastion configuration from the config vars for add-ons installed onto Shield
39
+ * Private Spaces.
40
+ *
41
+ * If there are bastions, extracts a host and a key from the config vars.
42
+ * If there are no bastions, returns an empty Object.
43
+ *
44
+ * We assert that _BASTIONS and _BASTION_KEY always exist together.
45
+ * If either is falsy, pretend neither exist.
46
+ *
47
+ * @param config - The configuration variables object
48
+ * @param baseName - The base name for the configuration variables
49
+ * @returns The bastion configuration object
50
+ */
51
+ export const getBastionConfig = function (config, baseName) {
52
+ // <BASE_NAME>_BASTION_KEY contains the private key for the bastion.
53
+ const bastionKey = config[`${baseName}_BASTION_KEY`];
54
+ // <BASE_NAME>_BASTIONS contains a comma-separated list of hosts, select one at random.
55
+ const bastions = (config[`${baseName}_BASTIONS`] || '').split(',');
56
+ const bastionHost = bastions[Math.floor(Math.random() * bastions.length)];
57
+ if (bastionKey && bastionHost) {
58
+ return { bastionHost, bastionKey };
59
+ }
60
+ return {};
61
+ };
62
+ /**
63
+ * Returns both the required environment variables to effect the psql command execution and the tunnel
64
+ * configuration according to the database connection details.
65
+ *
66
+ * @param connectionDetails - The database connection details with attachment information
67
+ * @returns Object containing database environment variables and tunnel configuration
68
+ */
69
+ export function getPsqlConfigs(connectionDetails) {
70
+ const dbEnv = baseEnv(connectionDetails);
71
+ const dbTunnelConfig = tunnelConfig(connectionDetails);
72
+ // If a tunnel is required, we need to adjust the environment variables for psql to use the tunnel host and port.
73
+ if (connectionDetails.bastionKey) {
74
+ Object.assign(dbEnv, {
75
+ PGHOST: dbTunnelConfig.localHost,
76
+ PGPORT: dbTunnelConfig.localPort.toString(),
77
+ });
78
+ }
79
+ return {
80
+ dbEnv,
81
+ dbTunnelConfig,
14
82
  };
83
+ }
84
+ /**
85
+ * Returns the base environment variables for the database connection based on the connection details
86
+ * only, without taking into account if a tunnel is required for connecting to the database through a bastion host.
87
+ *
88
+ * @param connectionDetails - The database connection details
89
+ * @returns The base environment variables for the database connection
90
+ */
91
+ function baseEnv(connectionDetails) {
92
+ // Mapping of environment variables to ConnectionDetails properties
15
93
  const mapping = {
16
94
  PGDATABASE: 'database',
17
95
  PGHOST: 'host',
@@ -19,52 +97,53 @@ export const env = (db) => {
19
97
  PGPORT: 'port',
20
98
  PGUSER: 'user',
21
99
  };
100
+ const baseEnv = {
101
+ PGAPPNAME: 'psql non-interactive',
102
+ PGSSLMODE: (!connectionDetails.host || connectionDetails.host === 'localhost') ? 'prefer' : 'require',
103
+ ...process.env,
104
+ };
22
105
  for (const envVar of Object.keys(mapping)) {
23
- const val = db[mapping[envVar]];
106
+ const val = connectionDetails[mapping[envVar]];
24
107
  if (val) {
25
108
  baseEnv[envVar] = val;
26
109
  }
27
110
  }
28
111
  return baseEnv;
29
- };
30
- export async function fetchConfig(heroku, db) {
31
- return heroku.get(`/client/v11/databases/${encodeURIComponent(db.id)}/bastion`, {
32
- hostname: host(),
33
- });
34
112
  }
35
- export const getBastion = function (config, baseName) {
36
- // If there are bastions, extract a host and a key
37
- // otherwise, return an empty Object
38
- // If there are bastions:
39
- // * there should be one *_BASTION_KEY
40
- // * pick one host from the comma-separated list in *_BASTIONS
41
- // We assert that _BASTIONS and _BASTION_KEY always exist together
42
- // If either is falsy, pretend neither exist
43
- const bastionKey = config[`${baseName}_BASTION_KEY`];
44
- const bastions = (config[`${baseName}_BASTIONS`] || '').split(',');
45
- const bastionHost = bastions[Math.floor(Math.random() * bastions.length)];
46
- return (bastionKey && bastionHost) ? { bastionHost, bastionKey } : {};
47
- };
48
- export function getConfigs(db) {
49
- const dbEnv = env(db);
50
- const dbTunnelConfig = tunnelConfig(db);
51
- if (db.bastionKey) {
52
- Object.assign(dbEnv, {
53
- PGHOST: dbTunnelConfig.localHost,
54
- PGPORT: dbTunnelConfig.localPort,
55
- });
56
- }
113
+ /**
114
+ * Creates a tunnel configuration object based on the connection details.
115
+ *
116
+ * @param connectionDetails - The database connection details with attachment information
117
+ * @returns The tunnel configuration object
118
+ */
119
+ function tunnelConfig(connectionDetails) {
120
+ const localHost = '127.0.0.1';
121
+ const localPort = Math.floor((Math.random() * (65_535 - 49_152)) + 49_152);
57
122
  return {
58
- dbEnv,
59
- dbTunnelConfig,
123
+ dstHost: connectionDetails.host,
124
+ dstPort: Number.parseInt(connectionDetails.port, 10),
125
+ host: connectionDetails.bastionHost,
126
+ localHost,
127
+ localPort,
128
+ privateKey: connectionDetails.bastionKey,
129
+ username: 'bastion',
60
130
  };
61
131
  }
62
- export async function sshTunnel(db, dbTunnelConfig, timeout = 10_000) {
63
- if (!db.bastionKey) {
64
- return null;
132
+ /**
133
+ * Establishes an SSH tunnel to the database using the provided configuration.
134
+ *
135
+ * @param connectionDetails - The database connection details with attachment information
136
+ * @param dbTunnelConfig - The tunnel configuration object
137
+ * @param timeout - The timeout in milliseconds (default: 10000)
138
+ * @param createSSHTunnel - The function to create the SSH tunnel (default: promisified createTunnel.default)
139
+ * @returns Promise that resolves to the tunnel server or null if no bastion key is provided
140
+ * @throws Error if unable to establish the tunnel
141
+ */
142
+ export async function sshTunnel(connectionDetails, dbTunnelConfig, timeout = 10_000, createSSHTunnel = promisify(createTunnel.default)) {
143
+ if (!connectionDetails.bastionKey) {
144
+ return;
65
145
  }
66
146
  const timeoutInstance = new Timeout(timeout, 'Establishing a secure tunnel timed out');
67
- const createSSHTunnel = promisify(createTunnel.default);
68
147
  try {
69
148
  return await Promise.race([
70
149
  timeoutInstance.promise(),
@@ -73,44 +152,50 @@ export async function sshTunnel(db, dbTunnelConfig, timeout = 10_000) {
73
152
  }
74
153
  catch (error) {
75
154
  pgDebug(error);
76
- ux.error('Unable to establish a secure tunnel to your database.');
155
+ throw new Error(`Unable to establish a secure tunnel to your database: ${error.message}.`);
77
156
  }
78
157
  finally {
79
158
  timeoutInstance.cancel();
80
159
  }
81
160
  }
82
- export function tunnelConfig(db) {
83
- const localHost = '127.0.0.1';
84
- const localPort = Math.floor((Math.random() * (65_535 - 49_152)) + 49_152);
85
- return {
86
- dstHost: db.host || undefined,
87
- dstPort: (db.port && Number.parseInt(db.port, 10)) || undefined,
88
- host: db.bastionHost,
89
- localHost,
90
- localPort,
91
- privateKey: db.bastionKey,
92
- username: 'bastion',
93
- };
94
- }
161
+ /**
162
+ * A timeout utility class that can be cancelled.
163
+ */
95
164
  class Timeout {
96
- events = new EventEmitter.EventEmitter();
165
+ // eslint-disable-next-line unicorn/prefer-event-target
166
+ events = new EventEmitter();
97
167
  message;
98
168
  timeout;
99
169
  timer;
170
+ /**
171
+ * Creates a new Timeout instance.
172
+ *
173
+ * @param timeout - The timeout duration in milliseconds
174
+ * @param message - The error message to display when timeout occurs
175
+ */
100
176
  constructor(timeout, message) {
101
177
  this.timeout = timeout;
102
178
  this.message = message;
103
179
  }
180
+ /**
181
+ * Cancels the timeout.
182
+ *
183
+ * @returns void
184
+ */
104
185
  cancel() {
105
186
  this.events.emit('cancelled');
106
187
  }
188
+ /**
189
+ * Returns a promise that resolves when the timeout is cancelled or rejects when the timeout occurs.
190
+ *
191
+ * @returns Promise that resolves to void when cancelled or rejects with an error when timeout occurs
192
+ */
107
193
  async promise() {
108
- this.timer = setTimeout(() => {
109
- this.events.emit('error', new Error(this.message));
110
- }, this.timeout);
194
+ this.timer = setTimeout(() => this.events.emit('timeout'), this.timeout);
111
195
  try {
112
- await new Promise(resolve => {
196
+ await new Promise((resolve, reject) => {
113
197
  this.events.once('cancelled', () => resolve());
198
+ this.events.once('timeout', () => reject(new Error(this.message)));
114
199
  });
115
200
  }
116
201
  finally {
@@ -1,8 +1,34 @@
1
+ import type { HTTP } from '@heroku/http-call';
1
2
  import type { APIClient } from '@heroku-cli/command';
2
- import type { AddOnAttachment } from '@heroku-cli/schema';
3
- import type { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api.js';
4
- export declare function getConfig(heroku: APIClient, app: string): Promise<Record<string, string> | undefined>;
5
- export declare function getConfigVarName(configVars: string[]): string;
6
- export declare function getConfigVarNameFromAttachment(attachment: Required<{
7
- addon: AddOnAttachmentWithConfigVarsAndPlan;
8
- } & AddOnAttachment>, config?: Record<string, string>): string;
3
+ import type { ExtendedAddonAttachment } from '../../types/pg/data-api.js';
4
+ /**
5
+ * Cache of app config vars.
6
+ */
7
+ export declare const configVarsByAppIdCache: Map<string, Promise<HTTP<Record<string, string>>>>;
8
+ /**
9
+ * Returns the app's config vars as a record of key-value pairs, either from the cache or from the API.
10
+ *
11
+ * @param heroku - The Heroku API client
12
+ * @param appId - The ID of the app to get config vars for
13
+ * @returns Promise resolving to a record of config var key-value pairs
14
+ */
15
+ export declare function getConfig(heroku: APIClient, appId: string): Promise<Record<string, string>>;
16
+ /**
17
+ * Returns the attachment's first config var name that has a `_URL` suffix, expected to be the name of the one
18
+ * that contains the database URL connection string.
19
+ *
20
+ * @param configVarNames - Array of config var names from the attachment
21
+ * @returns The first config var name ending with '_URL'
22
+ * @throws {Error} When no config var names end with '_URL'
23
+ */
24
+ export declare function getConfigVarName(configVarNames: ExtendedAddonAttachment['config_vars']): string;
25
+ /**
26
+ * Returns the config var name that contains the database URL connection string for the given
27
+ * attachment, based on the contents of the app's config vars.
28
+ *
29
+ * @param attachment - The addon attachment to get the config var name for
30
+ * @param config - Optional record of app config vars (defaults to empty object)
31
+ * @returns The config var name containing the database URL
32
+ * @throws {Error} When no config vars are found or when they don't contain a database URL
33
+ */
34
+ export declare function getConfigVarNameFromAttachment(attachment: ExtendedAddonAttachment, config?: Record<string, string>): string;
@@ -1,28 +1,62 @@
1
1
  import { color } from '@heroku-cli/color';
2
- import { ux } from '@oclif/core';
3
- const responseByAppId = new Map();
4
- export async function getConfig(heroku, app) {
5
- if (!responseByAppId.has(app)) {
6
- const promise = heroku.get(`/apps/${app}/config-vars`);
7
- responseByAppId.set(app, promise);
2
+ /**
3
+ * Cache of app config vars.
4
+ */
5
+ export const configVarsByAppIdCache = new Map();
6
+ /**
7
+ * Returns the app's config vars as a record of key-value pairs, either from the cache or from the API.
8
+ *
9
+ * @param heroku - The Heroku API client
10
+ * @param appId - The ID of the app to get config vars for
11
+ * @returns Promise resolving to a record of config var key-value pairs
12
+ */
13
+ export async function getConfig(heroku, appId) {
14
+ let promise = configVarsByAppIdCache.get(appId);
15
+ if (!promise) {
16
+ promise = heroku.get(`/apps/${appId}/config-vars`);
17
+ configVarsByAppIdCache.set(appId, promise);
8
18
  }
9
- const result = await responseByAppId.get(app);
10
- return result?.body;
19
+ const { body: config } = await promise;
20
+ return config;
11
21
  }
12
- export function getConfigVarName(configVars) {
13
- const connStringVars = configVars.filter(cv => (cv.endsWith('_URL')));
14
- if (connStringVars.length === 0)
22
+ /**
23
+ * Returns the attachment's first config var name that has a `_URL` suffix, expected to be the name of the one
24
+ * that contains the database URL connection string.
25
+ *
26
+ * @param configVarNames - Array of config var names from the attachment
27
+ * @returns The first config var name ending with '_URL'
28
+ * @throws {Error} When no config var names end with '_URL'
29
+ */
30
+ export function getConfigVarName(configVarNames) {
31
+ const urlConfigVarNames = configVarNames.filter(cv => (cv.endsWith('_URL')));
32
+ if (urlConfigVarNames.length === 0)
15
33
  throw new Error('Database URL not found for this addon');
16
- return connStringVars[0];
34
+ return urlConfigVarNames[0];
17
35
  }
36
+ /**
37
+ * Returns the config var name that contains the database URL connection string for the given
38
+ * attachment, based on the contents of the app's config vars.
39
+ *
40
+ * @param attachment - The addon attachment to get the config var name for
41
+ * @param config - Optional record of app config vars (defaults to empty object)
42
+ * @returns The config var name containing the database URL
43
+ * @throws {Error} When no config vars are found or when they don't contain a database URL
44
+ */
18
45
  export function getConfigVarNameFromAttachment(attachment, config = {}) {
19
- const configVars = attachment.addon.config_vars?.filter((cv) => config[cv]?.startsWith('postgres://')) ?? [];
20
- if (configVars.length === 0) {
21
- ux.error(`No config vars found for ${attachment.name}; perhaps they were removed as a side effect of ${color.cmd('heroku rollback')}? Use ${color.cmd('heroku addons:attach')} to create a new attachment and then ${color.cmd('heroku addons:detach')} to remove the current attachment.`);
46
+ // Handle the case where no attachment config var names remain after filtering out those that don't contain a
47
+ // database URL connection string in the app's config vars.
48
+ const connStringConfigVarNames = attachment.config_vars
49
+ .filter(cvn => config[cvn]?.startsWith('postgres://'));
50
+ if (connStringConfigVarNames.length === 0) {
51
+ throw new Error(`No config vars found for ${attachment.name}; perhaps they were removed as a side effect of`
52
+ + ` ${color.cmd('heroku rollback')}? Use ${color.cmd('heroku addons:attach')} to create a new attachment and `
53
+ + `then ${color.cmd('heroku addons:detach')} to remove the current attachment.`);
22
54
  }
55
+ // Generate the default config var name and return it if it contains a database URL connection string.
23
56
  const configVarName = `${attachment.name}_URL`;
24
- if (configVars.includes(configVarName) && configVarName in config) {
57
+ if (connStringConfigVarNames.includes(configVarName) && configVarName in config) {
25
58
  return configVarName;
26
59
  }
27
- return getConfigVarName(configVars);
60
+ // Return the first config var name that has a `_URL` suffix. This might not be needed at all anymore.
61
+ return getConfigVarName(connStringConfigVarNames);
28
62
  }
@@ -1,12 +1,76 @@
1
- import type { AddOnAttachment } from '@heroku-cli/schema';
2
1
  import { APIClient } from '@heroku-cli/command';
3
- import type { AddOnAttachmentWithConfigVarsAndPlan } from '../../types/pg/data-api.js';
2
+ import type { ExtendedAddonAttachment } from '../../types/pg/data-api.js';
4
3
  import type { ConnectionDetails, ConnectionDetailsWithAttachment } from '../../types/pg/tunnel.js';
5
- export declare function getAttachment(heroku: APIClient, app: string, db?: string, namespace?: string): Promise<Required<{
6
- addon: AddOnAttachmentWithConfigVarsAndPlan;
7
- } & AddOnAttachment>>;
8
- export declare const getConnectionDetails: (attachment: Required<{
9
- addon: AddOnAttachmentWithConfigVarsAndPlan;
10
- } & AddOnAttachment>, configVars?: Record<string, string>) => ConnectionDetailsWithAttachment;
11
- export declare function getDatabase(heroku: APIClient, app: string, db?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
12
- export declare const parsePostgresConnectionString: (db: string) => ConnectionDetails;
4
+ import { fetchBastionConfig } from './bastion.js';
5
+ import { getConfig } from './config-vars.js';
6
+ export default class DatabaseResolver {
7
+ private readonly heroku;
8
+ private readonly getConfigFn;
9
+ private readonly fetchBastionConfigFn;
10
+ private readonly addonAttachmentResolver;
11
+ private readonly attachmentHeaders;
12
+ constructor(heroku: APIClient, getConfigFn?: typeof getConfig, fetchBastionConfigFn?: typeof fetchBastionConfig);
13
+ /**
14
+ * Resolves a database attachment based on the provided database identifier
15
+ * (attachment name, id, or config var name) and namespace (credential).
16
+ *
17
+ * @param appId - The ID of the app to get the attachment for
18
+ * @param attachmentId - The database identifier (defaults to 'DATABASE_URL')
19
+ * @param namespace - Optional namespace/credential for the attachment
20
+ * @returns Promise resolving to the database attachment
21
+ * @throws {Error} When no databases exist or when database identifier is unknown
22
+ * @throws {AmbiguousError} When multiple matching attachments are found
23
+ */
24
+ getAttachment(appId: string, attachmentId?: string, namespace?: string): Promise<ExtendedAddonAttachment>;
25
+ /**
26
+ * Returns the connection details for a database attachment resolved through the identifiers passed as
27
+ * arguments: appId, attachmentId and namespace (credential).
28
+ *
29
+ * @param appId - The ID of the app containing the database
30
+ * @param attachmentId - Optional database identifier (defaults to 'DATABASE_URL')
31
+ * @param namespace - Optional namespace/credential for the attachment
32
+ * @returns Promise resolving to connection details with attachment information
33
+ */
34
+ getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
35
+ /**
36
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
37
+ *
38
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
39
+ * @returns Connection details object with parsed connection information
40
+ */
41
+ parsePostgresConnectionString(connStringOrDbName: string): ConnectionDetails;
42
+ /**
43
+ * Fetches all Heroku PostgreSQL add-on attachments for a given app.
44
+ *
45
+ * This is used internally by the `getAttachment` function to get all valid Heroku PostgreSQL add-on attachments
46
+ * to generate a list of possible valid attachments when the user passes a database name that doesn't match any
47
+ * attachments.
48
+ *
49
+ * @param appId - The ID of the app to get the attachments for
50
+ * @returns Promise resolving to array of PostgreSQL add-on attachments
51
+ */
52
+ private allPostgresAttachments;
53
+ /**
54
+ * Returns the connection details for a database attachment according to the app config vars.
55
+ *
56
+ * @param attachment - The attachment to get the connection details for
57
+ * @param config - The record of app config vars with their values
58
+ * @returns Connection details with attachment information
59
+ */
60
+ private getConnectionDetails;
61
+ /**
62
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
63
+ * (attachment name, id, or config var name).
64
+ *
65
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
66
+ * It returns either a single match, multiple matches (for ambiguous cases), or null if no matches are found.
67
+ *
68
+ * The AddonAttachmentResolver uses the Platform API add-on attachment resolver endpoint to get the attachment.
69
+ *
70
+ * @param appId - The ID of the app to search for attachments
71
+ * @param attachmentId - The database identifier to match
72
+ * @param namespace - Optional namespace/credential filter
73
+ * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
74
+ */
75
+ private matchesHelper;
76
+ }