capsium 0.3.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 +7 -3
- data/CHANGELOG.md +197 -0
- data/README.adoc +323 -14
- data/capsium.gemspec +2 -0
- data/lib/capsium/cli/package.rb +62 -25
- data/lib/capsium/cli/reactor.rb +112 -6
- data/lib/capsium/cli.rb +29 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +73 -17
- data/lib/capsium/package/composition.rb +28 -12
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +39 -9
- data/lib/capsium/package/encryption_config.rb +7 -3
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +4 -2
- 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/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +18 -3
- data/lib/capsium/package/store.rb +27 -0
- data/lib/capsium/package/validator.rb +27 -2
- data/lib/capsium/package/verification.rb +7 -5
- data/lib/capsium/package.rb +24 -5
- data/lib/capsium/packager.rb +64 -22
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -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/introspection.rb +151 -34
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -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 +90 -11
- data/lib/capsium/reactor.rb +96 -49
- 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/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +12 -1
- data/sig/capsium/package/composition.rbs +4 -3
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +8 -4
- data/sig/capsium/package/encryption_config.rbs +6 -2
- data/sig/capsium/package/merged_view.rbs +2 -1
- 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/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +8 -1
- data/sig/capsium/package/store.rbs +6 -0
- data/sig/capsium/package/verification.rbs +5 -3
- data/sig/capsium/package.rbs +8 -3
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -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/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -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 +28 -4
- data/sig/capsium/reactor.rbs +17 -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 +66 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Builds the GraphQL schema for a mounted package (ARCHITECTURE.md
|
|
8
|
+
# section 5): per dataset a query field "<dataset>" (list, optional
|
|
9
|
+
# "id:" argument for a single item) plus
|
|
10
|
+
# create<Dataset>/update<Dataset>/delete<Dataset> mutations.
|
|
11
|
+
# Item types are inferred from the dataset's JSON schema when
|
|
12
|
+
# present, else map to a permissive JSON scalar. Field resolvers
|
|
13
|
+
# delegate to the given API object's public methods.
|
|
14
|
+
class GraphqlSchema
|
|
15
|
+
# Permissive scalar carrying any JSON value (schema-less items,
|
|
16
|
+
# mutation inputs, nested structures).
|
|
17
|
+
class JsonScalar < GraphQL::Schema::Scalar
|
|
18
|
+
graphql_name("JSON")
|
|
19
|
+
description "An arbitrary JSON value"
|
|
20
|
+
|
|
21
|
+
def self.coerce_input(input_value, _context) = input_value
|
|
22
|
+
|
|
23
|
+
def self.coerce_result(ruby_value, _context) = ruby_value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(mount, api)
|
|
27
|
+
@mount = mount
|
|
28
|
+
@api = api
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build
|
|
32
|
+
api = @api
|
|
33
|
+
query_type = Class.new(GraphQL::Schema::Object) { graphql_name("Query") }
|
|
34
|
+
mutation_type = Class.new(GraphQL::Schema::Object) { graphql_name("Mutation") }
|
|
35
|
+
@mount.package.storage.datasets.each do |dataset|
|
|
36
|
+
next if dataset.config.sqlite?
|
|
37
|
+
|
|
38
|
+
add_dataset_fields(query_type, mutation_type, dataset, api)
|
|
39
|
+
end
|
|
40
|
+
Class.new(GraphQL::Schema) do
|
|
41
|
+
query(query_type)
|
|
42
|
+
mutation(mutation_type)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def add_dataset_fields(query_type, mutation_type, dataset, api)
|
|
49
|
+
name = graphql_name_for(dataset.name)
|
|
50
|
+
type = item_type_for(dataset, name)
|
|
51
|
+
add_query_field(query_type, name, type, dataset, api)
|
|
52
|
+
add_mutation_fields(mutation_type, name, type, dataset, api)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "<dataset>" returns the merged item list; with "id:" the single
|
|
56
|
+
# matching item (a one-element list, not-found in "errors").
|
|
57
|
+
def add_query_field(query_type, name, type, dataset, api)
|
|
58
|
+
query_type.class_eval do
|
|
59
|
+
field name, [type], null: false, camelize: false do
|
|
60
|
+
argument :id, String, required: false
|
|
61
|
+
end
|
|
62
|
+
define_method(name) { |id: nil| api.collection(dataset, id: id) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# create<Dataset>/update<Dataset>/delete<Dataset> with the same
|
|
67
|
+
# semantics (and schema validation) as the REST verbs.
|
|
68
|
+
def add_mutation_fields(mutation_type, name, type, dataset, api)
|
|
69
|
+
capitalized = name.sub(/\A./, &:upcase)
|
|
70
|
+
mutation_type.class_eval do
|
|
71
|
+
field "create#{capitalized}", type, null: true, camelize: false do
|
|
72
|
+
argument :item, JsonScalar, required: true
|
|
73
|
+
end
|
|
74
|
+
define_method("create#{capitalized}") { |item:| api.create_item(dataset, item: item) }
|
|
75
|
+
|
|
76
|
+
field "update#{capitalized}", type, null: true, camelize: false do
|
|
77
|
+
argument :id, String, required: true
|
|
78
|
+
argument :item, JsonScalar, required: true
|
|
79
|
+
end
|
|
80
|
+
define_method("update#{capitalized}") do |id:, item:|
|
|
81
|
+
api.update_item(dataset, id: id, item: item)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
field "delete#{capitalized}", GraphQL::Types::Boolean, null: false, camelize: false do
|
|
85
|
+
argument :id, String, required: true
|
|
86
|
+
end
|
|
87
|
+
define_method("delete#{capitalized}") { |id:| api.remove_item(dataset, id: id) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The item type inferred from the dataset's JSON schema (the
|
|
92
|
+
# schema describes the collection; its "items" describe an item),
|
|
93
|
+
# else the permissive JSON scalar.
|
|
94
|
+
def item_type_for(dataset, name)
|
|
95
|
+
properties = item_properties(dataset.json_schema)
|
|
96
|
+
return JsonScalar if properties.nil? || properties.empty?
|
|
97
|
+
|
|
98
|
+
fields = properties.to_h { |property, spec| [property, graphql_type_for(spec)] }
|
|
99
|
+
Class.new(GraphQL::Schema::Object) do
|
|
100
|
+
graphql_name("#{name.sub(/\A./, &:upcase)}Item")
|
|
101
|
+
fields.each do |property, type|
|
|
102
|
+
field property, type, null: true, camelize: false
|
|
103
|
+
define_method(property) { object[property] }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def item_properties(schema)
|
|
109
|
+
return nil unless schema.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
if schema["type"] == "array" && schema["items"].is_a?(Hash)
|
|
112
|
+
schema["items"]["properties"]
|
|
113
|
+
elsif schema["type"] == "object"
|
|
114
|
+
schema["properties"]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def graphql_type_for(spec)
|
|
119
|
+
case spec.is_a?(Hash) ? spec["type"] : nil
|
|
120
|
+
when "string" then String
|
|
121
|
+
when "integer" then Integer
|
|
122
|
+
when "number" then Float
|
|
123
|
+
when "boolean" then GraphQL::Types::Boolean
|
|
124
|
+
when "array" then [graphql_type_for(spec["items"] || {})]
|
|
125
|
+
else JsonScalar
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Dataset names become GraphQL identifiers ("my-data" -> "myData").
|
|
130
|
+
def graphql_name_for(name)
|
|
131
|
+
parts = name.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
|
|
132
|
+
return "data" if parts.empty?
|
|
133
|
+
|
|
134
|
+
parts.first.downcase + parts.drop(1).map(&:capitalize).join
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -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,73 +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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
70
109
|
}
|
|
71
|
-
entry[:signatureValid] = package.verify_signature if package.signed?
|
|
72
|
-
entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
|
|
73
|
-
{ contentValidity: [entry] }
|
|
74
110
|
end
|
|
75
111
|
|
|
112
|
+
def metrics_report = @reactor.metrics.snapshot.merge(uptime: uptime)
|
|
113
|
+
|
|
76
114
|
private
|
|
77
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
|
+
|
|
78
195
|
# SHA-256 of the .cap blob when the package was loaded from one.
|
|
79
196
|
# For directory sources there is no blob, so the hash covers a
|
|
80
197
|
# canonical (sorted-key) JSON serialization of the package content
|
|
81
198
|
# checksums — the same data security.json integrityChecks carry.
|
|
82
|
-
def content_hash
|
|
83
|
-
cap_file =
|
|
199
|
+
def content_hash(pkg)
|
|
200
|
+
cap_file = pkg.cap_file_path
|
|
84
201
|
return Digest::SHA256.file(cap_file).hexdigest if cap_file
|
|
85
202
|
|
|
86
|
-
checksums = Package::Security.checksums_for(
|
|
203
|
+
checksums = Package::Security.checksums_for(pkg.path)
|
|
87
204
|
Digest::SHA256.hexdigest(JSON.generate(checksums))
|
|
88
205
|
end
|
|
89
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
|