gemkeeper 0.7.2 → 0.8.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 +18 -1
- data/README.md +11 -11
- data/lib/gemkeeper/bundler_mirror_configurator.rb +1 -1
- data/lib/gemkeeper/cli/commands/list.rb +2 -2
- data/lib/gemkeeper/cli/commands/server/start.rb +4 -4
- data/lib/gemkeeper/cli/commands/server/status.rb +3 -3
- data/lib/gemkeeper/cli/commands/server/stop.rb +3 -3
- data/lib/gemkeeper/cli/commands/sync.rb +1 -1
- data/lib/gemkeeper/compact_index_server/cache_meta.rb +34 -0
- data/lib/gemkeeper/compact_index_server/cache_store.rb +64 -0
- data/lib/gemkeeper/compact_index_server/gem_cache.rb +88 -0
- data/lib/gemkeeper/compact_index_server/gem_index.rb +78 -0
- data/lib/gemkeeper/compact_index_server/index_merger.rb +81 -0
- data/lib/gemkeeper/compact_index_server/response.rb +12 -0
- data/lib/gemkeeper/compact_index_server/response_builder.rb +63 -0
- data/lib/gemkeeper/compact_index_server/rubygems_client.rb +59 -0
- data/lib/gemkeeper/compact_index_server/spec_mapper.rb +38 -0
- data/lib/gemkeeper/compact_index_server/upload_handler.rb +36 -0
- data/lib/gemkeeper/compact_index_server/upstream_cache.rb +26 -0
- data/lib/gemkeeper/compact_index_server.rb +131 -0
- data/lib/gemkeeper/configuration.rb +1 -1
- data/lib/gemkeeper/gem_syncer.rb +53 -84
- data/lib/gemkeeper/gem_uploader.rb +26 -18
- data/lib/gemkeeper/rackup_process.rb +12 -7
- data/lib/gemkeeper/repo_fetcher.rb +80 -0
- data/lib/gemkeeper/server_manager.rb +1 -1
- data/lib/gemkeeper/version.rb +1 -1
- data/lib/gemkeeper.rb +2 -0
- data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-consolidated-v-1.md +168 -0
- data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-claude.md +124 -0
- data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-codex.md +125 -0
- data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-copilot.md +261 -0
- data/specs/20260529-091429-replace-geminabox-compact-proxy/spec.md +360 -0
- data/specs/20260529-131354-sync-serve-cache-contract/critique-consolidated-v-1.md +95 -0
- data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-claude.md +47 -0
- data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-codex.md +112 -0
- data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-copilot.md +169 -0
- data/specs/20260529-131354-sync-serve-cache-contract/implementation-summary.md +59 -0
- data/specs/20260529-131354-sync-serve-cache-contract/spec.md +169 -0
- metadata +38 -28
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
require_relative "response"
|
|
7
|
+
|
|
8
|
+
module Gemkeeper
|
|
9
|
+
class CompactIndexServer
|
|
10
|
+
# HTTP client for RubyGems.org compact index endpoints.
|
|
11
|
+
# Speaks conditional GET (ETag) and gzip; raises UpstreamUnavailableError
|
|
12
|
+
# on any network failure so callers can fall back to cache.
|
|
13
|
+
class RubygemsClient
|
|
14
|
+
HOST = "rubygems.org"
|
|
15
|
+
OPEN_TIMEOUT = 5
|
|
16
|
+
READ_TIMEOUT = 10
|
|
17
|
+
|
|
18
|
+
# Returns :not_modified, or {status:, body:, etag:}.
|
|
19
|
+
def get(path, if_none_match = nil)
|
|
20
|
+
uri = URI("https://#{HOST}#{path}")
|
|
21
|
+
req = build_request(uri, if_none_match)
|
|
22
|
+
|
|
23
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
24
|
+
open_timeout: OPEN_TIMEOUT,
|
|
25
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
26
|
+
interpret(http.request(req))
|
|
27
|
+
end
|
|
28
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
29
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => error
|
|
30
|
+
raise UpstreamUnavailableError, error.message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_request(uri, if_none_match)
|
|
36
|
+
req = Net::HTTP::Get.new(uri)
|
|
37
|
+
req["If-None-Match"] = if_none_match if if_none_match
|
|
38
|
+
req["Accept-Encoding"] = "gzip"
|
|
39
|
+
req
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def interpret(res)
|
|
43
|
+
case res.code.to_i
|
|
44
|
+
when 304 then Response.new(304, nil, nil)
|
|
45
|
+
when 200 then Response.new(200, decompress(res.body.to_s, res["Content-Encoding"]), res["ETag"])
|
|
46
|
+
else Response.new(res.code.to_i, res.body.to_s, nil)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def decompress(body, encoding)
|
|
51
|
+
return body unless encoding&.include?("gzip")
|
|
52
|
+
|
|
53
|
+
Zlib::GzipReader.new(StringIO.new(body)).read
|
|
54
|
+
rescue Zlib::Error
|
|
55
|
+
body
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "compact_index"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Gemkeeper
|
|
7
|
+
class CompactIndexServer
|
|
8
|
+
# Maps a Gem::Specification to the compact-index representations the index is
|
|
9
|
+
# built from: the on-disk gem filename and a CompactIndex::GemVersion entry.
|
|
10
|
+
module SpecMapper
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def filename(spec)
|
|
14
|
+
platform = spec.platform.to_s
|
|
15
|
+
suffix = platform.empty? || platform == "ruby" ? "" : "-#{platform}"
|
|
16
|
+
"#{spec.name}-#{spec.version}#{suffix}.gem"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def gem_version(spec, gem_file)
|
|
20
|
+
CompactIndex::GemVersion.new(
|
|
21
|
+
spec.version.to_s,
|
|
22
|
+
spec.platform.to_s,
|
|
23
|
+
Digest::SHA256.file(gem_file).hexdigest,
|
|
24
|
+
nil,
|
|
25
|
+
dependencies(spec),
|
|
26
|
+
spec.required_ruby_version&.to_s,
|
|
27
|
+
spec.required_rubygems_version&.to_s
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dependencies(spec)
|
|
32
|
+
(spec.runtime_dependencies || []).map do |dep|
|
|
33
|
+
CompactIndex::Dependency.new(dep.name, dep.requirement.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module Gemkeeper
|
|
7
|
+
class CompactIndexServer
|
|
8
|
+
# Handles POST /upload: reads the multipart gem file, parses its spec,
|
|
9
|
+
# and adds it to the index. Maps failures to compact-index HTTP responses.
|
|
10
|
+
class UploadHandler
|
|
11
|
+
def initialize(index)
|
|
12
|
+
@index = index
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(req)
|
|
16
|
+
upload = req.params["file"]
|
|
17
|
+
return text(400, "Missing file parameter") unless upload
|
|
18
|
+
|
|
19
|
+
text(201, "Uploaded #{add(upload[:tempfile].path)}")
|
|
20
|
+
rescue Errno::EEXIST
|
|
21
|
+
text(409, "Gem already exists")
|
|
22
|
+
rescue Gem::Exception, Gem::Package::FormatError, Zlib::Error, TypeError, ArgumentError => error
|
|
23
|
+
text(422, "Invalid gem: #{error.message}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def add(tempfile_path)
|
|
29
|
+
spec = Gem::Package.new(tempfile_path).spec
|
|
30
|
+
@index.add(tempfile_path, spec)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def text(status, message) = [status, { "content-type" => "text/plain" }, [message]]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_store"
|
|
4
|
+
require_relative "gem_cache"
|
|
5
|
+
require_relative "index_merger"
|
|
6
|
+
require_relative "rubygems_client"
|
|
7
|
+
|
|
8
|
+
module Gemkeeper
|
|
9
|
+
class CompactIndexServer
|
|
10
|
+
# Composition root for upstream RubyGems.org caching. Wires a shared on-disk
|
|
11
|
+
# store and HTTP client into the merged-index and per-gem cache collaborators.
|
|
12
|
+
class UpstreamCache
|
|
13
|
+
def initialize(cache_dir)
|
|
14
|
+
store = CacheStore.new(File.join(cache_dir, "rubygems_cache"))
|
|
15
|
+
client = RubygemsClient.new
|
|
16
|
+
@merger = IndexMerger.new(store, client)
|
|
17
|
+
@gems = GemCache.new(store, client)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def merged_versions(private_gems) = @merger.versions(private_gems)
|
|
21
|
+
def merged_names(private_names) = @merger.names(private_names)
|
|
22
|
+
def info(gemname) = @gems.info(gemname)
|
|
23
|
+
def gem_binary(filename) = @gems.binary(filename)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "compact_index"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "rack"
|
|
6
|
+
|
|
7
|
+
require_relative "compact_index_server/cache_store"
|
|
8
|
+
require_relative "compact_index_server/gem_index"
|
|
9
|
+
require_relative "compact_index_server/response_builder"
|
|
10
|
+
require_relative "compact_index_server/upload_handler"
|
|
11
|
+
require_relative "compact_index_server/upstream_cache"
|
|
12
|
+
|
|
13
|
+
module Gemkeeper
|
|
14
|
+
# Rack application implementing the Bundler compact index protocol.
|
|
15
|
+
# Delegates private gem state to GemIndex and upstream caching to UpstreamCache.
|
|
16
|
+
class CompactIndexServer
|
|
17
|
+
VALID_NAME = /\A[a-zA-Z0-9._-]+\z/
|
|
18
|
+
RESOURCE_ROUTES = {
|
|
19
|
+
info: %r{\A/info/([^/]+)\z},
|
|
20
|
+
gem: %r{\A/gems/([^/]+\.gem)\z}
|
|
21
|
+
}.freeze
|
|
22
|
+
# Private-store presence check for `gemkeeper sync`; reads the index only,
|
|
23
|
+
# never the upstream proxy (unlike /info), so it can't be fooled by a public gem.
|
|
24
|
+
PRESENCE_ROUTE = %r{\A/gemkeeper/has/([^/]+)/([^/]+)\z}
|
|
25
|
+
|
|
26
|
+
def initialize(gems_path:, cache_dir:)
|
|
27
|
+
@index = GemIndex.new(File.join(gems_path, "gems"))
|
|
28
|
+
@cache = UpstreamCache.new(cache_dir)
|
|
29
|
+
@upload = UploadHandler.new(@index)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
req = Rack::Request.new(env)
|
|
34
|
+
path = req.path_info
|
|
35
|
+
case [req.request_method, path]
|
|
36
|
+
in ["GET", "/"] then health
|
|
37
|
+
in ["GET", "/names"] then serve_names(req)
|
|
38
|
+
in ["GET", "/versions"] then serve_versions(req)
|
|
39
|
+
in ["POST", "/upload"] then @upload.call(req)
|
|
40
|
+
in ["GET", String => p] if p.start_with?("/gemkeeper/") then serve_presence(p)
|
|
41
|
+
in ["GET", _] then serve_resource(path, req)
|
|
42
|
+
else not_found
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# ── Routing ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def serve_presence(path)
|
|
51
|
+
match = path.match(PRESENCE_ROUTE)
|
|
52
|
+
return not_found unless match
|
|
53
|
+
|
|
54
|
+
name, version = match.captures
|
|
55
|
+
return invalid_name unless VALID_NAME.match?(name) && VALID_NAME.match?(version)
|
|
56
|
+
|
|
57
|
+
@index.serves?(name, version) ? present : not_found
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serve_resource(path, req)
|
|
61
|
+
type, name = match_resource(path)
|
|
62
|
+
return not_found unless type
|
|
63
|
+
return invalid_name unless VALID_NAME.match?(name)
|
|
64
|
+
|
|
65
|
+
type == :info ? serve_info(name, req) : serve_gem_file(name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def match_resource(path)
|
|
69
|
+
RESOURCE_ROUTES.each do |type, pattern|
|
|
70
|
+
match = path.match(pattern)
|
|
71
|
+
return [type, match[1]] if match
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ── Serving ───────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def serve_names(req)
|
|
79
|
+
result = @cache.merged_names(@index.keys)
|
|
80
|
+
serve_index_file(result[:path], result[:etag], req)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def serve_versions(req)
|
|
84
|
+
result = @cache.merged_versions(@index.values)
|
|
85
|
+
serve_index_file(result[:path], result[:etag], req)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serve_info(gemname, req)
|
|
89
|
+
gem = @index[gemname]
|
|
90
|
+
gem ? serve_private_info(gem, req) : serve_upstream_info(gemname, req)
|
|
91
|
+
rescue UpstreamUnavailableError
|
|
92
|
+
upstream_unavailable
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def serve_private_info(gem, req)
|
|
96
|
+
body = CompactIndex.info(gem.versions)
|
|
97
|
+
ResponseBuilder.new(req).index(body, Digest::SHA256.hexdigest(body))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def serve_upstream_info(gemname, req)
|
|
101
|
+
result = @cache.info(gemname)
|
|
102
|
+
result ? ResponseBuilder.new(req).index(result[:body], result[:etag]) : not_found
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def serve_gem_file(filename)
|
|
106
|
+
path = @index.gem_path(filename) || @cache.gem_binary(filename)
|
|
107
|
+
path ? ResponseBuilder.file(path) : not_found
|
|
108
|
+
rescue UpstreamUnavailableError
|
|
109
|
+
upstream_unavailable
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── HTTP response helpers ─────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def serve_index_file(file_path, etag, req)
|
|
115
|
+
return upstream_unavailable unless file_path && File.exist?(file_path)
|
|
116
|
+
|
|
117
|
+
ResponseBuilder.new(req).index(File.binread(file_path), etag)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def health = [200, { "content-type" => "text/plain" }, ["OK"]]
|
|
121
|
+
def present = [200, { "content-type" => "text/plain" }, ["present"]]
|
|
122
|
+
def not_found = [404, { "content-type" => "text/plain" }, ["Not Found"]]
|
|
123
|
+
def invalid_name = [400, { "content-type" => "text/plain" }, ["Invalid name"]]
|
|
124
|
+
|
|
125
|
+
def upstream_unavailable
|
|
126
|
+
[503, { "content-type" => "text/plain" },
|
|
127
|
+
["Upstream unavailable and no local cache. " \
|
|
128
|
+
"Connect to the internet and run bundle install to warm the cache."]]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/gemkeeper/gem_syncer.rb
CHANGED
|
@@ -1,73 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
|
|
3
5
|
module Gemkeeper
|
|
4
|
-
# Syncs a single gem:
|
|
6
|
+
# Syncs a single gem: checks the server, reuses a built artifact, or builds
|
|
7
|
+
# from source, then uploads. Repo acquisition is delegated to RepoFetcher.
|
|
5
8
|
class GemSyncer
|
|
6
|
-
AUTH_ERROR_PATTERNS = [
|
|
7
|
-
/authentication failed/i,
|
|
8
|
-
/could not read from remote repository/i,
|
|
9
|
-
/permission denied \(publickey\)/i,
|
|
10
|
-
/repository not found/i,
|
|
11
|
-
/fatal: credential/i
|
|
12
|
-
].freeze
|
|
13
|
-
|
|
14
9
|
def initialize(config, uploader, manifest:)
|
|
15
|
-
@config
|
|
10
|
+
@config = config
|
|
16
11
|
@uploader = uploader
|
|
17
|
-
@
|
|
12
|
+
@fetcher = RepoFetcher.new(manifest, config.repos_path)
|
|
18
13
|
end
|
|
19
14
|
|
|
20
15
|
def sync(gem_def)
|
|
21
|
-
|
|
22
|
-
version = resolve_version(gem_def)
|
|
23
|
-
name = gem_def.name
|
|
24
|
-
gems_path = @config.gems_path
|
|
25
|
-
|
|
26
|
-
return :skipped if !gem_def.latest? && cached?(name, version, gems_path)
|
|
27
|
-
|
|
28
|
-
puts "Syncing #{name} @ #{version}..."
|
|
29
|
-
local_path = File.join(@config.repos_path, name)
|
|
30
|
-
repo = fetch_repo(repo_url, local_path)
|
|
31
|
-
|
|
32
|
-
if gem_def.latest?
|
|
33
|
-
version = latest_version!(repo, name, gems_path, repo_url)
|
|
34
|
-
return :skipped unless version
|
|
35
|
-
else
|
|
36
|
-
Output.step("Checking out #{version}...")
|
|
37
|
-
repo.checkout_version(version)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
build_and_upload(local_path, gems_path)
|
|
41
|
-
:synced
|
|
16
|
+
gem_def.latest? ? sync_latest(gem_def) : sync_pinned(gem_def)
|
|
42
17
|
end
|
|
43
18
|
|
|
44
19
|
private
|
|
45
20
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
21
|
+
# Pinned / from_lockfile: version is known without the repo, so check the
|
|
22
|
+
# server first, then reuse a local artifact, and only build as a last resort.
|
|
23
|
+
def sync_pinned(gem_def)
|
|
24
|
+
name = gem_def.name
|
|
25
|
+
version = resolve_version(gem_def)
|
|
26
|
+
return skip(name, version) if @uploader.serves?(name, version)
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
28
|
+
artifact = File.join(@config.gems_path, "#{name}-#{version}.gem")
|
|
29
|
+
return reupload(artifact, name, version) if reusable_artifact?(artifact, name, version)
|
|
55
30
|
|
|
56
|
-
|
|
57
|
-
unless File.exist?(@manifest.path)
|
|
58
|
-
raise InvalidConfigError,
|
|
59
|
-
"No manifest found at #{@manifest.path} — run 'gemkeeper manifest generate' to create one"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
raise InvalidConfigError,
|
|
63
|
-
"No repo configured for #{name.inspect} — add it to the manifest with 'gemkeeper manifest generate'"
|
|
31
|
+
build_and_upload(gem_def, version)
|
|
64
32
|
end
|
|
65
33
|
|
|
66
|
-
|
|
67
|
-
|
|
34
|
+
# latest: the version is only known after checkout, so the repo is always
|
|
35
|
+
# fetched; the server check then decides whether the resolved version uploads.
|
|
36
|
+
def sync_latest(gem_def)
|
|
37
|
+
repo, local_path = @fetcher.fetch(gem_def)
|
|
38
|
+
version = repo.current_version or
|
|
39
|
+
raise BuildError, "Could not read version from gemspec for #{gem_def.name}"
|
|
40
|
+
return skip(gem_def.name, version) if @uploader.serves?(gem_def.name, version)
|
|
68
41
|
|
|
69
|
-
|
|
70
|
-
"differs from manifest (#{manifest_repo}) — using gemkeeper.yml"
|
|
42
|
+
build_gem(local_path)
|
|
71
43
|
end
|
|
72
44
|
|
|
73
45
|
def resolve_version(gem_def)
|
|
@@ -84,49 +56,46 @@ module Gemkeeper
|
|
|
84
56
|
versions[gem_def.name] or raise GitError, "#{gem_def.name} not found in #{lockfile_path}"
|
|
85
57
|
end
|
|
86
58
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
59
|
+
def build_and_upload(gem_def, version)
|
|
60
|
+
repo, local_path = @fetcher.fetch(gem_def)
|
|
61
|
+
Output.step("Checking out #{version}...")
|
|
62
|
+
repo.checkout_version(version)
|
|
63
|
+
build_gem(local_path)
|
|
64
|
+
end
|
|
91
65
|
|
|
92
|
-
|
|
93
|
-
|
|
66
|
+
def build_gem(local_path)
|
|
67
|
+
Output.step("Building gem...")
|
|
68
|
+
gem_path = GemBuilder.new(local_path, @config.gems_path).build
|
|
69
|
+
upload(gem_path)
|
|
70
|
+
:synced
|
|
94
71
|
end
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
73
|
+
# Reuse a previously built artifact only if its embedded spec matches the
|
|
74
|
+
# requested gem — never upload the wrong file because a name collided.
|
|
75
|
+
def reusable_artifact?(path, name, version)
|
|
76
|
+
return false unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
spec = Gem::Package.new(path).spec
|
|
79
|
+
spec.name == name && spec.version.to_s == version
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
103
82
|
end
|
|
104
83
|
|
|
105
|
-
def
|
|
106
|
-
version
|
|
107
|
-
|
|
108
|
-
|
|
84
|
+
def reupload(path, name, version)
|
|
85
|
+
Output.step("Uploading cached #{name} @ #{version} (no rebuild)...")
|
|
86
|
+
upload(path)
|
|
87
|
+
:synced
|
|
109
88
|
end
|
|
110
89
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
gem_path = GemBuilder.new(local_path, gems_path).build
|
|
114
|
-
Output.step("Uploading to Geminabox...")
|
|
115
|
-
result = @uploader.upload(gem_path)
|
|
90
|
+
def upload(path)
|
|
91
|
+
result = @uploader.upload(path)
|
|
116
92
|
Output.step(result[:message])
|
|
117
93
|
Output.success(" Done!")
|
|
118
94
|
end
|
|
119
95
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def auth_failure_error(repo_url, original_error)
|
|
125
|
-
GitError.new(
|
|
126
|
-
"Git authentication failed for #{repo_url}.\n" \
|
|
127
|
-
"#{original_error.message}\n" \
|
|
128
|
-
"Configure GitHub credentials: https://docs.github.com/en/authentication"
|
|
129
|
-
)
|
|
96
|
+
def skip(name, version)
|
|
97
|
+
Output.skip("Skipping #{name} @ #{version} (already on server)")
|
|
98
|
+
:skipped
|
|
130
99
|
end
|
|
131
100
|
end
|
|
132
101
|
end
|
|
@@ -4,12 +4,12 @@ require "faraday"
|
|
|
4
4
|
require "faraday/multipart"
|
|
5
5
|
|
|
6
6
|
module Gemkeeper
|
|
7
|
-
# Encapsulates
|
|
7
|
+
# Encapsulates the Gemkeeper server's upload HTTP API so callers never construct multipart requests directly.
|
|
8
8
|
class GemUploader
|
|
9
|
-
attr_reader :
|
|
9
|
+
attr_reader :server_url
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
11
|
+
def initialize(server_url)
|
|
12
|
+
@server_url = server_url
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def upload(gem_path)
|
|
@@ -27,9 +27,20 @@ module Gemkeeper
|
|
|
27
27
|
|
|
28
28
|
handle_response(response, gem_path)
|
|
29
29
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
not_reachable!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True when the running server's private store already serves name@version.
|
|
34
|
+
# Hits the private-store endpoint, never /info, so a public gem can't fool it.
|
|
35
|
+
def serves?(name, version)
|
|
36
|
+
status = connection.get("/gemkeeper/has/#{name}/#{version}").status
|
|
37
|
+
case status
|
|
38
|
+
when 200 then true
|
|
39
|
+
when 404 then false
|
|
40
|
+
else raise ServerError, "Unexpected status #{status} checking #{name} #{version} on #{@server_url}"
|
|
41
|
+
end
|
|
42
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError
|
|
43
|
+
not_reachable!
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
def reachable?
|
|
@@ -40,22 +51,19 @@ module Gemkeeper
|
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
def list_gems
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
body = response.body
|
|
46
|
-
raise UploadError, "Failed to list gems: #{response.status} #{body}" unless response.success?
|
|
47
|
-
|
|
48
|
-
JSON.parse(body)
|
|
49
|
-
rescue JSON::ParserError => parse_error
|
|
50
|
-
raise UploadError, "Invalid JSON response: #{parse_error.message}"
|
|
51
|
-
rescue Faraday::Error => connection_error
|
|
52
|
-
raise UploadError, "Connection error: #{connection_error.message}"
|
|
54
|
+
raise NotImplementedError, "list_gems is not supported by CompactIndexServer; use gemkeeper list instead"
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
private
|
|
56
58
|
|
|
59
|
+
def not_reachable!
|
|
60
|
+
raise ServerNotReachableError,
|
|
61
|
+
"Gemkeeper server is not reachable at #{@server_url} — " \
|
|
62
|
+
"run 'gemkeeper server start' or check 'gemkeeper server status'"
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
def connection
|
|
58
|
-
@connection ||= Faraday.new(url: @
|
|
66
|
+
@connection ||= Faraday.new(url: @server_url) do |f|
|
|
59
67
|
f.request :multipart
|
|
60
68
|
f.request :url_encoded
|
|
61
69
|
f.adapter Faraday::Adapter::NetHttp
|
|
@@ -34,24 +34,29 @@ module Gemkeeper
|
|
|
34
34
|
|
|
35
35
|
def config_ru_content
|
|
36
36
|
gems_path = @config.gems_path
|
|
37
|
+
cache_dir = @config.cache_dir
|
|
37
38
|
<<~RUBY
|
|
38
39
|
# frozen_string_literal: true
|
|
39
40
|
# Auto-generated by Gemkeeper
|
|
40
41
|
|
|
41
|
-
require "
|
|
42
|
+
require "gemkeeper/compact_index_server"
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
run Gemkeeper::CompactIndexServer.new(
|
|
45
|
+
gems_path: #{gems_path.inspect},
|
|
46
|
+
cache_dir: #{cache_dir.inspect}
|
|
47
|
+
)
|
|
47
48
|
RUBY
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def build_rackup_cmd(*extra)
|
|
51
|
-
[rackup_bin, @config.config_ru_path, "--host", "127.0.0.1",
|
|
52
|
+
[rackup_bin, "-I", lib_dir, @config.config_ru_path, "--host", "127.0.0.1",
|
|
52
53
|
"-p", @config.port.to_s, "-s", "puma", *extra]
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
# The gem's own lib/, so the spawned rackup process can require gemkeeper
|
|
57
|
+
# even when running from source (it does not inherit the exe's $LOAD_PATH).
|
|
58
|
+
def lib_dir = File.expand_path("..", __dir__)
|
|
59
|
+
|
|
55
60
|
def rackup_bin
|
|
56
61
|
gem_home = ENV.fetch("GEM_HOME", nil)
|
|
57
62
|
return "rackup" unless gem_home
|
|
@@ -66,7 +71,7 @@ module Gemkeeper
|
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def wait_for_server
|
|
69
|
-
ServerReadinessProbe.new(@config.
|
|
74
|
+
ServerReadinessProbe.new(@config.server_url).wait
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemkeeper
|
|
4
|
+
# Resolves a gem's source repo URL (manifest lookup, with an optional
|
|
5
|
+
# gemkeeper.yml override) and clones or pulls it, mapping git authentication
|
|
6
|
+
# failures to actionable guidance.
|
|
7
|
+
class RepoFetcher
|
|
8
|
+
AUTH_ERROR_PATTERNS = [
|
|
9
|
+
/authentication failed/i,
|
|
10
|
+
/could not read from remote repository/i,
|
|
11
|
+
/permission denied \(publickey\)/i,
|
|
12
|
+
/repository not found/i,
|
|
13
|
+
/fatal: credential/i
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(manifest, repos_path)
|
|
17
|
+
@manifest = manifest
|
|
18
|
+
@repos_path = repos_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Resolves the repo URL, clones/pulls it, and returns [GitRepository, local_path].
|
|
22
|
+
def fetch(gem_def)
|
|
23
|
+
local_path = File.join(@repos_path, gem_def.name)
|
|
24
|
+
[clone_or_pull(resolve(gem_def), local_path), local_path]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Explicit repo: in gemkeeper.yml wins, but warns on divergence from the
|
|
28
|
+
# manifest. Otherwise the repo is resolved from the manifest by gem name.
|
|
29
|
+
def resolve(gem_def)
|
|
30
|
+
name = gem_def.name
|
|
31
|
+
override = gem_def.repo
|
|
32
|
+
manifest_repo = @manifest.repo_for(name)
|
|
33
|
+
return manifest_repo || missing_repo!(name) unless override
|
|
34
|
+
|
|
35
|
+
warn_if_divergent(name, override, manifest_repo)
|
|
36
|
+
override
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def missing_repo!(name)
|
|
42
|
+
path = @manifest.path
|
|
43
|
+
unless File.exist?(path)
|
|
44
|
+
raise InvalidConfigError,
|
|
45
|
+
"No manifest found at #{path} — run 'gemkeeper manifest generate' to create one"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise InvalidConfigError,
|
|
49
|
+
"No repo configured for #{name.inspect} — add it to the manifest with 'gemkeeper manifest generate'"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def warn_if_divergent(name, override, manifest_repo)
|
|
53
|
+
return unless manifest_repo && manifest_repo != override
|
|
54
|
+
|
|
55
|
+
warn "Warning: repo for #{name} in gemkeeper.yml (#{override}) " \
|
|
56
|
+
"differs from manifest (#{manifest_repo}) — using gemkeeper.yml"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clone_or_pull(repo_url, local_path)
|
|
60
|
+
repo = GitRepository.new(repo_url, local_path)
|
|
61
|
+
Output.step("Fetching from #{repo_url}...")
|
|
62
|
+
repo.clone_or_pull
|
|
63
|
+
repo
|
|
64
|
+
rescue GitError => git_error
|
|
65
|
+
raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def auth_error?(error)
|
|
69
|
+
AUTH_ERROR_PATTERNS.any? { |pat| error.message.match?(pat) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def auth_failure_error(repo_url, original_error)
|
|
73
|
+
GitError.new(
|
|
74
|
+
"Git authentication failed for #{repo_url}.\n" \
|
|
75
|
+
"#{original_error.message}\n" \
|
|
76
|
+
"Configure GitHub credentials: https://docs.github.com/en/authentication"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|