pray-cli 1.20.0 → 1.22.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/CHANGELOG.md +25 -0
- data/lib/pray/cli/commands/distribution.rb +34 -3
- data/lib/pray/cli/commands/packages.rb +9 -7
- data/lib/pray/cli/commands/update_latest.rb +11 -4
- data/lib/pray/cli/commands/workflow.rb +4 -2
- data/lib/pray/cli/help.rb +16 -1
- data/lib/pray/cli.rb +14 -0
- data/lib/pray/derived_metadata.rb +194 -0
- data/lib/pray/git_cache.rb +46 -19
- data/lib/pray/git_legacy.rb +36 -0
- data/lib/pray/git_materialize.rb +204 -38
- data/lib/pray/git_run.rb +30 -1
- data/lib/pray/git_store.rb +125 -14
- data/lib/pray/package_spec.rb +6 -1
- data/lib/pray/package_spec_paths.rb +18 -0
- data/lib/pray/publish.rb +49 -5
- data/lib/pray/registry.rb +12 -9
- data/lib/pray/sync.rb +1 -4
- data/lib/pray/tar_validation.rb +2 -0
- data/lib/pray/terminal.rb +28 -0
- data/lib/pray/trust_ops.rb +2 -1
- data/lib/pray/upstream_merge.rb +6 -2
- data/lib/pray/upstream_plan.rb +84 -0
- data/lib/pray/version.rb +2 -2
- data/lib/pray.rb +3 -0
- metadata +6 -2
data/lib/pray/git_materialize.rb
CHANGED
|
@@ -5,7 +5,10 @@ require "open3"
|
|
|
5
5
|
require_relative "error"
|
|
6
6
|
require_relative "git_clone"
|
|
7
7
|
require_relative "git_run"
|
|
8
|
+
require_relative "git_store"
|
|
8
9
|
require_relative "path_safety"
|
|
10
|
+
require_relative "resource_limits"
|
|
11
|
+
require_relative "tar_validation"
|
|
9
12
|
|
|
10
13
|
module Pray
|
|
11
14
|
module GitMaterialize
|
|
@@ -14,56 +17,129 @@ module Pray
|
|
|
14
17
|
|
|
15
18
|
module_function
|
|
16
19
|
|
|
17
|
-
def read_local_artifact_bytes(source_root, artifact)
|
|
18
|
-
if artifact.start_with?("file
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return File.binread(path)
|
|
23
|
-
end
|
|
24
|
-
if artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
|
|
25
|
-
raise Error.integrity("remote artifact path must be relative: #{artifact}")
|
|
20
|
+
def read_local_artifact_bytes(source_root, artifact, offline: false)
|
|
21
|
+
if artifact.include?("://") || artifact.start_with?("file:")
|
|
22
|
+
raise Error.integrity(
|
|
23
|
+
"local and git catalog artifacts must be relative paths under the distribution root, not URLs: #{artifact}"
|
|
24
|
+
)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
path = PathSafety.join_under_root(source_root, artifact)
|
|
29
28
|
raise Error.resolution("package artifact path escapes distribution root") unless path
|
|
30
29
|
|
|
31
|
-
materialize_catalog_file(source_root, artifact) unless File.exist?(path)
|
|
30
|
+
materialize_catalog_file(source_root, artifact, offline: offline) unless File.exist?(path)
|
|
32
31
|
raise Error.resolution("package artifact missing at #{path}") unless File.exist?(path)
|
|
33
32
|
|
|
34
33
|
File.binread(path)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh)
|
|
38
|
-
|
|
36
|
+
def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh, offline: false)
|
|
37
|
+
require_revision_present!(git_dir, revision, offline: offline)
|
|
38
|
+
GitStore.with_git_cache_lock(dest) do
|
|
39
|
+
return if !refresh && catalog_matches?(dest, git_dir, revision)
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
staging = "#{dest}.partial"
|
|
42
|
+
FileUtils.rm_rf(staging)
|
|
43
|
+
FileUtils.mkdir_p(staging)
|
|
44
|
+
unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
|
|
45
|
+
unpack_archive_prefix(git_dir, staging, revision, prefix, offline: offline)
|
|
46
|
+
end
|
|
47
|
+
unless unpacked
|
|
48
|
+
FileUtils.rm_rf(staging)
|
|
49
|
+
if offline
|
|
50
|
+
raise Error.resolution(
|
|
51
|
+
"git catalog at revision #{revision} is not materializable locally and offline mode is enabled"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
raise Error.resolution("no pray distribution root in git source at revision #{revision}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
File.write(File.join(staging, REVISION_MARKER), revision)
|
|
58
|
+
File.write(File.join(staging, GIT_DIR_MARKER), git_dir)
|
|
59
|
+
publish_catalog_tree!(dest, staging)
|
|
44
60
|
end
|
|
45
|
-
|
|
61
|
+
end
|
|
46
62
|
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
def publish_catalog_tree!(dest, staging)
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
65
|
+
if File.exist?(dest)
|
|
66
|
+
backup = "#{dest}.previous"
|
|
67
|
+
FileUtils.rm_rf(backup)
|
|
68
|
+
File.rename(dest, backup)
|
|
69
|
+
begin
|
|
70
|
+
File.rename(staging, dest)
|
|
71
|
+
rescue
|
|
72
|
+
File.rename(backup, dest) if File.exist?(backup)
|
|
73
|
+
FileUtils.rm_rf(staging)
|
|
74
|
+
raise
|
|
75
|
+
end
|
|
76
|
+
FileUtils.rm_rf(backup)
|
|
77
|
+
else
|
|
78
|
+
File.rename(staging, dest)
|
|
79
|
+
end
|
|
49
80
|
end
|
|
81
|
+
private_class_method :publish_catalog_tree!
|
|
50
82
|
|
|
51
|
-
def materialize_catalog_file(source_root, relative)
|
|
52
|
-
|
|
83
|
+
def materialize_catalog_file(source_root, relative, offline: false)
|
|
84
|
+
full_path = File.join(source_root, relative)
|
|
85
|
+
return if File.file?(full_path)
|
|
53
86
|
|
|
54
87
|
markers = find_catalog_markers(source_root)
|
|
55
88
|
return unless markers
|
|
56
89
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
markers.fetch(:git_dir),
|
|
61
|
-
"
|
|
62
|
-
markers.fetch(:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
GitStore.with_git_cache_lock(markers.fetch(:catalog_root)) do
|
|
91
|
+
return if File.file?(full_path)
|
|
92
|
+
|
|
93
|
+
require_revision_present!(markers.fetch(:git_dir), markers.fetch(:revision), offline: offline)
|
|
94
|
+
repo_relative = full_path.delete_prefix(markers.fetch(:catalog_root)).delete_prefix("/")
|
|
95
|
+
object = "#{markers.fetch(:revision)}:#{repo_relative}"
|
|
96
|
+
require_blob_within_limit!(markers.fetch(:git_dir), object, offline: offline)
|
|
97
|
+
env = GitRun.git_env(offline_git_env(offline))
|
|
98
|
+
stdout, status = Open3.capture2(
|
|
99
|
+
env,
|
|
100
|
+
"git",
|
|
101
|
+
"-c",
|
|
102
|
+
"protocol.file.allow=always",
|
|
103
|
+
"-C",
|
|
104
|
+
markers.fetch(:git_dir),
|
|
105
|
+
"show",
|
|
106
|
+
object,
|
|
107
|
+
stdin_data: ""
|
|
108
|
+
)
|
|
109
|
+
unless status.success?
|
|
110
|
+
if offline
|
|
111
|
+
raise Error.resolution(
|
|
112
|
+
"git catalog blob #{repo_relative} at revision #{markers.fetch(:revision)} is not available locally and offline mode is enabled"
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
annotate_git_materialize_failure!(
|
|
116
|
+
markers.fetch(:git_dir),
|
|
117
|
+
markers.fetch(:revision),
|
|
118
|
+
"git show failed"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
write_catalog_blob_atomic!(full_path, stdout)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def require_revision_present!(git_dir, revision, offline: false)
|
|
126
|
+
return if GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
|
|
127
|
+
|
|
128
|
+
if offline
|
|
129
|
+
raise Error.resolution(
|
|
130
|
+
"git revision #{revision} is not available in #{git_dir} and offline mode is enabled (git_revision_unfetched)."
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
raise Error.resolution(
|
|
134
|
+
"git revision #{revision} is not available in #{git_dir} (git_revision_unfetched). " \
|
|
135
|
+
"Run `pray install --locked`, or clear the project git cache and shared bare then install again."
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def annotate_git_materialize_failure!(git_dir, revision, detail)
|
|
140
|
+
raise Error.resolution(
|
|
141
|
+
"git catalog materialize failed for revision #{revision} in #{git_dir} (git_cache_stale): #{detail}. " \
|
|
142
|
+
"Run `pray install --locked`, or clear `.pray/cache/git` and the shared bare then install again."
|
|
67
143
|
)
|
|
68
144
|
end
|
|
69
145
|
|
|
@@ -73,6 +149,7 @@ module Pray
|
|
|
73
149
|
return false unless File.file?(revision_path) && File.file?(git_dir_path)
|
|
74
150
|
return false unless File.read(revision_path).strip == revision
|
|
75
151
|
return false unless File.read(git_dir_path).strip == git_dir
|
|
152
|
+
return false unless GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
|
|
76
153
|
|
|
77
154
|
File.directory?(File.join(dest, "v1", "packages")) ||
|
|
78
155
|
File.directory?(File.join(dest, "prayers", "v1", "packages")) ||
|
|
@@ -81,8 +158,10 @@ module Pray
|
|
|
81
158
|
false
|
|
82
159
|
end
|
|
83
160
|
|
|
84
|
-
def unpack_archive_prefix(git_dir, dest, revision, prefix)
|
|
85
|
-
|
|
161
|
+
def unpack_archive_prefix(git_dir, dest, revision, prefix, offline: false)
|
|
162
|
+
return false unless tree_prefix_within_limits?(git_dir, revision, prefix, offline: offline)
|
|
163
|
+
|
|
164
|
+
env = GitRun.git_env(offline_git_env(offline).merge("GIT_TERMINAL_PROMPT" => "0"))
|
|
86
165
|
stdout, _stderr, status = Open3.capture3(
|
|
87
166
|
env,
|
|
88
167
|
"git",
|
|
@@ -98,13 +177,78 @@ module Pray
|
|
|
98
177
|
stdin_data: ""
|
|
99
178
|
)
|
|
100
179
|
return false unless status.success? && !stdout.empty?
|
|
180
|
+
if stdout.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
|
|
181
|
+
raise Error.integrity(
|
|
182
|
+
"git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
101
185
|
|
|
102
|
-
|
|
103
|
-
raise Error.resolution("failed to unpack git catalog archive") unless extract_status.success?
|
|
104
|
-
|
|
186
|
+
TarValidation.extract!(stdout, dest)
|
|
105
187
|
true
|
|
106
188
|
end
|
|
107
189
|
|
|
190
|
+
def require_blob_within_limit!(git_dir, object, offline: false)
|
|
191
|
+
env = GitRun.git_env(offline_git_env(offline))
|
|
192
|
+
stdout, status = Open3.capture2(
|
|
193
|
+
env, "git", "-c", "protocol.file.allow=always", "-C", git_dir, "cat-file", "-s", object,
|
|
194
|
+
stdin_data: ""
|
|
195
|
+
)
|
|
196
|
+
return unless status.success?
|
|
197
|
+
|
|
198
|
+
size = stdout.strip.to_i
|
|
199
|
+
return if size <= ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
|
|
200
|
+
|
|
201
|
+
raise Error.integrity(
|
|
202
|
+
"git catalog blob exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def tree_prefix_within_limits?(git_dir, revision, prefix, offline: false)
|
|
207
|
+
env = GitRun.git_env(offline_git_env(offline))
|
|
208
|
+
stdout, status = Open3.capture2(
|
|
209
|
+
env, "git", "-c", "protocol.file.allow=always", "-C", git_dir,
|
|
210
|
+
"ls-tree", "-r", "-l", revision, "--", prefix, stdin_data: ""
|
|
211
|
+
)
|
|
212
|
+
return false unless status.success? && !stdout.strip.empty?
|
|
213
|
+
|
|
214
|
+
entry_count = 0
|
|
215
|
+
total_bytes = 0
|
|
216
|
+
stdout.each_line do |line|
|
|
217
|
+
parts = line.split
|
|
218
|
+
next unless parts[1] == "blob"
|
|
219
|
+
|
|
220
|
+
size = parts[3].to_i
|
|
221
|
+
entry_count += 1
|
|
222
|
+
if entry_count > ResourceLimits::MAX_ARCHIVE_ENTRIES
|
|
223
|
+
raise Error.integrity(
|
|
224
|
+
"git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRIES} entries"
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
if size > ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
|
|
228
|
+
raise Error.integrity(
|
|
229
|
+
"git catalog archive entry exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
total_bytes += size
|
|
233
|
+
if total_bytes > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
|
|
234
|
+
raise Error.integrity(
|
|
235
|
+
"git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
entry_count.positive?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def write_catalog_blob_atomic!(full_path, bytes)
|
|
243
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
244
|
+
partial = "#{full_path}.partial"
|
|
245
|
+
File.binwrite(partial, bytes)
|
|
246
|
+
File.rename(partial, full_path)
|
|
247
|
+
rescue
|
|
248
|
+
FileUtils.rm_f(partial)
|
|
249
|
+
raise
|
|
250
|
+
end
|
|
251
|
+
|
|
108
252
|
def find_catalog_markers(start)
|
|
109
253
|
current = start
|
|
110
254
|
8.times do
|
|
@@ -113,9 +257,9 @@ module Pray
|
|
|
113
257
|
if File.file?(revision_path) && File.file?(git_dir_path)
|
|
114
258
|
revision = File.read(revision_path).strip
|
|
115
259
|
git_dir = File.read(git_dir_path).strip
|
|
116
|
-
return
|
|
260
|
+
return unless acceptable_revision?(revision) && acceptable_shared_bare?(git_dir)
|
|
117
261
|
|
|
118
|
-
return {git_dir: git_dir, revision: revision,
|
|
262
|
+
return {git_dir: git_dir, revision: revision, catalog_root: current}
|
|
119
263
|
end
|
|
120
264
|
parent = File.dirname(current)
|
|
121
265
|
return if parent == current
|
|
@@ -124,5 +268,27 @@ module Pray
|
|
|
124
268
|
end
|
|
125
269
|
nil
|
|
126
270
|
end
|
|
271
|
+
|
|
272
|
+
def acceptable_revision?(revision)
|
|
273
|
+
return false if revision.empty? || revision.start_with?("-")
|
|
274
|
+
return false unless [40, 64].include?(revision.length)
|
|
275
|
+
|
|
276
|
+
revision.match?(/\A[0-9a-fA-F]+\z/)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def acceptable_shared_bare?(git_dir)
|
|
280
|
+
return false unless File.file?(File.join(git_dir, "HEAD"))
|
|
281
|
+
return false unless File.directory?(File.join(git_dir, "objects"))
|
|
282
|
+
|
|
283
|
+
root = GitStore.global_cache_root
|
|
284
|
+
return false unless root
|
|
285
|
+
|
|
286
|
+
expected = File.join(root, "git")
|
|
287
|
+
PathSafety.path_under_root?(expected, git_dir)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def offline_git_env(offline)
|
|
291
|
+
offline ? {"GIT_NO_LAZY_FETCH" => "1"} : {}
|
|
292
|
+
end
|
|
127
293
|
end
|
|
128
294
|
end
|
data/lib/pray/git_run.rb
CHANGED
|
@@ -5,8 +5,37 @@ require_relative "error"
|
|
|
5
5
|
|
|
6
6
|
module Pray
|
|
7
7
|
module GitRun
|
|
8
|
+
# Mirrors git's own sanitize_repo_env: `git rev-parse --local-env-vars` minus
|
|
9
|
+
# GIT_CONFIG_PARAMETERS and GIT_CONFIG_COUNT. Git exports these to hooks, so
|
|
10
|
+
# pray started from a hook would otherwise run every cache command against the
|
|
11
|
+
# host repository. The two config variables pass through, as they do when git
|
|
12
|
+
# enters a submodule, because callers such as CI jobs use them to hand git
|
|
13
|
+
# credentials or URL rewrites, and they cannot select a repository.
|
|
14
|
+
REPOSITORY_LOCAL_VARIABLES = %w[
|
|
15
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES
|
|
16
|
+
GIT_CONFIG
|
|
17
|
+
GIT_OBJECT_DIRECTORY
|
|
18
|
+
GIT_DIR
|
|
19
|
+
GIT_WORK_TREE
|
|
20
|
+
GIT_IMPLICIT_WORK_TREE
|
|
21
|
+
GIT_GRAFT_FILE
|
|
22
|
+
GIT_INDEX_FILE
|
|
23
|
+
GIT_NO_REPLACE_OBJECTS
|
|
24
|
+
GIT_REPLACE_REF_BASE
|
|
25
|
+
GIT_PREFIX
|
|
26
|
+
GIT_SHALLOW_FILE
|
|
27
|
+
GIT_COMMON_DIR
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
8
30
|
module_function
|
|
9
31
|
|
|
32
|
+
# Process.spawn merges this hash into the inherited environment, so a nil
|
|
33
|
+
# value is what removes a variable. Credential, SSH, proxy and global config
|
|
34
|
+
# variables still pass through.
|
|
35
|
+
def git_env(extra = {})
|
|
36
|
+
REPOSITORY_LOCAL_VARIABLES.to_h { |name| [name, nil] }.merge(extra)
|
|
37
|
+
end
|
|
38
|
+
|
|
10
39
|
def run_git(cwd, *arguments)
|
|
11
40
|
output, status = capture_git(cwd, *arguments)
|
|
12
41
|
return if status.success?
|
|
@@ -20,7 +49,7 @@ module Pray
|
|
|
20
49
|
end
|
|
21
50
|
|
|
22
51
|
def capture_git(cwd, *arguments)
|
|
23
|
-
env =
|
|
52
|
+
env = git_env("GIT_TERMINAL_PROMPT" => "0")
|
|
24
53
|
Open3.capture2e(
|
|
25
54
|
env,
|
|
26
55
|
"git",
|
data/lib/pray/git_store.rb
CHANGED
|
@@ -8,27 +8,28 @@ require_relative "git_clone"
|
|
|
8
8
|
|
|
9
9
|
module Pray
|
|
10
10
|
module GitStore
|
|
11
|
+
READY_MARKER = ".pray-ready"
|
|
12
|
+
|
|
11
13
|
module_function
|
|
12
14
|
|
|
13
15
|
def ensure_global_git_db(clone_url, pinned_revision:, refresh:, offline:, working_directory:)
|
|
14
16
|
db = global_git_cache_directory(clone_url)
|
|
15
17
|
raise Error.resolution("git object cache is not configured") unless db
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
with_git_cache_lock(db) do
|
|
20
|
+
unless global_git_cache_ready?(db)
|
|
21
|
+
raise Error.resolution(offline_git_source_uncached(clone_url)) if offline
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
|
|
28
|
-
return [db, pinned_revision]
|
|
29
|
-
end
|
|
23
|
+
install_bare_clone(working_directory, clone_url, db)
|
|
24
|
+
end
|
|
25
|
+
fetch_origin_tip(db, clone_url) if refresh && !offline
|
|
26
|
+
if pinned_revision
|
|
27
|
+
ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
|
|
28
|
+
return [db, pinned_revision]
|
|
29
|
+
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
[db, git_head_revision(db)]
|
|
32
|
+
end
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def global_cache_root
|
|
@@ -45,13 +46,98 @@ module Pray
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def global_git_cache_ready?(global_cache)
|
|
48
|
-
|
|
49
|
+
return false unless File.file?(File.join(global_cache, "HEAD"))
|
|
50
|
+
return false unless File.directory?(File.join(global_cache, "objects"))
|
|
51
|
+
return true if File.file?(File.join(global_cache, READY_MARKER))
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
git_head_revision(global_cache)
|
|
55
|
+
true
|
|
56
|
+
rescue Error
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mark_git_cache_ready(global_cache)
|
|
62
|
+
File.write(File.join(global_cache, READY_MARKER), "1")
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
def offline_git_source_uncached(clone_url)
|
|
52
66
|
"git source #{clone_url} is not cached locally and offline mode is enabled"
|
|
53
67
|
end
|
|
54
68
|
|
|
69
|
+
def install_bare_clone(working_directory, clone_url, db)
|
|
70
|
+
FileUtils.mkdir_p(File.dirname(db))
|
|
71
|
+
staging = "#{db}.partial"
|
|
72
|
+
FileUtils.rm_rf(staging) if File.exist?(staging)
|
|
73
|
+
GitClone.clone_bare_git_db(working_directory, clone_url, staging, quiet: false)
|
|
74
|
+
ensure_git_remote_origin(staging, clone_url)
|
|
75
|
+
git_head_revision(staging)
|
|
76
|
+
mark_git_cache_ready(staging)
|
|
77
|
+
if File.exist?(db)
|
|
78
|
+
if global_git_cache_ready?(db)
|
|
79
|
+
FileUtils.rm_rf(staging)
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
FileUtils.rm_rf(db)
|
|
83
|
+
end
|
|
84
|
+
FileUtils.mv(staging, db)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
LOCK_ATTEMPTS = 2_400
|
|
88
|
+
LOCK_RETRY_SECONDS = 0.025
|
|
89
|
+
STALE_LOCK_SECONDS = 600
|
|
90
|
+
|
|
91
|
+
def with_git_cache_lock(db)
|
|
92
|
+
lock_directory = "#{db}.lock.d"
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(lock_directory))
|
|
94
|
+
acquired = false
|
|
95
|
+
LOCK_ATTEMPTS.times do
|
|
96
|
+
Dir.mkdir(lock_directory)
|
|
97
|
+
write_lock_owner!(lock_directory)
|
|
98
|
+
acquired = true
|
|
99
|
+
break
|
|
100
|
+
rescue Errno::EEXIST
|
|
101
|
+
clear_stale_lock!(lock_directory)
|
|
102
|
+
sleep LOCK_RETRY_SECONDS
|
|
103
|
+
end
|
|
104
|
+
unless acquired
|
|
105
|
+
raise Error.resolution(
|
|
106
|
+
"timed out waiting for git cache lock #{lock_directory}. " \
|
|
107
|
+
"If no other pray process is using that cache, remove the lock directory and retry."
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
yield
|
|
113
|
+
ensure
|
|
114
|
+
FileUtils.rm_rf(lock_directory)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_lock_owner!(lock_directory)
|
|
119
|
+
File.write(File.join(lock_directory, "owner"), "#{Process.pid}\n#{Time.now.to_i}\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def clear_stale_lock!(lock_directory)
|
|
123
|
+
age = lock_age_seconds(lock_directory)
|
|
124
|
+
return if age < STALE_LOCK_SECONDS
|
|
125
|
+
|
|
126
|
+
FileUtils.rm_rf(lock_directory)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def lock_age_seconds(lock_directory)
|
|
130
|
+
owner = File.join(lock_directory, "owner")
|
|
131
|
+
if File.file?(owner)
|
|
132
|
+
stamp = File.read(owner).lines[1].to_s.strip.to_i
|
|
133
|
+
return Time.now.to_i - stamp if stamp.positive?
|
|
134
|
+
end
|
|
135
|
+
Time.now.to_i - File.mtime(lock_directory).to_i
|
|
136
|
+
rescue Errno::ENOENT
|
|
137
|
+
0
|
|
138
|
+
end
|
|
139
|
+
private_class_method :write_lock_owner!, :clear_stale_lock!, :lock_age_seconds
|
|
140
|
+
|
|
55
141
|
def ensure_revision_in_db(db, clone_url, revision, allow_fetch)
|
|
56
142
|
return if GitRun.try_run_git(db, "cat-file", "-e", revision)
|
|
57
143
|
unless allow_fetch
|
|
@@ -73,6 +159,7 @@ module Pray
|
|
|
73
159
|
end
|
|
74
160
|
|
|
75
161
|
def ensure_git_remote_origin(repository, clone_url)
|
|
162
|
+
require_own_git_dir(repository)
|
|
76
163
|
if GitRun.try_run_git(repository, "remote", "get-url", "origin")
|
|
77
164
|
GitRun.run_git(repository, "remote", "set-url", "origin", clone_url)
|
|
78
165
|
else
|
|
@@ -80,12 +167,36 @@ module Pray
|
|
|
80
167
|
end
|
|
81
168
|
end
|
|
82
169
|
|
|
170
|
+
# Git searches parent directories when `repository` is not a valid git
|
|
171
|
+
# directory, so a damaged cache inside another checkout would otherwise
|
|
172
|
+
# rewrite that checkout's origin.
|
|
173
|
+
def require_own_git_dir(repository)
|
|
174
|
+
output, status = GitRun.capture_git(repository, "rev-parse", "--absolute-git-dir")
|
|
175
|
+
resolved = status.success? ? output.strip : ""
|
|
176
|
+
own = [repository, File.join(repository, ".git")].any? do |candidate|
|
|
177
|
+
real_path(candidate) && real_path(candidate) == real_path(resolved)
|
|
178
|
+
end
|
|
179
|
+
return if own
|
|
180
|
+
|
|
181
|
+
raise Error.resolution(
|
|
182
|
+
"git cache #{repository} is not a usable git repository (git resolved #{resolved.inspect}). " \
|
|
183
|
+
"Remove it and run the command again."
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def real_path(path)
|
|
188
|
+
File.realpath(path)
|
|
189
|
+
rescue SystemCallError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
83
193
|
def fetch_origin_tip(db, clone_url)
|
|
84
194
|
ensure_git_remote_origin(db, clone_url)
|
|
85
195
|
unless GitRun.try_run_git(db, "fetch", "--depth", "1", "--filter=blob:none", "origin")
|
|
86
196
|
GitRun.run_git(db, "fetch", "--depth", "1", "origin")
|
|
87
197
|
end
|
|
88
198
|
GitRun.run_git(db, "update-ref", "HEAD", "FETCH_HEAD")
|
|
199
|
+
mark_git_cache_ready(db)
|
|
89
200
|
end
|
|
90
201
|
|
|
91
202
|
def fetch_unshallow(db)
|
data/lib/pray/package_spec.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Pray
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def tree_hash_for_root(root)
|
|
58
|
+
validate_relative_paths!
|
|
58
59
|
file_bytes = {}
|
|
59
60
|
files.each do |file|
|
|
60
61
|
path = File.join(root, file)
|
|
@@ -196,7 +197,11 @@ module Pray
|
|
|
196
197
|
expect_start!
|
|
197
198
|
spec = PackageSpec.new
|
|
198
199
|
while (statement = next_statement)
|
|
199
|
-
|
|
200
|
+
if statement == "end"
|
|
201
|
+
package = spec.canonicalized
|
|
202
|
+
package.validate_relative_paths!
|
|
203
|
+
return package
|
|
204
|
+
end
|
|
200
205
|
|
|
201
206
|
apply_statement(spec, statement)
|
|
202
207
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_safety"
|
|
4
|
+
|
|
5
|
+
module Pray
|
|
6
|
+
class PackageSpec
|
|
7
|
+
def validate_relative_paths!
|
|
8
|
+
files.each { |file| PathSafety.validate_archive_member_path!(file) }
|
|
9
|
+
exports.each_value do |export|
|
|
10
|
+
PathSafety.validate_archive_member_path!(export.path)
|
|
11
|
+
PathSafety.validate_archive_member_path!(export.default_path) if export.default_path
|
|
12
|
+
end
|
|
13
|
+
skills.each_value { |skill| PathSafety.validate_archive_member_path!(skill.path) }
|
|
14
|
+
templates.each_value { |template| PathSafety.validate_archive_member_path!(template.path) }
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|