capsium 0.2.0 → 0.6.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/.rubocop.yml +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +135 -1
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require "time"
|
|
6
|
+
require "uri"
|
|
6
7
|
|
|
7
8
|
module Capsium
|
|
8
9
|
class Reactor
|
|
9
|
-
# Monitoring HTTP API reports for the
|
|
10
|
-
# (ARCHITECTURE.md section 7)
|
|
11
|
-
#
|
|
10
|
+
# Monitoring HTTP API reports for the packages this reactor serves
|
|
11
|
+
# (ARCHITECTURE.md section 7): the package-level
|
|
12
|
+
# /api/v1/introspect/* endpoints aggregate ALL mounted packages, the
|
|
13
|
+
# 07-reactor follow-ons add reactor-level /introspect/* (status,
|
|
14
|
+
# config, metrics) and per-package /package/:id/* (status, metadata,
|
|
15
|
+
# logs) resolved by package name.
|
|
12
16
|
class Introspection
|
|
13
17
|
METADATA_PATH = "/api/v1/introspect/metadata"
|
|
14
18
|
ROUTES_PATH = "/api/v1/introspect/routes"
|
|
@@ -17,70 +21,186 @@ module Capsium
|
|
|
17
21
|
PATHS = [METADATA_PATH, ROUTES_PATH, CONTENT_HASHES_PATH,
|
|
18
22
|
CONTENT_VALIDITY_PATH].freeze
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
STATUS_PATH = "/introspect/status"
|
|
25
|
+
CONFIG_PATH = "/introspect/config"
|
|
26
|
+
METRICS_PATH = "/introspect/metrics"
|
|
27
|
+
REACTOR_PATHS = [STATUS_PATH, CONFIG_PATH, METRICS_PATH].freeze
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
# Per-package endpoints: "/package/<name>/status|metadata|logs".
|
|
30
|
+
# PACKAGE_MOUNT is the mount point (WEBrick longest-prefix
|
|
31
|
+
# matching routes all "/package/..." requests to the reactor);
|
|
32
|
+
# the name resolves against every mounted package, anything else
|
|
33
|
+
# is a 404.
|
|
34
|
+
PACKAGE_MOUNT = "/package"
|
|
35
|
+
PACKAGE_PATH_PATTERN = %r{\A/package/(?<name>[^/]+)/(?<report>status|metadata|logs)\z}
|
|
36
|
+
DEFAULT_LOG_LINES = 100
|
|
37
|
+
MAX_LOG_LINES = 1000
|
|
38
|
+
|
|
39
|
+
attr_reader :packages
|
|
40
|
+
|
|
41
|
+
# The packages this reactor serves (one per mount; ARCHITECTURE.md
|
|
42
|
+
# section 7 introspection aggregates all of them).
|
|
43
|
+
def initialize(packages, reactor: nil)
|
|
44
|
+
@packages = packages
|
|
45
|
+
@reactor = reactor
|
|
24
46
|
end
|
|
25
47
|
|
|
48
|
+
# The first mounted package (the single-package view).
|
|
49
|
+
def package = @packages.first
|
|
50
|
+
|
|
26
51
|
def endpoint?(path)
|
|
27
|
-
PATHS.include?(path)
|
|
52
|
+
PATHS.include?(path) || package_endpoint?(path) ||
|
|
53
|
+
(!@reactor.nil? && REACTOR_PATHS.include?(path))
|
|
28
54
|
end
|
|
29
55
|
|
|
30
|
-
# The report body for an
|
|
31
|
-
#
|
|
32
|
-
def report_for(path)
|
|
56
|
+
# The report body for an endpoint, or nil when the path is not an
|
|
57
|
+
# endpoint or the package name does not match (404).
|
|
58
|
+
def report_for(path, params: {})
|
|
33
59
|
case path
|
|
34
60
|
when METADATA_PATH then metadata_report
|
|
35
61
|
when ROUTES_PATH then routes_report
|
|
36
62
|
when CONTENT_HASHES_PATH then content_hashes_report
|
|
37
63
|
when CONTENT_VALIDITY_PATH then content_validity_report
|
|
64
|
+
when STATUS_PATH then status_report
|
|
65
|
+
when CONFIG_PATH then config_report
|
|
66
|
+
when METRICS_PATH then metrics_report
|
|
67
|
+
else package_report_for(path, params)
|
|
38
68
|
end
|
|
39
69
|
end
|
|
40
70
|
|
|
41
71
|
def metadata_report
|
|
42
|
-
|
|
43
|
-
{ packages: [{
|
|
44
|
-
name: metadata.name,
|
|
45
|
-
version: metadata.version,
|
|
46
|
-
author: metadata.author,
|
|
47
|
-
description: metadata.description
|
|
48
|
-
}] }
|
|
72
|
+
{ packages: @packages.map { |pkg| metadata_entry(pkg) } }
|
|
49
73
|
end
|
|
50
74
|
|
|
51
75
|
def routes_report
|
|
52
|
-
entries =
|
|
53
|
-
|
|
76
|
+
entries = @packages.map do |pkg|
|
|
77
|
+
routes = pkg.routes.config.routes.map do |route|
|
|
78
|
+
{ method: route.http_method || "GET", path: route.path }
|
|
79
|
+
end
|
|
80
|
+
{ package: pkg.name, routes: routes }
|
|
54
81
|
end
|
|
55
|
-
{ routes:
|
|
82
|
+
{ routes: entries }
|
|
56
83
|
end
|
|
57
84
|
|
|
58
85
|
def content_hashes_report
|
|
59
|
-
|
|
86
|
+
entries = @packages.map do |pkg|
|
|
87
|
+
{ package: pkg.name, hash: content_hash(pkg) }
|
|
88
|
+
end
|
|
89
|
+
{ contentHashes: entries }
|
|
60
90
|
end
|
|
61
91
|
|
|
62
92
|
def content_validity_report
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
{ contentValidity: @packages.map { |pkg| content_validity_entry(pkg) } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def status_report
|
|
97
|
+
{ status: "running", uptime: uptime, packagesLoaded: @packages.size }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reactor configuration; secrets (deploy.json, registry URL
|
|
101
|
+
# credentials) are never exposed.
|
|
102
|
+
def config_report
|
|
103
|
+
{
|
|
104
|
+
port: @reactor.port,
|
|
105
|
+
storeDir: store_dir,
|
|
106
|
+
cacheControl: @reactor.cache_control,
|
|
107
|
+
authEnabled: @reactor.authenticator.enabled?,
|
|
108
|
+
registry: registry_location
|
|
68
109
|
}
|
|
69
|
-
entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
|
|
70
|
-
{ contentValidity: [entry] }
|
|
71
110
|
end
|
|
72
111
|
|
|
112
|
+
def metrics_report = @reactor.metrics.snapshot.merge(uptime: uptime)
|
|
113
|
+
|
|
73
114
|
private
|
|
74
115
|
|
|
116
|
+
def package_endpoint?(path) = !PACKAGE_PATH_PATTERN.match(path).nil?
|
|
117
|
+
|
|
118
|
+
def package_report_for(path, params)
|
|
119
|
+
match = PACKAGE_PATH_PATTERN.match(path)
|
|
120
|
+
return nil unless match
|
|
121
|
+
|
|
122
|
+
pkg = @packages.find { |candidate| candidate.name == match[:name] }
|
|
123
|
+
return nil unless pkg
|
|
124
|
+
|
|
125
|
+
case match[:report]
|
|
126
|
+
when "status" then package_status_report(pkg)
|
|
127
|
+
when "metadata" then package_metadata_report(pkg)
|
|
128
|
+
when "logs" then package_logs_report(pkg, params)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def metadata_entry(pkg)
|
|
133
|
+
metadata = pkg.metadata
|
|
134
|
+
{ name: metadata.name, version: metadata.version,
|
|
135
|
+
author: metadata.author, description: metadata.description }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def content_validity_entry(pkg)
|
|
139
|
+
errors = pkg.verify_integrity
|
|
140
|
+
entry = { package: pkg.name, valid: errors.empty?,
|
|
141
|
+
lastChecked: Time.now.utc.iso8601,
|
|
142
|
+
signed: pkg.signed?, encrypted: pkg.encrypted? }
|
|
143
|
+
entry[:signatureValid] = pkg.verify_signature if pkg.signed?
|
|
144
|
+
entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
|
|
145
|
+
entry
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def package_status_report(pkg)
|
|
149
|
+
{ package: pkg.name, version: pkg.metadata.version,
|
|
150
|
+
status: "loaded", valid: pkg.verify_integrity.empty? }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def package_metadata_report(pkg)
|
|
154
|
+
metadata = pkg.metadata
|
|
155
|
+
{ name: metadata.name, version: metadata.version,
|
|
156
|
+
description: metadata.description, author: metadata.author,
|
|
157
|
+
guid: metadata.guid }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def package_logs_report(pkg, params)
|
|
161
|
+
lines = @reactor ? @reactor.log_buffer.lines(log_line_count(params)) : []
|
|
162
|
+
{ package: pkg.name, logs: lines }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_line_count(params)
|
|
166
|
+
Integer(params["lines"] || DEFAULT_LOG_LINES).clamp(1, MAX_LOG_LINES)
|
|
167
|
+
rescue ArgumentError, TypeError
|
|
168
|
+
DEFAULT_LOG_LINES
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def uptime = (Time.now - @reactor.started_at).round
|
|
172
|
+
|
|
173
|
+
def store_dir
|
|
174
|
+
store = @reactor.store
|
|
175
|
+
store.is_a?(Package::Store) ? store.dir : store
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def registry_location
|
|
179
|
+
registry = @reactor.registry
|
|
180
|
+
redact(registry.is_a?(Capsium::Registry) ? registry.location : registry)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Strips any userinfo (credentials) from a URL reference.
|
|
184
|
+
def redact(ref)
|
|
185
|
+
return ref unless ref.is_a?(String) && ref.match?(%r{\Ahttps?://})
|
|
186
|
+
|
|
187
|
+
uri = URI.parse(ref)
|
|
188
|
+
uri.password = nil
|
|
189
|
+
uri.user = nil
|
|
190
|
+
uri.to_s
|
|
191
|
+
rescue URI::InvalidURIError
|
|
192
|
+
ref
|
|
193
|
+
end
|
|
194
|
+
|
|
75
195
|
# SHA-256 of the .cap blob when the package was loaded from one.
|
|
76
196
|
# For directory sources there is no blob, so the hash covers a
|
|
77
197
|
# canonical (sorted-key) JSON serialization of the package content
|
|
78
198
|
# checksums — the same data security.json integrityChecks carry.
|
|
79
|
-
def content_hash
|
|
80
|
-
cap_file =
|
|
199
|
+
def content_hash(pkg)
|
|
200
|
+
cap_file = pkg.cap_file_path
|
|
81
201
|
return Digest::SHA256.file(cap_file).hexdigest if cap_file
|
|
82
202
|
|
|
83
|
-
checksums = Package::Security.checksums_for(
|
|
203
|
+
checksums = Package::Security.checksums_for(pkg.path)
|
|
84
204
|
Digest::SHA256.hexdigest(JSON.generate(checksums))
|
|
85
205
|
end
|
|
86
206
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Reactor
|
|
5
|
+
# Minimal in-memory request counters for the /introspect/metrics
|
|
6
|
+
# endpoint (07-reactor): total requests plus a breakdown by HTTP
|
|
7
|
+
# status, thread-safe via Mutex (WEBrick serves concurrently).
|
|
8
|
+
class Metrics
|
|
9
|
+
def initialize
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@total = 0
|
|
12
|
+
@by_status = Hash.new(0)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(status)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@total += 1
|
|
18
|
+
@by_status[status.to_i] += 1
|
|
19
|
+
end
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def snapshot
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
{ requestsTotal: @total, requestsByStatus: @by_status.dup }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Raised when two mounts claim the same URL prefix, whether given
|
|
8
|
+
# explicitly or derived from package names.
|
|
9
|
+
class MountConflictError < Capsium::Error; end
|
|
10
|
+
|
|
11
|
+
# One mounted package: the URL prefix it answers under plus its
|
|
12
|
+
# serving state (routes, merged view; ARCHITECTURE.md section 7).
|
|
13
|
+
# The reactor serves a list of mounts resolved by longest-prefix
|
|
14
|
+
# matching. Default mount points: the first source at "/", each
|
|
15
|
+
# additional source at "/<metadata.name>/".
|
|
16
|
+
class Mount
|
|
17
|
+
ROOT_PATH = "/"
|
|
18
|
+
SPEC_SEPARATOR = "="
|
|
19
|
+
|
|
20
|
+
# A mount entry as accepted from the CLI or a JSON config file:
|
|
21
|
+
# "path" may be nil (the default is assigned on build), "source"
|
|
22
|
+
# is a package directory or .cap file, "store" optionally
|
|
23
|
+
# overrides the global package store for this mount.
|
|
24
|
+
Entry = Data.define(:path, :source, :store)
|
|
25
|
+
|
|
26
|
+
attr_reader :path, :package, :overlay
|
|
27
|
+
|
|
28
|
+
# Parses a "--mount PATH=SOURCE" command-line value into an Entry.
|
|
29
|
+
def self.parse_spec(spec)
|
|
30
|
+
path, separator, source = spec.to_s.partition(SPEC_SEPARATOR)
|
|
31
|
+
if separator.empty? || path.empty? || source.empty?
|
|
32
|
+
raise Error, "Invalid mount spec #{spec.inspect} (expected PATH=SOURCE)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Entry.new(path: path, source: source, store: nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The mount entries of a JSON config file:
|
|
39
|
+
# {"mounts": [{"path": "/", "source": "dir-or.cap", "store": "..."}]}
|
|
40
|
+
def self.config_entries(config_file)
|
|
41
|
+
doc = JSON.parse(File.read(config_file))
|
|
42
|
+
mounts = doc.is_a?(Hash) ? doc["mounts"] : nil
|
|
43
|
+
unless mounts.is_a?(Array) && mounts.all?(Hash)
|
|
44
|
+
raise Error, "Invalid mount config #{config_file}: expected " \
|
|
45
|
+
'{"mounts": [{"path": ..., "source": ...}]}'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
mounts.map do |entry|
|
|
49
|
+
unless entry["source"].is_a?(String)
|
|
50
|
+
raise Error, "Invalid mount config #{config_file}: every mount " \
|
|
51
|
+
"needs a \"source\""
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Entry.new(path: entry["path"], source: entry["source"],
|
|
55
|
+
store: entry["store"])
|
|
56
|
+
end
|
|
57
|
+
rescue JSON::ParserError => e
|
|
58
|
+
raise Error, "Invalid mount config #{config_file}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Builds the mounts for a list of entries: loads each package,
|
|
62
|
+
# assigns default paths (first entry "/", each additional
|
|
63
|
+
# "/<metadata.name>/") and rejects duplicate prefixes with a
|
|
64
|
+
# MountConflictError. On failure every package loaded so far is
|
|
65
|
+
# cleaned up again.
|
|
66
|
+
def self.build(entries, store: nil, registry: nil)
|
|
67
|
+
built = []
|
|
68
|
+
entries.each_with_index do |entry, index|
|
|
69
|
+
package_store = entry.store || store
|
|
70
|
+
package = load_package(entry.source, package_store, registry)
|
|
71
|
+
path = entry.path || default_path(index, package)
|
|
72
|
+
built << new(path: path, package: package,
|
|
73
|
+
store: package_store, registry: registry)
|
|
74
|
+
end
|
|
75
|
+
check_conflicts!(built)
|
|
76
|
+
built
|
|
77
|
+
rescue StandardError
|
|
78
|
+
built.each { |mount| mount.package.cleanup }
|
|
79
|
+
raise
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Loads a mount source (a package directory, .cap file or an
|
|
83
|
+
# already-built Package) with the given resolution context.
|
|
84
|
+
def self.load_package(source, store, registry)
|
|
85
|
+
return source unless source.is_a?(String)
|
|
86
|
+
|
|
87
|
+
Package.new(source, store: store, registry: registry)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.default_path(index, package)
|
|
91
|
+
index.zero? ? ROOT_PATH : "#{ROOT_PATH}#{package.name}"
|
|
92
|
+
end
|
|
93
|
+
private_class_method :default_path
|
|
94
|
+
|
|
95
|
+
def self.check_conflicts!(built)
|
|
96
|
+
paths = built.map(&:path)
|
|
97
|
+
duplicates = paths.tally.select { |_path, count| count > 1 }.keys
|
|
98
|
+
return if duplicates.empty?
|
|
99
|
+
|
|
100
|
+
raise MountConflictError,
|
|
101
|
+
"mount path conflict: #{duplicates.join(', ')} is mounted twice"
|
|
102
|
+
end
|
|
103
|
+
private_class_method :check_conflicts!
|
|
104
|
+
|
|
105
|
+
# Canonical mount prefix: leading "/", no trailing "/" (except the
|
|
106
|
+
# root itself).
|
|
107
|
+
def self.normalize_path(path)
|
|
108
|
+
return ROOT_PATH if path.nil? || path == ROOT_PATH
|
|
109
|
+
|
|
110
|
+
normalized = path.start_with?(ROOT_PATH) ? path : "#{ROOT_PATH}#{path}"
|
|
111
|
+
normalized.chomp(ROOT_PATH)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize(path:, package:, store: nil, registry: nil, workdir: nil)
|
|
115
|
+
@path = self.class.normalize_path(path)
|
|
116
|
+
@store = store
|
|
117
|
+
@registry = registry
|
|
118
|
+
@package = self.class.load_package(package, store, registry)
|
|
119
|
+
attach_workdir(workdir) if workdir
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Attaches the reactor workdir: the writable overlay (topmost
|
|
123
|
+
# layer, ARCHITECTURE.md section 5a) lives under it. Reads always
|
|
124
|
+
# resolve through the overlay; writes require #writable?.
|
|
125
|
+
def attach_workdir(workdir)
|
|
126
|
+
@overlay = Overlay.new(root: File.join(workdir, "overlays", @package.name))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Writable unless the package declares "readOnly": true; writes
|
|
130
|
+
# then produce 403s and no overlay state is recorded.
|
|
131
|
+
def writable?
|
|
132
|
+
!@overlay.nil? && @package.metadata.read_only != true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether this mount answers the given request path: the prefix
|
|
136
|
+
# itself or anything below it. The root mount answers everything.
|
|
137
|
+
def matches?(request_path)
|
|
138
|
+
path == ROOT_PATH || request_path == path ||
|
|
139
|
+
request_path.start_with?("#{path}#{ROOT_PATH}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The package-local path for a request path under this mount
|
|
143
|
+
# ("/" when the request hits the mount prefix itself).
|
|
144
|
+
def inner_path(request_path)
|
|
145
|
+
inner = request_path.delete_prefix(path)
|
|
146
|
+
return ROOT_PATH if inner.empty?
|
|
147
|
+
return inner if inner.start_with?(ROOT_PATH)
|
|
148
|
+
|
|
149
|
+
"#{ROOT_PATH}#{inner}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def routes = @package.routes
|
|
153
|
+
|
|
154
|
+
# The merged content view; the overlay is always the topmost
|
|
155
|
+
# layer when a workdir is attached (hot-swap: content writes and
|
|
156
|
+
# tombstones resolve on the next request).
|
|
157
|
+
def merged_view
|
|
158
|
+
@merged_view ||= if @overlay
|
|
159
|
+
@package.merged_view(extra_layers: [@overlay.layer])
|
|
160
|
+
else
|
|
161
|
+
@package.merged_view
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The dataset as served: base data merged with the overlay's
|
|
166
|
+
# mutation log.
|
|
167
|
+
def dataset_data(dataset)
|
|
168
|
+
@overlay ? @overlay.dataset_data(dataset) : dataset.data
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def data_api = @data_api ||= DataApi.new(self)
|
|
172
|
+
|
|
173
|
+
def content_api = @content_api ||= ContentApi.new(self)
|
|
174
|
+
|
|
175
|
+
# Whether this mount exposes a GraphQL API (any file-backed
|
|
176
|
+
# dataset; SQLite datasets are skipped).
|
|
177
|
+
def graphql?
|
|
178
|
+
@package.storage.datasets.any? { |dataset| !dataset.config.sqlite? }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def graphql_api = @graphql_api ||= GraphqlApi.new(self)
|
|
182
|
+
|
|
183
|
+
# The one-line "name version" summary used in reactor logs.
|
|
184
|
+
def summary = "#{@package.name} #{@package.metadata.version}"
|
|
185
|
+
|
|
186
|
+
# The WEBrick mount points serving this mount: every serving path
|
|
187
|
+
# for the root mount (unchanged single-package behavior), the
|
|
188
|
+
# prefix itself for a non-root mount (longest-prefix matching
|
|
189
|
+
# routes everything below it to the reactor).
|
|
190
|
+
def server_paths
|
|
191
|
+
return [path] unless path == ROOT_PATH
|
|
192
|
+
|
|
193
|
+
routes.config.routes.map(&:serving_path)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Reloads the package from its (prepared) path, e.g. after the
|
|
197
|
+
# filesystem listener noticed changes. The overlay survives (it
|
|
198
|
+
# lives in the reactor workdir, not in the package).
|
|
199
|
+
def reload
|
|
200
|
+
@merged_view = nil
|
|
201
|
+
@graphql_api = nil
|
|
202
|
+
@package = Package.new(@package.path, store: @store, registry: @registry)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# OAuth2 authorization-code flow client (05x-authentication), used by
|
|
11
|
+
# the Authenticator. Talks to the provider over net/http; the client
|
|
12
|
+
# secret never comes from the package. State is a random nonce signed
|
|
13
|
+
# with the session HMAC key (CSRF protection, self-contained).
|
|
14
|
+
class OAuth2
|
|
15
|
+
# The token exchange or userinfo fetch failed at the provider.
|
|
16
|
+
class FlowError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
GRANT_TYPE = "authorization_code"
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
def initialize(config, client_secret:, session:, base_url: nil)
|
|
23
|
+
@config = config
|
|
24
|
+
@client_secret = client_secret
|
|
25
|
+
@session = session
|
|
26
|
+
@base_url = base_url
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The provider URL the login endpoint redirects the browser to.
|
|
30
|
+
def authorization_url(request)
|
|
31
|
+
params = {
|
|
32
|
+
"response_type" => "code",
|
|
33
|
+
"client_id" => @config.client_id,
|
|
34
|
+
"redirect_uri" => callback_uri(request),
|
|
35
|
+
"state" => generate_state
|
|
36
|
+
}
|
|
37
|
+
params["scope"] = @config.scopes.join(" ") unless @config.scopes.empty?
|
|
38
|
+
"#{@config.authorization_url}?#{URI.encode_www_form(params)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def valid_state?(state)
|
|
42
|
+
nonce, signature = state.to_s.split(".", 2)
|
|
43
|
+
return false if nonce.nil? || nonce.empty? || signature.nil?
|
|
44
|
+
|
|
45
|
+
Reactor.secure_compare(@session.sign(nonce), signature)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exchanges the authorization code and fetches the userinfo claims.
|
|
49
|
+
# Returns the raw userinfo hash; raises FlowError on provider
|
|
50
|
+
# errors.
|
|
51
|
+
def complete(code, request)
|
|
52
|
+
userinfo(exchange_code(code, callback_uri(request)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def callback_uri(request)
|
|
56
|
+
"#{base_url_for(request)}#{@config.redirect_path}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_state
|
|
62
|
+
nonce = SecureRandom.hex(16)
|
|
63
|
+
"#{nonce}.#{@session.sign(nonce)}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def base_url_for(request)
|
|
67
|
+
@base_url || "http://#{request['Host']}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def exchange_code(code, redirect_uri)
|
|
71
|
+
response = Net::HTTP.post_form(
|
|
72
|
+
URI(@config.token_url),
|
|
73
|
+
"grant_type" => GRANT_TYPE, "code" => code,
|
|
74
|
+
"redirect_uri" => redirect_uri, "client_id" => @config.client_id,
|
|
75
|
+
"client_secret" => @client_secret
|
|
76
|
+
)
|
|
77
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
78
|
+
raise FlowError, "token exchange failed (HTTP #{response.code})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
JSON.parse(response.body).fetch("access_token") do
|
|
82
|
+
raise FlowError, "token response without access_token"
|
|
83
|
+
end
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise FlowError, "token response was not JSON"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def userinfo(access_token)
|
|
89
|
+
uri = URI(@config.userinfo_url)
|
|
90
|
+
response = Net::HTTP.start(uri.host, uri.port,
|
|
91
|
+
use_ssl: uri.scheme == "https") do |http|
|
|
92
|
+
http.get(uri.request_uri,
|
|
93
|
+
"authorization" => "Bearer #{access_token}",
|
|
94
|
+
"accept" => "application/json")
|
|
95
|
+
end
|
|
96
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
97
|
+
raise FlowError, "userinfo request failed (HTTP #{response.code})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
JSON.parse(response.body)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise FlowError, "userinfo response was not JSON"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|