deb-s3-lock-fix 0.11.8.fix1

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.
@@ -0,0 +1,125 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require "base64"
3
+ require "digest/md5"
4
+ require "etc"
5
+ require "socket"
6
+ require "tempfile"
7
+ require "securerandom"
8
+
9
+ class Deb::S3::Lock
10
+ attr_reader :user
11
+ attr_reader :host
12
+
13
+ def initialize(user, host)
14
+ @user = user
15
+ @host = host
16
+ end
17
+
18
+ class << self
19
+ #
20
+ # 2-phase mutual lock mechanism based on `s3:CopyObject`.
21
+ #
22
+ # This logic isn't relying on S3's enhanced features like Object Lock
23
+ # because it imposes some limitation on using other features like
24
+ # S3 Cross-Region replication. This should work more than good enough
25
+ # with S3's strong read-after-write consistency which we can presume
26
+ # in all region nowadays.
27
+ #
28
+ # This is relying on S3 to set object's ETag as object's MD5 if an
29
+ # object isn't comprized from multiple parts. We'd be able to presume
30
+ # it as the lock file is usually an object of some smaller bytes.
31
+ #
32
+ # acquire lock:
33
+ # 1. call `s3:HeadObject` on final lock object
34
+ # 1. If final lock object exists, restart from the beginning
35
+ # 2. Otherwise, call `s3:PutObject` to create initial lock object
36
+ # 2. Perform `s3:CopyObject` to copy from initial lock object
37
+ # to final lock object with specifying ETag/MD5 of the initial
38
+ # lock object
39
+ # 1. If copy object fails as `PreconditionFailed`, restart
40
+ # from the beginning
41
+ # 2. Otherwise, lock has been acquired
42
+ #
43
+ # release lock:
44
+ # 1. remove final lock object by `s3:DeleteObject`
45
+ #
46
+ def lock(codename, component = nil, architecture = nil, cache_control = nil, max_attempts=60, max_wait_interval=10)
47
+ uuid = SecureRandom.uuid
48
+ lockbody = "#{Etc.getlogin}@#{Socket.gethostname}-#{uuid}"
49
+
50
+ initial_lockfile = initial_lock_path(codename, component, architecture, cache_control)
51
+ final_lockfile = lock_path(codename, component, architecture, cache_control)
52
+
53
+ md5_b64 = Base64.encode64(Digest::MD5.digest(lockbody))
54
+ md5_hex = Digest::MD5.hexdigest(lockbody)
55
+ max_attempts.times do |i|
56
+ wait_interval = [(1<<i)/10, max_wait_interval].min
57
+ if Deb::S3::Utils.s3_exists?(final_lockfile)
58
+ lock = current(codename, component, architecture, cache_control)
59
+ $stderr.puts("Repository is locked by another user: #{lock.user} at host #{lock.host} (phase-1)")
60
+ $stderr.puts("Attempting to obtain a lock after #{wait_interval} secound(s).")
61
+ sleep(wait_interval)
62
+ else
63
+ # upload the file
64
+ Deb::S3::Utils.s3.put_object(
65
+ bucket: Deb::S3::Utils.bucket,
66
+ key: Deb::S3::Utils.s3_path(initial_lockfile),
67
+ body: lockbody,
68
+ content_type: "text/plain",
69
+ content_md5: md5_b64,
70
+ metadata: {
71
+ "md5" => md5_hex,
72
+ },
73
+ )
74
+ begin
75
+ Deb::S3::Utils.s3.copy_object(
76
+ bucket: Deb::S3::Utils.bucket,
77
+ key: Deb::S3::Utils.s3_path(final_lockfile),
78
+ copy_source: "/#{Deb::S3::Utils.bucket}/#{Deb::S3::Utils.s3_path(initial_lockfile)}",
79
+ copy_source_if_match: md5_hex,
80
+ )
81
+ return
82
+ rescue Aws::S3::Errors::PreconditionFailed => error
83
+ lock = current(codename, component, architecture, cache_control)
84
+ $stderr.puts("Repository is locked by another user: #{lock.user} at host #{lock.host} (phase-2)")
85
+ $stderr.puts("Attempting to obtain a lock after #{wait_interval} second(s).")
86
+ sleep(wait_interval)
87
+ end
88
+ end
89
+ end
90
+ # TODO: throw appropriate error class
91
+ raise("Unable to obtain a lock after #{max_attempts}, giving up.")
92
+ end
93
+
94
+ def unlock(codename, component = nil, architecture = nil, cache_control = nil)
95
+ Deb::S3::Utils.s3_remove(initial_lock_path(codename, component, architecture, cache_control))
96
+ Deb::S3::Utils.s3_remove(lock_path(codename, component, architecture, cache_control))
97
+ end
98
+
99
+ def current(codename, component = nil, architecture = nil, cache_control = nil)
100
+ lockbody = Deb::S3::Utils.s3_read(lock_path(codename, component, architecture, cache_control))
101
+ if lockbody
102
+ user, host_with_uuid = lockbody.to_s.split("@", 2)
103
+ lock = Deb::S3::Lock.new(user, host_with_uuid)
104
+ else
105
+ lock = Deb::S3::Lock.new("unknown", "unknown")
106
+ end
107
+ lock
108
+ end
109
+
110
+ private
111
+ def initial_lock_path(codename, component = nil, architecture = nil, cache_control = nil)
112
+ "dists/#{codename}/lockfile.lock"
113
+ end
114
+
115
+ def lock_path(codename, component = nil, architecture = nil, cache_control = nil)
116
+ #
117
+ # Acquire repository lock at `codename` level to avoid race between concurrent upload attempts.
118
+ #
119
+ # * `deb-s3 upload --arch=all` touchs multiples of `dists/{codename}/{component}/binary-*/Packages*`
120
+ # * All `deb-s3 upload` touchs `dists/{codename}/Release`
121
+ #
122
+ "dists/#{codename}/lockfile"
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,144 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require "tempfile"
3
+ require "zlib"
4
+ require 'deb/s3/utils'
5
+ require 'deb/s3/package'
6
+
7
+ class Deb::S3::Manifest
8
+ include Deb::S3::Utils
9
+
10
+ attr_accessor :codename
11
+ attr_accessor :component
12
+ attr_accessor :cache_control
13
+ attr_accessor :architecture
14
+ attr_accessor :fail_if_exists
15
+ attr_accessor :skip_package_upload
16
+
17
+ attr_accessor :files
18
+
19
+ attr_reader :packages
20
+ attr_reader :packages_to_be_upload
21
+
22
+ def initialize
23
+ @packages = []
24
+ @packages_to_be_upload = []
25
+ @component = nil
26
+ @architecture = nil
27
+ @files = {}
28
+ @cache_control = ""
29
+ @fail_if_exists = false
30
+ @skip_package_upload = false
31
+ end
32
+
33
+ class << self
34
+ def retrieve(codename, component, architecture, cache_control, fail_if_exists, skip_package_upload=false)
35
+ m = if s = Deb::S3::Utils.s3_read("dists/#{codename}/#{component}/binary-#{architecture}/Packages")
36
+ self.parse_packages(s)
37
+ else
38
+ self.new
39
+ end
40
+
41
+ m.codename = codename
42
+ m.component = component
43
+ m.architecture = architecture
44
+ m.cache_control = cache_control
45
+ m.fail_if_exists = fail_if_exists
46
+ m.skip_package_upload = skip_package_upload
47
+ m
48
+ end
49
+
50
+ def parse_packages(str)
51
+ m = self.new
52
+ str.split("\n\n").each do |s|
53
+ next if s.chomp.empty?
54
+ m.packages << Deb::S3::Package.parse_string(s)
55
+ end
56
+ m
57
+ end
58
+ end
59
+
60
+ def add(pkg, preserve_versions, needs_uploading=true)
61
+ if self.fail_if_exists
62
+ packages.each { |p|
63
+ next unless p.name == pkg.name && \
64
+ p.full_version == pkg.full_version && \
65
+ File.basename(p.url_filename(@codename)) != \
66
+ File.basename(pkg.url_filename(@codename))
67
+ raise AlreadyExistsError,
68
+ "package #{pkg.name}_#{pkg.full_version} already exists " \
69
+ "with different filename (#{p.url_filename(@codename)})"
70
+ }
71
+ end
72
+ if preserve_versions
73
+ packages.delete_if { |p| p.name == pkg.name && p.full_version == pkg.full_version }
74
+ else
75
+ packages.delete_if { |p| p.name == pkg.name }
76
+ end
77
+ packages << pkg
78
+ packages_to_be_upload << pkg if needs_uploading
79
+ pkg
80
+ end
81
+
82
+ def delete_package(pkg, versions=nil)
83
+ deleted = []
84
+ new_packages = @packages.select { |p|
85
+ # Include packages we didn't name
86
+ if p.name != pkg
87
+ p
88
+ # Also include the packages not matching a specified version
89
+ elsif (!versions.nil? and p.name == pkg and !versions.include?(p.version) and !versions.include?("#{p.version}-#{p.iteration}") and !versions.include?(p.full_version))
90
+ p
91
+ end
92
+ }
93
+ deleted = @packages - new_packages
94
+ @packages = new_packages
95
+ deleted
96
+ end
97
+
98
+ def generate
99
+ @packages.collect { |pkg| pkg.generate(@codename) }.join("\n")
100
+ end
101
+
102
+ def write_to_s3
103
+ manifest = self.generate
104
+
105
+ unless self.skip_package_upload
106
+ # store any packages that need to be stored
107
+ @packages_to_be_upload.each do |pkg|
108
+ yield pkg.url_filename(@codename) if block_given?
109
+ s3_store(pkg.filename, pkg.url_filename(@codename), 'application/octet-stream; charset=binary', self.cache_control, self.fail_if_exists)
110
+ end
111
+ end
112
+
113
+ # generate the Packages file
114
+ pkgs_temp = Tempfile.new("Packages")
115
+ pkgs_temp.write manifest
116
+ pkgs_temp.close
117
+ f = "dists/#{@codename}/#{@component}/binary-#{@architecture}/Packages"
118
+ yield f if block_given?
119
+ s3_store(pkgs_temp.path, f, 'text/plain; charset=utf-8', self.cache_control)
120
+ @files["#{@component}/binary-#{@architecture}/Packages"] = hashfile(pkgs_temp.path)
121
+ pkgs_temp.unlink
122
+
123
+ # generate the Packages.gz file
124
+ gztemp = Tempfile.new("Packages.gz")
125
+ gztemp.close
126
+ Zlib::GzipWriter.open(gztemp.path) { |gz| gz.write manifest }
127
+ f = "dists/#{@codename}/#{@component}/binary-#{@architecture}/Packages.gz"
128
+ yield f if block_given?
129
+ s3_store(gztemp.path, f, 'application/x-gzip; charset=binary', self.cache_control)
130
+ @files["#{@component}/binary-#{@architecture}/Packages.gz"] = hashfile(gztemp.path)
131
+ gztemp.unlink
132
+
133
+ nil
134
+ end
135
+
136
+ def hashfile(path)
137
+ {
138
+ :size => File.size(path),
139
+ :sha1 => Digest::SHA1.file(path).hexdigest,
140
+ :sha256 => Digest::SHA2.file(path).hexdigest,
141
+ :md5 => Digest::MD5.file(path).hexdigest
142
+ }
143
+ end
144
+ end
@@ -0,0 +1,309 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require "digest/sha1"
3
+ require "digest/sha2"
4
+ require "digest/md5"
5
+ require "open3"
6
+ require "socket"
7
+ require "tmpdir"
8
+ require "uri"
9
+
10
+ require 'deb/s3/utils'
11
+
12
+ class Deb::S3::Package
13
+ include Deb::S3::Utils
14
+
15
+ attr_accessor :name
16
+ attr_accessor :version
17
+ attr_accessor :epoch
18
+ attr_accessor :iteration
19
+ attr_accessor :maintainer
20
+ attr_accessor :vendor
21
+ attr_accessor :url
22
+ attr_accessor :category
23
+ attr_accessor :license
24
+ attr_accessor :architecture
25
+ attr_accessor :description
26
+
27
+ attr_accessor :dependencies
28
+
29
+ # Any other attributes specific to this package.
30
+ # This is where you'd put rpm, deb, or other specific attributes.
31
+ attr_accessor :attributes
32
+
33
+ # hashes
34
+ attr_accessor :sha1
35
+ attr_accessor :sha256
36
+ attr_accessor :md5
37
+ attr_accessor :size
38
+
39
+ attr_accessor :filename
40
+
41
+ class << self
42
+ include Deb::S3::Utils
43
+
44
+ def parse_file(package)
45
+ p = self.new
46
+ p.extract_info(extract_control(package))
47
+ p.apply_file_info(package)
48
+ p.filename = package
49
+ p
50
+ end
51
+
52
+ def parse_string(s)
53
+ p = self.new
54
+ p.extract_info(s)
55
+ p
56
+ end
57
+
58
+ def extract_control(package)
59
+ if system("which dpkg > /dev/null 2>&1")
60
+ output, status = Open3.capture2("dpkg", "-f", package)
61
+ output
62
+ else
63
+ # use ar to determine control file name (control.ext)
64
+ package_files = `ar t #{package}`
65
+ control_file = package_files.split("\n").select do |file|
66
+ file.start_with?("control.")
67
+ end.first
68
+ if control_file === "control.tar.gz"
69
+ compression = "z"
70
+ elsif control_file === "control.tar.zst"
71
+ compression = "I zstd"
72
+ else
73
+ compression = "J"
74
+ end
75
+
76
+ # ar fails to find the control.tar.gz tarball within the .deb
77
+ # on Mac OS. Try using ar to list the control file, if found,
78
+ # use ar to extract, otherwise attempt with tar which works on OS X.
79
+ extract_control_tarball_cmd = "ar p #{package} #{control_file}"
80
+
81
+ begin
82
+ safesystem("ar t #{package} #{control_file} &> /dev/null")
83
+ rescue SafeSystemError
84
+ warn "Failed to find control data in .deb with ar, trying tar."
85
+ extract_control_tarball_cmd = "tar -#{compression} -xf #{package} --to-stdout #{control_file}"
86
+ end
87
+
88
+ Dir.mktmpdir do |path|
89
+ safesystem("#{extract_control_tarball_cmd} | tar -#{compression} -xf - -C #{path}")
90
+ File.read(File.join(path, "control"))
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ def initialize
97
+ @attributes = {}
98
+
99
+ # Reference
100
+ # http://www.debian.org/doc/manuals/maint-guide/first.en.html
101
+ # http://wiki.debian.org/DeveloperConfiguration
102
+ # https://github.com/jordansissel/fpm/issues/37
103
+ if ENV.include?("DEBEMAIL") and ENV.include?("DEBFULLNAME")
104
+ # Use DEBEMAIL and DEBFULLNAME as the default maintainer if available.
105
+ @maintainer = "#{ENV["DEBFULLNAME"]} <#{ENV["DEBEMAIL"]}>"
106
+ else
107
+ # TODO(sissel): Maybe support using 'git config' for a default as well?
108
+ # git config --get user.name, etc can be useful.
109
+ #
110
+ # Otherwise default to user@currenthost
111
+ @maintainer = "<#{ENV["USER"]}@#{Socket.gethostname}>"
112
+ end
113
+
114
+ @name = nil
115
+ @architecture = "native"
116
+ @description = "no description given"
117
+ @version = nil
118
+ @epoch = nil
119
+ @iteration = nil
120
+ @url = nil
121
+ @category = "default"
122
+ @license = "unknown"
123
+ @vendor = "none"
124
+ @sha1 = nil
125
+ @sha256 = nil
126
+ @md5 = nil
127
+ @size = nil
128
+ @filename = nil
129
+ @url_filename = nil
130
+
131
+ @dependencies = []
132
+ end
133
+
134
+ def full_version
135
+ return nil if [epoch, version, iteration].all?(&:nil?)
136
+ [[epoch, version].compact.join(":"), iteration].compact.join("-")
137
+ end
138
+
139
+ def url_filename=(f)
140
+ @url_filename = f
141
+ end
142
+
143
+ def url_filename(codename)
144
+ @url_filename || "pool/#{codename}/#{self.name[0]}/#{self.name[0..1]}/#{File.basename(self.filename)}"
145
+ end
146
+
147
+ def generate(codename)
148
+ template("package.erb").result(binding)
149
+ end
150
+
151
+ # from fpm
152
+ def parse_depends(data)
153
+ return [] if data.nil? or data.empty?
154
+ # parse dependencies. Debian dependencies come in one of two forms:
155
+ # * name
156
+ # * name (op version)
157
+ # They are all on one line, separated by ", "
158
+
159
+ dep_re = /^([^ ]+)(?: \(([>=<]+) ([^)]+)\))?$/
160
+ return data.split(/, */).collect do |dep|
161
+ m = dep_re.match(dep)
162
+ if m
163
+ name, op, version = m.captures
164
+ # this is the proper form of dependency
165
+ if op && version && op != "" && version != ""
166
+ "#{name} (#{op} #{version})".strip
167
+ else
168
+ name.strip
169
+ end
170
+ else
171
+ # Assume normal form dependency, "name op version".
172
+ dep
173
+ end
174
+ end
175
+ end # def parse_depends
176
+
177
+ # from fpm
178
+ def fix_dependency(dep)
179
+ # Deb dependencies are: NAME (OP VERSION), like "zsh (> 3.0)"
180
+ # Convert anything that looks like 'NAME OP VERSION' to this format.
181
+ if dep =~ /[\(,\|]/
182
+ # Don't "fix" ones that could appear well formed already.
183
+ else
184
+ # Convert ones that appear to be 'name op version'
185
+ name, op, version = dep.split(/ +/)
186
+ if !version.nil?
187
+ # Convert strings 'foo >= bar' to 'foo (>= bar)'
188
+ dep = "#{name} (#{debianize_op(op)} #{version})"
189
+ end
190
+ end
191
+
192
+ name_re = /^[^ \(]+/
193
+ name = dep[name_re]
194
+ if name =~ /[A-Z]/
195
+ dep = dep.gsub(name_re) { |n| n.downcase }
196
+ end
197
+
198
+ if dep.include?("_")
199
+ dep = dep.gsub("_", "-")
200
+ end
201
+
202
+ # Convert gem ~> X.Y.Z to '>= X.Y.Z' and << X.Y+1.0
203
+ if dep =~ /\(~>/
204
+ name, version = dep.gsub(/[()~>]/, "").split(/ +/)[0..1]
205
+ nextversion = version.split(".").collect { |v| v.to_i }
206
+ l = nextversion.length
207
+ nextversion[l-2] += 1
208
+ nextversion[l-1] = 0
209
+ nextversion = nextversion.join(".")
210
+ return ["#{name} (>= #{version})", "#{name} (<< #{nextversion})"]
211
+ elsif (m = dep.match(/(\S+)\s+\(!= (.+)\)/))
212
+ # Append this to conflicts
213
+ self.conflicts += [dep.gsub(/!=/,"=")]
214
+ return []
215
+ elsif (m = dep.match(/(\S+)\s+\(= (.+)\)/)) and
216
+ self.attributes[:deb_ignore_iteration_in_dependencies?]
217
+ # Convert 'foo (= x)' to 'foo (>= x)' and 'foo (<< x+1)'
218
+ # but only when flag --ignore-iteration-in-dependencies is passed.
219
+ name, version = m[1..2]
220
+ nextversion = version.split('.').collect { |v| v.to_i }
221
+ nextversion[-1] += 1
222
+ nextversion = nextversion.join(".")
223
+ return ["#{name} (>= #{version})", "#{name} (<< #{nextversion})"]
224
+ else
225
+ # otherwise the dep is probably fine
226
+ return dep.rstrip
227
+ end
228
+ end # def fix_dependency
229
+
230
+ # from fpm
231
+ def extract_info(control)
232
+ fields = parse_control(control)
233
+
234
+ # Parse 'epoch:version-iteration' in the version string
235
+ full_version = fields.delete('Version')
236
+ if full_version !~ /^(?:([0-9]+):)?(.+?)(?:-(.*))?$/
237
+ raise "Unsupported version string '#{full_version}'"
238
+ end
239
+ self.epoch, self.version, self.iteration = $~.captures
240
+
241
+ self.architecture = fields.delete('Architecture')
242
+ self.category = fields.delete('Section')
243
+ self.license = fields.delete('License') || self.license
244
+ self.maintainer = fields.delete('Maintainer')
245
+ self.name = fields.delete('Package')
246
+ self.url = fields.delete('Homepage')
247
+ self.vendor = fields.delete('Vendor') || self.vendor
248
+ self.attributes[:deb_priority] = fields.delete('Priority')
249
+ self.attributes[:deb_origin] = fields.delete('Origin')
250
+ self.attributes[:deb_installed_size] = fields.delete('Installed-Size')
251
+
252
+ # Packages manifest fields
253
+ self.url_filename = fields.delete('Filename')
254
+ self.sha1 = fields.delete('SHA1')
255
+ self.sha256 = fields.delete('SHA256')
256
+ self.md5 = fields.delete('MD5sum')
257
+ self.size = fields.delete('Size')
258
+ self.description = fields.delete('Description')
259
+
260
+ #self.config_files = config_files
261
+
262
+ self.dependencies += Array(parse_depends(fields.delete('Depends')))
263
+
264
+ self.attributes[:deb_recommends] = fields.delete('Recommends')
265
+ self.attributes[:deb_suggests] = fields.delete('Suggests')
266
+ self.attributes[:deb_enhances] = fields.delete('Enhances')
267
+ self.attributes[:deb_pre_depends] = fields.delete('Pre-Depends')
268
+
269
+ self.attributes[:deb_breaks] = fields.delete('Breaks')
270
+ self.attributes[:deb_conflicts] = fields.delete('Conflicts')
271
+ self.attributes[:deb_provides] = fields.delete('Provides')
272
+ self.attributes[:deb_replaces] = fields.delete('Replaces')
273
+
274
+ self.attributes[:deb_field] = Hash[fields.map { |k, v|
275
+ [k.sub(/\AX[BCS]{0,3}-/, ''), v]
276
+ }]
277
+ end # def extract_info
278
+
279
+ def apply_file_info(file)
280
+ self.size = File.size(file)
281
+ self.sha1 = Digest::SHA1.file(file).hexdigest
282
+ self.sha256 = Digest::SHA2.file(file).hexdigest
283
+ self.md5 = Digest::MD5.file(file).hexdigest
284
+ end
285
+
286
+ def parse_control(control)
287
+ field = nil
288
+ value = ""
289
+ {}.tap do |fields|
290
+ control.each_line do |line|
291
+ if line =~ /^(\s+)(\S.*)$/
292
+ indent, rest = $1, $2
293
+ # Continuation
294
+ if indent.size == 1 && rest == "."
295
+ value << "\n"
296
+ rest = ""
297
+ elsif value.size > 0
298
+ value << "\n"
299
+ end
300
+ value << rest
301
+ elsif line =~ /^([-\w]+):(.*)$/
302
+ fields[field] = value if field
303
+ field, value = $1, $2.strip
304
+ end
305
+ end
306
+ fields[field] = value if field
307
+ end
308
+ end
309
+ end