hecks 3.4.2 → 3.5.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.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
- data/lib/hecks/deploy/oidc.json +225 -0
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
- data/lib/hecks/hecks/custodian.bluebook +12 -2
- data/lib/hecks/hecks/hecks.hecksagon +57 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +8 -14
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/site/bluebook/site.bluebook +7 -1
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +17 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
module SiteHost
|
|
9
|
+
# The text of the two files, written as the file a project would write by hand.
|
|
10
|
+
module Templates
|
|
11
|
+
BANNER = "Generated by hecks site site_projection.project_site. Do not edit."
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param cms [Hash{Symbol => Object}] the checked `Cms` row
|
|
16
|
+
# @return [String] the image's build file
|
|
17
|
+
def dockerfile(cms)
|
|
18
|
+
format(DOCKERFILE, banner: BANNER, node: cms[:node], port: cms[:port], heap: cms[:heap_mb])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param secrets [Array<Hash{Symbol => Object}>] the checked `BootSecret` rows
|
|
22
|
+
# @return [String] the start-up script
|
|
23
|
+
def boot(secrets)
|
|
24
|
+
["// #{BANNER}", BOOT_HEAD, *secrets.map { |secret| extra(secret) }, BOOT_TAIL].join("\n\n") << "\n"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def extra(secret)
|
|
28
|
+
from = JSON.generate(secret[:from])
|
|
29
|
+
env = JSON.generate(secret[:env])
|
|
30
|
+
return field_secret(from, env, JSON.generate(secret[:field])) if secret[:field]
|
|
31
|
+
|
|
32
|
+
whole_secret(secret, from, env)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def field_secret(from, env, field)
|
|
36
|
+
<<~JS.chomp
|
|
37
|
+
if (process.env[#{from}]) {
|
|
38
|
+
process.env[#{env}] = await secretField(process.env[#{from}], #{field});
|
|
39
|
+
}
|
|
40
|
+
JS
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def whole_secret(secret, from, env)
|
|
44
|
+
<<~JS.chomp
|
|
45
|
+
// #{secret[:env]} is the whole of the secret named by #{secret[:from]}. Optional and non-fatal.
|
|
46
|
+
if (process.env[#{from}] && !process.env[#{env}]) {
|
|
47
|
+
try {
|
|
48
|
+
const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: process.env[#{from}] }));
|
|
49
|
+
if (SecretString) process.env[#{env}] = SecretString;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn(`boot: could not read #{secret[:from]}: ${err instanceof Error ? err.message : err}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
JS
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
BOOT_HEAD = <<~JS.chomp
|
|
58
|
+
// Cold-start bootstrap: resolve the secrets, then start Next.
|
|
59
|
+
//
|
|
60
|
+
// The database password and the signing secret are fetched from Secrets Manager here, in the
|
|
61
|
+
// process, and composed into the environment keys payload.config.ts reads (DATABASE_URL,
|
|
62
|
+
// PAYLOAD_SECRET), so neither is ever a plaintext environment variable of the task. A
|
|
63
|
+
// DATABASE_URL / PAYLOAD_SECRET already in the environment is used when the *_ARN variables are
|
|
64
|
+
// absent: a person running this file locally.
|
|
65
|
+
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
|
66
|
+
|
|
67
|
+
const client = new SecretsManagerClient({});
|
|
68
|
+
|
|
69
|
+
async function secretField(arn, field) {
|
|
70
|
+
const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: arn }));
|
|
71
|
+
const value = JSON.parse(SecretString)[field];
|
|
72
|
+
if (!value) throw new Error(`secret ${arn} has no "${field}" field`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (process.env.DB_SECRET_ARN) {
|
|
77
|
+
const { DB_HOST, DB_NAME } = process.env;
|
|
78
|
+
if (!DB_HOST || !DB_NAME) throw new Error("DB_HOST and DB_NAME are required when DB_SECRET_ARN is set");
|
|
79
|
+
const password = await secretField(process.env.DB_SECRET_ARN, "password");
|
|
80
|
+
// The password goes through encodeURIComponent: the generated one can hold characters (@ / #)
|
|
81
|
+
// that would otherwise split the URL. RDS enforces TLS; verify against Amazon's CA bundle,
|
|
82
|
+
// which the image holds beside this file.
|
|
83
|
+
const ssl = "sslmode=verify-full&sslrootcert=" + new URL("./rds-global-bundle.pem", import.meta.url).pathname;
|
|
84
|
+
process.env.DATABASE_URL = `postgres://postgres:${encodeURIComponent(password)}@${DB_HOST}:5432/${DB_NAME}?${ssl}`;
|
|
85
|
+
} else if (!process.env.DATABASE_URL) {
|
|
86
|
+
throw new Error("either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (process.env.PAYLOAD_SECRET_ARN) {
|
|
90
|
+
process.env.PAYLOAD_SECRET = await secretField(process.env.PAYLOAD_SECRET_ARN, "secret");
|
|
91
|
+
} else if (!process.env.PAYLOAD_SECRET) {
|
|
92
|
+
throw new Error("either PAYLOAD_SECRET_ARN or PAYLOAD_SECRET is required");
|
|
93
|
+
}
|
|
94
|
+
JS
|
|
95
|
+
|
|
96
|
+
BOOT_TAIL = <<~JS.chomp
|
|
97
|
+
// Next's standalone server starts listening on import (PORT and HOSTNAME come from the environment).
|
|
98
|
+
await import("./server.js");
|
|
99
|
+
JS
|
|
100
|
+
|
|
101
|
+
DOCKERFILE = <<~'DOCKER'
|
|
102
|
+
# %<banner>s
|
|
103
|
+
#
|
|
104
|
+
# The content system (Payload on Next's standalone server) as a container. Build from this
|
|
105
|
+
# directory. deploy-aws/boot.mjs resolves the secrets named by the *_ARN variables into
|
|
106
|
+
# DATABASE_URL and PAYLOAD_SECRET before it starts the server, so nothing here is a secret.
|
|
107
|
+
#
|
|
108
|
+
# Runtime environment, supplied by the task definition:
|
|
109
|
+
# NODE_ENV=production, USE_POSTGRES=true, USE_S3_STORAGE=true, S3_MEDIA_BUCKET
|
|
110
|
+
# DB_HOST, DB_NAME, DB_SECRET_ARN, PAYLOAD_SECRET_ARN, CORS_ORIGINS, AWS_REGION
|
|
111
|
+
|
|
112
|
+
# ---- build stage ----
|
|
113
|
+
FROM node:%<node>s-slim AS build
|
|
114
|
+
WORKDIR /app
|
|
115
|
+
|
|
116
|
+
# A lockfile written on macOS omits the Linux entries of optional native packages
|
|
117
|
+
# (sharp), and `next build` then fails deep inside page-data collection. Write it on the
|
|
118
|
+
# image's own platform: remove node_modules and package-lock.json and run npm install.
|
|
119
|
+
COPY package.json package-lock.json ./
|
|
120
|
+
RUN npm ci
|
|
121
|
+
|
|
122
|
+
COPY . .
|
|
123
|
+
# payload-types.ts is generated and untracked, so a clean checkout has none and the type check
|
|
124
|
+
# inside `next build` would see untyped results. Generating it only loads the config
|
|
125
|
+
# (no database); the values are placeholders and the flags are the ones production boots with.
|
|
126
|
+
RUN USE_POSTGRES=true USE_S3_STORAGE=true PAYLOAD_SECRET=build-only \
|
|
127
|
+
S3_MEDIA_BUCKET=build-only AWS_REGION=us-east-1 DATABASE_URL=postgres://build-only/build-only \
|
|
128
|
+
npm run generate:types
|
|
129
|
+
RUN npm run build
|
|
130
|
+
|
|
131
|
+
# ---- runtime stage ----
|
|
132
|
+
FROM node:%<node>s-slim
|
|
133
|
+
WORKDIR /app
|
|
134
|
+
ENV NODE_ENV=production
|
|
135
|
+
# The task is shared with sibling containers; without a cap Node sizes its heap from the whole task.
|
|
136
|
+
ENV NODE_OPTIONS=--max-old-space-size=%<heap>d
|
|
137
|
+
|
|
138
|
+
# Next's standalone output holds a minimal server.js and its traced node_modules; the static
|
|
139
|
+
# files are not part of it and are copied separately.
|
|
140
|
+
COPY --from=build /app/.next/standalone/ ./
|
|
141
|
+
COPY --from=build /app/.next/static ./.next/static
|
|
142
|
+
|
|
143
|
+
COPY deploy-aws/boot.mjs ./
|
|
144
|
+
# RDS enforces TLS; boot.mjs verifies against Amazon's CA bundle.
|
|
145
|
+
ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
|
|
146
|
+
|
|
147
|
+
# boot.mjs sits outside the Next app, so `next build` does not trace its import of the AWS SDK.
|
|
148
|
+
COPY --from=build /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
|
|
149
|
+
COPY --from=build /app/node_modules/@smithy ./node_modules/@smithy
|
|
150
|
+
COPY --from=build /app/node_modules/tslib ./node_modules/tslib
|
|
151
|
+
|
|
152
|
+
# Next's standalone server reads HOSTNAME for its bind address.
|
|
153
|
+
ENV HOSTNAME=0.0.0.0
|
|
154
|
+
ENV PORT=%<port>d
|
|
155
|
+
EXPOSE %<port>d
|
|
156
|
+
|
|
157
|
+
# Nothing writes to /app at runtime (media goes to S3), so no root.
|
|
158
|
+
USER node
|
|
159
|
+
CMD ["node", "boot.mjs"]
|
|
160
|
+
DOCKER
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../projector"
|
|
5
|
+
require_relative "root_rows"
|
|
6
|
+
require_relative "site_host/templates"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Projections
|
|
10
|
+
module Site
|
|
11
|
+
# The content system's container image and its start-up script, from rows beside the route
|
|
12
|
+
# table: `cms/Dockerfile` and `cms/deploy-aws/boot.mjs`.
|
|
13
|
+
#
|
|
14
|
+
# The script resolves the content system's secrets before it starts the server: the database
|
|
15
|
+
# password and the signing secret always, and any further secret a `BootSecret` row names.
|
|
16
|
+
# Each is
|
|
17
|
+
# fetched from Secrets Manager in the process, so none is ever a plaintext environment
|
|
18
|
+
# variable of
|
|
19
|
+
# the task. The image is a plain two-stage build of a Next standalone server that runs the
|
|
20
|
+
# script.
|
|
21
|
+
module SiteHost
|
|
22
|
+
extend Projector::Target
|
|
23
|
+
|
|
24
|
+
projects_as :site_host, emits: :files
|
|
25
|
+
|
|
26
|
+
# The `Cms` row: where the content system lives and how its image is built.
|
|
27
|
+
CMS = RootRows.new("Cms", fields: { dir: String, node: String, port: Integer, heap_mb: Integer,
|
|
28
|
+
dockerfile: [TrueClass, FalseClass] },
|
|
29
|
+
defaults: { dir: "cms", node: "22", port: 8080, heap_mb: 1024, dockerfile: true })
|
|
30
|
+
|
|
31
|
+
# A `BootSecret` row: an environment variable filled from a secret whose id the variable
|
|
32
|
+
# `from`
|
|
33
|
+
# holds. With `field` the secret is JSON and that field is the value, required once the
|
|
34
|
+
# secret
|
|
35
|
+
# is named; without it the whole secret is the value and failing to read it only warns.
|
|
36
|
+
BOOT_SECRETS = RootRows.new("BootSecret", fields: { env: String, from: String, field: String },
|
|
37
|
+
required: %i[env from], many: true)
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param bluebook [Bluebook::Chapter] the chapter that declares the route table
|
|
42
|
+
# @param options [Hash{Symbol => Object}] unused
|
|
43
|
+
# @return [Hash{String => String}] each file's path relative to the project root to its
|
|
44
|
+
# text;
|
|
45
|
+
# empty when the project declares no `Cms` row
|
|
46
|
+
# @raise [Table::Invalid] when a row is refused
|
|
47
|
+
def call(bluebook:, options: {})
|
|
48
|
+
cms = CMS.read(bluebook).first
|
|
49
|
+
return {} unless cms
|
|
50
|
+
|
|
51
|
+
files = { "#{cms[:dir]}/deploy-aws/boot.mjs" => Templates.boot(BOOT_SECRETS.read(bluebook)) }
|
|
52
|
+
files["#{cms[:dir]}/Dockerfile"] = Templates.dockerfile(cms) if cms[:dockerfile]
|
|
53
|
+
files
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../projector"
|
|
4
|
+
require_relative "root_rows"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Projections
|
|
8
|
+
module Site
|
|
9
|
+
# The files at a project's root that are written from rows beside its route table: the
|
|
10
|
+
# local-stack settings template (`.env.tpl`, from `Secrets` and `Env` rows) and the workflow
|
|
11
|
+
# that
|
|
12
|
+
# keeps the generated files current (`.github/workflows/site-routes.yml`, from a `Ci` row).
|
|
13
|
+
#
|
|
14
|
+
# Each file is written only when its rows are declared. Settings that are secrets are
|
|
15
|
+
# references
|
|
16
|
+
# into 1Password, never values, so nothing secret is written to disk.
|
|
17
|
+
module SiteRoot
|
|
18
|
+
extend Projector::Target
|
|
19
|
+
|
|
20
|
+
projects_as :site_root, emits: :files
|
|
21
|
+
|
|
22
|
+
# The `Secrets` row: where the secrets live and what starts the stack.
|
|
23
|
+
SECRETS = RootRows.new("Secrets", fields: { vault: String, item: String, section: String, launcher: String },
|
|
24
|
+
required: %i[vault item])
|
|
25
|
+
|
|
26
|
+
# An `Env` row: a plain setting when it carries `value`, a secret reference when it does
|
|
27
|
+
# not. `group` heads the rows from it on with a comment; `off` comments the line out.
|
|
28
|
+
ENV_ROWS = RootRows.new("Env", required: %i[name], many: true,
|
|
29
|
+
fields: { name: String, value: [String, Integer, TrueClass, FalseClass], group: String,
|
|
30
|
+
off: [TrueClass, FalseClass] })
|
|
31
|
+
|
|
32
|
+
# The `Ci` row: the workflow that checks the generated files.
|
|
33
|
+
CI = RootRows.new("Ci", fields: { name: String, ruby: String, node: String, gem_dir: String, script: String,
|
|
34
|
+
test: String, paths: String, install: String },
|
|
35
|
+
required: %i[gem_dir],
|
|
36
|
+
defaults: { name: "Site routes", ruby: "3.3", node: "22", script: "bin/site_routes",
|
|
37
|
+
test: "npm test", paths: "", install: "npm ci" })
|
|
38
|
+
|
|
39
|
+
# The workflow's path and the steps' action pins, which a project does not choose.
|
|
40
|
+
WORKFLOW = ".github/workflows/site-routes.yml"
|
|
41
|
+
BANNER = "# Generated by hecks site site_projection.project_site. Do not edit."
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# @param bluebook [Bluebook::Chapter] the chapter that declares the route table
|
|
46
|
+
# @param options [Hash{Symbol => Object}] unused
|
|
47
|
+
# @return [Hash{String => String}] each file's path relative to the project root to its
|
|
48
|
+
# text;
|
|
49
|
+
# empty when the project declares no such rows
|
|
50
|
+
# @raise [Table::Invalid] when a row is refused
|
|
51
|
+
def call(bluebook:, options: {})
|
|
52
|
+
files = {}
|
|
53
|
+
env = env_template(bluebook)
|
|
54
|
+
files[".env.tpl"] = env if env
|
|
55
|
+
ci = CI.read(bluebook).first
|
|
56
|
+
files[WORKFLOW] = workflow(ci) if ci
|
|
57
|
+
files
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String, nil] the template, or nil with no `Env` rows
|
|
61
|
+
def env_template(chapter)
|
|
62
|
+
vars = ENV_ROWS.read(chapter)
|
|
63
|
+
return nil if vars.empty?
|
|
64
|
+
|
|
65
|
+
secrets = SECRETS.read(chapter).first
|
|
66
|
+
if secrets.nil? && vars.any? { |var| !var.key?(:value) }
|
|
67
|
+
raise Table::Invalid, "Env rows without a value are secrets; the project declares no Secrets row"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
[BANNER, *header(secrets), *body(vars, secrets)].join("\n") << "\n"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def header(secrets)
|
|
74
|
+
return [] unless secrets
|
|
75
|
+
|
|
76
|
+
where = ["vault \"#{secrets[:vault]}\"", "item \"#{secrets[:item]}\"",
|
|
77
|
+
*(secrets[:section] ? ["section \"#{secrets[:section]}\""] : [])].join(", ")
|
|
78
|
+
starter = secrets[:launcher] ? " (#{secrets[:launcher]} does this)" : ""
|
|
79
|
+
["# Local-stack settings. Secrets are op:// references into 1Password (#{where}),",
|
|
80
|
+
"# resolved at start-up by `op run --env-file=.env.tpl -- <command>`#{starter};",
|
|
81
|
+
"# no secret is ever written to disk. The plain lines are not secret."]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def body(vars, secrets)
|
|
85
|
+
group = nil
|
|
86
|
+
vars.flat_map do |var|
|
|
87
|
+
heading = var[:group] && var[:group] != group ? ["", "# #{var[:group]}"] : []
|
|
88
|
+
group = var[:group] if var[:group]
|
|
89
|
+
[*heading, line(var, secrets)]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def line(var, secrets)
|
|
94
|
+
text = "#{var[:name]}=#{var.key?(:value) ? var[:value] : reference(var, secrets)}"
|
|
95
|
+
var[:off] ? "# #{text}" : text
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reference(var, secrets)
|
|
99
|
+
"op://#{[secrets[:vault], secrets[:item], secrets[:section], var[:name]].compact.join('/')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param row [Hash{Symbol => Object}] the checked `Ci` row
|
|
103
|
+
# @return [String] the workflow
|
|
104
|
+
def workflow(row)
|
|
105
|
+
paths = path_list(row).map { |path| " - #{path.to_s.inspect}" }.join("\n")
|
|
106
|
+
format(WORKFLOW_TEXT, banner: BANNER, name: row[:name], paths: paths, ruby: row[:ruby].inspect,
|
|
107
|
+
node: row[:node].inspect, gem_dir: row[:gem_dir], script: row[:script], test: row[:test],
|
|
108
|
+
install_steps: install_steps(row[:install]))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The steps that install the project's dependencies; a project that installs with yarn gets
|
|
112
|
+
# corepack enabled first, so the version it pins is the one that runs.
|
|
113
|
+
def install_steps(install)
|
|
114
|
+
steps = []
|
|
115
|
+
steps << "- name: Enable corepack\n run: corepack enable" if install.start_with?("yarn")
|
|
116
|
+
steps << "- name: Install dependencies\n run: #{install}"
|
|
117
|
+
steps.join("\n\n ")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def path_list(row)
|
|
121
|
+
listed = row[:paths].split(",").map(&:strip).reject(&:empty?)
|
|
122
|
+
[*listed, row[:script], "#{row[:gem_dir]}/Gemfile.lock", WORKFLOW].uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
WORKFLOW_TEXT = <<~YAML
|
|
126
|
+
%<banner>s
|
|
127
|
+
# Two checks, neither needing AWS or a database:
|
|
128
|
+
#
|
|
129
|
+
# 1. the generated files are current: `%<script>s --check` regenerates them with the hecks
|
|
130
|
+
# gem the Gemfile.lock pins and exits 1, naming each file, if a row was edited without
|
|
131
|
+
# regenerating or a generated file was hand-edited;
|
|
132
|
+
# 2. `%<test>s`, which holds the generated code to what the hand-written code does.
|
|
133
|
+
name: %<name>s
|
|
134
|
+
|
|
135
|
+
on:
|
|
136
|
+
push:
|
|
137
|
+
branches: [main]
|
|
138
|
+
paths:
|
|
139
|
+
%<paths>s
|
|
140
|
+
pull_request:
|
|
141
|
+
paths:
|
|
142
|
+
%<paths>s
|
|
143
|
+
# Lets a GitHub merge queue run this check on each queued PR (paths do not apply to merge_group).
|
|
144
|
+
merge_group:
|
|
145
|
+
workflow_dispatch: {}
|
|
146
|
+
|
|
147
|
+
permissions:
|
|
148
|
+
contents: read
|
|
149
|
+
|
|
150
|
+
jobs:
|
|
151
|
+
routes-current-and-parity:
|
|
152
|
+
runs-on: ubuntu-24.04
|
|
153
|
+
timeout-minutes: 15
|
|
154
|
+
|
|
155
|
+
steps:
|
|
156
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
157
|
+
|
|
158
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
|
|
159
|
+
with:
|
|
160
|
+
ruby-version: %<ruby>s
|
|
161
|
+
bundler-cache: true
|
|
162
|
+
working-directory: %<gem_dir>s
|
|
163
|
+
|
|
164
|
+
- name: The generated files are current
|
|
165
|
+
run: %<script>s --check
|
|
166
|
+
|
|
167
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
168
|
+
with:
|
|
169
|
+
node-version: %<node>s
|
|
170
|
+
|
|
171
|
+
%<install_steps>s
|
|
172
|
+
|
|
173
|
+
- name: Parity between the generated code and the hand-written code
|
|
174
|
+
run: %<test>s
|
|
175
|
+
YAML
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -99,6 +99,8 @@ Hecks.bluebook "Site" do
|
|
|
99
99
|
attribute :run, RunKey
|
|
100
100
|
attribute :out, Location, optional: true
|
|
101
101
|
attribute :template, Location, optional: true
|
|
102
|
+
attribute :cms, Location, optional: true
|
|
103
|
+
attribute :root, Location, optional: true
|
|
102
104
|
attribute :extension, Extension, optional: true
|
|
103
105
|
attribute :check, Switch, optional: true
|
|
104
106
|
attribute :live, Location, optional: true
|
|
@@ -162,7 +164,7 @@ Hecks.bluebook "Site" do
|
|
|
162
164
|
transition "Fault" => "faulted", from: "requested"
|
|
163
165
|
end
|
|
164
166
|
|
|
165
|
-
# `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [extension=ts|mts] [--check]`.
|
|
167
|
+
# `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [cms=<dir>] [root=<dir>] [extension=ts|mts] [--check]`.
|
|
166
168
|
# With `check` nothing is written: a routes.ts that differs from the table is faulted, naming the
|
|
167
169
|
# file, so a CI job fails on drift.
|
|
168
170
|
command "ProjectSite" do
|
|
@@ -172,6 +174,8 @@ Hecks.bluebook "Site" do
|
|
|
172
174
|
attribute :run, RunKey
|
|
173
175
|
attribute :out, Location, optional: true
|
|
174
176
|
attribute :template, Location, optional: true
|
|
177
|
+
attribute :cms, Location, optional: true
|
|
178
|
+
attribute :root, Location, optional: true
|
|
175
179
|
attribute :extension, Extension, optional: true
|
|
176
180
|
attribute :check, Switch, optional: true
|
|
177
181
|
|
|
@@ -179,6 +183,8 @@ Hecks.bluebook "Site" do
|
|
|
179
183
|
sets :run
|
|
180
184
|
sets :out
|
|
181
185
|
sets :template
|
|
186
|
+
sets :cms
|
|
187
|
+
sets :root
|
|
182
188
|
sets :extension
|
|
183
189
|
sets :check
|
|
184
190
|
|
|
@@ -7,15 +7,21 @@ require_relative "../tools"
|
|
|
7
7
|
require_relative "../projections/site/routes_ts"
|
|
8
8
|
require_relative "../projections/site/site_cdn"
|
|
9
9
|
require_relative "../projections/site/regions"
|
|
10
|
+
require_relative "../projections/site/admin"
|
|
11
|
+
require_relative "../projections/site/site_admin"
|
|
12
|
+
require_relative "../projections/site/admin_cms"
|
|
13
|
+
require_relative "../projections/site/site_root"
|
|
14
|
+
require_relative "../projections/site/site_host"
|
|
15
|
+
require_relative "../projections/site/payload_driver"
|
|
10
16
|
|
|
11
17
|
module Hecks
|
|
12
18
|
module Tools
|
|
13
|
-
# Projects a project's route table into `routes.ts` (`Projections::Site::RoutesTs`)
|
|
14
|
-
#
|
|
15
|
-
# (`Projections::Site::
|
|
19
|
+
# Projects a project's route table into `routes.ts` (`Projections::Site::RoutesTs`), its edge
|
|
20
|
+
# into the behaviours and listener rules of its template (`Projections::Site::SiteCdn`) and its
|
|
21
|
+
# `Admin` row into `admin.ts` (`Projections::Site::SiteAdmin`), each when it declares one.
|
|
16
22
|
#
|
|
17
23
|
# hecks site site_projection.project_site [<project>] [--out=<dir>] [--template=<file>]
|
|
18
|
-
#
|
|
24
|
+
# [--cms=<dir>] [--root=<dir>] [--extension=ts|mts] [--check]
|
|
19
25
|
#
|
|
20
26
|
# `<project>` (default: the working directory) holds `bluebook/` and the `vendor/` it attaches
|
|
21
27
|
# from. `routes.ts` goes to `<project>/generated` or to `--out`, anywhere. The template is the
|
|
@@ -30,7 +36,7 @@ module Hecks
|
|
|
30
36
|
EXTENSIONS = %w[ts mts].freeze
|
|
31
37
|
|
|
32
38
|
USAGE = "usage: hecks site site_projection.project_site [<project>] [--out=<dir>] " \
|
|
33
|
-
"[--template=<file>] [--extension=#{EXTENSIONS.join('|')}] [--check]".freeze
|
|
39
|
+
"[--template=<file>] [--cms=<dir>] [--root=<dir>] [--extension=#{EXTENSIONS.join('|')}] [--check]".freeze
|
|
34
40
|
|
|
35
41
|
module_function
|
|
36
42
|
|
|
@@ -43,18 +49,20 @@ module Hecks
|
|
|
43
49
|
# table is refused, or a flag is refused
|
|
44
50
|
def main(argv, root: Dir.pwd)
|
|
45
51
|
argv = argv.dup
|
|
46
|
-
out = template = extension = nil
|
|
52
|
+
out = template = extension = cms = root_dir = nil
|
|
47
53
|
check = false
|
|
48
54
|
OptionParser.new do |parser|
|
|
49
55
|
parser.on("--out=DIR") { |value| out = value }
|
|
50
56
|
parser.on("--template=FILE") { |value| template = value }
|
|
57
|
+
parser.on("--cms=DIR") { |value| cms = value }
|
|
58
|
+
parser.on("--root=DIR") { |value| root_dir = value }
|
|
51
59
|
parser.on("--extension=EXT") { |value| extension = value }
|
|
52
60
|
parser.on("--check") { check = true }
|
|
53
61
|
end.parse!(argv)
|
|
54
62
|
project = argv.empty? ? root : File.expand_path(argv.shift)
|
|
55
63
|
abort USAGE unless argv.empty?
|
|
56
64
|
|
|
57
|
-
files = projection(project, out: out, template: template, extension: extension)
|
|
65
|
+
files = projection(project, out: out, template: template, extension: extension, cms: cms, root_dir: root_dir)
|
|
58
66
|
stale = files.reject { |path, text| File.file?(path) && File.read(path) == text }.keys
|
|
59
67
|
return report(stale, project, check) if check
|
|
60
68
|
|
|
@@ -75,7 +83,7 @@ module Hecks
|
|
|
75
83
|
# @return [Hash{String => String}] each file's absolute path to the text it should hold
|
|
76
84
|
# @raise [SystemExit] when the project declares no route table, the table is refused, or a
|
|
77
85
|
# path or the extension is refused
|
|
78
|
-
def projection(root, out: nil, template: nil, extension: nil)
|
|
86
|
+
def projection(root, out: nil, template: nil, extension: nil, cms: nil, root_dir: nil)
|
|
79
87
|
extension ||= EXTENSIONS.first
|
|
80
88
|
unless EXTENSIONS.include?(extension)
|
|
81
89
|
abort "project_site: --extension is one of #{EXTENSIONS.join(', ')}, not #{extension.inspect}"
|
|
@@ -88,11 +96,73 @@ module Hecks
|
|
|
88
96
|
table = Projections::Site::Table.read(chapter, registry: registry)
|
|
89
97
|
files = Projector.call(:site_routes_ts, bluebook: chapter, options: { table: table })
|
|
90
98
|
written = files.to_h { |name, text| [File.join(dir, name.sub(/\.ts\z/, ".#{extension}")), text] }
|
|
99
|
+
admin = Projections::Site::Admin.read(chapter, table: table)
|
|
100
|
+
written.merge!(admin_files(dir, chapter, admin, extension))
|
|
101
|
+
written.merge!(cms_files(cms, chapter, admin)) if cms
|
|
102
|
+
written.merge!(root_files(root_dir, chapter)) if root_dir
|
|
91
103
|
written.merge(template_files(root, out, chapter, table, registry, template: template))
|
|
92
104
|
rescue Projections::Site::Table::Invalid => e
|
|
93
105
|
abort "project_site: #{e.message}"
|
|
94
106
|
end
|
|
95
107
|
|
|
108
|
+
# The admin sign-in module, when the project declares an `Admin` row.
|
|
109
|
+
#
|
|
110
|
+
# @param dir [String] the directory the route table module is written to
|
|
111
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
112
|
+
# @param admin [Projections::Site::Admin, nil] the checked admin row
|
|
113
|
+
# @param extension [String] the extension the modules are written with
|
|
114
|
+
# @return [Hash{String => String}] the module's path to its text; empty with no admin row
|
|
115
|
+
def admin_files(dir, chapter, admin, extension)
|
|
116
|
+
files = Projector.call(:site_admin, bluebook: chapter, options: { admin: admin, extension: extension })
|
|
117
|
+
files.to_h { |name, text| [File.join(dir, name.sub(/\.ts\z/, ".#{extension}")), text] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The files at the project's root that its rows write: the settings template, the workflow,
|
|
121
|
+
# the
|
|
122
|
+
# content system's image and start-up script, and the files that drive the domain.
|
|
123
|
+
#
|
|
124
|
+
# @param dir [String] the directory they are written under, relative to the working directory
|
|
125
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
126
|
+
# @return [Hash{String => String}] each file's absolute path to its text; empty with no rows
|
|
127
|
+
def root_files(dir, chapter)
|
|
128
|
+
base = File.expand_path(dir)
|
|
129
|
+
files = Projector.call(:site_root, bluebook: chapter).merge(Projector.call(:site_host, bluebook: chapter))
|
|
130
|
+
files = files.merge(payload_files(base, chapter))
|
|
131
|
+
files.to_h { |name, text| [File.join(base, name), text] }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The files that drive the project's domain from the content system, when it declares a
|
|
135
|
+
# `Payload` row.
|
|
136
|
+
#
|
|
137
|
+
# @param base [String] the directory the project's root files are written under
|
|
138
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table and the rows
|
|
139
|
+
# @return [Hash{String => String}] each file's path relative to `base` to its text
|
|
140
|
+
def payload_files(base, chapter)
|
|
141
|
+
row = Projections::Site::PayloadDriver::PAYLOAD.read(chapter).first
|
|
142
|
+
return {} unless row
|
|
143
|
+
|
|
144
|
+
domain_root = File.join(base, row[:domain])
|
|
145
|
+
domain = registry_for(domain_root).bluebooks.values.find { |candidate| candidate.name == row[:chapter] }
|
|
146
|
+
abort "project_site: #{domain_root} declares no chapter #{row[:chapter]}" unless domain
|
|
147
|
+
Projector.call(:payload_driver, bluebook: chapter, options: { domain_chapter: domain })
|
|
148
|
+
rescue ArgumentError => e
|
|
149
|
+
abort "project_site: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The content system's half of the admin sign-in: four files under the `--cms` directory.
|
|
153
|
+
#
|
|
154
|
+
# @param cms [String] the directory the files are written under
|
|
155
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
156
|
+
# @param admin [Projections::Site::Admin, nil] the checked admin row
|
|
157
|
+
# @return [Hash{String => String}] each file's absolute path to its text
|
|
158
|
+
# @raise [SystemExit] when `--cms` is named and the project declares no `Admin` row
|
|
159
|
+
def cms_files(cms, chapter, admin)
|
|
160
|
+
abort "project_site: --cms names #{cms}, but the project declares no Admin row" if admin.nil?
|
|
161
|
+
|
|
162
|
+
files = Projector.call(:site_admin_cms, bluebook: chapter, options: { admin: admin })
|
|
163
|
+
files.to_h { |name, text| [File.join(File.expand_path(cms), name), text] }
|
|
164
|
+
end
|
|
165
|
+
|
|
96
166
|
# The template with the edge's regions rewritten, when the project declares an edge.
|
|
97
167
|
#
|
|
98
168
|
# @param root [String] the project directory
|
data/lib/hecks/version.rb
CHANGED
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.5.0
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
@@ -364,6 +364,7 @@ files:
|
|
|
364
364
|
- lib/hecks/grammar/grammar.hecksagon
|
|
365
365
|
- lib/hecks/grammar/oidc.json
|
|
366
366
|
- lib/hecks/grammar/translation.bluebook
|
|
367
|
+
- lib/hecks/hecks/adapters/bluebook_report.rb
|
|
367
368
|
- lib/hecks/hecks/adapters/codebase/codemods.rb
|
|
368
369
|
- lib/hecks/hecks/adapters/codebase/conformance.rb
|
|
369
370
|
- lib/hecks/hecks/adapters/codebase/corpus_tasks.rb
|
|
@@ -387,6 +388,7 @@ files:
|
|
|
387
388
|
- lib/hecks/hecks/adapters/console_capture.rb
|
|
388
389
|
- lib/hecks/hecks/adapters/cost_explorer.adapter
|
|
389
390
|
- lib/hecks/hecks/adapters/cost_explorer.rb
|
|
391
|
+
- lib/hecks/hecks/adapters/deploy_plans.rb
|
|
390
392
|
- lib/hecks/hecks/adapters/deploy_toolchain.adapter
|
|
391
393
|
- lib/hecks/hecks/adapters/deploy_toolchain.rb
|
|
392
394
|
- lib/hecks/hecks/adapters/finding_github.adapter
|
|
@@ -554,6 +556,7 @@ files:
|
|
|
554
556
|
- lib/hecks/projections/deploy/box.rb
|
|
555
557
|
- lib/hecks/projections/deploy/box/hosting.rb
|
|
556
558
|
- lib/hecks/projections/deploy/box/hosting_settings.rb
|
|
559
|
+
- lib/hecks/projections/deploy/box/roll_recipe.rb
|
|
557
560
|
- lib/hecks/projections/deploy/box/settings.rb
|
|
558
561
|
- lib/hecks/projections/deploy/box/templates/box.yaml.tmpl
|
|
559
562
|
- lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl
|
|
@@ -621,15 +624,28 @@ files:
|
|
|
621
624
|
- lib/hecks/projections/reference.rb
|
|
622
625
|
- lib/hecks/projections/rust_vocabulary.rb
|
|
623
626
|
- lib/hecks/projections/shape.rb
|
|
627
|
+
- lib/hecks/projections/site/admin.rb
|
|
628
|
+
- lib/hecks/projections/site/admin_cms.rb
|
|
629
|
+
- lib/hecks/projections/site/admin_cms/templates.rb
|
|
624
630
|
- lib/hecks/projections/site/edge.rb
|
|
625
631
|
- lib/hecks/projections/site/edge/behaviours.rb
|
|
626
632
|
- lib/hecks/projections/site/edge/checks.rb
|
|
627
633
|
- lib/hecks/projections/site/edge/pattern.rb
|
|
628
634
|
- lib/hecks/projections/site/edge/rules.rb
|
|
629
635
|
- lib/hecks/projections/site/live_edge.rb
|
|
636
|
+
- lib/hecks/projections/site/payload/fields.rb
|
|
637
|
+
- lib/hecks/projections/site/payload/lifecycle.rb
|
|
638
|
+
- lib/hecks/projections/site/payload/model.rb
|
|
639
|
+
- lib/hecks/projections/site/payload/specs.rb
|
|
640
|
+
- lib/hecks/projections/site/payload_driver.rb
|
|
630
641
|
- lib/hecks/projections/site/regions.rb
|
|
642
|
+
- lib/hecks/projections/site/root_rows.rb
|
|
631
643
|
- lib/hecks/projections/site/routes_ts.rb
|
|
644
|
+
- lib/hecks/projections/site/site_admin.rb
|
|
632
645
|
- lib/hecks/projections/site/site_cdn.rb
|
|
646
|
+
- lib/hecks/projections/site/site_host.rb
|
|
647
|
+
- lib/hecks/projections/site/site_host/templates.rb
|
|
648
|
+
- lib/hecks/projections/site/site_root.rb
|
|
633
649
|
- lib/hecks/projections/site/table.rb
|
|
634
650
|
- lib/hecks/projections/site/table/checks.rb
|
|
635
651
|
- lib/hecks/projections/site/table/link_builder.rb
|