@reventlessdev/reventless-spec 3.0.0-alpha.154 → 3.0.0-alpha.156

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.
@@ -0,0 +1,230 @@
1
+ /***
2
+ The environments an app deploys to, as a file: `environments.yaml`, beside its
3
+ `deploy-manifest.yaml`.
4
+
5
+ ```yaml
6
+ environments:
7
+ - { name: review, purpose: review, pullRequestsInto: dev }
8
+ - { name: dev, purpose: dev, branch: dev }
9
+ - { name: test, purpose: test, branch: test }
10
+ - { name: production, purpose: production, branch: main, tags: "v*", stack: prod }
11
+ - { name: demo, purpose: demo, tags: "v*" }
12
+ ```
13
+
14
+ A *shared* environment has one stack and is fed by a branch, by tags, or by both.
15
+ A *review* environment is fed by the pull requests into one branch and has one
16
+ stack per open pull request, named `<name>-pr-<number>`.
17
+
18
+ Here rather than in the AWS package because more than the deploy reads it: a
19
+ tool that shows which commit runs where needs the same answer to *which branch
20
+ feeds which environment* as the workflow that deploys, and a second reading of
21
+ the file is how the two would come to disagree.
22
+
23
+ The file is optional. Without it a deploy uses the branch name as the stack, as
24
+ it always has; nothing here is consulted.
25
+ */
26
+
27
+ /**
28
+ What an environment is for. `Other` keeps a team's own word rather than refusing
29
+ the file, because a purpose nothing here branches on costs nothing to carry.
30
+ */
31
+ type purpose =
32
+ | Dev
33
+ | Test
34
+ | Production
35
+ | Demo
36
+ | Review
37
+ | Other(string)
38
+
39
+ let purposeSchema: S.t<purpose> = S.union([
40
+ S.literal("dev")->S.shape(_ => Dev),
41
+ S.literal("test")->S.shape(_ => Test),
42
+ S.literal("production")->S.shape(_ => Production),
43
+ S.literal("demo")->S.shape(_ => Demo),
44
+ S.literal("review")->S.shape(_ => Review),
45
+ S.string->S.shape(other => Other(other)),
46
+ ])
47
+
48
+ let purposeName = (purpose: purpose): string =>
49
+ switch purpose {
50
+ | Dev => "dev"
51
+ | Test => "test"
52
+ | Production => "production"
53
+ | Demo => "demo"
54
+ | Review => "review"
55
+ | Other(name) => name
56
+ }
57
+
58
+ /** One entry as the file spells it. [parseString] turns it into an [environment]. */
59
+ @schema
60
+ type entry = {
61
+ name: string,
62
+ purpose: @s.matches(purposeSchema) purpose,
63
+ branch?: string,
64
+ tags?: string,
65
+ stack?: string,
66
+ pullRequestsInto?: string,
67
+ drafts?: bool,
68
+ }
69
+
70
+ @schema
71
+ type file = {environments: array<entry>}
72
+
73
+ /** What feeds an environment, and so how many stacks it has. */
74
+ type feed =
75
+ | Shared({branch: option<string>, tags: option<string>, stack: string})
76
+ | ReviewOf({into: string, drafts: bool})
77
+
78
+ type environment = {name: string, purpose: purpose, feed: feed}
79
+
80
+ type t = array<environment>
81
+
82
+ // ── Reading ──────────────────────────────────────────────────────────────────
83
+
84
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
85
+ @module("yaml") external stringifyYaml: JSON.t => string = "stringify"
86
+
87
+ let environmentOf = (entry: entry): result<environment, string> =>
88
+ switch (entry.pullRequestsInto, entry.branch, entry.tags) {
89
+ | (Some(_), Some(_), _) | (Some(_), _, Some(_)) =>
90
+ Error(`${entry.name}: pullRequestsInto cannot be combined with branch or tags`)
91
+ | (Some(_), None, None) if entry.stack->Option.isSome =>
92
+ Error(`${entry.name}: a review environment names its stacks itself; remove stack`)
93
+ | (Some(into), None, None) =>
94
+ Ok({
95
+ name: entry.name,
96
+ purpose: entry.purpose,
97
+ feed: ReviewOf({into, drafts: entry.drafts->Option.getOr(false)}),
98
+ })
99
+ | (None, None, None) =>
100
+ Error(`${entry.name}: says neither branch, tags nor pullRequestsInto, so nothing feeds it`)
101
+ | (None, _, _) if entry.drafts->Option.isSome =>
102
+ Error(`${entry.name}: drafts is only meaningful with pullRequestsInto`)
103
+ | (None, branch, tags) =>
104
+ Ok({
105
+ name: entry.name,
106
+ purpose: entry.purpose,
107
+ feed: Shared({branch, tags, stack: entry.stack->Option.getOr(entry.name)}),
108
+ })
109
+ }
110
+
111
+ /** Parses an environments document. Strict, like the accounts manifest: a
112
+ malformed entry refuses the whole file, because a skipped environment is a
113
+ branch that silently stops deploying. */
114
+ let parseString = (yamlText: string): result<t, string> =>
115
+ switch try Ok(S.parseOrThrow(parseYaml(yamlText), ~to=fileSchema)) catch {
116
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
117
+ | _ => Error("YAML parse error")
118
+ } {
119
+ | Error(_) as e => e
120
+ | Ok({environments}) =>
121
+ let names = environments->Array.map(e => e.name)
122
+ switch names->Array.find(n => names->Array.filter(m => m == n)->Array.length > 1) {
123
+ | Some(duplicate) => Error(`${duplicate}: named twice`)
124
+ | None =>
125
+ environments->Array.reduce(Ok([]), (acc, entry) =>
126
+ switch (acc, environmentOf(entry)) {
127
+ | (Error(_) as e, _) => e
128
+ | (Ok(_), Error(message)) => Error(message)
129
+ | (Ok(done), Ok(environment)) => Ok(done->Array.concat([environment]))
130
+ }
131
+ )
132
+ }
133
+ }
134
+
135
+ let parseFile = (path: string): result<t, string> =>
136
+ try {
137
+ parseString(NodeFs.readFileSync(path))
138
+ } catch {
139
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
140
+ | _ => Error(`Cannot read ${path}`)
141
+ }
142
+
143
+ let entryOf = (environment: environment): entry =>
144
+ switch environment.feed {
145
+ | Shared({branch, tags, stack}) => {
146
+ name: environment.name,
147
+ purpose: environment.purpose,
148
+ ?branch,
149
+ ?tags,
150
+ stack: ?(stack == environment.name ? None : Some(stack)),
151
+ }
152
+ | ReviewOf({into, drafts}) => {
153
+ name: environment.name,
154
+ purpose: environment.purpose,
155
+ pullRequestsInto: into,
156
+ drafts: ?(drafts ? Some(true) : None),
157
+ }
158
+ }
159
+
160
+ /** The document for a set of environments. Comments are not kept: this writes a
161
+ new file, it does not edit one. */
162
+ let print = (environments: t): string =>
163
+ {environments: environments->Array.map(entryOf)}->Util_Sury.toJson(fileSchema)->stringifyYaml
164
+
165
+ /** Where a deploy looks for it: beside the deploy manifest. */
166
+ let fileName = "environments.yaml"
167
+
168
+ let pathBeside = (~manifest: string): string =>
169
+ NodePath.join([NodePath.dirname(manifest), fileName])
170
+
171
+ // ── Resolving ────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ A branch or tag pattern as GitHub's workflow filters spell them: `*` matches
175
+ within one path segment, `**` across segments. Nothing else is special.
176
+ */
177
+ let matches = (~pattern: string, name: string): bool => {
178
+ let escaped =
179
+ pattern
180
+ ->String.split("**")
181
+ ->Array.map(part =>
182
+ part
183
+ ->String.replaceRegExp(/[.+?^${}()|[\]\\]/g, "\\$&")
184
+ ->String.replaceAll("*", "[^/]*")
185
+ )
186
+ ->Array.join(".*")
187
+ RegExp.fromString(`^${escaped}$`)->RegExp.test(name)
188
+ }
189
+
190
+ /** What a push or a tag deploys to. */
191
+ type ref_ =
192
+ | Branch(string)
193
+ | Tag(string)
194
+
195
+ /** `refs/heads/test` and `refs/tags/v1`; anything else is not deployable. */
196
+ let refOf = (ref: string): option<ref_> =>
197
+ if ref->String.startsWith("refs/heads/") {
198
+ Some(Branch(ref->String.slice(~start=11)))
199
+ } else if ref->String.startsWith("refs/tags/") {
200
+ Some(Tag(ref->String.slice(~start=10)))
201
+ } else {
202
+ None
203
+ }
204
+
205
+ type resolved = {name: string, stack: string, purpose: purpose}
206
+
207
+ /** The first shared environment, in file order, that the branch or tag feeds. */
208
+ let resolve = (environments: t, ref: ref_): option<resolved> =>
209
+ environments->Array.findMap(({name, purpose, feed}) =>
210
+ switch (feed, ref) {
211
+ | (Shared({branch: Some(pattern), stack}), Branch(branch)) if matches(~pattern, branch) =>
212
+ Some({name, stack, purpose})
213
+ | (Shared({tags: Some(pattern), stack}), Tag(tag)) if matches(~pattern, tag) =>
214
+ Some({name, stack, purpose})
215
+ | (Shared(_), _) | (ReviewOf(_), _) => None
216
+ }
217
+ )
218
+
219
+ /** The review stack of one pull request, if the branch it targets has a review
220
+ environment. `draft` pull requests get one only where the environment asks. */
221
+ let resolvePullRequest = (environments: t, ~base: string, ~number: int, ~draft: bool): option<
222
+ resolved,
223
+ > =>
224
+ environments->Array.findMap(({name, purpose, feed}) =>
225
+ switch feed {
226
+ | ReviewOf({into, drafts}) if matches(~pattern=into, base) && (drafts || !draft) =>
227
+ Some({name, stack: `${name}-pr-${number->Int.toString}`, purpose})
228
+ | ReviewOf(_) | Shared(_) => None
229
+ }
230
+ )
@@ -0,0 +1,314 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Yaml from "yaml";
5
+ import * as Nodefs from "node:fs";
6
+ import * as Nodepath from "node:path";
7
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
8
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
+ import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
12
+
13
+ let purposeSchema = Sury.union([
14
+ Sury.shape(Sury.literal("dev"), param => "Dev"),
15
+ Sury.shape(Sury.literal("test"), param => "Test"),
16
+ Sury.shape(Sury.literal("production"), param => "Production"),
17
+ Sury.shape(Sury.literal("demo"), param => "Demo"),
18
+ Sury.shape(Sury.literal("review"), param => "Review"),
19
+ Sury.shape(Sury.string, other => ({
20
+ TAG: "Other",
21
+ _0: other
22
+ }))
23
+ ]);
24
+
25
+ function purposeName(purpose) {
26
+ if (typeof purpose === "object") {
27
+ return purpose._0;
28
+ }
29
+ switch (purpose) {
30
+ case "Dev" :
31
+ return "dev";
32
+ case "Test" :
33
+ return "test";
34
+ case "Production" :
35
+ return "production";
36
+ case "Demo" :
37
+ return "demo";
38
+ case "Review" :
39
+ return "review";
40
+ }
41
+ }
42
+
43
+ let entrySchema = Sury.$schema(s => ({
44
+ name: s.m(Sury.string),
45
+ purpose: s.m(purposeSchema),
46
+ branch: s.m(Sury.$option(Sury.string)),
47
+ tags: s.m(Sury.$option(Sury.string)),
48
+ stack: s.m(Sury.$option(Sury.string)),
49
+ pullRequestsInto: s.m(Sury.$option(Sury.string)),
50
+ drafts: s.m(Sury.$option(Sury.boolean))
51
+ }));
52
+
53
+ let fileSchema = Sury.$schema(s => ({
54
+ environments: s.m(Sury.array(entrySchema))
55
+ }));
56
+
57
+ function environmentOf(entry) {
58
+ let match = entry.pullRequestsInto;
59
+ let match$1 = entry.branch;
60
+ let match$2 = entry.tags;
61
+ if (match !== undefined) {
62
+ if (match$1 === undefined && match$2 === undefined) {
63
+ if (Stdlib_Option.isSome(entry.stack)) {
64
+ return {
65
+ TAG: "Error",
66
+ _0: entry.name + `: a review environment names its stacks itself; remove stack`
67
+ };
68
+ } else {
69
+ return {
70
+ TAG: "Ok",
71
+ _0: {
72
+ name: entry.name,
73
+ purpose: entry.purpose,
74
+ feed: {
75
+ TAG: "ReviewOf",
76
+ into: match,
77
+ drafts: Stdlib_Option.getOr(entry.drafts, false)
78
+ }
79
+ }
80
+ };
81
+ }
82
+ }
83
+ } else {
84
+ let exit = 0;
85
+ if (match$1 !== undefined) {
86
+ exit = 2;
87
+ } else {
88
+ if (match$2 === undefined) {
89
+ return {
90
+ TAG: "Error",
91
+ _0: entry.name + `: says neither branch, tags nor pullRequestsInto, so nothing feeds it`
92
+ };
93
+ }
94
+ exit = 2;
95
+ }
96
+ if (exit === 2) {
97
+ if (Stdlib_Option.isSome(entry.drafts)) {
98
+ return {
99
+ TAG: "Error",
100
+ _0: entry.name + `: drafts is only meaningful with pullRequestsInto`
101
+ };
102
+ } else {
103
+ return {
104
+ TAG: "Ok",
105
+ _0: {
106
+ name: entry.name,
107
+ purpose: entry.purpose,
108
+ feed: {
109
+ TAG: "Shared",
110
+ branch: match$1,
111
+ tags: match$2,
112
+ stack: Stdlib_Option.getOr(entry.stack, entry.name)
113
+ }
114
+ }
115
+ };
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ TAG: "Error",
121
+ _0: entry.name + `: pullRequestsInto cannot be combined with branch or tags`
122
+ };
123
+ }
124
+
125
+ function parseString(yamlText) {
126
+ let e;
127
+ try {
128
+ e = {
129
+ TAG: "Ok",
130
+ _0: Sury.parseOrThrow(Yaml.parse(yamlText), fileSchema)
131
+ };
132
+ } catch (raw_err) {
133
+ let err = Primitive_exceptions.internalToException(raw_err);
134
+ e = err.RE_EXN_ID === "JsExn" ? ({
135
+ TAG: "Error",
136
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "YAML parse error")
137
+ }) : ({
138
+ TAG: "Error",
139
+ _0: "YAML parse error"
140
+ });
141
+ }
142
+ if (e.TAG !== "Ok") {
143
+ return e;
144
+ }
145
+ let environments = e._0.environments;
146
+ let names = environments.map(e => e.name);
147
+ let duplicate = names.find(n => names.filter(m => m === n).length > 1);
148
+ if (duplicate !== undefined) {
149
+ return {
150
+ TAG: "Error",
151
+ _0: duplicate + `: named twice`
152
+ };
153
+ } else {
154
+ return Stdlib_Array.reduce(environments, {
155
+ TAG: "Ok",
156
+ _0: []
157
+ }, (acc, entry) => {
158
+ let match = environmentOf(entry);
159
+ if (acc.TAG === "Ok") {
160
+ if (match.TAG === "Ok") {
161
+ return {
162
+ TAG: "Ok",
163
+ _0: acc._0.concat([match._0])
164
+ };
165
+ } else {
166
+ return {
167
+ TAG: "Error",
168
+ _0: match._0
169
+ };
170
+ }
171
+ } else {
172
+ return acc;
173
+ }
174
+ });
175
+ }
176
+ }
177
+
178
+ function parseFile(path) {
179
+ try {
180
+ return parseString(Nodefs.readFileSync(path, "utf8"));
181
+ } catch (raw_err) {
182
+ let err = Primitive_exceptions.internalToException(raw_err);
183
+ if (err.RE_EXN_ID === "JsExn") {
184
+ return {
185
+ TAG: "Error",
186
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + path)
187
+ };
188
+ } else {
189
+ return {
190
+ TAG: "Error",
191
+ _0: `Cannot read ` + path
192
+ };
193
+ }
194
+ }
195
+ }
196
+
197
+ function entryOf(environment) {
198
+ let match = environment.feed;
199
+ if (match.TAG !== "Shared") {
200
+ return {
201
+ name: environment.name,
202
+ purpose: environment.purpose,
203
+ pullRequestsInto: match.into,
204
+ drafts: match.drafts ? true : undefined
205
+ };
206
+ }
207
+ let stack = match.stack;
208
+ return {
209
+ name: environment.name,
210
+ purpose: environment.purpose,
211
+ branch: match.branch,
212
+ tags: match.tags,
213
+ stack: stack === environment.name ? undefined : stack
214
+ };
215
+ }
216
+
217
+ function print(environments) {
218
+ return Yaml.stringify(Util_Sury$Reventless.toJson({
219
+ environments: environments.map(entryOf)
220
+ }, fileSchema));
221
+ }
222
+
223
+ let fileName = "environments.yaml";
224
+
225
+ function pathBeside(manifest) {
226
+ return Nodepath.join(Nodepath.dirname(manifest), fileName);
227
+ }
228
+
229
+ function matches(pattern, name) {
230
+ let escaped = pattern.split("**").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*")).join(".*");
231
+ return new RegExp(`^` + escaped + `$`).test(name);
232
+ }
233
+
234
+ function refOf(ref) {
235
+ if (ref.startsWith("refs/heads/")) {
236
+ return {
237
+ TAG: "Branch",
238
+ _0: ref.slice(11)
239
+ };
240
+ } else if (ref.startsWith("refs/tags/")) {
241
+ return {
242
+ TAG: "Tag",
243
+ _0: ref.slice(10)
244
+ };
245
+ } else {
246
+ return;
247
+ }
248
+ }
249
+
250
+ function resolve(environments, ref) {
251
+ return Stdlib_Array.findMap(environments, param => {
252
+ let feed = param.feed;
253
+ let purpose = param.purpose;
254
+ let name = param.name;
255
+ if (feed.TAG !== "Shared") {
256
+ return;
257
+ }
258
+ let pattern = feed.branch;
259
+ if (pattern !== undefined && ref.TAG === "Branch") {
260
+ if (matches(pattern, ref._0)) {
261
+ return {
262
+ name: name,
263
+ stack: feed.stack,
264
+ purpose: purpose
265
+ };
266
+ } else {
267
+ return;
268
+ }
269
+ }
270
+ let pattern$1 = feed.tags;
271
+ if (pattern$1 !== undefined && ref.TAG !== "Branch" && matches(pattern$1, ref._0)) {
272
+ return {
273
+ name: name,
274
+ stack: feed.stack,
275
+ purpose: purpose
276
+ };
277
+ }
278
+ });
279
+ }
280
+
281
+ function resolvePullRequest(environments, base, number, draft) {
282
+ return Stdlib_Array.findMap(environments, param => {
283
+ let feed = param.feed;
284
+ let name = param.name;
285
+ if (feed.TAG === "Shared" || !(matches(feed.into, base) && (feed.drafts || !draft))) {
286
+ return;
287
+ } else {
288
+ return {
289
+ name: name,
290
+ stack: name + `-pr-` + number.toString(),
291
+ purpose: param.purpose
292
+ };
293
+ }
294
+ });
295
+ }
296
+
297
+ export {
298
+ purposeSchema,
299
+ purposeName,
300
+ entrySchema,
301
+ fileSchema,
302
+ environmentOf,
303
+ parseString,
304
+ parseFile,
305
+ entryOf,
306
+ print,
307
+ fileName,
308
+ pathBeside,
309
+ matches,
310
+ refOf,
311
+ resolve,
312
+ resolvePullRequest,
313
+ }
314
+ /* purposeSchema Not a pure module */
@@ -2,6 +2,23 @@
2
2
  @schema
3
3
  type service = string
4
4
 
5
+ /**
6
+ The issuing caller, classified for `@owner` enforcement (`OwnerScope.toClaim`).
7
+
8
+ Written only by the command generator, from the authenticated identity, so a
9
+ handler can tell an owner from an operator without seeing the caller's groups.
10
+ Absent on every command that did not come through the generator — an
11
+ automation's follow-up, an internal dispatch — which a handler reads as the
12
+ platform acting for itself.
13
+ */
14
+ module CallerClaim = {
15
+ @schema
16
+ type t =
17
+ | Owned({userId: string})
18
+ | Exempt
19
+ | Unidentified
20
+ }
21
+
5
22
  /**
6
23
  Envelope metadata attached to every event and command.
7
24
 
@@ -50,6 +67,10 @@ type meta = {
50
67
  /** Extensible header bag for cross-cutting context (tenantId, feature flags, etc.).
51
68
  Absent when empty — consumers normalise with `->Option.getOr(Dict.make())`. */
52
69
  headers?: dict<string>,
70
+ /** Commands only: the caller as the generator classified it. Deliberately not
71
+ inherited by `deriveMeta`, so an event or a follow-up command never carries
72
+ the claim of the command that caused it. */
73
+ callerClaim?: CallerClaim.t,
53
74
  }
54
75
 
55
76
  /**
@@ -6,6 +6,19 @@ import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
6
6
 
7
7
  let serviceSchema = Sury.string;
8
8
 
9
+ let schema = Sury.union([
10
+ Sury.$schema(s => ({
11
+ TAG: "Owned",
12
+ userId: s.m(Sury.string)
13
+ })),
14
+ Sury.literal("Exempt"),
15
+ Sury.literal("Unidentified")
16
+ ]);
17
+
18
+ let CallerClaim = {
19
+ schema: schema
20
+ };
21
+
9
22
  let metaSchema = Sury.$schema(s => ({
10
23
  service: s.m(serviceSchema),
11
24
  time: s.m(Sury.string),
@@ -16,7 +29,8 @@ let metaSchema = Sury.$schema(s => ({
16
29
  causationId: s.m(Sury.$option(Sury.string)),
17
30
  traceparent: s.m(Sury.$option(Sury.string)),
18
31
  schemaVersion: s.m(Sury.$option(Sury.string)),
19
- headers: s.m(Sury.$option(Sury.record(Sury.string)))
32
+ headers: s.m(Sury.$option(Sury.record(Sury.string))),
33
+ callerClaim: s.m(Sury.$option(schema))
20
34
  }));
21
35
 
22
36
  let contextSchema = Sury.$schema(s => ({
@@ -189,6 +203,7 @@ function composeEventJson$p(id, meta, eventJson) {
189
203
 
190
204
  export {
191
205
  serviceSchema,
206
+ CallerClaim,
192
207
  metaSchema,
193
208
  contextSchema,
194
209
  statusChangeSchema,