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
data/capsium.gemspec
CHANGED
|
@@ -33,12 +33,15 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
|
|
34
34
|
spec.required_ruby_version = ">= 3.2.0"
|
|
35
35
|
|
|
36
|
+
spec.add_dependency "bcrypt"
|
|
36
37
|
spec.add_dependency "csv"
|
|
38
|
+
spec.add_dependency "graphql"
|
|
37
39
|
spec.add_dependency "json"
|
|
38
40
|
spec.add_dependency "json-schema"
|
|
39
41
|
spec.add_dependency "listen"
|
|
40
42
|
spec.add_dependency "lutaml-model", ">= 0.8", "< 1.0"
|
|
41
43
|
spec.add_dependency "marcel"
|
|
44
|
+
spec.add_dependency "rnp", ">= 1.0"
|
|
42
45
|
spec.add_dependency "rubyzip", ">= 2.3.2", "< 4.0"
|
|
43
46
|
spec.add_dependency "sqlite3"
|
|
44
47
|
spec.add_dependency "thor"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Cli < Thor
|
|
5
|
+
# Output formatting helpers for CLI subcommands.
|
|
6
|
+
module Formatting
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def format_result(result)
|
|
10
|
+
line = "#{result.ok? ? 'PASS' : 'FAIL'} #{result.name}"
|
|
11
|
+
return line if result.messages.empty?
|
|
12
|
+
|
|
13
|
+
"#{line}: #{result.messages.join('; ')}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The resolved dependency tree (ARCHITECTURE.md section 4a): one
|
|
17
|
+
# line per dependency showing the declared GUID and range, the
|
|
18
|
+
# resolved version and the store .cap it resolved to, nested.
|
|
19
|
+
def print_dependency_tree(package, indent = "")
|
|
20
|
+
package.resolved_dependencies.each do |dep|
|
|
21
|
+
puts "#{indent}- #{dep.guid} (#{dep.range}) => #{dep.version} [#{dep.path}]"
|
|
22
|
+
print_dependency_tree(dep.package, "#{indent} ")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/capsium/cli/package.rb
CHANGED
|
@@ -6,14 +6,17 @@ module Capsium
|
|
|
6
6
|
class Cli
|
|
7
7
|
class Package < Thor
|
|
8
8
|
extend ThorExt::Start
|
|
9
|
+
include Formatting
|
|
9
10
|
|
|
10
11
|
desc "info PACKAGE_PATH", "Display information about the package"
|
|
12
|
+
option :store, type: :string, desc: "Package store directory (default: CAPSIUM_STORE)"
|
|
11
13
|
|
|
12
14
|
def info(path_to_package)
|
|
13
|
-
package = Capsium::Package.new(path_to_package)
|
|
15
|
+
package = Capsium::Package.new(path_to_package, store: options[:store])
|
|
14
16
|
puts "Package Path: #{package.path}"
|
|
15
17
|
puts "Routes: #{package.routes.to_hash}"
|
|
16
18
|
puts "Manifest: #{package.manifest.to_hash}"
|
|
19
|
+
print_dependency_tree(package)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
desc "manifest PATH_TO_PACKAGE",
|
|
@@ -47,9 +50,14 @@ module Capsium
|
|
|
47
50
|
|
|
48
51
|
desc "pack PATH_TO_PACKAGE_FOLDER", "Package the files into the package"
|
|
49
52
|
option :force, type: :boolean, default: false, aliases: "-f"
|
|
53
|
+
option :bundle_deps, type: :boolean, default: false, aliases: "--bundle",
|
|
54
|
+
desc: "Embed the resolved dependencies under packages/ " \
|
|
55
|
+
"so the .cap activates with no store or registry"
|
|
56
|
+
option :store, type: :string, desc: "Package store directory (default: CAPSIUM_STORE)"
|
|
57
|
+
option :registry, type: :string, desc: "Registry reference (default: CAPSIUM_REGISTRY)"
|
|
50
58
|
|
|
51
59
|
def pack(path_to_package)
|
|
52
|
-
package = Capsium::Package.new(path_to_package)
|
|
60
|
+
package = Capsium::Package.new(path_to_package, store: options[:store])
|
|
53
61
|
packager = Capsium::Packager.new
|
|
54
62
|
packager.pack(package, options)
|
|
55
63
|
end
|
|
@@ -63,6 +71,18 @@ module Capsium
|
|
|
63
71
|
puts "Package unpacked to: #{destination}"
|
|
64
72
|
end
|
|
65
73
|
|
|
74
|
+
desc "push PACKAGE_FILE", "Push a .cap into a static registry directory"
|
|
75
|
+
option :registry, type: :string,
|
|
76
|
+
desc: "Registry directory (default: CAPSIUM_REGISTRY)"
|
|
77
|
+
|
|
78
|
+
def push(path_to_package)
|
|
79
|
+
registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
|
|
80
|
+
entry = registry.push(path_to_package)
|
|
81
|
+
puts "Pushed #{entry.name} #{entry.version} to #{registry.location}"
|
|
82
|
+
rescue Capsium::Registry::RegistryError => e
|
|
83
|
+
raise Thor::Error, e.message
|
|
84
|
+
end
|
|
85
|
+
|
|
66
86
|
desc "validate PACKAGE_PATH", "Validate a package directory or .cap file"
|
|
67
87
|
|
|
68
88
|
def validate(path_to_package)
|
|
@@ -73,13 +93,92 @@ module Capsium
|
|
|
73
93
|
raise Thor::Error, "Package validation failed"
|
|
74
94
|
end
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
desc "sign PACKAGE_PATH --key KEY [--cert CERT.pem] [--openpgp]",
|
|
97
|
+
"Sign the package (RSA-SHA256/X.509, or OpenPGP) into security.json"
|
|
98
|
+
option :key, type: :string, required: true,
|
|
99
|
+
desc: "RSA private key PEM (min 2048 bits); OpenPGP secret key with --openpgp"
|
|
100
|
+
option :cert, type: :string, desc: "X.509 certificate PEM (must match the key)"
|
|
101
|
+
option :openpgp, type: :boolean, default: false, desc: "OpenPGP detached signature"
|
|
102
|
+
|
|
103
|
+
def sign(path_to_package)
|
|
104
|
+
if options[:openpgp] && options[:cert]
|
|
105
|
+
raise Thor::Error, "--cert is only used with X.509 signing"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if options[:openpgp]
|
|
109
|
+
Capsium::Package::OpenPgpSigner.sign_package(path_to_package, options[:key])
|
|
110
|
+
else
|
|
111
|
+
Capsium::Package::Signer.sign_package(path_to_package, options[:key], options[:cert])
|
|
112
|
+
end
|
|
113
|
+
puts "Package signed: #{path_to_package}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "verify-signature PACKAGE_PATH [--cert CERT-or-PUB] [--openpgp]",
|
|
117
|
+
"Verify the declared digital signature (auto-detected from security.json)"
|
|
118
|
+
option :cert, type: :string, desc: "certificate or public key (default: embedded)"
|
|
119
|
+
option :openpgp, type: :boolean, default: false, desc: "verify as OpenPGP"
|
|
77
120
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
121
|
+
def verify_signature(path_to_package)
|
|
122
|
+
verifier = options[:openpgp] ? Capsium::Package::OpenPgpSigner : Capsium::Package::Signer
|
|
123
|
+
unless verifier.verify_package(path_to_package, options[:cert])
|
|
124
|
+
raise Thor::Error, "Signature verification failed: #{path_to_package}"
|
|
125
|
+
end
|
|
81
126
|
|
|
82
|
-
"
|
|
127
|
+
puts "Signature valid: #{path_to_package}"
|
|
128
|
+
rescue Capsium::Package::Signer::SignatureError => e
|
|
129
|
+
raise Thor::Error, e.message
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
desc "encrypt PACKAGE_PATH -o OUT.cap [--public-key PUB.pem | --openpgp --recipient PUB]",
|
|
133
|
+
"Encrypt a package (AES-256-GCM; RSA or OpenPGP key management)"
|
|
134
|
+
option :public_key, type: :string, desc: "recipient RSA public key or X.509 certificate PEM"
|
|
135
|
+
option :recipient, type: :string, desc: "recipient OpenPGP public key (with --openpgp)"
|
|
136
|
+
option :openpgp, type: :boolean, default: false, desc: "encrypt for an OpenPGP recipient"
|
|
137
|
+
option :output, type: :string, required: true, aliases: "-o",
|
|
138
|
+
desc: "Output path for the encrypted .cap"
|
|
139
|
+
|
|
140
|
+
def encrypt(path_to_package)
|
|
141
|
+
key = options[:public_key] || options[:recipient]
|
|
142
|
+
raise Thor::Error, "encrypt requires --public-key or --openpgp --recipient" if key.nil?
|
|
143
|
+
|
|
144
|
+
cipher = options[:openpgp] ? Capsium::Package::OpenPgpCipher.new : Capsium::Package::Cipher.new
|
|
145
|
+
cipher.encrypt(path_to_package, key, options[:output])
|
|
146
|
+
puts "Package encrypted: #{options[:output]}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
desc "decrypt PACKAGE_PATH [--private-key PRIV.pem | --openpgp --key SEC.asc] [-o OUT.cap]",
|
|
150
|
+
"Decrypt an encrypted package (key management auto-detected from the envelope)"
|
|
151
|
+
option :private_key, type: :string, desc: "recipient RSA private key PEM"
|
|
152
|
+
option :key, type: :string, desc: "recipient OpenPGP secret key"
|
|
153
|
+
option :openpgp, type: :boolean, default: false, desc: "decrypt with an OpenPGP key"
|
|
154
|
+
option :output, type: :string, aliases: "-o",
|
|
155
|
+
desc: "Output path (default: <name>-decrypted.cap)"
|
|
156
|
+
|
|
157
|
+
def decrypt(path_to_package)
|
|
158
|
+
key = options[:private_key] || options[:key]
|
|
159
|
+
raise Thor::Error, "decrypt requires --private-key (RSA) or --key (OpenPGP)" if key.nil?
|
|
160
|
+
|
|
161
|
+
output = options[:output] || "#{File.basename(path_to_package, '.cap')}-decrypted.cap"
|
|
162
|
+
cipher = if options[:openpgp]
|
|
163
|
+
Capsium::Package::OpenPgpCipher.new
|
|
164
|
+
else
|
|
165
|
+
Capsium::Package::Cipher.for_encrypted(path_to_package)
|
|
166
|
+
end
|
|
167
|
+
cipher.decrypt(path_to_package, key, output)
|
|
168
|
+
puts "Package decrypted: #{output}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
desc "test PACKAGE_PATH", "Run the package's tests/*.yaml suite (05x-testing DSL)"
|
|
172
|
+
|
|
173
|
+
def test(path_to_package)
|
|
174
|
+
package = Capsium::Package.new(path_to_package)
|
|
175
|
+
report = Capsium::Package::Testing::TestSuite.new(package).run
|
|
176
|
+
report.results.each { |result| puts format_result(result) }
|
|
177
|
+
if report.results.empty?
|
|
178
|
+
puts "No tests found (#{Capsium::Package::Testing::TestSuite::TESTS_DIR}/*.yaml)"
|
|
179
|
+
end
|
|
180
|
+
puts report.summary
|
|
181
|
+
raise Thor::Error, "Package tests failed" unless report.ok?
|
|
83
182
|
end
|
|
84
183
|
end
|
|
85
184
|
end
|
data/lib/capsium/cli/reactor.rb
CHANGED
|
@@ -5,20 +5,134 @@ module Capsium
|
|
|
5
5
|
class Reactor < Thor
|
|
6
6
|
extend ThorExt::Start
|
|
7
7
|
|
|
8
|
-
desc "serve PACKAGE_PATH",
|
|
9
|
-
"Start the Capsium reactor to serve
|
|
8
|
+
desc "serve PACKAGE_PATH ...",
|
|
9
|
+
"Start the Capsium reactor to serve one or more packages " \
|
|
10
|
+
"(local packages, or capsium:// GUIDs installed from a registry)"
|
|
10
11
|
option :port, type: :numeric, default: Capsium::Reactor::DEFAULT_PORT
|
|
11
12
|
option :do_not_listen, type: :boolean, default: false
|
|
13
|
+
option :store, type: :string,
|
|
14
|
+
desc: "Package store directory for dependency " \
|
|
15
|
+
"resolution (default: CAPSIUM_STORE)"
|
|
16
|
+
option :deploy, type: :string,
|
|
17
|
+
desc: "deploy.json with reactor-side secrets " \
|
|
18
|
+
"(default: CAPSIUM_DEPLOY)"
|
|
19
|
+
option :registry, type: :string,
|
|
20
|
+
desc: "Registry directory or https base URL for " \
|
|
21
|
+
"capsium:// GUIDs and dependency fallback " \
|
|
22
|
+
"(default: CAPSIUM_REGISTRY)"
|
|
23
|
+
option :constraint, type: :string, default: "*",
|
|
24
|
+
desc: "Semver constraint for a capsium:// GUID"
|
|
25
|
+
option :mount, type: :array,
|
|
26
|
+
desc: "Mount a source at a URL prefix, PATH=SOURCE " \
|
|
27
|
+
"(repeatable; sources without a prefix default " \
|
|
28
|
+
"to / for the first and /<name>/ for the rest)"
|
|
29
|
+
option :config, type: :string,
|
|
30
|
+
desc: "JSON mount config: " \
|
|
31
|
+
'{"mounts": [{"path": "/", "source": "...", "store": "..."}]}'
|
|
32
|
+
option :workdir, type: :string,
|
|
33
|
+
desc: "Reactor work directory for writable overlays " \
|
|
34
|
+
"and saved packages (default: a temporary directory)"
|
|
12
35
|
|
|
13
|
-
|
|
36
|
+
# Thor array options are last-wins when the flag repeats; collect
|
|
37
|
+
# every --mount value (both "--mount V" and "--mount=V" forms)
|
|
38
|
+
# and re-emit one trailing occurrence so repetition accumulates.
|
|
39
|
+
# (Subcommands are dispatched in-process, so this hooks dispatch,
|
|
40
|
+
# not start; merging is idempotent for the direct-start flow.)
|
|
41
|
+
def self.dispatch(meth, given_args, given_opts, config)
|
|
42
|
+
super(meth, merge_mount_options(given_args),
|
|
43
|
+
given_opts && merge_mount_options(given_opts), config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.merge_mount_options(args)
|
|
47
|
+
values = []
|
|
48
|
+
rest = []
|
|
49
|
+
index = 0
|
|
50
|
+
while index < args.length
|
|
51
|
+
arg = args[index]
|
|
52
|
+
if arg == "--mount"
|
|
53
|
+
index += 1
|
|
54
|
+
while index < args.length && !args[index].start_with?("-")
|
|
55
|
+
values << args[index]
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
elsif arg.start_with?("--mount=")
|
|
59
|
+
values << arg.split("=", 2).last
|
|
60
|
+
index += 1
|
|
61
|
+
else
|
|
62
|
+
rest << arg
|
|
63
|
+
index += 1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
values.empty? ? args : rest + ["--mount"] + values
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def serve(*sources)
|
|
70
|
+
entries = mount_entries(sources)
|
|
71
|
+
if entries.empty?
|
|
72
|
+
raise Thor::Error, "no package source given (positional " \
|
|
73
|
+
"arguments, --mount or --config)"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
mounts = Capsium::Reactor::Mount.build(
|
|
77
|
+
entries, store: options[:store], registry: options[:registry]
|
|
78
|
+
)
|
|
14
79
|
reactor = Capsium::Reactor.new(
|
|
15
|
-
|
|
80
|
+
mounts: mounts,
|
|
16
81
|
port: options[:port],
|
|
17
|
-
do_not_listen: options[:do_not_listen]
|
|
82
|
+
do_not_listen: options[:do_not_listen],
|
|
83
|
+
store: options[:store],
|
|
84
|
+
deploy: options[:deploy],
|
|
85
|
+
registry: options[:registry],
|
|
86
|
+
workdir: options[:workdir]
|
|
18
87
|
)
|
|
19
88
|
reactor.serve
|
|
89
|
+
rescue Capsium::Error => e
|
|
90
|
+
raise Thor::Error, e.message
|
|
20
91
|
ensure
|
|
21
|
-
reactor&.
|
|
92
|
+
reactor&.cleanup
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# The combined mount entries from --config, --mount and the
|
|
98
|
+
# positional sources (in that order), with capsium:// GUIDs
|
|
99
|
+
# installed from the registry into the store.
|
|
100
|
+
def mount_entries(sources)
|
|
101
|
+
entries = []
|
|
102
|
+
entries.concat(Capsium::Reactor::Mount.config_entries(options[:config])) if options[:config]
|
|
103
|
+
entries.concat(Array(options[:mount]).map do |spec|
|
|
104
|
+
Capsium::Reactor::Mount.parse_spec(spec)
|
|
105
|
+
end)
|
|
106
|
+
entries.concat(sources.map do |source|
|
|
107
|
+
Capsium::Reactor::Mount::Entry.new(path: nil, source: source, store: nil)
|
|
108
|
+
end)
|
|
109
|
+
entries.map do |entry|
|
|
110
|
+
entry.with(source: resolve_source(entry.source))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve_source(source)
|
|
115
|
+
return source unless source.start_with?("capsium://")
|
|
116
|
+
|
|
117
|
+
install_from_registry(source)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Install-then-serve for capsium:// GUIDs: resolve and install
|
|
121
|
+
# from the registry into the package store, returning the
|
|
122
|
+
# installed store .cap path.
|
|
123
|
+
def install_from_registry(guid)
|
|
124
|
+
registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
|
|
125
|
+
store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
|
|
126
|
+
if store.nil? || store.empty?
|
|
127
|
+
raise Thor::Error,
|
|
128
|
+
"no package store configured (pass --store or set CAPSIUM_STORE)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
path = registry.install(guid, options[:constraint], store: store)
|
|
132
|
+
puts "Installed #{guid} to #{path}"
|
|
133
|
+
path
|
|
134
|
+
rescue Capsium::Registry::RegistryError => e
|
|
135
|
+
raise Thor::Error, e.message
|
|
22
136
|
end
|
|
23
137
|
end
|
|
24
138
|
end
|
data/lib/capsium/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Capsium
|
|
|
7
7
|
extend ThorExt::Start
|
|
8
8
|
|
|
9
9
|
autoload :Convert, "capsium/cli/convert"
|
|
10
|
+
autoload :Formatting, "capsium/cli/formatting"
|
|
10
11
|
autoload :Package, "capsium/cli/package"
|
|
11
12
|
autoload :Reactor, "capsium/cli/reactor"
|
|
12
13
|
|
|
@@ -18,5 +19,34 @@ module Capsium
|
|
|
18
19
|
|
|
19
20
|
desc "convert SUBCOMMAND ...ARGS", "Convert from another format"
|
|
20
21
|
subcommand "convert", Capsium::Cli::Convert
|
|
22
|
+
|
|
23
|
+
desc "install GUID", "Install a package from a registry into the package store"
|
|
24
|
+
option :constraint, type: :string, default: "*",
|
|
25
|
+
desc: "Semver constraint the installed version must satisfy"
|
|
26
|
+
option :registry, type: :string,
|
|
27
|
+
desc: "Registry directory or https base URL " \
|
|
28
|
+
"(default: CAPSIUM_REGISTRY)"
|
|
29
|
+
option :store, type: :string,
|
|
30
|
+
desc: "Package store directory (default: CAPSIUM_STORE)"
|
|
31
|
+
|
|
32
|
+
def install(guid)
|
|
33
|
+
registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
|
|
34
|
+
path = registry.install(guid, options[:constraint], store: store_dir!)
|
|
35
|
+
puts "Installed #{guid} to #{path}"
|
|
36
|
+
rescue Capsium::Registry::RegistryError => e
|
|
37
|
+
raise Thor::Error, e.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# The store directory for install-like commands: --store or
|
|
43
|
+
# CAPSIUM_STORE, otherwise a typed CLI error.
|
|
44
|
+
def store_dir!
|
|
45
|
+
store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
|
|
46
|
+
return store unless store.nil? || store.empty?
|
|
47
|
+
|
|
48
|
+
raise Thor::Error,
|
|
49
|
+
"no package store configured (pass --store or set CAPSIUM_STORE)"
|
|
50
|
+
end
|
|
21
51
|
end
|
|
22
52
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
# A small thread-safe ring buffer of timestamped log entries with a
|
|
7
|
+
# fixed capacity: when full, the oldest entry is dropped. The reactor
|
|
8
|
+
# records key serving events here and exposes the most recent lines
|
|
9
|
+
# through the /package/:id/logs introspection endpoint.
|
|
10
|
+
class LogBuffer
|
|
11
|
+
DEFAULT_CAPACITY = 500
|
|
12
|
+
|
|
13
|
+
# One buffer entry: a UTC timestamp and a message.
|
|
14
|
+
Entry = Data.define(:timestamp, :message) do
|
|
15
|
+
# "2026-07-19T15:00:00Z message"
|
|
16
|
+
def line = "#{timestamp.utc.iso8601} #{message}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :capacity
|
|
20
|
+
|
|
21
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
22
|
+
raise ArgumentError, "capacity must be at least 1" if capacity < 1
|
|
23
|
+
|
|
24
|
+
@capacity = capacity
|
|
25
|
+
@entries = []
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(message, timestamp: Time.now)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@entries.shift if @entries.size >= @capacity
|
|
32
|
+
@entries << Entry.new(timestamp: timestamp, message: message)
|
|
33
|
+
end
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The last count entries, oldest first.
|
|
38
|
+
def last(count)
|
|
39
|
+
@mutex.synchronize { @entries.last(count) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The last count entries as formatted lines, oldest first.
|
|
43
|
+
def lines(count)
|
|
44
|
+
last(count).map(&:line)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# Loads a package's authentication.json (ARCHITECTURE.md section 4b).
|
|
8
|
+
class Authentication
|
|
9
|
+
extend Forwardable
|
|
10
|
+
|
|
11
|
+
attr_reader :path, :config
|
|
12
|
+
|
|
13
|
+
def_delegators :@config, :to_json, :to_hash
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
@config = File.exist?(path) ? AuthenticationConfig.from_json(File.read(path)) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def present?
|
|
21
|
+
!@config.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def basic_auth
|
|
25
|
+
@config&.authentication&.basic_auth
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def oauth2
|
|
29
|
+
@config&.authentication&.oauth2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether any authentication method is enabled.
|
|
33
|
+
def enabled?
|
|
34
|
+
!!(basic_auth&.enabled? || oauth2&.enabled?)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# The "basicAuth" object of authentication.json (ARCHITECTURE.md
|
|
9
|
+
# section 4b): Apache-style Basic authentication verified against an
|
|
10
|
+
# htpasswd file. "passwdFile" is a package-relative path (password
|
|
11
|
+
# hashes, not plaintext secrets, so it may ship in the package);
|
|
12
|
+
# deploy.json may override it with a reactor-side file.
|
|
13
|
+
class BasicAuthConfig < Lutaml::Model::Serializable
|
|
14
|
+
attribute :enabled, :boolean, default: false
|
|
15
|
+
attribute :passwd_file, :string
|
|
16
|
+
attribute :realm, :string, default: "capsium"
|
|
17
|
+
|
|
18
|
+
json do
|
|
19
|
+
map :enabled, to: :enabled
|
|
20
|
+
map "passwdFile", to: :passwd_file
|
|
21
|
+
map :realm, to: :realm
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
!!enabled
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The "oauth2" object of authentication.json: an OAuth2
|
|
30
|
+
# authorization-code flow. The client secret is NEVER read from the
|
|
31
|
+
# package — it comes from deploy.json or reactor configuration.
|
|
32
|
+
class OAuth2Config < Lutaml::Model::Serializable
|
|
33
|
+
attribute :enabled, :boolean, default: false
|
|
34
|
+
attribute :provider, :string
|
|
35
|
+
attribute :client_id, :string
|
|
36
|
+
attribute :authorization_url, :string
|
|
37
|
+
attribute :token_url, :string
|
|
38
|
+
attribute :userinfo_url, :string
|
|
39
|
+
attribute :redirect_path, :string, default: "/auth/callback"
|
|
40
|
+
attribute :scopes, :string, collection: true, default: []
|
|
41
|
+
|
|
42
|
+
json do
|
|
43
|
+
map :enabled, to: :enabled
|
|
44
|
+
map :provider, to: :provider
|
|
45
|
+
map "clientId", to: :client_id
|
|
46
|
+
map "authorizationUrl", to: :authorization_url
|
|
47
|
+
map "tokenUrl", to: :token_url
|
|
48
|
+
map "userinfoUrl", to: :userinfo_url
|
|
49
|
+
map "redirectPath", to: :redirect_path
|
|
50
|
+
map :scopes, to: :scopes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
!!enabled
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The "authentication" object of authentication.json.
|
|
59
|
+
class AuthenticationData < Lutaml::Model::Serializable
|
|
60
|
+
attribute :basic_auth, BasicAuthConfig
|
|
61
|
+
attribute :oauth2, OAuth2Config
|
|
62
|
+
|
|
63
|
+
json do
|
|
64
|
+
map "basicAuth", to: :basic_auth
|
|
65
|
+
map :oauth2, to: :oauth2
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Canonical authentication.json model (ARCHITECTURE.md section 4b).
|
|
70
|
+
class AuthenticationConfig < Lutaml::Model::Serializable
|
|
71
|
+
attribute :authentication, AuthenticationData
|
|
72
|
+
|
|
73
|
+
json do
|
|
74
|
+
map :authentication, to: :authentication
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "zip"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Package
|
|
10
|
+
# Bundled dependencies of an encapsulated (self-contained) package
|
|
11
|
+
# (ARCHITECTURE.md section 4a extension): dependency .cap files
|
|
12
|
+
# embedded under packages/ with a packages/index.json manifest
|
|
13
|
+
# mapping each dependency GUID to its file, version and SHA-256:
|
|
14
|
+
#
|
|
15
|
+
# packages/
|
|
16
|
+
# index.json # guid => {"file", "version", "sha256"}
|
|
17
|
+
# <name>-<version>.cap # one per declared dependency
|
|
18
|
+
#
|
|
19
|
+
# One-level bundling policy: only the dependencies DECLARED in the
|
|
20
|
+
# package's own metadata.dependencies are bundled; to make a whole
|
|
21
|
+
# tree self-contained the parent must declare the transitive
|
|
22
|
+
# closure. At load time bundled dependencies resolve FIRST, before
|
|
23
|
+
# the store -> registry chain, and a package's bundle is passed down
|
|
24
|
+
# to its dependencies so their re-declared dependencies resolve from
|
|
25
|
+
# it too (recursive-capable).
|
|
26
|
+
#
|
|
27
|
+
# Security: bundled .cap files are covered by the parent package's
|
|
28
|
+
# security.json checksums like every other file; the manifest
|
|
29
|
+
# SHA-256 is re-verified when a bundled dependency is resolved, and
|
|
30
|
+
# each bundled package's own security.json is verified when it is
|
|
31
|
+
# loaded (the usual Capsium::Package activation checks).
|
|
32
|
+
class Bundle
|
|
33
|
+
DIRECTORY = "packages"
|
|
34
|
+
INDEX_FILE = "packages/index.json"
|
|
35
|
+
|
|
36
|
+
# One bundled dependency: the absolute .cap path, its version and
|
|
37
|
+
# the SHA-256 recorded in the manifest.
|
|
38
|
+
Entry = Data.define(:file, :version, :sha256)
|
|
39
|
+
|
|
40
|
+
attr_reader :entries
|
|
41
|
+
|
|
42
|
+
def initialize(package_path, entries = nil)
|
|
43
|
+
@entries = entries || load_entries(package_path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Embeds the resolved dependency .cap files (guid => .cap path
|
|
47
|
+
# pairs, in declaration order) into the package directory at
|
|
48
|
+
# package_path: copies each under packages/ and writes the
|
|
49
|
+
# packages/index.json manifest. Returns the manifest hash.
|
|
50
|
+
def self.write(package_path, resolutions)
|
|
51
|
+
FileUtils.mkdir_p(File.join(package_path, DIRECTORY))
|
|
52
|
+
index = resolutions.to_h do |guid, cap_path|
|
|
53
|
+
file = File.join(DIRECTORY, File.basename(cap_path))
|
|
54
|
+
FileUtils.cp(cap_path, File.join(package_path, file))
|
|
55
|
+
[guid, { "file" => file,
|
|
56
|
+
"version" => cap_version(cap_path),
|
|
57
|
+
"sha256" => Digest::SHA256.file(cap_path).hexdigest }]
|
|
58
|
+
end
|
|
59
|
+
File.write(File.join(package_path, INDEX_FILE), JSON.pretty_generate(index))
|
|
60
|
+
index
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.cap_version(cap_path)
|
|
64
|
+
Zip::File.open(cap_path) do |zip|
|
|
65
|
+
entry = zip.find_entry(METADATA_FILE)
|
|
66
|
+
raise DependencyError, "no #{METADATA_FILE} in #{cap_path}" unless entry
|
|
67
|
+
|
|
68
|
+
JSON.parse(entry.get_input_stream.read)["version"].to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :cap_version
|
|
72
|
+
|
|
73
|
+
# Whether this bundle embeds any dependency.
|
|
74
|
+
def present? = !@entries.empty?
|
|
75
|
+
|
|
76
|
+
# Merges an inherited (ancestor) bundle underneath this one; own
|
|
77
|
+
# entries win on GUID conflicts. Entries carry absolute paths, so
|
|
78
|
+
# merged entries keep pointing into their source packages.
|
|
79
|
+
def merged_with(other)
|
|
80
|
+
return self if other.nil? || !other.present?
|
|
81
|
+
return other unless present?
|
|
82
|
+
|
|
83
|
+
self.class.new(nil, other.entries.merge(@entries))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The absolute .cap path for a bundled dependency, or nil when the
|
|
87
|
+
# GUID is not bundled (the caller falls back to the store ->
|
|
88
|
+
# registry chain). Raises CircularDependencyError when the GUID is
|
|
89
|
+
# already being resolved up-chain, UnsatisfiableDependencyError
|
|
90
|
+
# when the bundled version does not satisfy the declared range and
|
|
91
|
+
# Security::IntegrityError when the bundled file fails the
|
|
92
|
+
# manifest SHA-256.
|
|
93
|
+
def resolve_path(guid, range, chain:)
|
|
94
|
+
entry = @entries[guid]
|
|
95
|
+
return nil if entry.nil?
|
|
96
|
+
|
|
97
|
+
if chain.include?(guid)
|
|
98
|
+
raise CircularDependencyError,
|
|
99
|
+
"circular dependency: #{(chain + [guid]).join(' -> ')}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless VersionRange.parse(range).satisfied_by?(Version.parse(entry.version))
|
|
103
|
+
raise UnsatisfiableDependencyError,
|
|
104
|
+
"bundled #{guid} #{entry.version} does not satisfy '#{range}'"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
verify_sha256(guid, entry)
|
|
108
|
+
entry.file
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def verify_sha256(guid, entry)
|
|
114
|
+
actual = Digest::SHA256.file(entry.file).hexdigest
|
|
115
|
+
return if actual == entry.sha256
|
|
116
|
+
|
|
117
|
+
raise Security::IntegrityError,
|
|
118
|
+
"bundled package failed manifest SHA-256 verification: " \
|
|
119
|
+
"#{guid} (#{entry.file})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def load_entries(package_path)
|
|
123
|
+
index_path = File.join(package_path, INDEX_FILE)
|
|
124
|
+
return {} unless File.file?(index_path)
|
|
125
|
+
|
|
126
|
+
JSON.parse(File.read(index_path)).to_h do |guid, entry|
|
|
127
|
+
[guid, Entry.new(file: File.join(package_path, entry.fetch("file")),
|
|
128
|
+
version: entry.fetch("version"),
|
|
129
|
+
sha256: entry.fetch("sha256"))]
|
|
130
|
+
end
|
|
131
|
+
rescue JSON::ParserError, KeyError => e
|
|
132
|
+
raise DependencyError, "invalid #{INDEX_FILE}: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|