ruby_wasm_ic_installer 0.1.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.
Potentially problematic release.
This version of ruby_wasm_ic_installer might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/exe/ruby_wasm_ic_installer +6 -0
- data/lib/ruby_wasm_ic_installer/app_setup.rb +176 -0
- data/lib/ruby_wasm_ic_installer/candid.rb +262 -0
- data/lib/ruby_wasm_ic_installer/canister.rb +208 -0
- data/lib/ruby_wasm_ic_installer/cli.rb +108 -0
- data/lib/ruby_wasm_ic_installer/diff.rb +73 -0
- data/lib/ruby_wasm_ic_installer/gem_files.rb +125 -0
- data/lib/ruby_wasm_ic_installer/installer.rb +94 -0
- data/lib/ruby_wasm_ic_installer/lockfile.rb +63 -0
- data/lib/ruby_wasm_ic_installer/magic_comment.rb +60 -0
- data/lib/ruby_wasm_ic_installer/plan.rb +84 -0
- data/lib/ruby_wasm_ic_installer/runner.rb +59 -0
- data/lib/ruby_wasm_ic_installer/version.rb +5 -0
- data/lib/ruby_wasm_ic_installer.rb +21 -0
- metadata +65 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# ruby_wasm_ic_installer/canister.rb: the Installer's canister half, carried
|
|
2
|
+
# by an Application (packed under /app, so it is there before anything is
|
|
3
|
+
# installed) and required at the top of its app.rb. `ruby_wasm_ic_installer
|
|
4
|
+
# setup <app_dir>` writes that require and the Declarations it serves.
|
|
5
|
+
#
|
|
6
|
+
# Core Ruby and the builder's IC only (IC.caller, IC.refuse, IC::DATA_DIR):
|
|
7
|
+
# it loads on a `minimal` canister, so it names no Encoding constant and
|
|
8
|
+
# requires nothing.
|
|
9
|
+
#
|
|
10
|
+
# The layout it keeps (W29, Q7 to Q11):
|
|
11
|
+
# /data/gems/<name>-<version>/... one directory per gem version, complete
|
|
12
|
+
# /data/gems/.partial/<name>-<version>/ a version still arriving
|
|
13
|
+
# /data/setup.rb the setup file: its generation and the
|
|
14
|
+
# load paths, required at boot
|
|
15
|
+
# A version arrives into .partial by install_put and is renamed into place by
|
|
16
|
+
# install_commit, which also writes the setup file, last, once every gem it
|
|
17
|
+
# names is complete. A new setup file takes effect at the next boot. A version
|
|
18
|
+
# is removed only by install_remove, and never while the running boot or the
|
|
19
|
+
# pending one names it. Every method is for the canister's controllers only.
|
|
20
|
+
module RubyWasmIcInstaller
|
|
21
|
+
module Canister
|
|
22
|
+
# A gem version's directory: a gem name, a dash, a version.
|
|
23
|
+
NAME_VERSION = /\A[A-Za-z0-9][A-Za-z0-9_.\-]*-[0-9][A-Za-z0-9_.\-]*\z/
|
|
24
|
+
SETUP_HEAD = "# generated by ruby_wasm_ic_installer: do not edit, it is rewritten by install_commit\n"
|
|
25
|
+
GENERATION_LINE = /\A# generation: (\d+)$/
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# The running generation (the setup file this boot loaded), or nil.
|
|
29
|
+
attr_reader :running_generation
|
|
30
|
+
|
|
31
|
+
def data_dir = @data_dir || IC::DATA_DIR
|
|
32
|
+
attr_writer :data_dir
|
|
33
|
+
|
|
34
|
+
def gems_dir = File.join(data_dir, "gems")
|
|
35
|
+
def partial_dir = File.join(gems_dir, ".partial")
|
|
36
|
+
def setup_file = File.join(data_dir, "setup.rb")
|
|
37
|
+
|
|
38
|
+
# At boot: load the setup file if there is one. It calls #setup below.
|
|
39
|
+
def boot!
|
|
40
|
+
@running_generation = nil
|
|
41
|
+
@running_paths = []
|
|
42
|
+
load(setup_file) if File.file?(setup_file)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Called by the setup file: puts its load paths on $LOAD_PATH, after
|
|
47
|
+
# /app (so a Carried library still shadows an installed one) and before
|
|
48
|
+
# the Shipped library, in the lock's order.
|
|
49
|
+
def setup(generation, load_paths)
|
|
50
|
+
@running_generation = generation
|
|
51
|
+
@running_paths = load_paths.map { |p| File.join(gems_dir, p) }
|
|
52
|
+
app = $LOAD_PATH.rindex { |p| p == "/app" || p.start_with?("/app/") }
|
|
53
|
+
$LOAD_PATH.insert(app ? app + 1 : 0, *(@running_paths - $LOAD_PATH))
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# install_put: files into .partial/<name-version>/, each path
|
|
58
|
+
# "<name-version>/<path inside the gem>".
|
|
59
|
+
def put(files)
|
|
60
|
+
controller!
|
|
61
|
+
files.each do |f|
|
|
62
|
+
nv, rest = split!(f[:path])
|
|
63
|
+
full = File.join(partial_dir, nv, rest)
|
|
64
|
+
mkdir_p(File.dirname(full))
|
|
65
|
+
File.binwrite(full, f[:body])
|
|
66
|
+
end
|
|
67
|
+
"wrote #{files.size} files, #{files.sum { _1[:body].bytesize }} bytes"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# install_commit: rename each named .partial directory into place, then
|
|
71
|
+
# write the setup file if one is given, whose every load path must be
|
|
72
|
+
# inside a complete version.
|
|
73
|
+
def commit(request)
|
|
74
|
+
controller!
|
|
75
|
+
done = request[:gems].map do |nv|
|
|
76
|
+
name_version!(nv)
|
|
77
|
+
from = File.join(partial_dir, nv)
|
|
78
|
+
to = File.join(gems_dir, nv)
|
|
79
|
+
IC.refuse("#{nv} has nothing in .partial to commit") unless File.directory?(from)
|
|
80
|
+
IC.refuse("#{nv} is already installed") if File.exist?(to)
|
|
81
|
+
File.rename(from, to)
|
|
82
|
+
nv
|
|
83
|
+
end
|
|
84
|
+
msg = "committed #{done.size} gems"
|
|
85
|
+
setup = request[:setup]
|
|
86
|
+
return msg unless setup
|
|
87
|
+
|
|
88
|
+
write_setup!(setup[:generation], setup[:load_paths])
|
|
89
|
+
"#{msg}; wrote setup generation #{setup[:generation]}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# install_remove: delete one complete version that neither the running
|
|
93
|
+
# boot nor the pending setup file names, or a leftover in .partial.
|
|
94
|
+
def remove(nv)
|
|
95
|
+
controller!
|
|
96
|
+
name_version!(nv)
|
|
97
|
+
if File.directory?(File.join(partial_dir, nv)) && !File.exist?(File.join(gems_dir, nv))
|
|
98
|
+
rm_r(File.join(partial_dir, nv))
|
|
99
|
+
return "removed #{nv} from .partial"
|
|
100
|
+
end
|
|
101
|
+
dir = File.join(gems_dir, nv)
|
|
102
|
+
IC.refuse("#{nv} is not installed") unless File.directory?(dir)
|
|
103
|
+
IC.refuse("#{nv} is named by the running boot (generation #{running_generation})") if named?(running_names, nv)
|
|
104
|
+
IC.refuse("#{nv} is named by the pending setup (generation #{pending_generation})") if named?(pending_names, nv)
|
|
105
|
+
rm_r(dir)
|
|
106
|
+
"removed #{nv}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# installed: public, a query. What runs and what is waiting.
|
|
110
|
+
def installed
|
|
111
|
+
{
|
|
112
|
+
running: running_generation,
|
|
113
|
+
pending: pending_generation,
|
|
114
|
+
gems: list(gems_dir),
|
|
115
|
+
partial: list(partial_dir),
|
|
116
|
+
load_paths: pending_paths,
|
|
117
|
+
running_paths: (@running_paths || []).map { _1.delete_prefix("#{gems_dir}/") }
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pending_generation
|
|
122
|
+
return nil unless File.file?(setup_file)
|
|
123
|
+
|
|
124
|
+
File.foreach(setup_file) { |l| return Regexp.last_match(1).to_i if l =~ GENERATION_LINE }
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def controller!
|
|
131
|
+
IC.caller.controller? || IC.refuse("only a controller may install code")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def name_version!(nv)
|
|
135
|
+
IC.refuse("not a gem version directory: #{nv.inspect}") unless nv.is_a?(String) && nv =~ NAME_VERSION
|
|
136
|
+
nv
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# "<name-version>/<rest>", with no absolute path, no "..", no "." and
|
|
140
|
+
# no empty segment.
|
|
141
|
+
def split!(path)
|
|
142
|
+
IC.refuse("path must be relative: #{path}") if path.start_with?("/")
|
|
143
|
+
parts = path.split("/", -1)
|
|
144
|
+
IC.refuse("path must not hold . or .. or an empty segment: #{path}") if parts.any? { ["", ".", ".."].include?(_1) }
|
|
145
|
+
IC.refuse("path must be <name-version>/<file>: #{path}") if parts.size < 2
|
|
146
|
+
[name_version!(parts.first), parts.drop(1).join("/")]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def write_setup!(generation, load_paths)
|
|
150
|
+
unless generation.is_a?(Integer) && generation > (pending_generation || 0) && generation > (running_generation || 0)
|
|
151
|
+
IC.refuse("setup generation #{generation} must be above the pending (#{pending_generation.inspect}) " \
|
|
152
|
+
"and running (#{running_generation.inspect}) ones")
|
|
153
|
+
end
|
|
154
|
+
load_paths.each do |p|
|
|
155
|
+
nv, = split!(p)
|
|
156
|
+
IC.refuse("load path #{p} is not inside an installed gem") unless File.directory?(File.join(gems_dir, p)) &&
|
|
157
|
+
File.directory?(File.join(gems_dir, nv))
|
|
158
|
+
end
|
|
159
|
+
body = SETUP_HEAD + "# generation: #{generation}\n" \
|
|
160
|
+
"RubyWasmIcInstaller::Canister.setup(#{generation}, #{load_paths.map(&:to_s).inspect})\n"
|
|
161
|
+
File.write(setup_file, body)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def pending_paths
|
|
165
|
+
return [] unless File.file?(setup_file)
|
|
166
|
+
|
|
167
|
+
line = File.foreach(setup_file).find { _1.start_with?("RubyWasmIcInstaller::Canister.setup(") }
|
|
168
|
+
return [] unless line
|
|
169
|
+
|
|
170
|
+
line.scan(/"((?:[^"\\]|\\.)*)"/).map(&:first)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def running_names = (@running_paths || []).map { |p| p.delete_prefix("#{gems_dir}/").split("/").first }
|
|
174
|
+
def pending_names = pending_paths.map { _1.split("/").first }
|
|
175
|
+
def named?(names, nv) = names.include?(nv)
|
|
176
|
+
|
|
177
|
+
def list(dir)
|
|
178
|
+
return [] unless File.directory?(dir)
|
|
179
|
+
|
|
180
|
+
Dir.children(dir).reject { _1.start_with?(".") }.select { File.directory?(File.join(dir, _1)) }.sort
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def mkdir_p(dir)
|
|
184
|
+
return if File.directory?(dir)
|
|
185
|
+
|
|
186
|
+
mkdir_p(File.dirname(dir))
|
|
187
|
+
Dir.mkdir(dir)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def rm_r(path)
|
|
191
|
+
if File.directory?(path) && !File.symlink?(path)
|
|
192
|
+
Dir.children(path).each { rm_r(File.join(path, _1)) }
|
|
193
|
+
Dir.rmdir(path)
|
|
194
|
+
else
|
|
195
|
+
File.delete(path)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The Declarations `setup` writes into ic-interface.rb are served by these.
|
|
203
|
+
def install_put(files) = RubyWasmIcInstaller::Canister.put(files)
|
|
204
|
+
def install_commit(request) = RubyWasmIcInstaller::Canister.commit(request)
|
|
205
|
+
def install_remove(name_version) = RubyWasmIcInstaller::Canister.remove(name_version)
|
|
206
|
+
def installed = RubyWasmIcInstaller::Canister.installed
|
|
207
|
+
|
|
208
|
+
RubyWasmIcInstaller::Canister.boot! if defined?(IC::DATA_DIR)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module RubyWasmIcInstaller
|
|
6
|
+
# The command: `ruby_wasm_ic_installer <install|status|prune|setup> ...`.
|
|
7
|
+
class CLI
|
|
8
|
+
USAGE = <<~TEXT
|
|
9
|
+
Usage:
|
|
10
|
+
ruby_wasm_ic_installer install CANISTER [--lock Gemfile.lock] [--dry-run] [icp options]
|
|
11
|
+
ruby_wasm_ic_installer status CANISTER [icp options]
|
|
12
|
+
ruby_wasm_ic_installer prune CANISTER [--dry-run] [icp options]
|
|
13
|
+
ruby_wasm_ic_installer setup APP_DIR [--yes | --check]
|
|
14
|
+
|
|
15
|
+
install sends the lock's pure-Ruby gems the canister lacks, then the setup
|
|
16
|
+
file that loads them; it takes effect at the next upgrade
|
|
17
|
+
status what the canister has installed, running and pending
|
|
18
|
+
prune removes every version neither the running nor the pending setup names
|
|
19
|
+
setup wires the Installer's methods into an Application (ic-interface.rb,
|
|
20
|
+
app.rb, vendor/ruby_wasm_ic_installer/)
|
|
21
|
+
|
|
22
|
+
icp options, passed to `icp canister call`:
|
|
23
|
+
-e, --environment ENV -n, --network NETWORK --identity NAME
|
|
24
|
+
--project-root DIR the icp project (icp's --project-root-override)
|
|
25
|
+
--icp PATH the icp command (default: icp)
|
|
26
|
+
Other options:
|
|
27
|
+
--cache DIR where fetched .gem files are kept
|
|
28
|
+
(default: ~/.cache/ruby_wasm_ic_installer)
|
|
29
|
+
--budget BYTES the largest batch, in bytes of Candid (default: #{Plan::DEFAULT_BUDGET})
|
|
30
|
+
-h, --help, --version
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
def self.start(argv, out: $stdout, err: $stderr, input: $stdin, runner: nil, gem_files: nil)
|
|
34
|
+
new(out: out, err: err, input: input, runner: runner, gem_files: gem_files).run(argv)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(out:, err:, input:, runner:, gem_files:)
|
|
38
|
+
@out = out
|
|
39
|
+
@err = err
|
|
40
|
+
@input = input
|
|
41
|
+
@runner = runner
|
|
42
|
+
@gem_files = gem_files
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The exit status.
|
|
46
|
+
def run(argv)
|
|
47
|
+
opts = { lock: "Gemfile.lock", icp: "icp", icp_args: [], dry_run: false, yes: false, check: false,
|
|
48
|
+
cache: File.join(Dir.home, ".cache", "ruby_wasm_ic_installer"), budget: Plan::DEFAULT_BUDGET }
|
|
49
|
+
args = parser(opts).parse(argv)
|
|
50
|
+
return 0 if opts[:done]
|
|
51
|
+
|
|
52
|
+
command = args.shift
|
|
53
|
+
target = args.shift
|
|
54
|
+
raise Error, "#{USAGE}\nunexpected: #{args.join(' ')}" unless args.empty?
|
|
55
|
+
raise Error, USAGE if command.nil? || target.nil?
|
|
56
|
+
|
|
57
|
+
case command
|
|
58
|
+
when "setup" then setup(target, opts)
|
|
59
|
+
when "install" then report(installer(target, opts).install(Lockfile.read(opts[:lock]), dry_run: opts[:dry_run]))
|
|
60
|
+
when "status" then report(installer(target, opts).status)
|
|
61
|
+
when "prune" then report(installer(target, opts).prune(dry_run: opts[:dry_run]))
|
|
62
|
+
else raise Error, "#{USAGE}\nunknown command: #{command}"
|
|
63
|
+
end
|
|
64
|
+
rescue Error, OptionParser::ParseError => e
|
|
65
|
+
@err.puts(e.message)
|
|
66
|
+
e.is_a?(Runner::CallError) || e.is_a?(AppSetup::Aborted) ? 1 : 2
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parser(opts)
|
|
72
|
+
OptionParser.new do |o|
|
|
73
|
+
o.banner = USAGE
|
|
74
|
+
o.on("--lock PATH") { opts[:lock] = _1 }
|
|
75
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
76
|
+
o.on("--yes") { opts[:yes] = true }
|
|
77
|
+
o.on("--check") { opts[:check] = true }
|
|
78
|
+
o.on("--icp PATH") { opts[:icp] = _1 }
|
|
79
|
+
o.on("--cache DIR") { opts[:cache] = _1 }
|
|
80
|
+
o.on("--budget BYTES", Integer) { opts[:budget] = _1 }
|
|
81
|
+
o.on("-e", "--environment ENV") { opts[:icp_args] += ["-e", _1] }
|
|
82
|
+
o.on("-n", "--network NETWORK") { opts[:icp_args] += ["-n", _1] }
|
|
83
|
+
o.on("--identity NAME") { opts[:icp_args] += ["--identity", _1] }
|
|
84
|
+
o.on("--project-root DIR") { opts[:icp_args] += ["--project-root-override", _1] }
|
|
85
|
+
o.on("-h", "--help") { @out.puts(USAGE) || opts[:done] = true }
|
|
86
|
+
o.on("--version") { @out.puts(VERSION) || opts[:done] = true }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def installer(canister, opts)
|
|
91
|
+
runner = @runner || Runner.new(canister: canister, icp: opts[:icp], icp_args: opts[:icp_args])
|
|
92
|
+
Installer.new(runner: runner, gem_files: @gem_files || GemFiles.new(cache: opts[:cache]), budget: opts[:budget])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def setup(dir, opts)
|
|
96
|
+
s = AppSetup.new(dir, io: @out, input: @input)
|
|
97
|
+
return s.check if opts[:check]
|
|
98
|
+
|
|
99
|
+
s.run(yes: opts[:yes])
|
|
100
|
+
0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def report(lines)
|
|
104
|
+
lines.each { @out.puts(_1) }
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyWasmIcInstaller
|
|
4
|
+
# A unified diff of two texts by lines, enough for `setup` to show what it
|
|
5
|
+
# would change (W29, Q17). Longest common subsequence; three lines of
|
|
6
|
+
# context; no dependency.
|
|
7
|
+
module Diff
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def unified(old_text, new_text, from:, to:, context: 3)
|
|
11
|
+
a = old_text.lines
|
|
12
|
+
b = new_text.lines
|
|
13
|
+
ops = edits(a, b)
|
|
14
|
+
return "" if ops.all? { _1.first == :same }
|
|
15
|
+
|
|
16
|
+
out = +"--- #{from}\n+++ #{to}\n"
|
|
17
|
+
hunks(ops, context).each do |h|
|
|
18
|
+
a_start = h.first[2] + 1
|
|
19
|
+
b_start = h.first[3] + 1
|
|
20
|
+
a_len = h.count { _1.first != :add }
|
|
21
|
+
b_len = h.count { _1.first != :del }
|
|
22
|
+
out << "@@ -#{a_len.zero? ? a_start - 1 : a_start},#{a_len} +#{b_len.zero? ? b_start - 1 : b_start},#{b_len} @@\n"
|
|
23
|
+
h.each do |op, line, *|
|
|
24
|
+
mark = { same: " ", del: "-", add: "+" }.fetch(op)
|
|
25
|
+
out << mark << line
|
|
26
|
+
out << "\n\\n" unless line.end_with?("\n")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
out
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# [[op, line, index in a, index in b], ...] in order.
|
|
33
|
+
def edits(a, b)
|
|
34
|
+
lcs = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
|
|
35
|
+
(a.size - 1).downto(0) do |i|
|
|
36
|
+
(b.size - 1).downto(0) do |j|
|
|
37
|
+
lcs[i][j] = a[i] == b[j] ? lcs[i + 1][j + 1] + 1 : [lcs[i + 1][j], lcs[i][j + 1]].max
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
i = j = 0
|
|
41
|
+
ops = []
|
|
42
|
+
while i < a.size || j < b.size
|
|
43
|
+
if i < a.size && j < b.size && a[i] == b[j]
|
|
44
|
+
ops << [:same, a[i], i, j]
|
|
45
|
+
i += 1
|
|
46
|
+
j += 1
|
|
47
|
+
elsif i < a.size && (j == b.size || lcs[i + 1][j] >= lcs[i][j + 1])
|
|
48
|
+
ops << [:del, a[i], i, j] # a deletion first, as diff(1) prints it
|
|
49
|
+
i += 1
|
|
50
|
+
else
|
|
51
|
+
ops << [:add, b[j], i, j]
|
|
52
|
+
j += 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
ops
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hunks(ops, context)
|
|
59
|
+
changed = ops.each_index.reject { ops[_1].first == :same }
|
|
60
|
+
groups = []
|
|
61
|
+
changed.each do |k|
|
|
62
|
+
lo = [k - context, 0].max
|
|
63
|
+
hi = [k + context, ops.size - 1].min
|
|
64
|
+
if groups.any? && lo <= groups.last.last + 1
|
|
65
|
+
groups.last[1] = hi
|
|
66
|
+
else
|
|
67
|
+
groups << [lo, hi]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
groups.map { |lo, hi| ops[lo..hi] }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rubygems/package"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
module RubyWasmIcInstaller
|
|
10
|
+
# One locked gem made ready to send (W29, Q8 and Q9): its `.gem` fetched
|
|
11
|
+
# for platform `ruby` (or found in the cache), checked against the lock's
|
|
12
|
+
# sha256 when the lock has one, refused if it has extensions, and opened
|
|
13
|
+
# for its require_paths alone, with magic comments fixed.
|
|
14
|
+
class GemFiles
|
|
15
|
+
Prepared = Struct.new(:name_version, :require_paths, :files, :rewrites, keyword_init: true) do
|
|
16
|
+
def bytes = files.sum { _2.bytesize }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Refused < Error; end
|
|
20
|
+
|
|
21
|
+
# `fetcher` is called as fetcher.(name, version, dir) and must leave
|
|
22
|
+
# "<name>-<version>.gem" in dir; the default runs `gem fetch`.
|
|
23
|
+
def initialize(cache:, fetcher: nil)
|
|
24
|
+
@cache = cache
|
|
25
|
+
@fetcher = fetcher || method(:gem_fetch)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def prepare(locked)
|
|
29
|
+
path = fetch(locked)
|
|
30
|
+
check_sum!(locked, path)
|
|
31
|
+
package = ::Gem::Package.new(path)
|
|
32
|
+
spec = package.spec
|
|
33
|
+
check_spec!(locked, spec)
|
|
34
|
+
files, rewrites = read_files(locked, package, spec)
|
|
35
|
+
paths = spec.require_paths.select { |rp| files.keys.any? { _1.start_with?("#{rp}/") } }
|
|
36
|
+
raise Refused, "#{locked.name_version} has no files under #{spec.require_paths.join(', ')}" if paths.empty?
|
|
37
|
+
|
|
38
|
+
Prepared.new(name_version: locked.name_version, require_paths: paths, files: files, rewrites: rewrites)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The stand-ins a fetched ruby_wasm_ic_minimal carries: the libraries it
|
|
42
|
+
# stands in for, so the locked gems of those names are skipped.
|
|
43
|
+
def standins(locked)
|
|
44
|
+
package = ::Gem::Package.new(fetch(locked))
|
|
45
|
+
Dir.mktmpdir do |dir|
|
|
46
|
+
package.extract_files(dir, "lib/ruby_wasm_ic_minimal/standins/*.rb")
|
|
47
|
+
Dir.children(File.join(dir, "lib/ruby_wasm_ic_minimal/standins")).map { File.basename(_1, ".rb") }
|
|
48
|
+
.reject { _1 == "encoding_constants" }.sort
|
|
49
|
+
end
|
|
50
|
+
rescue Errno::ENOENT
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def fetch(locked)
|
|
57
|
+
FileUtils.mkdir_p(@cache)
|
|
58
|
+
path = File.join(@cache, "#{locked.name_version}.gem")
|
|
59
|
+
return path if File.file?(path)
|
|
60
|
+
|
|
61
|
+
Dir.mktmpdir do |dir|
|
|
62
|
+
@fetcher.call(locked.name, locked.version, dir)
|
|
63
|
+
got = File.join(dir, "#{locked.name_version}.gem")
|
|
64
|
+
raise Refused, "fetching #{locked.name_version} left no #{File.basename(got)}" unless File.file?(got)
|
|
65
|
+
|
|
66
|
+
FileUtils.mv(got, path)
|
|
67
|
+
end
|
|
68
|
+
path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def gem_fetch(name, version, dir)
|
|
72
|
+
out, status = Open3.capture2e("gem", "fetch", name, "--version", version, "--platform", "ruby", chdir: dir)
|
|
73
|
+
raise Refused, "gem fetch #{name} #{version} failed:\n#{out}" unless status.success?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check_sum!(locked, path)
|
|
77
|
+
return unless locked.sha256
|
|
78
|
+
|
|
79
|
+
have = Digest::SHA256.file(path).hexdigest
|
|
80
|
+
return if have == locked.sha256
|
|
81
|
+
|
|
82
|
+
raise Refused, "#{locked.name_version}.gem has sha256 #{have}, and the lock says #{locked.sha256}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_spec!(locked, spec)
|
|
86
|
+
unless spec.platform.to_s == "ruby"
|
|
87
|
+
raise Refused, "#{locked.name_version} was fetched for #{spec.platform}, not ruby: only pure-Ruby gems " \
|
|
88
|
+
"can be installed into a canister"
|
|
89
|
+
end
|
|
90
|
+
return if spec.extensions.empty?
|
|
91
|
+
|
|
92
|
+
raise Refused, "#{locked.name_version} has a C extension (#{spec.extensions.join(', ')}): only pure-Ruby " \
|
|
93
|
+
"gems can be installed into a canister, which cannot load compiled code"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_files(locked, package, spec)
|
|
97
|
+
files = {}
|
|
98
|
+
rewrites = []
|
|
99
|
+
Dir.mktmpdir do |dir|
|
|
100
|
+
# extract_files matches without FNM_PATHNAME, so "*" crosses "/":
|
|
101
|
+
# "lib/*" is every file at every depth under lib/ ("lib/**/*" would
|
|
102
|
+
# miss lib/rack.rb itself).
|
|
103
|
+
spec.require_paths.each { |rp| package.extract_files(dir, "#{rp}/*") }
|
|
104
|
+
spec.require_paths.each do |rp|
|
|
105
|
+
base = File.join(dir, rp)
|
|
106
|
+
next unless File.directory?(base)
|
|
107
|
+
|
|
108
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: base).sort.each do |rel|
|
|
109
|
+
full = File.join(base, rel)
|
|
110
|
+
next unless File.file?(full)
|
|
111
|
+
|
|
112
|
+
body = File.binread(full)
|
|
113
|
+
path = "#{rp}/#{rel}"
|
|
114
|
+
if rel.end_with?(".rb")
|
|
115
|
+
body, rewrite = MagicComment.fix("#{locked.name_version}/#{path}", body)
|
|
116
|
+
rewrites << rewrite if rewrite
|
|
117
|
+
end
|
|
118
|
+
files[path] = body
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
[files, rewrites]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyWasmIcInstaller
|
|
4
|
+
# The off-chain half's three acts against one canister, through a Runner:
|
|
5
|
+
# `install` (the lock's gems the canister lacks, then the setup file),
|
|
6
|
+
# `status` (what installed() says) and `prune` (every version neither the
|
|
7
|
+
# running boot nor the pending setup names). Each returns the lines of its
|
|
8
|
+
# report; nothing here prints.
|
|
9
|
+
class Installer
|
|
10
|
+
MINIMAL = "ruby_wasm_ic_minimal"
|
|
11
|
+
|
|
12
|
+
def initialize(runner:, gem_files:, budget: Plan::DEFAULT_BUDGET)
|
|
13
|
+
@runner = runner
|
|
14
|
+
@gem_files = gem_files
|
|
15
|
+
@budget = budget
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def status
|
|
19
|
+
i = installed
|
|
20
|
+
[
|
|
21
|
+
"running generation: #{i[:running] || 'none'}",
|
|
22
|
+
"pending generation: #{i[:pending] || 'none'}#{pending_note(i)}",
|
|
23
|
+
"installed: #{i[:gems].empty? ? 'none' : i[:gems].join(', ')}",
|
|
24
|
+
*(i[:partial].empty? ? [] : ["half-sent (.partial): #{i[:partial].join(', ')}"])
|
|
25
|
+
]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `dry_run`: plan and report, call nothing that writes.
|
|
29
|
+
def install(lockfile, dry_run: false)
|
|
30
|
+
report = []
|
|
31
|
+
locked = lockfile.gems
|
|
32
|
+
skip = standins(locked)
|
|
33
|
+
skipped, wanted = locked.partition { skip.include?(_1.name) }
|
|
34
|
+
skipped.each { report << "skip #{_1.name_version}: #{MINIMAL} stands in for #{_1.name} on a minimal canister" }
|
|
35
|
+
prepared = wanted.map { @gem_files.prepare(_1) }
|
|
36
|
+
prepared.flat_map(&:rewrites).each do |r|
|
|
37
|
+
report << "rewrite #{r.file}:#{r.line}: magic comment #{r.from} -> #{r.to}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
plan = Plan.new(prepared, installed, budget: @budget)
|
|
41
|
+
plan.sent.each { report << "send #{_1.name_version} (#{_1.files.size} files, #{_1.bytes} bytes)" }
|
|
42
|
+
if plan.empty?
|
|
43
|
+
report << "nothing to send: the canister has every locked gem and the setup file already names them"
|
|
44
|
+
return report
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
plan.steps.each do |step|
|
|
48
|
+
report << "#{dry_run ? 'would call' : 'call'} #{step.method}: #{step.note}"
|
|
49
|
+
next if dry_run
|
|
50
|
+
|
|
51
|
+
reply = Candid.decode(@runner.call(step.method, step.args)).first
|
|
52
|
+
report << " #{reply}"
|
|
53
|
+
end
|
|
54
|
+
report << (plan.generation ? "setup generation #{plan.generation} takes effect at the next upgrade" \
|
|
55
|
+
: "the setup file is unchanged")
|
|
56
|
+
report << "upgrade the canister to load it (icp deploy --mode upgrade)" if plan.generation && !dry_run
|
|
57
|
+
report
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prune(dry_run: false)
|
|
61
|
+
i = installed
|
|
62
|
+
keep = (names_of(i[:load_paths]) + names_of(i[:running_paths])).uniq
|
|
63
|
+
gone = i[:gems] - keep
|
|
64
|
+
return ["nothing to prune: every installed version is named by the running or pending setup"] if gone.empty?
|
|
65
|
+
|
|
66
|
+
gone.map do |nv|
|
|
67
|
+
next "would remove #{nv}" if dry_run
|
|
68
|
+
|
|
69
|
+
reply = Candid.decode(@runner.call("install_remove", Candid.text_arg(nv))).first
|
|
70
|
+
"remove #{nv}: #{reply}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def installed
|
|
77
|
+
v = Candid.decode(@runner.call("installed", Candid.empty_args, query: true)).first
|
|
78
|
+
Candid.record(v, :running, :pending, :gems, :partial, :load_paths, :running_paths)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def names_of(paths) = paths.map { _1.split("/").first }.uniq
|
|
82
|
+
|
|
83
|
+
def pending_note(i)
|
|
84
|
+
return "" if i[:pending].nil? || i[:pending] == i[:running]
|
|
85
|
+
|
|
86
|
+
" (takes effect at the next upgrade)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def standins(locked)
|
|
90
|
+
minimal = locked.find { _1.name == MINIMAL }
|
|
91
|
+
minimal ? @gem_files.standins(minimal) : []
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module RubyWasmIcInstaller
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# An Application's Gemfile.lock, read with Bundler's own parser (W29, Q8):
|
|
9
|
+
# each locked gem as a name and version, in the lock's order, with the
|
|
10
|
+
# sha256 the lock's CHECKSUMS section gives for its `ruby` platform, if any.
|
|
11
|
+
# Nothing is resolved or installed: the lock is the author's decision.
|
|
12
|
+
class Lockfile
|
|
13
|
+
Gem = Struct.new(:name, :version, :sha256, keyword_init: true) do
|
|
14
|
+
def name_version = "#{name}-#{version}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :gems
|
|
18
|
+
|
|
19
|
+
def self.read(path)
|
|
20
|
+
raise Error, "no lockfile at #{path}: run `bundle lock` first" unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
new(File.read(path), path: path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(text, path: "Gemfile.lock")
|
|
26
|
+
@path = path
|
|
27
|
+
parser = Bundler::LockfileParser.new(text)
|
|
28
|
+
@gems = to_gems(parser.specs, checksums(text))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# One entry per name: its `ruby` platform spec. A gem locked only for a
|
|
34
|
+
# native platform cannot be installed, which pure Ruby only means.
|
|
35
|
+
def to_gems(specs, sums)
|
|
36
|
+
by_name = specs.group_by(&:name)
|
|
37
|
+
by_name.map do |name, list|
|
|
38
|
+
spec = list.find { |s| s.platform.to_s == "ruby" }
|
|
39
|
+
unless spec
|
|
40
|
+
raise Error, "#{name} is locked only for #{list.map { _1.platform.to_s }.uniq.join(', ')} in #{@path}: " \
|
|
41
|
+
"only pure-Ruby gems can be installed into a canister " \
|
|
42
|
+
"(run `bundle lock --add-platform ruby`, or drop the gem)"
|
|
43
|
+
end
|
|
44
|
+
source = spec.source
|
|
45
|
+
unless source.is_a?(Bundler::Source::Rubygems)
|
|
46
|
+
raise Error, "#{name} comes from #{source.class.name.split('::').last.downcase} in #{@path}: " \
|
|
47
|
+
"only gems from a gem server can be installed"
|
|
48
|
+
end
|
|
49
|
+
Gem.new(name: name, version: spec.version.to_s, sha256: sums["#{name}-#{spec.version}"])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The CHECKSUMS section's `ruby`-platform sha256 for each name-version.
|
|
54
|
+
def checksums(text)
|
|
55
|
+
section = text[/^CHECKSUMS\n(.*?)(?:\n\S|\z)/m, 1].to_s
|
|
56
|
+
section.lines.each_with_object({}) do |line, h|
|
|
57
|
+
next unless (m = line.match(/\A\s+(\S+) \(([^)\s]+)\) sha256=([0-9a-f]{64})/))
|
|
58
|
+
|
|
59
|
+
h["#{m[1]}-#{m[2]}"] = m[3]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|