@heroku/sdk 0.5.1 → 0.5.3
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/dist/core/create-client.d.ts +9 -1
- package/dist/core/create-client.js +4 -2
- package/dist/resources/data/redis/errors.d.ts +8 -0
- package/dist/resources/data/redis/errors.js +15 -0
- package/dist/resources/data/redis/index.d.ts +9 -0
- package/dist/resources/data/redis/index.js +10 -0
- package/dist/resources/data/redis/resolve-by-app.d.ts +35 -0
- package/dist/resources/data/redis/resolve-by-app.js +56 -0
- package/dist/resources/data/redis/wait-for-ready.d.ts +32 -0
- package/dist/resources/data/redis/wait-for-ready.js +46 -0
- package/dist/resources/extensions/data.d.ts +1 -0
- package/dist/resources/extensions/data.js +1 -0
- package/dist/resources/extensions/platform.d.ts +5 -1
- package/dist/resources/extensions/platform.js +5 -1
- package/dist/resources/platform/add-on/destroy-and-wait.d.ts +22 -0
- package/dist/resources/platform/add-on/destroy-and-wait.js +62 -0
- package/dist/resources/platform/add-on/index.d.ts +9 -5
- package/dist/resources/platform/add-on/index.js +6 -0
- package/dist/resources/platform/add-on/types.d.ts +35 -0
- package/dist/resources/platform/add-on/wait-for-provisioning.d.ts +19 -0
- package/dist/resources/platform/add-on/wait-for-provisioning.js +37 -0
- package/dist/resources/platform/app/index.d.ts +3 -0
- package/dist/resources/platform/app/index.js +3 -0
- package/dist/resources/platform/app/wait-for-acm-certificates.d.ts +34 -0
- package/dist/resources/platform/app/wait-for-acm-certificates.js +51 -0
- package/dist/resources/platform/container/ensure-container-stack.d.ts +18 -0
- package/dist/resources/platform/container/ensure-container-stack.js +32 -0
- package/dist/resources/platform/container/index.d.ts +13 -0
- package/dist/resources/platform/container/index.js +12 -0
- package/dist/resources/platform/container/release-images.d.ts +21 -0
- package/dist/resources/platform/container/release-images.js +23 -0
- package/dist/resources/platform/container/remove-process-types.d.ts +22 -0
- package/dist/resources/platform/container/remove-process-types.js +20 -0
- package/dist/resources/platform/dyno/index.d.ts +3 -0
- package/dist/resources/platform/dyno/index.js +3 -0
- package/dist/resources/platform/dyno/run.d.ts +76 -0
- package/dist/resources/platform/dyno/run.js +74 -0
- package/dist/resources/platform/log-session/index.js +71 -12
- package/dist/resources/platform/sni-endpoint/create-and-associate.d.ts +38 -0
- package/dist/resources/platform/sni-endpoint/create-and-associate.js +86 -0
- package/dist/resources/platform/sni-endpoint/index.d.ts +5 -0
- package/dist/resources/platform/sni-endpoint/index.js +6 -0
- package/dist/resources/platform/telemetry-drain/index.d.ts +6 -0
- package/dist/resources/platform/telemetry-drain/index.js +6 -0
- package/dist/resources/platform/telemetry-drain/remove-drains-for-target.d.ts +13 -0
- package/dist/resources/platform/telemetry-drain/remove-drains-for-target.js +25 -0
- package/dist/resources/platform/telemetry-drain/types.d.ts +13 -0
- package/dist/resources/platform/telemetry-drain/types.js +1 -0
- package/dist/resources/platform/test-run/index.d.ts +5 -0
- package/dist/resources/platform/test-run/index.js +9 -0
- package/dist/resources/platform/test-run/wait-for-state.d.ts +68 -0
- package/dist/resources/platform/test-run/wait-for-state.js +65 -0
- package/package.json +10 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { wait } from '../../../utils/wait.js';
|
|
2
|
+
const DEFAULT_WAIT_INTERVAL_MS = 15_000;
|
|
3
|
+
/**
|
|
4
|
+
* Waits for ACM (Automated Certificate Management) certificates to be
|
|
5
|
+
* issued on all custom domains of an app.
|
|
6
|
+
*
|
|
7
|
+
* Polls the app's domains until every `custom` domain reaches a
|
|
8
|
+
* terminal `acm_status` of `'cert issued'` or `'failed'`, then returns
|
|
9
|
+
* the custom domains. `heroku` domains are ignored and never block
|
|
10
|
+
* termination.
|
|
11
|
+
*
|
|
12
|
+
* @throws if any custom domain finishes with `acm_status: 'failed'`
|
|
13
|
+
* (`'ACM not enabled for some domains'`), or if `timeoutMs` elapses
|
|
14
|
+
* before all custom domains reach a terminal status.
|
|
15
|
+
*/
|
|
16
|
+
export async function waitForACMCertificates(ctx, appIdentity, options = {}) {
|
|
17
|
+
const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, signal, timeoutMs } = options;
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
20
|
+
function terminal(domains) {
|
|
21
|
+
return domains
|
|
22
|
+
.filter(domain => domain.kind === 'custom')
|
|
23
|
+
.every(domain => domain.acm_status === 'cert issued' || domain.acm_status === 'failed');
|
|
24
|
+
}
|
|
25
|
+
function someFailed(domains) {
|
|
26
|
+
return domains
|
|
27
|
+
.filter(domain => domain.kind === 'custom')
|
|
28
|
+
.some(domain => domain.acm_status === 'failed');
|
|
29
|
+
}
|
|
30
|
+
function backoff(attempts) {
|
|
31
|
+
const multiplier = attempts < 60 ? Math.floor(attempts / 20) : 3;
|
|
32
|
+
return intervalMs * (1 + multiplier);
|
|
33
|
+
}
|
|
34
|
+
let domains = await platform.domain.list(appIdentity);
|
|
35
|
+
if (!terminal(domains)) {
|
|
36
|
+
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
|
37
|
+
let retries = 0;
|
|
38
|
+
while (!terminal(domains)) {
|
|
39
|
+
await wait(backoff(retries), signal);
|
|
40
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
41
|
+
throw new Error(`Timed out waiting for ACM certificates on app ${appIdentity}`);
|
|
42
|
+
}
|
|
43
|
+
domains = await platform.domain.list(appIdentity);
|
|
44
|
+
retries++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (someFailed(domains)) {
|
|
48
|
+
throw new Error('ACM not enabled for some domains');
|
|
49
|
+
}
|
|
50
|
+
return domains.filter(domain => domain.kind === 'custom');
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { App } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
import type { ContainerOptions } from './index.js';
|
|
4
|
+
export declare class NotAContainerAppError extends Error {
|
|
5
|
+
readonly app: Pick<App, 'build_stack' | 'id' | 'name' | 'stack'>;
|
|
6
|
+
readonly id = "not_a_container_app";
|
|
7
|
+
constructor(app: Pick<App, 'build_stack' | 'id' | 'name' | 'stack'>);
|
|
8
|
+
get body(): {
|
|
9
|
+
app: Pick<App, "stack" | "id" | "name" | "build_stack">;
|
|
10
|
+
id: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ensure that the given app is a container app.
|
|
16
|
+
* @throws {NotAContainerAppError} if the app is not a container app
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureContainerStack(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, options?: ContainerOptions): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class NotAContainerAppError extends Error {
|
|
2
|
+
app;
|
|
3
|
+
id = 'not_a_container_app';
|
|
4
|
+
constructor(app) {
|
|
5
|
+
super('This operation is for Docker apps only.');
|
|
6
|
+
this.app = app;
|
|
7
|
+
this.name = 'NotAContainerAppError';
|
|
8
|
+
}
|
|
9
|
+
get body() {
|
|
10
|
+
return { app: this.app, id: this.id, message: this.message };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Ensure that the given app is a container app.
|
|
15
|
+
* @throws {NotAContainerAppError} if the app is not a container app
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureContainerStack(ctx, appIdentity, options = {}) {
|
|
18
|
+
const { signal } = options;
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
21
|
+
const app = await platform.app.info(appIdentity);
|
|
22
|
+
const allowedStack = 'container';
|
|
23
|
+
if (app.build_stack.name !== allowedStack && app.stack.name !== allowedStack) {
|
|
24
|
+
throw new NotAContainerAppError({
|
|
25
|
+
// eslint-disable-next-line camelcase
|
|
26
|
+
build_stack: app.build_stack,
|
|
27
|
+
id: app.id,
|
|
28
|
+
name: app.name,
|
|
29
|
+
stack: app.stack,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DockerReleasesBatchUpdateOpts } from './release-images.js';
|
|
2
|
+
import { type RemoveProcessTypesOpts } from './remove-process-types.js';
|
|
3
|
+
export { ensureContainerStack, NotAContainerAppError } from './ensure-container-stack.js';
|
|
4
|
+
export { type DockerReleasesBatchUpdateOpts, releaseDockerImages, type ReleaseDockerImagesResult } from './release-images.js';
|
|
5
|
+
export { removeProcessTypes, type RemoveProcessTypesOpts } from './remove-process-types.js';
|
|
6
|
+
export type ContainerOptions = {
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
};
|
|
9
|
+
export declare const containerExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "container", {
|
|
10
|
+
ensureContainerStack: (appIdentity: string, options?: ContainerOptions) => Promise<void>;
|
|
11
|
+
releaseImages: (appIdentity: string, imagesByProcessType: DockerReleasesBatchUpdateOpts[], options?: ContainerOptions) => Promise<import("./release-images.js").ReleaseDockerImagesResult>;
|
|
12
|
+
removeProcessTypes: (appIdentity: string, processTypes: string[], options?: RemoveProcessTypesOpts) => Promise<import("@heroku/types/3.sdk").Formation[]>;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { extendResource } from '../../../core/extend-resource.js';
|
|
2
|
+
import { ensureContainerStack } from './ensure-container-stack.js';
|
|
3
|
+
import { releaseDockerImages } from './release-images.js';
|
|
4
|
+
import { removeProcessTypes } from './remove-process-types.js';
|
|
5
|
+
export { ensureContainerStack, NotAContainerAppError } from './ensure-container-stack.js';
|
|
6
|
+
export { releaseDockerImages } from './release-images.js';
|
|
7
|
+
export { removeProcessTypes } from './remove-process-types.js';
|
|
8
|
+
export const containerExtensions = extendResource('platform', 'container', ctx => ({
|
|
9
|
+
ensureContainerStack: (appIdentity, options) => ensureContainerStack(ctx, appIdentity, options),
|
|
10
|
+
releaseImages: (appIdentity, imagesByProcessType, options) => releaseDockerImages(ctx, appIdentity, imagesByProcessType, options),
|
|
11
|
+
removeProcessTypes: (appIdentity, processTypes, options) => removeProcessTypes(ctx, appIdentity, processTypes, options),
|
|
12
|
+
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Release } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
import type { ContainerOptions } from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* An update item for the `3.docker-releases` API variant.
|
|
6
|
+
*
|
|
7
|
+
* The `docker_image` field is only available with
|
|
8
|
+
* `Accept: application/vnd.heroku+json; version=3.docker-releases`
|
|
9
|
+
*/
|
|
10
|
+
export type DockerReleasesBatchUpdateOpts = {
|
|
11
|
+
docker_image: string;
|
|
12
|
+
type: string;
|
|
13
|
+
};
|
|
14
|
+
export type ReleaseDockerImagesResult = {
|
|
15
|
+
newRelease: Release | undefined;
|
|
16
|
+
oldRelease: Release | undefined;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Release Docker images to a container app.
|
|
20
|
+
*/
|
|
21
|
+
export declare function releaseDockerImages(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, imagesByProcessType: DockerReleasesBatchUpdateOpts[], options?: ContainerOptions): Promise<ReleaseDockerImagesResult>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ensureContainerStack } from './ensure-container-stack.js';
|
|
2
|
+
/**
|
|
3
|
+
* Release Docker images to a container app.
|
|
4
|
+
*/
|
|
5
|
+
export async function releaseDockerImages(ctx, appIdentity, imagesByProcessType, options = {}) {
|
|
6
|
+
await ensureContainerStack(ctx, appIdentity, options);
|
|
7
|
+
const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
|
|
8
|
+
const oldReleases = await platform
|
|
9
|
+
.withHeaders({ Range: 'version ..; max=1, order=desc' })
|
|
10
|
+
.release.list(appIdentity);
|
|
11
|
+
const oldRelease = oldReleases[0];
|
|
12
|
+
await platform
|
|
13
|
+
.withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
|
|
14
|
+
.formation.batchUpdate(appIdentity, { updates: imagesByProcessType });
|
|
15
|
+
const newReleases = await platform
|
|
16
|
+
.withHeaders({ Range: 'version ..; max=1, order=desc' })
|
|
17
|
+
.release.list(appIdentity);
|
|
18
|
+
const newRelease = newReleases[0];
|
|
19
|
+
return {
|
|
20
|
+
newRelease,
|
|
21
|
+
oldRelease,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Formation } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
import type { ContainerOptions } from './index.js';
|
|
4
|
+
export type RemoveProcessTypesOpts = {
|
|
5
|
+
/**
|
|
6
|
+
* Fires `onStart` immediately before, and `onStop` immediately after,
|
|
7
|
+
* each process type's `formation.update` call. Process types are
|
|
8
|
+
* removed one at a time (not concurrently) so a caller can drive a
|
|
9
|
+
* single shared UI element (e.g. a CLI spinner) across the whole
|
|
10
|
+
* batch. If an update rejects, its `onStart` has already fired but
|
|
11
|
+
* `onStop` never will — the rejection propagates immediately and any
|
|
12
|
+
* remaining process types are not attempted.
|
|
13
|
+
*/
|
|
14
|
+
onProgress?: {
|
|
15
|
+
onStart?: (processType: string) => void;
|
|
16
|
+
onStop?: (processType: string) => void;
|
|
17
|
+
};
|
|
18
|
+
} & ContainerOptions;
|
|
19
|
+
/**
|
|
20
|
+
* Remove one or more process types from a container app
|
|
21
|
+
*/
|
|
22
|
+
export declare function removeProcessTypes(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, processTypes: string[], options?: RemoveProcessTypesOpts): Promise<Formation[]>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ensureContainerStack } from './ensure-container-stack.js';
|
|
2
|
+
/**
|
|
3
|
+
* Remove one or more process types from a container app
|
|
4
|
+
*/
|
|
5
|
+
export async function removeProcessTypes(ctx, appIdentity, processTypes, options = {}) {
|
|
6
|
+
await ensureContainerStack(ctx, appIdentity, options);
|
|
7
|
+
const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
|
|
8
|
+
// eslint-disable-next-line camelcase
|
|
9
|
+
const requestBody = { docker_image: null };
|
|
10
|
+
const updatedFormations = [];
|
|
11
|
+
for (const processType of processTypes) {
|
|
12
|
+
options.onProgress?.onStart?.(processType);
|
|
13
|
+
const formation = await platform
|
|
14
|
+
.withHeaders({ Accept: 'application/vnd.heroku+json; version=3.docker-releases' })
|
|
15
|
+
.formation.update(appIdentity, processType, requestBody);
|
|
16
|
+
options.onProgress?.onStop?.(processType);
|
|
17
|
+
updatedFormations.push(formation);
|
|
18
|
+
}
|
|
19
|
+
return updatedFormations;
|
|
20
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Formation } from '@heroku/types/3.sdk';
|
|
2
2
|
import type { StickyRouteOptions } from '../../../core/create-client.js';
|
|
3
3
|
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
4
|
+
import { type RunDynoOptions } from './run.js';
|
|
4
5
|
import { type WaitForInfoOptions } from './wait-for-info.js';
|
|
6
|
+
export { runDyno, type RunDynoOptions } from './run.js';
|
|
5
7
|
export { DynoNotReadyError, type DynoState, waitForInfo, type WaitForInfoOptions, } from './wait-for-info.js';
|
|
6
8
|
export type DynoOptions = {
|
|
7
9
|
signal?: AbortSignal;
|
|
@@ -39,6 +41,7 @@ export declare function scaleDynos(ctx: {
|
|
|
39
41
|
export declare function restartDynos(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions): Promise<void>;
|
|
40
42
|
export declare const dynoExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "dyno", {
|
|
41
43
|
restart: (appIdentity: string, target?: RestartDynosTarget, options?: DynoOptions) => Promise<void>;
|
|
44
|
+
run: (appIdentity: string, command: string, options?: RunDynoOptions) => Promise<import("@heroku/types/3.sdk").Dyno>;
|
|
42
45
|
scale: {
|
|
43
46
|
(appIdentity: string, updates: ScaleDynosUpdate, options?: DynoOptions): Promise<Formation>;
|
|
44
47
|
(appIdentity: string, updates: ScaleDynosUpdate[], options?: DynoOptions): Promise<Formation[]>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { extendResource } from '../../../core/extend-resource.js';
|
|
2
|
+
import { runDyno } from './run.js';
|
|
2
3
|
import { waitForInfo } from './wait-for-info.js';
|
|
4
|
+
export { runDyno } from './run.js';
|
|
3
5
|
export { DynoNotReadyError, waitForInfo, } from './wait-for-info.js';
|
|
4
6
|
export async function scaleDynos(ctx, appIdentity, updates, options = {}) {
|
|
5
7
|
options.signal?.throwIfAborted();
|
|
@@ -24,6 +26,7 @@ export async function restartDynos(ctx, appIdentity, target, options = {}) {
|
|
|
24
26
|
}
|
|
25
27
|
export const dynoExtensions = extendResource('platform', 'dyno', ctx => ({
|
|
26
28
|
restart: (appIdentity, target, options) => (restartDynos(ctx, appIdentity, target, options)),
|
|
29
|
+
run: (appIdentity, command, options) => (runDyno(ctx, appIdentity, command, options)),
|
|
27
30
|
scale: ((appIdentity, updates, options) => scaleDynos(ctx, appIdentity, updates, options)),
|
|
28
31
|
waitForInfo: (appIdentity, dynoIdentity, options) => (waitForInfo(ctx, appIdentity, dynoIdentity, options)),
|
|
29
32
|
}));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HerokuApiClientOptions } from '@heroku/heroku-fetch';
|
|
2
|
+
import type { Dyno } from '@heroku/types/3.sdk';
|
|
3
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
4
|
+
export type RunDynoOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* When `true`, the platform returns a dyno with a non-null
|
|
7
|
+
* `attach_url` the caller can use to open its own interactive
|
|
8
|
+
* session (STDIN/STDOUT rendezvous). The SDK itself does not open
|
|
9
|
+
* the connection.
|
|
10
|
+
*/
|
|
11
|
+
attach?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Options forwarded to the raw `HerokuApiClient` created for the
|
|
14
|
+
* exec-inside path (`dyno` is set). The route registry has no entry
|
|
15
|
+
* for `POST /apps/{app}/dynos/{dyno}`, so that call is issued via a
|
|
16
|
+
* bare client. Ignored when `dyno` is omitted — the standard
|
|
17
|
+
* create-one-off path uses the SDK's own routes client.
|
|
18
|
+
*/
|
|
19
|
+
clientOptions?: HerokuApiClientOptions;
|
|
20
|
+
/**
|
|
21
|
+
* If set, exec `command` inside the named dyno via
|
|
22
|
+
* `POST /apps/{app}/dynos/{dyno}` with the `version=3.run-inside`
|
|
23
|
+
* Accept variant. Omit to create a new one-off dyno via
|
|
24
|
+
* `POST /apps/{app}/dynos`.
|
|
25
|
+
*/
|
|
26
|
+
dyno?: string;
|
|
27
|
+
/** Additional environment variables layered onto the dyno. */
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* When `true`, `command` is appended with `; echo "
|
|
31
|
+
* heroku-command-exit-status: $?"` to surface the process exit
|
|
32
|
+
* code over the attach stream. Consumers may parse this line to
|
|
33
|
+
* recover the exit code.
|
|
34
|
+
*
|
|
35
|
+
* Because the wrap uses a bare `;`, `command` must be a single
|
|
36
|
+
* shell statement that terminates cleanly with `;`. Trailing `&`
|
|
37
|
+
* (backgrounding), line continuations (`\`), unclosed quotes, or
|
|
38
|
+
* unterminated heredocs will break the wrap and produce a shell
|
|
39
|
+
* syntax error or garbled output. Callers who need those
|
|
40
|
+
* constructs should wrap them in a subshell or `{ ...; }` group
|
|
41
|
+
* themselves before passing `command`.
|
|
42
|
+
*/
|
|
43
|
+
exitCode?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Serialized to the platform as `force_no_tty`. Forces an attached
|
|
46
|
+
* one-off dyno to not run in a TTY even when the caller's
|
|
47
|
+
* environment looks like one.
|
|
48
|
+
*/
|
|
49
|
+
forceNoTTY?: boolean;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
size?: string;
|
|
52
|
+
type?: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Create a one-off dyno or exec a command inside an existing one.
|
|
56
|
+
*
|
|
57
|
+
* Route selection:
|
|
58
|
+
* - `POST /apps/{app}/dynos` (default) creates a new one-off dyno.
|
|
59
|
+
* Uses the SDK's routes client, which sends the platform
|
|
60
|
+
* service's default `Accept: version=3` header.
|
|
61
|
+
* - `POST /apps/{app}/dynos/{dyno}` (when `options.dyno` is set)
|
|
62
|
+
* execs the command inside the named dyno. Uses a raw
|
|
63
|
+
* `HerokuApiClient` with `Accept: version=3.sdk` because the
|
|
64
|
+
* routes registry has no entry for this endpoint.
|
|
65
|
+
*
|
|
66
|
+
* Retries: the runtime API occasionally returns 409 while a newly
|
|
67
|
+
* pushed app's release is still propagating. This is transient and
|
|
68
|
+
* short-lived, so 409 is retried up to two times back-to-back with no
|
|
69
|
+
* delay. Any other error propagates. Matches the retry semantics of
|
|
70
|
+
* the historical CLI implementation.
|
|
71
|
+
*
|
|
72
|
+
* The returned `Dyno` includes `attach_url` for callers who want to
|
|
73
|
+
* open their own interactive session; the SDK does not establish the
|
|
74
|
+
* session, spawn ssh, or manage TTY state.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runDyno(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, command: string, options?: RunDynoOptions): Promise<Dyno>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { HerokuApiClient, HerokuApiError } from '@heroku/heroku-fetch';
|
|
2
|
+
const DEFAULT_RUN_RETRIES = 2;
|
|
3
|
+
const ACCEPT_RUN_INSIDE = 'application/vnd.heroku+json; version=3.sdk';
|
|
4
|
+
/**
|
|
5
|
+
* Create a one-off dyno or exec a command inside an existing one.
|
|
6
|
+
*
|
|
7
|
+
* Route selection:
|
|
8
|
+
* - `POST /apps/{app}/dynos` (default) creates a new one-off dyno.
|
|
9
|
+
* Uses the SDK's routes client, which sends the platform
|
|
10
|
+
* service's default `Accept: version=3` header.
|
|
11
|
+
* - `POST /apps/{app}/dynos/{dyno}` (when `options.dyno` is set)
|
|
12
|
+
* execs the command inside the named dyno. Uses a raw
|
|
13
|
+
* `HerokuApiClient` with `Accept: version=3.sdk` because the
|
|
14
|
+
* routes registry has no entry for this endpoint.
|
|
15
|
+
*
|
|
16
|
+
* Retries: the runtime API occasionally returns 409 while a newly
|
|
17
|
+
* pushed app's release is still propagating. This is transient and
|
|
18
|
+
* short-lived, so 409 is retried up to two times back-to-back with no
|
|
19
|
+
* delay. Any other error propagates. Matches the retry semantics of
|
|
20
|
+
* the historical CLI implementation.
|
|
21
|
+
*
|
|
22
|
+
* The returned `Dyno` includes `attach_url` for callers who want to
|
|
23
|
+
* open their own interactive session; the SDK does not establish the
|
|
24
|
+
* session, spawn ssh, or manage TTY state.
|
|
25
|
+
*/
|
|
26
|
+
export async function runDyno(ctx, appIdentity, command, options = {}) {
|
|
27
|
+
options.signal?.throwIfAborted();
|
|
28
|
+
const body = {
|
|
29
|
+
command: options.exitCode
|
|
30
|
+
? `${command}; echo " heroku-command-exit-status: $?"`
|
|
31
|
+
: command,
|
|
32
|
+
};
|
|
33
|
+
if (options.attach !== undefined)
|
|
34
|
+
body.attach = options.attach;
|
|
35
|
+
if (options.env !== undefined)
|
|
36
|
+
body.env = options.env;
|
|
37
|
+
if (options.forceNoTTY !== undefined)
|
|
38
|
+
body.force_no_tty = options.forceNoTTY;
|
|
39
|
+
if (options.size !== undefined)
|
|
40
|
+
body.size = options.size;
|
|
41
|
+
if (options.type !== undefined)
|
|
42
|
+
body.type = options.type;
|
|
43
|
+
/* eslint-disable no-await-in-loop -- retry is inherently sequential on 409 */
|
|
44
|
+
let attemptsLeft = DEFAULT_RUN_RETRIES + 1;
|
|
45
|
+
while (true) {
|
|
46
|
+
attemptsLeft--;
|
|
47
|
+
try {
|
|
48
|
+
return await postRun(ctx, appIdentity, body, options);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (attemptsLeft > 0 && error instanceof HerokuApiError && error.statusCode === 409) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/* eslint-enable no-await-in-loop */
|
|
58
|
+
}
|
|
59
|
+
async function postRun(ctx, appIdentity, body, options) {
|
|
60
|
+
if (options.dyno) {
|
|
61
|
+
const apiClient = new HerokuApiClient({
|
|
62
|
+
...options.clientOptions,
|
|
63
|
+
service: 'platform',
|
|
64
|
+
});
|
|
65
|
+
const path = `/apps/${encodeURIComponent(appIdentity)}/dynos/${encodeURIComponent(options.dyno)}`;
|
|
66
|
+
const response = await apiClient.post(path, body, {
|
|
67
|
+
headers: { Accept: ACCEPT_RUN_INSIDE },
|
|
68
|
+
signal: options.signal,
|
|
69
|
+
});
|
|
70
|
+
return (await response.json());
|
|
71
|
+
}
|
|
72
|
+
const platform = options.signal ? ctx.platform.withOptions({ signal: options.signal }) : ctx.platform;
|
|
73
|
+
return platform.dyno.create(appIdentity, body);
|
|
74
|
+
}
|
|
@@ -73,10 +73,20 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
|
|
|
73
73
|
service: 'custom',
|
|
74
74
|
token: '',
|
|
75
75
|
});
|
|
76
|
-
|
|
76
|
+
// Fir's telemetry-proxy sends newline-delimited plain text by
|
|
77
|
+
// default, but concatenates records with zero delimiter bytes —
|
|
78
|
+
// splitting on '\n' yields nothing. Requesting SSE flips the
|
|
79
|
+
// proxy into `text/event-stream`, where each record is framed
|
|
80
|
+
// as `id: N\ndata: <line>\n\n` (Cedar's logplex ignores the
|
|
81
|
+
// header and keeps sending `text/plain`).
|
|
82
|
+
const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`, {
|
|
83
|
+
headers: { Accept: 'text/event-stream' },
|
|
84
|
+
});
|
|
77
85
|
if (!response.body) {
|
|
78
86
|
throw new Error('Logplex stream returned no body.');
|
|
79
87
|
}
|
|
88
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
89
|
+
const isSse = contentType.toLowerCase().includes('text/event-stream');
|
|
80
90
|
const reader = response.body.getReader();
|
|
81
91
|
const decoder = new TextDecoder();
|
|
82
92
|
let pending = '';
|
|
@@ -101,22 +111,46 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
|
|
|
101
111
|
break;
|
|
102
112
|
armTimeout();
|
|
103
113
|
pending += decoder.decode(value, { stream: true });
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
if (isSse) {
|
|
115
|
+
// SSE events are separated by a blank line (`\n\n`). Each
|
|
116
|
+
// event holds `data:` field lines whose values, joined by
|
|
117
|
+
// `\n` per the spec, are the log line to yield.
|
|
118
|
+
let boundary = pending.indexOf('\n\n');
|
|
119
|
+
while (boundary !== -1) {
|
|
120
|
+
const frame = pending.slice(0, boundary);
|
|
121
|
+
pending = pending.slice(boundary + 2);
|
|
122
|
+
// Any complete SSE frame — including heartbeats with no
|
|
123
|
+
// data — is proof the transport is healthy, so reset the
|
|
124
|
+
// retry counter here rather than only on yielded lines.
|
|
109
125
|
consecutiveTransportErrors = 0;
|
|
110
|
-
|
|
126
|
+
const line = extractSseData(frame);
|
|
127
|
+
if (line.length > 0)
|
|
128
|
+
yield line;
|
|
129
|
+
boundary = pending.indexOf('\n\n');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
let newlineIndex = pending.indexOf('\n');
|
|
134
|
+
while (newlineIndex !== -1) {
|
|
135
|
+
const line = pending.slice(0, newlineIndex);
|
|
136
|
+
pending = pending.slice(newlineIndex + 1);
|
|
137
|
+
if (line.length > 0) {
|
|
138
|
+
consecutiveTransportErrors = 0;
|
|
139
|
+
yield line;
|
|
140
|
+
}
|
|
141
|
+
newlineIndex = pending.indexOf('\n');
|
|
111
142
|
}
|
|
112
|
-
newlineIndex = pending.indexOf('\n');
|
|
113
143
|
}
|
|
114
144
|
}
|
|
115
145
|
// Flush any trailing partial line that didn't end in '\n'.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
// Only meaningful for the plain-text path — an SSE stream that
|
|
147
|
+
// ends mid-frame has no complete event to yield.
|
|
148
|
+
if (!isSse) {
|
|
149
|
+
const trailing = pending + decoder.decode();
|
|
150
|
+
if (trailing.length > 0) {
|
|
151
|
+
consecutiveTransportErrors = 0;
|
|
152
|
+
yield trailing;
|
|
153
|
+
}
|
|
120
154
|
}
|
|
121
155
|
}
|
|
122
156
|
finally {
|
|
@@ -162,6 +196,31 @@ export async function* streamLogs(ctx, appIdentity, options = {}) {
|
|
|
162
196
|
}
|
|
163
197
|
/* eslint-enable no-await-in-loop */
|
|
164
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Extract the joined `data:` field values from a single SSE event
|
|
201
|
+
* frame, per the HTML Living Standard's event-stream parsing rules.
|
|
202
|
+
* Lines beginning with `:` are comments and skipped; other field
|
|
203
|
+
* names (`id`, `event`, `retry`) are ignored — we only care about
|
|
204
|
+
* the log payload. Multiple `data:` lines within one event are
|
|
205
|
+
* joined with `\n`.
|
|
206
|
+
*/
|
|
207
|
+
function extractSseData(frame) {
|
|
208
|
+
const dataLines = [];
|
|
209
|
+
for (const rawLine of frame.split('\n')) {
|
|
210
|
+
if (!rawLine || rawLine.startsWith(':'))
|
|
211
|
+
continue;
|
|
212
|
+
const colon = rawLine.indexOf(':');
|
|
213
|
+
const field = colon === -1 ? rawLine : rawLine.slice(0, colon);
|
|
214
|
+
if (field !== 'data')
|
|
215
|
+
continue;
|
|
216
|
+
// Per spec: strip a single leading space from the value.
|
|
217
|
+
let value = colon === -1 ? '' : rawLine.slice(colon + 1);
|
|
218
|
+
if (value.startsWith(' '))
|
|
219
|
+
value = value.slice(1);
|
|
220
|
+
dataLines.push(value);
|
|
221
|
+
}
|
|
222
|
+
return dataLines.join('\n');
|
|
223
|
+
}
|
|
165
224
|
/**
|
|
166
225
|
* Resolves after `ms` milliseconds, or rejects if the signal aborts
|
|
167
226
|
* first. Used between recreate attempts so a persistent transport
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SniEndpoint } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
export type CreateAndAssociateOptions = {
|
|
4
|
+
intervalMs?: number;
|
|
5
|
+
/** Required. Given the wildcard-matched candidate hostnames, returns the subset to associate. */
|
|
6
|
+
resolveDomains: (candidates: string[]) => Promise<string[]>;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Create an SNI endpoint (SSL certificate) on an app and associate it with the
|
|
12
|
+
* app's matching domains.
|
|
13
|
+
*
|
|
14
|
+
* Ports the CLI `certs:add` business logic into the SDK.
|
|
15
|
+
*
|
|
16
|
+
* Steps:
|
|
17
|
+
* 1. Create the SNI endpoint from the given certificate chain and private key.
|
|
18
|
+
* 2. Wait for the app's domains to reach ready status (replaces the old CNAME poll).
|
|
19
|
+
* The SDK waits on domain STATUS (`succeeded`/`none`) via `domain.waitForReady`
|
|
20
|
+
* rather than the CLI's old `cname`-defined readiness check, polling at a default
|
|
21
|
+
* 1000ms interval to mirror the CLI's `waitForDomains` cadence. `timeoutMs`
|
|
22
|
+
* defaults to 30s and is applied per pending domain (not a single global budget),
|
|
23
|
+
* so an app with several pending domains can wait longer than 30s overall.
|
|
24
|
+
* 3. List the (now stable) app domains and wildcard-match them against the
|
|
25
|
+
* certificate's `cert_domains`.
|
|
26
|
+
* 4. If any domains matched, invoke `resolveDomains` — the interactive selection
|
|
27
|
+
* hook the consumer supplies — to choose which matched hostnames to associate.
|
|
28
|
+
* 5. Associate the selected hostnames with the endpoint in parallel via
|
|
29
|
+
* `PATCH /apps/{app}/domains/{hostname}` (`domain.update`).
|
|
30
|
+
*
|
|
31
|
+
* If no domains match the certificate, neither `resolveDomains` nor any
|
|
32
|
+
* association call is made.
|
|
33
|
+
*
|
|
34
|
+
* @throws if the domain readiness wait times out or a domain fails, or if any
|
|
35
|
+
* association call rejects.
|
|
36
|
+
* @returns the created SNI endpoint.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createAndAssociate(ctx: Pick<ResourceCtx, 'platform'>, appIdentity: string, certificateChain: string, privateKey: string, options: CreateAndAssociateOptions): Promise<SniEndpoint>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { waitForReady } from '../domain/wait-for-ready.js';
|
|
2
|
+
const DEFAULT_WAIT_INTERVAL_MS = 1000;
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
/**
|
|
5
|
+
* Create an SNI endpoint (SSL certificate) on an app and associate it with the
|
|
6
|
+
* app's matching domains.
|
|
7
|
+
*
|
|
8
|
+
* Ports the CLI `certs:add` business logic into the SDK.
|
|
9
|
+
*
|
|
10
|
+
* Steps:
|
|
11
|
+
* 1. Create the SNI endpoint from the given certificate chain and private key.
|
|
12
|
+
* 2. Wait for the app's domains to reach ready status (replaces the old CNAME poll).
|
|
13
|
+
* The SDK waits on domain STATUS (`succeeded`/`none`) via `domain.waitForReady`
|
|
14
|
+
* rather than the CLI's old `cname`-defined readiness check, polling at a default
|
|
15
|
+
* 1000ms interval to mirror the CLI's `waitForDomains` cadence. `timeoutMs`
|
|
16
|
+
* defaults to 30s and is applied per pending domain (not a single global budget),
|
|
17
|
+
* so an app with several pending domains can wait longer than 30s overall.
|
|
18
|
+
* 3. List the (now stable) app domains and wildcard-match them against the
|
|
19
|
+
* certificate's `cert_domains`.
|
|
20
|
+
* 4. If any domains matched, invoke `resolveDomains` — the interactive selection
|
|
21
|
+
* hook the consumer supplies — to choose which matched hostnames to associate.
|
|
22
|
+
* 5. Associate the selected hostnames with the endpoint in parallel via
|
|
23
|
+
* `PATCH /apps/{app}/domains/{hostname}` (`domain.update`).
|
|
24
|
+
*
|
|
25
|
+
* If no domains match the certificate, neither `resolveDomains` nor any
|
|
26
|
+
* association call is made.
|
|
27
|
+
*
|
|
28
|
+
* @throws if the domain readiness wait times out or a domain fails, or if any
|
|
29
|
+
* association call rejects.
|
|
30
|
+
* @returns the created SNI endpoint.
|
|
31
|
+
*/
|
|
32
|
+
// Signature mirrors the CLI `certs:add` / plan-locked public API:
|
|
33
|
+
// createAndAssociate(app, certChain, privateKey, {resolveDomains}).
|
|
34
|
+
// eslint-disable-next-line max-params
|
|
35
|
+
export async function createAndAssociate(ctx, appIdentity, certificateChain, privateKey, options) {
|
|
36
|
+
const { intervalMs = DEFAULT_WAIT_INTERVAL_MS, resolveDomains, signal, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
const platform = signal ? ctx.platform.withOptions({ signal }) : ctx.platform;
|
|
39
|
+
const sniEndpoint = await platform.sniEndpoint.create(appIdentity, {
|
|
40
|
+
certificate_chain: certificateChain,
|
|
41
|
+
private_key: privateKey,
|
|
42
|
+
});
|
|
43
|
+
await waitForReady(ctx, appIdentity, { signal, timeoutMs, waitIntervalMs: intervalMs });
|
|
44
|
+
const apiDomains = await platform.domain.list(appIdentity);
|
|
45
|
+
const appDomains = apiDomains.map(d => d.hostname);
|
|
46
|
+
const certDomains = (sniEndpoint.ssl_cert.cert_domains ?? []);
|
|
47
|
+
const matched = matchDomains(certDomains, appDomains);
|
|
48
|
+
if (matched.length > 0) {
|
|
49
|
+
const selected = await resolveDomains(matched);
|
|
50
|
+
await Promise.all(selected.map(hostname => platform.domain.update(appIdentity, hostname, { sni_endpoint: sniEndpoint.name })));
|
|
51
|
+
}
|
|
52
|
+
return sniEndpoint;
|
|
53
|
+
}
|
|
54
|
+
function splitDomains(domains) {
|
|
55
|
+
return domains.map(domain => [domain.slice(0, 1), domain.slice(1)]);
|
|
56
|
+
}
|
|
57
|
+
// Adapted from the CLI `certs/add.ts` matcher, but ADDS `^`/`$` anchors to fix a
|
|
58
|
+
// prefix-match bug: without them a wildcard cert like `*.example.com` would match
|
|
59
|
+
// hostnames that merely share a prefix (e.g. `www.example.com.evil.org`). Wildcard
|
|
60
|
+
// certs must not match such hostnames, so both ends of the pattern are anchored.
|
|
61
|
+
function createMatcherFromSplitDomain([firstChar, rest]) {
|
|
62
|
+
const matcherContents = [];
|
|
63
|
+
if (firstChar === '*') {
|
|
64
|
+
matcherContents.push(String.raw `^[\w\-]+`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
matcherContents.push(`^${firstChar}`);
|
|
68
|
+
}
|
|
69
|
+
const escapedRest = rest.replaceAll('.', String.raw `\.`);
|
|
70
|
+
matcherContents.push(escapedRest);
|
|
71
|
+
return new RegExp(`${matcherContents.join('')}$`);
|
|
72
|
+
}
|
|
73
|
+
function matchDomains(certDomains, appDomains) {
|
|
74
|
+
const splitCertDomains = splitDomains(certDomains);
|
|
75
|
+
const matchers = splitCertDomains.map(splitDomain => createMatcherFromSplitDomain(splitDomain));
|
|
76
|
+
if (splitCertDomains.some(domain => (domain[0] === '*'))) {
|
|
77
|
+
const matchedDomains = [];
|
|
78
|
+
for (const appDomain of appDomains) {
|
|
79
|
+
if (matchers.some(matcher => matcher.test(appDomain))) {
|
|
80
|
+
matchedDomains.push(appDomain);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return matchedDomains;
|
|
84
|
+
}
|
|
85
|
+
return certDomains.filter(domain => appDomains.includes(domain));
|
|
86
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CreateAndAssociateOptions } from './create-and-associate.js';
|
|
2
|
+
export { createAndAssociate, type CreateAndAssociateOptions } from './create-and-associate.js';
|
|
3
|
+
export declare const sniEndpointExtensions: import("../../../core/extend-resource.js").ResourceExtension<"platform", "sniEndpoint", {
|
|
4
|
+
createAndAssociate: (appIdentity: string, certificateChain: string, privateKey: string, options: CreateAndAssociateOptions) => Promise<import("@heroku/types/3.sdk").SniEndpoint>;
|
|
5
|
+
}>;
|