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
data/lib/capsium/reactor.rb
CHANGED
|
@@ -1,40 +1,86 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "json"
|
|
4
5
|
require "listen"
|
|
5
6
|
require "tmpdir"
|
|
6
7
|
require "webrick"
|
|
7
8
|
|
|
9
|
+
# WEBrick's ProcHandler only dispatches do_GET/do_POST/do_PUT; the
|
|
10
|
+
# writable-package API (REST CRUD, content writes) also needs DELETE
|
|
11
|
+
# and PATCH. AbstractServlet#service looks the do_<METHOD> up per
|
|
12
|
+
# request, so aliasing the missing verbs routes them to the same proc.
|
|
13
|
+
# rubocop:disable Style/OneClassPerFile
|
|
14
|
+
module WEBrick
|
|
15
|
+
module HTTPServlet
|
|
16
|
+
class ProcHandler < AbstractServlet
|
|
17
|
+
alias do_DELETE do_GET # rubocop:disable Naming/MethodName
|
|
18
|
+
alias do_PATCH do_GET # rubocop:disable Naming/MethodName
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
8
23
|
module Capsium
|
|
9
24
|
class Reactor
|
|
10
25
|
autoload :Authenticator, "capsium/reactor/authenticator"
|
|
26
|
+
autoload :ContentApi, "capsium/reactor/content_api"
|
|
27
|
+
autoload :DataApi, "capsium/reactor/data_api"
|
|
11
28
|
autoload :Deploy, "capsium/reactor/deploy"
|
|
29
|
+
autoload :Endpoints, "capsium/reactor/endpoints"
|
|
30
|
+
autoload :GraphqlApi, "capsium/reactor/graphql_api"
|
|
31
|
+
autoload :GraphqlSchema, "capsium/reactor/graphql_schema"
|
|
12
32
|
autoload :Htpasswd, "capsium/reactor/htpasswd"
|
|
13
33
|
autoload :Introspection, "capsium/reactor/introspection"
|
|
34
|
+
autoload :Metrics, "capsium/reactor/metrics"
|
|
35
|
+
autoload :Mount, "capsium/reactor/mount"
|
|
36
|
+
autoload :MountConflictError, "capsium/reactor/mount"
|
|
14
37
|
autoload :OAuth2, "capsium/reactor/oauth2"
|
|
38
|
+
autoload :Overlay, "capsium/reactor/overlay"
|
|
39
|
+
autoload :PackageSaver, "capsium/reactor/package_saver"
|
|
40
|
+
autoload :Responses, "capsium/reactor/responses"
|
|
15
41
|
autoload :Serving, "capsium/reactor/serving"
|
|
16
42
|
autoload :Session, "capsium/reactor/session"
|
|
17
43
|
|
|
44
|
+
include Responses
|
|
45
|
+
include Endpoints
|
|
18
46
|
include Serving
|
|
19
47
|
|
|
20
48
|
DEFAULT_PORT = 8864
|
|
21
49
|
DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
|
|
50
|
+
PACKAGE_SAVE_PATTERN = %r{\A/package/(?<name>[^/]+)/save\z}
|
|
22
51
|
|
|
23
52
|
attr_reader :package, :package_path, :routes, :port, :cache_control,
|
|
24
|
-
:server, :server_thread, :introspection, :authenticator
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
:server, :server_thread, :introspection, :authenticator,
|
|
54
|
+
:store, :registry, :started_at, :metrics, :log_buffer,
|
|
55
|
+
:mounts, :workdir
|
|
56
|
+
|
|
57
|
+
# Serves one or more packages: either a single `package` (directory,
|
|
58
|
+
# .cap file or Package, mounted at "/") or a list of `mounts`
|
|
59
|
+
# (Reactor::Mount) resolved by longest-prefix matching. `workdir`
|
|
60
|
+
# holds the writable overlays (ARCHITECTURE.md section 5a) and
|
|
61
|
+
# saved packages; it defaults to a temporary directory the reactor
|
|
62
|
+
# removes on cleanup.
|
|
63
|
+
def initialize(package: nil, mounts: nil, port: DEFAULT_PORT,
|
|
27
64
|
cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
|
|
28
|
-
store: nil, deploy: nil)
|
|
29
|
-
@package = package.is_a?(String) ? Package.new(package, store: store) : package
|
|
30
|
-
@package_path = @package.path
|
|
65
|
+
store: nil, deploy: nil, registry: nil, workdir: nil)
|
|
31
66
|
@store = store
|
|
67
|
+
@registry = registry
|
|
68
|
+
@workdir = workdir || Dir.mktmpdir("capsium-reactor-")
|
|
69
|
+
@own_workdir = workdir.nil?
|
|
70
|
+
@mounts = mounts || [Mount.new(path: Mount::ROOT_PATH, package: package,
|
|
71
|
+
store: store, registry: registry)]
|
|
72
|
+
@mounts.each { |mount| mount.attach_workdir(@workdir) }
|
|
32
73
|
@port = port
|
|
33
74
|
@cache_control = cache_control
|
|
75
|
+
@started_at = Time.now
|
|
76
|
+
@metrics = Metrics.new
|
|
77
|
+
@log_buffer = Capsium::LogBuffer.new
|
|
34
78
|
@deploy_config = Deploy.load(deploy)
|
|
35
79
|
setup_server(do_not_listen)
|
|
36
80
|
load_state
|
|
37
81
|
mount_routes
|
|
82
|
+
@log_buffer.add("reactor started: " \
|
|
83
|
+
"#{@mounts.map(&:summary).join(', ')} on port #{@port}")
|
|
38
84
|
end
|
|
39
85
|
|
|
40
86
|
def serve
|
|
@@ -43,44 +89,43 @@ module Capsium
|
|
|
43
89
|
start_listener
|
|
44
90
|
end
|
|
45
91
|
|
|
92
|
+
# Entry point for every mounted path: dispatches the request, then
|
|
93
|
+
# records it in the request metrics and the log buffer.
|
|
46
94
|
def handle_request(request, response)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
identity = @authenticator.authenticate(request)
|
|
53
|
-
return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
|
|
54
|
-
return serve_introspection(request, response) if @introspection.endpoint?(request.path)
|
|
55
|
-
|
|
56
|
-
route = @routes.resolve(request.path)
|
|
57
|
-
return respond_not_found(response) unless route
|
|
58
|
-
|
|
59
|
-
case @authenticator.authorize(identity, route.access_control)
|
|
60
|
-
when :unauthenticated then return @authenticator.challenge(response)
|
|
61
|
-
when :forbidden then return respond_forbidden(response)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
serve_route(route, response)
|
|
95
|
+
dispatch_request(request, response)
|
|
96
|
+
ensure
|
|
97
|
+
record_request(request, response)
|
|
65
98
|
end
|
|
66
99
|
|
|
67
100
|
def mount_routes
|
|
68
|
-
paths =
|
|
69
|
-
|
|
101
|
+
paths = Introspection::PATHS + Introspection::REACTOR_PATHS +
|
|
102
|
+
@authenticator.endpoints
|
|
103
|
+
paths.concat(@mounts.flat_map(&:server_paths))
|
|
70
104
|
paths.each do |path|
|
|
71
105
|
@server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
|
|
72
106
|
end
|
|
107
|
+
# WEBrick longest-prefix matching: catches every "/package/...".
|
|
108
|
+
@server.mount_proc(Introspection::PACKAGE_MOUNT) do |req, res|
|
|
109
|
+
handle_request(req, res)
|
|
110
|
+
end
|
|
73
111
|
end
|
|
74
112
|
|
|
75
113
|
def restart_server
|
|
76
114
|
@server.shutdown
|
|
77
115
|
@server_thread&.join
|
|
78
|
-
|
|
116
|
+
load_packages
|
|
79
117
|
setup_server(false)
|
|
80
118
|
mount_routes
|
|
81
119
|
@server_thread = start_server
|
|
82
120
|
end
|
|
83
121
|
|
|
122
|
+
# Cleans up every mounted package (Package#cleanup for all) and the
|
|
123
|
+
# workdir when the reactor created it.
|
|
124
|
+
def cleanup
|
|
125
|
+
@mounts.each { |mount| mount.package.cleanup }
|
|
126
|
+
FileUtils.remove_entry(@workdir) if @own_workdir && File.directory?(@workdir)
|
|
127
|
+
end
|
|
128
|
+
|
|
84
129
|
private
|
|
85
130
|
|
|
86
131
|
def setup_server(do_not_listen)
|
|
@@ -113,15 +158,20 @@ module Capsium
|
|
|
113
158
|
@server_thread.join
|
|
114
159
|
end
|
|
115
160
|
|
|
116
|
-
def
|
|
117
|
-
@
|
|
161
|
+
def load_packages
|
|
162
|
+
@mounts.each do |mount|
|
|
163
|
+
@log_buffer.add("package reloaded: #{mount.reload.name}")
|
|
164
|
+
end
|
|
118
165
|
load_state
|
|
119
166
|
end
|
|
120
167
|
|
|
121
168
|
def load_state
|
|
122
|
-
|
|
123
|
-
@
|
|
124
|
-
@
|
|
169
|
+
root = root_mount
|
|
170
|
+
@package = root.package
|
|
171
|
+
@package_path = @package.path
|
|
172
|
+
@routes = root.routes
|
|
173
|
+
@merged_view = root.merged_view
|
|
174
|
+
@introspection = Introspection.new(@mounts.map(&:package), reactor: self)
|
|
125
175
|
@authenticator = Authenticator.new(
|
|
126
176
|
@package.authentication,
|
|
127
177
|
deploy: @deploy_config,
|
|
@@ -131,26 +181,23 @@ module Capsium
|
|
|
131
181
|
)
|
|
132
182
|
end
|
|
133
183
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
response.body = JSON.generate(@introspection.report_for(request.path))
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def respond_not_found(response) = respond_text(response, 404, "Not Found")
|
|
143
|
-
|
|
144
|
-
def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
|
|
184
|
+
def dispatch_request(request, response)
|
|
185
|
+
if @authenticator.endpoint?(request.path)
|
|
186
|
+
@authenticator.serve_endpoint(request, response)
|
|
187
|
+
return
|
|
188
|
+
end
|
|
145
189
|
|
|
146
|
-
|
|
190
|
+
identity = @authenticator.authenticate(request)
|
|
191
|
+
return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
|
|
192
|
+
return serve_introspection(request, response) if @introspection.endpoint?(request.path)
|
|
193
|
+
return serve_package_save(request, response) if PACKAGE_SAVE_PATTERN.match?(request.path)
|
|
147
194
|
|
|
148
|
-
|
|
195
|
+
mount = resolve_mount(request.path)
|
|
196
|
+
return respond_not_found(response) unless mount
|
|
149
197
|
|
|
150
|
-
|
|
151
|
-
response.status = status
|
|
152
|
-
response["Content-Type"] = "text/plain"
|
|
153
|
-
response.body = body
|
|
198
|
+
serve_mounted_request(mount, identity, request, response)
|
|
154
199
|
end
|
|
155
200
|
end
|
|
156
201
|
end
|
|
202
|
+
|
|
203
|
+
# rubocop:enable Style/OneClassPerFile
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Capsium
|
|
8
|
+
class Registry
|
|
9
|
+
# A static registry in a local directory (read-write). Push
|
|
10
|
+
# validates the .cap (Capsium::Package::Validator), copies it into
|
|
11
|
+
# the registry directory and atomically rewrites index.json
|
|
12
|
+
# (tmp + rename) with recomputed sha256 and size.
|
|
13
|
+
class Local < Registry
|
|
14
|
+
attr_reader :dir
|
|
15
|
+
|
|
16
|
+
def initialize(dir)
|
|
17
|
+
super()
|
|
18
|
+
if File.exist?(dir) && !File.directory?(dir)
|
|
19
|
+
raise InvalidRegistryError, "registry path is not a directory: #{dir}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@dir = dir
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def location = dir
|
|
26
|
+
|
|
27
|
+
# Validates cap_path, copies it into the registry as
|
|
28
|
+
# "<name>-<version>.cap" and records it in index.json. Returns the
|
|
29
|
+
# recorded Entry. Raises InvalidPackageError when the package
|
|
30
|
+
# fails validation.
|
|
31
|
+
def push(cap_path)
|
|
32
|
+
validate_package!(cap_path)
|
|
33
|
+
entry = build_entry(read_metadata(cap_path), cap_path)
|
|
34
|
+
FileUtils.mkdir_p(dir)
|
|
35
|
+
atomic_copy(cap_path, File.join(dir, entry.file))
|
|
36
|
+
record(entry)
|
|
37
|
+
entry
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def index
|
|
43
|
+
@index ||= begin
|
|
44
|
+
path = File.join(dir, INDEX_FILE)
|
|
45
|
+
File.file?(path) ? parse_index(File.read(path)) : { "packages" => {} }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_entry_file(entry)
|
|
50
|
+
path = File.join(dir, entry.file)
|
|
51
|
+
unless File.file?(path)
|
|
52
|
+
raise InvalidRegistryError, "#{location}: indexed file missing: #{entry.file}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
yield path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_package!(cap_path)
|
|
59
|
+
raise InvalidPackageError, "not a file: #{cap_path}" unless File.file?(cap_path)
|
|
60
|
+
|
|
61
|
+
failures = Package::Validator.new(cap_path).run.reject(&:ok?)
|
|
62
|
+
return if failures.empty?
|
|
63
|
+
|
|
64
|
+
details = failures.flat_map do |failure|
|
|
65
|
+
failure.messages.map { |message| "#{failure.name}: #{message}" }
|
|
66
|
+
end
|
|
67
|
+
raise InvalidPackageError,
|
|
68
|
+
"package validation failed for #{cap_path} — #{details.join('; ')}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read_metadata(cap_path)
|
|
72
|
+
Packager.new.with_unpacked_cap(cap_path) do |unpacked|
|
|
73
|
+
JSON.parse(File.read(File.join(unpacked, Package::METADATA_FILE)))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_entry(metadata, cap_path)
|
|
78
|
+
guid = metadata["guid"]
|
|
79
|
+
name = metadata["name"]
|
|
80
|
+
version = metadata["version"]
|
|
81
|
+
unless guid.is_a?(String) && name.is_a?(String) && version.is_a?(String)
|
|
82
|
+
raise InvalidPackageError, "metadata.json must declare guid, name and version"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Entry.new(guid: guid, name: name, version: parse_package_version(version),
|
|
86
|
+
file: "#{name}-#{version}.cap",
|
|
87
|
+
sha256: Digest::SHA256.file(cap_path).hexdigest,
|
|
88
|
+
size: File.size(cap_path))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_package_version(string)
|
|
92
|
+
Package::Version.parse(string)
|
|
93
|
+
rescue Capsium::Error => e
|
|
94
|
+
raise InvalidPackageError, "metadata.json version is invalid: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def record(entry)
|
|
98
|
+
listing = index["packages"][entry.guid] ||
|
|
99
|
+
{ "name" => entry.name, "versions" => {} }
|
|
100
|
+
listing["versions"][entry.version.to_s] = {
|
|
101
|
+
"file" => entry.file, "sha256" => entry.sha256, "size" => entry.size
|
|
102
|
+
}
|
|
103
|
+
index["packages"][entry.guid] = listing
|
|
104
|
+
atomic_write(File.join(dir, INDEX_FILE), JSON.pretty_generate(index))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def atomic_copy(source, destination)
|
|
108
|
+
tmp = "#{destination}.tmp-#{Process.pid}"
|
|
109
|
+
FileUtils.cp(source, tmp)
|
|
110
|
+
File.rename(tmp, destination)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def atomic_write(path, content)
|
|
114
|
+
tmp = "#{path}.tmp-#{Process.pid}"
|
|
115
|
+
File.write(tmp, content)
|
|
116
|
+
File.rename(tmp, path)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "timeout"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module Capsium
|
|
12
|
+
class Registry
|
|
13
|
+
# A read-only static registry behind an https base URL (any static
|
|
14
|
+
# host: GitHub Pages, S3, nginx). index.json and .cap files are
|
|
15
|
+
# fetched over net/http with redirect following and timeouts; plain
|
|
16
|
+
# http is accepted for loopback hosts only (local development).
|
|
17
|
+
class Remote < Registry
|
|
18
|
+
OPEN_TIMEOUT = 5
|
|
19
|
+
READ_TIMEOUT = 15
|
|
20
|
+
MAX_REDIRECTS = 5
|
|
21
|
+
LOOPBACK_NETWORKS = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128")].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :base_url
|
|
24
|
+
|
|
25
|
+
def initialize(base_url)
|
|
26
|
+
super()
|
|
27
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
28
|
+
validate_scheme!(request_uri(INDEX_FILE))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def location = base_url
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def index
|
|
36
|
+
@index ||= parse_index(fetch_bytes(INDEX_FILE))
|
|
37
|
+
rescue FetchError => e
|
|
38
|
+
raise InvalidRegistryError, "no readable #{INDEX_FILE} at #{base_url}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def with_entry_file(entry)
|
|
42
|
+
Tempfile.create(["capsium-registry", ".cap"]) do |tmp|
|
|
43
|
+
tmp.binmode
|
|
44
|
+
tmp.write(fetch_bytes(entry.file))
|
|
45
|
+
tmp.flush
|
|
46
|
+
yield tmp.path
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetch_bytes(relative_path)
|
|
51
|
+
uri = request_uri(relative_path)
|
|
52
|
+
MAX_REDIRECTS.times do
|
|
53
|
+
response = http_get(uri)
|
|
54
|
+
return response.body if response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
|
|
56
|
+
uri = follow_redirect(uri, response)
|
|
57
|
+
end
|
|
58
|
+
raise FetchError, "GET #{uri}: too many redirects (limit #{MAX_REDIRECTS})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def follow_redirect(uri, response)
|
|
62
|
+
location = response["location"] if response.is_a?(Net::HTTPRedirection)
|
|
63
|
+
raise FetchError, "GET #{uri} failed: HTTP #{response.code}" if location.nil?
|
|
64
|
+
|
|
65
|
+
target = URI.join(uri.to_s, location)
|
|
66
|
+
validate_scheme!(target)
|
|
67
|
+
target
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def http_get(uri)
|
|
71
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
72
|
+
http.use_ssl = uri.is_a?(URI::HTTPS)
|
|
73
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
74
|
+
http.read_timeout = READ_TIMEOUT
|
|
75
|
+
http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
76
|
+
rescue Timeout::Error, SocketError, SystemCallError,
|
|
77
|
+
OpenSSL::SSL::SSLError => e
|
|
78
|
+
raise FetchError, "GET #{uri} failed: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def request_uri(relative_path)
|
|
82
|
+
URI.parse("#{base_url}/#{relative_path}")
|
|
83
|
+
rescue URI::InvalidURIError => e
|
|
84
|
+
raise InvalidRegistryError, "invalid registry URL #{base_url.inspect}: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_scheme!(uri)
|
|
88
|
+
return if uri.is_a?(URI::HTTPS)
|
|
89
|
+
return if uri.is_a?(URI::HTTP) && loopback_host?(uri.host)
|
|
90
|
+
|
|
91
|
+
raise InvalidRegistryError,
|
|
92
|
+
"registry URL must use https (plain http for loopback only): #{uri}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def loopback_host?(host)
|
|
96
|
+
return true if host == "localhost" || host.to_s.end_with?(".localhost")
|
|
97
|
+
|
|
98
|
+
address = IPAddr.new(host)
|
|
99
|
+
LOOPBACK_NETWORKS.any? { |network| network.include?(address) }
|
|
100
|
+
rescue IPAddr::InvalidAddressError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Capsium
|
|
8
|
+
# A static package registry: a directory or a static https base URL
|
|
9
|
+
# holding an index.json plus .cap files stored relative to the
|
|
10
|
+
# registry root, so any static host (GitHub Pages, S3, nginx) can
|
|
11
|
+
# serve it. Index shape:
|
|
12
|
+
#
|
|
13
|
+
# { "packages": { "<guid>": { "name": "story-of-claire",
|
|
14
|
+
# "versions": { "1.0.0": { "file": "story-of-claire-1.0.0.cap",
|
|
15
|
+
# "sha256": "<hex>", "size": 642047 } } } } }
|
|
16
|
+
#
|
|
17
|
+
# Registry.fetch(ref) returns the implementation for a reference: a
|
|
18
|
+
# Local directory (read-write) or a Remote http(s) base URL
|
|
19
|
+
# (read-only). Both resolve and install; only Local pushes.
|
|
20
|
+
class Registry
|
|
21
|
+
autoload :Local, "capsium/registry/local"
|
|
22
|
+
autoload :Remote, "capsium/registry/remote"
|
|
23
|
+
|
|
24
|
+
INDEX_FILE = "index.json"
|
|
25
|
+
ENV_VAR = "CAPSIUM_REGISTRY"
|
|
26
|
+
|
|
27
|
+
# Base error for every registry failure.
|
|
28
|
+
class RegistryError < Capsium::Error; end
|
|
29
|
+
|
|
30
|
+
# No registry reference was given (neither flag nor CAPSIUM_REGISTRY).
|
|
31
|
+
class RegistryNotConfiguredError < RegistryError; end
|
|
32
|
+
|
|
33
|
+
# The registry itself is unusable (bad path or URL, unreadable index).
|
|
34
|
+
class InvalidRegistryError < RegistryError; end
|
|
35
|
+
|
|
36
|
+
# A .cap offered to push failed package validation.
|
|
37
|
+
class InvalidPackageError < RegistryError; end
|
|
38
|
+
|
|
39
|
+
# The index has no entry for the requested package GUID.
|
|
40
|
+
class PackageNotFoundError < RegistryError; end
|
|
41
|
+
|
|
42
|
+
# The registry has the GUID but no version satisfies the constraint.
|
|
43
|
+
class UnsatisfiableConstraintError < RegistryError; end
|
|
44
|
+
|
|
45
|
+
# A fetched .cap does not match the sha256 declared in the index.
|
|
46
|
+
class ChecksumMismatchError < RegistryError; end
|
|
47
|
+
|
|
48
|
+
# A registry file could not be retrieved (network or HTTP failure).
|
|
49
|
+
class FetchError < RegistryError; end
|
|
50
|
+
|
|
51
|
+
# One indexed version of a registry package.
|
|
52
|
+
Entry = Data.define(:guid, :name, :version, :file, :sha256, :size) do
|
|
53
|
+
# The canonical store file name for this entry's .cap.
|
|
54
|
+
def cap_file_name = "#{name}-#{version}.cap"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# The registry at the given reference: a Local directory or a
|
|
59
|
+
# Remote http(s) base URL. Raises RegistryNotConfiguredError when
|
|
60
|
+
# no reference is given.
|
|
61
|
+
def fetch(ref)
|
|
62
|
+
if ref.nil? || ref.to_s.empty?
|
|
63
|
+
raise RegistryNotConfiguredError,
|
|
64
|
+
"no registry configured (pass --registry or set #{ENV_VAR})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ref.to_s.match?(%r{\Ahttps?://}) ? Remote.new(ref.to_s) : Local.new(ref.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The registry named by the CAPSIUM_REGISTRY environment variable,
|
|
71
|
+
# or nil when unset.
|
|
72
|
+
def default
|
|
73
|
+
ref = ENV.fetch(ENV_VAR, nil)
|
|
74
|
+
ref.nil? || ref.empty? ? nil : fetch(ref)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize
|
|
79
|
+
@index = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Where this registry lives (directory path or base URL), for
|
|
83
|
+
# messages and reports.
|
|
84
|
+
def location = raise(NotImplementedError)
|
|
85
|
+
|
|
86
|
+
# The newest indexed version of the package GUID satisfying the
|
|
87
|
+
# semver constraint (default "*"). Raises PackageNotFoundError or
|
|
88
|
+
# UnsatisfiableConstraintError.
|
|
89
|
+
def resolve(guid, constraint = "*")
|
|
90
|
+
listing = packages[guid]
|
|
91
|
+
raise PackageNotFoundError, "no package #{guid} in registry #{location}" if listing.nil?
|
|
92
|
+
|
|
93
|
+
versions = listing_versions(guid, listing)
|
|
94
|
+
range = Package::VersionRange.parse(constraint)
|
|
95
|
+
satisfying = versions.keys.select { |version| range.satisfied_by?(version) }
|
|
96
|
+
raise_unsatisfiable(guid, constraint, versions) if satisfying.empty?
|
|
97
|
+
|
|
98
|
+
entry_for(guid, listing, satisfying.max)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Resolves the newest satisfying version, fetches its .cap (verifying
|
|
102
|
+
# the sha256 declared in the index) and installs it into the package
|
|
103
|
+
# store as "<name>-<version>.cap". Returns the installed store path.
|
|
104
|
+
def install(guid, constraint = "*", store:)
|
|
105
|
+
entry = resolve(guid, constraint)
|
|
106
|
+
with_entry_file(entry) do |path|
|
|
107
|
+
verify_checksum!(entry, path)
|
|
108
|
+
store_for(store).install(path, guid: entry.guid, file_name: entry.cap_file_name)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Only Local registries are writable; Remote overrides nothing.
|
|
113
|
+
def push(_cap_path)
|
|
114
|
+
raise RegistryError, "registry is read-only: #{location}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Subclass hooks: the parsed index.json hash, and a yield of a local
|
|
120
|
+
# filesystem path holding the entry's .cap bytes.
|
|
121
|
+
def index = raise(NotImplementedError)
|
|
122
|
+
|
|
123
|
+
def with_entry_file(entry) = raise(NotImplementedError)
|
|
124
|
+
|
|
125
|
+
def packages
|
|
126
|
+
all = index["packages"]
|
|
127
|
+
unless all.is_a?(Hash)
|
|
128
|
+
raise InvalidRegistryError, "#{location}: #{INDEX_FILE} has no \"packages\" object"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
all
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_index(json_text)
|
|
135
|
+
JSON.parse(json_text)
|
|
136
|
+
rescue JSON::ParserError => e
|
|
137
|
+
raise InvalidRegistryError, "#{location}: #{INDEX_FILE} is not valid JSON: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def listing_versions(guid, listing)
|
|
141
|
+
versions = listing["versions"]
|
|
142
|
+
unless versions.is_a?(Hash) && !versions.empty?
|
|
143
|
+
raise InvalidRegistryError, "#{location}: no versions indexed for #{guid}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
versions.to_h { |string, data| [parse_index_version(guid, string), data] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_index_version(guid, string)
|
|
150
|
+
Package::Version.parse(string)
|
|
151
|
+
rescue Capsium::Error
|
|
152
|
+
raise InvalidRegistryError, "#{location}: invalid version #{string.inspect} for #{guid}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def raise_unsatisfiable(guid, constraint, versions)
|
|
156
|
+
available = versions.keys.map(&:to_s).sort.join(", ")
|
|
157
|
+
raise UnsatisfiableConstraintError,
|
|
158
|
+
"no version of #{guid} satisfies '#{constraint}' (registry has: #{available})"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def entry_for(guid, listing, version)
|
|
162
|
+
data = listing.fetch("versions").fetch(version.to_s)
|
|
163
|
+
unless data["file"].is_a?(String) && data["sha256"].is_a?(String)
|
|
164
|
+
raise InvalidRegistryError,
|
|
165
|
+
"#{location}: incomplete index entry for #{guid} #{version} (file/sha256)"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Entry.new(guid: guid, name: listing["name"].to_s, version: version,
|
|
169
|
+
file: data["file"], sha256: data["sha256"], size: data["size"].to_i)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def verify_checksum!(entry, path)
|
|
173
|
+
actual = Digest::SHA256.file(path).hexdigest
|
|
174
|
+
return if actual == entry.sha256.downcase
|
|
175
|
+
|
|
176
|
+
raise ChecksumMismatchError,
|
|
177
|
+
"sha256 mismatch for #{entry.cap_file_name} from #{location}: " \
|
|
178
|
+
"index declares #{entry.sha256}, file hashes to #{actual}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A Store for the given Store or directory path (created when
|
|
182
|
+
# missing, so installing into a fresh store directory works).
|
|
183
|
+
def store_for(store)
|
|
184
|
+
return store if store.is_a?(Package::Store)
|
|
185
|
+
|
|
186
|
+
FileUtils.mkdir_p(store.to_s)
|
|
187
|
+
Package::Store.new(store.to_s)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/capsium/version.rb
CHANGED
data/lib/capsium.rb
CHANGED
|
@@ -6,8 +6,10 @@ module Capsium
|
|
|
6
6
|
autoload :VERSION, "capsium/version"
|
|
7
7
|
autoload :Cli, "capsium/cli"
|
|
8
8
|
autoload :Converters, "capsium/converters"
|
|
9
|
+
autoload :LogBuffer, "capsium/log_buffer"
|
|
9
10
|
autoload :Package, "capsium/package"
|
|
10
11
|
autoload :Packager, "capsium/packager"
|
|
11
12
|
autoload :Reactor, "capsium/reactor"
|
|
13
|
+
autoload :Registry, "capsium/registry"
|
|
12
14
|
autoload :ThorExt, "capsium/thor_ext"
|
|
13
15
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Capsium
|
|
2
|
+
# A small thread-safe ring buffer of timestamped log entries with a
|
|
3
|
+
# fixed capacity: when full, the oldest entry is dropped.
|
|
4
|
+
class LogBuffer
|
|
5
|
+
DEFAULT_CAPACITY: Integer
|
|
6
|
+
|
|
7
|
+
# One buffer entry: a UTC timestamp and a message.
|
|
8
|
+
class Entry
|
|
9
|
+
def self.new: (timestamp: Time, message: String) -> void
|
|
10
|
+
|
|
11
|
+
def timestamp: () -> Time
|
|
12
|
+
def message: () -> String
|
|
13
|
+
|
|
14
|
+
# "2026-07-19T15:00:00Z message"
|
|
15
|
+
def line: () -> String
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader capacity: Integer
|
|
19
|
+
|
|
20
|
+
def initialize: (?capacity: Integer capacity) -> void
|
|
21
|
+
|
|
22
|
+
def add: (String message, ?timestamp: Time timestamp) -> LogBuffer
|
|
23
|
+
|
|
24
|
+
# The last n entries, oldest first.
|
|
25
|
+
def last: (Integer count) -> Array[Entry]
|
|
26
|
+
|
|
27
|
+
# The last n entries as formatted lines, oldest first.
|
|
28
|
+
def lines: (Integer count) -> Array[String]
|
|
29
|
+
end
|
|
30
|
+
end
|