hecks 3.6.0 → 3.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. metadata +50 -1
@@ -51,10 +51,14 @@ module Hecks
51
51
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
52
52
  :containers, :routes, :default_container, :origin_header, :origin_secret,
53
53
  :secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image,
54
- :task_definition, :migration, :s3_buckets, :hosting, keyword_init: true
54
+ :task_definition, :migration, :s3_buckets, :hosting, :shared_database, keyword_init: true
55
55
  ) do
56
- # @return [String] the CloudFormation stack that holds the database
57
- def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
56
+ # @return [String] the CloudFormation stack that holds the database: the shared
57
+ # instance's when the world names one, else this site's own
58
+ def rds_stack = shared_database || "#{stack_prefix}-#{infra_name}-rds"
59
+
60
+ # @return [Boolean] whether the database lives on an instance shared with other sites
61
+ def shared? = !shared_database.nil?
58
62
 
59
63
  # @return [String] the CloudFormation stack that holds the box
60
64
  def box_stack = "#{stack_prefix}-#{infra_name}-box"
@@ -47,6 +47,7 @@ module Hecks
47
47
  def extra_fields(settings, task_definition, database_name)
48
48
  {
49
49
  task_definition: task_definition, migration: read_migration(settings[:migration], database_name),
50
+ shared_database: read_shared_database(settings),
50
51
  s3_buckets: read_s3_access(settings.fetch(:s3_access, [])),
51
52
  hosting: HostingSettings.read(settings, task_definition)
52
53
  }
@@ -129,6 +130,23 @@ module Hecks
129
130
  end.uniq(&:name)
130
131
  end
131
132
 
133
+ # The stack of the RDS instance this site shares with others. The instance's size,
134
+ # storage, engine and backups are the instance's, so a world that sets them is refused.
135
+ #
136
+ # @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
137
+ # @return [String, nil] the shared stack's name, or nil for a database of its own
138
+ # @raise [ArgumentError] when the name is malformed or the world also sizes the instance
139
+ def read_shared_database(settings)
140
+ return nil unless settings.key?(:shared_database)
141
+
142
+ clash = SHARED_INSTANCE_SETTINGS & settings.keys
143
+ unless clash.empty?
144
+ raise ArgumentError, "shared_database: #{clash.join(", ")} size the instance, which belongs to the shared " \
145
+ "stack #{settings[:shared_database]}; drop #{clash.size == 1 ? "it" : "them"}"
146
+ end
147
+ check(:shared_database, settings[:shared_database], NAME)
148
+ end
149
+
132
150
  def read_database_name(settings, infra_name)
133
151
  check(:database_name, settings.fetch(:database_name, infra_name.gsub(/[^a-zA-Z0-9]/, "")), DB_NAME)
134
152
  end
@@ -0,0 +1,69 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Box
5
+ # The parts of a box deploy that change when its database is one of several on a shared RDS
6
+ # instance, each client in its own database with its own login role (ADR 0092). Mixed into
7
+ # `Box`. Everything here returns the dedicated-instance text unchanged when the plan has no
8
+ # `shared_database`, so a site with its own instance generates the same files as before.
9
+ module SharedDatabase
10
+ # @param plan [Settings::Plan] the resolved settings
11
+ # @return [Hash{String => String}] the Makefile markers that depend on where the database
12
+ # lives
13
+ def database_makefile_values(plan)
14
+ return dedicated_makefile_values unless plan.shared?
15
+
16
+ { "WHAT" => "one app box and a database on the shared instance #{plan.rds_stack}",
17
+ "REHEARSAL_NOTE" => "# true gives the box no Elastic IP. It never deletes the shared instance\n" \
18
+ "# or this site's database.\n",
19
+ "RDS_DEPLOY" => "", "DB_SECRET_PARAM" => "\t\tDbSecretArn=$$(aws secretsmanager describe-secret --secret-id " \
20
+ "#{plan.infra_name}/database --query ARN --output text) \\",
21
+ "PHONY" => ".PHONY: stacks deploy provision", "PROVISION" => provision_target(plan) }
22
+ end
23
+
24
+ # @param plan [Settings::Plan] the resolved settings
25
+ # @return [String] the line of `deploy-box.sh` that finds the secret the containers read
26
+ def database_secret_line(plan)
27
+ return 'DB_SECRET=$(out "$RDS_STACK" DbSecretArn)' unless plan.shared?
28
+
29
+ "DB_SECRET=$(aws secretsmanager describe-secret --secret-id #{plan.infra_name}/database --query ARN --output text)"
30
+ end
31
+
32
+ # @param plan [Settings::Plan] the resolved settings
33
+ # @return [String] `provision-database.sh`, which creates the site's database, role and
34
+ # secret
35
+ def provision_database_sh(plan)
36
+ template("provision-database.sh.tmpl", "STACK" => plan.infra_name, "RDS_STACK" => plan.rds_stack,
37
+ "DATABASE" => plan.database_name)
38
+ end
39
+
40
+ private
41
+
42
+ def dedicated_makefile_values
43
+ { "WHAT" => "one app box and one RDS instance",
44
+ "REHEARSAL_NOTE" => "# true makes a throwaway pair: the database is deleted with its stack and the box has no\n" \
45
+ "# Elastic IP. The default is a production pair, with deletion protection.\n",
46
+ "RDS_DEPLOY" => rds_deploy_lines, "DB_SECRET_PARAM" => dedicated_secret_param,
47
+ "PHONY" => ".PHONY: stacks deploy", "PROVISION" => "" }
48
+ end
49
+
50
+ def rds_deploy_lines
51
+ "\taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\\n" \
52
+ "\t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)\n"
53
+ end
54
+
55
+ def dedicated_secret_param
56
+ "\t\tDbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) " \
57
+ "--query \"Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue\" --output text) \\"
58
+ end
59
+
60
+ def provision_target(plan)
61
+ "# Creates #{plan.infra_name}'s database, role and secret on #{plan.rds_stack}. Run it once; run it again with\n" \
62
+ "# ROTATE=--rotate to change the role's password. make provision BASTION=i-... [ROTATE=--rotate]\n" \
63
+ "provision:\n\tbash ./provision-database.sh $(BASTION) $(ROTATE)\n\n"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,24 +1,23 @@
1
- # @@STACK@@: one app box and one RDS instance.
1
+ # @@STACK@@: @@WHAT@@.
2
2
  # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
3
3
  # make deploy @@DEPLOY_HINT@@
4
4
  RDS_STACK = @@RDS_STACK@@
5
5
  BOX_STACK = @@BOX_STACK@@
6
- # true makes a throwaway pair: the database is deleted with its stack and the box has no
7
- # Elastic IP. The default is a production pair, with deletion protection.
6
+ @@REHEARSAL_NOTE@@
8
7
  REHEARSAL ?= false
9
8
 
10
9
  HECKS ?= hecks
11
10
 
12
- .PHONY: stacks deploy
11
+ @@PHONY@@
13
12
  stacks:
14
- aws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \
15
- --parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)
13
+ @@RDS_DEPLOY@@
16
14
  aws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \
17
15
  --parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \
18
16
  DbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \
19
- DbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text) \
17
+ @@DB_SECRET_PARAM@@
20
18
  AlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" --output text) \
21
19
  Rehearsal=$(REHEARSAL)
22
20
 
21
+ @@PROVISION@@
23
22
  deploy:
24
23
  @@DEPLOY_RECIPE@@
@@ -26,7 +26,7 @@ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].
26
26
 
27
27
  BOX=$(out "$BOX_STACK" InstanceId)
28
28
  DB_HOST=$(out "$RDS_STACK" DbEndpoint)
29
- DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
29
+ @@DB_SECRET_LINE@@
30
30
  [ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 40; }
31
31
  ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
32
32
 
@@ -0,0 +1,97 @@
1
+ #!/bin/bash
2
+ # Create @@STACK@@'s database and login role on the shared RDS instance (stack @@RDS_STACK@@), and the
3
+ # secret @@STACK@@/database that the box's containers read. Run once for a new site, from a machine with
4
+ # AWS credentials that may read the instance's master secret and start SSM sessions, through a bastion
5
+ # that can reach the instance.
6
+ #
7
+ # provision-database.sh <bastion-instance-id> [--rotate]
8
+ #
9
+ # The role owns only the database @@DATABASE@@, is not a superuser, and no other role may connect to that
10
+ # database. The secret holds {username, password, host, port, dbname}; the box's role reads that secret
11
+ # and never the instance's master secret.
12
+ #
13
+ # Safe to run again: a role and a database that exist are left alone, and a secret that exists keeps
14
+ # its password. --rotate gives the role a new password and writes it to the secret; roll the box after it.
15
+ #
16
+ # Ends 70 (the shared stack has no instance).
17
+ #
18
+ # Needs psql, jq, openssl, the SSM plugin and AWS credentials.
19
+ set -euo pipefail
20
+
21
+ BASTION=${1:?bastion instance id}
22
+ ROTATE=${2:-}
23
+ [ -z "$ROTATE" ] || [ "$ROTATE" = --rotate ] || { echo "usage: provision-database.sh <bastion-instance-id> [--rotate]" >&2; exit 2; }
24
+ RDS_STACK=@@RDS_STACK@@
25
+ DB=@@DATABASE@@
26
+ ROLE=@@DATABASE@@
27
+ SECRET=@@STACK@@/database
28
+ PORT=15434
29
+ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].Outputs[?OutputKey==\`$2\`].OutputValue" --output text; }
30
+
31
+ HOST=$(out "$RDS_STACK" DbEndpoint)
32
+ MASTER=$(out "$RDS_STACK" DbSecretArn)
33
+ [ -n "$HOST" ] && [ "$HOST" != None ] || { echo "no instance in stack $RDS_STACK" >&2; exit 70; }
34
+
35
+ WORK=$(mktemp -d)
36
+ PIDS=()
37
+ cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null || true; done; rm -rf "$WORK"; }
38
+ trap cleanup EXIT
39
+
40
+ master_field() { aws secretsmanager get-secret-value --secret-id "$MASTER" --query SecretString --output text | jq -r "$1"; }
41
+ MASTER_USER=$(master_field '.username // "postgres"')
42
+ MASTER_PW=$(master_field .password)
43
+
44
+ aws ssm start-session --target "$BASTION" --document-name AWS-StartPortForwardingSessionToRemoteHost \
45
+ --parameters "{\"host\":[\"$HOST\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"$PORT\"]}" >"$WORK/tunnel.log" 2>&1 &
46
+ PIDS+=($!)
47
+ disown
48
+ for _ in $(seq 1 20); do grep -q "Waiting for connections" "$WORK/tunnel.log" 2>/dev/null && break; sleep 1; done
49
+
50
+ # One statement, one answer. The master connects to the maintenance database.
51
+ M() { PGPASSWORD=$MASTER_PW psql -h localhost -p $PORT -U "$MASTER_USER" -d postgres -v ON_ERROR_STOP=1 -Atc "$1"; }
52
+ # A script on stdin, with the new password passed as a variable so it is never in a command line.
53
+ MS() { PGPASSWORD=$MASTER_PW psql -h localhost -p $PORT -U "$MASTER_USER" -d postgres -v ON_ERROR_STOP=1 -v pw="$PW" -q -f -; }
54
+
55
+ HAVE_ROLE=$(M "select count(*) from pg_roles where rolname='$ROLE'")
56
+ HAVE_SECRET=no
57
+ aws secretsmanager describe-secret --secret-id "$SECRET" >/dev/null 2>&1 && HAVE_SECRET=yes
58
+
59
+ # Letters and digits only: the host puts the password in a URL without percent-encoding it.
60
+ PW=""
61
+ if [ "$HAVE_ROLE" = 0 ]; then
62
+ PW=$(openssl rand -hex 16)
63
+ MS <<SQL
64
+ create role "$ROLE" login password :'pw' nosuperuser nocreatedb nocreaterole noinherit;
65
+ SQL
66
+ echo "created role $ROLE"
67
+ elif [ "$ROTATE" = --rotate ] || [ "$HAVE_SECRET" = no ]; then
68
+ PW=$(openssl rand -hex 16)
69
+ MS <<SQL
70
+ alter role "$ROLE" password :'pw';
71
+ SQL
72
+ echo "set a new password for role $ROLE"
73
+ else
74
+ echo "role $ROLE exists and so does $SECRET; kept"
75
+ fi
76
+
77
+ # RDS's master is not a true superuser: it may create a database for a role only as a member of that role.
78
+ if [ "$(M "select count(*) from pg_database where datname='$DB'")" = 0 ]; then
79
+ M "grant \"$ROLE\" to \"$MASTER_USER\"" >/dev/null
80
+ M "create database \"$DB\" owner \"$ROLE\"" >/dev/null
81
+ echo "created database $DB"
82
+ else
83
+ echo "database $DB exists"
84
+ fi
85
+ # Nobody but the owner (and the master) may connect to it.
86
+ M "revoke all on database \"$DB\" from public" >/dev/null
87
+
88
+ if [ -n "$PW" ]; then
89
+ BODY=$(jq -n --arg u "$ROLE" --arg p "$PW" --arg h "$HOST" --arg d "$DB" '{username: $u, password: $p, host: $h, port: 5432, dbname: $d}')
90
+ if [ "$HAVE_SECRET" = yes ]; then
91
+ aws secretsmanager put-secret-value --secret-id "$SECRET" --secret-string "$BODY" >/dev/null
92
+ else
93
+ aws secretsmanager create-secret --name "$SECRET" --description "@@STACK@@'s database login on @@RDS_STACK@@" --secret-string "$BODY" >/dev/null
94
+ fi
95
+ echo "wrote secret $SECRET"
96
+ fi
97
+ echo "ok $DB on $HOST, role $ROLE, secret $SECRET"
@@ -7,6 +7,8 @@
7
7
  #
8
8
  # FORCE=1 drop the target schemas first: a re-load before cutover, or a rollback copy in the other
9
9
  # direction (swap the hosts and secrets, and SRC_DB and DST_DB)
10
+ # SCHEMAS the schemas to copy, space separated (default @@SCHEMAS@@); another database's schemas, such as
11
+ # an analytics database's `public`, are copied by naming them here with SRC_DB and DST_DB
10
12
  # SRC_DB the source database (default @@SOURCE_DATABASE@@)
11
13
  # DST_DB the target database (default @@DATABASE@@)
12
14
  # VERIFY_BY_COMMAND=1 end after the copy and leave the comparison to `hecks deploy data_copy.restore`
@@ -28,7 +30,7 @@ HERE=$(cd "$(dirname "$0")" && pwd)
28
30
 
29
31
  BASTION=${1:?bastion instance id}; SRC_HOST=${2:?source host}; SRC_SECRET=${3:?source secret arn}
30
32
  DST_HOST=${4:?target host}; DST_SECRET=${5:?target secret arn}
31
- SCHEMAS="@@SCHEMAS@@"
33
+ SCHEMAS=${SCHEMAS:-"@@SCHEMAS@@"}
32
34
  SRC_DB=${SRC_DB:-@@SOURCE_DATABASE@@}; DST_DB=${DST_DB:-@@DATABASE@@}
33
35
  SRC_PORT=15432; DST_PORT=15433
34
36
 
@@ -93,4 +95,4 @@ done
93
95
 
94
96
  # Same structure, same exact row counts in every table, nothing unpopulated.
95
97
  [ -z "${VERIFY_BY_COMMAND:-}" ] || exit 0
96
- A_DB=$SRC_DB B_DB=$DST_DB bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
98
+ A_DB=$SRC_DB B_DB=$DST_DB COMPARE_SCHEMAS=$(echo "$SCHEMAS" | tr ' ' ',') bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
@@ -6,13 +6,14 @@
6
6
  # verify-copy.sh <bastion-instance-id> <host-a> <secret-arn-a> <host-b> <secret-arn-b>
7
7
  #
8
8
  # A_DB, B_DB the database on each side (default @@DATABASE@@)
9
+ # COMPARE_SCHEMAS the schemas to compare, comma separated (default @@SCHEMAS_CSV@@)
9
10
  #
10
11
  # Schemas compared: @@SCHEMAS@@
11
12
  # Exit 0 only if both are identical and no materialized view is unpopulated in either; 50 when they differ.
12
13
  set -euo pipefail
13
14
 
14
15
  BASTION=${1:?bastion instance id}; A_HOST=${2:?}; A_SECRET=${3:?}; B_HOST=${4:?}; B_SECRET=${5:?}
15
- SCHEMAS="@@SCHEMAS_CSV@@"
16
+ SCHEMAS=${COMPARE_SCHEMAS:-"@@SCHEMAS_CSV@@"}
16
17
  A_DB=${A_DB:-@@DATABASE@@}; B_DB=${B_DB:-@@DATABASE@@}
17
18
  A_PORT=15434; B_PORT=15435
18
19
 
@@ -7,6 +7,7 @@ require_relative "box/caddy"
7
7
  require_relative "box/access"
8
8
  require_relative "box/stack_yaml"
9
9
  require_relative "box/compose"
10
+ require_relative "box/shared_database"
10
11
  require_relative "box/roll_scripts"
11
12
 
12
13
  module Hecks
@@ -22,6 +23,7 @@ module Hecks
22
23
  extend Access
23
24
  extend StackYaml
24
25
  extend Compose
26
+ extend SharedDatabase
25
27
  extend RollScripts
26
28
 
27
29
  projects_as :aws_box, needs_world: true, emits: :files
@@ -129,13 +131,23 @@ module Hecks
129
131
  # @return [Hash{String => String}] every generated file
130
132
  def render_all(plan, region)
131
133
  {
132
- "rds.yaml" => rds_yaml(plan), "box.yaml" => box_yaml(plan, region),
134
+ "box.yaml" => box_yaml(plan, region),
133
135
  "Caddyfile" => caddyfile(plan), "services.json" => services_json(plan),
134
136
  "render-compose.sh" => render_compose_sh(plan, region),
135
137
  "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
136
138
  "deploy-box.sh" => deploy_box_sh(plan, region),
137
139
  "Makefile" => makefile(plan)
138
- }.merge(migration_files(plan)).then { |files| Hosting.extend_files(files, plan: plan, region: region) }
140
+ }.merge(database_files(plan)).merge(migration_files(plan))
141
+ .then { |files| Hosting.extend_files(files, plan: plan, region: region) }
142
+ end
143
+
144
+ # @param plan [Settings::Plan] the resolved settings
145
+ # @return [Hash{String => String}] the database stack, or for a database on a shared
146
+ # instance the script that provisions it; the shared stack is not this site's to generate
147
+ def database_files(plan)
148
+ return { "rds.yaml" => rds_yaml(plan) } unless plan.shared?
149
+
150
+ { "provision-database.sh" => provision_database_sh(plan) }
139
151
  end
140
152
 
141
153
  # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
@@ -0,0 +1,11 @@
1
+ # @@STACK@@: the shared RDS instance that each site's own box stack keeps a database on.
2
+ # make stack VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b [BASTION_SG=sg-...] [ALERT_EMAIL=you@example.com] [REHEARSAL=true]
3
+ STACK = @@STACK@@
4
+ # true makes a throwaway copy: no deletion protection and no final snapshot. The default is the real instance.
5
+ REHEARSAL ?= false
6
+
7
+ .PHONY: stack
8
+ stack:
9
+ aws cloudformation deploy --template-file rds.yaml --stack-name $(STACK) --capabilities CAPABILITY_IAM \
10
+ --parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL) \
11
+ $(if $(BASTION_SG),BastionSecurityGroupId=$(BASTION_SG)) $(if $(ALERT_EMAIL),AlertEmail=$(ALERT_EMAIL))
@@ -0,0 +1,25 @@
1
+ # @@STACK@@
2
+
3
+ The shared RDS Postgres instance (@@DATABASE_CLASS@@, @@STORAGE_GB@@ GB, Postgres @@ENGINE_VERSION@@, @@BACKUP_DAYS@@-day backups) on which several
4
+ sites each keep their own database and login role. Generated by `hecks deploy project`; do not edit `rds.yaml`.
5
+
6
+ ## Create it
7
+
8
+ ```
9
+ make stack VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b BASTION_SG=sg-... ALERT_EMAIL=you@example.com
10
+ ```
11
+
12
+ `BASTION_SG` is the security group of the machine that tunnels to the instance over SSM (a site's own box works).
13
+ Outputs: `DbEndpoint`, `DbSecurityGroupId`, `DbSecretArn` (the master secret) and `AlertTopicArn`.
14
+
15
+ ## Add a site
16
+
17
+ A site's world says `shared_database "@@STACK@@"`. Its generated `provision-database.sh` creates that site's role, its
18
+ database, and the secret `<site>/database` that its containers read; its box stack adds its own ingress rule to this
19
+ instance's security group. Nothing else needs to change here.
20
+
21
+ ## Things to know
22
+
23
+ - The master secret is read only by the person who provisions a site, never by a box.
24
+ - This stack cannot be deleted while any site's box stack still references its security group.
25
+ - One maintenance window, restart or failure touches every site. Alarms (storage, CPU, memory) are for the instance.
@@ -0,0 +1,180 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Description: >
3
+ The shared RDS Postgres instance @@STACK@@, on which several sites each keep their own database and
4
+ login role. Private subnets only; each site's box stack admits itself on 5432, so nothing else can reach
5
+ it. The master password is managed by RDS in Secrets Manager ({username, password}); only the operator
6
+ who runs a site's provision-database.sh reads it, and no box does.
7
+
8
+ Parameters:
9
+ VpcId:
10
+ Type: AWS::EC2::VPC::Id
11
+ BastionSecurityGroupId:
12
+ Type: String
13
+ Default: ""
14
+ Description: >
15
+ A security group allowed to reach the instance on 5432, for the bastion that provision-database.sh and
16
+ restore-to-rds.sh tunnel through. Blank admits nothing but the sites' boxes.
17
+ PrivateSubnetIds:
18
+ Type: List<AWS::EC2::Subnet::Id>
19
+ Description: At least two subnets in different availability zones (an RDS subnet group needs them).
20
+ InstanceClass:
21
+ Type: String
22
+ Default: @@DB_CLASS@@
23
+ AllocatedStorageGb:
24
+ Type: Number
25
+ Default: @@STORAGE_GB@@
26
+ EngineVersion:
27
+ Type: String
28
+ Default: "@@ENGINE_VERSION@@"
29
+ Description: A major version ("16") lets RDS pick its current minor release.
30
+ BackupRetentionDays:
31
+ Type: Number
32
+ Default: @@BACKUP_DAYS@@
33
+ ExistingAlertTopicArn:
34
+ Type: String
35
+ Default: ""
36
+ Description: An SNS topic that already has subscribers. Blank creates a new topic.
37
+ AlertEmail:
38
+ Type: String
39
+ Default: ""
40
+ Description: Subscribed to a new topic (AWS sends one confirmation link). Ignored when ExistingAlertTopicArn is set.
41
+ Rehearsal:
42
+ Type: String
43
+ Default: "false"
44
+ AllowedValues: ["true", "false"]
45
+ Description: >
46
+ true is a throwaway copy (no deletion protection, no final snapshot). false is the real
47
+ database: deletion protection on and a snapshot taken on delete.
48
+
49
+ Conditions:
50
+ NewTopic: !Equals [!Ref ExistingAlertTopicArn, ""]
51
+ NewTopicWithEmail: !And [!Condition NewTopic, !Not [!Equals [!Ref AlertEmail, ""]]]
52
+ IsRehearsal: !Equals [!Ref Rehearsal, "true"]
53
+ HasBastion: !Not [!Equals [!Ref BastionSecurityGroupId, ""]]
54
+
55
+ Resources:
56
+ # No inline rules: every site's box stack adds its own ingress rule to this group, and CloudFormation can
57
+ # revoke an externally added rule when it updates an inline list. The bastion's rule is its own resource.
58
+ DbSecurityGroup:
59
+ Type: AWS::EC2::SecurityGroup
60
+ Properties:
61
+ GroupDescription: @@STACK@@ RDS, 5432 from each site's box (their stacks add the rules) and a bastion if given
62
+ VpcId: !Ref VpcId
63
+
64
+ BastionIngress:
65
+ Type: AWS::EC2::SecurityGroupIngress
66
+ Condition: HasBastion
67
+ Properties:
68
+ GroupId: !Ref DbSecurityGroup
69
+ IpProtocol: tcp
70
+ FromPort: 5432
71
+ ToPort: 5432
72
+ SourceSecurityGroupId: !Ref BastionSecurityGroupId
73
+ Description: Bastion for provision-database.sh and restore-to-rds.sh
74
+
75
+ DbSubnetGroup:
76
+ Type: AWS::RDS::DBSubnetGroup
77
+ Properties:
78
+ DBSubnetGroupDescription: @@STACK@@ RDS subnets
79
+ SubnetIds: !Ref PrivateSubnetIds
80
+
81
+ Database:
82
+ Type: AWS::RDS::DBInstance
83
+ DeletionPolicy: !If [IsRehearsal, Delete, Snapshot]
84
+ UpdateReplacePolicy: !If [IsRehearsal, Delete, Snapshot]
85
+ Properties:
86
+ Engine: postgres
87
+ EngineVersion: !Ref EngineVersion
88
+ DBInstanceClass: !Ref InstanceClass
89
+ AllocatedStorage: !Ref AllocatedStorageGb
90
+ StorageType: gp3
91
+ StorageEncrypted: true
92
+ MasterUsername: postgres
93
+ ManageMasterUserPassword: true
94
+ DBSubnetGroupName: !Ref DbSubnetGroup
95
+ VPCSecurityGroups: [!Ref DbSecurityGroup]
96
+ PubliclyAccessible: false
97
+ MultiAZ: false
98
+ BackupRetentionPeriod: !Ref BackupRetentionDays
99
+ DeletionProtection: !If [IsRehearsal, false, true]
100
+ AutoMinorVersionUpgrade: true
101
+ CopyTagsToSnapshot: true
102
+ Tags:
103
+ - Key: Name
104
+ Value: @@STACK@@-rds
105
+
106
+ AlertTopic:
107
+ Type: AWS::SNS::Topic
108
+ Condition: NewTopic
109
+ Properties:
110
+ DisplayName: @@STACK@@-alerts
111
+
112
+ AlertSubscription:
113
+ Type: AWS::SNS::Subscription
114
+ Condition: NewTopicWithEmail
115
+ Properties:
116
+ TopicArn: !Ref AlertTopic
117
+ Protocol: email
118
+ Endpoint: !Ref AlertEmail
119
+
120
+ # Every site's journal only grows, and RDS stops accepting writes when storage is full.
121
+ StorageLowAlarm:
122
+ Type: AWS::CloudWatch::Alarm
123
+ Properties:
124
+ AlarmDescription: RDS free storage under 3 GB
125
+ Namespace: AWS/RDS
126
+ MetricName: FreeStorageSpace
127
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
128
+ Statistic: Minimum
129
+ Period: 300
130
+ EvaluationPeriods: 3
131
+ Threshold: 3221225472
132
+ ComparisonOperator: LessThanThreshold
133
+ TreatMissingData: breaching
134
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
135
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
136
+
137
+ CpuHighAlarm:
138
+ Type: AWS::CloudWatch::Alarm
139
+ Properties:
140
+ AlarmDescription: "RDS CPU above 80% for 15 minutes (a burstable class may be out of credits)"
141
+ Namespace: AWS/RDS
142
+ MetricName: CPUUtilization
143
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
144
+ Statistic: Average
145
+ Period: 300
146
+ EvaluationPeriods: 3
147
+ Threshold: 80
148
+ ComparisonOperator: GreaterThanThreshold
149
+ TreatMissingData: notBreaching
150
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
151
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
152
+
153
+ MemoryLowAlarm:
154
+ Type: AWS::CloudWatch::Alarm
155
+ Properties:
156
+ AlarmDescription: RDS freeable memory under 100 MB for 15 minutes
157
+ Namespace: AWS/RDS
158
+ MetricName: FreeableMemory
159
+ Dimensions: [{ Name: DBInstanceIdentifier, Value: !Ref Database }]
160
+ Statistic: Minimum
161
+ Period: 300
162
+ EvaluationPeriods: 3
163
+ Threshold: 104857600
164
+ ComparisonOperator: LessThanThreshold
165
+ TreatMissingData: notBreaching
166
+ AlarmActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
167
+ OKActions: [!If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]]
168
+
169
+ Outputs:
170
+ AlertTopicArn:
171
+ Description: Pass as AlertTopicArn to the box stack.
172
+ Value: !If [NewTopic, !Ref AlertTopic, !Ref ExistingAlertTopicArn]
173
+ DbEndpoint:
174
+ Value: !GetAtt Database.Endpoint.Address
175
+ DbSecretArn:
176
+ Description: RDS-managed master secret ({username, password}).
177
+ Value: !GetAtt Database.MasterUserSecret.SecretArn
178
+ DbSecurityGroupId:
179
+ Description: Pass as DbSecurityGroupId to the box stack.
180
+ Value: !Ref DbSecurityGroup