hecks 3.0.4 → 3.1.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/exe/hecks +25 -7
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
- data/lib/hecks/bluebook/hexagon.rb +45 -25
- data/lib/hecks/bluebook/model_check.rb +7 -7
- data/lib/hecks/chapters.rb +31 -3
- data/lib/hecks/cli/check_era.rb +1 -1
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/deploy_template_diff.rb +1 -1
- data/lib/hecks/cli/doc_coverage.rb +1 -1
- data/lib/hecks/cli/domain_stub.rb +128 -0
- data/lib/hecks/cli/interview_draft.rb +246 -0
- data/lib/hecks/cli/model_check.rb +1 -1
- data/lib/hecks/cli/project_cli.rb +69 -18
- data/lib/hecks/cli/reference.rb +2 -1
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
- data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
- data/lib/hecks/cli/spec_example.rb +1 -1
- data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
- data/lib/hecks/corpus.rb +11 -8
- data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/doc/reference.rb +4 -3
- data/lib/hecks/doors/cli_door.rb +13 -13
- data/lib/hecks/doors/cli_runner.rb +52 -32
- data/lib/hecks/doors/launcher_options.rb +23 -5
- data/lib/hecks/doors/mcp_door.rb +40 -18
- data/lib/hecks/doors/mcp_door_scope.rb +244 -24
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -2
- data/lib/hecks/forms/banking_presentation.rb +1 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
- data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
- data/lib/hecks/framework.rb +1 -1
- data/lib/hecks/fuzzing/combination_miner.rb +1 -1
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
- data/lib/hecks/fuzzing/differential.rb +4 -2
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/form_census.rb +2 -2
- data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
- data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
- data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
- data/lib/hecks/gate/stages.yml +8 -4
- data/lib/hecks/grammar/grammar.hecksagon +2 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
- data/lib/hecks/hecks/adapters/host_http.rb +4 -3
- data/lib/hecks/hecks/adapters/local_files.rb +59 -0
- data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
- data/lib/hecks/hecks/codebase.bluebook +4 -3
- data/lib/hecks/hecks/context_map.hecksagon +2 -2
- data/lib/hecks/hecks/custodian.bluebook +45 -3
- data/lib/hecks/hecks/hecks.hecksagon +10 -4
- data/lib/hecks/hecks/hecks.world +7 -4
- data/lib/hecks/hecks/site.behaviors +17 -0
- data/lib/hecks/hecks/site.world +10 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/box/settings.rb +248 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
- data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
- data/lib/hecks/projections/deploy/box.rb +387 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +11 -10
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
- data/lib/hecks/projections/diagrams.rb +2 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
- data/lib/hecks/projections/site/edge/checks.rb +137 -0
- data/lib/hecks/projections/site/edge/pattern.rb +50 -0
- data/lib/hecks/projections/site/edge/rules.rb +128 -0
- data/lib/hecks/projections/site/edge.rb +136 -0
- data/lib/hecks/projections/site/regions.rb +44 -0
- data/lib/hecks/projections/site/routes_ts.rb +284 -0
- data/lib/hecks/projections/site/site_cdn.rb +78 -0
- data/lib/hecks/projections/site/table/checks.rb +124 -0
- data/lib/hecks/projections/site/table/row_builder.rb +155 -0
- data/lib/hecks/projections/site/table.rb +98 -0
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/projector/cli_projector.rb +172 -95
- data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
- data/lib/hecks/quality_control/adapters/agent.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
- data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
- data/lib/hecks/quality_control/quality_control.bluebook +18 -18
- data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +2 -0
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/invocation.rb +5 -3
- data/lib/hecks/runtime/registry/verification.rb +15 -29
- data/lib/hecks/runtime/registry.rb +14 -20
- data/lib/hecks/rust_build/append_optionals.rb +79 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
- data/lib/hecks/rust_build/domain_name.rb +26 -0
- data/lib/hecks/rust_build/kernel_input.rb +58 -0
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
- data/lib/hecks/rust_build/project_rust.rb +57 -24
- data/lib/hecks/rust_build/rust_literal.rb +33 -0
- data/lib/hecks/rust_build/write_if_changed.rb +86 -0
- data/lib/hecks/site/bluebook/site.bluebook +205 -0
- data/lib/hecks/site/bluebook/site.hecksagon +5 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
- data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
- data/lib/hecks/storehouse.rb +37 -7
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
- data/lib/hecks/three_zero/forms.yml +88 -88
- data/lib/hecks/tools/ci_gates.rb +1 -1
- data/lib/hecks/tools/deploy_recipe.rb +14 -6
- data/lib/hecks/tools/oidc_manifests.rb +2 -1
- data/lib/hecks/tools/sequence_script.rb +2 -2
- data/lib/hecks/tools/site_routes.rb +152 -0
- data/lib/hecks/tools/tenant_provisioning.rb +1 -1
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/build/src/cargo_sync.rs +1 -1
- data/rust/build/src/pipeline.rs +23 -16
- data/rust/build/src/resolve.rs +39 -6
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +87 -7
- data/rust/host/src/needs.rs +111 -0
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/parser/src/keywords.rs +7 -6
- data/rust/parser/src/main.rs +4 -3
- data/rust/parser/src/parse/chapter.rs +1 -1
- data/rust/parser/src/parse/hecksagon.rs +25 -10
- data/rust/project/mutations.rb +4 -30
- data/rust/project/naming.rb +8 -28
- data/rust/project/write_if_changed.rb +4 -80
- data/rust/project_rust_pipeline.rb +16 -12
- data/rust/src/kernel/cli.rs +2 -0
- data/rust/src/kernel/needs.rs +74 -1
- metadata +39 -2
|
@@ -16,7 +16,7 @@ module Hecks
|
|
|
16
16
|
|
|
17
17
|
# Runs both halves of the era gate over every bluebook in a registry.
|
|
18
18
|
#
|
|
19
|
-
# Looks up each bluebook's own source separately: `
|
|
19
|
+
# Looks up each bluebook's own source separately: `attaches` can share a boot between
|
|
20
20
|
# the domain's own bluebook and a differently-sourced one, so one file read once and reused
|
|
21
21
|
# for every bluebook would attribute the wrong text.
|
|
22
22
|
#
|
|
@@ -84,7 +84,7 @@ module Hecks
|
|
|
84
84
|
# Reads the source text one bluebook was declared in, wherever that source lives.
|
|
85
85
|
#
|
|
86
86
|
# Matched by declared name, not position: a domain directory using
|
|
87
|
-
# `
|
|
87
|
+
# `attaches` may hold more than one file, and a framework
|
|
88
88
|
# member or vendored bluebook is excluded from the single-file
|
|
89
89
|
# fallback so its own real source is read, not a sibling's.
|
|
90
90
|
#
|
|
@@ -150,11 +150,11 @@ module Hecks
|
|
|
150
150
|
#
|
|
151
151
|
# @param registry [Runtime::Registry] the registry whose hecksagons are searched
|
|
152
152
|
# @param bluebook_name [String] the bluebook's declared Pascal-case name
|
|
153
|
-
# @return [String, nil] the package name as written in `
|
|
153
|
+
# @return [String, nil] the package name as written in `attaches ... from: :vendor`; nil
|
|
154
154
|
# when no hecksagon vendored a package whose name Pascal-cases to `bluebook_name`
|
|
155
155
|
def vendored_bluebook_name_for(registry, bluebook_name)
|
|
156
156
|
registry.hecksagons.each_value do |hecksagon|
|
|
157
|
-
match = hecksagon.
|
|
157
|
+
match = hecksagon.vendored_packages.find { |name| Naming.pascal(name) == bluebook_name }
|
|
158
158
|
return match if match
|
|
159
159
|
end
|
|
160
160
|
nil
|
|
@@ -123,7 +123,8 @@ module Hecks
|
|
|
123
123
|
raise unless defined?(PG::Error) && e.is_a?(PG::Error)
|
|
124
124
|
|
|
125
125
|
raise Runtime::WiringError,
|
|
126
|
-
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
|
|
126
|
+
"cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip} " \
|
|
127
|
+
"-- to run without a database, set HECKS_ENVIRONMENT=memory (a domain needs an environments/memory overlay)"
|
|
127
128
|
end
|
|
128
129
|
|
|
129
130
|
# Joins the process's shared connection for the declared database and schema, so every
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Projections
|
|
3
|
+
module Deploy
|
|
4
|
+
module Box
|
|
5
|
+
# Resolves a world's `deployed_to("AwsBox")` settings into the checked plan the box
|
|
6
|
+
# generator renders from.
|
|
7
|
+
#
|
|
8
|
+
# Every string that reaches a template or a script is matched against a conservative
|
|
9
|
+
# pattern first, so a world file cannot splice shell, YAML or Caddy syntax into the output.
|
|
10
|
+
# A setting the world leaves out resolves to the default the golden stack was built with.
|
|
11
|
+
module Settings
|
|
12
|
+
NAME = /\A[a-z][a-z0-9-]{0,40}\z/
|
|
13
|
+
REPOSITORY = %r{\A[a-z0-9][a-z0-9._/-]{1,100}\z}
|
|
14
|
+
URL_PATH = %r{\A/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\z}
|
|
15
|
+
SECRET_NAME = %r{\A[A-Za-z0-9/_+=.@-]{1,256}\*?\z}
|
|
16
|
+
ENV_KEY = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
17
|
+
ENV_VALUE = /\A[^\x00-\x1f\x7f]*\z/
|
|
18
|
+
HEADER = /\A[A-Za-z][A-Za-z0-9-]{0,63}\z/
|
|
19
|
+
DB_NAME = /\A[a-zA-Z][a-zA-Z0-9]{0,62}\z/
|
|
20
|
+
ENGINE = /\A\d{2}(\.\d{1,2})?\z/
|
|
21
|
+
PREFIX = /\A[a-z][a-z0-9-]{0,20}\z/
|
|
22
|
+
IMAGE = %r{\A[a-z0-9][a-z0-9._/:@-]{1,200}\z}
|
|
23
|
+
# Default images, each a version tag plus the digest of its multi-architecture index,
|
|
24
|
+
# so a rebuilt box pulls the same bytes.
|
|
25
|
+
TUNNEL_IMAGE = "cloudflare/cloudflared:2026.9.3" \
|
|
26
|
+
"@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c".freeze
|
|
27
|
+
PROXY_IMAGE = "public.ecr.aws/docker/library/caddy:2.8" \
|
|
28
|
+
"@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
|
|
29
|
+
TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
|
|
30
|
+
"(the secret holding the tunnel token)".freeze
|
|
31
|
+
ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
|
|
32
|
+
"and the secret that holds its value".freeze
|
|
33
|
+
|
|
34
|
+
# One container the box runs: its image repository, its port and its settings.
|
|
35
|
+
#
|
|
36
|
+
# @!attribute [r] name [String] the compose service name
|
|
37
|
+
# @!attribute [r] repository [String] the ECR repository holding its images
|
|
38
|
+
# @!attribute [r] port [Integer] the port it listens on, on the box's own network
|
|
39
|
+
# @!attribute [r] env [Hash{String => String}] plain environment variables
|
|
40
|
+
# @!attribute [r] secrets [Hash{String => String}] environment variable => secret name,
|
|
41
|
+
# resolved on the box at deploy time and never written into a template
|
|
42
|
+
Container = Struct.new(:name, :repository, :port, :env, :secrets, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# A set of URL paths one container serves.
|
|
45
|
+
#
|
|
46
|
+
# @!attribute [r] container [String] the container's name
|
|
47
|
+
# @!attribute [r] paths [Array<String>] Caddy path patterns, such as "/cms/*"
|
|
48
|
+
Route = Struct.new(:container, :paths, keyword_init: true)
|
|
49
|
+
|
|
50
|
+
# A Cloudflare tunnel the box runs as a service, pointed at one container.
|
|
51
|
+
#
|
|
52
|
+
# @!attribute [r] container [String] the container the tunnel forwards to
|
|
53
|
+
# @!attribute [r] port [Integer] that container's port
|
|
54
|
+
# @!attribute [r] token_secret [String] the secret holding the tunnel token
|
|
55
|
+
# @!attribute [r] image [String] the cloudflared image
|
|
56
|
+
Tunnel = Struct.new(:container, :port, :token_secret, :image, keyword_init: true)
|
|
57
|
+
|
|
58
|
+
# Everything the generator reads, checked.
|
|
59
|
+
Plan = Struct.new(
|
|
60
|
+
:infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
|
|
61
|
+
:storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
|
|
62
|
+
:containers, :routes, :default_container, :origin_header, :origin_secret,
|
|
63
|
+
:secret_prefixes, :tunnel, :tunnel_service, :proxy_image, keyword_init: true
|
|
64
|
+
) do
|
|
65
|
+
# @return [String] the CloudFormation stack that holds the database
|
|
66
|
+
def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
|
|
67
|
+
|
|
68
|
+
# @return [String] the CloudFormation stack that holds the box
|
|
69
|
+
def box_stack = "#{stack_prefix}-#{infra_name}-box"
|
|
70
|
+
|
|
71
|
+
# @return [Container] the container that answers every path no route claims
|
|
72
|
+
def default
|
|
73
|
+
containers.find { |c| c.name == default_container }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
module_function
|
|
78
|
+
|
|
79
|
+
# @param deploy_settings [Hash{Symbol => Object}] the world's `AwsBox` settings
|
|
80
|
+
# @param target [Object] the validated `BoxTarget` (its instance type, volume, database
|
|
81
|
+
# class and storage, already checked by `Declare`)
|
|
82
|
+
# @param infra_name [String] the stack name the world or the domain gives
|
|
83
|
+
# @return [Plan] the resolved plan
|
|
84
|
+
# @raise [ArgumentError] when a setting is missing, malformed or inconsistent
|
|
85
|
+
def resolve(deploy_settings:, target:, infra_name:)
|
|
86
|
+
s = deploy_settings
|
|
87
|
+
check(:stack_name, infra_name, NAME)
|
|
88
|
+
containers = read_containers(s.fetch(:containers) { raise ArgumentError, missing_containers }, infra_name)
|
|
89
|
+
header, secret = read_origin(s)
|
|
90
|
+
tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
|
|
91
|
+
|
|
92
|
+
Plan.new(
|
|
93
|
+
infra_name: infra_name, stack_prefix: check(:stack_prefix, s.fetch(:stack_prefix, "hecks"), PREFIX),
|
|
94
|
+
**declared_sizes(target), **read_sizes(s), database_name: read_database_name(s, infra_name),
|
|
95
|
+
engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
|
|
96
|
+
containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
|
|
97
|
+
default_container: read_default(s[:default_container], containers), origin_header: header,
|
|
98
|
+
origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
|
|
99
|
+
tunnel: tunnel, tunnel_service: tunnel_service,
|
|
100
|
+
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param target [Object] the declared `BoxTarget`
|
|
105
|
+
# @return [Hash{Symbol => Object}] the sizes `Declare` has already validated
|
|
106
|
+
def declared_sizes(target)
|
|
107
|
+
%i[instance_type volume_gb database_class storage_gb].to_h { |key| [key, target.state[key].value] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param settings [Hash{Symbol => Object}] the world's settings
|
|
111
|
+
# @return [Hash{Symbol => Integer}] swap, backup retention and snapshot retention, bounded
|
|
112
|
+
def read_sizes(settings)
|
|
113
|
+
{
|
|
114
|
+
swap_gb: integer(:swap_gb, settings.fetch(:swap_gb, 2), 0, 64),
|
|
115
|
+
backup_days: integer(:backup_days, settings.fetch(:backup_days, 7), 1, 35),
|
|
116
|
+
snapshots_keep: integer(:snapshots_keep, settings.fetch(:snapshots_keep, 7), 1, 1000)
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read_database_name(settings, infra_name)
|
|
121
|
+
check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read_containers(list, infra_name)
|
|
125
|
+
raise ArgumentError, missing_containers unless list.is_a?(Array) && !list.empty?
|
|
126
|
+
|
|
127
|
+
containers = list.map { |c| read_container(c, infra_name) }
|
|
128
|
+
names = containers.map(&:name)
|
|
129
|
+
dup = names.find { |n| names.count(n) > 1 }
|
|
130
|
+
raise ArgumentError, "containers: two containers are named #{dup.inspect}" if dup
|
|
131
|
+
|
|
132
|
+
ports = containers.map(&:port)
|
|
133
|
+
clash = ports.find { |p| ports.count(p) > 1 }
|
|
134
|
+
raise ArgumentError, "containers: two containers listen on port #{clash}; they share the box's network" if clash
|
|
135
|
+
|
|
136
|
+
containers
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def read_container(spec, infra_name)
|
|
140
|
+
raise ArgumentError, "containers: each container is a hash, got #{spec.inspect}" unless spec.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
name = check(:container_name, spec.fetch(:name) { raise ArgumentError, "containers: a container has no name" }, NAME)
|
|
143
|
+
Container.new(
|
|
144
|
+
name: name, repository: check(:repository, spec.fetch(:repository, "#{infra_name}-#{name}"), REPOSITORY),
|
|
145
|
+
port: integer(:port, spec.fetch(:port) { raise ArgumentError, "containers: #{name} has no port" }, 1, 65_535),
|
|
146
|
+
env: string_map(:env, spec.fetch(:env, {}), ENV_KEY, ENV_VALUE),
|
|
147
|
+
secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME)
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read_routes(list, containers)
|
|
152
|
+
raise ArgumentError, "routes: expected a list" unless list.is_a?(Array)
|
|
153
|
+
|
|
154
|
+
list.map { |spec| read_route(spec, containers) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def read_route(spec, containers)
|
|
158
|
+
container = spec.fetch(:container) { raise ArgumentError, "routes: a route has no container" }
|
|
159
|
+
name = check(:route_container, container, NAME)
|
|
160
|
+
known_container!(:routes, name, containers)
|
|
161
|
+
paths = Array(spec.fetch(:paths) { raise ArgumentError, "routes: the route for #{name} has no paths" })
|
|
162
|
+
raise ArgumentError, "routes: the route for #{name} has no paths" if paths.empty?
|
|
163
|
+
|
|
164
|
+
Route.new(container: name, paths: paths.map { |p| check(:path, p, URL_PATH) })
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def read_default(name, containers)
|
|
168
|
+
return containers.first.name if name.nil? && containers.size == 1
|
|
169
|
+
raise ArgumentError, "default_container: name the container that answers every other path" if name.nil?
|
|
170
|
+
|
|
171
|
+
known_container!(:default_container, check(:default_container, name, NAME), containers)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def known_container!(key, name, containers)
|
|
175
|
+
return name if containers.any? { |c| c.name == name }
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "#{key}: #{name.inspect} is not a declared container"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_origin(settings)
|
|
181
|
+
header = settings[:origin_header]
|
|
182
|
+
secret = settings[:origin_secret]
|
|
183
|
+
return [nil, nil] if header.nil? && secret.nil?
|
|
184
|
+
raise ArgumentError, ORIGIN_PAIR if header.nil? || secret.nil?
|
|
185
|
+
|
|
186
|
+
[check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
|
|
190
|
+
#
|
|
191
|
+
# @param value [Boolean, Hash{Symbol => Object}] the world's `tunnel` setting
|
|
192
|
+
# @param containers [Array<Container>] the declared containers
|
|
193
|
+
# @return [Array(Boolean, Tunnel)] whether the egress opens, and the service if one runs
|
|
194
|
+
def read_tunnel(value, containers)
|
|
195
|
+
return [boolean(:tunnel, value), nil] unless value.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
to = value.fetch(:to) { raise ArgumentError, TUNNEL_SHAPE }
|
|
198
|
+
target = known_container!(:tunnel, check(:tunnel_to, to, NAME), containers)
|
|
199
|
+
token = check(:tunnel_token_secret, value.fetch(:token_secret) { raise ArgumentError, TUNNEL_SHAPE }, SECRET_NAME)
|
|
200
|
+
image = check(:tunnel_image, value.fetch(:image, TUNNEL_IMAGE), IMAGE)
|
|
201
|
+
port = containers.find { |c| c.name == target }.port
|
|
202
|
+
[true, Tunnel.new(container: target, port: port, token_secret: token, image: image)]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def read_prefixes(settings, infra_name)
|
|
206
|
+
list = Array(settings.fetch(:secret_prefixes, ["#{infra_name}/*"]))
|
|
207
|
+
list.map { |p| check(:secret_prefixes, p, SECRET_NAME) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def string_map(key, value, key_pattern, value_pattern)
|
|
211
|
+
raise ArgumentError, "#{key}: expected a hash" unless value.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
value.to_h { |k, v| [check(key, k.to_s, key_pattern), check(key, v.to_s, value_pattern)] }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def check(key, value, pattern)
|
|
217
|
+
return value if value.is_a?(String) && value.match?(pattern)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError, "#{key}: #{value.inspect} does not match #{pattern.inspect}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def integer(key, value, low, high)
|
|
223
|
+
return value if value.is_a?(Integer) && value.between?(low, high)
|
|
224
|
+
|
|
225
|
+
raise ArgumentError, "#{key}: #{value.inspect} must be an integer from #{low} to #{high}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def boolean(key, value)
|
|
229
|
+
return value if [true, false].include?(value)
|
|
230
|
+
|
|
231
|
+
raise ArgumentError, "#{key}: #{value.inspect} must be true or false"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def missing_containers
|
|
235
|
+
<<~MSG
|
|
236
|
+
deployed_to("AwsBox") needs at least one container. Add one, e.g.:
|
|
237
|
+
|
|
238
|
+
deployed_to("AwsBox") do
|
|
239
|
+
region "us-east-1"
|
|
240
|
+
containers [{ name: "web", port: 8080 }]
|
|
241
|
+
end
|
|
242
|
+
MSG
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Description: >
|
|
3
|
+
One EC2 box that runs @@STACK@@'s containers with Docker Compose behind Caddy, reading the RDS
|
|
4
|
+
instance from rds.yaml. No inbound rules but an optional CDN prefix list on port 80; use SSM Session
|
|
5
|
+
Manager to reach it. Secrets are read from Secrets Manager on the box at deploy time.
|
|
6
|
+
|
|
7
|
+
Parameters:
|
|
8
|
+
VpcId:
|
|
9
|
+
Type: AWS::EC2::VPC::Id
|
|
10
|
+
SubnetId:
|
|
11
|
+
Type: AWS::EC2::Subnet::Id
|
|
12
|
+
Description: A public subnet. The box needs a public IP to reach ECR and Secrets Manager.
|
|
13
|
+
DbSecurityGroupId:
|
|
14
|
+
Type: AWS::EC2::SecurityGroup::Id
|
|
15
|
+
Description: DbSecurityGroupId output of the RDS stack.
|
|
16
|
+
DbSecretArn:
|
|
17
|
+
Type: String
|
|
18
|
+
Description: DbSecretArn output of the RDS stack.
|
|
19
|
+
InstanceType:
|
|
20
|
+
Type: String
|
|
21
|
+
Default: @@INSTANCE_TYPE@@
|
|
22
|
+
AmiId:
|
|
23
|
+
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
|
|
24
|
+
Default: @@AMI_PARAMETER@@
|
|
25
|
+
Description: Never re-resolve this on a live box, because a newer image replaces the instance.
|
|
26
|
+
RootVolumeGb:
|
|
27
|
+
Type: Number
|
|
28
|
+
Default: @@VOLUME_GB@@
|
|
29
|
+
AlertTopicArn:
|
|
30
|
+
Type: String
|
|
31
|
+
Default: ""
|
|
32
|
+
Description: AlertTopicArn output of the RDS stack. Blank creates the alarms with no notification target.
|
|
33
|
+
CdnPrefixListId:
|
|
34
|
+
Type: String
|
|
35
|
+
Default: ""
|
|
36
|
+
Description: >
|
|
37
|
+
A managed prefix list of the CDN's origin-facing addresses. When set, port 80 is open to it (the
|
|
38
|
+
proxy still refuses requests without the origin secret). Blank leaves the box with no inbound at all.
|
|
39
|
+
Rehearsal:
|
|
40
|
+
Type: String
|
|
41
|
+
Default: "false"
|
|
42
|
+
AllowedValues: ["true", "false"]
|
|
43
|
+
Description: true is a throwaway box with no Elastic IP.
|
|
44
|
+
|
|
45
|
+
Conditions:
|
|
46
|
+
HasPrefixList: !Not [!Equals [!Ref CdnPrefixListId, ""]]
|
|
47
|
+
HasAlerts: !Not [!Equals [!Ref AlertTopicArn, ""]]
|
|
48
|
+
IsProduction: !Equals [!Ref Rehearsal, "false"]
|
|
49
|
+
|
|
50
|
+
Resources:
|
|
51
|
+
BoxSecurityGroup:
|
|
52
|
+
Type: AWS::EC2::SecurityGroup
|
|
53
|
+
Properties:
|
|
54
|
+
GroupDescription: @@STACK@@ app box, port 80 from the CDN only, egress to RDS and 443
|
|
55
|
+
VpcId: !Ref VpcId
|
|
56
|
+
SecurityGroupIngress:
|
|
57
|
+
- !If
|
|
58
|
+
- HasPrefixList
|
|
59
|
+
- IpProtocol: tcp
|
|
60
|
+
FromPort: 80
|
|
61
|
+
ToPort: 80
|
|
62
|
+
SourcePrefixListId: !Ref CdnPrefixListId
|
|
63
|
+
Description: CDN origin-facing addresses
|
|
64
|
+
- !Ref AWS::NoValue
|
|
65
|
+
SecurityGroupEgress:
|
|
66
|
+
- IpProtocol: tcp
|
|
67
|
+
FromPort: 5432
|
|
68
|
+
ToPort: 5432
|
|
69
|
+
DestinationSecurityGroupId: !Ref DbSecurityGroupId
|
|
70
|
+
- IpProtocol: tcp
|
|
71
|
+
FromPort: 443
|
|
72
|
+
ToPort: 443
|
|
73
|
+
CidrIp: 0.0.0.0/0
|
|
74
|
+
@@TUNNEL_EGRESS@@
|
|
75
|
+
BoxToDbIngress:
|
|
76
|
+
Type: AWS::EC2::SecurityGroupIngress
|
|
77
|
+
Properties:
|
|
78
|
+
GroupId: !Ref DbSecurityGroupId
|
|
79
|
+
IpProtocol: tcp
|
|
80
|
+
FromPort: 5432
|
|
81
|
+
ToPort: 5432
|
|
82
|
+
SourceSecurityGroupId: !Ref BoxSecurityGroup
|
|
83
|
+
Description: App box
|
|
84
|
+
|
|
85
|
+
BoxRole:
|
|
86
|
+
Type: AWS::IAM::Role
|
|
87
|
+
Properties:
|
|
88
|
+
AssumeRolePolicyDocument:
|
|
89
|
+
Version: "2012-10-17"
|
|
90
|
+
Statement:
|
|
91
|
+
- Effect: Allow
|
|
92
|
+
Principal: { Service: ec2.amazonaws.com }
|
|
93
|
+
Action: sts:AssumeRole
|
|
94
|
+
ManagedPolicyArns:
|
|
95
|
+
- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
|
|
96
|
+
- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
|
|
97
|
+
Policies:
|
|
98
|
+
- PolicyName: read-secrets
|
|
99
|
+
PolicyDocument:
|
|
100
|
+
Version: "2012-10-17"
|
|
101
|
+
Statement:
|
|
102
|
+
- Effect: Allow
|
|
103
|
+
Action: secretsmanager:GetSecretValue
|
|
104
|
+
Resource:
|
|
105
|
+
- !Ref DbSecretArn
|
|
106
|
+
@@SECRET_RESOURCES@@
|
|
107
|
+
|
|
108
|
+
BoxProfile:
|
|
109
|
+
Type: AWS::IAM::InstanceProfile
|
|
110
|
+
Properties:
|
|
111
|
+
Roles: [!Ref BoxRole]
|
|
112
|
+
|
|
113
|
+
Box:
|
|
114
|
+
Type: AWS::EC2::Instance
|
|
115
|
+
Properties:
|
|
116
|
+
ImageId: !Ref AmiId
|
|
117
|
+
InstanceType: !Ref InstanceType
|
|
118
|
+
SubnetId: !Ref SubnetId
|
|
119
|
+
SecurityGroupIds: [!Ref BoxSecurityGroup]
|
|
120
|
+
IamInstanceProfile: !Ref BoxProfile
|
|
121
|
+
BlockDeviceMappings:
|
|
122
|
+
- DeviceName: /dev/xvda
|
|
123
|
+
Ebs:
|
|
124
|
+
VolumeType: gp3
|
|
125
|
+
VolumeSize: !Ref RootVolumeGb
|
|
126
|
+
Encrypted: true
|
|
127
|
+
DeleteOnTermination: true
|
|
128
|
+
MetadataOptions:
|
|
129
|
+
HttpTokens: required
|
|
130
|
+
HttpPutResponseHopLimit: 2
|
|
131
|
+
Tags:
|
|
132
|
+
- Key: Name
|
|
133
|
+
Value: @@STACK@@-box
|
|
134
|
+
- Key: Backup
|
|
135
|
+
Value: @@STACK@@-box
|
|
136
|
+
UserData:
|
|
137
|
+
Fn::Base64: |
|
|
138
|
+
#!/bin/bash
|
|
139
|
+
set -eux
|
|
140
|
+
dnf install -y docker jq
|
|
141
|
+
systemctl enable --now docker
|
|
142
|
+
mkdir -p /usr/local/lib/docker/cli-plugins
|
|
143
|
+
curl -fsSL https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-$(uname -m) \
|
|
144
|
+
-o /usr/local/lib/docker/cli-plugins/docker-compose
|
|
145
|
+
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
|
146
|
+
@@SWAP_COMMANDS@@ mkdir -p /opt/@@STACK@@
|
|
147
|
+
|
|
148
|
+
# Production only. A CDN needs an origin DNS name that survives a stop and start; an Elastic IP
|
|
149
|
+
# keeps the instance's public DNS name stable.
|
|
150
|
+
BoxAddress:
|
|
151
|
+
Type: AWS::EC2::EIP
|
|
152
|
+
Condition: IsProduction
|
|
153
|
+
Properties:
|
|
154
|
+
Domain: vpc
|
|
155
|
+
Tags:
|
|
156
|
+
- Key: Name
|
|
157
|
+
Value: @@STACK@@-box
|
|
158
|
+
|
|
159
|
+
BoxAddressAssociation:
|
|
160
|
+
Type: AWS::EC2::EIPAssociation
|
|
161
|
+
Condition: IsProduction
|
|
162
|
+
Properties:
|
|
163
|
+
AllocationId: !GetAtt BoxAddress.AllocationId
|
|
164
|
+
InstanceId: !Ref Box
|
|
165
|
+
|
|
166
|
+
# Daily snapshot of every volume on the instance. The database has its own RDS backups; this makes
|
|
167
|
+
# rebuilding the box fast.
|
|
168
|
+
SnapshotRole:
|
|
169
|
+
Type: AWS::IAM::Role
|
|
170
|
+
Properties:
|
|
171
|
+
AssumeRolePolicyDocument:
|
|
172
|
+
Version: "2012-10-17"
|
|
173
|
+
Statement:
|
|
174
|
+
- Effect: Allow
|
|
175
|
+
Principal: { Service: dlm.amazonaws.com }
|
|
176
|
+
Action: sts:AssumeRole
|
|
177
|
+
ManagedPolicyArns:
|
|
178
|
+
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole
|
|
179
|
+
|
|
180
|
+
SnapshotPolicy:
|
|
181
|
+
Type: AWS::DLM::LifecyclePolicy
|
|
182
|
+
Properties:
|
|
183
|
+
# DLM allows only letters, digits, spaces, underscores and hyphens here.
|
|
184
|
+
Description: Daily snapshots of the @@STACK@@ app box - keep @@SNAPSHOTS_KEEP@@
|
|
185
|
+
State: ENABLED
|
|
186
|
+
ExecutionRoleArn: !GetAtt SnapshotRole.Arn
|
|
187
|
+
PolicyDetails:
|
|
188
|
+
ResourceTypes: [INSTANCE]
|
|
189
|
+
TargetTags:
|
|
190
|
+
- Key: Backup
|
|
191
|
+
Value: @@STACK@@-box
|
|
192
|
+
Schedules:
|
|
193
|
+
- Name: daily
|
|
194
|
+
CopyTags: true
|
|
195
|
+
CreateRule:
|
|
196
|
+
Interval: 24
|
|
197
|
+
IntervalUnit: HOURS
|
|
198
|
+
Times: ["07:00"]
|
|
199
|
+
RetainRule:
|
|
200
|
+
Count: @@SNAPSHOTS_KEEP@@
|
|
201
|
+
|
|
202
|
+
# A hardware fault on the host: EC2 moves the instance to healthy hardware (same disk, same
|
|
203
|
+
# address) and the topic is told.
|
|
204
|
+
SystemStatusAlarm:
|
|
205
|
+
Type: AWS::CloudWatch::Alarm
|
|
206
|
+
Properties:
|
|
207
|
+
AlarmDescription: EC2 system status check failing, recovering the instance
|
|
208
|
+
Namespace: AWS/EC2
|
|
209
|
+
MetricName: StatusCheckFailed_System
|
|
210
|
+
Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
|
|
211
|
+
Statistic: Maximum
|
|
212
|
+
Period: 60
|
|
213
|
+
EvaluationPeriods: 2
|
|
214
|
+
Threshold: 0
|
|
215
|
+
ComparisonOperator: GreaterThanThreshold
|
|
216
|
+
TreatMissingData: notBreaching
|
|
217
|
+
AlarmActions: !If
|
|
218
|
+
- HasAlerts
|
|
219
|
+
- [!Ref AlertTopicArn, !Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
|
|
220
|
+
- [!Sub "arn:${AWS::Partition}:automate:${AWS::Region}:ec2:recover"]
|
|
221
|
+
|
|
222
|
+
# The OS or networking inside the instance is unhealthy. Not auto-fixed: a reboot can lose state
|
|
223
|
+
# worth looking at.
|
|
224
|
+
InstanceStatusAlarm:
|
|
225
|
+
Type: AWS::CloudWatch::Alarm
|
|
226
|
+
Condition: HasAlerts
|
|
227
|
+
Properties:
|
|
228
|
+
AlarmDescription: EC2 instance status check failing for 5 minutes
|
|
229
|
+
Namespace: AWS/EC2
|
|
230
|
+
MetricName: StatusCheckFailed_Instance
|
|
231
|
+
Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
|
|
232
|
+
Statistic: Maximum
|
|
233
|
+
Period: 60
|
|
234
|
+
EvaluationPeriods: 5
|
|
235
|
+
Threshold: 0
|
|
236
|
+
ComparisonOperator: GreaterThanThreshold
|
|
237
|
+
TreatMissingData: notBreaching
|
|
238
|
+
AlarmActions: [!Ref AlertTopicArn]
|
|
239
|
+
OKActions: [!Ref AlertTopicArn]
|
|
240
|
+
|
|
241
|
+
CpuHighAlarm:
|
|
242
|
+
Type: AWS::CloudWatch::Alarm
|
|
243
|
+
Condition: HasAlerts
|
|
244
|
+
Properties:
|
|
245
|
+
AlarmDescription: "Box CPU above 80% for 30 minutes"
|
|
246
|
+
Namespace: AWS/EC2
|
|
247
|
+
MetricName: CPUUtilization
|
|
248
|
+
Dimensions: [{ Name: InstanceId, Value: !Ref Box }]
|
|
249
|
+
Statistic: Average
|
|
250
|
+
Period: 300
|
|
251
|
+
EvaluationPeriods: 6
|
|
252
|
+
Threshold: 80
|
|
253
|
+
ComparisonOperator: GreaterThanThreshold
|
|
254
|
+
TreatMissingData: notBreaching
|
|
255
|
+
AlarmActions: [!Ref AlertTopicArn]
|
|
256
|
+
OKActions: [!Ref AlertTopicArn]
|
|
257
|
+
@@ECR_REPOSITORIES@@
|
|
258
|
+
Outputs:
|
|
259
|
+
InstanceId:
|
|
260
|
+
Value: !Ref Box
|
|
261
|
+
AppOriginDomain:
|
|
262
|
+
Condition: IsProduction
|
|
263
|
+
Description: The box's stable public DNS name, for a CDN's origin.
|
|
264
|
+
Value: !Join ["", ["ec2-", !Join ["-", !Split [".", !Ref BoxAddress]], ".@@COMPUTE_DOMAIN@@"]]
|
|
265
|
+
BoxSecurityGroupId:
|
|
266
|
+
Value: !Ref BoxSecurityGroup
|
|
267
|
+
@@ECR_OUTPUTS@@
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Roll @@STACK@@'s app box: render the compose files, push them over SSM, start the containers and check
|
|
3
|
+
# them. Exits non-zero if the roll fails or the box does not answer as expected.
|
|
4
|
+
#
|
|
5
|
+
# deploy-box.sh [name=tag ...]
|
|
6
|
+
#
|
|
7
|
+
# A container named without a tag runs its "latest" image. Secret values are resolved on the box by
|
|
8
|
+
# fetch-secrets.sh and never pass through the SSM command.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
11
|
+
BOX_STACK=@@BOX_STACK@@
|
|
12
|
+
RDS_STACK=@@RDS_STACK@@
|
|
13
|
+
REGION=@@REGION@@
|
|
14
|
+
DIR=/opt/@@STACK@@
|
|
15
|
+
out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].Outputs[?OutputKey==\`$2\`].OutputValue" --output text; }
|
|
16
|
+
|
|
17
|
+
BOX=$(out "$BOX_STACK" InstanceId)
|
|
18
|
+
DB_HOST=$(out "$RDS_STACK" DbEndpoint)
|
|
19
|
+
DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
|
|
20
|
+
[ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 1; }
|
|
21
|
+
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
|
|
22
|
+
|
|
23
|
+
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
|
24
|
+
( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
|
|
25
|
+
|
|
26
|
+
# Run a script on the box over SSM, wait for it, print its output, return its status.
|
|
27
|
+
run_on_box() {
|
|
28
|
+
local cid st i
|
|
29
|
+
cid=$(aws ssm send-command --instance-ids "$BOX" --document-name AWS-RunShellScript --timeout-seconds 900 \
|
|
30
|
+
--parameters "$1" --query 'Command.CommandId' --output text)
|
|
31
|
+
sleep 3
|
|
32
|
+
for i in $(seq 1 150); do
|
|
33
|
+
st=$(aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query Status --output text)
|
|
34
|
+
[ "$st" != InProgress ] && [ "$st" != Pending ] && break
|
|
35
|
+
sleep 5
|
|
36
|
+
done
|
|
37
|
+
aws ssm get-command-invocation --command-id "$cid" --instance-id "$BOX" --query StandardOutputContent --output text
|
|
38
|
+
[ "$st" = Success ]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
B64() { base64 < "$1" | tr -d '\n'; }
|
|
42
|
+
ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
|
|
43
|
+
--arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
|
|
44
|
+
--arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
|
|
45
|
+
{commands: ["set -e", "mkdir -p \($dir) && cd \($dir)", "umask 077",
|
|
46
|
+
"echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
|
|
47
|
+
"echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
|
|
48
|
+
"bash fetch-secrets.sh",
|
|
49
|
+
"aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
|
|
50
|
+
"docker compose -f compose.json pull --quiet",
|
|
51
|
+
"docker compose -f compose.json up -d --remove-orphans",
|
|
52
|
+
"sleep 20; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
|
|
53
|
+
executionTimeout: ["900"]}')
|
|
54
|
+
|
|
55
|
+
echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
|
|
56
|
+
run_on_box "$ROLL" || { echo "==> the roll did not succeed" >&2; exit 1; }
|
|
57
|
+
|
|
58
|
+
echo "==> checking the box"
|
|
59
|
+
CHECK=$(cat <<'CHECK_EOF'
|
|
60
|
+
cd @@DIR@@
|
|
61
|
+
BAD=0
|
|
62
|
+
@@HEALTH_CHECKS@@
|
|
63
|
+
DOWN=$(docker compose -f compose.json ps --format "{{.Service}} {{.Status}}" | grep -vE " Up " || true)
|
|
64
|
+
if [ -z "$DOWN" ]; then echo "ok every container is up"; else echo "FAIL containers not up: $DOWN"; BAD=1; fi
|
|
65
|
+
[ "$BAD" = 0 ]
|
|
66
|
+
CHECK_EOF
|
|
67
|
+
)
|
|
68
|
+
CHECK_JSON=$(jq -n --arg c "$(printf '%s' "$CHECK" | base64 | tr -d '\n')" '{commands: ["echo \($c) | base64 -d | bash"]}')
|
|
69
|
+
run_on_box "$CHECK_JSON" || { echo "==> the box is NOT healthy after the roll" >&2; exit 1; }
|
|
70
|
+
echo "==> box roll done ($(date -u +%H:%M:%SZ))"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Runs ON the box. Resolves secrets.json into per-service env files that compose reads, using the
|
|
3
|
+
# instance role, so secret values never pass through SSM command text.
|
|
4
|
+
#
|
|
5
|
+
# secrets.json: [{"service":"web","name":"AUTH_SECRET","valueFrom":"myapp/auth"}]
|
|
6
|
+
# valueFrom is a secret name (its whole string is the value) or a full ARN, optionally with a JSON
|
|
7
|
+
# key (arn:...:secret:NAME:jsonkey::) to pick one field of a JSON secret.
|
|
8
|
+
set -eu
|
|
9
|
+
cd "$(dirname "$0")"
|
|
10
|
+
umask 077
|
|
11
|
+
rm -f ./*.secrets.env
|
|
12
|
+
jq -r '.[] | [.service, .name, .valueFrom] | @tsv' secrets.json | while IFS=$'\t' read -r svc name from; do
|
|
13
|
+
case "$from" in
|
|
14
|
+
arn:*) id=$(echo "$from" | cut -d: -f1-7); region=$(echo "$from" | cut -d: -f4); jkey=$(echo "$from" | cut -d: -f8) ;;
|
|
15
|
+
*) id=$from; region=""; jkey="" ;;
|
|
16
|
+
esac
|
|
17
|
+
val=$(aws secretsmanager get-secret-value ${region:+--region "$region"} --secret-id "$id" --query SecretString --output text)
|
|
18
|
+
if [ -n "$jkey" ]; then val=$(printf %s "$val" | jq -r --arg k "$jkey" '.[$k]'); fi
|
|
19
|
+
[ -n "$val" ] && [ "$val" != null ] || { echo "empty secret for $svc/$name" >&2; exit 1; }
|
|
20
|
+
printf '%s=%s\n' "$name" "$val" >> "$svc.secrets.env"
|
|
21
|
+
done
|