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
|
@@ -26,6 +26,7 @@ module Capsium
|
|
|
26
26
|
|
|
27
27
|
def run
|
|
28
28
|
Dir.mktmpdir do |dir|
|
|
29
|
+
@workdir = dir
|
|
29
30
|
@package_path = prepare(dir)
|
|
30
31
|
[metadata_check, manifest_check, routes_check,
|
|
31
32
|
storage_check, security_check, content_check]
|
|
@@ -113,15 +114,39 @@ module Capsium
|
|
|
113
114
|
|
|
114
115
|
# The merged content view (ARCHITECTURE.md section 5a) over the
|
|
115
116
|
# package being validated; layered and tombstoned resources resolve
|
|
116
|
-
# the same way the reactor resolves them.
|
|
117
|
+
# the same way the reactor resolves them. For encapsulated
|
|
118
|
+
# packages the bundled dependencies (packages/, recursively) act
|
|
119
|
+
# as read-only exported layers exactly as at activation, so
|
|
120
|
+
# fallthrough routes referencing dependency content validate.
|
|
117
121
|
def merged_view
|
|
118
122
|
@merged_view ||= MergedView.new(
|
|
119
123
|
@package_path,
|
|
120
124
|
storage: Storage.new(metadata_path(STORAGE_FILE)),
|
|
121
|
-
manifest: Manifest.new(metadata_path(MANIFEST_FILE))
|
|
125
|
+
manifest: Manifest.new(metadata_path(MANIFEST_FILE)),
|
|
126
|
+
dependency_views: dependency_views(@package_path)
|
|
122
127
|
)
|
|
123
128
|
end
|
|
124
129
|
|
|
130
|
+
# The [guid, exported MergedView] layers of the dependencies
|
|
131
|
+
# bundled under the package directory's packages/ (empty when the
|
|
132
|
+
# package embeds no bundle).
|
|
133
|
+
def dependency_views(package_path)
|
|
134
|
+
bundle = Bundle.new(package_path)
|
|
135
|
+
return [] unless bundle.present?
|
|
136
|
+
|
|
137
|
+
bundle.entries.map do |guid, entry|
|
|
138
|
+
dependency_dir = File.join(@workdir, "bundles", guid.gsub(/[^A-Za-z0-9.-]/, "_"))
|
|
139
|
+
Packager.new.unpack(entry.file, dependency_dir)
|
|
140
|
+
[guid, MergedView.new(
|
|
141
|
+
dependency_dir,
|
|
142
|
+
storage: Storage.new(File.join(dependency_dir, STORAGE_FILE)),
|
|
143
|
+
manifest: Manifest.new(File.join(dependency_dir, MANIFEST_FILE)),
|
|
144
|
+
dependency_views: dependency_views(dependency_dir),
|
|
145
|
+
exported_only: true
|
|
146
|
+
)]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
125
150
|
def security_check
|
|
126
151
|
security = Security.new(metadata_path(SECURITY_FILE))
|
|
127
152
|
return result("security", []) unless security.present?
|
|
@@ -19,13 +19,15 @@ module Capsium
|
|
|
19
19
|
# Whether security.json declares a digital signature for this package.
|
|
20
20
|
def signed? = @security.signed?
|
|
21
21
|
|
|
22
|
-
# Verifies the declared digital signature
|
|
23
|
-
# checksum-covered payload
|
|
24
|
-
# declared
|
|
25
|
-
|
|
22
|
+
# Verifies the declared digital signature against the
|
|
23
|
+
# checksum-covered payload, through the signer matching the
|
|
24
|
+
# declared certificateType (OpenPgpSigner for OpenPGP, the
|
|
25
|
+
# RSA-SHA256 Signer otherwise). True when the package is unsigned
|
|
26
|
+
# (nothing declared) or the signature verifies; false on mismatch.
|
|
27
|
+
def verify_signature = !signed? || Signer.signer_class_for(@path).new(@path).verify
|
|
26
28
|
|
|
27
29
|
def verify_signature!
|
|
28
|
-
Signer.new(@path).verify! if signed?
|
|
30
|
+
Signer.signer_class_for(@path).new(@path).verify! if signed?
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
end
|
data/lib/capsium/package.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Capsium
|
|
|
13
13
|
autoload :AuthenticationConfig, "capsium/package/authentication_config"
|
|
14
14
|
autoload :AuthenticationData, "capsium/package/authentication_config"
|
|
15
15
|
autoload :BasicAuthConfig, "capsium/package/authentication_config"
|
|
16
|
+
autoload :Bundle, "capsium/package/bundle"
|
|
16
17
|
autoload :Dataset, "capsium/package/dataset"
|
|
17
18
|
autoload :DatasetConfig, "capsium/package/storage_config"
|
|
18
19
|
autoload :DependencyError, "capsium/package/dependency_resolver"
|
|
@@ -30,6 +31,9 @@ module Capsium
|
|
|
30
31
|
autoload :Metadata, "capsium/package/metadata"
|
|
31
32
|
autoload :MetadataData, "capsium/package/metadata_config"
|
|
32
33
|
autoload :OAuth2Config, "capsium/package/authentication_config"
|
|
34
|
+
autoload :OpenPgp, "capsium/package/open_pgp"
|
|
35
|
+
autoload :OpenPgpCipher, "capsium/package/open_pgp_cipher"
|
|
36
|
+
autoload :OpenPgpSigner, "capsium/package/open_pgp_signer"
|
|
33
37
|
autoload :Preparation, "capsium/package/preparation"
|
|
34
38
|
autoload :Repository, "capsium/package/metadata_config"
|
|
35
39
|
autoload :ResolvedDependency, "capsium/package/dependency_resolver"
|
|
@@ -73,10 +77,16 @@ module Capsium
|
|
|
73
77
|
# Loads a package directory or .cap file. Composite packages
|
|
74
78
|
# (metadata.dependencies, ARCHITECTURE.md section 4a) resolve against
|
|
75
79
|
# the package store given as `store` (directory path or Store) or via
|
|
76
|
-
# CAPSIUM_STORE
|
|
77
|
-
#
|
|
80
|
+
# CAPSIUM_STORE, falling back to the registry given as `registry`
|
|
81
|
+
# (Capsium::Registry or reference) or via CAPSIUM_REGISTRY when the
|
|
82
|
+
# store has no package for a dependency GUID. Dependencies bundled
|
|
83
|
+
# under packages/ (encapsulated packages, Capsium::Package::Bundle)
|
|
84
|
+
# resolve first, without any store or registry. `dependency_chain`
|
|
85
|
+
# is internal: the ancestor GUIDs used for circular-dependency
|
|
86
|
+
# detection during recursive resolution. `bundle` is internal too:
|
|
87
|
+
# the ancestor bundle serving re-declared transitive dependencies.
|
|
78
88
|
def initialize(path, load_type: nil, decryption_key: nil, store: nil,
|
|
79
|
-
dependency_chain: [])
|
|
89
|
+
registry: nil, dependency_chain: [], bundle: nil)
|
|
80
90
|
@decryption_key = decryption_key
|
|
81
91
|
@original_path = Pathname.new(path).expand_path
|
|
82
92
|
@path = prepare_package(@original_path).to_s
|
|
@@ -84,7 +94,7 @@ module Capsium
|
|
|
84
94
|
create_package_structure
|
|
85
95
|
load_package
|
|
86
96
|
@name = metadata.name
|
|
87
|
-
@resolved_dependencies = resolve_dependencies(store, dependency_chain)
|
|
97
|
+
@resolved_dependencies = resolve_dependencies(store, registry, dependency_chain, bundle)
|
|
88
98
|
validate_dependency_references!
|
|
89
99
|
verify_integrity!
|
|
90
100
|
verify_signature!
|
|
@@ -119,7 +129,16 @@ module Capsium
|
|
|
119
129
|
# The merged content view across the storage layers (ARCHITECTURE.md
|
|
120
130
|
# section 5a); exported_only gives the dependent-package view (section 4a).
|
|
121
131
|
# Resolved dependencies act as read-only layers below all own layers.
|
|
122
|
-
|
|
132
|
+
# extra_layers stack above everything (the reactor's writable overlay);
|
|
133
|
+
# views with extra layers are built fresh, not memoized.
|
|
134
|
+
def merged_view(exported_only: false, extra_layers: [])
|
|
135
|
+
unless extra_layers.empty?
|
|
136
|
+
return MergedView.new(@path, storage: @storage, manifest: @manifest,
|
|
137
|
+
dependency_views: dependency_views,
|
|
138
|
+
exported_only: exported_only,
|
|
139
|
+
extra_layers: extra_layers)
|
|
140
|
+
end
|
|
141
|
+
|
|
123
142
|
@merged_views ||= {}
|
|
124
143
|
@merged_views[exported_only] ||=
|
|
125
144
|
MergedView.new(@path, storage: @storage, manifest: @manifest,
|
data/lib/capsium/packager.rb
CHANGED
|
@@ -16,31 +16,16 @@ module Capsium
|
|
|
16
16
|
class UnsafeEntryError < Capsium::Error; end
|
|
17
17
|
|
|
18
18
|
DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
|
|
19
|
+
DOT_ENTRIES = [".", ".."].freeze
|
|
19
20
|
|
|
20
21
|
def pack(package, options = {})
|
|
21
|
-
directory = package.path
|
|
22
22
|
output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
|
|
23
|
-
|
|
24
|
-
cap_file_path
|
|
25
|
-
|
|
26
|
-
if File.exist?(cap_file_path) && !options[:force]
|
|
27
|
-
raise FileAlreadyExistsError,
|
|
28
|
-
"Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
|
|
29
|
-
elsif File.exist?(cap_file_path)
|
|
30
|
-
puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
|
|
31
|
-
FileUtils.rm_f(cap_file_path)
|
|
32
|
-
end
|
|
23
|
+
cap_file_path = File.join(File.dirname(package.path), output_file_name)
|
|
24
|
+
check_target(cap_file_path, options)
|
|
33
25
|
|
|
34
26
|
Dir.mktmpdir do |dir|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
new_package = Package.new(dir)
|
|
38
|
-
new_package.solidify
|
|
39
|
-
generate_security(new_package)
|
|
40
|
-
new_cap_file_path = File.join(dir, output_file_name)
|
|
41
|
-
compress_package(new_package, new_cap_file_path)
|
|
42
|
-
puts "Package built at: #{new_cap_file_path}"
|
|
43
|
-
FileUtils.mv(new_cap_file_path, cap_file_path)
|
|
27
|
+
build_cap(package, dir, output_file_name, options)
|
|
28
|
+
FileUtils.mv(File.join(dir, output_file_name), cap_file_path)
|
|
44
29
|
puts "Package created: #{relative_path_current(cap_file_path)}"
|
|
45
30
|
return cap_file_path
|
|
46
31
|
end
|
|
@@ -58,8 +43,9 @@ module Capsium
|
|
|
58
43
|
end
|
|
59
44
|
|
|
60
45
|
def compress_package(package, cap_file)
|
|
61
|
-
entries = Dir
|
|
62
|
-
File.expand_path(file) == File.expand_path(cap_file)
|
|
46
|
+
entries = Dir.glob(File.join(package.path, "**", "**"), File::FNM_DOTMATCH).reject do |file|
|
|
47
|
+
File.expand_path(file) == File.expand_path(cap_file) ||
|
|
48
|
+
DOT_ENTRIES.include?(File.basename(file))
|
|
63
49
|
end
|
|
64
50
|
Zip::File.open(cap_file, create: true) do |zipfile|
|
|
65
51
|
entries.each do |file|
|
|
@@ -99,6 +85,62 @@ module Capsium
|
|
|
99
85
|
|
|
100
86
|
private
|
|
101
87
|
|
|
88
|
+
# Guards the pack target: without :force an existing .cap aborts,
|
|
89
|
+
# with :force it is removed first.
|
|
90
|
+
def check_target(cap_file_path, options)
|
|
91
|
+
if File.exist?(cap_file_path) && !options[:force]
|
|
92
|
+
raise FileAlreadyExistsError,
|
|
93
|
+
"Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
|
|
94
|
+
end
|
|
95
|
+
return unless File.exist?(cap_file_path)
|
|
96
|
+
|
|
97
|
+
puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
|
|
98
|
+
FileUtils.rm_f(cap_file_path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Builds the .cap inside the temporary directory: copy, optional
|
|
102
|
+
# dependency bundling, solidify, security.json, compress.
|
|
103
|
+
def build_cap(package, directory, output_file_name, options)
|
|
104
|
+
FileUtils.cp_r("#{package.path}/.", directory)
|
|
105
|
+
strip_security_artifacts(directory)
|
|
106
|
+
bundle_dependencies(directory, options) if options[:bundle_deps]
|
|
107
|
+
new_package = load_packed_package(directory, options)
|
|
108
|
+
new_package.solidify
|
|
109
|
+
generate_security(new_package)
|
|
110
|
+
built_path = File.join(directory, output_file_name)
|
|
111
|
+
compress_package(new_package, built_path)
|
|
112
|
+
puts "Package built at: #{built_path}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Loads the copied package directory for solidification, with the
|
|
116
|
+
# pack-time store/registry available for dependency resolution.
|
|
117
|
+
def load_packed_package(directory, options)
|
|
118
|
+
Package.new(directory, store: options[:store], registry: options[:registry])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Encapsulated packing (`pack --bundle-deps`): resolves every
|
|
122
|
+
# declared dependency through the store -> registry chain and embeds
|
|
123
|
+
# the resolved .cap files under packages/ (Capsium::Package::Bundle),
|
|
124
|
+
# so the packed package activates with no store or registry. Only
|
|
125
|
+
# the declared dependencies are bundled — the one-level policy: the
|
|
126
|
+
# parent's metadata.dependencies must list the transitive closure.
|
|
127
|
+
# Typed dependency errors (DependencyNotFoundError,
|
|
128
|
+
# UnsatisfiableDependencyError) propagate for unresolvable
|
|
129
|
+
# dependencies.
|
|
130
|
+
def bundle_dependencies(directory, options)
|
|
131
|
+
metadata = Package::Metadata.new(File.join(directory, Package::METADATA_FILE))
|
|
132
|
+
declared = metadata.dependencies
|
|
133
|
+
return if declared.empty?
|
|
134
|
+
|
|
135
|
+
resolver = Package::DependencyResolver.new(
|
|
136
|
+
options[:store] || Package::Store.default, registry: options[:registry]
|
|
137
|
+
)
|
|
138
|
+
resolutions = declared.map do |guid, range|
|
|
139
|
+
[guid, resolver.resolve_path(guid, range, chain: [metadata.guid])]
|
|
140
|
+
end
|
|
141
|
+
Package::Bundle.write(directory, resolutions)
|
|
142
|
+
end
|
|
143
|
+
|
|
102
144
|
# Resolves an entry name against the destination and returns the
|
|
103
145
|
# absolute target path, raising UnsafeEntryError when the entry would
|
|
104
146
|
# escape the destination (zip-slip).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Content writes over the mount's overlay (ARCHITECTURE.md section
|
|
8
|
+
# 5a): PUT creates/overwrites a content file (creating its route on
|
|
9
|
+
# demand), DELETE records a tombstone so the path 404s even when a
|
|
10
|
+
# lower layer holds the file. The immutable package never changes
|
|
11
|
+
# on disk. Text bodies only for v1.
|
|
12
|
+
class ContentApi
|
|
13
|
+
include Responses
|
|
14
|
+
|
|
15
|
+
WRITE_METHODS = %w[PUT DELETE].freeze
|
|
16
|
+
CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
|
|
17
|
+
|
|
18
|
+
def self.write_method?(method)
|
|
19
|
+
WRITE_METHODS.include?(method)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(mount)
|
|
23
|
+
@mount = mount
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def handle(inner_path, request, response)
|
|
27
|
+
return read_only(response) unless @mount.writable?
|
|
28
|
+
|
|
29
|
+
route = @mount.routes.resolve(inner_path)
|
|
30
|
+
return guard_route(route, inner_path, response) if route && !writable_route?(route)
|
|
31
|
+
|
|
32
|
+
case request.request_method
|
|
33
|
+
when "PUT" then put_content(inner_path, route, request, response)
|
|
34
|
+
when "DELETE" then delete_content(inner_path, route, response)
|
|
35
|
+
else respond_method_not_allowed(response)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# A route is writable when it is a plain resource route (or no
|
|
42
|
+
# route at all): dataset paths are the DataApi's, handler routes
|
|
43
|
+
# are not writable, dependency content stays immutable.
|
|
44
|
+
def writable_route?(route)
|
|
45
|
+
route.kind == :resource && !route.dependency_reference?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def guard_route(route, inner_path, response)
|
|
49
|
+
return respond_method_not_allowed(response) if route.kind == :dataset
|
|
50
|
+
|
|
51
|
+
respond_error(response, 400, "cannot write #{route.kind} content: #{inner_path}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def put_content(inner_path, route, request, response)
|
|
55
|
+
relative = content_relative(resource_path(inner_path, route))
|
|
56
|
+
return unsafe_path(inner_path, response) if relative.nil?
|
|
57
|
+
|
|
58
|
+
@mount.overlay.write_content(relative, request.body.to_s)
|
|
59
|
+
resource = "#{Package::CONTENT_DIR}/#{relative}"
|
|
60
|
+
@mount.package.routes.add_route(inner_path, resource) unless route
|
|
61
|
+
respond_json(response, 200, { path: inner_path, resource: resource })
|
|
62
|
+
rescue Overlay::Error => e
|
|
63
|
+
respond_error(response, 400, e.message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Tombstones the served resource: 404 when the path is already
|
|
67
|
+
# tombstoned or nothing (here or in a lower layer) serves it.
|
|
68
|
+
def delete_content(inner_path, route, response)
|
|
69
|
+
relative = content_relative(resource_path(inner_path, route))
|
|
70
|
+
return unsafe_path(inner_path, response) if relative.nil?
|
|
71
|
+
return respond_not_found(response) if @mount.overlay.tombstones.include?(relative)
|
|
72
|
+
unless @mount.merged_view.resolve("#{Package::CONTENT_DIR}/#{relative}")
|
|
73
|
+
return respond_not_found(response)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@mount.overlay.delete_content(relative)
|
|
77
|
+
response.status = 204
|
|
78
|
+
rescue Overlay::Error => e
|
|
79
|
+
respond_error(response, 400, e.message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unsafe_path(inner_path, response)
|
|
83
|
+
respond_error(response, 400, "unsafe content path: #{inner_path}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def read_only(response)
|
|
87
|
+
respond_error(response, 403, "package #{@mount.package.name} is read-only")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The package resource a write addresses: the existing route's
|
|
91
|
+
# resource (overwrite what GET serves), else a content file
|
|
92
|
+
# derived from the URL path (created on demand).
|
|
93
|
+
def resource_path(inner_path, route)
|
|
94
|
+
route ? route.resource : "#{Package::CONTENT_DIR}/#{inner_path.delete_prefix('/')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The content/-relative path for a package resource, nil for
|
|
98
|
+
# unsafe paths (outside content/, dot segments).
|
|
99
|
+
def content_relative(resource_path)
|
|
100
|
+
return nil unless resource_path.start_with?(CONTENT_PREFIX)
|
|
101
|
+
|
|
102
|
+
relative = resource_path.delete_prefix(CONTENT_PREFIX)
|
|
103
|
+
segments = relative.split("/")
|
|
104
|
+
return nil if segments.empty? || segments.any? { |s| s.empty? || s == ".." }
|
|
105
|
+
|
|
106
|
+
relative
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# REST CRUD over a mount's datasets (ARCHITECTURE.md sections 5 and
|
|
8
|
+
# 5a): JSON item append/read/replace/delete against the overlay-
|
|
9
|
+
# merged collection. Writes go only to the mount's writable overlay;
|
|
10
|
+
# the immutable package never changes on disk.
|
|
11
|
+
class DataApi
|
|
12
|
+
include Responses
|
|
13
|
+
|
|
14
|
+
PREFIX = "/api/v1/data/"
|
|
15
|
+
COLLECTION_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)\z}
|
|
16
|
+
ITEM_PATTERN = %r{\A/api/v1/data/(?<dataset>[^/]+)/(?<id>[^/]+)\z}
|
|
17
|
+
|
|
18
|
+
def self.path?(inner_path)
|
|
19
|
+
inner_path.start_with?(PREFIX)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(mount)
|
|
23
|
+
@mount = mount
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The declared dataset route a data path addresses (the collection
|
|
27
|
+
# route, also for item paths), or nil when the package does not
|
|
28
|
+
# route this dataset — undeclared datasets are not served.
|
|
29
|
+
def route_for(inner_path)
|
|
30
|
+
match = COLLECTION_PATTERN.match(inner_path) || ITEM_PATTERN.match(inner_path)
|
|
31
|
+
return nil unless match
|
|
32
|
+
|
|
33
|
+
route = @mount.routes.resolve("#{PREFIX}#{match[:dataset]}")
|
|
34
|
+
route if route&.dataset == match[:dataset]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle(inner_path, request, response)
|
|
38
|
+
route = route_for(inner_path)
|
|
39
|
+
return respond_not_found(response) unless route
|
|
40
|
+
|
|
41
|
+
dataset = @mount.package.storage.dataset(route.dataset)
|
|
42
|
+
return respond_not_found(response) unless dataset
|
|
43
|
+
|
|
44
|
+
collection = COLLECTION_PATTERN.match(inner_path)
|
|
45
|
+
if collection
|
|
46
|
+
handle_collection(dataset, request, response)
|
|
47
|
+
else
|
|
48
|
+
handle_item(dataset, ITEM_PATTERN.match(inner_path)[:id], request, response)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def handle_collection(dataset, request, response)
|
|
55
|
+
case request.request_method
|
|
56
|
+
when "GET" then respond_json(response, 200, @mount.dataset_data(dataset))
|
|
57
|
+
when "POST" then append_item(dataset, request, response)
|
|
58
|
+
else respond_method_not_allowed(response)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_item(dataset, id, request, response)
|
|
63
|
+
case request.request_method
|
|
64
|
+
when "GET" then read_item(dataset, id, response)
|
|
65
|
+
when "PUT" then replace_item(dataset, id, request, response)
|
|
66
|
+
when "DELETE" then delete_item(dataset, id, response)
|
|
67
|
+
else respond_method_not_allowed(response)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_item(dataset, request, response)
|
|
72
|
+
return unless writable?(dataset, response)
|
|
73
|
+
|
|
74
|
+
item = parse_body(request, response)
|
|
75
|
+
return if item.nil?
|
|
76
|
+
|
|
77
|
+
id = @mount.overlay.append_item(dataset, item)
|
|
78
|
+
response["Location"] = "#{request.path}/#{id}"
|
|
79
|
+
respond_json(response, 201, item)
|
|
80
|
+
rescue Overlay::Error => e
|
|
81
|
+
respond_overlay_error(response, e)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def read_item(dataset, id, response)
|
|
85
|
+
respond_json(response, 200, @mount.overlay.item(dataset, id))
|
|
86
|
+
rescue Overlay::ItemNotFoundError
|
|
87
|
+
respond_not_found(response)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replace_item(dataset, id, request, response)
|
|
91
|
+
return unless writable?(dataset, response)
|
|
92
|
+
|
|
93
|
+
item = parse_body(request, response)
|
|
94
|
+
return if item.nil?
|
|
95
|
+
|
|
96
|
+
@mount.overlay.replace_item(dataset, id, item)
|
|
97
|
+
respond_json(response, 200, item)
|
|
98
|
+
rescue Overlay::ItemNotFoundError
|
|
99
|
+
respond_not_found(response)
|
|
100
|
+
rescue Overlay::Error => e
|
|
101
|
+
respond_overlay_error(response, e)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete_item(dataset, id, response)
|
|
105
|
+
return unless writable?(dataset, response)
|
|
106
|
+
|
|
107
|
+
@mount.overlay.delete_item(dataset, id)
|
|
108
|
+
response.status = 204
|
|
109
|
+
rescue Overlay::ItemNotFoundError
|
|
110
|
+
respond_not_found(response)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Write guards: 403 for a read-only mount, 501 for a SQLite
|
|
114
|
+
# dataset (not file-backed JSON/YAML).
|
|
115
|
+
def writable?(dataset, response)
|
|
116
|
+
unless @mount.writable?
|
|
117
|
+
respond_error(response, 403, "package #{@mount.package.name} is read-only")
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
if dataset.config.sqlite?
|
|
121
|
+
respond_error(response, 501,
|
|
122
|
+
"dataset #{dataset.name} is SQLite; writes are not supported")
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The JSON request body, or nil after answering 400 (invalid JSON)
|
|
129
|
+
# or 422 (a null body cannot be an item).
|
|
130
|
+
def parse_body(request, response)
|
|
131
|
+
body = JSON.parse(request.body.to_s)
|
|
132
|
+
if body.nil?
|
|
133
|
+
respond_error(response, 422, "request body must not be null")
|
|
134
|
+
return nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
body
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
respond_error(response, 400, "invalid JSON body: #{e.message}")
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def respond_overlay_error(response, error)
|
|
144
|
+
case error
|
|
145
|
+
when Overlay::ConflictError then respond_error(response, 409, error.message)
|
|
146
|
+
when Overlay::SchemaViolationError
|
|
147
|
+
respond_error(response, 422, "schema validation failed",
|
|
148
|
+
messages: error.messages)
|
|
149
|
+
else respond_error(response, 422, error.message)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Serving of the reactor-level endpoints (introspection reports,
|
|
8
|
+
# per-package save), mixed into Reactor.
|
|
9
|
+
module Endpoints
|
|
10
|
+
include Responses
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def serve_introspection(request, response)
|
|
15
|
+
return respond_method_not_allowed(response) unless request.request_method == "GET"
|
|
16
|
+
|
|
17
|
+
report = @introspection.report_for(request.path, params: request.query)
|
|
18
|
+
return respond_not_found(response) if report.nil?
|
|
19
|
+
|
|
20
|
+
respond_json(response, 200, report)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /package/<name>/save: folds the mounted package's base plus
|
|
24
|
+
# its overlay into a new versioned .cap in the workdir.
|
|
25
|
+
def serve_package_save(request, response)
|
|
26
|
+
return respond_method_not_allowed(response) unless request.request_method == "POST"
|
|
27
|
+
|
|
28
|
+
name = PACKAGE_SAVE_PATTERN.match(request.path)[:name]
|
|
29
|
+
mount = @mounts.find { |candidate| candidate.package.name == name }
|
|
30
|
+
return respond_not_found(response) unless mount
|
|
31
|
+
return respond_error(response, 403, "package #{name} is read-only") unless mount.writable?
|
|
32
|
+
|
|
33
|
+
respond_json(response, 200, PackageSaver.new(mount).save(@workdir))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Reactor
|
|
8
|
+
# GraphQL over a mount's datasets (POST and GET <mount>/graphql):
|
|
9
|
+
# executes the schema GraphqlSchema derives from the package's
|
|
10
|
+
# storage and maps the overlay's typed errors into the "errors"
|
|
11
|
+
# array (never a 500 on user error). Query fields read the merged
|
|
12
|
+
# collection; mutations match the REST semantics, schema validation
|
|
13
|
+
# included.
|
|
14
|
+
class GraphqlApi
|
|
15
|
+
include Responses
|
|
16
|
+
|
|
17
|
+
PATH = "/graphql"
|
|
18
|
+
|
|
19
|
+
def self.path?(inner_path)
|
|
20
|
+
inner_path == PATH
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(mount)
|
|
24
|
+
@mount = mount
|
|
25
|
+
@schema = GraphqlSchema.new(mount, self).build
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def handle(request, response)
|
|
29
|
+
case request.request_method
|
|
30
|
+
when "POST" then handle_post(request, response)
|
|
31
|
+
when "GET" then handle_get(request, response)
|
|
32
|
+
else respond_method_not_allowed(response)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Query/mutation resolvers (public; called from the derived
|
|
37
|
+
# schema's fields).
|
|
38
|
+
def collection(dataset, id: nil)
|
|
39
|
+
return @mount.overlay.items(dataset) if id.nil?
|
|
40
|
+
|
|
41
|
+
[@mount.overlay.item(dataset, id)]
|
|
42
|
+
rescue Overlay::Error => e
|
|
43
|
+
raise execution_error(e)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_item(dataset, item:)
|
|
47
|
+
ensure_writable!
|
|
48
|
+
@mount.overlay.append_item(dataset, item)
|
|
49
|
+
item
|
|
50
|
+
rescue Overlay::Error => e
|
|
51
|
+
raise execution_error(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update_item(dataset, id:, item:)
|
|
55
|
+
ensure_writable!
|
|
56
|
+
@mount.overlay.replace_item(dataset, id, item)
|
|
57
|
+
item
|
|
58
|
+
rescue Overlay::Error => e
|
|
59
|
+
raise execution_error(e)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove_item(dataset, id:)
|
|
63
|
+
ensure_writable!
|
|
64
|
+
@mount.overlay.delete_item(dataset, id)
|
|
65
|
+
true
|
|
66
|
+
rescue Overlay::Error => e
|
|
67
|
+
raise execution_error(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def handle_post(request, response)
|
|
73
|
+
payload = JSON.parse(request.body.to_s)
|
|
74
|
+
unless payload.is_a?(Hash) && payload["query"].is_a?(String)
|
|
75
|
+
return respond_error(response, 400, "GraphQL POST body must be " \
|
|
76
|
+
'{"query": "...", "variables": {...}}')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
execute(response, payload["query"], variables: payload["variables"],
|
|
80
|
+
operation_name: payload["operationName"])
|
|
81
|
+
rescue JSON::ParserError => e
|
|
82
|
+
respond_error(response, 400, "invalid JSON body: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_get(request, response)
|
|
86
|
+
query = request.query["query"]
|
|
87
|
+
return respond_error(response, 400, "missing GraphQL query") if query.nil?
|
|
88
|
+
|
|
89
|
+
execute(response, query, variables: parse_variables(request.query["variables"]),
|
|
90
|
+
operation_name: request.query["operationName"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_variables(raw)
|
|
94
|
+
return nil if raw.nil?
|
|
95
|
+
|
|
96
|
+
JSON.parse(raw)
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute(response, query, variables:, operation_name:)
|
|
102
|
+
result = @schema.execute(query, variables: variables || {},
|
|
103
|
+
operation_name: operation_name)
|
|
104
|
+
respond_json(response, 200, result.to_h)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ensure_writable!
|
|
108
|
+
return if @mount.writable?
|
|
109
|
+
|
|
110
|
+
raise GraphQL::ExecutionError, "package #{@mount.package.name} is read-only"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def execution_error(error)
|
|
114
|
+
message = if error.is_a?(Overlay::SchemaViolationError)
|
|
115
|
+
error.messages.join("; ")
|
|
116
|
+
else
|
|
117
|
+
error.message
|
|
118
|
+
end
|
|
119
|
+
GraphQL::ExecutionError.new(message)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|