@samyx/preview-stacks-client 0.25.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/index.d.ts +56 -2
- package/dist/index.js +14 -0
- package/dist/types.d.ts +122 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,62 @@ for (const c of rt.containers.filter((c) => c.restartCount > 2)) {
|
|
|
69
69
|
const logs = await pstack.deployments.logs('pr-7', { service: 'web', tail: 500, timestamps: true, vars: { PR: 7 } });
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
### Sleep and wake
|
|
73
|
+
|
|
74
|
+
A stack that is only looked at now and then can be put to sleep — its compose project goes down, its
|
|
75
|
+
volumes and axes stay — and comes back on the next request to any of its hostnames, or on `wake`.
|
|
76
|
+
(The spec can do this on a timer with a `sleep:` block; see the pstack docs.)
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const job = await pstack.deployments.sleep('pr-7', { PR: 7 });
|
|
80
|
+
await pstack.waitForJob(job.id);
|
|
81
|
+
const d = await pstack.deployments.get('pr-7', { PR: 7 });
|
|
82
|
+
d.asleep; // { since, reason, hosts, rules } while asleep, null otherwise
|
|
83
|
+
await pstack.deployments.wake('pr-7', { PR: 7 }); // the same as `up`, recorded as a wake
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Share a read-only link
|
|
87
|
+
|
|
88
|
+
Hand someone the logs of one deployment without creating an account for them. The link carries a
|
|
89
|
+
signed token that reaches exactly the views you name, on that deployment, until it expires (7 days
|
|
90
|
+
by default, 30 at most). The token is returned once and stored nowhere; rotating the server's
|
|
91
|
+
`PSTACK_TOKEN` is the only revocation.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const link = await pstack.deployments.share('pr-7', { views: ['logs'], ttl: '24h' });
|
|
95
|
+
console.log(link.url); // https://control.preview.example.com/deployments/pr-7/public-logs-view?token=…
|
|
96
|
+
|
|
97
|
+
// The token also works as this client's bearer, for the reads the link allows:
|
|
98
|
+
const viewer = createClient({ baseUrl, token: link.token });
|
|
99
|
+
await viewer.deployments.logs('pr-7'); // ok
|
|
100
|
+
await viewer.deployments.up('pr-7'); // PstackError 403
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### The swarm
|
|
104
|
+
|
|
105
|
+
On a host that runs previews as swarm stacks, the nodes and the material a new worker needs:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const swarm = await pstack.swarm.info(); // { reachable, active, managerAddr, nodes, ports, … }
|
|
109
|
+
const script = await pstack.swarm.join({ format: 'script' }); // a SECRET: treat like a password
|
|
110
|
+
// formats: 'token' | 'command' | 'script' | 'cloud-config' (+ distro for cloud-config)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Single sign-on
|
|
114
|
+
|
|
115
|
+
The sign-in itself is two browser redirects (`/api/auth/sso/start` → the provider →
|
|
116
|
+
`/api/auth/sso/callback`); what an SDK can do is configure it.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const { callbackUrl, presets } = await pstack.sso.config(); // register callbackUrl with your provider
|
|
120
|
+
await pstack.sso.save({ mode: 'oidc', issuer: 'https://accounts.google.com',
|
|
121
|
+
clientId: '…', clientSecret: '…', label: 'Google' });
|
|
122
|
+
// The issuer is fetched while saving, so a typo throws here rather than failing someone's login.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`config()` returns the client secret as a mask — there is no route that returns the real one, and
|
|
126
|
+
submitting the mask back keeps what is stored.
|
|
127
|
+
|
|
72
128
|
### Verify a webhook
|
|
73
129
|
|
|
74
130
|
The half that lives in your receiver. It prevents the two mistakes that make a correct signature look
|
package/dist/index.d.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* polling a job without a terminal-state list, or treating `readiness.state === 'watching'` as an
|
|
26
26
|
* error rather than "ask again".
|
|
27
27
|
*/
|
|
28
|
-
import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, SpecMeta } from './types.ts';
|
|
28
|
+
import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, ShareLink, ShareView, SpecMeta, SsoConfig, SsoConfigResponse, SwarmInfo } from './types.ts';
|
|
29
29
|
export * from './types.ts';
|
|
30
30
|
/** A non-2xx answer. `body` is the parsed JSON, which is where the server's own message lives. */
|
|
31
31
|
export declare class PstackError extends Error {
|
|
@@ -38,7 +38,9 @@ export type ClientOptions = {
|
|
|
38
38
|
/** e.g. `https://api.preview.example.com`. A trailing slash is fine. */
|
|
39
39
|
baseUrl: string;
|
|
40
40
|
/**
|
|
41
|
-
* `PSTACK_TOKEN` (the machine credential CI holds)
|
|
41
|
+
* `PSTACK_TOKEN` (the machine credential CI holds), a personal token (`pstack_pat_…`), or the
|
|
42
|
+
* token of a share link (`deployments.share`) — which then reaches exactly the reads that link
|
|
43
|
+
* allows, on that one deployment.
|
|
42
44
|
*
|
|
43
45
|
* Optional only because a server with no token configured is loopback-only dev mode. Anywhere
|
|
44
46
|
* else, every route needs it.
|
|
@@ -91,6 +93,22 @@ export declare function createClient(opts: ClientOptions): {
|
|
|
91
93
|
verify?: boolean;
|
|
92
94
|
force?: boolean;
|
|
93
95
|
}, vars?: Vars) => Promise<Job>;
|
|
96
|
+
/**
|
|
97
|
+
* Take the compose project down and KEEP its volumes and axes. A request to any of its
|
|
98
|
+
* hostnames brings it back (wake-on-call), as does `wake`. Refused (409) for `kind: shared`.
|
|
99
|
+
*/
|
|
100
|
+
sleep: (id: string, vars?: Vars) => Promise<Job>;
|
|
101
|
+
/** `up`, recorded as a wake. */
|
|
102
|
+
wake: (id: string, vars?: Vars) => Promise<Job>;
|
|
103
|
+
/**
|
|
104
|
+
* Mint a read-only link to this deployment: `views` default to both `details` and `logs`,
|
|
105
|
+
* `ttl` to 7 days (30 at most). The token is returned once and stored nowhere; rotating
|
|
106
|
+
* PSTACK_TOKEN is the only revocation.
|
|
107
|
+
*/
|
|
108
|
+
share: (id: string, body?: {
|
|
109
|
+
views?: ShareView[];
|
|
110
|
+
ttl?: string;
|
|
111
|
+
}) => Promise<ShareLink>;
|
|
94
112
|
runtime: (id: string, vars?: Vars) => Promise<Runtime>;
|
|
95
113
|
/**
|
|
96
114
|
* Is it SERVING yet — separate from "did the deploy job succeed", which only means the
|
|
@@ -137,6 +155,42 @@ export declare function createClient(opts: ClientOptions): {
|
|
|
137
155
|
note: string;
|
|
138
156
|
}>;
|
|
139
157
|
};
|
|
158
|
+
/** The swarm this daemon manages. `reachable: false` means docker did not answer — nothing else is known. */
|
|
159
|
+
swarm: {
|
|
160
|
+
info: () => Promise<SwarmInfo>;
|
|
161
|
+
/**
|
|
162
|
+
* What a new worker runs. A SECRET — whoever holds the token can add a node that runs any
|
|
163
|
+
* task. `cloud-config` renders a user-data file; `distro` picks its Docker install steps.
|
|
164
|
+
*/
|
|
165
|
+
join: (o?: {
|
|
166
|
+
format: "token" | "command" | "script" | "cloud-config";
|
|
167
|
+
distro?: string;
|
|
168
|
+
}) => Promise<string>;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* The identity provider people sign in with. The sign-in flow itself is two browser redirects
|
|
172
|
+
* (`/api/auth/sso/start` → the provider → `/api/auth/sso/callback`) and has nothing for an SDK
|
|
173
|
+
* to call — this is the configuration around it.
|
|
174
|
+
*/
|
|
175
|
+
sso: {
|
|
176
|
+
/** `clientSecret` comes back as a mask. There is no route that returns the real one. */
|
|
177
|
+
config: () => Promise<SsoConfigResponse>;
|
|
178
|
+
/**
|
|
179
|
+
* Save. Omit `clientSecret` (or send the mask back) to keep the stored one. For `mode: 'oidc'`
|
|
180
|
+
* the issuer is fetched here, so a bad one is a 400 now rather than a failed login later.
|
|
181
|
+
*/
|
|
182
|
+
save: (config: Partial<SsoConfig> & {
|
|
183
|
+
clientSecret?: string;
|
|
184
|
+
}) => Promise<{
|
|
185
|
+
ok: true;
|
|
186
|
+
config: SsoConfig;
|
|
187
|
+
callbackUrl: string;
|
|
188
|
+
}>;
|
|
189
|
+
/** Forget the provider. Nobody is deleted — accounts it created keep working. */
|
|
190
|
+
remove: () => Promise<{
|
|
191
|
+
ok: true;
|
|
192
|
+
}>;
|
|
193
|
+
};
|
|
140
194
|
jobs: {
|
|
141
195
|
list: () => Promise<Job[]>;
|
|
142
196
|
get: (jobId: string) => Promise<Job>;
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,8 @@ function createClient(opts) {
|
|
|
40
40
|
signal: opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined
|
|
41
41
|
});
|
|
42
42
|
const text = await res.text();
|
|
43
|
+
if (res.ok && (res.headers.get("content-type") ?? "").startsWith("text/plain"))
|
|
44
|
+
return text;
|
|
43
45
|
let parsed = null;
|
|
44
46
|
try {
|
|
45
47
|
parsed = text ? JSON.parse(text) : null;
|
|
@@ -67,6 +69,9 @@ function createClient(opts) {
|
|
|
67
69
|
up: (id, vars) => post(`/api/deployments/${enc(id)}/up${qs(vars)}`).then((r) => r.job),
|
|
68
70
|
verify: (id, vars) => post(`/api/deployments/${enc(id)}/verify${qs(vars)}`).then((r) => r.job),
|
|
69
71
|
down: (id, body = {}, vars) => post(`/api/deployments/${enc(id)}/down${qs(vars)}`, body).then((r) => r.job),
|
|
72
|
+
sleep: (id, vars) => post(`/api/deployments/${enc(id)}/sleep${qs(vars)}`).then((r) => r.job),
|
|
73
|
+
wake: (id, vars) => post(`/api/deployments/${enc(id)}/wake${qs(vars)}`).then((r) => r.job),
|
|
74
|
+
share: (id, body = {}) => post(`/api/deployments/${enc(id)}/share`, body),
|
|
70
75
|
runtime: (id, vars) => get(`/api/deployments/${enc(id)}/runtime${qs(vars)}`),
|
|
71
76
|
readiness: (id, o = {}) => get(`/api/deployments/${enc(id)}/readiness${qs(o.vars, { wait: o.wait, refresh: o.refresh ? 1 : undefined, timeout: o.timeout })}`),
|
|
72
77
|
logs: (id, o = {}) => get(`/api/deployments/${enc(id)}/logs${qs(o.vars, {
|
|
@@ -81,6 +86,15 @@ function createClient(opts) {
|
|
|
81
86
|
stop: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/stop${qs(o.vars, { grace: o.grace })}`),
|
|
82
87
|
restart: (id, name, o = {}) => post(`/api/deployments/${enc(id)}/containers/${enc(name)}/restart${qs(o.vars, { grace: o.grace })}`)
|
|
83
88
|
},
|
|
89
|
+
swarm: {
|
|
90
|
+
info: () => get("/api/swarm"),
|
|
91
|
+
join: (o = { format: "command" }) => get(`/api/swarm/join${qs(undefined, { format: o.format, distro: o.distro })}`)
|
|
92
|
+
},
|
|
93
|
+
sso: {
|
|
94
|
+
config: () => get("/api/sso/config"),
|
|
95
|
+
save: (config) => put("/api/sso/config", config),
|
|
96
|
+
remove: () => del("/api/sso/config")
|
|
97
|
+
},
|
|
84
98
|
jobs: {
|
|
85
99
|
list: () => get("/api/jobs").then((r) => r.jobs),
|
|
86
100
|
get: (jobId) => get(`/api/jobs/${enc(jobId)}`).then((r) => r.job),
|
package/dist/types.d.ts
CHANGED
|
@@ -11,7 +11,21 @@
|
|
|
11
11
|
* renames the server's fields makes the API docs wrong for anyone reading both.
|
|
12
12
|
*/
|
|
13
13
|
export type Kind = 'isolated' | 'shared';
|
|
14
|
-
|
|
14
|
+
/** `sleep` takes the compose project down (volumes and axes stay); `wake` is `up` recorded under its own name. */
|
|
15
|
+
export type JobAction = 'up' | 'down' | 'verify' | 'sleep' | 'wake';
|
|
16
|
+
export type Orchestrator = 'compose' | 'swarm';
|
|
17
|
+
export type ShareView = 'details' | 'logs';
|
|
18
|
+
/**
|
|
19
|
+
* Present while a deployment is asleep: when, why, and the hostnames a request to which wakes it
|
|
20
|
+
* (captured from its Traefik labels the moment before teardown).
|
|
21
|
+
*/
|
|
22
|
+
export type SleepRecord = {
|
|
23
|
+
since: number;
|
|
24
|
+
reason: string;
|
|
25
|
+
hosts: string[];
|
|
26
|
+
/** `HostRegexp` patterns (wildcard subdomains), Go syntax. */
|
|
27
|
+
rules: string[];
|
|
28
|
+
};
|
|
15
29
|
/** `cancelled` = a person stopped it; nothing it had done was undone. */
|
|
16
30
|
export type JobState = 'running' | 'ok' | 'failed' | 'leaked' | 'cancelled';
|
|
17
31
|
export type ReadinessState = 'watching' | 'ready' | 'failed' | 'timedout';
|
|
@@ -20,6 +34,11 @@ export type Health = {
|
|
|
20
34
|
/** False only in loopback dev mode, where the server refuses to bind off-localhost. */
|
|
21
35
|
authEnforced: boolean;
|
|
22
36
|
hasUsers?: boolean;
|
|
37
|
+
/** The configured identity provider, or null. Readable BEFORE authenticating — a login page needs it. */
|
|
38
|
+
sso?: {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
label: string;
|
|
41
|
+
} | null;
|
|
23
42
|
dataDir: string;
|
|
24
43
|
version: string;
|
|
25
44
|
};
|
|
@@ -38,11 +57,57 @@ export type DeploymentRow = {
|
|
|
38
57
|
*/
|
|
39
58
|
busy: boolean | null;
|
|
40
59
|
running: boolean | null;
|
|
60
|
+
/** The sleep record, or null when awake. A sleeping stack is neither running nor torn down. */
|
|
61
|
+
asleep?: SleepRecord | null;
|
|
62
|
+
/** `compose` or `swarm`; null when the spec has no compose section or could not be resolved. */
|
|
63
|
+
orchestrator?: Orchestrator | null;
|
|
64
|
+
/** The spec's `sleep:` policy as durations (`2h`), on the detail route only. */
|
|
65
|
+
sleep?: {
|
|
66
|
+
idle: string | null;
|
|
67
|
+
after: string | null;
|
|
68
|
+
} | null;
|
|
41
69
|
/** Why the spec could not be resolved — usually a variable this call did not supply. */
|
|
42
70
|
unresolved?: string;
|
|
43
71
|
createdAt?: number;
|
|
44
72
|
updatedAt?: number;
|
|
45
73
|
};
|
|
74
|
+
export type SwarmNode = {
|
|
75
|
+
id: string;
|
|
76
|
+
hostname: string;
|
|
77
|
+
role: 'manager' | 'worker';
|
|
78
|
+
status: string;
|
|
79
|
+
availability: string;
|
|
80
|
+
managerStatus: string | null;
|
|
81
|
+
engineVersion: string;
|
|
82
|
+
/** The node this API runs on. */
|
|
83
|
+
self: boolean;
|
|
84
|
+
};
|
|
85
|
+
export type SwarmInfo = {
|
|
86
|
+
/** False when docker did not answer — every other field is then unknown, not empty. */
|
|
87
|
+
reachable: boolean;
|
|
88
|
+
/** Whether this daemon is a swarm manager. Inactive hosts run previews with compose. */
|
|
89
|
+
active: boolean;
|
|
90
|
+
nodeId: string | null;
|
|
91
|
+
/** `<advertise-addr>:2377`, what a worker joins. */
|
|
92
|
+
managerAddr: string | null;
|
|
93
|
+
nodes: SwarmNode[];
|
|
94
|
+
error?: string;
|
|
95
|
+
/** The ports a worker must reach on the manager (and nodes on each other). */
|
|
96
|
+
ports: Array<{
|
|
97
|
+
port: string;
|
|
98
|
+
why: string;
|
|
99
|
+
}>;
|
|
100
|
+
note: string;
|
|
101
|
+
};
|
|
102
|
+
export type ShareLink = {
|
|
103
|
+
/** `https://control.<domain>/deployments/<id>/public-logs-view?token=…` */
|
|
104
|
+
url: string;
|
|
105
|
+
/** The JWT. Returned ONCE; stored nowhere on the server. Works as this client's `token` for the GETs it allows. */
|
|
106
|
+
token: string;
|
|
107
|
+
views: ShareView[];
|
|
108
|
+
/** Epoch ms. */
|
|
109
|
+
expiresAt: number;
|
|
110
|
+
};
|
|
46
111
|
export type StepResult = {
|
|
47
112
|
axis: string;
|
|
48
113
|
phase: 'requires' | 'up' | 'down' | 'assert_gone' | 'assert_live' | 'compose';
|
|
@@ -112,6 +177,12 @@ export type RuntimeContainer = {
|
|
|
112
177
|
hostPort?: string;
|
|
113
178
|
}>;
|
|
114
179
|
traefikLabels: Record<string, string>;
|
|
180
|
+
/** Epoch ms when the process started; null when docker did not say. */
|
|
181
|
+
startedAt?: number | null;
|
|
182
|
+
/** The swarm node it runs on, or null under compose. */
|
|
183
|
+
node?: string | null;
|
|
184
|
+
/** A swarm task on ANOTHER node: listed, but out of reach of exec/stop from the manager. */
|
|
185
|
+
remote?: boolean;
|
|
115
186
|
};
|
|
116
187
|
export type RuntimeRoute = {
|
|
117
188
|
router: string;
|
|
@@ -128,6 +199,8 @@ export type RuntimeRoute = {
|
|
|
128
199
|
};
|
|
129
200
|
export type Runtime = {
|
|
130
201
|
id?: string;
|
|
202
|
+
orchestrator?: Orchestrator | null;
|
|
203
|
+
asleep?: SleepRecord | null;
|
|
131
204
|
stack: string;
|
|
132
205
|
containers: RuntimeContainer[];
|
|
133
206
|
routes: RuntimeRoute[];
|
|
@@ -197,3 +270,51 @@ export type PstackEvent = {
|
|
|
197
270
|
at: number;
|
|
198
271
|
data: Record<string, unknown>;
|
|
199
272
|
};
|
|
273
|
+
export type SsoClaimMap = {
|
|
274
|
+
subject: string;
|
|
275
|
+
username: string;
|
|
276
|
+
email: string;
|
|
277
|
+
name: string;
|
|
278
|
+
avatar: string;
|
|
279
|
+
};
|
|
280
|
+
/** The stored provider. The client secret is NEVER part of this — it has no read path. */
|
|
281
|
+
export type SsoConfig = {
|
|
282
|
+
mode: 'oidc' | 'oauth2';
|
|
283
|
+
enabled: boolean;
|
|
284
|
+
/** Button text, and the `providerKey` half of an account's identity. */
|
|
285
|
+
label: string;
|
|
286
|
+
clientId: string;
|
|
287
|
+
/** Mode A: the issuer, or a full `.well-known` URL. */
|
|
288
|
+
discoveryUrl: string;
|
|
289
|
+
/** Mode B: a preset key, or `custom`. */
|
|
290
|
+
provider: string;
|
|
291
|
+
authorizeUrl: string;
|
|
292
|
+
tokenUrl: string;
|
|
293
|
+
userInfoUrl: string;
|
|
294
|
+
/** Consulted only when the profile carries no address (GitHub's default). */
|
|
295
|
+
emailsUrl: string;
|
|
296
|
+
scopes: string;
|
|
297
|
+
claimMap: SsoClaimMap;
|
|
298
|
+
/** Non-empty ⇒ a login outside these domains is refused, INCLUDING one with no address at all. */
|
|
299
|
+
allowedEmailDomains: string[];
|
|
300
|
+
defaultRole: string;
|
|
301
|
+
};
|
|
302
|
+
export type SsoPreset = {
|
|
303
|
+
key: string;
|
|
304
|
+
label: string;
|
|
305
|
+
authorizeUrl: string;
|
|
306
|
+
tokenUrl: string;
|
|
307
|
+
userInfoUrl: string | null;
|
|
308
|
+
scopes: string;
|
|
309
|
+
claimMap: SsoClaimMap;
|
|
310
|
+
};
|
|
311
|
+
export type SsoConfigResponse = {
|
|
312
|
+
configured: boolean;
|
|
313
|
+
/** Register THIS with the provider, byte for byte. Built server-side; never guess it. */
|
|
314
|
+
callbackUrl: string;
|
|
315
|
+
presets: SsoPreset[];
|
|
316
|
+
config: SsoConfig | null;
|
|
317
|
+
/** A mask when one is stored. Submit it back unchanged to keep the stored secret. */
|
|
318
|
+
clientSecret: string;
|
|
319
|
+
updatedAt: number | null;
|
|
320
|
+
};
|
package/package.json
CHANGED