@intentius/chant 0.66.1 → 0.67.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/behaviour-engine.d.ts +212 -0
- package/dist/behaviour-engine.d.ts.map +1 -0
- package/dist/behaviour-http.d.ts +1 -1
- package/dist/behaviour-http.d.ts.map +1 -1
- package/dist/behaviour-kinds.d.ts +220 -0
- package/dist/behaviour-kinds.d.ts.map +1 -0
- package/dist/behaviour-predict.d.ts +83 -0
- package/dist/behaviour-predict.d.ts.map +1 -0
- package/dist/behaviour-request.d.ts +141 -0
- package/dist/behaviour-request.d.ts.map +1 -0
- package/dist/behaviour.d.ts +7 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.d.ts +12 -5
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/behaviour-delta.test.ts +8 -8
- package/src/behaviour-engine.ts +478 -0
- package/src/behaviour-http.ts +1 -1
- package/src/behaviour-kinds.test.ts +235 -0
- package/src/behaviour-kinds.ts +324 -0
- package/src/behaviour-predict.ts +253 -0
- package/src/behaviour-request.ts +294 -0
- package/src/behaviour.ts +7 -7
- package/src/cli/handlers/scenario.test.ts +1 -1
- package/src/cli/handlers/scenario.ts +1 -1
- package/src/index.ts +1 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/scenario-cost.test.ts +2 -2
- package/src/op/activities/index.ts +1 -2
- package/src/op/activities/predict-behaviour.test.ts +20 -7
- package/src/op/activities/predict-behaviour.ts +32 -30
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `predictBehaviour()` — the fourth
|
|
3
|
+
* observation method (#2357, contract #2356).
|
|
4
|
+
*
|
|
5
|
+
* The whole method is four moves, in this order, and the order is the contract:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Screen the request.** `screenBehaviourRequest` and nothing else. It is
|
|
8
|
+
* the only entry point, and calling `assertNoCredentialInOptions` instead
|
|
9
|
+
* applies one rule of three — that was review finding F1 on #2365, where a
|
|
10
|
+
* `ghp_…` past the walk's depth budget and an `awsSecretAccessKey` in
|
|
11
|
+
* `props` both went out with the request.
|
|
12
|
+
* 2. **Resolve the engine.** `behaviourEngineFrom`, walking
|
|
13
|
+
* `CHANT_BEHAVIOUR_ENGINE_BEHAVIOUR_SCOPE` → `CHANT_BEHAVIOUR_ENGINE` →
|
|
14
|
+
* `BEHAVIOUR_ENGINE`. Nothing named means `noBehaviourEngineRefusal`, and
|
|
15
|
+
* it happens **before** anything is priced: a lexicon that prices first and
|
|
16
|
+
* checks the engine afterwards has already decided what zero means.
|
|
17
|
+
* 3. **Build the request.** `buildEngineRequest`, offline and pure
|
|
18
|
+
* (`./request.ts`).
|
|
19
|
+
* 4. **Ask, and translate the answer.** One {@link EngineOutcome} onto the
|
|
20
|
+
* contract's builders. A refusal comes back from the transport already
|
|
21
|
+
* built — the contract's transport (#2373) names the condition and the
|
|
22
|
+
* variable where the wire was seen — and is returned as it stands; this
|
|
23
|
+
* file holds no switch over causes. No figure is ever computed here either:
|
|
24
|
+
* this file has no arithmetic on money in it at all, and that is not an
|
|
25
|
+
* accident. The epic's first rule is that a missing engine produces a
|
|
26
|
+
* refusal and "never a locally faked number", and the surest way to keep
|
|
27
|
+
* that true is for the code that would have faked it not to exist.
|
|
28
|
+
*
|
|
29
|
+
* ## Every entity lands somewhere
|
|
30
|
+
*
|
|
31
|
+
* `behaviourReport` refuses a report that leaves a name in neither map, so the
|
|
32
|
+
* loop below has no `continue` that drops one. Four ways an entity can end up
|
|
33
|
+
* unpredicted, and each names a different thing:
|
|
34
|
+
*
|
|
35
|
+
* | Outcome | Reason | Means |
|
|
36
|
+
* |---|---|---|
|
|
37
|
+
* | the coverage table declares it unmapped | `unsupported-kind` | chant looked, and this kind has no rate |
|
|
38
|
+
* | the coverage table has never seen it | `unsupported-kind` | chant has not looked; the detail says so and where to add the row |
|
|
39
|
+
* | the engine declined it | `read-failed` | chant asked and the engine could not answer |
|
|
40
|
+
* | the engine answered about neither | `read-failed` | the engine lost it, and this says so rather than hiding it |
|
|
41
|
+
*
|
|
42
|
+
* The last row is the one that would otherwise be silent. An engine that
|
|
43
|
+
* returns figures for eleven of twelve nodes and mentions the twelfth nowhere
|
|
44
|
+
* is a bug in the engine, and an estate that quietly renders eleven nodes is
|
|
45
|
+
* how it stays a bug.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
behaviourReport,
|
|
50
|
+
behaviourEngineFrom,
|
|
51
|
+
noBehaviourEngineRefusal,
|
|
52
|
+
predictedRate,
|
|
53
|
+
screenBehaviourRequest,
|
|
54
|
+
unreachableBehaviourEngineRefusal,
|
|
55
|
+
type BehaviourResult,
|
|
56
|
+
type PredictBehaviourOptions,
|
|
57
|
+
type PredictedBehaviour,
|
|
58
|
+
type UnpredictedEntity,
|
|
59
|
+
} from "./behaviour";
|
|
60
|
+
import { buildEngineRequest } from "./behaviour-request";
|
|
61
|
+
import type { BehaviourKinds } from "./behaviour-kinds";
|
|
62
|
+
import { BEHAVIOUR_SCOPE, defaultConnect, type EngineConnect, type EngineFigure } from "./behaviour-engine";
|
|
63
|
+
|
|
64
|
+
export { BEHAVIOUR_SCOPE };
|
|
65
|
+
|
|
66
|
+
/** What {@link createBehaviourPredict} needs that the method's own options do not carry. */
|
|
67
|
+
export interface BehaviourPredictDeps {
|
|
68
|
+
/**
|
|
69
|
+
* The coverage rows to resolve entity types against — every configured
|
|
70
|
+
* lexicon's `behaviourKinds`, in the order the plugins were loaded.
|
|
71
|
+
*
|
|
72
|
+
* An empty list is legal and means every declared entity is withheld as
|
|
73
|
+
* `unknown-type`: nothing has said what any of them are. That is a report
|
|
74
|
+
* about an estate nobody has rows for, not a refusal, because the engine was
|
|
75
|
+
* reachable and answered; the distinction is the whole point of the refusal
|
|
76
|
+
* arm.
|
|
77
|
+
*/
|
|
78
|
+
kinds?: readonly BehaviourKinds[];
|
|
79
|
+
/** The environment the engine address is resolved from. Defaults to the process's. */
|
|
80
|
+
env?: Record<string, string | undefined>;
|
|
81
|
+
/**
|
|
82
|
+
* How an address becomes an engine. Defaults to `./behaviour-engine.ts`'s chooser,
|
|
83
|
+
* which dials a URL through core's HTTP transport and a bare address as a
|
|
84
|
+
* command on PATH.
|
|
85
|
+
*/
|
|
86
|
+
connect?: EngineConnect;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build core's `predictBehaviour`, with the environment and the
|
|
91
|
+
* transport injected.
|
|
92
|
+
*
|
|
93
|
+
* Injected rather than read from module scope so a test can drive the whole
|
|
94
|
+
* method — screen, resolve, serialize, translate — against a fixture engine
|
|
95
|
+
* without touching `process.env`, which is what the shared conformance suite's
|
|
96
|
+
* probes need in order to ask the same request twice and compare.
|
|
97
|
+
*/
|
|
98
|
+
export function createBehaviourPredict(
|
|
99
|
+
deps: BehaviourPredictDeps = {},
|
|
100
|
+
): (options: PredictBehaviourOptions) => Promise<BehaviourResult> {
|
|
101
|
+
const env = deps.env ?? process.env;
|
|
102
|
+
const kinds = deps.kinds ?? [];
|
|
103
|
+
const connect = deps.connect ?? defaultConnect;
|
|
104
|
+
|
|
105
|
+
return async function predictBehaviour(options: PredictBehaviourOptions): Promise<BehaviourResult> {
|
|
106
|
+
const unsafe = screenBehaviourRequest(BEHAVIOUR_SCOPE, options);
|
|
107
|
+
if (unsafe) return unsafe;
|
|
108
|
+
|
|
109
|
+
const endpoint = behaviourEngineFrom(BEHAVIOUR_SCOPE, env);
|
|
110
|
+
if (!endpoint) return noBehaviourEngineRefusal(BEHAVIOUR_SCOPE);
|
|
111
|
+
|
|
112
|
+
const engine = connect(endpoint, env);
|
|
113
|
+
if (!engine) {
|
|
114
|
+
return unreachableBehaviourEngineRefusal(
|
|
115
|
+
BEHAVIOUR_SCOPE,
|
|
116
|
+
endpoint,
|
|
117
|
+
"no transport speaks that address — a http(s) URL is dialled with a bearer token, and a bare " +
|
|
118
|
+
"address is run as a command on PATH; any other scheme has no transport yet",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const request = buildEngineRequest(options, kinds);
|
|
123
|
+
const outcome = await engine.predict(request);
|
|
124
|
+
|
|
125
|
+
// Built where the wire was seen, and returned as it stands. The three
|
|
126
|
+
// engine-answered causes and the token cases are mapped once, in the
|
|
127
|
+
// contract's transport, rather than re-derived here from a cause.
|
|
128
|
+
if (!outcome.ok) return outcome.refusal;
|
|
129
|
+
|
|
130
|
+
const { answer } = outcome;
|
|
131
|
+
// `Object.create(null)`, not `{}`. An entity named `constructor` or
|
|
132
|
+
// `toString` writes onto `Object.prototype`'s members on a plain literal,
|
|
133
|
+
// and one named `__proto__` sets the prototype instead of adding a key, so
|
|
134
|
+
// the entry vanishes and `behaviourReport`'s totality check fires on an
|
|
135
|
+
// entity this function believed it had reported. `coverageFor` already
|
|
136
|
+
// guards its own lookups this way (`./mapping.ts`); these are the same
|
|
137
|
+
// hazard on the writing side.
|
|
138
|
+
const entities: Record<string, PredictedBehaviour> = Object.create(null) as Record<string, PredictedBehaviour>;
|
|
139
|
+
const unpredicted: Record<string, UnpredictedEntity> = Object.create(null) as Record<string, UnpredictedEntity>;
|
|
140
|
+
|
|
141
|
+
for (const held of request.withheld) {
|
|
142
|
+
unpredicted[held.name] = {
|
|
143
|
+
...(held.entityType === "(undeclared)" ? {} : { type: held.entityType }),
|
|
144
|
+
reason: "unsupported-kind",
|
|
145
|
+
detail: held.detail,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const node of request.nodes) {
|
|
150
|
+
// `hasOwnProperty` for the same reason: `answer.declined` comes from
|
|
151
|
+
// `JSON.parse`, so `declined["constructor"]` is a function rather than
|
|
152
|
+
// `undefined` and the `!== undefined` guard below missed it.
|
|
153
|
+
const declined = has(answer.declined, node.name) ? answer.declined![node.name] : undefined;
|
|
154
|
+
if (declined !== undefined) {
|
|
155
|
+
unpredicted[node.name] = {
|
|
156
|
+
type: node.entityType,
|
|
157
|
+
reason: "read-failed",
|
|
158
|
+
detail: `${answer.engine} was sent ${node.name} as a ${node.kind} and declined it: ${declined}`,
|
|
159
|
+
};
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const figure = has(answer.figures, node.name) ? answer.figures[node.name] : undefined;
|
|
163
|
+
if (figure === undefined) {
|
|
164
|
+
unpredicted[node.name] = {
|
|
165
|
+
type: node.entityType,
|
|
166
|
+
reason: "read-failed",
|
|
167
|
+
detail:
|
|
168
|
+
`${answer.engine} was sent ${node.name} and its answer names it in neither its figures nor ` +
|
|
169
|
+
"its declined list. An engine that loses a node is reported, not rendered as an estate one " +
|
|
170
|
+
"node smaller.",
|
|
171
|
+
};
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
entities[node.name] = block(options.traffic, figure, answer);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return behaviourReport(
|
|
178
|
+
options,
|
|
179
|
+
{
|
|
180
|
+
engine: answer.engine,
|
|
181
|
+
version: answer.version,
|
|
182
|
+
...(answer.total
|
|
183
|
+
? { total: predictedRate(answer.total.perHour, answer.total.currency) }
|
|
184
|
+
: {}),
|
|
185
|
+
},
|
|
186
|
+
entities,
|
|
187
|
+
unpredicted,
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** An own key, not a prototype member. See the accumulators above. */
|
|
193
|
+
function has(map: Record<string, unknown> | undefined, key: string): boolean {
|
|
194
|
+
return map !== undefined && Object.prototype.hasOwnProperty.call(map, key);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One engine figure as a contract block.
|
|
199
|
+
*
|
|
200
|
+
* `at` comes from the request rather than from the engine's answer. The engine
|
|
201
|
+
* was asked at one level and `behaviourReport` refuses a block priced at a
|
|
202
|
+
* level the run did not ask for, so echoing the engine's own idea of the level
|
|
203
|
+
* would turn an engine that quietly substituted a level it liked better into a
|
|
204
|
+
* report that agrees with itself and answers the wrong question.
|
|
205
|
+
*
|
|
206
|
+
* `headroom` is copied axis by axis. An axis the engine did not model is
|
|
207
|
+
* **absent**, never `0` — zero headroom means saturated, which is the opposite
|
|
208
|
+
* claim, and `BehaviourHeadroom` is a union requiring at least one axis so a
|
|
209
|
+
* figure with neither is a type error rather than a block behold drops.
|
|
210
|
+
*/
|
|
211
|
+
function block(
|
|
212
|
+
traffic: string,
|
|
213
|
+
figure: EngineFigure,
|
|
214
|
+
answer: { engine: string; version: string; tolerance: string; basis: PredictedBehaviour["provenance"]["basis"] },
|
|
215
|
+
): PredictedBehaviour {
|
|
216
|
+
const cpu = figure.headroom?.cpu;
|
|
217
|
+
const latency = figure.headroom?.latency;
|
|
218
|
+
// Built axis by axis, and the cast the first version used here was a lie:
|
|
219
|
+
// `EngineFigure.headroom` is optional, so a figure stating none produced
|
|
220
|
+
// `{ latency: undefined }` — an object satisfying `BehaviourHeadroom`
|
|
221
|
+
// structurally and carrying no axis at all, which behold drops. The doc
|
|
222
|
+
// claiming a type error caught this was false. `parseEngineAnswer` now
|
|
223
|
+
// refuses such a figure before it reaches here; this stays correct anyway,
|
|
224
|
+
// because a lexicon should not depend on its own validator having run.
|
|
225
|
+
const headroom =
|
|
226
|
+
cpu !== undefined
|
|
227
|
+
? { cpu, ...(latency !== undefined ? { latency } : {}) }
|
|
228
|
+
: { latency: latency as number };
|
|
229
|
+
if (cpu === undefined && latency === undefined) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`predictBehaviour: the engine's figure for this entity carries neither a cpu nor a latency ` +
|
|
232
|
+
"headroom axis, and parseEngineAnswer should have refused it.",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
at: { traffic },
|
|
237
|
+
cost: predictedRate(figure.perHour, figure.currency),
|
|
238
|
+
headroom,
|
|
239
|
+
errorRate: figure.errorRate,
|
|
240
|
+
resilience: {
|
|
241
|
+
failure: figure.resilience.failure,
|
|
242
|
+
verdict: figure.resilience.verdict,
|
|
243
|
+
...(figure.resilience.note ? { note: figure.resilience.note } : {}),
|
|
244
|
+
},
|
|
245
|
+
...(figure.rightSize ? { rightSize: figure.rightSize } : {}),
|
|
246
|
+
provenance: {
|
|
247
|
+
engine: answer.engine,
|
|
248
|
+
version: answer.version,
|
|
249
|
+
tolerance: answer.tolerance,
|
|
250
|
+
basis: answer.basis,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's request, built offline from chant's typed source (#2357).
|
|
3
|
+
*
|
|
4
|
+
* `chant build` reaches no network and is byte-identical on re-run
|
|
5
|
+
* (`packages/core/src/components/verbs/reproducibility.ts`, the
|
|
6
|
+
* `deterministic-synthesis` basis). The request an engine is handed is built
|
|
7
|
+
* the same way and holds itself to the same property, for a reason that is not
|
|
8
|
+
* tidiness: the epic wants the declared prediction and the live prediction
|
|
9
|
+
* shown as a delta, and a delta is only a statement about the estate if both
|
|
10
|
+
* sides were assembled the same way from the same source. A request carrying a
|
|
11
|
+
* timestamp, an unsorted map or a floating-point rendering that depends on the
|
|
12
|
+
* platform makes every re-run look like drift.
|
|
13
|
+
*
|
|
14
|
+
* So everything here is pure. No clock, no `Math.random`, no environment, no
|
|
15
|
+
* filesystem, no network. {@link renderEngineRequest} is the only place bytes
|
|
16
|
+
* are produced, and it produces them through a canonical writer that sorts
|
|
17
|
+
* every object key and every list.
|
|
18
|
+
*
|
|
19
|
+
* ## What is in the request, and why the withheld list is in it too
|
|
20
|
+
*
|
|
21
|
+
* Nodes, edges, the traffic level, the caller's edge-coverage claim — and
|
|
22
|
+
* `withheld`, the entities the caller asked about that this lexicon is not
|
|
23
|
+
* asking the engine about, each with the reason from the coverage table.
|
|
24
|
+
*
|
|
25
|
+
* That last one is the same argument `edgeCoverage` won on the contract
|
|
26
|
+
* (#2365, review finding 6). An engine handed twelve nodes cannot tell whether
|
|
27
|
+
* the estate has twelve or twenty, and a resilience verdict computed over a
|
|
28
|
+
* graph with eight nodes missing is a confident answer to a question nobody
|
|
29
|
+
* asked. Omitting them would make the request's own coverage invisible one
|
|
30
|
+
* level down from where the contract made it visible.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { IREdge } from "./graph-ir";
|
|
34
|
+
import type { BehaviourEdgeCoverage, PredictBehaviourOptions } from "./behaviour";
|
|
35
|
+
import {
|
|
36
|
+
byCodeUnit,
|
|
37
|
+
coverageFor,
|
|
38
|
+
coverageLabel,
|
|
39
|
+
ownerOf,
|
|
40
|
+
unmappedDetail,
|
|
41
|
+
type BehaviourKinds,
|
|
42
|
+
type EngineKind,
|
|
43
|
+
} from "./behaviour-kinds";
|
|
44
|
+
|
|
45
|
+
/** The wire version. Bumped when the shape changes, the way `behaviour: "v1"` is. */
|
|
46
|
+
export const BEHAVIOUR_REQUEST_VERSION = "behaviour/v1" as const;
|
|
47
|
+
|
|
48
|
+
/** One entity, in the engine's four words: a kind, a provider, a region and a size. */
|
|
49
|
+
export interface EngineNode {
|
|
50
|
+
/** The chant entity name, which is also the key `edges` uses. */
|
|
51
|
+
name: string;
|
|
52
|
+
/** The declared type it was translated from, so an engine can say what it choked on. */
|
|
53
|
+
entityType: string;
|
|
54
|
+
/**
|
|
55
|
+
* The provider's own type, where the declared type does not carry it: a
|
|
56
|
+
* terraform `resource` block is `Terraform::Resource` whatever it declares,
|
|
57
|
+
* and `aws_instance` is what an engine can say it choked on (#2360).
|
|
58
|
+
*/
|
|
59
|
+
resourceType?: string;
|
|
60
|
+
kind: EngineKind;
|
|
61
|
+
provider: string;
|
|
62
|
+
/** Absent where neither the entity nor the caller states one. Never defaulted. */
|
|
63
|
+
region?: string;
|
|
64
|
+
/**
|
|
65
|
+
* The size in the provider's own words, verbatim. A string where the
|
|
66
|
+
* declaration holds one; absent where it holds none. Never parsed, never
|
|
67
|
+
* converted, never guessed — see the note in `./mapping.ts`.
|
|
68
|
+
*/
|
|
69
|
+
size?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One edge, flattened from `IREdge` to the two fields an engine reads. */
|
|
73
|
+
export interface EngineEdge {
|
|
74
|
+
from: string;
|
|
75
|
+
to: string;
|
|
76
|
+
/** The referring attribute, where the edge came from a declared reference. */
|
|
77
|
+
via?: string;
|
|
78
|
+
/** The referenced attribute on the far end. */
|
|
79
|
+
toAttr?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One entity the caller asked about that is not on the wire, and why. */
|
|
83
|
+
export interface WithheldEntity {
|
|
84
|
+
name: string;
|
|
85
|
+
entityType: string;
|
|
86
|
+
/** The provider's own type, for a terraform block. See {@link EngineNode.resourceType}. */
|
|
87
|
+
resourceType?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Which of the coverage table's three not-sent verdicts this is:
|
|
90
|
+
* `declared-unmapped` (looked at, and it carries no rate),
|
|
91
|
+
* `provider-not-modelled` (a substrate a lexicon states it does not cover), or
|
|
92
|
+
* `unknown-type` (a modelled provider's type with no row — the only one that
|
|
93
|
+
* is a defect). See `./mapping.ts` for why they are not one.
|
|
94
|
+
*/
|
|
95
|
+
status: "declared-unmapped" | "provider-not-modelled" | "unknown-type";
|
|
96
|
+
detail: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What goes on the wire. */
|
|
100
|
+
export interface EngineRequest {
|
|
101
|
+
request: typeof BEHAVIOUR_REQUEST_VERSION;
|
|
102
|
+
/** The level to predict at, verbatim from the caller. chant parses nothing. */
|
|
103
|
+
traffic: string;
|
|
104
|
+
/** Present only when the caller named one; the engine's own default is not chant's to pick. */
|
|
105
|
+
region?: string;
|
|
106
|
+
nodes: EngineNode[];
|
|
107
|
+
edges: EngineEdge[];
|
|
108
|
+
/** The caller's claim about how complete `edges` is, passed through unchanged. */
|
|
109
|
+
coverage: BehaviourEdgeCoverage;
|
|
110
|
+
withheld: WithheldEntity[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read a dotted path out of a declared property bag. Returns `undefined` for any miss. */
|
|
114
|
+
function readPath(props: Record<string, unknown>, path: string): unknown {
|
|
115
|
+
let cursor: unknown = props;
|
|
116
|
+
for (const segment of path.split(".")) {
|
|
117
|
+
if (typeof cursor !== "object" || cursor === null) return undefined;
|
|
118
|
+
cursor = (cursor as Record<string, unknown>)[segment];
|
|
119
|
+
}
|
|
120
|
+
return cursor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A declared size, as a string, or absent.
|
|
125
|
+
*
|
|
126
|
+
* `sizeType` says which type the row's property holds, and a value of the
|
|
127
|
+
* other type is **absent** rather than coerced. Without that, a
|
|
128
|
+
* `DBInstanceClass: 42` — a wrong-typed declaration, or a parameter that
|
|
129
|
+
* folded to a number — rendered as `"42"` and went to the engine to be matched
|
|
130
|
+
* against a price table of instance-class names it does not appear in. That is
|
|
131
|
+
* the outcome the paragraph below is written against, arriving through the
|
|
132
|
+
* door the paragraph left open.
|
|
133
|
+
*
|
|
134
|
+
* Anything else — an object, an array, an unresolved intrinsic, a `NaN` — is
|
|
135
|
+
* absent too, because a size an engine cannot read is worse than no size: it
|
|
136
|
+
* will either be ignored silently or matched against nothing.
|
|
137
|
+
*
|
|
138
|
+
* A terraform block's unresolved reference is a string, `"${var.size}"`, and
|
|
139
|
+
* is absent for the same reason a CloudFormation intrinsic object is: it is
|
|
140
|
+
* the name of a value, not the value (#2360).
|
|
141
|
+
*/
|
|
142
|
+
export function sizeOf(
|
|
143
|
+
props: Record<string, unknown>,
|
|
144
|
+
sizeProp: string | undefined,
|
|
145
|
+
sizeType?: "string" | "number",
|
|
146
|
+
): string | undefined {
|
|
147
|
+
if (!sizeProp) return undefined;
|
|
148
|
+
const raw = readPath(props, sizeProp);
|
|
149
|
+
if (sizeType === "number") {
|
|
150
|
+
return typeof raw === "number" && Number.isFinite(raw) ? String(raw) : undefined;
|
|
151
|
+
}
|
|
152
|
+
// `string`, and the default for a row naming a property and no type.
|
|
153
|
+
return isLiteral(raw) ? raw : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A non-empty string that is a value rather than a terraform `${…}` reference to one. */
|
|
157
|
+
function isLiteral(raw: unknown): raw is string {
|
|
158
|
+
return typeof raw === "string" && raw.length > 0 && !raw.includes("${");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Translate one request's worth of chant entities and edges into the engine's
|
|
163
|
+
* shape. Pure, and the same input gives the same output forever.
|
|
164
|
+
*
|
|
165
|
+
* Every name in `entityNames` comes out in exactly one of `nodes` or
|
|
166
|
+
* `withheld`. The caller in `./predict-behaviour.ts` depends on that to satisfy
|
|
167
|
+
* `behaviourReport`'s totality check, and a name the caller asked about that is
|
|
168
|
+
* missing from `options.entities` is `withheld` with an `unknown-type` detail
|
|
169
|
+
* rather than dropped — a `continue` in this loop is the exact silent hole the
|
|
170
|
+
* contract's totality refusal exists to catch.
|
|
171
|
+
*/
|
|
172
|
+
export function buildEngineRequest(
|
|
173
|
+
options: Pick<
|
|
174
|
+
PredictBehaviourOptions,
|
|
175
|
+
"entityNames" | "entities" | "edges" | "edgeCoverage" | "traffic" | "region"
|
|
176
|
+
>,
|
|
177
|
+
kinds: readonly BehaviourKinds[],
|
|
178
|
+
): EngineRequest {
|
|
179
|
+
const nodes: EngineNode[] = [];
|
|
180
|
+
const withheld: WithheldEntity[] = [];
|
|
181
|
+
|
|
182
|
+
for (const name of [...options.entityNames].sort(byCodeUnit)) {
|
|
183
|
+
const declared = options.entities.get(name);
|
|
184
|
+
if (!declared) {
|
|
185
|
+
withheld.push({
|
|
186
|
+
name,
|
|
187
|
+
entityType: "(undeclared)",
|
|
188
|
+
status: "unknown-type",
|
|
189
|
+
detail:
|
|
190
|
+
`${name} was named in entityNames and is not in the entities map, so this lexicon has ` +
|
|
191
|
+
"nothing to translate. It is reported rather than dropped: an entity that vanished " +
|
|
192
|
+
"between the build and the request is a defect in the caller, and a silent omission " +
|
|
193
|
+
"hides it behind an estate that looks smaller.",
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const verdict = coverageFor(kinds, declared.entityType, declared.props);
|
|
198
|
+
// Where a lexicon keys its rows on something other than the entity type,
|
|
199
|
+
// that key rides beside the entity type on the wire and in the withheld
|
|
200
|
+
// list: `Terraform::Resource` alone names nothing an engine or a reader
|
|
201
|
+
// can act on (#2360), and its provider type is what does. Asked of the
|
|
202
|
+
// owning contributor rather than special-cased here (#2382).
|
|
203
|
+
const owner = ownerOf(kinds, declared.entityType);
|
|
204
|
+
const resolved = owner?.resolveType?.(declared.entityType, declared.props);
|
|
205
|
+
const resourceType = resolved && resolved !== declared.entityType ? resolved : undefined;
|
|
206
|
+
if (verdict.status !== "mapped") {
|
|
207
|
+
withheld.push({
|
|
208
|
+
name,
|
|
209
|
+
entityType: declared.entityType,
|
|
210
|
+
...(resourceType ? { resourceType } : {}),
|
|
211
|
+
status: verdict.status,
|
|
212
|
+
detail: unmappedDetail(
|
|
213
|
+
coverageLabel(kinds, declared.entityType, declared.props),
|
|
214
|
+
verdict,
|
|
215
|
+
owner,
|
|
216
|
+
),
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const { mapping } = verdict;
|
|
221
|
+
const declaredRegion = mapping.regionProp
|
|
222
|
+
? readPath(declared.props, mapping.regionProp)
|
|
223
|
+
: undefined;
|
|
224
|
+
const region = isLiteral(declaredRegion) ? declaredRegion : options.region;
|
|
225
|
+
const size = sizeOf(declared.props, mapping.sizeProp, mapping.sizeType);
|
|
226
|
+
nodes.push({
|
|
227
|
+
name,
|
|
228
|
+
entityType: declared.entityType,
|
|
229
|
+
...(resourceType ? { resourceType } : {}),
|
|
230
|
+
kind: mapping.kind,
|
|
231
|
+
provider: mapping.provider,
|
|
232
|
+
...(region ? { region } : {}),
|
|
233
|
+
...(size ? { size } : {}),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const edges: EngineEdge[] = options.edges
|
|
238
|
+
.map((edge: IREdge) => ({
|
|
239
|
+
from: edge.from,
|
|
240
|
+
to: edge.to,
|
|
241
|
+
...(edge.viaAttr ? { via: edge.viaAttr } : {}),
|
|
242
|
+
...(edge.toAttr ? { toAttr: edge.toAttr } : {}),
|
|
243
|
+
}))
|
|
244
|
+
// By code unit, like the node order above. `localeCompare` reads the
|
|
245
|
+
// ambient locale, so two machines with different `LANG` values produced
|
|
246
|
+
// two different byte streams from one estate — in a module whose whole
|
|
247
|
+
// claim is that its bytes are a function of its content and nothing else.
|
|
248
|
+
.sort(
|
|
249
|
+
(a, b) =>
|
|
250
|
+
byCodeUnit(a.from, b.from) ||
|
|
251
|
+
byCodeUnit(a.to, b.to) ||
|
|
252
|
+
byCodeUnit(a.via ?? "", b.via ?? "") ||
|
|
253
|
+
byCodeUnit(a.toAttr ?? "", b.toAttr ?? ""),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
request: BEHAVIOUR_REQUEST_VERSION,
|
|
258
|
+
traffic: options.traffic,
|
|
259
|
+
...(options.region ? { region: options.region } : {}),
|
|
260
|
+
nodes,
|
|
261
|
+
edges,
|
|
262
|
+
coverage: options.edgeCoverage,
|
|
263
|
+
withheld,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Canonical JSON: object keys sorted, arrays in the order they were built,
|
|
269
|
+
* two-space indent, one trailing newline.
|
|
270
|
+
*
|
|
271
|
+
* Key order is where a "deterministic" serializer usually is not. `JSON.stringify`
|
|
272
|
+
* preserves insertion order, and insertion order here follows the order a
|
|
273
|
+
* property bag was assembled in, which follows discovery order, which follows
|
|
274
|
+
* the filesystem. Sorting the keys makes the bytes a function of the content
|
|
275
|
+
* and nothing else — which is what `chant build`'s own reproducibility claim
|
|
276
|
+
* means, and what makes a re-run comparable rather than merely re-run.
|
|
277
|
+
*/
|
|
278
|
+
export function renderEngineRequest(request: EngineRequest): string {
|
|
279
|
+
return `${JSON.stringify(canonical(request), null, 2)}\n`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function canonical(value: unknown): unknown {
|
|
283
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
284
|
+
if (typeof value === "object" && value !== null) {
|
|
285
|
+
const out: Record<string, unknown> = {};
|
|
286
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
287
|
+
const inner = (value as Record<string, unknown>)[key];
|
|
288
|
+
if (inner === undefined) continue;
|
|
289
|
+
out[key] = canonical(inner);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
package/src/behaviour.ts
CHANGED
|
@@ -146,8 +146,8 @@
|
|
|
146
146
|
* ## The transport is part of the contract (#2373, decided in #2359)
|
|
147
147
|
*
|
|
148
148
|
* The first version of this module named the address chain, said the address
|
|
149
|
-
* may be a URL, a socket path or a command on `PATH`, and stopped.
|
|
150
|
-
* (#2357) then declared a private `BehaviourEngine` and its own mapping from
|
|
149
|
+
* may be a URL, a socket path or a command on `PATH`, and stopped. The first
|
|
150
|
+
* consumer (#2357) then declared a private `BehaviourEngine` and its own mapping from
|
|
151
151
|
* what the wire said to which of the three engine-answered refusals to build.
|
|
152
152
|
* With a second implementation to generalise from, the seam is here:
|
|
153
153
|
* {@link BehaviourTransport} is one method, `send(body)`, taking the request
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
* is that switch, written once.
|
|
174
174
|
*
|
|
175
175
|
* What the wire says maps to the causes above as follows, and
|
|
176
|
-
* `./behaviour-http.ts` (the first adapter) and
|
|
176
|
+
* `./behaviour-http.ts` (the first adapter) and `./behaviour-engine.ts`'s command transport both
|
|
177
177
|
* hold to it: an HTTP 402 is `engine-out-of-credit`; a 429 is
|
|
178
178
|
* `engine-over-quota`; a connection refused, a timeout, a 5xx, a redirect, or
|
|
179
179
|
* an answer that is not JSON is `engine-unreachable`; an unset address is
|
|
@@ -1256,7 +1256,7 @@ export function overQuotaBehaviourEngineRefusal(
|
|
|
1256
1256
|
* refusal ready to be returned from `predictBehaviour` as it stands.
|
|
1257
1257
|
*
|
|
1258
1258
|
* The answer is text rather than a parsed object because what the text means
|
|
1259
|
-
* is the
|
|
1259
|
+
* is the wire version (`behaviour/v1`), and `./behaviour-engine.ts` is what
|
|
1260
1260
|
* validates it — an answer that fails that validation is
|
|
1261
1261
|
* {@link unreachableBehaviourEngineRefusal} with a detail naming the field,
|
|
1262
1262
|
* built by the lexicon, since the transport has nothing to say about it.
|
|
@@ -1268,7 +1268,7 @@ export type BehaviourTransportOutcome =
|
|
|
1268
1268
|
/**
|
|
1269
1269
|
* The seam between a lexicon and whatever answers its request (#2373).
|
|
1270
1270
|
*
|
|
1271
|
-
* One method. `body` is the request as
|
|
1271
|
+
* One method. `body` is the request as it was rendered —
|
|
1272
1272
|
* canonical JSON, say — and the transport carries it to the address it was
|
|
1273
1273
|
* built for and brings back what came out, or a refusal naming why nothing
|
|
1274
1274
|
* did. A transport is built knowing the lexicon and the endpoint, which is
|
|
@@ -1276,7 +1276,7 @@ export type BehaviourTransportOutcome =
|
|
|
1276
1276
|
* better than returning a cause.
|
|
1277
1277
|
*
|
|
1278
1278
|
* Two ship today: `./behaviour-http.ts` dials a URL with a bearer token, and
|
|
1279
|
-
*
|
|
1279
|
+
* `./behaviour-engine.ts`'s `commandTransport` spawns a command on `PATH` with
|
|
1280
1280
|
* {@link behaviourEngineChildEnvironment}. A lexicon picks one by the shape of
|
|
1281
1281
|
* the address and does not otherwise know which it got.
|
|
1282
1282
|
*/
|
|
@@ -1323,7 +1323,7 @@ export function behaviourWireRefusal(
|
|
|
1323
1323
|
* and the child holds `AWS_SECRET_ACCESS_KEY` anyway. So a transport that
|
|
1324
1324
|
* spawns builds the environment from this and nothing more — `PATH` because
|
|
1325
1325
|
* the address is resolved against it, and no allowlist beyond that, because
|
|
1326
|
-
* every name added is a name a credential could be sitting under.
|
|
1326
|
+
* every name added is a name a credential could be sitting under. The
|
|
1327
1327
|
* command transport pins this by test (#2372); this is the rule it pins.
|
|
1328
1328
|
*/
|
|
1329
1329
|
export function behaviourEngineChildEnvironment(
|
|
@@ -450,7 +450,7 @@ describe("runScenarioCheck", () => {
|
|
|
450
450
|
|
|
451
451
|
test("a fixture whose recorded prediction is a refusal fails with the refusal's own cause", async () => {
|
|
452
452
|
const fixturePath = await writeFixture(
|
|
453
|
-
snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("
|
|
453
|
+
snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("chant") }),
|
|
454
454
|
);
|
|
455
455
|
const scenario = Scenario("stays under a dollar an hour", {
|
|
456
456
|
given: snapshot(fixturePath),
|
|
@@ -166,7 +166,7 @@ interface GivenResolution {
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Read the `behaviour` block off the fixture's snapshots. One block, not one
|
|
169
|
-
* per lexicon: a prediction is over the whole estate (
|
|
169
|
+
* per lexicon: a prediction is over the whole estate (it reads every
|
|
170
170
|
* lexicon's entities and holds none of its own), so it belongs to no single
|
|
171
171
|
* lexicon's file, and two files carrying two different blocks is a fixture
|
|
172
172
|
* that answers twice. Validated on arrival — a hand-edited fixture with a
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export * from "./apply";
|
|
|
64
64
|
export * from "./deep-observation";
|
|
65
65
|
export * from "./behaviour";
|
|
66
66
|
export * from "./behaviour-http";
|
|
67
|
+
export * from "./behaviour-kinds";
|
|
67
68
|
export * from "./behaviour-delta";
|
|
68
69
|
export * from "./claimed-fields";
|
|
69
70
|
export * from "./fold-provenance";
|
package/src/lexicon.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
|
24
24
|
import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
|
|
25
25
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
26
26
|
import type { BehaviourResult, PredictBehaviourOptions } from "./behaviour";
|
|
27
|
+
import type { BehaviourKinds } from "./behaviour-kinds";
|
|
27
28
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
28
29
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
29
30
|
import type { CommandGroup } from "./cli/command-group";
|
|
@@ -1459,6 +1460,25 @@ export interface LexiconPlugin {
|
|
|
1459
1460
|
*/
|
|
1460
1461
|
predictBehaviour?(options: PredictBehaviourOptions): Promise<BehaviourResult>;
|
|
1461
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* What this lexicon's own entity types are, to an engine that prices them
|
|
1465
|
+
* (#2382). Rows, not a capability: core resolves and predicts, and a lexicon
|
|
1466
|
+
* says only what its types mean.
|
|
1467
|
+
*
|
|
1468
|
+
* Optional in the way rows can safely be and a capability cannot. A lexicon
|
|
1469
|
+
* that is not installed declared no entities of its types, so its absent
|
|
1470
|
+
* rows describe an absent part of the estate; whereas a *capability* behind
|
|
1471
|
+
* an optional install leaves a consumer with entities nobody will say
|
|
1472
|
+
* anything about, and no refusal naming why — an outcome
|
|
1473
|
+
* `packages/core/src/behaviour.ts` never defined.
|
|
1474
|
+
*
|
|
1475
|
+
* Three things can be said, and saying none is itself a statement that this
|
|
1476
|
+
* lexicon has not been considered: `mapped` rows price a type, `unmapped`
|
|
1477
|
+
* rows state that a real type carries no rate and why, and `nothingPriced`
|
|
1478
|
+
* says it once for the whole substrate — a CI workflow is not an estate.
|
|
1479
|
+
*/
|
|
1480
|
+
behaviourKinds?: BehaviourKinds;
|
|
1481
|
+
|
|
1462
1482
|
/**
|
|
1463
1483
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
1464
1484
|
*
|