hecks 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +21 -2
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +3 -1
- metadata +59 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
# The startup gate for the stdio MCP servers in `bin/` (`hecks_mcp_door`,
|
|
5
|
+
# `hecks_query_ir_mcp`): it refuses to run when the process was set up to
|
|
6
|
+
# speak to anything but a local parent, and it says on stderr what the
|
|
7
|
+
# process does and does not protect.
|
|
8
|
+
#
|
|
9
|
+
# ## Why a gate and not a comment
|
|
10
|
+
#
|
|
11
|
+
# Both servers have no authentication. What keeps a stranger away from
|
|
12
|
+
# them is that only a process that can spawn them can write to their
|
|
13
|
+
# stdin. That holds until someone wraps one in `socat TCP-LISTEN:... EXEC:`
|
|
14
|
+
# or an `inetd`-style listener, which hands the process a network socket
|
|
15
|
+
# as stdin/stdout without changing a line of the server. The gate turns
|
|
16
|
+
# that setup into a refusal at startup instead of an exposure nobody
|
|
17
|
+
# noticed.
|
|
18
|
+
#
|
|
19
|
+
# ## What it checks
|
|
20
|
+
#
|
|
21
|
+
# - **Arguments** — none, except `--stdio`. A `--port` or `--http` flag is
|
|
22
|
+
# refused rather than ignored, so a caller that believes it started a
|
|
23
|
+
# network server finds out immediately.
|
|
24
|
+
# - **Environment** — no `HECKS_MCP_*` variable except
|
|
25
|
+
# `HECKS_MCP_TRANSPORT=stdio`. The servers take no configuration, so an
|
|
26
|
+
# unknown option in that namespace fails closed.
|
|
27
|
+
# - **Descriptors** — stdin and stdout must not be internet-protocol sockets
|
|
28
|
+
# (`AF_INET` or `AF_INET6`). A pipe, a file, a terminal and a Unix-domain socket all pass;
|
|
29
|
+
# the last matters because some MCP clients spawn servers over a
|
|
30
|
+
# `socketpair`, which is local to the machine.
|
|
31
|
+
#
|
|
32
|
+
# ## What it does not do
|
|
33
|
+
#
|
|
34
|
+
# It does not authenticate anyone and it cannot see a proxy that copies
|
|
35
|
+
# bytes from a network socket into an ordinary pipe. It closes the
|
|
36
|
+
# configurations that announce themselves; the ADR on MCP authentication
|
|
37
|
+
# (`docs/decisions/0062-mcp-servers-need-real-authentication-before-any-network-transport.md`)
|
|
38
|
+
# covers what a network transport would need.
|
|
39
|
+
module McpStdioGuard
|
|
40
|
+
ACCEPTED_ARGS = %w[--stdio].freeze
|
|
41
|
+
ENV_PREFIX = "HECKS_MCP_".freeze
|
|
42
|
+
ACCEPTED_ENV = { "HECKS_MCP_TRANSPORT" => "stdio" }.freeze
|
|
43
|
+
EXIT_STATUS = 2
|
|
44
|
+
|
|
45
|
+
# The lines every stdio server prints, ahead of its own notes.
|
|
46
|
+
COMMON_NOTES = [
|
|
47
|
+
"stdio only. This server has no authentication and must not be exposed over a network.",
|
|
48
|
+
"Whoever can write to this process's stdin can make every call it accepts."
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Lists every reason this process should not start as a stdio server.
|
|
54
|
+
#
|
|
55
|
+
# @param argv [Array<String>] the command-line arguments
|
|
56
|
+
# @param env [Hash{String => String}] the process environment
|
|
57
|
+
# @param stdin [IO] the stream requests arrive on
|
|
58
|
+
# @param stdout [IO] the stream responses leave on
|
|
59
|
+
# @return [Array<String>] one message per violation; empty when the process is
|
|
60
|
+
# set up for stdio
|
|
61
|
+
def violations(argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout)
|
|
62
|
+
argument_violations(argv) + environment_violations(env) + descriptor_violations(stdin, stdout)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Builds the warning printed at startup.
|
|
66
|
+
#
|
|
67
|
+
# @param server [String] the server's name, used as the line prefix
|
|
68
|
+
# @param notes [Array<String>] server-specific lines added after `COMMON_NOTES`
|
|
69
|
+
# @return [Array<String>] the banner lines, each prefixed with `server`
|
|
70
|
+
def banner(server:, notes: [])
|
|
71
|
+
(COMMON_NOTES + notes).map { |line| "#{server}: #{line}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Refuses to continue on a non-stdio setup.
|
|
75
|
+
#
|
|
76
|
+
# A server calls this before it requires anything heavy, so a misconfigured start
|
|
77
|
+
# fails in milliseconds and before any protocol byte is answered. Everything goes
|
|
78
|
+
# to `stderr`: stdout carries the MCP protocol, and one stray line there corrupts
|
|
79
|
+
# the client's framing.
|
|
80
|
+
#
|
|
81
|
+
# @param server [String] the server's name, used as the line prefix
|
|
82
|
+
# @param argv [Array<String>] the command-line arguments
|
|
83
|
+
# @param env [Hash{String => String}] the process environment
|
|
84
|
+
# @param stdin [IO] the stream requests arrive on
|
|
85
|
+
# @param stdout [IO] the stream responses leave on
|
|
86
|
+
# @param stderr [IO] where the refusal is written
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [SystemExit] with status `EXIT_STATUS` when `violations` is not empty
|
|
89
|
+
def enforce_stdio!(server:, argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
90
|
+
found = violations(argv: argv, env: env, stdin: stdin, stdout: stdout)
|
|
91
|
+
refuse!(server, found, stderr) unless found.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Prints the startup warning to `stderr`, never to stdout.
|
|
95
|
+
#
|
|
96
|
+
# @param server [String] the server's name, used as the line prefix
|
|
97
|
+
# @param notes [Array<String>] server-specific warning lines
|
|
98
|
+
# @param stderr [IO] where the warning is written
|
|
99
|
+
# @return [void]
|
|
100
|
+
def warn!(server:, notes: [], stderr: $stderr)
|
|
101
|
+
banner(server: server, notes: notes).each { |line| stderr.puts(line) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Enforces stdio, then prints the warning.
|
|
105
|
+
#
|
|
106
|
+
# @param server [String] the server's name, used as the line prefix
|
|
107
|
+
# @param notes [Array<String>] server-specific warning lines
|
|
108
|
+
# @param stderr [IO] where the refusal or the warning is written
|
|
109
|
+
# @param checks [Hash{Symbol => Object}] `argv:`, `env:`, `stdin:` and `stdout:`, as
|
|
110
|
+
# for `enforce_stdio!`
|
|
111
|
+
# @return [void]
|
|
112
|
+
# @raise [SystemExit] with status `EXIT_STATUS` when the setup is not stdio
|
|
113
|
+
def start!(server:, notes: [], stderr: $stderr, **checks)
|
|
114
|
+
enforce_stdio!(server: server, stderr: stderr, **checks)
|
|
115
|
+
warn!(server: server, notes: notes, stderr: stderr)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @api private
|
|
119
|
+
def refuse!(server, found, stderr)
|
|
120
|
+
found.each { |message| stderr.puts("#{server}: refusing to start: #{message}") }
|
|
121
|
+
stderr.puts("#{server}: this server speaks MCP over stdio only.")
|
|
122
|
+
exit(EXIT_STATUS)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @api private
|
|
126
|
+
def argument_violations(argv)
|
|
127
|
+
(argv - ACCEPTED_ARGS).map do |arg|
|
|
128
|
+
"argument #{arg.inspect} is not accepted (only #{ACCEPTED_ARGS.join(', ')}); network options are refused, not ignored"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def environment_violations(env)
|
|
134
|
+
env.select { |name, value| name.start_with?(ENV_PREFIX) && ACCEPTED_ENV[name] != value }.map do |name, value|
|
|
135
|
+
"environment #{name}=#{value.inspect} is not accepted; the servers take no #{ENV_PREFIX}* options " \
|
|
136
|
+
"except #{ACCEPTED_ENV.map { |k, v| "#{k}=#{v}" }.join(', ')}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @api private
|
|
141
|
+
def descriptor_violations(stdin, stdout)
|
|
142
|
+
{ "stdin" => stdin, "stdout" => stdout }.select { |_, io| ip_socket?(io) }.map do |label, _|
|
|
143
|
+
"#{label} is a network (IP) socket, so this process is being served over a network"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A socket whose address cannot be read is refused: it cannot be shown to be local.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
def ip_socket?(io)
|
|
151
|
+
return false unless io.stat.socket?
|
|
152
|
+
|
|
153
|
+
socket = Socket.for_fd(io.fileno)
|
|
154
|
+
socket.autoclose = false # the descriptor belongs to `io`
|
|
155
|
+
socket.local_address.ip?
|
|
156
|
+
rescue SystemCallError, IOError
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -47,7 +47,7 @@ module Hecks
|
|
|
47
47
|
#
|
|
48
48
|
# @param dispatcher [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted
|
|
49
49
|
# dispatcher both the lookup query and the `Shred` dispatch run through
|
|
50
|
-
# @param domain [String] the subject's own domain FQN, e.g. `"
|
|
50
|
+
# @param domain [String] the subject's own domain FQN, e.g. `"Site::Registration"`
|
|
51
51
|
# @param subject_id [String] the data subject to erase
|
|
52
52
|
# @return [Boolean] true when a live key was found and shredded by this call; false
|
|
53
53
|
# when no `SubjectKey` record exists for this subject, or it was already shredded
|
|
@@ -15,6 +15,13 @@ module Hecks
|
|
|
15
15
|
# A domain with no hecksagon gets the default in-memory bind. A domain that has one
|
|
16
16
|
# must bind the aggregate exactly once without a role.
|
|
17
17
|
#
|
|
18
|
+
# A `default_adapter` a world declares (`Registry#default_adapter_for` — the
|
|
19
|
+
# chapter's own world, else the project's) stands in wherever the hecksagon binds
|
|
20
|
+
# nothing for the aggregate, and replaces the in-memory fallback for a domain
|
|
21
|
+
# with no hecksagon. It is never consulted once the hecksagon binds the aggregate
|
|
22
|
+
# or declares a domain-level default of its own, so an explicit chapter decision
|
|
23
|
+
# always wins.
|
|
24
|
+
#
|
|
18
25
|
# @param registry [Runtime::Registry] the registry holding the domain's hecksagon
|
|
19
26
|
# @param domain [String, Symbol] name of the domain the aggregate belongs to
|
|
20
27
|
# @param aggregate [Bluebook::Aggregate] the aggregate whose binding is wanted
|
|
@@ -23,10 +30,12 @@ module Hecks
|
|
|
23
30
|
# @raise [Runtime::WiringError] if the hecksagon has no `persisted_by` bind for the
|
|
24
31
|
# aggregate, more or fewer than one bind without a role, or any bind with a role
|
|
25
32
|
def resolve(registry, domain, aggregate)
|
|
26
|
-
hexagon
|
|
27
|
-
|
|
33
|
+
hexagon = registry.hecksagon(domain)
|
|
34
|
+
declared = registry.default_adapter_for(domain)
|
|
35
|
+
return default_binding(aggregate, declared || DEFAULT_ADAPTER) unless hexagon
|
|
28
36
|
|
|
29
37
|
bindings = hexagon.binds_for(aggregate.hecks_name, VERB)
|
|
38
|
+
bindings = [default_binding(aggregate, declared)] if bindings.empty? && declared
|
|
30
39
|
raise missing_binding(domain, aggregate) if bindings.empty?
|
|
31
40
|
|
|
32
41
|
authoritative = bindings.select { |bind| bind.role.nil? || bind.role.empty? }
|
|
@@ -39,9 +48,11 @@ module Hecks
|
|
|
39
48
|
# Builds the bind an aggregate gets when its domain declares no hecksagon.
|
|
40
49
|
#
|
|
41
50
|
# @param aggregate [Bluebook::Aggregate] the aggregate to bind
|
|
42
|
-
# @
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
# @param adapter [String] the adapter to bind it to; the framework's in-memory
|
|
52
|
+
# default unless a world declared one
|
|
53
|
+
# @return [Bluebook::Bind] a roleless `persisted_by` bind to `adapter`
|
|
54
|
+
def default_binding(aggregate, adapter = DEFAULT_ADAPTER)
|
|
55
|
+
Bluebook::Bind.new(aggregate: aggregate.hecks_name, verb: VERB, adapter: adapter)
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
# Builds, without raising, the error for an aggregate a hecksagon leaves unbound.
|
|
@@ -4,6 +4,7 @@ require_relative "lineage"
|
|
|
4
4
|
require_relative "../../../../naming"
|
|
5
5
|
require_relative "../../../../framework"
|
|
6
6
|
require_relative "../../../../runtime/registry"
|
|
7
|
+
require_relative "expected_era"
|
|
7
8
|
|
|
8
9
|
module Hecks
|
|
9
10
|
module Runtime
|
|
@@ -295,7 +296,7 @@ module Hecks
|
|
|
295
296
|
"it could be found (checked #{directory.inspect} and the framework registry)"
|
|
296
297
|
end
|
|
297
298
|
|
|
298
|
-
settings = registry.
|
|
299
|
+
settings = registry.binding_settings(bluebook.name, Ports::Persistence::VERB, adapter_name)
|
|
299
300
|
registry.adapter_class(adapter_name).era_check!(
|
|
300
301
|
registry: registry, bluebook: bluebook, current_text: current_text, settings: settings,
|
|
301
302
|
directory: directory
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Runtime
|
|
7
|
+
module EraCheck
|
|
8
|
+
# The read side of the era gate, for a deploy operator rather than a boot:
|
|
9
|
+
# asks a running host which era it reports at `GET /version` and compares
|
|
10
|
+
# it with an allow-list file of the eras that should be running.
|
|
11
|
+
#
|
|
12
|
+
# `EraCheck.check!` decides at boot, against the store, whether a host may
|
|
13
|
+
# run on an era. This answers the question after a roll, from outside:
|
|
14
|
+
# did the host that came up report an era the operator expects? The two
|
|
15
|
+
# never share state. The host's own `GET /version` document
|
|
16
|
+
# (`rust/host/src/server.rs`, `version_body`) is the only input, so a
|
|
17
|
+
# rollback to an older image, or a fork of the journal back onto an older
|
|
18
|
+
# era, shows up here as an era missing from the list.
|
|
19
|
+
#
|
|
20
|
+
# ## The allow-list file
|
|
21
|
+
#
|
|
22
|
+
# One era id per line. Blank lines and lines starting with `#` are
|
|
23
|
+
# ignored, and so is a trailing ` # comment`. A file that lists no era
|
|
24
|
+
# means "only check that the host reports one", so a first roll can be
|
|
25
|
+
# checked before any era id is known. The generated `expected-era` file
|
|
26
|
+
# (`Deploy::Scripts`) has this shape.
|
|
27
|
+
#
|
|
28
|
+
# `bin/check_era` is the command-line wrapper.
|
|
29
|
+
module ExpectedEra
|
|
30
|
+
# The host could not be reached, or did not answer with a `200`.
|
|
31
|
+
class Unreachable < StandardError; end
|
|
32
|
+
|
|
33
|
+
# The host answered, but not with a `/version` document carrying an era.
|
|
34
|
+
class BadResponse < StandardError; end
|
|
35
|
+
|
|
36
|
+
# What a comparison concluded.
|
|
37
|
+
#
|
|
38
|
+
# @!attribute [r] status
|
|
39
|
+
# @return [Symbol] `:match`, `:unlisted` (no era on the list, one reported) or `:mismatch`
|
|
40
|
+
# @!attribute [r] era
|
|
41
|
+
# @return [String] the era the host reported
|
|
42
|
+
# @!attribute [r] allowed
|
|
43
|
+
# @return [Array<String>] the eras the list accepts
|
|
44
|
+
Verdict = Struct.new(:status, :era, :allowed) do
|
|
45
|
+
# Tells whether the roll is acceptable.
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] true unless the reported era is off the list
|
|
48
|
+
def ok? = status != :mismatch
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Reads the eras out of an allow-list file's text.
|
|
54
|
+
#
|
|
55
|
+
# @param text [String] the file's contents
|
|
56
|
+
# @return [Array<String>] the era ids in file order, without comments or blanks
|
|
57
|
+
def parse(text)
|
|
58
|
+
text.each_line.filter_map do |line|
|
|
59
|
+
entry = line.strip.sub(/\s+#.*\z/, "")
|
|
60
|
+
entry unless entry.empty? || entry.start_with?("#")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Builds the `/version` URL from what an operator gave.
|
|
65
|
+
#
|
|
66
|
+
# @param url [String] a host's base URL, or its `/version` URL itself
|
|
67
|
+
# @return [String] the URL to request
|
|
68
|
+
def version_url(url)
|
|
69
|
+
base = url.to_s.chomp("/")
|
|
70
|
+
base.end_with?("/version") ? base : "#{base}/version"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Asks a host which era it reports.
|
|
74
|
+
#
|
|
75
|
+
# @param url [String] the host's base URL, or its `/version` URL
|
|
76
|
+
# @param timeout [Numeric] seconds allowed for connecting and for reading
|
|
77
|
+
# @return [String] the `era` the host's `/version` document carries
|
|
78
|
+
# @raise [Unreachable] if the request fails or the status is not `200`
|
|
79
|
+
# @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
|
|
80
|
+
def fetch_era(url, timeout: 10)
|
|
81
|
+
response = get(version_url(url), timeout)
|
|
82
|
+
raise Unreachable, "#{version_url(url)} answered #{response.code}" unless response.code == "200"
|
|
83
|
+
|
|
84
|
+
era = JSON.parse(response.body)["era"]
|
|
85
|
+
raise BadResponse, "#{version_url(url)} carries no era" unless era.is_a?(String) && !era.empty?
|
|
86
|
+
|
|
87
|
+
era
|
|
88
|
+
rescue JSON::ParserError, TypeError
|
|
89
|
+
raise BadResponse, "#{version_url(url)} did not answer a JSON version document"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Compares a reported era with an allow-list.
|
|
93
|
+
#
|
|
94
|
+
# @param era [String] the era the host reported
|
|
95
|
+
# @param allowed [Array<String>] the eras the list accepts; empty accepts any
|
|
96
|
+
# @return [Verdict] `:unlisted` when the list is empty, else `:match` or `:mismatch`
|
|
97
|
+
def verdict(era, allowed)
|
|
98
|
+
status = if allowed.empty? then :unlisted
|
|
99
|
+
elsif allowed.include?(era) then :match
|
|
100
|
+
else :mismatch
|
|
101
|
+
end
|
|
102
|
+
Verdict.new(status, era, allowed)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fetches the host's era and compares it with an allow-list file.
|
|
106
|
+
#
|
|
107
|
+
# @param url [String] the host's base URL, or its `/version` URL
|
|
108
|
+
# @param file [String] path of the allow-list file
|
|
109
|
+
# @param timeout [Numeric] seconds allowed for connecting and for reading
|
|
110
|
+
# @return [Verdict] the comparison
|
|
111
|
+
# @raise [Errno::ENOENT] if the allow-list file does not exist
|
|
112
|
+
# @raise [Unreachable] see `fetch_era`
|
|
113
|
+
# @raise [BadResponse] see `fetch_era`
|
|
114
|
+
def check(url, file, timeout: 10)
|
|
115
|
+
allowed = parse(File.read(file))
|
|
116
|
+
verdict(fetch_era(url, timeout: timeout), allowed)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def get(url, timeout)
|
|
120
|
+
uri = URI.parse(url)
|
|
121
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
122
|
+
open_timeout: timeout, read_timeout: timeout) do |http|
|
|
123
|
+
http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "application/json"))
|
|
124
|
+
end
|
|
125
|
+
rescue SystemCallError, SocketError, Timeout::Error, IOError, URI::InvalidURIError => e
|
|
126
|
+
raise Unreachable, "#{url} could not be reached: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
private_class_method :get
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -159,7 +159,7 @@ module Hecks
|
|
|
159
159
|
# aware, same as `apply_drop` above (`apply_backfill`'s own
|
|
160
160
|
# header) — a bare name was the only shape this ever needed
|
|
161
161
|
# until a value object gained new required members with zero
|
|
162
|
-
# source data of their own (found live:
|
|
162
|
+
# source data of their own (found live: a client site's Attendee
|
|
163
163
|
# redesign, commit 4326dcd).
|
|
164
164
|
@backfills.each { |backfill| apply_backfill(state, backfill) }
|
|
165
165
|
Entry.new(operation: entry.operation, id: entry.id, state: state, mirrors: entry.mirrors)
|
|
@@ -64,7 +64,7 @@ module Hecks
|
|
|
64
64
|
# real, live gap for any new required value-object member with no
|
|
65
65
|
# source data at all — `compute` cannot fill it either, since its
|
|
66
66
|
# own guard requires a real, already-present field to consume
|
|
67
|
-
# (found live:
|
|
67
|
+
# (found live: a client site's Attendee redesign, commit 4326dcd,
|
|
68
68
|
# needed exactly this and had nothing that worked).
|
|
69
69
|
declared.backfills.each do |backfill|
|
|
70
70
|
expression = compile_backfill(expression, backfill)
|
|
@@ -12,10 +12,13 @@ module Hecks
|
|
|
12
12
|
module Persistence
|
|
13
13
|
module Plugins
|
|
14
14
|
# ADR 0033 — requiring this file is installing the plugin. Nothing in
|
|
15
|
-
# Hecks core requires it
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# Hecks core requires it. A boot loads it on demand for a domain that
|
|
16
|
+
# binds `PostgresEra` (`Runtime::Loader.load_bound_adapters!` resolves the
|
|
17
|
+
# adapter, which autoloads this file, before the gates are collected). An
|
|
18
|
+
# app that wants schema-translation support without binding `PostgresEra`
|
|
19
|
+
# requires it explicitly, the same shape every adapter-specific spec
|
|
20
|
+
# fixture already uses to load one particular `.adapter` file rather than
|
|
21
|
+
# all of them.
|
|
19
22
|
module Era
|
|
20
23
|
module_function
|
|
21
24
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Ports
|
|
5
|
+
module Persistence
|
|
6
|
+
class PostgresDump
|
|
7
|
+
# A Postgres connection URL, split so a password reaches the tools run
|
|
8
|
+
# against it through their environment and never on a command line, where
|
|
9
|
+
# any user of the machine can read it from the process list.
|
|
10
|
+
class Connection
|
|
11
|
+
# @param url [String] a `postgres://` or `postgresql://` URL
|
|
12
|
+
# @raise [PostgresDump::Error] if `url` is not a Postgres URL
|
|
13
|
+
def initialize(url)
|
|
14
|
+
@uri = URI.parse(url)
|
|
15
|
+
raise Error, "not a postgres URL" unless %w[postgres postgresql].include?(@uri.scheme)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Names the database the URL points at.
|
|
19
|
+
#
|
|
20
|
+
# @return [String] the database name, empty when the URL names none
|
|
21
|
+
def database
|
|
22
|
+
@uri.path.to_s.delete_prefix("/")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Points the same server and credentials at another database.
|
|
26
|
+
#
|
|
27
|
+
# @param name [String] the database to connect to instead
|
|
28
|
+
# @return [Connection] a new connection; this one is unchanged
|
|
29
|
+
def with_database(name)
|
|
30
|
+
copy = @uri.dup
|
|
31
|
+
copy.path = "/#{name}"
|
|
32
|
+
self.class.new(copy.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Lists the connection settings the URL carries, in `PG.connect` keyword form.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash{Symbol => String, Integer}] `host`, `port`, `user`, `password`,
|
|
38
|
+
# `dbname` and `sslmode`, each only when the URL gives one; the user and password
|
|
39
|
+
# are percent-decoded
|
|
40
|
+
def settings
|
|
41
|
+
{
|
|
42
|
+
host: (@uri.host unless @uri.host.to_s.empty?), port: @uri.port,
|
|
43
|
+
user: decode(@uri.user), password: decode(@uri.password),
|
|
44
|
+
dbname: (database unless database.empty?), sslmode: URI.decode_www_form(@uri.query.to_s).to_h["sslmode"]
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Builds the libpq environment variables for a child process.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash{String => String}] `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`,
|
|
51
|
+
# `PGDATABASE` and `PGSSLMODE`, each only when `settings` has the matching key
|
|
52
|
+
def env
|
|
53
|
+
settings.to_h { |key, value| ["PG#{key.to_s.sub('dbname', 'database').upcase}", value.to_s] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Opens a connection with the `pg` gem.
|
|
57
|
+
#
|
|
58
|
+
# @return [PG::Connection] an open connection; the caller closes it
|
|
59
|
+
def connect
|
|
60
|
+
require "pg"
|
|
61
|
+
PG.connect(**settings)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def decode(value)
|
|
67
|
+
value && URI.decode_www_form_component(value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require_relative "postgres_dump/connection"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Ports
|
|
7
|
+
module Persistence
|
|
8
|
+
# Dumps one schema of a Postgres database and proves the dump restores.
|
|
9
|
+
#
|
|
10
|
+
# ## What the proof is
|
|
11
|
+
#
|
|
12
|
+
# A dump nobody has restored is a hope. After `pg_dump` writes the file,
|
|
13
|
+
# the dump is restored into a scratch database (on the source server, or on a
|
|
14
|
+
# separate `verify_url` server such as the one the dump will be loaded
|
|
15
|
+
# into) and every table's row count is compared with the source. That shows
|
|
16
|
+
# the dump is complete and loadable. It does not re-derive current state
|
|
17
|
+
# from an event journal, so a caller who needs that proves it separately.
|
|
18
|
+
#
|
|
19
|
+
# ## Credentials
|
|
20
|
+
#
|
|
21
|
+
# URLs are split by `Connection`: the password travels in the child
|
|
22
|
+
# process's environment, never in `argv`.
|
|
23
|
+
#
|
|
24
|
+
# ## Requirements
|
|
25
|
+
#
|
|
26
|
+
# The `pg` gem, and `pg_dump` and `pg_restore` on `PATH` at a version no older
|
|
27
|
+
# than the server's. Neither the gem nor the tools are a dependency of
|
|
28
|
+
# hecks; nothing here loads until a dump is asked for.
|
|
29
|
+
class PostgresDump
|
|
30
|
+
# Raised for a refusal or a failed tool.
|
|
31
|
+
class Error < StandardError; end
|
|
32
|
+
|
|
33
|
+
# What a verified dump holds.
|
|
34
|
+
#
|
|
35
|
+
# @!attribute [r] tables
|
|
36
|
+
# @return [Hash{String => Integer}] row count of every table in the schema, by name
|
|
37
|
+
Result = Struct.new(:tables, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
# @param url [String] the source database's `postgres://` URL
|
|
40
|
+
# @param schema [String] the schema to dump, a plain identifier
|
|
41
|
+
# @param verify_url [String, nil] the server to restore into for the proof; the source
|
|
42
|
+
# server when nil
|
|
43
|
+
# @raise [Error] if `schema` is not a plain identifier or a URL is not a Postgres URL
|
|
44
|
+
def initialize(url:, schema:, verify_url: nil)
|
|
45
|
+
raise Error, "schema #{schema.inspect} is not a plain identifier" unless schema.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
|
|
46
|
+
|
|
47
|
+
@schema = schema
|
|
48
|
+
@source = Connection.new(url)
|
|
49
|
+
@verify = Connection.new(verify_url || url)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Writes a custom-format dump of the schema and proves it restores.
|
|
53
|
+
#
|
|
54
|
+
# @param dest [String] the file to write
|
|
55
|
+
# @return [Result] the verified row counts
|
|
56
|
+
# @raise [Error] if the schema has no tables, a tool fails, the database refuses, or
|
|
57
|
+
# the restored counts differ from the source's
|
|
58
|
+
def call(dest)
|
|
59
|
+
require "pg"
|
|
60
|
+
raise Error, "schema #{@schema} has no tables in #{@source.database}" if counting(@source) { |db| counts(db) }.empty?
|
|
61
|
+
|
|
62
|
+
run("pg_dump", @source, "--format=custom", "--no-owner", "--no-privileges", "--schema=#{@schema}", "--file=#{dest}")
|
|
63
|
+
expected = counting(@source) { |db| counts(db) }
|
|
64
|
+
verify_restore(dest, expected)
|
|
65
|
+
Result.new(tables: expected)
|
|
66
|
+
rescue PG::Error => e
|
|
67
|
+
raise Error, "database error (#{e.class}): #{e.message.lines.first.to_s.strip}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def verify_restore(dump, expected)
|
|
73
|
+
scratch = "dump_verify_#{Process.pid}_#{SecureRandom.hex(3)}"
|
|
74
|
+
counting(@verify) do |admin|
|
|
75
|
+
admin.exec("CREATE DATABASE #{admin.quote_ident(scratch)}")
|
|
76
|
+
begin
|
|
77
|
+
target = @verify.with_database(scratch)
|
|
78
|
+
run("pg_restore", target, "--no-owner", "--no-privileges", "--exit-on-error", "--dbname=#{scratch}", dump)
|
|
79
|
+
actual = counting(target) { |db| counts(db) }
|
|
80
|
+
raise Error, mismatch(expected, actual) unless actual == expected
|
|
81
|
+
ensure
|
|
82
|
+
drop(admin, scratch)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def counting(connection)
|
|
88
|
+
db = connection.connect
|
|
89
|
+
yield db
|
|
90
|
+
ensure
|
|
91
|
+
db&.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def counts(db)
|
|
95
|
+
names = db.exec_params("SELECT table_name FROM information_schema.tables " \
|
|
96
|
+
"WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY 1", [@schema])
|
|
97
|
+
.map { |row| row["table_name"] }
|
|
98
|
+
names.to_h do |name|
|
|
99
|
+
[name, db.exec("SELECT count(*) FROM #{db.quote_ident(@schema)}.#{db.quote_ident(name)}").getvalue(0, 0).to_i]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mismatch(expected, actual)
|
|
104
|
+
differing = (expected.keys | actual.keys).reject { |name| expected[name] == actual[name] }
|
|
105
|
+
detail = differing.map { |name| "#{name} (source #{expected[name].inspect}, restored #{actual[name].inspect})" }
|
|
106
|
+
"the restored dump disagrees with the source for #{detail.join(', ')}; " \
|
|
107
|
+
"if the database took writes during the dump, dump it again"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def drop(admin, scratch)
|
|
111
|
+
admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(scratch)} WITH (FORCE)")
|
|
112
|
+
rescue PG::Error => e
|
|
113
|
+
warn "could not drop scratch database #{scratch} (#{e.class}); drop it by hand"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run(tool, connection, *)
|
|
117
|
+
_out, err, status = Open3.capture3(connection.env, tool, *)
|
|
118
|
+
raise Error, "#{tool} failed: #{err.lines.first(3).join.strip}" unless status.success?
|
|
119
|
+
rescue Errno::ENOENT
|
|
120
|
+
raise Error, "#{tool} is not on PATH"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -30,8 +30,8 @@ module Hecks
|
|
|
30
30
|
# lacks `append`, `project` or `entries`
|
|
31
31
|
def build(registry, domain, aggregate, bind, recover: true, settings_verb: VERB)
|
|
32
32
|
registry.check_verb(bind)
|
|
33
|
-
settings =
|
|
34
|
-
|
|
33
|
+
settings = registry.binding_settings(domain, settings_verb, bind.adapter)
|
|
34
|
+
.reject { |key, _| key.to_sym == :role }
|
|
35
35
|
registry.check_settings(bind, settings)
|
|
36
36
|
# The domain, the resolved era, and (for an old checkout) the era
|
|
37
37
|
# that superseded it ride along after the declared-settings
|
|
@@ -17,6 +17,10 @@ require_relative "persistence/remote_runtime"
|
|
|
17
17
|
require_relative "persistence/null_saga_store"
|
|
18
18
|
require_relative "persistence/state_codec"
|
|
19
19
|
|
|
20
|
+
# Loaded on first mention: nothing about a domain's own boot needs the dump
|
|
21
|
+
# tooling, and it wants the optional `pg` gem.
|
|
22
|
+
Hecks::Ports::Persistence.autoload(:PostgresDump, File.join(__dir__, "persistence/postgres_dump"))
|
|
23
|
+
|
|
20
24
|
module Hecks
|
|
21
25
|
module Ports
|
|
22
26
|
# Reopened once `BindingPolicy`/`RepositoryFactory` (required above) are
|