@intentius/chant-lexicon-k8s 0.58.0 → 0.60.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/api/fake-cluster.d.ts +7 -6
- package/dist/api/fake-cluster.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/cron-schedule.d.ts +26 -0
- package/dist/composites/cron-schedule.d.ts.map +1 -0
- package/dist/composites/cron-workload.d.ts +1 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +1 -1
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config-schema.d.ts +3 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/effect-receipt-row.d.ts +164 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/wk8505.d.ts +3 -1
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/WK8505.md +2 -2
- package/dist/okf/types/Kustomization.md +1 -1
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +113 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/wk8505.ts +4 -2
- package/dist/serializer.d.ts.map +1 -1
- package/dist/subscribe-changes.d.ts +84 -0
- package/dist/subscribe-changes.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/fake-cluster.ts +17 -4
- package/src/codegen/docs.ts +7 -0
- package/src/composites/composites.test.ts +59 -1
- package/src/composites/cron-schedule.ts +47 -0
- package/src/composites/cron-workload.ts +4 -1
- package/src/composites/operator-stack.ts +3 -4
- package/src/config-schema.ts +5 -0
- package/src/config.ts +23 -0
- package/src/deep-observe.ts +26 -7
- package/src/describe-resources.ts +31 -7
- package/src/effect-receipt-row.test.ts +285 -0
- package/src/effect-receipt-row.ts +268 -0
- package/src/index.ts +22 -0
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/wk8505.ts +4 -2
- package/src/op/activities/index.ts +14 -0
- package/src/op/activities/kubectl.test.ts +38 -0
- package/src/op/activities/kubectl.ts +15 -0
- package/src/plugin.ts +15 -0
- package/src/receipt-store.test.ts +380 -0
- package/src/receipt-store.ts +290 -0
- package/src/serializer.ts +92 -1
- package/src/subscribe-changes.test.ts +368 -0
- package/src/subscribe-changes.ts +210 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s `ReceiptStore` (#2074, epic #1703): core's injectable receipt seam
|
|
3
|
+
* (#1834, `@intentius/chant/op/receipt-store`) implemented over a core
|
|
4
|
+
* `ConfigMap`, at the address ./effect-receipt-row.ts derives from the
|
|
5
|
+
* ownership marker fields, plus the plan-side live read of the same rows.
|
|
6
|
+
*
|
|
7
|
+
* The transport is the lexicon's own typed client (./api/connect.ts), so a
|
|
8
|
+
* receipt read and write take the same environment-to-cluster binding
|
|
9
|
+
* (#1100/#1155), the same credential policy and the same field manager as
|
|
10
|
+
* every other k8s mutation. No `kubectl` binary is involved.
|
|
11
|
+
*
|
|
12
|
+
* Write discipline (epic decision 3): `write` exists for the `effect()` step
|
|
13
|
+
* alone, because the step's read-compare-run-write is the only path that reaches it,
|
|
14
|
+
* on success, last. It is a server-side apply as `chant:<stack>`, stamping the
|
|
15
|
+
* ownership marker labels and {@link RECEIPT_LABEL_KEY}, so a later write
|
|
16
|
+
* updates the value in place and the owned-only prune retains rather than
|
|
17
|
+
* deletes it (./op/activities/kubectl.ts).
|
|
18
|
+
*
|
|
19
|
+
* Identity: the ConfigMap name needs `<stack>` and `<env>`, which the activity
|
|
20
|
+
* args deliberately do not carry (the `EffectReceiptRef` is
|
|
21
|
+
* identity-of-the-effect, not identity-of-the-deployment). The store resolves
|
|
22
|
+
* them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
|
|
23
|
+
* `chant run --env` sets) and the project's `ownership` block, the same fields
|
|
24
|
+
* that stamp markers (epic decision 4). Nothing resolving is an error, never a
|
|
25
|
+
* guessed segment.
|
|
26
|
+
*
|
|
27
|
+
* Not exported from the package entry point. Like ./secret-store.ts and
|
|
28
|
+
* ./teardown.ts, this module names the API client, which must stay off the
|
|
29
|
+
* build path (chant #1074, examples/k8s-client-boundary.test.ts). Consumers
|
|
30
|
+
* reach it by subpath: `@intentius/chant-lexicon-k8s/receipt-store`.
|
|
31
|
+
*/
|
|
32
|
+
import type { ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
33
|
+
import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
34
|
+
import type { DeepResourceObservation } from "@intentius/chant/deep-observation";
|
|
35
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
36
|
+
import { type K8sConnector } from "./api/connect.js";
|
|
37
|
+
import { type RenderedReceiptRow } from "./effect-receipt-row.js";
|
|
38
|
+
/** Options for {@link k8sReceiptStore}. All optional: the default store reads
|
|
39
|
+
* its identity and its namespace from the project, and its cluster from the
|
|
40
|
+
* environment binding. */
|
|
41
|
+
export interface K8sReceiptStoreOptions {
|
|
42
|
+
/** The name's `<stack>` segment. Omitted, the project's `ownership.stack`
|
|
43
|
+
* (chant.config.ts, found upward from `cwd`) answers. */
|
|
44
|
+
stack?: string;
|
|
45
|
+
/** The name's `<env>` segment, explicit by decision 4. Omitted, `CHANT_ENV`
|
|
46
|
+
* (set by `chant run --env`) answers, then a literal `ownership.env`. */
|
|
47
|
+
environment?: string;
|
|
48
|
+
/** Namespace the receipts live in. Omitted, `k8s.receipts.namespace`
|
|
49
|
+
* answers, then `default`. */
|
|
50
|
+
namespace?: string;
|
|
51
|
+
/** Where to look for chant.config.ts. Defaults to the working directory. */
|
|
52
|
+
cwd?: string;
|
|
53
|
+
/** Explicit kubectl context, for a caller that already resolved one. */
|
|
54
|
+
context?: string;
|
|
55
|
+
/** The connector to build a client with. Test seam. */
|
|
56
|
+
connect?: K8sConnector;
|
|
57
|
+
/** Environment record the identity fallback reads. Defaults to `process.env`. */
|
|
58
|
+
env?: Record<string, string | undefined>;
|
|
59
|
+
}
|
|
60
|
+
/** The stored expectation on a live receipt ConfigMap, or undefined when the
|
|
61
|
+
* object holds none. */
|
|
62
|
+
export declare function receiptValueOf(object: K8sObject | undefined): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel
|
|
65
|
+
* as `receiptActivities(k8sReceiptStore())`, and the registry resolves
|
|
66
|
+
* `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
|
|
67
|
+
* `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so
|
|
68
|
+
* module load never reads the project or connects to anything.
|
|
69
|
+
*/
|
|
70
|
+
export declare function k8sReceiptStore(options?: K8sReceiptStoreOptions): ReceiptStore;
|
|
71
|
+
/** What the observation leg learned about the declared receipt rows. */
|
|
72
|
+
export interface ReceiptRowObservation {
|
|
73
|
+
resources: Record<string, ResourceMetadata>;
|
|
74
|
+
unobserved: Record<string, UnobservedEntity>;
|
|
75
|
+
}
|
|
76
|
+
/** The receipt rows a build output carries, keyed by entity name. Only the
|
|
77
|
+
* entities this observation was asked about. */
|
|
78
|
+
export declare function receiptRowsFor(entityNames: readonly string[], buildOutput: string | undefined): Map<string, RenderedReceiptRow>;
|
|
79
|
+
/**
|
|
80
|
+
* The plan-side live read of the receipt rows (#2074's observation leg).
|
|
81
|
+
*
|
|
82
|
+
* A receipt is not a document the applier ever wrote (#1832), and it carries
|
|
83
|
+
* no `props` on the declared side, so the generic declared-entity sweep in
|
|
84
|
+
* ./describe-resources.ts has neither a `metadata.name` to query by nor an
|
|
85
|
+
* honest verdict to give. The serializer rendered each receipt's derived
|
|
86
|
+
* ConfigMap address into the build output's receipt comment, so this leg reads
|
|
87
|
+
* the addresses back from there, one derivation, decision 4, and asks the
|
|
88
|
+
* cluster for each.
|
|
89
|
+
*
|
|
90
|
+
* Present maps the stored value onto `attributes.value` (core's
|
|
91
|
+
* `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a
|
|
92
|
+
* failed read is an `unobserved` hole, never a wrong answer: a receipt nobody
|
|
93
|
+
* could read must not arrive downstream as "the effect never ran".
|
|
94
|
+
*/
|
|
95
|
+
export declare function observeReceiptRows(client: K8sClient, rows: ReadonlyMap<string, RenderedReceiptRow>): Promise<ReceiptRowObservation>;
|
|
96
|
+
/**
|
|
97
|
+
* The deep read's answer for the receipt rows.
|
|
98
|
+
*
|
|
99
|
+
* A receipt is read back here for the same reason the thin path reads it: a
|
|
100
|
+
* declared entity nobody looked at is a hole, and a hole in the deep read is
|
|
101
|
+
* noise on every `lifecycle diff --live --deep` a project with receipts runs.
|
|
102
|
+
* What it deliberately contributes is an EMPTY property tree: the declaration
|
|
103
|
+
* has no `props`, so every live path would land outside the claimed-field set
|
|
104
|
+
* (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the
|
|
105
|
+
* receipt's stored value is not drift on any reading, because a stale receipt is an
|
|
106
|
+
* `effect` row from `planReceipts` (#1832), never an update. Presence and the
|
|
107
|
+
* uid are the whole of what the deep read has to say about a receipt.
|
|
108
|
+
*/
|
|
109
|
+
export declare function observeReceiptRowsDeep(client: K8sClient, rows: ReadonlyMap<string, RenderedReceiptRow>): Promise<{
|
|
110
|
+
resources: Record<string, DeepResourceObservation>;
|
|
111
|
+
unobserved: Record<string, UnobservedEntity>;
|
|
112
|
+
}>;
|
|
113
|
+
//# sourceMappingURL=receipt-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receipt-store.d.ts","sourceRoot":"","sources":["../src/receipt-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AASH,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACxF,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAQL,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAE9B;;0BAE0B;AAC1B,MAAM,WAAW,sBAAsB;IACrC;6DACyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;6EACyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;kCAC8B;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC1C;AA+CD;wBACwB;AACxB,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAIhF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,YAAY,CAuClF;AAED,wEAAwE;AACxE,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC9C;AAED;gDACgD;AAChD,wBAAgB,cAAc,CAC5B,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAUjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAC5C,OAAO,CAAC,qBAAqB,CAAC,CAsChC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAC5C,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAAE,CAAC,CAW/G"}
|
package/dist/rules/wk8505.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* invocations, so "no Kustomization in THIS build sets spec.decryption" can
|
|
16
16
|
* mean "wired up in the other build" as easily as "forgotten" — the design
|
|
17
17
|
* doc's §4 explains why no path-to-build-target join exists to tell those
|
|
18
|
-
* apart.
|
|
18
|
+
* apart. Single-build-root scoped, tracked by chant #1939 as a candidate for
|
|
19
|
+
* an opt-in project-level mode; promote to error if that join ever becomes
|
|
20
|
+
* available.
|
|
19
21
|
*
|
|
20
22
|
* Fires only on claims that actually resolved (`problems.length === 0`, via
|
|
21
23
|
* `resolveEncryptedSecretClaims`), not on every raw declaration. An
|
|
@@ -39,7 +41,7 @@ function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
|
|
|
39
41
|
export const wk8505: PostSynthCheck = {
|
|
40
42
|
id: "WK8505",
|
|
41
43
|
description:
|
|
42
|
-
"committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it",
|
|
44
|
+
"committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it. Sees one build root at a time, so it goes silent when that Kustomization lives in a different build root (chant #1939).",
|
|
43
45
|
|
|
44
46
|
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
45
47
|
const kustomizations = fluxKustomizations(allManifests(ctx));
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAyRlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAkO3B,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
|
|
3
|
+
*
|
|
4
|
+
* Kubernetes is the one substrate chant reaches where a change stream is
|
|
5
|
+
* complete, trustworthy, and needs nothing deployed into the cluster being
|
|
6
|
+
* observed: the Watch API is served for every kind the API server serves, is
|
|
7
|
+
* `resourceVersion`-based so a reconnect has a defined resume point, and is
|
|
8
|
+
* authorized by the same read credentials `describeResources` already uses.
|
|
9
|
+
* Every cloud substrate fails on that last point. Subscribing to EventBridge,
|
|
10
|
+
* Cloud Asset Inventory or Event Grid means writing infrastructure into the
|
|
11
|
+
* account being watched, which inverts the property that makes a read-only
|
|
12
|
+
* watch safe to point at production. The verdict table in the operator guide
|
|
13
|
+
* records that per lexicon; this file is the one place it came out `yes`.
|
|
14
|
+
*
|
|
15
|
+
* ## What this is allowed to conclude: nothing
|
|
16
|
+
*
|
|
17
|
+
* A watch event never becomes an observation. The frames are read, and then
|
|
18
|
+
* discarded. The only thing that leaves this module is a no-argument
|
|
19
|
+
* `onChange()`, which wakes an operator tick that re-observes the estate from
|
|
20
|
+
* scratch through the ordinary read path. There is no code here that could
|
|
21
|
+
* turn a `DELETED` frame into a proposed `create`, because there is no channel
|
|
22
|
+
* from a frame to anything but a function call with no parameters.
|
|
23
|
+
*
|
|
24
|
+
* That is also why a missed event costs nothing. A `410 Gone`, a dropped
|
|
25
|
+
* connection, a subscription that never got established: all of them slow
|
|
26
|
+
* detection back to the operator's timer, and none of them make the estate
|
|
27
|
+
* read as clean.
|
|
28
|
+
*
|
|
29
|
+
* ## Scope
|
|
30
|
+
*
|
|
31
|
+
* One watch per (kind, namespace) the declared entities name. The kinds come
|
|
32
|
+
* from the same generated operation surface `describeResources` addresses
|
|
33
|
+
* entities through; the namespaces come from the declarations themselves,
|
|
34
|
+
* falling back to the client's own default for a namespaced entity that
|
|
35
|
+
* declares none. Nothing widens that: a project declaring three Deployments in
|
|
36
|
+
* one namespace opens one connection, not a cluster-wide firehose.
|
|
37
|
+
*/
|
|
38
|
+
import type { ChangeSubscription, SubscribeChangesOptions } from "@intentius/chant/lexicon";
|
|
39
|
+
import { type K8sConnector } from "./api/connect.js";
|
|
40
|
+
/**
|
|
41
|
+
* The most connections one subscription will hold open.
|
|
42
|
+
*
|
|
43
|
+
* A watch is a long-lived HTTP/2 stream against the API server, and one per
|
|
44
|
+
* (kind, namespace) is cheap right up until an estate declares eighty kinds
|
|
45
|
+
* across a dozen namespaces. Past this ceiling the honest move is to refuse
|
|
46
|
+
* the whole subscription and say so, rather than to open some arbitrary
|
|
47
|
+
* prefix of it: a partial watch is a signal that goes quiet for exactly the
|
|
48
|
+
* resources nobody chose to drop. The operator then runs on its timer, which
|
|
49
|
+
* is what it did before this existed.
|
|
50
|
+
*/
|
|
51
|
+
export declare const MAX_WATCHES = 32;
|
|
52
|
+
/** One thing to watch: a kind, and the namespace to watch it in. */
|
|
53
|
+
interface WatchTarget {
|
|
54
|
+
apiVersion: string;
|
|
55
|
+
kind: string;
|
|
56
|
+
/** Absent for a cluster-scoped kind. */
|
|
57
|
+
namespace?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The distinct (kind, namespace) pairs a declared estate implies.
|
|
61
|
+
*
|
|
62
|
+
* Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
|
|
63
|
+
* scope every time and a test can assert on it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function watchTargets(entities: Map<string, {
|
|
66
|
+
entityType: string;
|
|
67
|
+
props: Record<string, unknown>;
|
|
68
|
+
}>, defaultNamespace: string): {
|
|
69
|
+
targets: WatchTarget[];
|
|
70
|
+
unaddressable: string[];
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Open one watch per declared (kind, namespace) and report every event as a
|
|
74
|
+
* bare `onChange()`.
|
|
75
|
+
*
|
|
76
|
+
* Throws only for a failure that makes the whole subscription impossible: no
|
|
77
|
+
* entities in scope, a cluster binding that will not resolve, a scope past the
|
|
78
|
+
* ceiling. The operator turns that into one logged line and keeps polling.
|
|
79
|
+
* Once the subscription is live, nothing throws: a watch that dies reports
|
|
80
|
+
* through `onError` and the operator re-subscribes on its next round.
|
|
81
|
+
*/
|
|
82
|
+
export declare function subscribeChanges(options: SubscribeChangesOptions, connect?: K8sConnector): Promise<ChangeSubscription>;
|
|
83
|
+
export {};
|
|
84
|
+
//# sourceMappingURL=subscribe-changes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscribe-changes.d.ts","sourceRoot":"","sources":["../src/subscribe-changes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAE5F,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAGvE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,oEAAoE;AACpE,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,EAC7E,gBAAgB,EAAE,MAAM,GACvB;IAAE,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CAkCrD;AAOD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,EAChC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,kBAAkB,CAAC,CAkF7B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@types/js-yaml": "^4.0.9"
|
|
77
77
|
},
|
|
78
78
|
"optionalDependencies": {
|
|
79
|
-
"@intentius/chant-k8s-client": "^0.
|
|
79
|
+
"@intentius/chant-k8s-client": "^0.60.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@intentius/chant": "*",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"zod": "^4.3.6",
|
|
87
|
-
"@intentius/chant": "^0.
|
|
87
|
+
"@intentius/chant": "^0.60.0",
|
|
88
88
|
"typescript": "^5.9.3"
|
|
89
89
|
}
|
|
90
90
|
}
|
package/src/api/fake-cluster.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
17
17
|
import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "@intentius/chant-k8s-client/testing";
|
|
18
|
-
import type { FakeRequestLayer, RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
18
|
+
import type { FakeRequestLayer, FakeResponse, RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
19
19
|
import { createK8sClient } from "@intentius/chant-k8s-client";
|
|
20
20
|
import type { ConnectedClient, ConnectOptions, K8sConnector } from "./connect";
|
|
21
21
|
import { operationTable } from "./operation-surface";
|
|
@@ -33,8 +33,12 @@ export interface FakeClusterOptions {
|
|
|
33
33
|
* CRD resolvable in a test without registering anything.
|
|
34
34
|
*/
|
|
35
35
|
serves?: readonly string[];
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Full control: return a response for a request, or undefined to fall
|
|
38
|
+
* through. A `stream` on the response is what a watch reads (chant #1981);
|
|
39
|
+
* see `fakeWatchStream` in the client's testing harness.
|
|
40
|
+
*/
|
|
41
|
+
respond?: (request: RecordedRequest) => FakeResponse | undefined;
|
|
38
42
|
/** Kubeconfig to hand the client. Defaults to a single-context one. */
|
|
39
43
|
kubeconfig?: string;
|
|
40
44
|
}
|
|
@@ -177,7 +181,16 @@ export function fakeCluster(options: FakeClusterOptions = {}): FakeCluster {
|
|
|
177
181
|
if (object) return { body: object };
|
|
178
182
|
|
|
179
183
|
if (isListPath(request.path, resources)) {
|
|
180
|
-
|
|
184
|
+
// A `resourceVersion` on every list: it is what a watch resumes from
|
|
185
|
+
// (chant #1981), and a list without one is not a shape any API server
|
|
186
|
+
// produces.
|
|
187
|
+
return {
|
|
188
|
+
body: {
|
|
189
|
+
kind: "List",
|
|
190
|
+
items: listPaths.get(request.path) ?? [],
|
|
191
|
+
metadata: { resourceVersion: "1" },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
return { status: 404, body: statusBody(404, "NotFound", `${request.path} not found`) };
|
package/src/codegen/docs.ts
CHANGED
|
@@ -87,6 +87,13 @@ The generated file includes:
|
|
|
87
87
|
- \`metadata.name\` auto-generated from export names (camelCase → kebab-case)
|
|
88
88
|
- Default labels and annotations injected from \`defaultLabels()\`/\`defaultAnnotations()\`
|
|
89
89
|
|
|
90
|
+
The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as
|
|
91
|
+
ConfigMaps named \`chant-receipt.<stack>.<env>.<effect>\` in \`k8s.receipts.namespace\`
|
|
92
|
+
(\`default\` when unset), holding the expectation under \`data.expectation\`. A receipt is
|
|
93
|
+
never a document in the manifest stream: the rows ride a trailing
|
|
94
|
+
\`# chant:effect-receipts\` comment, which \`kubectl apply\` ignores, because the
|
|
95
|
+
\`effect()\` step is a receipt's sole writer.
|
|
96
|
+
|
|
90
97
|
## Key conversions
|
|
91
98
|
|
|
92
99
|
| Chant (TypeScript) | YAML output | Rule |
|
|
@@ -340,6 +340,32 @@ describe("CronWorkload", () => {
|
|
|
340
340
|
expect((p(result.role).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
|
|
341
341
|
expect((p(result.roleBinding).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
|
|
342
342
|
});
|
|
343
|
+
|
|
344
|
+
// ── schedule validation (#2071 item 4) ────────────────────────────
|
|
345
|
+
|
|
346
|
+
test("accepts a valid five-field schedule", () => {
|
|
347
|
+
expect(() =>
|
|
348
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 2 * * *" }),
|
|
349
|
+
).not.toThrow();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("refuses a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
|
|
353
|
+
expect(() =>
|
|
354
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 0 2 * * *" }),
|
|
355
|
+
).toThrow(/schedule "0 0 2 \* \* \*" is 6-field cron.*five fields/s);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("refuses a malformed schedule", () => {
|
|
359
|
+
expect(() =>
|
|
360
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "not a cron" }),
|
|
361
|
+
).toThrow(/schedule "not a cron" is not valid cron syntax/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("refuses a blank schedule", () => {
|
|
365
|
+
expect(() =>
|
|
366
|
+
CronWorkload({ name: "backup", image: "backup:1.0", schedule: "" }),
|
|
367
|
+
).toThrow(/schedule is required/);
|
|
368
|
+
});
|
|
343
369
|
});
|
|
344
370
|
|
|
345
371
|
// ── AutoscaledService ──────────────────────────────────────────────
|
|
@@ -4016,7 +4042,39 @@ describe("OperatorStack", () => {
|
|
|
4016
4042
|
image: "chant:latest",
|
|
4017
4043
|
converge: [{ name: "fountain-observe", schedule: "", env: "staging" }],
|
|
4018
4044
|
}),
|
|
4019
|
-
).toThrow(/schedule/);
|
|
4045
|
+
).toThrow(/schedule is required/);
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
// ── schedule validation (#2071 item 4) ────────────────────────────
|
|
4049
|
+
|
|
4050
|
+
test("accepts a valid five-field schedule", () => {
|
|
4051
|
+
expect(() =>
|
|
4052
|
+
OperatorStack({
|
|
4053
|
+
name: "chant-operator",
|
|
4054
|
+
image: "chant:latest",
|
|
4055
|
+
converge: [{ name: "fountain-observe", schedule: "*/10 * * * *", env: "staging" }],
|
|
4056
|
+
}),
|
|
4057
|
+
).not.toThrow();
|
|
4058
|
+
});
|
|
4059
|
+
|
|
4060
|
+
test("refuses a host with a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
|
|
4061
|
+
expect(() =>
|
|
4062
|
+
OperatorStack({
|
|
4063
|
+
name: "chant-operator",
|
|
4064
|
+
image: "chant:latest",
|
|
4065
|
+
converge: [{ name: "fountain-observe", schedule: "0 */10 * * * *", env: "staging" }],
|
|
4066
|
+
}),
|
|
4067
|
+
).toThrow(/schedule "0 \*\/10 \* \* \* \*" is 6-field cron.*five fields/s);
|
|
4068
|
+
});
|
|
4069
|
+
|
|
4070
|
+
test("refuses a host with a malformed schedule", () => {
|
|
4071
|
+
expect(() =>
|
|
4072
|
+
OperatorStack({
|
|
4073
|
+
name: "chant-operator",
|
|
4074
|
+
image: "chant:latest",
|
|
4075
|
+
converge: [{ name: "fountain-observe", schedule: "not a cron", env: "staging" }],
|
|
4076
|
+
}),
|
|
4077
|
+
).toThrow(/schedule "not a cron" is not valid cron syntax/);
|
|
4020
4078
|
});
|
|
4021
4079
|
|
|
4022
4080
|
test("refuses a blank image", () => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CronJob schedule validation (issue #2071 item 4).
|
|
3
|
+
*
|
|
4
|
+
* `OperatorStack` and `CronWorkload` both hand their `schedule` field
|
|
5
|
+
* straight to a k8s `CronJob`, which parses only five fields (minute hour
|
|
6
|
+
* day-of-month month day-of-week). Core's own cron parser
|
|
7
|
+
* (`isValidCronExpression`, `packages/core/src/op/cron.ts`, #2120) is
|
|
8
|
+
* deliberately more permissive: it also accepts the 6-field Quartz form
|
|
9
|
+
* (`second minute hour dom month dow`) that a `ConvergeOp`'s own `schedule`
|
|
10
|
+
* may legitimately carry, since the final word on cron syntax belongs to
|
|
11
|
+
* whichever scheduler runs the string. A k8s CronJob is that scheduler here,
|
|
12
|
+
* and it is stricter than core's parser, so a 6-field `ConvergeOp` schedule
|
|
13
|
+
* reused as-is on a CronJob fails at `kubectl apply`, not at build time.
|
|
14
|
+
*
|
|
15
|
+
* This module gives both composites one place to catch that at construction,
|
|
16
|
+
* reusing core's parser rather than a second copy of it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isValidCronExpression } from "@intentius/chant/op";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate `schedule` for a k8s CronJob, throwing before a bad value ever
|
|
23
|
+
* reaches `kubectl apply`. `context` names the composite (and host, for
|
|
24
|
+
* `OperatorStack`) so the thrown message reads like the rest of that
|
|
25
|
+
* composite's build-time refusals, e.g. `OperatorStack "chant-operator",
|
|
26
|
+
* host "fountain-observe"` or `CronWorkload "backup"`.
|
|
27
|
+
*/
|
|
28
|
+
export function validateCronJobSchedule(context: string, schedule: string): void {
|
|
29
|
+
if (!schedule || schedule.trim().length === 0) {
|
|
30
|
+
throw new Error(`${context}: schedule is required. A CronJob with no schedule never ticks.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fields = schedule.trim().split(/\s+/);
|
|
34
|
+
|
|
35
|
+
if (!isValidCronExpression(schedule)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${context}: schedule "${schedule}" is not valid cron syntax. A Kubernetes CronJob takes five fields: minute hour day-of-month month day-of-week.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (fields.length === 6) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${context}: schedule "${schedule}" is 6-field cron. A Kubernetes CronJob takes five fields (minute hour day-of-month month day-of-week) and has no seconds column. ` +
|
|
44
|
+
`A ConvergeOp schedule string in that form must be converted before it reaches a CronJob: drop the leading seconds field and keep the remaining five.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
import { Composite, mergeDefaults } from "@intentius/chant";
|
|
9
9
|
import { CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
|
|
10
10
|
import type { ContainerSecurityContext } from "./security-context";
|
|
11
|
+
import { validateCronJobSchedule } from "./cron-schedule";
|
|
11
12
|
|
|
12
13
|
export interface CronWorkloadProps {
|
|
13
14
|
/** Workload name — used in metadata and labels. */
|
|
14
15
|
name: string;
|
|
15
16
|
/** Container image. */
|
|
16
17
|
image: string;
|
|
17
|
-
/** Cron schedule expression (e.g., "0 * * * *"). */
|
|
18
|
+
/** Cron schedule expression (e.g., "0 * * * *"). Validated at construction: a k8s CronJob takes five fields only, so a 6-field (seconds) form is refused (`./cron-schedule.ts`, #2071). */
|
|
18
19
|
schedule: string;
|
|
19
20
|
/** Command to run in the container. */
|
|
20
21
|
command?: string[];
|
|
@@ -92,6 +93,8 @@ export const CronWorkload = Composite((props: CronWorkloadProps) => {
|
|
|
92
93
|
defaults: defs,
|
|
93
94
|
} = props;
|
|
94
95
|
|
|
96
|
+
validateCronJobSchedule(`CronWorkload "${name}"`, schedule);
|
|
97
|
+
|
|
95
98
|
const saName = `${name}-sa`;
|
|
96
99
|
const roleName = `${name}-role`;
|
|
97
100
|
const bindingName = `${name}-binding`;
|
|
@@ -76,6 +76,7 @@ import { Composite, mergeDefaults } from "@intentius/chant";
|
|
|
76
76
|
import { classifyOpVerbClass } from "@intentius/chant/op";
|
|
77
77
|
import type { OpConfig, OpVerbClass } from "@intentius/chant/op";
|
|
78
78
|
import { Namespace, CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
|
|
79
|
+
import { validateCronJobSchedule } from "./cron-schedule";
|
|
79
80
|
|
|
80
81
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
81
82
|
|
|
@@ -96,7 +97,7 @@ export interface OperatorRbacResourceRule {
|
|
|
96
97
|
export interface OperatorStackConvergeHost {
|
|
97
98
|
/** ConvergeOp's own name (`ConvergeOpConfig.name`) — the CronJob, ServiceAccount, Role, and RoleBinding name stem for this host. */
|
|
98
99
|
name: string;
|
|
99
|
-
/** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. */
|
|
100
|
+
/** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. Validated at construction: a 6-field (seconds) form, which `ConvergeOp` accepts, is refused here since a k8s CronJob takes five fields only (`./cron-schedule.ts`, #2071). */
|
|
100
101
|
schedule: string;
|
|
101
102
|
/** Environment this ConvergeOp converges (`ConvergeOpConfig.env`) — carried onto the container as `CHANT_CONVERGE_ENV`, for log/estate readability only (`chant run <name>` needs no `--env`: the target op already carries it). */
|
|
102
103
|
env: string;
|
|
@@ -268,9 +269,7 @@ export const OperatorStack = Composite((props: OperatorStackConfig) => {
|
|
|
268
269
|
throw new Error(`OperatorStack "${name}": duplicate hosted ConvergeOp name "${host.name}" — CronJob/ServiceAccount names would collide.`);
|
|
269
270
|
}
|
|
270
271
|
seen.add(host.name);
|
|
271
|
-
|
|
272
|
-
throw new Error(`OperatorStack "${name}", host "${host.name}": schedule is required — an operator CronJob with no schedule never ticks.`);
|
|
273
|
-
}
|
|
272
|
+
validateCronJobSchedule(`OperatorStack "${name}", host "${host.name}"`, host.schedule);
|
|
274
273
|
}
|
|
275
274
|
if (!image || image.trim().length === 0) {
|
|
276
275
|
throw new Error(`OperatorStack "${name}": image is required — the CronJob has nothing to run.`);
|
package/src/config-schema.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const k8sConfigSchema = z.strictObject({
|
|
|
28
28
|
roots: z.array(z.string()).optional(),
|
|
29
29
|
})
|
|
30
30
|
.optional(),
|
|
31
|
+
receipts: z
|
|
32
|
+
.strictObject({
|
|
33
|
+
namespace: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.optional(),
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
declare module "@intentius/chant/config" {
|
package/src/config.ts
CHANGED
|
@@ -111,6 +111,29 @@ export interface K8sChantConfig {
|
|
|
111
111
|
/** Kustomization directories to render into the build. */
|
|
112
112
|
roots?: string[];
|
|
113
113
|
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Effect receipt settings (#2074, epic #1703).
|
|
117
|
+
*
|
|
118
|
+
* `namespace` is where this project's receipt ConfigMaps live. The name is
|
|
119
|
+
* derived from the ownership fields (`chant-receipt.<stack>.<env>.<effect>`,
|
|
120
|
+
* see `./effect-receipt-row.ts`); the namespace is the one part of the
|
|
121
|
+
* address the ownership block cannot answer, so it is declared here. Unset,
|
|
122
|
+
* receipts land in `default`, the same namespace every other namespace-less
|
|
123
|
+
* k8s read and write in this lexicon falls through to. It is never derived
|
|
124
|
+
* from the stack or the environment: a guessed namespace is one chant would
|
|
125
|
+
* have to create, and the receipt row creates nothing but the receipt.
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* k8s: {
|
|
129
|
+
* receipts: { namespace: "chant-system" },
|
|
130
|
+
* } satisfies K8sChantConfig
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
receipts?: {
|
|
134
|
+
/** Namespace the receipt ConfigMaps live in. Defaults to `default`. */
|
|
135
|
+
namespace?: string;
|
|
136
|
+
};
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
declare module "@intentius/chant/config" {
|
package/src/deep-observe.ts
CHANGED
|
@@ -118,6 +118,7 @@ import {
|
|
|
118
118
|
} from "./api/classify";
|
|
119
119
|
import { operationFor } from "./api/operation-surface";
|
|
120
120
|
import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
121
|
+
import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store";
|
|
121
122
|
|
|
122
123
|
// Re-exported so a dynamic importer of this module (plugin.ts's
|
|
123
124
|
// `observeResourcesDeep`, a test) can get the reader and its hooks from one
|
|
@@ -159,11 +160,23 @@ export async function observeResourcesDeepK8s(
|
|
|
159
160
|
): Promise<DeepObservationResult> {
|
|
160
161
|
const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
// Effect receipts (#2074) are read by their own leg at the end: they carry
|
|
164
|
+
// no declared props, so every live path would be an unclaimed field (#2160)
|
|
165
|
+
// and their staleness is an `effect` row from the plan (#1832), never
|
|
166
|
+
// property drift. Reading them here keeps the deep read from calling a
|
|
167
|
+
// declared entity a hole; contributing an empty tree keeps it from calling
|
|
168
|
+
// one drift.
|
|
169
|
+
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
|
|
170
|
+
|
|
171
|
+
const declared = [...options.entities]
|
|
172
|
+
.filter(([entityName]) => !receiptRows.has(entityName))
|
|
173
|
+
.map(([entityName, entity]) => ({
|
|
174
|
+
entityName,
|
|
175
|
+
entityType: entity.entityType,
|
|
176
|
+
props: entity.props,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
|
|
167
180
|
|
|
168
181
|
let client;
|
|
169
182
|
try {
|
|
@@ -173,7 +186,7 @@ export async function observeResourcesDeepK8s(
|
|
|
173
186
|
return deepObservation(
|
|
174
187
|
{},
|
|
175
188
|
unobservedAll(
|
|
176
|
-
|
|
189
|
+
everyName,
|
|
177
190
|
"read-failed",
|
|
178
191
|
MISSING_CLIENT_DETAIL,
|
|
179
192
|
options.entities,
|
|
@@ -185,7 +198,7 @@ export async function observeResourcesDeepK8s(
|
|
|
185
198
|
return deepObservation(
|
|
186
199
|
{},
|
|
187
200
|
unobservedAll(
|
|
188
|
-
|
|
201
|
+
everyName,
|
|
189
202
|
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
190
203
|
outcome.kind === "unobserved" ? outcome.detail : undefined,
|
|
191
204
|
options.entities,
|
|
@@ -267,5 +280,11 @@ export async function observeResourcesDeepK8s(
|
|
|
267
280
|
}
|
|
268
281
|
});
|
|
269
282
|
|
|
283
|
+
if (receiptRows.size > 0) {
|
|
284
|
+
const receiptObs = await observeReceiptRowsDeep(client, receiptRows);
|
|
285
|
+
Object.assign(resources, receiptObs.resources);
|
|
286
|
+
Object.assign(unobserved, receiptObs.unobserved);
|
|
287
|
+
}
|
|
288
|
+
|
|
270
289
|
return deepObservation(resources, unobserved);
|
|
271
290
|
}
|