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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. 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 package this reactor serves
10
- # (ARCHITECTURE.md section 7). Each report wraps its single entry in
11
- # the list shape all reactors converge on.
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
- attr_reader :package
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
- def initialize(package)
23
- @package = package
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 introspection endpoint, or nil when the
31
- # path is not an introspection endpoint.
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
- metadata = package.metadata
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 = package.routes.config.routes.map do |route|
53
- { method: route.http_method || "GET", path: route.path }
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: [{ package: package.name, routes: entries }] }
82
+ { routes: entries }
56
83
  end
57
84
 
58
85
  def content_hashes_report
59
- { contentHashes: [{ package: package.name, hash: content_hash }] }
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
- errors = package.verify_integrity
64
- entry = {
65
- package: package.name,
66
- valid: errors.empty?,
67
- lastChecked: Time.now.utc.iso8601,
68
- signed: package.signed?,
69
- encrypted: package.encrypted?
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 = package.cap_file_path
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(package.path)
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