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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
- data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/cache_dir.rb +10 -0
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
- data/lib/hecks/deploy/oidc.json +10 -0
- data/lib/hecks/doors/usage_cache.rb +19 -9
- data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
- data/lib/hecks/fuzzing/failure_triage.rb +80 -0
- data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
- data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
- data/lib/hecks/fuzzing/form_census.rb +28 -52
- data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
- data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
- data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
- data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
- data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
- data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
- data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
- data/lib/hecks/fuzzing/mutation/report.rb +78 -0
- data/lib/hecks/fuzzing/mutation/run.rb +99 -0
- data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
- data/lib/hecks/fuzzing/mutation/site.rb +21 -0
- data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
- data/lib/hecks/fuzzing/mutation.rb +12 -0
- data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
- data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
- data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
- data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
- data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
- data/lib/hecks/fuzzing/properties.rb +7 -0
- data/lib/hecks/fuzzing/replay/observation.rb +1 -1
- data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
- data/lib/hecks/fuzzing/replay/session.rb +22 -6
- data/lib/hecks/fuzzing/replay.rb +1 -0
- data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
- data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
- data/lib/hecks/fuzzing/shrinker.rb +3 -0
- data/lib/hecks/fuzzing.rb +3 -0
- data/lib/hecks/gate/stages.yml +12 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
- data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
- data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +37 -0
- data/lib/hecks/hecks/hecks.hecksagon +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
- data/lib/hecks/projections/deploy/box/settings.rb +18 -0
- data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
- data/lib/hecks/projections/deploy/box.rb +14 -2
- data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
- data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
- data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
- data/lib/hecks/projections/deploy/vercel.rb +134 -0
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/projections/site/site_host/templates.rb +12 -4
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
- data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
- data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
- data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
- data/lib/hecks/tools/deploy_recipe.rb +20 -10
- data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
- data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
- data/lib/hecks/tools/fuzz_sweep.rb +26 -9
- data/lib/hecks/tools/lanes/files.rb +2 -4
- data/lib/hecks/tools/lanes/live.rb +3 -4
- data/lib/hecks/tools/lanes/ruleset.rb +23 -1
- data/lib/hecks/tools/lanes.rb +12 -1
- data/lib/hecks/tools/mutation_run.rb +73 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +3 -1
- data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
- data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
- data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +6 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +28 -5
- data/rust/host/src/auth.rs +11 -1
- data/rust/host/src/boundary_fuzz/api.rs +260 -0
- data/rust/host/src/boundary_fuzz/auth.rs +119 -0
- data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
- data/rust/host/src/boundary_fuzz/ir.rs +79 -0
- data/rust/host/src/boundary_fuzz/server.rs +227 -0
- data/rust/host/src/boundary_fuzz/web.rs +125 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +39 -0
- data/rust/host/src/main.rs +10 -44
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web/commerce.rs +113 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +299 -0
- data/rust/host/src/web.rs +55 -49
- data/rust/parser/fuzz/.gitignore +4 -0
- data/rust/parser/fuzz/Cargo.lock +89 -0
- data/rust/parser/fuzz/Cargo.toml +27 -0
- data/rust/parser/fuzz/README.md +19 -0
- data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
- data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
- data/rust/parser/src/parse/command.rs +14 -2
- data/rust/parser/src/parse/mod.rs +25 -0
- data/rust/parser/src/ruby_value.rs +95 -6
- data/rust/src/kernel/json.rs +34 -3
- 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
|