hecks 2.6.0 → 2.7.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/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +2 -1
- metadata +45 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Deploy
|
|
6
|
+
module Preview
|
|
7
|
+
# Renders `preview.sh`, the script that creates, updates and deletes one
|
|
8
|
+
# branch's preview stack.
|
|
9
|
+
#
|
|
10
|
+
# The script text lives in `preview.sh.erb` beside this file because it
|
|
11
|
+
# is a shell program, not Ruby: keeping it as a file lets `bash -n` and
|
|
12
|
+
# `shellcheck` read it as one. Every value baked into it has already
|
|
13
|
+
# passed `Settings`' patterns, so the template never quotes defensively.
|
|
14
|
+
module Script
|
|
15
|
+
TEMPLATE = File.join(__dir__, "preview.sh.erb").freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Renders the script.
|
|
20
|
+
#
|
|
21
|
+
# @param settings [Settings] the resolved preview settings
|
|
22
|
+
# @return [String] the bash script, ending in one newline
|
|
23
|
+
def render(settings)
|
|
24
|
+
ERB.new(File.read(TEMPLATE), trim_mode: "-").result_with_hash(
|
|
25
|
+
s: settings, image_hint: image_hint(settings)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Names the local images `deploy` expects, for the script's header.
|
|
30
|
+
#
|
|
31
|
+
# @param settings [Settings] the resolved preview settings
|
|
32
|
+
# @return [String] the image references, comma separated
|
|
33
|
+
def image_hint(settings)
|
|
34
|
+
settings.containers.map(&:image).join(", ")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require_relative "containers"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Deploy
|
|
6
|
+
module Preview
|
|
7
|
+
# The resolved, validated settings of one preview stack family.
|
|
8
|
+
#
|
|
9
|
+
# `Settings.build` is the only place a raw `preview` block meets the
|
|
10
|
+
# main stack's own facts, so every default lives here and the two
|
|
11
|
+
# renderers (`Template`, `Script`) read plain values.
|
|
12
|
+
#
|
|
13
|
+
# ## Why validation is this strict
|
|
14
|
+
#
|
|
15
|
+
# Every value ends up in a CloudFormation name, a Postgres database
|
|
16
|
+
# name that `CREATE DATABASE` cannot parameterize, or a shell script
|
|
17
|
+
# baked with the value. Patterns are checked once here, so neither
|
|
18
|
+
# renderer has to quote defensively.
|
|
19
|
+
class Settings
|
|
20
|
+
KEYS = %i[prefix alb_prefix owner_stack database_stack database_endpoint_output database_secret_output
|
|
21
|
+
db_prefix protected_databases protected_branches cpu memory log_retention_days session_cookie
|
|
22
|
+
first_admin signup_path landing_path db_init_image containers].freeze
|
|
23
|
+
|
|
24
|
+
DEFAULT_DB_INIT_IMAGE = "public.ecr.aws/docker/library/postgres:16-alpine".freeze
|
|
25
|
+
PREFIX_PATTERN = /\A[a-z0-9][a-z0-9-]{0,59}\z/
|
|
26
|
+
ALB_PREFIX_PATTERN = /\A[a-z0-9][a-z0-9-]{0,9}[a-z0-9]\z/
|
|
27
|
+
DB_PREFIX_PATTERN = /\A[a-z][a-z0-9_]{0,29}\z/
|
|
28
|
+
STACK_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]{0,127}\z/
|
|
29
|
+
OUTPUT_PATTERN = /\A[A-Za-z0-9]+\z/
|
|
30
|
+
BRANCH_PATTERN = %r{\A[A-Za-z0-9._/-]+\z}
|
|
31
|
+
PATH_PATTERN = Containers::PATH_PATTERN
|
|
32
|
+
COOKIE_PATTERN = /\A[A-Za-z0-9_.-]+\z/
|
|
33
|
+
RETENTION_DAYS = [1, 3, 5, 7, 14, 30, 60, 90].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader(*KEYS, :region, :stack_name, :infra_name, :main)
|
|
36
|
+
|
|
37
|
+
# Builds the settings for one domain's previews.
|
|
38
|
+
#
|
|
39
|
+
# @param raw [Hash{Symbol => Object}, true] the `preview` block; `true` accepts
|
|
40
|
+
# every default
|
|
41
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
|
|
42
|
+
# @param main [Hash{Symbol => Object}] the main stack's facts: `:infra_name`,
|
|
43
|
+
# `:stack_name`,
|
|
44
|
+
# `:stack_prefix`, `:region`, `:cpu`, `:memory`, `:db_name`, `:owner_stack` (nil unless
|
|
45
|
+
# the main stack borrows a database), `:name`, `:port`, `:image`, `:domain`, `:web`,
|
|
46
|
+
# `:wasm_path`, `:ir_path`, `:schema`
|
|
47
|
+
# @return [Settings] the validated settings
|
|
48
|
+
# @raise [ArgumentError] if a key is unknown or a value breaks its pattern
|
|
49
|
+
def self.build(raw, deploy_settings:, main:)
|
|
50
|
+
raw = raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : {}
|
|
51
|
+
unknown = raw.keys - KEYS
|
|
52
|
+
unless unknown.empty?
|
|
53
|
+
raise ArgumentError, "unknown preview setting(s): #{unknown.join(', ')} (known: #{KEYS.join(', ')})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
new(raw, deploy_settings, main)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param raw [Hash{Symbol => Object}] the symbolized `preview` settings
|
|
60
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
|
|
61
|
+
# @param main [Hash{Symbol => Object}] the main stack's facts, see `build`
|
|
62
|
+
def initialize(raw, deploy_settings, main)
|
|
63
|
+
@main = main
|
|
64
|
+
@region = main.fetch(:region)
|
|
65
|
+
@stack_name = main.fetch(:stack_name)
|
|
66
|
+
@infra_name = main.fetch(:infra_name)
|
|
67
|
+
assign_names(raw, main)
|
|
68
|
+
assign_database(raw, main)
|
|
69
|
+
assign_sizing(raw, main)
|
|
70
|
+
assign_behaviour(raw)
|
|
71
|
+
@containers = Containers.resolve(preview: raw, deploy_settings: deploy_settings, main: main,
|
|
72
|
+
signup_path: (signup_path if first_admin))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Answers the host container, when the task has one.
|
|
76
|
+
#
|
|
77
|
+
# @return [Containers::Entry, nil] the entry flagged `host`
|
|
78
|
+
def host = containers.find(&:host)
|
|
79
|
+
|
|
80
|
+
# Answers the container that receives unmatched requests.
|
|
81
|
+
#
|
|
82
|
+
# @return [Containers::Entry] the default entry
|
|
83
|
+
def default_container = containers.find(&:default)
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def assign_names(raw, main)
|
|
88
|
+
prefix_default = "#{main.fetch(:stack_prefix)}-#{infra_name}-preview".downcase.gsub(/[^a-z0-9-]+/, "-")
|
|
89
|
+
@prefix = pick(raw, :prefix, prefix_default, PREFIX_PATTERN)
|
|
90
|
+
alb_default = "#{infra_name.downcase.gsub(/[^a-z0-9]/, '')[0, 8]}pv"
|
|
91
|
+
@alb_prefix = pick(raw, :alb_prefix, alb_default, ALB_PREFIX_PATTERN)
|
|
92
|
+
@owner_stack = pick(raw, :owner_stack, main[:owner_stack] || stack_name, STACK_PATTERN)
|
|
93
|
+
@database_stack = pick(raw, :database_stack, owner_stack, STACK_PATTERN)
|
|
94
|
+
@database_endpoint_output = pick(raw, :database_endpoint_output, "DatabaseEndpoint", OUTPUT_PATTERN)
|
|
95
|
+
@database_secret_output = pick(raw, :database_secret_output, "DatabaseSecretArn", OUTPUT_PATTERN)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign_database(raw, main)
|
|
99
|
+
db_default = infra_name.downcase.gsub(/[^a-z0-9]+/, "_").sub(/\A[^a-z]+/, "")[0, 30].sub(/_+\z/, "")
|
|
100
|
+
@db_prefix = pick(raw, :db_prefix, db_default, DB_PREFIX_PATTERN)
|
|
101
|
+
extra = Array(raw[:protected_databases]).map(&:to_s)
|
|
102
|
+
@protected_databases = ([main.fetch(:db_name).to_s.downcase, "postgres", "template0", "template1"] + extra).uniq
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def assign_sizing(raw, main)
|
|
106
|
+
@cpu = Integer(raw.fetch(:cpu, main.fetch(:cpu)))
|
|
107
|
+
@memory = Integer(raw.fetch(:memory, main.fetch(:memory)))
|
|
108
|
+
@log_retention_days = Integer(raw.fetch(:log_retention_days, 7))
|
|
109
|
+
return if RETENTION_DAYS.include?(log_retention_days)
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "preview log_retention_days must be one of #{RETENTION_DAYS.join(', ')}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def assign_behaviour(raw)
|
|
115
|
+
@protected_branches = Array(raw.fetch(:protected_branches, %w[main master])).map(&:to_s)
|
|
116
|
+
unless protected_branches.all? { |b| b.match?(BRANCH_PATTERN) }
|
|
117
|
+
raise ArgumentError, "preview protected_branches must be plain branch names"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@session_cookie = pick(raw, :session_cookie, nil, COOKIE_PATTERN)
|
|
121
|
+
@first_admin = raw.fetch(:first_admin, true) != false
|
|
122
|
+
@signup_path = pick(raw, :signup_path, "/signups", PATH_PATTERN)
|
|
123
|
+
@landing_path = pick(raw, :landing_path, "/", PATH_PATTERN)
|
|
124
|
+
@db_init_image = pick(raw, :db_init_image, DEFAULT_DB_INIT_IMAGE, %r{\A[A-Za-z0-9./:_@-]+\z})
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pick(raw, key, default, pattern)
|
|
128
|
+
value = raw.key?(key) ? raw[key] : default
|
|
129
|
+
return value if value.nil?
|
|
130
|
+
|
|
131
|
+
value = value.to_s
|
|
132
|
+
return value if value.match?(pattern)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "preview #{key} #{value.inspect} must match #{pattern.inspect}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "yaml_text"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Projections
|
|
6
|
+
module Deploy
|
|
7
|
+
module Preview
|
|
8
|
+
# The task definitions of a preview stack: the containers it runs and the
|
|
9
|
+
# one-shot task that creates its database.
|
|
10
|
+
#
|
|
11
|
+
# The host container gets the domain, session and database environment; a
|
|
12
|
+
# container's own `environment` wins over a generated entry of the same name.
|
|
13
|
+
module TaskYaml
|
|
14
|
+
# The one-shot task's script. Idempotent, and refuses names the preview must never touch.
|
|
15
|
+
DB_INIT_SCRIPT = <<~SH.freeze
|
|
16
|
+
set -eu
|
|
17
|
+
case "$TARGET_DB" in
|
|
18
|
+
""|*[!a-z0-9_]*) echo "refusing TARGET_DB '$TARGET_DB': lowercase letters, digits and underscores only" >&2; exit 1 ;;
|
|
19
|
+
esac
|
|
20
|
+
[ "${#TARGET_DB}" -le 63 ] || { echo "refusing TARGET_DB '$TARGET_DB': longer than 63 characters" >&2; exit 1; }
|
|
21
|
+
for protected in $PROTECTED_DATABASES; do
|
|
22
|
+
[ "$TARGET_DB" != "$protected" ] || { echo "refusing to manage the protected database '$protected'" >&2; exit 1; }
|
|
23
|
+
done
|
|
24
|
+
export PGSSLMODE=require PGCONNECT_TIMEOUT=10
|
|
25
|
+
found=$(psql -h "$DB_HOST" -U "$PGUSER" -d postgres -Atc "SELECT 1 FROM pg_database WHERE datname = '$TARGET_DB'")
|
|
26
|
+
if [ "${ACTION:-create}" = "drop" ]; then
|
|
27
|
+
if [ "$found" = "1" ]; then
|
|
28
|
+
psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$PGUSER" -d postgres -c "DROP DATABASE \\"$TARGET_DB\\" WITH (FORCE)"
|
|
29
|
+
echo "dropped database $TARGET_DB"
|
|
30
|
+
else
|
|
31
|
+
echo "database $TARGET_DB does not exist"
|
|
32
|
+
fi
|
|
33
|
+
elif [ "$found" = "1" ]; then
|
|
34
|
+
echo "database $TARGET_DB already exists"
|
|
35
|
+
else
|
|
36
|
+
psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$PGUSER" -d postgres -c "CREATE DATABASE \\"$TARGET_DB\\""
|
|
37
|
+
echo "created database $TARGET_DB"
|
|
38
|
+
fi
|
|
39
|
+
SH
|
|
40
|
+
|
|
41
|
+
# The database credentials as container secrets, so the password never appears as a plain
|
|
42
|
+
# value in the task definition.
|
|
43
|
+
DB_INIT_SECRETS = <<~YAML.chomp.freeze
|
|
44
|
+
Secrets:
|
|
45
|
+
- Name: PGUSER
|
|
46
|
+
ValueFrom: !Sub "${OwningDatabaseSecretArn}:username::"
|
|
47
|
+
- Name: PGPASSWORD
|
|
48
|
+
ValueFrom: !Sub "${OwningDatabaseSecretArn}:password::"
|
|
49
|
+
YAML
|
|
50
|
+
|
|
51
|
+
extend YamlText
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# Renders the one-shot task that creates (or drops) this preview's database.
|
|
56
|
+
#
|
|
57
|
+
# @param settings [Settings] the resolved preview settings
|
|
58
|
+
# @return [String] the task definition's resource block
|
|
59
|
+
def db_init_task(settings)
|
|
60
|
+
<<~YAML.chomp
|
|
61
|
+
# Run by preview.sh (`aws ecs run-task`) after the images are pushed and before the
|
|
62
|
+
# service scales up: the database instance is reachable only from inside the VPC, so
|
|
63
|
+
# this is the one place a laptop-driven deploy can run CREATE DATABASE. Idempotent.
|
|
64
|
+
DbInitTaskDefinition:
|
|
65
|
+
Type: AWS::ECS::TaskDefinition
|
|
66
|
+
Properties:
|
|
67
|
+
Family: !Sub "#{settings.prefix}-${EnvName}-dbinit"
|
|
68
|
+
RequiresCompatibilities: [FARGATE]
|
|
69
|
+
NetworkMode: awsvpc
|
|
70
|
+
Cpu: "256"
|
|
71
|
+
Memory: "512"
|
|
72
|
+
RuntimePlatform:
|
|
73
|
+
CpuArchitecture: ARM64
|
|
74
|
+
OperatingSystemFamily: LINUX
|
|
75
|
+
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
|
|
76
|
+
TaskRoleArn: !GetAtt TaskRole.Arn
|
|
77
|
+
ContainerDefinitions:
|
|
78
|
+
- Name: dbinit
|
|
79
|
+
Image: #{settings.db_init_image}
|
|
80
|
+
Essential: true
|
|
81
|
+
Command:
|
|
82
|
+
- sh
|
|
83
|
+
- -c
|
|
84
|
+
- |
|
|
85
|
+
#{indent(DB_INIT_SCRIPT.chomp, 12)}
|
|
86
|
+
LogConfiguration:
|
|
87
|
+
LogDriver: awslogs
|
|
88
|
+
Options:
|
|
89
|
+
awslogs-group: !Ref LogGroup
|
|
90
|
+
awslogs-region: !Ref AWS::Region
|
|
91
|
+
awslogs-stream-prefix: dbinit
|
|
92
|
+
Environment:
|
|
93
|
+
- Name: DB_HOST
|
|
94
|
+
Value: !Ref OwningDatabaseEndpoint
|
|
95
|
+
- Name: TARGET_DB
|
|
96
|
+
Value: !Ref DbName
|
|
97
|
+
- Name: PROTECTED_DATABASES
|
|
98
|
+
Value: #{JSON.generate(settings.protected_databases.join(' '))}
|
|
99
|
+
#{indent(DB_INIT_SECRETS, 8)}
|
|
100
|
+
YAML
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Renders the preview's task definition, one container per entry of the container list.
|
|
104
|
+
#
|
|
105
|
+
# @param settings [Settings] the resolved preview settings
|
|
106
|
+
# @return [String] the task definition's resource block
|
|
107
|
+
def task_definition(settings)
|
|
108
|
+
<<~YAML.chomp
|
|
109
|
+
TaskDefinition:
|
|
110
|
+
Type: AWS::ECS::TaskDefinition
|
|
111
|
+
Properties:
|
|
112
|
+
Family: !Sub "#{settings.prefix}-${EnvName}"
|
|
113
|
+
RequiresCompatibilities: [FARGATE]
|
|
114
|
+
NetworkMode: awsvpc
|
|
115
|
+
Cpu: "#{settings.cpu}"
|
|
116
|
+
Memory: "#{settings.memory}"
|
|
117
|
+
RuntimePlatform:
|
|
118
|
+
CpuArchitecture: ARM64
|
|
119
|
+
OperatingSystemFamily: LINUX
|
|
120
|
+
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
|
|
121
|
+
TaskRoleArn: !GetAtt TaskRole.Arn
|
|
122
|
+
ContainerDefinitions:
|
|
123
|
+
#{indent(settings.containers.map { |c| container_definition(settings, c) }.join("\n"), 6)}
|
|
124
|
+
YAML
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Renders one container of the task definition.
|
|
128
|
+
#
|
|
129
|
+
# @param settings [Settings] the resolved preview settings
|
|
130
|
+
# @param container [Containers::Entry] the container
|
|
131
|
+
# @return [String] the list entry
|
|
132
|
+
def container_definition(settings, container)
|
|
133
|
+
lines = [
|
|
134
|
+
"- Name: #{container.name}",
|
|
135
|
+
%( Image: !Sub "${#{container.logical}Repository.RepositoryUri}:${#{container.logical}ImageTag}"),
|
|
136
|
+
" Essential: true"
|
|
137
|
+
]
|
|
138
|
+
lines += [" PortMappings:", " - ContainerPort: #{container.port}"] if container.routed?
|
|
139
|
+
lines += [" LogConfiguration:", indent(log_options(container), 4).chomp]
|
|
140
|
+
lines += [" Environment:", indent(environment_yaml(settings, container), 4).chomp]
|
|
141
|
+
lines.join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Renders the log driver settings of one container.
|
|
145
|
+
#
|
|
146
|
+
# @param container [Containers::Entry] the container
|
|
147
|
+
# @return [String] the settings, ending in a newline
|
|
148
|
+
def log_options(container)
|
|
149
|
+
<<~YAML
|
|
150
|
+
LogDriver: awslogs
|
|
151
|
+
Options:
|
|
152
|
+
awslogs-group: !Ref LogGroup
|
|
153
|
+
awslogs-region: !Ref AWS::Region
|
|
154
|
+
awslogs-stream-prefix: #{container.name}
|
|
155
|
+
YAML
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Renders one container's environment as a list.
|
|
159
|
+
#
|
|
160
|
+
# @param settings [Settings] the resolved preview settings
|
|
161
|
+
# @param container [Containers::Entry] the container
|
|
162
|
+
# @return [String] the list entries
|
|
163
|
+
def environment_yaml(settings, container)
|
|
164
|
+
environment_pairs(settings, container).map do |name, value|
|
|
165
|
+
"- Name: #{name}\n Value: #{value}"
|
|
166
|
+
end.join("\n")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Orders one container's environment as `[name, rendered value]` pairs; the container's
|
|
170
|
+
# `environment` wins over a generated entry of the same name.
|
|
171
|
+
#
|
|
172
|
+
# @param settings [Settings] the resolved preview settings
|
|
173
|
+
# @param container [Containers::Entry] the container
|
|
174
|
+
# @return [Array<Array(String, String)>] the pairs, each value already rendered as YAML
|
|
175
|
+
def environment_pairs(settings, container)
|
|
176
|
+
pairs = { "PORT" => scalar(container.port.to_s) }
|
|
177
|
+
pairs.merge!(host_environment(settings)) if container.host
|
|
178
|
+
pairs.merge!(database_environment) if container.host || container.database
|
|
179
|
+
container.secrets.each { |name| pairs[name] = "!Ref #{secret_id(container, name)}" }
|
|
180
|
+
container.environment.each { |name, value| pairs[name] = scalar(value) }
|
|
181
|
+
pairs.to_a
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Answers the environment only the Hecks host container gets.
|
|
185
|
+
#
|
|
186
|
+
# @param settings [Settings] the resolved preview settings
|
|
187
|
+
# @return [Hash{String => String}] name to rendered value
|
|
188
|
+
def host_environment(settings)
|
|
189
|
+
main = settings.main
|
|
190
|
+
pairs = {
|
|
191
|
+
"HECKS_DOMAIN" => scalar(main.fetch(:domain)), "HECKS_ERA" => scalar("1"),
|
|
192
|
+
"PORT" => scalar(settings.host.port.to_s), "BIND" => scalar("0.0.0.0"),
|
|
193
|
+
"HECKS_SERVE_MODE" => scalar("1"), "HECKS_WEB" => scalar(main.fetch(:web)),
|
|
194
|
+
"HECKS_WASM_PATH" => scalar(main.fetch(:wasm_path)), "HECKS_IR_PATH" => scalar(main.fetch(:ir_path)),
|
|
195
|
+
"SESSION_SECRET_ARN" => "!Ref SessionSecret", "HECKS_CHECKOUT_DOMAIN" => scalar(main.fetch(:domain))
|
|
196
|
+
}
|
|
197
|
+
pairs["HECKS_SCHEMA"] = scalar(main[:schema]) if main[:schema]
|
|
198
|
+
pairs["HECKS_SESSION_COOKIE"] = scalar(settings.session_cookie) if settings.session_cookie
|
|
199
|
+
pairs
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Answers the environment that points a container at this preview's database.
|
|
203
|
+
#
|
|
204
|
+
# @return [Hash{String => String}] name to rendered value
|
|
205
|
+
def database_environment
|
|
206
|
+
{
|
|
207
|
+
"DB_HOST" => "!Ref OwningDatabaseEndpoint", "DB_NAME" => "!Ref DbName",
|
|
208
|
+
"DB_SECRET_ARN" => "!Ref OwningDatabaseSecretArn"
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "yaml_text"
|
|
3
|
+
require_relative "access_yaml"
|
|
4
|
+
require_relative "task_yaml"
|
|
5
|
+
require_relative "routing_yaml"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Projections
|
|
9
|
+
module Deploy
|
|
10
|
+
module Preview
|
|
11
|
+
# Renders `preview.yaml`, the CloudFormation template of one
|
|
12
|
+
# per-branch preview stack.
|
|
13
|
+
#
|
|
14
|
+
# A separate template rather than conditions threaded through the main
|
|
15
|
+
# one, so a preview can never put the main stack's live resources in a
|
|
16
|
+
# change set. The two share a task shape and nothing else.
|
|
17
|
+
#
|
|
18
|
+
# ## What a preview owns
|
|
19
|
+
#
|
|
20
|
+
# Per branch: a cluster, service and task, a load balancer, one image
|
|
21
|
+
# repository per container, a session secret, any secrets a container
|
|
22
|
+
# declares, a log group, a CloudFront distribution (default hostname,
|
|
23
|
+
# nothing cached) and a database name on the shared instance, created
|
|
24
|
+
# by a one-shot task before the service starts.
|
|
25
|
+
#
|
|
26
|
+
# ## What it borrows and leaves out
|
|
27
|
+
#
|
|
28
|
+
# It attaches to the owner stack's VPC, subnets and compute security
|
|
29
|
+
# group and connects to the shared database instance, through the same
|
|
30
|
+
# `Owning*` parameters the main template takes for `database "Shared"`.
|
|
31
|
+
# It has no alarms, no notifications, no warmer and no custom domain:
|
|
32
|
+
# nobody should be paged for a preview.
|
|
33
|
+
module Template
|
|
34
|
+
CACHING_DISABLED = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad".freeze
|
|
35
|
+
ALL_VIEWER = "216adef6-5c7f-47e4-b989-5492eafa07d3".freeze
|
|
36
|
+
|
|
37
|
+
extend YamlText
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Renders the whole template.
|
|
42
|
+
#
|
|
43
|
+
# @param settings [Settings] the resolved preview settings
|
|
44
|
+
# @return [String] the CloudFormation YAML, ending in one newline
|
|
45
|
+
def render(settings)
|
|
46
|
+
[
|
|
47
|
+
header(settings), parameters(settings),
|
|
48
|
+
"Resources:\n#{indent(resources(settings).join("\n\n"), 2)}",
|
|
49
|
+
outputs
|
|
50
|
+
].join("\n") << "\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Renders the file's leading comment and template description.
|
|
54
|
+
#
|
|
55
|
+
# @param settings [Settings] the resolved preview settings
|
|
56
|
+
# @return [String] the header block
|
|
57
|
+
def header(settings)
|
|
58
|
+
<<~YAML.chomp
|
|
59
|
+
# GENERATED by bin/project_deploy - re-run it to refresh this file rather than
|
|
60
|
+
# hand-editing. Source: the `preview` block of the domain's deployed_to("AwsFargate").
|
|
61
|
+
#
|
|
62
|
+
# One stack per git branch, driven by preview.sh. A separate template from the main
|
|
63
|
+
# one on purpose, so a preview can never appear in a change set against the live
|
|
64
|
+
# stack. It shares the task shape and nothing else.
|
|
65
|
+
#
|
|
66
|
+
# Per branch: ECS cluster, service and task, an ALB, one ECR repository per
|
|
67
|
+
# container, secrets, a log group, a CloudFront distribution (default hostname,
|
|
68
|
+
# nothing cached) and its own database on the shared instance.
|
|
69
|
+
# Borrowed from #{settings.owner_stack}: VPC, subnets and the compute security group.
|
|
70
|
+
# Left out on purpose: alarms, notifications, warmers and custom domains.
|
|
71
|
+
AWSTemplateFormatVersion: '2010-09-09'
|
|
72
|
+
Description: >
|
|
73
|
+
#{settings.infra_name} preview - one feature branch, behind its own ALB and
|
|
74
|
+
CloudFront, on its own database.
|
|
75
|
+
YAML
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Renders the parameters section.
|
|
79
|
+
#
|
|
80
|
+
# @param settings [Settings] the resolved preview settings
|
|
81
|
+
# @return [String] the section, one image-tag parameter per container
|
|
82
|
+
def parameters(settings)
|
|
83
|
+
image_tags = settings.containers.map do |c|
|
|
84
|
+
<<~YAML.chomp
|
|
85
|
+
#{c.logical}ImageTag:
|
|
86
|
+
Type: String
|
|
87
|
+
Default: bootstrap
|
|
88
|
+
YAML
|
|
89
|
+
end
|
|
90
|
+
"Parameters:\n#{indent([base_parameters, *image_tags, desired_count_parameter].join("\n"), 2)}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Renders the parameters every preview takes: its name, database and the borrowed network.
|
|
94
|
+
#
|
|
95
|
+
# @return [String] the parameter blocks
|
|
96
|
+
def base_parameters
|
|
97
|
+
<<~YAML.chomp
|
|
98
|
+
EnvName:
|
|
99
|
+
Type: String
|
|
100
|
+
AllowedPattern: '^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$'
|
|
101
|
+
ConstraintDescription: lowercase letters, digits and hyphens, at most 20 characters, not ending in a hyphen (the ALB name limit is 32).
|
|
102
|
+
DbName:
|
|
103
|
+
Type: String
|
|
104
|
+
AllowedPattern: '^[a-z][a-z0-9_]{0,62}$'
|
|
105
|
+
Description: This preview's own database on the shared instance, created by the one-shot DbInit task before the service starts.
|
|
106
|
+
OwningVpcId:
|
|
107
|
+
Type: AWS::EC2::VPC::Id
|
|
108
|
+
OwningSubnetAId:
|
|
109
|
+
Type: AWS::EC2::Subnet::Id
|
|
110
|
+
OwningSubnetBId:
|
|
111
|
+
Type: AWS::EC2::Subnet::Id
|
|
112
|
+
OwningPublicSubnetAId:
|
|
113
|
+
Type: AWS::EC2::Subnet::Id
|
|
114
|
+
OwningPublicSubnetBId:
|
|
115
|
+
Type: AWS::EC2::Subnet::Id
|
|
116
|
+
OwningSecurityGroupId:
|
|
117
|
+
Type: AWS::EC2::SecurityGroup::Id
|
|
118
|
+
OwningDatabaseEndpoint:
|
|
119
|
+
Type: String
|
|
120
|
+
OwningDatabaseSecretArn:
|
|
121
|
+
Type: String
|
|
122
|
+
YAML
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Renders the parameter `preview.sh` uses to start the stack at zero tasks.
|
|
126
|
+
#
|
|
127
|
+
# @return [String] the parameter block
|
|
128
|
+
def desired_count_parameter
|
|
129
|
+
<<~YAML.chomp
|
|
130
|
+
# The repositories are created by this stack, so the first create has no image to
|
|
131
|
+
# point at. preview.sh creates the stack at 0, pushes, then scales up.
|
|
132
|
+
DesiredCount:
|
|
133
|
+
Type: Number
|
|
134
|
+
Default: 0
|
|
135
|
+
YAML
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Collects every resource block of the template.
|
|
139
|
+
#
|
|
140
|
+
# @param settings [Settings] the resolved preview settings
|
|
141
|
+
# @return [Array<String>] the resource blocks, in template order
|
|
142
|
+
def resources(settings)
|
|
143
|
+
[
|
|
144
|
+
AccessYaml.secrets(settings), RoutingYaml.networking(settings), repositories(settings),
|
|
145
|
+
cluster_and_logs(settings),
|
|
146
|
+
AccessYaml.roles(settings), TaskYaml.db_init_task(settings), TaskYaml.task_definition(settings),
|
|
147
|
+
RoutingYaml.load_balancing(settings),
|
|
148
|
+
RoutingYaml.service(settings), distribution
|
|
149
|
+
].flatten
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Renders one image repository per container, emptied when the stack is deleted.
|
|
153
|
+
#
|
|
154
|
+
# @param settings [Settings] the resolved preview settings
|
|
155
|
+
# @return [Array<String>] the resource blocks
|
|
156
|
+
def repositories(settings)
|
|
157
|
+
settings.containers.map do |c|
|
|
158
|
+
<<~YAML.chomp
|
|
159
|
+
#{c.logical}Repository:
|
|
160
|
+
Type: AWS::ECR::Repository
|
|
161
|
+
Properties:
|
|
162
|
+
RepositoryName: !Sub "#{settings.prefix}-${EnvName}-#{c.name}"
|
|
163
|
+
EmptyOnDelete: true
|
|
164
|
+
YAML
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Renders the cluster and the log group.
|
|
169
|
+
#
|
|
170
|
+
# @param settings [Settings] the resolved preview settings
|
|
171
|
+
# @return [Array<String>] the two resource blocks
|
|
172
|
+
def cluster_and_logs(settings)
|
|
173
|
+
[
|
|
174
|
+
<<~YAML.chomp,
|
|
175
|
+
Cluster:
|
|
176
|
+
Type: AWS::ECS::Cluster
|
|
177
|
+
Properties:
|
|
178
|
+
ClusterName: !Sub "#{settings.prefix}-${EnvName}"
|
|
179
|
+
YAML
|
|
180
|
+
<<~YAML.chomp
|
|
181
|
+
LogGroup:
|
|
182
|
+
Type: AWS::Logs::LogGroup
|
|
183
|
+
Properties:
|
|
184
|
+
LogGroupName: !Sub "/ecs/#{settings.prefix}-${EnvName}"
|
|
185
|
+
RetentionInDays: #{settings.log_retention_days}
|
|
186
|
+
YAML
|
|
187
|
+
]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Renders the CDN distribution, which caches nothing.
|
|
191
|
+
#
|
|
192
|
+
# @return [String] the resource block
|
|
193
|
+
def distribution
|
|
194
|
+
<<~YAML.chomp
|
|
195
|
+
# One behavior, nothing cached: a preview is for looking at a change, so no cache means
|
|
196
|
+
# no stale page after a redeploy and no session response served to the wrong visitor.
|
|
197
|
+
# AllViewer forwards the viewer's Host so a same-origin check sees the real hostname.
|
|
198
|
+
PreviewDistribution:
|
|
199
|
+
Type: AWS::CloudFront::Distribution
|
|
200
|
+
Properties:
|
|
201
|
+
DistributionConfig:
|
|
202
|
+
Enabled: true
|
|
203
|
+
HttpVersion: http2
|
|
204
|
+
Comment: !Sub "preview ${EnvName}"
|
|
205
|
+
Origins:
|
|
206
|
+
- Id: PreviewAlbOrigin
|
|
207
|
+
DomainName: !GetAtt Alb.DNSName
|
|
208
|
+
CustomOriginConfig:
|
|
209
|
+
OriginProtocolPolicy: http-only
|
|
210
|
+
OriginSSLProtocols: [TLSv1.2]
|
|
211
|
+
DefaultCacheBehavior:
|
|
212
|
+
TargetOriginId: PreviewAlbOrigin
|
|
213
|
+
ViewerProtocolPolicy: redirect-to-https
|
|
214
|
+
AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
|
|
215
|
+
CachedMethods: [GET, HEAD]
|
|
216
|
+
Compress: true
|
|
217
|
+
CachePolicyId: #{CACHING_DISABLED}
|
|
218
|
+
OriginRequestPolicyId: #{ALL_VIEWER}
|
|
219
|
+
YAML
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Renders the outputs `preview.sh` reads back.
|
|
223
|
+
#
|
|
224
|
+
# @return [String] the section
|
|
225
|
+
def outputs
|
|
226
|
+
<<~YAML.chomp
|
|
227
|
+
Outputs:
|
|
228
|
+
PreviewUrl:
|
|
229
|
+
Value: !Sub "https://${PreviewDistribution.DomainName}"
|
|
230
|
+
ClusterName:
|
|
231
|
+
Value: !Ref Cluster
|
|
232
|
+
ServiceName:
|
|
233
|
+
Value: !GetAtt Service.Name
|
|
234
|
+
DbInitTaskDefinition:
|
|
235
|
+
Value: !Ref DbInitTaskDefinition
|
|
236
|
+
# The ARN only, so preview.sh can fetch the secret to mint signup and session tokens.
|
|
237
|
+
SessionSecretArn:
|
|
238
|
+
Value: !Ref SessionSecret
|
|
239
|
+
DatabaseName:
|
|
240
|
+
Value: !Ref DbName
|
|
241
|
+
YAML
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|