@heroku/sdk 0.1.0-beta.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/LICENSE.txt +207 -0
- package/README.md +94 -0
- package/dist/compositions/add-on.d.ts +7 -0
- package/dist/compositions/add-on.js +6 -0
- package/dist/compositions/app.d.ts +8 -0
- package/dist/compositions/app.js +11 -0
- package/dist/compositions/dyno.d.ts +17 -0
- package/dist/compositions/dyno.js +23 -0
- package/dist/compositions/pg.d.ts +15 -0
- package/dist/compositions/pg.js +56 -0
- package/dist/compositions/pipeline.d.ts +33 -0
- package/dist/compositions/pipeline.js +146 -0
- package/dist/core/create-client.d.ts +32 -0
- package/dist/core/create-client.js +48 -0
- package/dist/core/dispatcher.d.ts +3 -0
- package/dist/core/dispatcher.js +80 -0
- package/dist/core/extend-resource.d.ts +24 -0
- package/dist/core/extend-resource.js +3 -0
- package/dist/core/extensions-proxy.d.ts +2 -0
- package/dist/core/extensions-proxy.js +56 -0
- package/dist/core/heroku-sdk.d.ts +14 -0
- package/dist/core/heroku-sdk.js +54 -0
- package/dist/core/interpolate-path.d.ts +3 -0
- package/dist/core/interpolate-path.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/resources/data/backup.d.ts +9 -0
- package/dist/resources/data/backup.js +10 -0
- package/dist/resources/data/database.d.ts +54 -0
- package/dist/resources/data/database.js +44 -0
- package/dist/resources/data/internal/resolve-addon-id.d.ts +2 -0
- package/dist/resources/data/internal/resolve-addon-id.js +13 -0
- package/dist/resources/data/internal/resolve-pg-database.d.ts +23 -0
- package/dist/resources/data/internal/resolve-pg-database.js +52 -0
- package/dist/resources/data/maintenance.d.ts +9 -0
- package/dist/resources/data/maintenance.js +10 -0
- package/dist/resources/data/postgres-credential.d.ts +11 -0
- package/dist/resources/data/postgres-credential.js +16 -0
- package/dist/resources/data/postgres-database.d.ts +32 -0
- package/dist/resources/data/postgres-database.js +14 -0
- package/dist/resources/data/postgres.d.ts +9 -0
- package/dist/resources/data/postgres.js +10 -0
- package/dist/resources/data/transfer-schedule.d.ts +9 -0
- package/dist/resources/data/transfer-schedule.js +10 -0
- package/dist/resources/data/transfer.d.ts +49 -0
- package/dist/resources/data/transfer.js +14 -0
- package/dist/resources/extensions/data.d.ts +4 -0
- package/dist/resources/extensions/data.js +4 -0
- package/dist/resources/extensions/platform.d.ts +8 -0
- package/dist/resources/extensions/platform.js +8 -0
- package/dist/resources/platform/add-on/create-and-wait.d.ts +28 -0
- package/dist/resources/platform/add-on/create-and-wait.js +63 -0
- package/dist/resources/platform/add-on/debug.d.ts +2 -0
- package/dist/resources/platform/add-on/debug.js +2 -0
- package/dist/resources/platform/add-on/describe.d.ts +20 -0
- package/dist/resources/platform/add-on/describe.js +51 -0
- package/dist/resources/platform/add-on/errors.d.ts +51 -0
- package/dist/resources/platform/add-on/errors.js +61 -0
- package/dist/resources/platform/add-on/index.d.ts +23 -0
- package/dist/resources/platform/add-on/index.js +25 -0
- package/dist/resources/platform/add-on/list-plans.d.ts +34 -0
- package/dist/resources/platform/add-on/list-plans.js +53 -0
- package/dist/resources/platform/add-on/pricing.d.ts +72 -0
- package/dist/resources/platform/add-on/pricing.js +100 -0
- package/dist/resources/platform/add-on/resolve.d.ts +43 -0
- package/dist/resources/platform/add-on/resolve.js +103 -0
- package/dist/resources/platform/add-on/types.d.ts +49 -0
- package/dist/resources/platform/add-on/types.js +1 -0
- package/dist/resources/platform/add-on/upgrade.d.ts +21 -0
- package/dist/resources/platform/add-on/upgrade.js +36 -0
- package/dist/resources/platform/add-on.d.ts +211 -0
- package/dist/resources/platform/add-on.js +354 -0
- package/dist/resources/platform/app/index.d.ts +58 -0
- package/dist/resources/platform/app/index.js +53 -0
- package/dist/resources/platform/app/info.d.ts +30 -0
- package/dist/resources/platform/app/info.js +27 -0
- package/dist/resources/platform/app.d.ts +56 -0
- package/dist/resources/platform/app.js +50 -0
- package/dist/resources/platform/dyno/index.d.ts +45 -0
- package/dist/resources/platform/dyno/index.js +26 -0
- package/dist/resources/platform/dyno.d.ts +45 -0
- package/dist/resources/platform/dyno.js +26 -0
- package/dist/resources/platform/log-session/index.d.ts +102 -0
- package/dist/resources/platform/log-session/index.js +223 -0
- package/dist/resources/platform/log-session/parser.d.ts +74 -0
- package/dist/resources/platform/log-session/parser.js +71 -0
- package/dist/resources/platform/log-session.d.ts +81 -0
- package/dist/resources/platform/log-session.js +167 -0
- package/dist/resources/platform/pipeline/coupling.d.ts +27 -0
- package/dist/resources/platform/pipeline/coupling.js +55 -0
- package/dist/resources/platform/pipeline/index.d.ts +53 -0
- package/dist/resources/platform/pipeline/index.js +75 -0
- package/dist/resources/platform/pipeline/promotion.d.ts +21 -0
- package/dist/resources/platform/pipeline/promotion.js +109 -0
- package/dist/resources/platform/pipeline-coupling.d.ts +27 -0
- package/dist/resources/platform/pipeline-coupling.js +55 -0
- package/dist/resources/platform/pipeline-promotion.d.ts +21 -0
- package/dist/resources/platform/pipeline-promotion.js +128 -0
- package/dist/resources/platform/pipeline.d.ts +53 -0
- package/dist/resources/platform/pipeline.js +75 -0
- package/dist/resources/platform/shield.d.ts +2 -0
- package/dist/resources/platform/shield.js +6 -0
- package/dist/services/data.d.ts +5 -0
- package/dist/services/data.js +5 -0
- package/dist/services/platform.d.ts +5 -0
- package/dist/services/platform.js +5 -0
- package/dist/utils/wait.d.ts +16 -0
- package/dist/utils/wait.js +35 -0
- package/package.json +54 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { HerokuApiClient } from '@heroku/heroku-fetch';
|
|
2
|
+
import createDebug from 'debug';
|
|
3
|
+
import { extendResource } from '../../core/extend-resource.js';
|
|
4
|
+
import { getGeneration } from './app.js';
|
|
5
|
+
const debug = createDebug('heroku:sdk:resources:log-session');
|
|
6
|
+
const DEFAULT_FIR_SESSION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
7
|
+
const FIR_TIMEOUT_ERROR_MESSAGE = 'Fir log stream timeout';
|
|
8
|
+
/**
|
|
9
|
+
* Stream logs for an app, yielding one line at a time.
|
|
10
|
+
*
|
|
11
|
+
* Creates a log session via `logSession.create`, opens the resulting
|
|
12
|
+
* `logplex_url`, and parses the chunked plain-text response into
|
|
13
|
+
* newline-delimited entries. Use `tail: true` to keep the stream
|
|
14
|
+
* open; `recreateSession: true` (the default when tailing) makes the
|
|
15
|
+
* SDK transparently re-open a new session after the platform's idle
|
|
16
|
+
* timeout, so the iterator keeps yielding without the caller having
|
|
17
|
+
* to handle the reconnect.
|
|
18
|
+
*
|
|
19
|
+
* The iterator terminates cleanly when:
|
|
20
|
+
* - the platform closes a non-tailing stream (all lines yielded)
|
|
21
|
+
* - `signal` is aborted (an `AbortError` propagates)
|
|
22
|
+
* - `recreateSession` is false and the platform closes a tail
|
|
23
|
+
*
|
|
24
|
+
* The iterator throws on:
|
|
25
|
+
* - non-2xx response from the logplex URL
|
|
26
|
+
* - errors thrown by `logSession.create` (auth, app-not-found, etc.)
|
|
27
|
+
*/
|
|
28
|
+
export async function* streamLogs(ctx, appIdentity, options = {}) {
|
|
29
|
+
const { dyno, lines, sessionTimeoutMs = DEFAULT_FIR_SESSION_TIMEOUT_MS, signal, source, tail = false, type, } = options;
|
|
30
|
+
signal?.throwIfAborted();
|
|
31
|
+
const generation = await getGeneration(ctx, appIdentity, { signal });
|
|
32
|
+
const isFir = generation === 'fir';
|
|
33
|
+
// Fir doesn't support a bounded session; the platform always streams.
|
|
34
|
+
const effectiveTail = isFir ? true : tail;
|
|
35
|
+
const recreateSession = options.recreateSession ?? effectiveTail;
|
|
36
|
+
const createOpts = buildCreateOpts({
|
|
37
|
+
dyno, isFir, lines, source, tail: effectiveTail, type,
|
|
38
|
+
});
|
|
39
|
+
// The recreate loop is inherently sequential: each session must
|
|
40
|
+
// close before we ask for the next one.
|
|
41
|
+
/* eslint-disable no-await-in-loop */
|
|
42
|
+
let isRecreate = false;
|
|
43
|
+
while (true) {
|
|
44
|
+
debug('streamLogs creating session app=%s generation=%s tail=%s', appIdentity, generation ?? '<unknown>', effectiveTail);
|
|
45
|
+
const session = await ctx.platform.logSession.create(appIdentity, createOpts);
|
|
46
|
+
if (!session.logplex_url) {
|
|
47
|
+
throw new Error('Log session response did not include a logplex_url.');
|
|
48
|
+
}
|
|
49
|
+
await options.onSessionCreated?.({ generation, isRecreate });
|
|
50
|
+
isRecreate = true;
|
|
51
|
+
debug('streamLogs session=%s opening stream', session.id);
|
|
52
|
+
let timedOut = false;
|
|
53
|
+
let timeoutHandle;
|
|
54
|
+
try {
|
|
55
|
+
// Logplex URLs hit a region-specific stream host (e.g.
|
|
56
|
+
// virginia.sessions.logs.heroku.com). The token is embedded in
|
|
57
|
+
// the URL, so we use a custom-service client with no auth and
|
|
58
|
+
// a per-call baseUrl; this also picks up heroku-fetch's
|
|
59
|
+
// env-var proxy support.
|
|
60
|
+
const parsed = new URL(session.logplex_url);
|
|
61
|
+
const logplexClient = new HerokuApiClient({
|
|
62
|
+
baseUrl: parsed.origin,
|
|
63
|
+
service: 'custom',
|
|
64
|
+
token: '',
|
|
65
|
+
});
|
|
66
|
+
const response = await logplexClient.stream(`${parsed.pathname}${parsed.search}`);
|
|
67
|
+
if (!response.body) {
|
|
68
|
+
throw new Error('Logplex stream returned no body.');
|
|
69
|
+
}
|
|
70
|
+
const reader = response.body.getReader();
|
|
71
|
+
const decoder = new TextDecoder();
|
|
72
|
+
let pending = '';
|
|
73
|
+
// Force a recreate after sessionTimeoutMs of silence; ignored
|
|
74
|
+
// when not tailing.
|
|
75
|
+
const armTimeout = () => {
|
|
76
|
+
if (!recreateSession)
|
|
77
|
+
return;
|
|
78
|
+
if (timeoutHandle)
|
|
79
|
+
clearTimeout(timeoutHandle);
|
|
80
|
+
timeoutHandle = setTimeout(() => {
|
|
81
|
+
timedOut = true;
|
|
82
|
+
debug('streamLogs session=%s timed out after %dms', session.id, sessionTimeoutMs);
|
|
83
|
+
reader.cancel().catch(() => { });
|
|
84
|
+
}, sessionTimeoutMs);
|
|
85
|
+
};
|
|
86
|
+
armTimeout();
|
|
87
|
+
try {
|
|
88
|
+
while (true) {
|
|
89
|
+
const { done, value } = await reader.read();
|
|
90
|
+
if (done)
|
|
91
|
+
break;
|
|
92
|
+
armTimeout();
|
|
93
|
+
pending += decoder.decode(value, { stream: true });
|
|
94
|
+
let newlineIndex = pending.indexOf('\n');
|
|
95
|
+
while (newlineIndex !== -1) {
|
|
96
|
+
const line = pending.slice(0, newlineIndex);
|
|
97
|
+
pending = pending.slice(newlineIndex + 1);
|
|
98
|
+
if (line.length > 0)
|
|
99
|
+
yield line;
|
|
100
|
+
newlineIndex = pending.indexOf('\n');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Flush any trailing partial line that didn't end in '\n'.
|
|
104
|
+
const trailing = pending + decoder.decode();
|
|
105
|
+
if (trailing.length > 0)
|
|
106
|
+
yield trailing;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
if (timeoutHandle)
|
|
110
|
+
clearTimeout(timeoutHandle);
|
|
111
|
+
reader.releaseLock();
|
|
112
|
+
}
|
|
113
|
+
if (timedOut) {
|
|
114
|
+
debug('streamLogs session=%s recreating after timeout', session.id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!recreateSession) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
debug('streamLogs session=%s remote closed; recreating', session.id);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (timeoutHandle)
|
|
124
|
+
clearTimeout(timeoutHandle);
|
|
125
|
+
if (signal?.aborted)
|
|
126
|
+
throw error;
|
|
127
|
+
if (timedOut)
|
|
128
|
+
continue;
|
|
129
|
+
// Translate Fir's documented timeout error message into a recreate.
|
|
130
|
+
if (error instanceof Error && error.message === FIR_TIMEOUT_ERROR_MESSAGE && recreateSession) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/* eslint-enable no-await-in-loop */
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Translate the SDK's consumer-facing options into the shape the
|
|
140
|
+
* platform's `log-sessions` endpoint expects per generation:
|
|
141
|
+
* - Cedar's session takes a single `dyno` field that historically
|
|
142
|
+
* accepted either a process type or a specific dyno; we collapse
|
|
143
|
+
* `dyno || type` for the caller. `lines` and `tail` are honored.
|
|
144
|
+
* - Fir's session takes `dyno` and `type` separately and ignores
|
|
145
|
+
* `lines`; the stream always tails.
|
|
146
|
+
*/
|
|
147
|
+
function buildCreateOpts(options) {
|
|
148
|
+
const createOpts = { source: options.source };
|
|
149
|
+
if (options.isFir) {
|
|
150
|
+
if (options.dyno)
|
|
151
|
+
createOpts.dyno = options.dyno;
|
|
152
|
+
if (options.type)
|
|
153
|
+
createOpts.type = options.type;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const cedarDyno = options.dyno ?? options.type;
|
|
157
|
+
if (cedarDyno)
|
|
158
|
+
createOpts.dyno = cedarDyno;
|
|
159
|
+
if (options.lines !== undefined)
|
|
160
|
+
createOpts.lines = options.lines;
|
|
161
|
+
createOpts.tail = options.tail;
|
|
162
|
+
}
|
|
163
|
+
return createOpts;
|
|
164
|
+
}
|
|
165
|
+
export const logSessionExtensions = extendResource('platform', 'logSession', ctx => ({
|
|
166
|
+
streamLogs: (appIdentity, options) => streamLogs(ctx, appIdentity, options),
|
|
167
|
+
}));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PipelineCoupling, TeamApp } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
export type PipelineWarning = {
|
|
4
|
+
limit: number;
|
|
5
|
+
pipelineId: string;
|
|
6
|
+
type: 'apps_truncated';
|
|
7
|
+
};
|
|
8
|
+
export type ListPipelineAppsOptions = {
|
|
9
|
+
onWarning?: (warning: PipelineWarning) => void;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
};
|
|
12
|
+
export type AppWithPipelineCoupling = TeamApp & {
|
|
13
|
+
pipelineCoupling: PipelineCoupling;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* List the apps coupled to a pipeline, each decorated with its
|
|
17
|
+
* `PipelineCoupling`.
|
|
18
|
+
*
|
|
19
|
+
* Couplings missing an app id are dropped before issuing the bulk
|
|
20
|
+
* filter call. If the pipeline has more than 1000 couplings, an
|
|
21
|
+
* `onWarning` handler must be supplied to opt into a truncated
|
|
22
|
+
* result; otherwise this throws.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listPipelineApps(ctx: Pick<ResourceCtx, 'platform'>, pipelineId: string, options?: ListPipelineAppsOptions): Promise<AppWithPipelineCoupling[]>;
|
|
25
|
+
export declare const pipelineCouplingExtensions: import("../../../index.js").ResourceExtension<"platform", "pipelineCoupling", {
|
|
26
|
+
listApps: (pipelineId: string, options?: ListPipelineAppsOptions) => Promise<AppWithPipelineCoupling[]>;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import createDebug from 'debug';
|
|
2
|
+
import { extendResource } from '../../../core/extend-resource.js';
|
|
3
|
+
const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
|
|
4
|
+
const APPS_FILTER_LIMIT = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* List the apps coupled to a pipeline, each decorated with its
|
|
7
|
+
* `PipelineCoupling`.
|
|
8
|
+
*
|
|
9
|
+
* Couplings missing an app id are dropped before issuing the bulk
|
|
10
|
+
* filter call. If the pipeline has more than 1000 couplings, an
|
|
11
|
+
* `onWarning` handler must be supplied to opt into a truncated
|
|
12
|
+
* result; otherwise this throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function listPipelineApps(ctx, pipelineId, options = {}) {
|
|
15
|
+
options.signal?.throwIfAborted();
|
|
16
|
+
const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
|
|
17
|
+
// Drop malformed couplings (no app id) before issuing the bulk filter call.
|
|
18
|
+
const couplings = allCouplings.filter(coupling => coupling.app?.id);
|
|
19
|
+
const skipped = allCouplings.length - couplings.length;
|
|
20
|
+
if (skipped > 0) {
|
|
21
|
+
debug('listPipelineApps pipeline=%s skipped=%d couplings without app.id', pipelineId, skipped);
|
|
22
|
+
}
|
|
23
|
+
if (couplings.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
let couplingsToResolve = couplings;
|
|
27
|
+
if (couplings.length > APPS_FILTER_LIMIT) {
|
|
28
|
+
if (!options.onWarning) {
|
|
29
|
+
throw new Error(`Pipeline ${pipelineId} has more than ${APPS_FILTER_LIMIT} apps. `
|
|
30
|
+
+ 'Pass an onWarning handler to opt into a truncated result.');
|
|
31
|
+
}
|
|
32
|
+
debug('listPipelineApps pipeline=%s truncating couplings=%d limit=%d', pipelineId, couplings.length, APPS_FILTER_LIMIT);
|
|
33
|
+
options.onWarning({ limit: APPS_FILTER_LIMIT, pipelineId, type: 'apps_truncated' });
|
|
34
|
+
couplingsToResolve = couplings.slice(0, APPS_FILTER_LIMIT);
|
|
35
|
+
}
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
const ids = couplingsToResolve.map(coupling => coupling.app.id);
|
|
38
|
+
const apps = await ctx.platform
|
|
39
|
+
.withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
|
|
40
|
+
.filterApps.apps({ in: { id: ids } });
|
|
41
|
+
if (apps.length !== ids.length) {
|
|
42
|
+
debug('listPipelineApps pipeline=%s requested=%d returned=%d', pipelineId, ids.length, apps.length);
|
|
43
|
+
}
|
|
44
|
+
const couplingByAppId = new Map();
|
|
45
|
+
for (const coupling of couplingsToResolve) {
|
|
46
|
+
couplingByAppId.set(coupling.app.id, coupling);
|
|
47
|
+
}
|
|
48
|
+
return apps.map(app => ({
|
|
49
|
+
...app,
|
|
50
|
+
pipelineCoupling: couplingByAppId.get(app.id),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
export const pipelineCouplingExtensions = extendResource('platform', 'pipelineCoupling', ctx => ({
|
|
54
|
+
listApps: (pipelineId, options) => listPipelineApps(ctx, pipelineId, options),
|
|
55
|
+
}));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Pipeline } from '@heroku/types/3.sdk';
|
|
2
|
+
import { type HerokuApiClientOptions } from '@heroku/heroku-fetch';
|
|
3
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
4
|
+
export type ResolvePipelineOptions = {
|
|
5
|
+
clientOptions?: HerokuApiClientOptions;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
};
|
|
8
|
+
export declare class PipelineNotFoundError extends Error {
|
|
9
|
+
readonly identity: string;
|
|
10
|
+
readonly id = "not_found";
|
|
11
|
+
readonly statusCode = 404;
|
|
12
|
+
constructor(identity: string);
|
|
13
|
+
get body(): {
|
|
14
|
+
id: string;
|
|
15
|
+
message: string;
|
|
16
|
+
resource: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export declare class PipelineAmbiguousError extends Error {
|
|
20
|
+
readonly identity: string;
|
|
21
|
+
readonly matches: Pipeline[];
|
|
22
|
+
readonly id = "multiple_matches";
|
|
23
|
+
readonly statusCode = 422;
|
|
24
|
+
constructor(identity: string, matches: Pipeline[]);
|
|
25
|
+
get body(): {
|
|
26
|
+
id: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a Pipeline by identity.
|
|
32
|
+
*
|
|
33
|
+
* The identity may be:
|
|
34
|
+
* - a UUID — fetched directly via `pipeline.info`.
|
|
35
|
+
* - a name — looked up via `GET /pipelines?eq[name]=<name>`. Pipeline
|
|
36
|
+
* names are not unique platform-wide, so a name lookup may return
|
|
37
|
+
* zero, one, or many matches.
|
|
38
|
+
*
|
|
39
|
+
* The name path uses a raw `HerokuApiClient` because the route
|
|
40
|
+
* registry's `pipeline.list` doesn't accept query params, and we don't
|
|
41
|
+
* want to bake those params into the routes-generated client (where
|
|
42
|
+
* they would leak across unrelated calls).
|
|
43
|
+
*
|
|
44
|
+
* Errors:
|
|
45
|
+
* - throws `PipelineNotFoundError` when no pipeline matches the name.
|
|
46
|
+
* - throws `PipelineAmbiguousError` when more than one does. Callers
|
|
47
|
+
* that want to prompt the user should catch this and inspect
|
|
48
|
+
* `error.matches`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolvePipeline(ctx: Pick<ResourceCtx, 'platform'>, identity: string, options?: ResolvePipelineOptions): Promise<Pipeline>;
|
|
51
|
+
export declare const pipelineExtensions: import("../../../index.js").ResourceExtension<"platform", "pipeline", {
|
|
52
|
+
resolve: (identity: string, options?: ResolvePipelineOptions) => Promise<Pipeline>;
|
|
53
|
+
}>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { HerokuApiClient } from '@heroku/heroku-fetch';
|
|
2
|
+
import { extendResource } from '../../../core/extend-resource.js';
|
|
3
|
+
export class PipelineNotFoundError extends Error {
|
|
4
|
+
identity;
|
|
5
|
+
id = 'not_found';
|
|
6
|
+
statusCode = 404;
|
|
7
|
+
constructor(identity) {
|
|
8
|
+
super(`Couldn't find a pipeline matching '${identity}'.`);
|
|
9
|
+
this.identity = identity;
|
|
10
|
+
this.name = 'PipelineNotFoundError';
|
|
11
|
+
}
|
|
12
|
+
get body() {
|
|
13
|
+
return { id: this.id, message: this.message, resource: 'pipeline' };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class PipelineAmbiguousError extends Error {
|
|
17
|
+
identity;
|
|
18
|
+
matches;
|
|
19
|
+
id = 'multiple_matches';
|
|
20
|
+
statusCode = 422;
|
|
21
|
+
constructor(identity, matches) {
|
|
22
|
+
super(`Ambiguous identifier; multiple pipelines named '${identity}' found (${matches.length} matches).`);
|
|
23
|
+
this.identity = identity;
|
|
24
|
+
this.matches = matches;
|
|
25
|
+
this.name = 'PipelineAmbiguousError';
|
|
26
|
+
}
|
|
27
|
+
get body() {
|
|
28
|
+
return { id: this.id, message: this.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const UUID_RE = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a Pipeline by identity.
|
|
34
|
+
*
|
|
35
|
+
* The identity may be:
|
|
36
|
+
* - a UUID — fetched directly via `pipeline.info`.
|
|
37
|
+
* - a name — looked up via `GET /pipelines?eq[name]=<name>`. Pipeline
|
|
38
|
+
* names are not unique platform-wide, so a name lookup may return
|
|
39
|
+
* zero, one, or many matches.
|
|
40
|
+
*
|
|
41
|
+
* The name path uses a raw `HerokuApiClient` because the route
|
|
42
|
+
* registry's `pipeline.list` doesn't accept query params, and we don't
|
|
43
|
+
* want to bake those params into the routes-generated client (where
|
|
44
|
+
* they would leak across unrelated calls).
|
|
45
|
+
*
|
|
46
|
+
* Errors:
|
|
47
|
+
* - throws `PipelineNotFoundError` when no pipeline matches the name.
|
|
48
|
+
* - throws `PipelineAmbiguousError` when more than one does. Callers
|
|
49
|
+
* that want to prompt the user should catch this and inspect
|
|
50
|
+
* `error.matches`.
|
|
51
|
+
*/
|
|
52
|
+
export async function resolvePipeline(ctx, identity, options = {}) {
|
|
53
|
+
options.signal?.throwIfAborted();
|
|
54
|
+
if (UUID_RE.test(identity)) {
|
|
55
|
+
return ctx.platform.pipeline.info(identity);
|
|
56
|
+
}
|
|
57
|
+
const apiClient = new HerokuApiClient({
|
|
58
|
+
...options.clientOptions,
|
|
59
|
+
service: 'platform',
|
|
60
|
+
});
|
|
61
|
+
const response = await apiClient.get('/pipelines', {
|
|
62
|
+
searchParams: { 'eq[name]': identity },
|
|
63
|
+
});
|
|
64
|
+
const matches = (await response.json());
|
|
65
|
+
if (matches.length === 0) {
|
|
66
|
+
throw new PipelineNotFoundError(identity);
|
|
67
|
+
}
|
|
68
|
+
if (matches.length > 1) {
|
|
69
|
+
throw new PipelineAmbiguousError(identity, matches);
|
|
70
|
+
}
|
|
71
|
+
return matches[0];
|
|
72
|
+
}
|
|
73
|
+
export const pipelineExtensions = extendResource('platform', 'pipeline', ctx => ({
|
|
74
|
+
resolve: (identity, options) => resolvePipeline(ctx, identity, options),
|
|
75
|
+
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PipelinePromotion, PipelinePromotionCreateOpts, PipelinePromotionTarget } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../../core/extend-resource.js';
|
|
3
|
+
export type ReleaseStreamContext = {
|
|
4
|
+
stream: ReadableStream<Uint8Array>;
|
|
5
|
+
target: PipelinePromotionTarget;
|
|
6
|
+
};
|
|
7
|
+
export type PromotePipelineOptions = {
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
onReleaseStream?: (context: ReleaseStreamContext) => Promise<void> | void;
|
|
10
|
+
releaseStreamMaxAttempts?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
export type PromotePipelineResult = {
|
|
15
|
+
promotion: PipelinePromotion;
|
|
16
|
+
targets: PipelinePromotionTarget[];
|
|
17
|
+
};
|
|
18
|
+
export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
|
|
19
|
+
export declare const pipelinePromotionExtensions: import("../../../index.js").ResourceExtension<"platform", "pipelinePromotion", {
|
|
20
|
+
promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { HerokuApiClient } from '@heroku/heroku-fetch';
|
|
2
|
+
import createDebug from 'debug';
|
|
3
|
+
import { extendResource } from '../../../core/extend-resource.js';
|
|
4
|
+
import { wait } from '../../../utils/wait.js';
|
|
5
|
+
const debug = createDebug('heroku:sdk:resources:pipeline-promotion');
|
|
6
|
+
const DEFAULT_INTERVAL_MS = 1000;
|
|
7
|
+
const DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS = 100;
|
|
8
|
+
export async function promotePipeline(ctx, body, options = {}) {
|
|
9
|
+
const { intervalMs = DEFAULT_INTERVAL_MS, onReleaseStream, releaseStreamMaxAttempts = DEFAULT_RELEASE_STREAM_MAX_ATTEMPTS, signal, timeoutMs, } = options;
|
|
10
|
+
debug('promote source=%s targets=%d', body.source?.app?.id ?? '<unknown>', body.targets?.length ?? 0);
|
|
11
|
+
const promotion = await ctx.platform.pipelinePromotion.create(body);
|
|
12
|
+
if (!promotion.id) {
|
|
13
|
+
throw new Error('Pipeline promotion response did not include an id');
|
|
14
|
+
}
|
|
15
|
+
debug('promote created promotion=%s intervalMs=%d timeoutMs=%s', promotion.id, intervalMs, timeoutMs ?? '<none>');
|
|
16
|
+
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
|
17
|
+
const streamRunner = onReleaseStream
|
|
18
|
+
? createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal)
|
|
19
|
+
: undefined;
|
|
20
|
+
let poll = 0;
|
|
21
|
+
while (true) {
|
|
22
|
+
signal?.throwIfAborted();
|
|
23
|
+
poll++;
|
|
24
|
+
// eslint-disable-next-line no-await-in-loop
|
|
25
|
+
const targets = await ctx.platform.pipelinePromotionTarget.list(promotion.id);
|
|
26
|
+
debug('promote poll=%d promotion=%s statuses=%o', poll, promotion.id, targets.map(target => target.status));
|
|
27
|
+
if (targets.every(target => target.status !== 'pending')) {
|
|
28
|
+
debug('promote terminal promotion=%s polls=%d', promotion.id, poll);
|
|
29
|
+
return { promotion, targets };
|
|
30
|
+
}
|
|
31
|
+
// eslint-disable-next-line no-await-in-loop
|
|
32
|
+
await streamRunner?.(targets);
|
|
33
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
34
|
+
throw new Error(`Pipeline promotion ${promotion.id} did not reach a terminal state within ${timeoutMs}ms`);
|
|
35
|
+
}
|
|
36
|
+
// eslint-disable-next-line no-await-in-loop
|
|
37
|
+
await wait(intervalMs, signal);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns a function that, on first call where `targets` is a single
|
|
42
|
+
* promotion target with `release.id` and `app.id`, fetches the release's
|
|
43
|
+
* output stream and hands it to `onReleaseStream`. Subsequent calls are
|
|
44
|
+
* no-ops; the stream is one-shot per promotion.
|
|
45
|
+
*/
|
|
46
|
+
function createReleaseStreamRunner(ctx, onReleaseStream, releaseStreamMaxAttempts, intervalMs, signal) {
|
|
47
|
+
let handled = false;
|
|
48
|
+
return async (targets) => {
|
|
49
|
+
if (handled || targets.length !== 1)
|
|
50
|
+
return;
|
|
51
|
+
const target = targets[0];
|
|
52
|
+
if (!target.release?.id || !target.app?.id)
|
|
53
|
+
return;
|
|
54
|
+
handled = true;
|
|
55
|
+
debug('promote release-stream lookup app=%s release=%s', target.app.id, target.release.id);
|
|
56
|
+
const release = await ctx.platform.release.info(target.app.id, target.release.id);
|
|
57
|
+
if (!release.output_stream_url) {
|
|
58
|
+
debug('promote release-stream skipped reason=no-output-stream-url');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
debug('promote release-stream fetching url=%s maxAttempts=%d', release.output_stream_url, releaseStreamMaxAttempts);
|
|
62
|
+
const stream = await fetchReleaseOutput(release.output_stream_url, releaseStreamMaxAttempts, intervalMs, signal);
|
|
63
|
+
await onReleaseStream({ stream, target });
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export const pipelinePromotionExtensions = extendResource('platform', 'pipelinePromotion', ctx => ({
|
|
67
|
+
promote: (body, options) => promotePipeline(ctx, body, options),
|
|
68
|
+
}));
|
|
69
|
+
async function fetchReleaseOutput(url, maxAttempts, intervalMs, signal) {
|
|
70
|
+
const parsed = new URL(url);
|
|
71
|
+
const path = `${parsed.pathname}${parsed.search}`;
|
|
72
|
+
// The release output URL points at a third-party stream host (e.g. busl).
|
|
73
|
+
// Use a custom-service client so the platform prefixUrl and bearer token
|
|
74
|
+
// don't leak across origins.
|
|
75
|
+
const buslClient = new HerokuApiClient({
|
|
76
|
+
baseUrl: parsed.origin,
|
|
77
|
+
service: 'custom',
|
|
78
|
+
token: '',
|
|
79
|
+
});
|
|
80
|
+
let attempt = 0;
|
|
81
|
+
while (true) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
attempt++;
|
|
84
|
+
let response;
|
|
85
|
+
try {
|
|
86
|
+
// eslint-disable-next-line no-await-in-loop
|
|
87
|
+
response = await buslClient.stream(path);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Treat HTTP errors thrown by the api-client as a retryable miss.
|
|
91
|
+
response = undefined;
|
|
92
|
+
}
|
|
93
|
+
if (response?.ok && response.body) {
|
|
94
|
+
debug('release-stream attempt=%d status=%d ok stream=true', attempt, response.status);
|
|
95
|
+
return response.body;
|
|
96
|
+
}
|
|
97
|
+
debug('release-stream attempt=%d status=%s ok=false', attempt, response?.status ?? '<error>');
|
|
98
|
+
if (response?.body) {
|
|
99
|
+
// Drain so the connection can be reused.
|
|
100
|
+
// eslint-disable-next-line no-await-in-loop
|
|
101
|
+
await response.body.cancel().catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
if (attempt >= maxAttempts) {
|
|
104
|
+
throw new Error('stream release output not available');
|
|
105
|
+
}
|
|
106
|
+
// eslint-disable-next-line no-await-in-loop
|
|
107
|
+
await wait(intervalMs, signal);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PipelineCoupling, TeamApp } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../core/extend-resource.js';
|
|
3
|
+
export type PipelineWarning = {
|
|
4
|
+
limit: number;
|
|
5
|
+
pipelineId: string;
|
|
6
|
+
type: 'apps_truncated';
|
|
7
|
+
};
|
|
8
|
+
export type ListPipelineAppsOptions = {
|
|
9
|
+
onWarning?: (warning: PipelineWarning) => void;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
};
|
|
12
|
+
export type AppWithPipelineCoupling = TeamApp & {
|
|
13
|
+
pipelineCoupling: PipelineCoupling;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* List the apps coupled to a pipeline, each decorated with its
|
|
17
|
+
* `PipelineCoupling`.
|
|
18
|
+
*
|
|
19
|
+
* Couplings missing an app id are dropped before issuing the bulk
|
|
20
|
+
* filter call. If the pipeline has more than 1000 couplings, an
|
|
21
|
+
* `onWarning` handler must be supplied to opt into a truncated
|
|
22
|
+
* result; otherwise this throws.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listPipelineApps(ctx: Pick<ResourceCtx, 'platform'>, pipelineId: string, options?: ListPipelineAppsOptions): Promise<AppWithPipelineCoupling[]>;
|
|
25
|
+
export declare const pipelineCouplingExtensions: import("../../index.js").ResourceExtension<"platform", "pipelineCoupling", {
|
|
26
|
+
listApps: (pipelineId: string, options?: ListPipelineAppsOptions) => Promise<AppWithPipelineCoupling[]>;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import createDebug from 'debug';
|
|
2
|
+
import { extendResource } from '../../core/extend-resource.js';
|
|
3
|
+
const debug = createDebug('heroku:sdk:resources:pipeline-coupling');
|
|
4
|
+
const APPS_FILTER_LIMIT = 1000;
|
|
5
|
+
/**
|
|
6
|
+
* List the apps coupled to a pipeline, each decorated with its
|
|
7
|
+
* `PipelineCoupling`.
|
|
8
|
+
*
|
|
9
|
+
* Couplings missing an app id are dropped before issuing the bulk
|
|
10
|
+
* filter call. If the pipeline has more than 1000 couplings, an
|
|
11
|
+
* `onWarning` handler must be supplied to opt into a truncated
|
|
12
|
+
* result; otherwise this throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function listPipelineApps(ctx, pipelineId, options = {}) {
|
|
15
|
+
options.signal?.throwIfAborted();
|
|
16
|
+
const allCouplings = await ctx.platform.pipelineCoupling.listByPipeline(pipelineId);
|
|
17
|
+
// Drop malformed couplings (no app id) before issuing the bulk filter call.
|
|
18
|
+
const couplings = allCouplings.filter(coupling => coupling.app?.id);
|
|
19
|
+
const skipped = allCouplings.length - couplings.length;
|
|
20
|
+
if (skipped > 0) {
|
|
21
|
+
debug('listPipelineApps pipeline=%s skipped=%d couplings without app.id', pipelineId, skipped);
|
|
22
|
+
}
|
|
23
|
+
if (couplings.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
let couplingsToResolve = couplings;
|
|
27
|
+
if (couplings.length > APPS_FILTER_LIMIT) {
|
|
28
|
+
if (!options.onWarning) {
|
|
29
|
+
throw new Error(`Pipeline ${pipelineId} has more than ${APPS_FILTER_LIMIT} apps. `
|
|
30
|
+
+ 'Pass an onWarning handler to opt into a truncated result.');
|
|
31
|
+
}
|
|
32
|
+
debug('listPipelineApps pipeline=%s truncating couplings=%d limit=%d', pipelineId, couplings.length, APPS_FILTER_LIMIT);
|
|
33
|
+
options.onWarning({ limit: APPS_FILTER_LIMIT, pipelineId, type: 'apps_truncated' });
|
|
34
|
+
couplingsToResolve = couplings.slice(0, APPS_FILTER_LIMIT);
|
|
35
|
+
}
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
const ids = couplingsToResolve.map(coupling => coupling.app.id);
|
|
38
|
+
const apps = await ctx.platform
|
|
39
|
+
.withHeaders({ Range: `id ..; max=${APPS_FILTER_LIMIT};` })
|
|
40
|
+
.filterApps.apps({ in: { id: ids } });
|
|
41
|
+
if (apps.length !== ids.length) {
|
|
42
|
+
debug('listPipelineApps pipeline=%s requested=%d returned=%d', pipelineId, ids.length, apps.length);
|
|
43
|
+
}
|
|
44
|
+
const couplingByAppId = new Map();
|
|
45
|
+
for (const coupling of couplingsToResolve) {
|
|
46
|
+
couplingByAppId.set(coupling.app.id, coupling);
|
|
47
|
+
}
|
|
48
|
+
return apps.map(app => ({
|
|
49
|
+
...app,
|
|
50
|
+
pipelineCoupling: couplingByAppId.get(app.id),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
export const pipelineCouplingExtensions = extendResource('platform', 'pipelineCoupling', ctx => ({
|
|
54
|
+
listApps: (pipelineId, options) => listPipelineApps(ctx, pipelineId, options),
|
|
55
|
+
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PipelinePromotion, PipelinePromotionCreateOpts, PipelinePromotionTarget } from '@heroku/types/3.sdk';
|
|
2
|
+
import type { ResourceCtx } from '../../core/extend-resource.js';
|
|
3
|
+
export type ReleaseStreamContext = {
|
|
4
|
+
stream: ReadableStream<Uint8Array>;
|
|
5
|
+
target: PipelinePromotionTarget;
|
|
6
|
+
};
|
|
7
|
+
export type PromotePipelineOptions = {
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
onReleaseStream?: (context: ReleaseStreamContext) => Promise<void> | void;
|
|
10
|
+
releaseStreamMaxAttempts?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
export type PromotePipelineResult = {
|
|
15
|
+
promotion: PipelinePromotion;
|
|
16
|
+
targets: PipelinePromotionTarget[];
|
|
17
|
+
};
|
|
18
|
+
export declare function promotePipeline(ctx: Pick<ResourceCtx, 'platform'>, body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions): Promise<PromotePipelineResult>;
|
|
19
|
+
export declare const pipelinePromotionExtensions: import("../../index.js").ResourceExtension<"platform", "pipelinePromotion", {
|
|
20
|
+
promote: (body: PipelinePromotionCreateOpts, options?: PromotePipelineOptions) => Promise<PromotePipelineResult>;
|
|
21
|
+
}>;
|