@homeflare/alchemy 0.12.0 → 0.13.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/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/package.json +1 -1
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` drift that is not about a secret's VALUE: a target switched off by hand,
|
|
3
|
+
* and a secret NAME added to the declaration. Split from pbs-notification-target-state.test.ts for
|
|
4
|
+
* the 250-line cap; the same fake server (fake-pbs-notify.ts) and the same engine.
|
|
5
|
+
*
|
|
6
|
+
* ★ BOTH WERE FOUND BY MUTATION, NOT BY READING. Deleting the `disable` line from `plainMatches`, or
|
|
7
|
+
* the secret-name line from `sealedState`, left every other test green: nothing had declared a
|
|
8
|
+
* target, changed it on the server, and asked the plan.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, expect, test } from 'bun:test';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
13
|
+
import type { PbsTarget } from './credentials.ts';
|
|
14
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
15
|
+
import { withoutBao } from './fake-pve.ts';
|
|
16
|
+
import {
|
|
17
|
+
PbsNotificationTarget,
|
|
18
|
+
type PbsNotificationTargetProps,
|
|
19
|
+
PbsNotificationTargetProvider,
|
|
20
|
+
} from './pbs-notification-target.ts';
|
|
21
|
+
import { keyAndValue } from './pbs-notification-target-wire.ts';
|
|
22
|
+
|
|
23
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
24
|
+
const PATH = 'config/notifications/endpoints/webhook/alertmanager';
|
|
25
|
+
|
|
26
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}) =>
|
|
27
|
+
PbsNotificationTarget('alertmanager', {
|
|
28
|
+
method: 'post',
|
|
29
|
+
name: 'alertmanager',
|
|
30
|
+
secret: { token: { fromEnv: 'HF_DRIFT_TOKEN' } },
|
|
31
|
+
target: PBS,
|
|
32
|
+
type: 'webhook',
|
|
33
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
delete process.env['HF_DRIFT_TOKEN'];
|
|
39
|
+
delete process.env['HF_DRIFT_ROUTE'];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const engineFor = (fake: ReturnType<typeof fakeNotify>) =>
|
|
43
|
+
engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
44
|
+
|
|
45
|
+
test('a target disabled by hand plans an update, and the deploy switches it back on', async () => {
|
|
46
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
47
|
+
const fake = fakeNotify();
|
|
48
|
+
await withoutBao(async () => {
|
|
49
|
+
const engine = engineFor(fake);
|
|
50
|
+
await engine.deploy(hook());
|
|
51
|
+
fake.objects.set(PATH, { ...fake.objects.get(PATH), disable: true });
|
|
52
|
+
expect((await engine.verify(hook(), { all: true })).rows[0]?.diff).toBe('update');
|
|
53
|
+
await engine.deploy(hook());
|
|
54
|
+
});
|
|
55
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
56
|
+
expect(put?.pairs).toContainEqual(['disable', '0']);
|
|
57
|
+
expect(put?.pairs.map(([key]) => key)).not.toContain('secret');
|
|
58
|
+
expect(fake.objects.get(PATH)?.['disable']).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a secret name added is stale by PRESENCE — seen with no variables set — and both are sent', async () => {
|
|
62
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
63
|
+
const fake = fakeNotify();
|
|
64
|
+
const twoSecrets = () =>
|
|
65
|
+
hook({
|
|
66
|
+
secret: { route: { fromEnv: 'HF_DRIFT_ROUTE' }, token: { fromEnv: 'HF_DRIFT_TOKEN' } },
|
|
67
|
+
});
|
|
68
|
+
await withoutBao(async () => {
|
|
69
|
+
const engine = engineFor(fake);
|
|
70
|
+
await engine.deploy(hook());
|
|
71
|
+
// ⚠️ No variable at plan time, so the seal cannot be checked: only the names can say this.
|
|
72
|
+
delete process.env['HF_DRIFT_TOKEN'];
|
|
73
|
+
expect((await engine.verify(twoSecrets(), { all: true })).rows[0]?.diff).toBe('update');
|
|
74
|
+
process.env['HF_DRIFT_TOKEN'] = 'tok-drift-not-real';
|
|
75
|
+
process.env['HF_DRIFT_ROUTE'] = 'route-not-real';
|
|
76
|
+
await engine.deploy(twoSecrets());
|
|
77
|
+
expect(engine.stored()).not.toContain('route-not-real');
|
|
78
|
+
});
|
|
79
|
+
expect((fake.secrets.get(PATH) ?? []).map((item) => keyAndValue(item).name).sort()).toEqual([
|
|
80
|
+
'route',
|
|
81
|
+
'token',
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a PBS notification target has changed, and the forms that change it.
|
|
3
|
+
*
|
|
4
|
+
* ★ THREE QUESTIONS, NOT ONE, because the three kinds of field can be checked three ways:
|
|
5
|
+
* plain every field the server returns in full — compared by value, like any family here
|
|
6
|
+
* header names from the read, values by digest (pbs-notification-target-wire.ts)
|
|
7
|
+
* sealed secret values and the smtp password: the server returns names at best, so values
|
|
8
|
+
* are checked against the seal of what this provider last wrote (write-only.ts)
|
|
9
|
+
* Each answers `match`, `stale` or `unknown`; only `stale` writes. `unknown` is "the plan cannot
|
|
10
|
+
* see it" — a variable missing from the plan's environment, or a secret this provider never
|
|
11
|
+
* wrote (an adoption) — and it is PRESENCE-ONLY by design: a plan never invents drift it
|
|
12
|
+
* cannot see, and adoption stays free.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ A WRITE SENDS ONLY THE GROUPS THAT ARE STALE. Omitting `header` or `secret` from a PUT keeps
|
|
15
|
+
* what the server holds (api/webhook.rs `update_endpoint`), so a changed comment is written
|
|
16
|
+
* without reading a single secret — and a deploy environment without the secrets can still
|
|
17
|
+
* repair the rest. A group that IS stale needs every value it holds; a missing one fails the
|
|
18
|
+
* deploy by variable name before anything is sent.
|
|
19
|
+
*/
|
|
20
|
+
import type { PveForm } from './client.ts';
|
|
21
|
+
import { literalCredentialHeaders, literalCredentialsInUrl } from './credential-literals.ts';
|
|
22
|
+
import { addressList } from './notification-target-form.ts';
|
|
23
|
+
import type {
|
|
24
|
+
PbsNotificationTargetAttributes,
|
|
25
|
+
PbsNotificationTargetProps,
|
|
26
|
+
} from './pbs-notification-target.ts';
|
|
27
|
+
import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
|
|
28
|
+
import { type Environment, type FromEnv, resolveAll, sealMatches } from './write-only.ts';
|
|
29
|
+
|
|
30
|
+
type Props = PbsNotificationTargetProps;
|
|
31
|
+
type Field = keyof Props;
|
|
32
|
+
|
|
33
|
+
/** Which family each optional field belongs to. A field outside its family is refused at plan. */
|
|
34
|
+
const OWNED: Readonly<Partial<Record<Field, readonly Props['type'][]>>> = {
|
|
35
|
+
author: ['sendmail', 'smtp'],
|
|
36
|
+
body: ['webhook'],
|
|
37
|
+
'from-address': ['sendmail', 'smtp'],
|
|
38
|
+
header: ['webhook'],
|
|
39
|
+
mailto: ['sendmail', 'smtp'],
|
|
40
|
+
'mailto-user': ['sendmail', 'smtp'],
|
|
41
|
+
method: ['webhook'],
|
|
42
|
+
mode: ['smtp'],
|
|
43
|
+
password: ['smtp'],
|
|
44
|
+
port: ['smtp'],
|
|
45
|
+
secret: ['webhook'],
|
|
46
|
+
server: ['smtp'],
|
|
47
|
+
url: ['webhook'],
|
|
48
|
+
username: ['smtp'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const REQUIRED: Readonly<Record<Props['type'], readonly Field[]>> = {
|
|
52
|
+
sendmail: [],
|
|
53
|
+
smtp: ['server', 'from-address'],
|
|
54
|
+
webhook: ['url', 'method'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A header or secret name lands inside a property string, where `,` and `=` are syntax. */
|
|
58
|
+
const CLEAN_NAME = /^[^,=\s]+$/;
|
|
59
|
+
|
|
60
|
+
/** How a refused literal credential should be declared instead. */
|
|
61
|
+
const WRITE_ONLY =
|
|
62
|
+
"declare it { fromEnv: 'NAME' }, or keep it in 'secret' and write {{ secrets.<name> }}";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Why this declaration cannot be written, or nothing. Checked at PLAN, so a webhook without a
|
|
66
|
+
* URL fails `alchemy plan` naming the field rather than a deploy failing on a 400.
|
|
67
|
+
* ⚠️ "AT PLAN" MEANS `diff` FOR A TARGET WITH STATE AND THE ADOPTION PROBE (`read`) FOR ONE
|
|
68
|
+
* WITHOUT — pbs-notification-target-lifecycle.ts has why both are needed.
|
|
69
|
+
*/
|
|
70
|
+
export const refusals = (props: Props): string[] => {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const [field, types] of Object.entries(OWNED) as [Field, readonly Props['type'][]][]) {
|
|
73
|
+
if (props[field] !== undefined && !types.includes(props.type)) {
|
|
74
|
+
out.push(`'${field}' belongs to ${types.join('/')} targets, not ${props.type}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const field of REQUIRED[props.type]) {
|
|
78
|
+
if (props[field] === undefined || props[field] === '')
|
|
79
|
+
out.push(`${props.type} needs '${field}'`);
|
|
80
|
+
}
|
|
81
|
+
for (const key of [...Object.keys(props.header ?? {}), ...Object.keys(props.secret ?? {})]) {
|
|
82
|
+
if (!CLEAN_NAME.test(key)) out.push(`'${key}' cannot be a header or secret name`);
|
|
83
|
+
}
|
|
84
|
+
// ⛔ credential-literals.ts: a literal credential in a plain prop is a credential in state.
|
|
85
|
+
for (const name of literalCredentialHeaders(props.header)) {
|
|
86
|
+
out.push(`header '${name}' holds a literal credential; ${WRITE_ONLY}`);
|
|
87
|
+
}
|
|
88
|
+
for (const where of literalCredentialsInUrl(props.url)) {
|
|
89
|
+
out.push(`'url' ${where} holds a literal credential; ${WRITE_ONLY}`);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isFromEnv = (value: string | FromEnv): value is FromEnv => typeof value !== 'string';
|
|
95
|
+
|
|
96
|
+
/** Each group's values, resolved from `env`, plus the variables that were missing. */
|
|
97
|
+
export const resolveGroups = (props: Props, env: Environment = process.env) => {
|
|
98
|
+
const headerRefs: Record<string, FromEnv> = {};
|
|
99
|
+
const headers: Record<string, string> = {};
|
|
100
|
+
for (const [name, value] of Object.entries(props.header ?? {})) {
|
|
101
|
+
if (isFromEnv(value)) headerRefs[name] = value;
|
|
102
|
+
else headers[name] = value;
|
|
103
|
+
}
|
|
104
|
+
const header = resolveAll(headerRefs, env);
|
|
105
|
+
const sealedRefs: Record<string, FromEnv> = {};
|
|
106
|
+
for (const [name, ref] of Object.entries(props.secret ?? {})) sealedRefs[`secret:${name}`] = ref;
|
|
107
|
+
if (props.password !== undefined) sealedRefs['password'] = props.password;
|
|
108
|
+
const sealed = resolveAll(sealedRefs, env);
|
|
109
|
+
return {
|
|
110
|
+
header: { missing: header.missing, values: { ...headers, ...header.values } },
|
|
111
|
+
sealed,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type Groups = ReturnType<typeof resolveGroups>;
|
|
116
|
+
export type GroupState = 'match' | 'stale' | 'unknown';
|
|
117
|
+
|
|
118
|
+
const same = (declared: string | undefined, live: string): boolean =>
|
|
119
|
+
declared === undefined || declared === live;
|
|
120
|
+
|
|
121
|
+
/** Every field the server returns in full. `disable` is the one with no undeclared case. */
|
|
122
|
+
export const plainMatches = (live: PbsNotificationTargetAttributes, props: Props): boolean =>
|
|
123
|
+
live.disable === (props.disable === true) &&
|
|
124
|
+
same(props.comment, live.comment) &&
|
|
125
|
+
same(props.author, live.author) &&
|
|
126
|
+
same(props['from-address'], live['from-address']) &&
|
|
127
|
+
same(props.server, live.server) &&
|
|
128
|
+
(props.port === undefined || props.port === live.port) &&
|
|
129
|
+
same(props.mode, live.mode) &&
|
|
130
|
+
same(props.username, live.username) &&
|
|
131
|
+
same(props.url, live.url) &&
|
|
132
|
+
same(props.method, live.method) &&
|
|
133
|
+
same(props.body, live.body) &&
|
|
134
|
+
(props.mailto === undefined || addressList(props.mailto) === live.mailto) &&
|
|
135
|
+
(props['mailto-user'] === undefined || addressList(props['mailto-user']) === live['mailto-user']);
|
|
136
|
+
|
|
137
|
+
export const headerState = (
|
|
138
|
+
live: PbsNotificationTargetAttributes,
|
|
139
|
+
props: Props,
|
|
140
|
+
groups: Groups,
|
|
141
|
+
): GroupState => {
|
|
142
|
+
if (props.header === undefined) return 'match';
|
|
143
|
+
if (names(Object.keys(props.header)) !== live.header) return 'stale';
|
|
144
|
+
if (groups.header.missing.length > 0) return 'unknown';
|
|
145
|
+
return headerDigest(props.name, groups.header.values) === live.headerDigest ? 'match' : 'stale';
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const sealedState = (
|
|
149
|
+
live: PbsNotificationTargetAttributes,
|
|
150
|
+
props: Props,
|
|
151
|
+
groups: Groups,
|
|
152
|
+
sealed: string,
|
|
153
|
+
): GroupState => {
|
|
154
|
+
if (props.secret === undefined && props.password === undefined) return 'match';
|
|
155
|
+
if (props.secret !== undefined && names(Object.keys(props.secret)) !== live.secret)
|
|
156
|
+
return 'stale';
|
|
157
|
+
if (groups.sealed.missing.length > 0 || sealed === '') return 'unknown';
|
|
158
|
+
return sealMatches(sealed, groups.sealed.values) ? 'match' : 'stale';
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Which write-only groups a write carries. A create carries every declared one. */
|
|
162
|
+
export interface Carry {
|
|
163
|
+
readonly header: boolean;
|
|
164
|
+
readonly sealed: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const secretItems = (props: Props, groups: Groups): string[] =>
|
|
168
|
+
Object.keys(props.secret ?? {}).map((name) =>
|
|
169
|
+
wireItem(name, groups.sealed.values[`secret:${name}`] ?? ''),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const headerItems = (props: Props, groups: Groups): string[] =>
|
|
173
|
+
Object.keys(props.header ?? {}).map((name) => wireItem(name, groups.header.values[name] ?? ''));
|
|
174
|
+
|
|
175
|
+
/** `''`/`[]` means "clear": dropped on a create, turned into a `delete` entry on an update. */
|
|
176
|
+
const plainPairs = (props: Props): [string, string | readonly string[]][] => [
|
|
177
|
+
['author', props.author ?? ''],
|
|
178
|
+
['body', props.body === undefined ? '' : base64(props.body)],
|
|
179
|
+
['comment', props.comment ?? ''],
|
|
180
|
+
['from-address', props['from-address'] ?? ''],
|
|
181
|
+
['method', props.method ?? ''],
|
|
182
|
+
['mode', props.mode ?? ''],
|
|
183
|
+
['port', props.port === undefined ? '' : String(props.port)],
|
|
184
|
+
['server', props.server ?? ''],
|
|
185
|
+
['url', props.url ?? ''],
|
|
186
|
+
['username', props.username ?? ''],
|
|
187
|
+
['mailto', props.mailto === undefined ? [] : addressList(props.mailto).split(',')],
|
|
188
|
+
[
|
|
189
|
+
'mailto-user',
|
|
190
|
+
props['mailto-user'] === undefined ? [] : addressList(props['mailto-user']).split(','),
|
|
191
|
+
],
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
const declared = (props: Props, key: string): boolean =>
|
|
195
|
+
(props as unknown as Record<string, unknown>)[key] !== undefined;
|
|
196
|
+
|
|
197
|
+
const empty = (value: string | readonly string[]): boolean =>
|
|
198
|
+
typeof value === 'string' ? value === '' : value.filter((item) => item !== '').length === 0;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The body of a create or an update — keys are the server's own, checked against generated/pbs.ts
|
|
202
|
+
* by pbs-notification-target.test.ts. ⛔ `disable` IS ALWAYS SENT, `0` included — the PVE family's
|
|
203
|
+
* reason: it is always compared, and "undeclared" and "false" must be one state, not a plan that
|
|
204
|
+
* asks for an update the PUT never performs.
|
|
205
|
+
*/
|
|
206
|
+
export const targetForm = (
|
|
207
|
+
props: Props,
|
|
208
|
+
groups: Groups,
|
|
209
|
+
carry: Carry,
|
|
210
|
+
mode: 'create' | 'update',
|
|
211
|
+
): PveForm => {
|
|
212
|
+
const form: Record<string, string | readonly string[]> = {
|
|
213
|
+
disable: props.disable === true ? '1' : '0',
|
|
214
|
+
};
|
|
215
|
+
const cleared: string[] = [];
|
|
216
|
+
const put = (key: string, value: string | readonly string[]) => {
|
|
217
|
+
if (!empty(value)) form[key] = value;
|
|
218
|
+
else if (mode === 'update') cleared.push(key);
|
|
219
|
+
};
|
|
220
|
+
for (const [key, value] of plainPairs(props)) if (declared(props, key)) put(key, value);
|
|
221
|
+
if (carry.header && props.header !== undefined) put('header', headerItems(props, groups));
|
|
222
|
+
if (carry.sealed && props.secret !== undefined) put('secret', secretItems(props, groups));
|
|
223
|
+
if (carry.sealed && props.password !== undefined) {
|
|
224
|
+
form['password'] = groups.sealed.values['password'] ?? '';
|
|
225
|
+
}
|
|
226
|
+
if (mode === 'create') form['name'] = props.name;
|
|
227
|
+
if (cleared.length > 0) form['delete'] = cleared;
|
|
228
|
+
return form;
|
|
229
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lifecycle of a `Pbs.NotificationTarget`: read, diff, reconcile, delete.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF pbs-notification-target.ts FOR THE 250-LINE CAP. That file says what a target is
|
|
5
|
+
* and holds `Provider.of`; this one says what each operation does. The `import type` back is
|
|
6
|
+
* erased, so the cycle is on paper only.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ NOT `pveHandlers`, AND IT SHOULD LOOK DIFFERENT (resource.ts asks for exactly that). The
|
|
9
|
+
* factory's `diff` sees only the live object, and a secret's value is not in the live object:
|
|
10
|
+
* the seal lives in the PREVIOUS STATE, which only a hand-written `diff` and `reconcile` receive
|
|
11
|
+
* (`output`). Everything else — the read, the read-back refusal, the `matches` guard that keeps
|
|
12
|
+
* adoption free — is the factory's behaviour, written out.
|
|
13
|
+
*/
|
|
14
|
+
import { isResolved } from 'alchemy/Diff';
|
|
15
|
+
import type { Input } from 'alchemy/Input';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import { pve } from './client.ts';
|
|
18
|
+
import type {
|
|
19
|
+
PbsNotificationTargetAttributes,
|
|
20
|
+
PbsNotificationTargetProps,
|
|
21
|
+
} from './pbs-notification-target.ts';
|
|
22
|
+
import {
|
|
23
|
+
type Carry,
|
|
24
|
+
type Groups,
|
|
25
|
+
headerState,
|
|
26
|
+
plainMatches,
|
|
27
|
+
refusals,
|
|
28
|
+
resolveGroups,
|
|
29
|
+
sealedState,
|
|
30
|
+
targetForm,
|
|
31
|
+
} from './pbs-notification-target-form.ts';
|
|
32
|
+
import { targetAttributes } from './pbs-notification-target-wire.ts';
|
|
33
|
+
import { seal } from './write-only.ts';
|
|
34
|
+
|
|
35
|
+
type Props = PbsNotificationTargetProps;
|
|
36
|
+
type Attributes = PbsNotificationTargetAttributes;
|
|
37
|
+
|
|
38
|
+
const path = (props: Props) => `config/notifications/endpoints/${props.type}/${props.name}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⚠️ A 404 and `{"data": null}` are answers here — and so, resource.ts's ⛔, is a 403. `pve()`
|
|
42
|
+
* returns `null` for the second, not `undefined`, so the test is `== null`.
|
|
43
|
+
*/
|
|
44
|
+
const readLive = (props: Props) =>
|
|
45
|
+
pve<Record<string, unknown>>(props.target, 'read', 'GET', path(props)).pipe(
|
|
46
|
+
Effect.map((data) => (data == null ? undefined : targetAttributes(data, props))),
|
|
47
|
+
Effect.orElseSucceed(() => undefined),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const refuse = (props: Props) => {
|
|
51
|
+
const reasons = refusals(props);
|
|
52
|
+
return reasons.length === 0
|
|
53
|
+
? Effect.void
|
|
54
|
+
: Effect.die(new Error(`${path(props)}: ${reasons.join('; ')}.`));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What a write must carry, given what is stale. `undefined` means nothing to write. */
|
|
58
|
+
const toCarry = (live: Attributes, props: Props, groups: Groups, sealed: string) => {
|
|
59
|
+
const header = headerState(live, props, groups) === 'stale';
|
|
60
|
+
const sealedStale = sealedState(live, props, groups, sealed) === 'stale';
|
|
61
|
+
return plainMatches(live, props) && !header && !sealedStale
|
|
62
|
+
? undefined
|
|
63
|
+
: ({ header, sealed: sealedStale } satisfies Carry);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** ⛔ Every variable a carried group needs, or a failure naming the missing ones — never a value. */
|
|
67
|
+
const requireValues = (props: Props, groups: Groups, carry: Carry) => {
|
|
68
|
+
const missing = [
|
|
69
|
+
...(carry.header ? groups.header.missing : []),
|
|
70
|
+
...(carry.sealed ? groups.sealed.missing : []),
|
|
71
|
+
];
|
|
72
|
+
return missing.length === 0
|
|
73
|
+
? Effect.void
|
|
74
|
+
: Effect.die(
|
|
75
|
+
new Error(
|
|
76
|
+
`${path(props)}: this write must send write-only values, and ${missing.join(', ')} ` +
|
|
77
|
+
'is unset or empty in the deploying environment. Export it and deploy again.',
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const handlers = {
|
|
83
|
+
/** ⛔ EMPTY: `mail-to-root` is on every host, and adoption stays an explicit act (resource.ts). */
|
|
84
|
+
list: () => Effect.succeed([]),
|
|
85
|
+
/**
|
|
86
|
+
* ⛔ WITH NO `output` THIS IS ALCHEMY'S ADOPTION PROBE, AND THE ONLY PLAN-TIME HOOK A NEW TARGET
|
|
87
|
+
* GETS — so the refusals run here too. Measured on beta.79 (Plan.ts, the `oldState ===
|
|
88
|
+
* undefined` branch): a declaration with no state row is `read` with `olds: news` and never
|
|
89
|
+
* `diff`ed, and Apply then commits its props as `creating` BEFORE `reconcile` runs. A refusal
|
|
90
|
+
* left to `reconcile` fired after the props — a literal token included — were in the store.
|
|
91
|
+
* ⚠️ ONLY WHEN THE PROPS ARE RESOLVED: Plan skips the probe while any prop is an unresolved
|
|
92
|
+
* Output, and then `reconcile` is the first check. docs/pbs-notifications.md says so.
|
|
93
|
+
* ★ A DEFECT (`Effect.die`), NOT A FAILURE, AND THAT IS WHAT KEEPS IT FROM WEDGING A STAGE. The
|
|
94
|
+
* same `read`-with-no-output shape is Alchemy's RECOVERY read of an interrupted create, at plan
|
|
95
|
+
* and before a delete (Plan.ts, Apply.ts), with the STORED props — and both wrap it in
|
|
96
|
+
* `catchDefect`, degrading to "nothing recovered". A typed failure would pass that catch and
|
|
97
|
+
* block the plan or the delete on props the user already fixed. The cold-start probe has no
|
|
98
|
+
* catch, so there the defect fails the plan, as intended. A refused create sent no request,
|
|
99
|
+
* so "nothing recovered" is also the truth.
|
|
100
|
+
*/
|
|
101
|
+
read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
102
|
+
(output === undefined ? refuse(olds) : Effect.void).pipe(
|
|
103
|
+
Effect.andThen(readLive(olds)),
|
|
104
|
+
Effect.map((live) =>
|
|
105
|
+
live === undefined ? undefined : { ...live, sealed: output?.sealed ?? '' },
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
/**
|
|
109
|
+
* ⚠️ `Input<Props>` and `isResolved`, resource.ts's reason: at plan time a prop can still be an
|
|
110
|
+
* unresolved Output, and comparing a placeholder to a live value reports a phantom update.
|
|
111
|
+
* ⛔ A NEW `type` UNDER THE SAME `name` REPLACES DELETE-FIRST: names are unique across every
|
|
112
|
+
* family on the host (api `ensure_unique`), so creating the new one first would be refused.
|
|
113
|
+
*/
|
|
114
|
+
diff: ({
|
|
115
|
+
news,
|
|
116
|
+
olds,
|
|
117
|
+
output,
|
|
118
|
+
}: {
|
|
119
|
+
news: Input<Props>;
|
|
120
|
+
olds: Props;
|
|
121
|
+
output: Attributes | undefined;
|
|
122
|
+
}) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
125
|
+
if (olds !== undefined && (olds.type !== news.type || olds.name !== news.name)) {
|
|
126
|
+
return { action: 'replace', deleteFirst: olds.name === news.name } as const;
|
|
127
|
+
}
|
|
128
|
+
yield* refuse(news);
|
|
129
|
+
const live = yield* readLive(news);
|
|
130
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
131
|
+
return toCarry(live, news, resolveGroups(news), output.sealed) === undefined
|
|
132
|
+
? ({ action: 'noop' } as const)
|
|
133
|
+
: ({ action: 'update' } as const);
|
|
134
|
+
}),
|
|
135
|
+
reconcile: ({ news, output }: { news: Props; output: Attributes | undefined }) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
yield* refuse(news);
|
|
138
|
+
const groups = resolveGroups(news);
|
|
139
|
+
const live = yield* readLive(news);
|
|
140
|
+
let sealed = output?.sealed ?? '';
|
|
141
|
+
if (live === undefined) {
|
|
142
|
+
const carry = { header: true, sealed: true } as const;
|
|
143
|
+
yield* requireValues(news, groups, carry);
|
|
144
|
+
const collection = `config/notifications/endpoints/${news.type}`;
|
|
145
|
+
yield* pve(
|
|
146
|
+
news.target,
|
|
147
|
+
'provision',
|
|
148
|
+
'POST',
|
|
149
|
+
collection,
|
|
150
|
+
targetForm(news, groups, carry, 'create'),
|
|
151
|
+
);
|
|
152
|
+
sealed = seal(groups.sealed.values);
|
|
153
|
+
} else {
|
|
154
|
+
const carry = toCarry(live, news, groups, sealed);
|
|
155
|
+
if (carry !== undefined) {
|
|
156
|
+
yield* requireValues(news, groups, carry);
|
|
157
|
+
yield* pve(
|
|
158
|
+
news.target,
|
|
159
|
+
'provision',
|
|
160
|
+
'PUT',
|
|
161
|
+
path(news),
|
|
162
|
+
targetForm(news, groups, carry, 'update'),
|
|
163
|
+
);
|
|
164
|
+
if (carry.sealed) sealed = seal(groups.sealed.values);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const after = yield* readLive(news);
|
|
168
|
+
if (after === undefined) {
|
|
169
|
+
// ⛔ resource.ts's rule: "no error" is not evidence of a write. Read back, or refuse.
|
|
170
|
+
return yield* Effect.die(
|
|
171
|
+
new Error(`${path(news)}: the write returned no error but the target is still absent.`),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return { ...after, sealed } satisfies Attributes;
|
|
175
|
+
}),
|
|
176
|
+
delete: ({ olds }: { olds: Props }) => pve(olds.target, 'provision', 'DELETE', path(olds)),
|
|
177
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` through Alchemy's own Plan and Apply, over a PBS that remembers writes
|
|
3
|
+
* (fake-pbs-notify.ts) — the write-only contract, end to end.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHAT IS PINNED, AND WHERE A REGRESSION WOULD LEAK:
|
|
6
|
+
* - the secret, the password and a `fromEnv` header reach the WIRE and never the STATE STORE
|
|
7
|
+
* (the store is dumped as JSON and searched, raw and base64);
|
|
8
|
+
* - a rotated value is found by the seal and written, and ONLY its group is written;
|
|
9
|
+
* - a plan without the variables is presence-only: noop, not a guess;
|
|
10
|
+
* - a write that needs a missing variable fails by the variable's NAME, before any request;
|
|
11
|
+
* - adopting a webhook that already has secrets writes nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
16
|
+
import type { PbsTarget } from './credentials.ts';
|
|
17
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
18
|
+
import { withoutBao } from './fake-pve.ts';
|
|
19
|
+
import {
|
|
20
|
+
PbsNotificationTarget,
|
|
21
|
+
type PbsNotificationTargetProps,
|
|
22
|
+
PbsNotificationTargetProvider,
|
|
23
|
+
} from './pbs-notification-target.ts';
|
|
24
|
+
import { keyAndValue, unbase64 } from './pbs-notification-target-wire.ts';
|
|
25
|
+
|
|
26
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
27
|
+
const PATH = 'config/notifications/endpoints/webhook/alertmanager';
|
|
28
|
+
const FIRST = 'tok-1111-not-real';
|
|
29
|
+
const SECOND = 'tok-2222-not-real';
|
|
30
|
+
const HEADER = 'hdr-3333-not-real';
|
|
31
|
+
|
|
32
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}) =>
|
|
33
|
+
PbsNotificationTarget('alertmanager', {
|
|
34
|
+
body: '[{"labels": {"alertname": "x"}}]',
|
|
35
|
+
header: { 'Content-Type': 'application/json', 'X-Scope': { fromEnv: 'HF_TEST_HEADER' } },
|
|
36
|
+
method: 'post',
|
|
37
|
+
name: 'alertmanager',
|
|
38
|
+
secret: { token: { fromEnv: 'HF_TEST_TOKEN' } },
|
|
39
|
+
target: PBS,
|
|
40
|
+
type: 'webhook',
|
|
41
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
42
|
+
...over,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const setEnv = (values: Record<string, string | undefined>) => {
|
|
46
|
+
for (const [key, value] of Object.entries(values)) {
|
|
47
|
+
if (value === undefined) delete process.env[key];
|
|
48
|
+
else process.env[key] = value;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
afterEach(() => setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined }));
|
|
53
|
+
|
|
54
|
+
const engineFor = (fake: ReturnType<typeof fakeNotify>) =>
|
|
55
|
+
engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
56
|
+
|
|
57
|
+
const leaks = (stored: string, values: readonly string[]) =>
|
|
58
|
+
values.filter((v) => stored.includes(v) || stored.includes(Buffer.from(v).toString('base64')));
|
|
59
|
+
|
|
60
|
+
const verifyDiff = async (engine: ReturnType<typeof engineFor>, body = hook()) =>
|
|
61
|
+
(await engine.verify(body, { all: true })).rows[0]?.diff;
|
|
62
|
+
|
|
63
|
+
describe('a webhook created with write-only values', () => {
|
|
64
|
+
test('sends them, stores none of them, and plans noop again', async () => {
|
|
65
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
66
|
+
const fake = fakeNotify();
|
|
67
|
+
await withoutBao(async () => {
|
|
68
|
+
const engine = engineFor(fake);
|
|
69
|
+
await engine.deploy(hook());
|
|
70
|
+
expect(leaks(engine.stored(), [FIRST, HEADER])).toEqual([]);
|
|
71
|
+
expect(engine.stored()).toContain('HF_TEST_TOKEN');
|
|
72
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
73
|
+
await engine.deploy(hook());
|
|
74
|
+
});
|
|
75
|
+
expect(fake.writes()).toEqual(['POST config/notifications/endpoints/webhook']);
|
|
76
|
+
const held = (fake.secrets.get(PATH) ?? []).map(keyAndValue);
|
|
77
|
+
expect(held.map((s) => [s.name, unbase64(s.value ?? '')])).toEqual([['token', FIRST]]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a rotated secret is found by the seal and PUT alone — headers are not resent', async () => {
|
|
81
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
82
|
+
const fake = fakeNotify();
|
|
83
|
+
await withoutBao(async () => {
|
|
84
|
+
const engine = engineFor(fake);
|
|
85
|
+
await engine.deploy(hook());
|
|
86
|
+
setEnv({ HF_TEST_TOKEN: SECOND });
|
|
87
|
+
expect(await verifyDiff(engine)).toBe('update');
|
|
88
|
+
await engine.deploy(hook());
|
|
89
|
+
expect(leaks(engine.stored(), [FIRST, SECOND, HEADER])).toEqual([]);
|
|
90
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
91
|
+
});
|
|
92
|
+
const put = fake.calls.filter((call) => call.method === 'PUT');
|
|
93
|
+
expect(put).toHaveLength(1);
|
|
94
|
+
expect(put[0]?.pairs.map(([key]) => key)).toContain('secret');
|
|
95
|
+
expect(put[0]?.pairs.map(([key]) => key)).not.toContain('header');
|
|
96
|
+
expect(unbase64(keyAndValue(fake.secrets.get(PATH)?.[0] ?? '').value ?? '')).toBe(SECOND);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a header changed on the server is drift, found by digest, repaired from the env', async () => {
|
|
100
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
101
|
+
const fake = fakeNotify();
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineFor(fake);
|
|
104
|
+
await engine.deploy(hook());
|
|
105
|
+
const object = fake.objects.get(PATH) ?? {};
|
|
106
|
+
const edited = `name=X-Scope,value=${Buffer.from('edited-by-hand').toString('base64')}`;
|
|
107
|
+
fake.objects.set(PATH, { ...object, header: [(object['header'] as string[])[0], edited] });
|
|
108
|
+
expect(await verifyDiff(engine)).toBe('update');
|
|
109
|
+
await engine.deploy(hook());
|
|
110
|
+
expect(leaks(engine.stored(), [HEADER, 'edited-by-hand'])).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
113
|
+
expect(put?.pairs.map(([key]) => key)).toContain('header');
|
|
114
|
+
expect(put?.pairs.map(([key]) => key)).not.toContain('secret');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('without the variables', () => {
|
|
119
|
+
test('a plan is presence-only: noop, and a deploy writes nothing', async () => {
|
|
120
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
121
|
+
const fake = fakeNotify();
|
|
122
|
+
await withoutBao(async () => {
|
|
123
|
+
const engine = engineFor(fake);
|
|
124
|
+
await engine.deploy(hook());
|
|
125
|
+
setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined });
|
|
126
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
127
|
+
await engine.deploy(hook());
|
|
128
|
+
});
|
|
129
|
+
expect(fake.writes()).toEqual(['POST config/notifications/endpoints/webhook']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a comment change is still written — without the write-only groups, which the server keeps', async () => {
|
|
133
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
134
|
+
const fake = fakeNotify();
|
|
135
|
+
await withoutBao(async () => {
|
|
136
|
+
const engine = engineFor(fake);
|
|
137
|
+
await engine.deploy(hook());
|
|
138
|
+
setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined });
|
|
139
|
+
await engine.deploy(hook({ comment: 'pager' }));
|
|
140
|
+
});
|
|
141
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
142
|
+
expect(put?.pairs.map(([key]) => key).sort()).toEqual([
|
|
143
|
+
'body',
|
|
144
|
+
'comment',
|
|
145
|
+
'disable',
|
|
146
|
+
'method',
|
|
147
|
+
'url',
|
|
148
|
+
]);
|
|
149
|
+
expect(unbase64(keyAndValue(fake.secrets.get(PATH)?.[0] ?? '').value ?? '')).toBe(FIRST);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a write that needs a missing value fails by variable name, before any request', async () => {
|
|
153
|
+
const fake = fakeNotify();
|
|
154
|
+
let failure = '';
|
|
155
|
+
await withoutBao(async () => {
|
|
156
|
+
await engineFor(fake)
|
|
157
|
+
.deploy(hook())
|
|
158
|
+
.catch((error: unknown) => {
|
|
159
|
+
failure = String(error);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
expect(failure).toContain('HF_TEST_HEADER, HF_TEST_TOKEN');
|
|
163
|
+
expect(fake.writes()).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('adopting a webhook that already holds secrets', () => {
|
|
168
|
+
test('reads only: names match, values are unknown, nothing is written', async () => {
|
|
169
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
170
|
+
const fake = fakeNotify({
|
|
171
|
+
[PATH]: {
|
|
172
|
+
body: Buffer.from('[{"labels": {"alertname": "x"}}]').toString('base64'),
|
|
173
|
+
header: [
|
|
174
|
+
`name=Content-Type,value=${Buffer.from('application/json').toString('base64')}`,
|
|
175
|
+
`name=X-Scope,value=${Buffer.from(HEADER).toString('base64')}`,
|
|
176
|
+
],
|
|
177
|
+
method: 'post',
|
|
178
|
+
name: 'alertmanager',
|
|
179
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
fake.secrets.set(PATH, [`name=token,value=${Buffer.from('set-by-hand').toString('base64')}`]);
|
|
183
|
+
await withoutBao(async () => {
|
|
184
|
+
const engine = engineFor(fake);
|
|
185
|
+
expect((await engine.verify(hook())).rows[0]).toMatchObject({ diff: 'noop', ok: true });
|
|
186
|
+
await engine.deploy(hook());
|
|
187
|
+
expect(leaks(engine.stored(), [HEADER, 'set-by-hand', FIRST])).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
expect(fake.writes()).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|