hecks 3.6.0 → 3.9.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. metadata +58 -2
@@ -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 box (the box 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
@@ -0,0 +1,106 @@
1
+ require "json"
2
+ require_relative "../../projector"
3
+ require_relative "box/settings"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Deploy
8
+ # The shared database target for `deployed_to("AwsSharedDatabase")`: one RDS Postgres instance
9
+ # that several sites each keep a database on (ADR 0092). It renders the instance's stack
10
+ # (`rds.yaml`), a `Makefile` that deploys it, and a README. A site's own world says
11
+ # `shared_database "<this stack>"`, and its box generates the provisioning of its database.
12
+ #
13
+ # The stack's name is the world's `stack_name` exactly, with no prefix or suffix, because
14
+ # every site names it by that string.
15
+ module SharedDatabaseInstance
16
+ extend Projector::Target
17
+
18
+ projects_as :aws_shared_database, needs_world: true, emits: :files
19
+
20
+ TEMPLATE_DIR = File.join(__dir__, "shared_database_instance", "templates").freeze
21
+
22
+ # What the world leaves out resolves to these.
23
+ DEFAULTS = { database_class: "db.t4g.small", storage_gb: 30, engine_version: "16", backup_days: 7 }.freeze
24
+
25
+ # The checked settings the templates are filled from.
26
+ Plan = Struct.new(:stack, :database_class, :storage_gb, :engine_version, :backup_days, keyword_init: true)
27
+
28
+ module_function
29
+
30
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
31
+ # @param options [Hash] generation options; same shape as `Box.call`'s
32
+ # @return [Hash{String => String}] the generated file contents, keyed by filename
33
+ # @raise [ArgumentError] if the domain's deploy settings are invalid
34
+ def call(bluebook:, options: {})
35
+ world, _domain, world_file = options.values_at(:world, :domain_dir, :world_file)
36
+ settings = world.for_verb("deployed_to")
37
+ target = declare(world.domain, settings, world_file)
38
+ render_all(resolve(settings, target))
39
+ end
40
+
41
+ # Validates the target through the Deploy bluebook's own `SharedDatabaseTarget.Declare`.
42
+ #
43
+ # @return [Object] the declared target aggregate
44
+ # @raise [ArgumentError] when the bluebook refuses a value
45
+ def declare(domain, settings, world_file)
46
+ dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
47
+ dispatcher.dispatch("Deploy::SharedDatabaseTarget.Declare", with: declare_arguments(domain, settings)).instance
48
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
49
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsSharedDatabase\") is invalid: #{e.message}"
50
+ end
51
+
52
+ # @return [Hash{Symbol => Hash}] the `Declare` arguments, each `{ value: }`
53
+ def declare_arguments(domain, settings)
54
+ {
55
+ domain: { value: domain }, region: { value: settings[:region] },
56
+ database_class: { value: settings.fetch(:database_class, DEFAULTS[:database_class]) },
57
+ storage_gb: { value: settings.fetch(:storage_gb, DEFAULTS[:storage_gb]) }
58
+ }
59
+ end
60
+
61
+ # @param settings [Hash{Symbol => Object}] the world's `AwsSharedDatabase` settings
62
+ # @param target [Object] the declared target, whose class and storage `Declare` has checked
63
+ # @return [Plan] the checked settings
64
+ # @raise [ArgumentError] when the stack name is missing or a setting is malformed
65
+ def resolve(settings, target)
66
+ Plan.new(stack: read_stack(settings), database_class: target.state[:database_class].value,
67
+ storage_gb: target.state[:storage_gb].value, engine_version: read_engine(settings),
68
+ backup_days: Box::Settings.integer(:backup_days, settings.fetch(:backup_days, DEFAULTS[:backup_days]), 1, 35))
69
+ end
70
+
71
+ # @return [String] the stack's name, which every site's `shared_database` repeats
72
+ def read_stack(settings)
73
+ stack = settings.fetch(:stack_name) do
74
+ raise ArgumentError, "stack_name: the shared instance's stack, which every site names in shared_database"
75
+ end
76
+ Box::Settings.check(:stack_name, stack, Box::Settings::NAME)
77
+ end
78
+
79
+ # @return [String] the Postgres major (or major.minor) version
80
+ def read_engine(settings)
81
+ Box::Settings.check(:engine_version, settings.fetch(:engine_version, DEFAULTS[:engine_version]).to_s,
82
+ Box::Settings::ENGINE)
83
+ end
84
+
85
+ # @param plan [Plan] the checked settings
86
+ # @return [Hash{String => String}] every generated file
87
+ def render_all(plan)
88
+ values = { "STACK" => plan.stack, "DB_CLASS" => plan.database_class, "DATABASE_CLASS" => plan.database_class,
89
+ "STORAGE_GB" => plan.storage_gb.to_s, "ENGINE_VERSION" => plan.engine_version,
90
+ "BACKUP_DAYS" => plan.backup_days.to_s }
91
+ { "rds.yaml" => template("rds.yaml.tmpl", values), "Makefile" => template("Makefile.tmpl", values),
92
+ "README.md" => template("README.md.tmpl", values) }
93
+ end
94
+
95
+ # Fills `@@NAME@@` markers.
96
+ #
97
+ # @param file [String] the template's name in `TEMPLATE_DIR`
98
+ # @param values [Hash{String => String}] marker name => text
99
+ # @return [String] the rendered file
100
+ def template(file, values)
101
+ values.reduce(File.read(File.join(TEMPLATE_DIR, file))) { |text, (marker, value)| text.gsub("@@#{marker}@@", value) }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,83 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Vercel
5
+ # Resolves a world's `deployed_to("Vercel")` settings into the checked plan the generator
6
+ # renders from.
7
+ #
8
+ # Every string that reaches `vercel.json` or the deploy script is matched against a
9
+ # conservative pattern first, so a world file cannot splice shell or JSON into the output.
10
+ # Secrets are named here, never held: `env` lists variable names, and the deploy script
11
+ # reads each value from the caller's environment.
12
+ module Settings
13
+ NAME = /\A[a-z0-9][a-z0-9-]{0,99}\z/
14
+ SCOPE = /\A[a-z0-9][a-z0-9_-]{0,99}\z/
15
+ ENV_VAR = /\A[A-Z][A-Z0-9_]{0,99}\z/
16
+ CRON_PATH = %r{\A/[A-Za-z0-9/_.-]{0,200}\z}
17
+ SCHEDULE = %r{\A[0-9*/,-]+( [0-9A-Za-z*/,-]+){4}\z}
18
+
19
+ # The variable the hecksagon's Postgres adapter reads; always set on deploy.
20
+ DATABASE_ENV = "DATABASE_URL".freeze
21
+
22
+ Plan = Data.define(:project, :scope, :region, :memory, :max_duration, :crons, :env)
23
+
24
+ module_function
25
+
26
+ # @param deploy_settings [Hash{Symbol => Object}] the world's `Vercel` settings
27
+ # @param target [Object] the validated `VercelTarget` (region, memory and duration,
28
+ # already checked by `Declare`)
29
+ # @param infra_name [String] the project name the world or the domain gives
30
+ # @return [Plan] the resolved plan
31
+ # @raise [ArgumentError] when a setting is malformed
32
+ def resolve(deploy_settings:, target:, infra_name:)
33
+ s = deploy_settings
34
+ Plan.new(project: check(:project, infra_name, NAME), scope: optional(:scope, s[:scope], SCOPE),
35
+ **declared_sizes(target), crons: read_crons(s.fetch(:crons, [])),
36
+ env: read_env(s.fetch(:env, [])))
37
+ end
38
+
39
+ # @param target [Object] the declared `VercelTarget`
40
+ # @return [Hash{Symbol => Object}] the values `Declare` has already validated
41
+ def declared_sizes(target)
42
+ %i[region memory max_duration].to_h { |key| [key, target.state[key].value] }
43
+ end
44
+
45
+ # @return [String] the value, when it matches
46
+ # @raise [ArgumentError] when it does not
47
+ def check(key, value, pattern)
48
+ return value.to_s if value.to_s.match?(pattern)
49
+
50
+ raise ArgumentError, "#{key} #{value.inspect} is not allowed here (it must match #{pattern.source})"
51
+ end
52
+
53
+ # @return [String, nil] the value, or nil when the world left it out
54
+ def optional(key, value, pattern)
55
+ value.nil? ? nil : check(key, value, pattern)
56
+ end
57
+
58
+ # @param list [Array<Hash>] `[{ path: "/cron/tick", schedule: "0 * * * *" }]`
59
+ # @return [Array<Hash{Symbol => String}>] the checked crons
60
+ def read_crons(list)
61
+ raise ArgumentError, "crons must be a list of { path:, schedule: }" unless list.is_a?(Array)
62
+
63
+ list.map do |cron|
64
+ unless cron.is_a?(Hash) && cron.key?(:path) && cron.key?(:schedule)
65
+ raise ArgumentError, "a cron needs a path and a schedule, got #{cron.inspect}"
66
+ end
67
+
68
+ { path: check(:cron_path, cron[:path], CRON_PATH), schedule: check(:cron_schedule, cron[:schedule], SCHEDULE) }
69
+ end
70
+ end
71
+
72
+ # @param list [Array<String>] environment variable names the deploy sets on Vercel
73
+ # @return [Array<String>] the names, `DATABASE_URL` first, each once
74
+ def read_env(list)
75
+ raise ArgumentError, "env must be a list of variable names" unless list.is_a?(Array)
76
+
77
+ ([DATABASE_ENV] + list.map { |name| check(:env, name, ENV_VAR) }).uniq
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,134 @@
1
+ require "json"
2
+ require_relative "../../projector"
3
+ require_relative "vercel/settings"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Deploy
8
+ # The Vercel deploy target for `deployed_to("Vercel")`: the domain's Rust host as one Vercel
9
+ # function. It renders `vercel.json` (the function's size, region, the rewrite that sends
10
+ # every path to it, crons), `.vercelignore`, `deploy-vercel.sh` and a `Makefile`.
11
+ #
12
+ # Persistence is the hecksagon's: the host reads `DATABASE_URL`, which the deploy script sets
13
+ # from the caller's environment. This projection creates no database and holds no secret.
14
+ module Vercel
15
+ extend Projector::Target
16
+
17
+ projects_as :vercel, needs_world: true, emits: :files
18
+
19
+ # The function every request is rewritten to; its file is the host's Vercel entry point.
20
+ FUNCTION = "api/host".freeze
21
+
22
+ # Keeps only the release binaries in an uploaded `target/`, as Vercel's Rust guide advises.
23
+ VERCELIGNORE = <<~IGNORE.freeze
24
+ target/**
25
+ !target/release
26
+ !target/x86_64-unknown-linux-gnu/release/**
27
+ !target/aarch64-unknown-linux-gnu/release/**
28
+ IGNORE
29
+
30
+ module_function
31
+
32
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
33
+ # @param options [Hash] generation options; same shape as `Box.call`'s
34
+ # @return [Hash{String => String}] the generated file contents, keyed by filename
35
+ # @raise [ArgumentError] if the domain's deploy settings are invalid
36
+ def call(bluebook:, options: {})
37
+ world, domain, world_file = options.values_at(:world, :domain_dir, :world_file)
38
+ settings = tenant_settings(world.for_verb("deployed_to"), options[:tenant] || {}, File.basename(domain))
39
+ target = declare(world.domain, settings, world_file)
40
+ infra_name = (settings[:stack_name] || File.basename(domain)).to_s.tr("_", "-").downcase
41
+ plan = Settings.resolve(deploy_settings: settings, target: target, infra_name: infra_name)
42
+ render_all(plan)
43
+ end
44
+
45
+ # @return [Hash{Symbol => Object}] the settings with the `--tenant` suffix on the stack name
46
+ def tenant_settings(settings, tenant, domain_name)
47
+ return settings unless tenant[:tenant]
48
+
49
+ settings.merge(stack_name: "#{settings[:stack_name] || domain_name}-#{tenant[:tenant]}")
50
+ end
51
+
52
+ # Validates the target through the Deploy bluebook's own `VercelTarget.Declare`.
53
+ #
54
+ # @return [Object] the declared target aggregate
55
+ # @raise [ArgumentError] when the bluebook refuses a value
56
+ def declare(domain, settings, world_file)
57
+ dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
58
+ dispatcher.dispatch("Deploy::VercelTarget.Declare", with: declare_arguments(domain, settings)).instance
59
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
60
+ raise ArgumentError, "#{world_file}'s deployed_to(\"Vercel\") is invalid: #{e.message}"
61
+ end
62
+
63
+ # @return [Hash{Symbol => Hash}] the `VercelTarget.Declare` arguments, each a `{ value: }`
64
+ def declare_arguments(domain, settings)
65
+ {
66
+ domain: { value: domain }, region: { value: settings.fetch(:region, "iad1") },
67
+ memory: { value: settings.fetch(:memory, 1024) },
68
+ max_duration: { value: settings.fetch(:max_duration, 30) }
69
+ }
70
+ end
71
+
72
+ # @param plan [Settings::Plan] the resolved settings
73
+ # @return [Hash{String => String}] every generated file
74
+ def render_all(plan)
75
+ { "vercel.json" => vercel_json(plan), ".vercelignore" => VERCELIGNORE,
76
+ "deploy-vercel.sh" => deploy_script(plan), "Makefile" => makefile(plan) }
77
+ end
78
+
79
+ # @param plan [Settings::Plan] the resolved settings
80
+ # @return [String] `vercel.json`
81
+ def vercel_json(plan)
82
+ config = {
83
+ "$schema" => "https://openapi.vercel.sh/vercel.json",
84
+ "functions" => { "#{FUNCTION}.rs" => { "memory" => plan.memory, "maxDuration" => plan.max_duration } },
85
+ "regions" => [plan.region],
86
+ "rewrites" => [{ "source" => "/(.*)", "destination" => "/#{FUNCTION}" }]
87
+ }
88
+ config["crons"] = plan.crons.map { |cron| cron.transform_keys(&:to_s) } if plan.crons.any?
89
+ "#{JSON.pretty_generate(config)}\n"
90
+ end
91
+
92
+ # The script sets each named variable on the project from the caller's environment over
93
+ # stdin, so no value is on a command line or in a file, then deploys to production.
94
+ #
95
+ # @param plan [Settings::Plan] the resolved settings
96
+ # @return [String] `deploy-vercel.sh`
97
+ def deploy_script(plan)
98
+ scope = plan.scope ? " --scope #{plan.scope}" : ""
99
+ sets = plan.env.map { |name| env_command(name, scope) }
100
+ <<~SH
101
+ #!/bin/bash
102
+ # Deploys #{plan.project} to Vercel production. Needs VERCEL_TOKEN and each variable below in the
103
+ # environment, e.g. `op run --env-file=.env.tpl -- make deploy`; no value is written to disk.
104
+ set -euo pipefail
105
+ cd "$(dirname "$0")"
106
+ vercel link --yes --project #{plan.project}#{scope}
107
+ #{sets.join("\n")}
108
+ vercel deploy --prod --yes#{scope}
109
+ SH
110
+ end
111
+
112
+ # @param name [String] a checked variable name
113
+ # @param scope [String] the ` --scope team` flag, or empty
114
+ # @return [String] the line that reads the variable and sets it on the project
115
+ def env_command(name, scope)
116
+ "printf '%s' \"${#{name}:?#{name} must be set in the environment}\" | " \
117
+ "vercel env add #{name} production --sensitive --force --yes#{scope}"
118
+ end
119
+
120
+ # @param plan [Settings::Plan] the resolved settings
121
+ # @return [String] the `Makefile`
122
+ def makefile(plan)
123
+ <<~MAKE
124
+ # #{plan.project}: one Vercel function.
125
+ # make deploy (needs VERCEL_TOKEN and #{plan.env.join(", ")} in the environment)
126
+ .PHONY: deploy
127
+ deploy:
128
+ \t./deploy-vercel.sh
129
+ MAKE
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ # The requests a live site must answer as its route table says, and the verdict on each
7
+ # answer. It sends nothing: the caller makes the requests and hands each response back.
8
+ # A route with a parameter or a prefix (`/blog/:slug.html`, `/auth/*`) has no one URL to ask.
9
+ #
10
+ # - an `admin` route refuses an anonymous GET (and POST, where it takes one), uncached
11
+ # - an indexable `public` page answers 200 and names its canonical link
12
+ # - a row that is `off` answers 404, and a `redirect` row redirects to `redirect_to`
13
+ # - a path no row declares answers 404
14
+ class Probe
15
+ # One request to make and what a good answer to it looks like.
16
+ #
17
+ # @!attribute [r] label
18
+ # @return [String] what is being checked, for the report
19
+ # @!attribute [r] verb
20
+ # @return [String] GET or POST
21
+ # @!attribute [r] path
22
+ # @return [String] the path to ask
23
+ # @!attribute [r] expect
24
+ # @return [Symbol] one of `:refused`, `:page`, `:missing`, `:redirect`
25
+ # @!attribute [r] to
26
+ # @return [String, nil] where a `:redirect` must lead
27
+ Check = Struct.new(:label, :verb, :path, :expect, :to, keyword_init: true)
28
+
29
+ # What one answer was: its status, its headers (lower-case names) and its body.
30
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
31
+
32
+ # The statuses that turn an anonymous caller away from an admin route.
33
+ REFUSED = [301, 302, 303, 307, 308, 401, 403].freeze
34
+
35
+ # The redirect statuses.
36
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
37
+
38
+ # @param rows [Array<Table::Row>] the checked rows of a route table
39
+ # @param run [String] the run's name, which makes the missing path unique to it
40
+ def initialize(rows, run:)
41
+ @rows = rows
42
+ @run = run
43
+ end
44
+
45
+ # @return [Array<Check>] every request worth making, in the table's order, then the
46
+ # path that must not exist
47
+ def checks
48
+ @rows.select { |row| askable?(row) }.flat_map { |row| for_row(row) } + [missing]
49
+ end
50
+
51
+ # Judges one answer.
52
+ #
53
+ # @param check [Check] the request that was made
54
+ # @param response [Response] what came back
55
+ # @return [String, nil] why the answer is wrong, or nil when it is right
56
+ def verdict(check, response)
57
+ case check.expect
58
+ when :refused then refused(response)
59
+ when :page then page(response)
60
+ when :missing then response.status == 404 ? nil : "expected 404, got HTTP #{response.status}"
61
+ when :redirect then redirect(check, response)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def askable?(row) = !row.path.empty? && !row.path.match?(/[:*]/)
68
+
69
+ def for_row(row)
70
+ return [off(row)] if row.off
71
+ return admin(row) if row.auth == "admin"
72
+ return [redirecting(row)] if row.kind == "redirect" && row.redirect_to
73
+ return [page_of(row)] if page?(row)
74
+
75
+ []
76
+ end
77
+
78
+ def page?(row) = row.auth == "public" && row.kind == "page" && row.indexable && row.verbs.include?("GET")
79
+
80
+ def off(row) = Check.new(label: "#{row.path} is switched off: 404", verb: "GET", path: row.path, expect: :missing)
81
+
82
+ def admin(row)
83
+ row.verbs.select { |verb| %w[GET POST].include?(verb) }.map do |verb|
84
+ Check.new(label: "#{verb} #{row.path} without a session is refused and not cached",
85
+ verb: verb, path: row.path, expect: :refused)
86
+ end
87
+ end
88
+
89
+ def redirecting(row)
90
+ Check.new(label: "#{row.path} redirects to #{row.redirect_to}", verb: "GET", path: row.path,
91
+ expect: :redirect, to: row.redirect_to)
92
+ end
93
+
94
+ def page_of(row)
95
+ Check.new(label: "#{row.path} answers 200 with a canonical link", verb: "GET", path: row.path, expect: :page)
96
+ end
97
+
98
+ def missing
99
+ Check.new(label: "a path no row declares is a real 404", verb: "GET", path: "/probe-#{@run}-missing",
100
+ expect: :missing)
101
+ end
102
+
103
+ def refused(response)
104
+ return "expected a redirect, 401 or 403, got HTTP #{response.status}" unless REFUSED.include?(response.status)
105
+ return nil if response.status >= 400
106
+
107
+ control = response.headers["cache-control"].to_s
108
+ return nil unless control.match?(/public|max-age=[1-9]/) && !control.match?(/no-store|private/)
109
+
110
+ "the redirect is cacheable (Cache-Control: #{control})"
111
+ end
112
+
113
+ def page(response)
114
+ return "expected 200, got HTTP #{response.status}" unless response.status == 200
115
+ return nil if response.body.to_s.match?(/<link[^>]+rel=["']canonical["']/i)
116
+
117
+ "the page has no canonical link"
118
+ end
119
+
120
+ def redirect(check, response)
121
+ return "expected a redirect, got HTTP #{response.status}" unless REDIRECTS.include?(response.status)
122
+
123
+ location = response.headers["location"].to_s
124
+ return nil if location == check.to || (location.end_with?(check.to) && location.start_with?("http"))
125
+
126
+ "redirects to #{location.inspect}, expected #{check.to.inspect}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end