| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| require "cgi/util" |
| require "digest/sha1" |
| require "digest/sha2" |
| require "io/console" |
| require "json" |
| require "net/http" |
| require "pathname" |
| require "tempfile" |
| require "thread" |
| require "time" |
| |
| begin |
| require "apt-dists-merge" |
| rescue LoadError |
| warn("apt-dists-merge is needed for apt:* tasks") |
| end |
| |
| class BinaryTask |
| include Rake::DSL |
| |
| class ThreadPool |
| def initialize(use_case, &worker) |
| @n_workers = choose_n_workers(use_case) |
| @worker = worker |
| @jobs = Thread::Queue.new |
| @workers = @n_workers.times.collect do |
| Thread.new do |
| loop do |
| job = @jobs.pop |
| break if job.nil? |
| @worker.call(job) |
| end |
| end |
| end |
| end |
| |
| def <<(job) |
| @jobs << job |
| end |
| |
| def join |
| @n_workers.times do |
| @jobs << nil |
| end |
| @workers.each(&:join) |
| end |
| |
| private |
| def choose_n_workers(use_case) |
| case use_case |
| when :artifactory |
| # Too many workers cause Artifactory error. |
| 6 |
| when :maven_repository |
| # Too many workers break ASF policy: |
| # https://infra.apache.org/infra-ban.html |
| 4 |
| when :gpg |
| # Too many workers cause gpg-agent error. |
| 2 |
| else |
| raise "Unknown use case: #{use_case}" |
| end |
| end |
| end |
| |
| class ProgressReporter |
| def initialize(label, count_max=0) |
| @label = label |
| @count_max = count_max |
| |
| @mutex = Thread::Mutex.new |
| |
| @time_start = Time.now |
| @time_previous = Time.now |
| @count_current = 0 |
| @count_previous = 0 |
| end |
| |
| def advance |
| @mutex.synchronize do |
| @count_current += 1 |
| |
| return if @count_max.zero? |
| |
| time_current = Time.now |
| if time_current - @time_previous <= 1 |
| return |
| end |
| |
| show_progress(time_current) |
| end |
| end |
| |
| def increment_max |
| @mutex.synchronize do |
| @count_max += 1 |
| show_progress(Time.now) if @count_max == 1 |
| end |
| end |
| |
| def finish |
| @mutex.synchronize do |
| return if @count_max.zero? |
| show_progress(Time.now) |
| $stderr.puts |
| end |
| end |
| |
| private |
| def show_progress(time_current) |
| n_finishes = @count_current - @count_previous |
| throughput = n_finishes.to_f / (time_current - @time_previous) |
| @time_previous = time_current |
| @count_previous = @count_current |
| |
| message = build_message(time_current, throughput) |
| $stderr.print("\r#{message}") if message |
| end |
| |
| def build_message(time_current, throughput) |
| percent = (@count_current / @count_max.to_f) * 100 |
| formatted_count = "[%s/%s]" % [ |
| format_count(@count_current), |
| format_count(@count_max), |
| ] |
| elapsed_second = time_current - @time_start |
| if throughput.zero? |
| rest_second = 0 |
| else |
| rest_second = (@count_max - @count_current) / throughput |
| end |
| separator = " - " |
| progress = "%5.1f%% %s %s %s %s" % [ |
| percent, |
| formatted_count, |
| format_time_interval(elapsed_second), |
| format_time_interval(rest_second), |
| format_throughput(throughput), |
| ] |
| label = @label |
| |
| width = guess_terminal_width |
| return "#{label}#{separator}#{progress}" if width.nil? |
| |
| return nil if progress.size > width |
| |
| label_width = width - progress.size - separator.size |
| if label.size > label_width |
| ellipsis = "..." |
| shorten_label_width = label_width - ellipsis.size |
| if shorten_label_width < 1 |
| return progress |
| else |
| label = label[0, shorten_label_width] + ellipsis |
| end |
| end |
| "#{label}#{separator}#{progress}" |
| end |
| |
| def format_count(count) |
| "%d" % count |
| end |
| |
| def format_time_interval(interval) |
| if interval < 60 |
| "00:00:%02d" % interval |
| elsif interval < (60 * 60) |
| minute, second = interval.divmod(60) |
| "00:%02d:%02d" % [minute, second] |
| elsif interval < (60 * 60 * 24) |
| minute, second = interval.divmod(60) |
| hour, minute = minute.divmod(60) |
| "%02d:%02d:%02d" % [hour, minute, second] |
| else |
| minute, second = interval.divmod(60) |
| hour, minute = minute.divmod(60) |
| day, hour = hour.divmod(24) |
| "%dd %02d:%02d:%02d" % [day, hour, minute, second] |
| end |
| end |
| |
| def format_throughput(throughput) |
| "%2d/s" % throughput |
| end |
| |
| def guess_terminal_width |
| guess_terminal_width_from_io || |
| guess_terminal_width_from_command || |
| guess_terminal_width_from_env || |
| 80 |
| end |
| |
| def guess_terminal_width_from_io |
| if IO.respond_to?(:console) and IO.console |
| IO.console.winsize[1] |
| elsif $stderr.respond_to?(:winsize) |
| begin |
| $stderr.winsize[1] |
| rescue SystemCallError |
| nil |
| end |
| else |
| nil |
| end |
| end |
| |
| def guess_terminal_width_from_command |
| IO.pipe do |input, output| |
| begin |
| pid = spawn("tput", "cols", {:out => output, :err => output}) |
| rescue SystemCallError |
| return nil |
| end |
| |
| output.close |
| _, status = Process.waitpid2(pid) |
| return nil unless status.success? |
| |
| result = input.read.chomp |
| begin |
| Integer(result, 10) |
| rescue ArgumentError |
| nil |
| end |
| end |
| end |
| |
| def guess_terminal_width_from_env |
| env = ENV["COLUMNS"] || ENV["TERM_WIDTH"] |
| return nil if env.nil? |
| |
| begin |
| Integer(env, 10) |
| rescue ArgumentError |
| nil |
| end |
| end |
| end |
| |
| class HTTPClient |
| class Error < StandardError |
| attr_reader :request |
| attr_reader :response |
| def initialize(request, response, message) |
| @request = request |
| @response = response |
| super(message) |
| end |
| end |
| |
| def initialize |
| @http = nil |
| @current_timeout = nil |
| end |
| |
| private def start_http(url, &block) |
| http = Net::HTTP.new(url.host, url.port) |
| http.set_debug_output($stderr) if ENV["DEBUG"] |
| http.use_ssl = true |
| if block_given? |
| http.start(&block) |
| else |
| http |
| end |
| end |
| |
| def close |
| return if @http.nil? |
| @http.finish if @http.started? |
| @http = nil |
| end |
| |
| def request(method, headers, url, body: nil, &block) |
| request = build_request(method, url, headers, body: body) |
| if ENV["DRY_RUN"] == "yes" |
| case request |
| when Net::HTTP::Get, Net::HTTP::Head |
| else |
| p [method, url] |
| return |
| end |
| end |
| @http ||= start_http(url) |
| request_internal(@http, request, &block) |
| end |
| |
| private def request_internal(http, request, &block) |
| read_timeout = http.read_timeout |
| begin |
| http.read_timeout = @current_timeout if @current_timeout |
| http.request(request) do |response| |
| case response |
| when Net::HTTPSuccess, |
| Net::HTTPNotModified |
| if block_given? |
| return yield(response) |
| else |
| response.read_body |
| return response |
| end |
| when Net::HTTPRedirection |
| redirected_url = URI(response["Location"]) |
| redirected_request = Net::HTTP::Get.new(redirected_url, {}) |
| start_http(redirected_url) do |redirected_http| |
| request_internal(redirected_http, redirected_request, &block) |
| end |
| else |
| message = "failed to request: " |
| message << "#{request.uri}: #{request.method}: " |
| message << "#{response.message} #{response.code}" |
| if response.body |
| message << "\n" |
| message << response.body |
| end |
| raise Error.new(request, response, message) |
| end |
| end |
| ensure |
| http.read_timeout = read_timeout |
| end |
| end |
| |
| def head(path) |
| url = build_read_url(path) |
| with_retry(3, url) do |
| request(:head, {}, url) |
| end |
| end |
| |
| def exist?(path) |
| begin |
| head(path) |
| true |
| rescue Error => error |
| case error.response |
| when Net::HTTPNotFound |
| false |
| else |
| raise |
| end |
| end |
| end |
| |
| def download(path, output_path=nil) |
| url = build_read_url(path) |
| with_retry(5, url) do |
| begin |
| begin |
| headers = {} |
| if output_path and File.exist?(output_path) |
| headers["If-Modified-Since"] = File.mtime(output_path).rfc2822 |
| end |
| request(:get, headers, url) do |response| |
| case response |
| when Net::HTTPNotModified |
| else |
| if output_path |
| File.open(output_path, "wb") do |output| |
| response.read_body do |chunk| |
| output.write(chunk) |
| end |
| end |
| last_modified = response["Last-Modified"] |
| if last_modified |
| FileUtils.touch(output_path, |
| mtime: Time.rfc2822(last_modified)) |
| end |
| else |
| response.body |
| end |
| end |
| end |
| rescue Error => error |
| case error.response |
| when Net::HTTPNotFound |
| $stderr.puts(error.message) |
| return |
| else |
| raise |
| end |
| end |
| end |
| rescue |
| FileUtils.rm_f(output_path) |
| raise |
| end |
| end |
| |
| def delete(path) |
| url = build_write_url(path) |
| with_retry(3, url) do |
| request(:delete, {}, url) |
| end |
| end |
| |
| private |
| def build_request(method, url, headers, body: nil) |
| need_auth = false |
| case method |
| when :head |
| request = Net::HTTP::Head.new(url, headers) |
| when :get |
| request = Net::HTTP::Get.new(url, headers) |
| when :post |
| need_auth = true |
| request = Net::HTTP::Post.new(url, headers) |
| when :put |
| need_auth = true |
| request = Net::HTTP::Put.new(url, headers) |
| when :delete |
| need_auth = true |
| request = Net::HTTP::Delete.new(url, headers) |
| else |
| raise "unsupported HTTP method: #{method.inspect}" |
| end |
| request["Connection"] = "Keep-Alive" |
| setup_auth(request) if need_auth |
| if body |
| if body.is_a?(String) |
| request.body = body |
| else |
| request.body_stream = body |
| end |
| end |
| request |
| end |
| |
| def with_retry(max_n_retries, target) |
| n_retries = 0 |
| begin |
| yield |
| rescue Net::OpenTimeout, |
| OpenSSL::OpenSSLError, |
| SocketError, |
| SystemCallError, |
| Timeout::Error, |
| Error => error |
| n_retries += 1 |
| if n_retries <= max_n_retries |
| $stderr.puts |
| $stderr.puts("Retry #{n_retries}: #{target}: " + |
| "#{error.class}: #{error.message}") |
| close |
| retry |
| else |
| raise |
| end |
| end |
| end |
| |
| def with_read_timeout(timeout) |
| current_timeout, @current_timeout = @current_timeout, timeout |
| begin |
| yield |
| ensure |
| @current_timeout = current_timeout |
| end |
| end |
| end |
| |
| # See also the REST API document: |
| # https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Nexus-Repository-2-Staging-Repository-via-REST-API |
| class MavenRepositoryClient < HTTPClient |
| PRODUCTION_DEPLOYED_BASE_URL = |
| "https://repo1.maven.org/maven2/org/apache/arrow" |
| STAGING_BASE_URL = "https://repository.apache.org" |
| STAGING_DEPLOYED_BASE_URL = |
| "#{STAGING_BASE_URL}/content/repositories/staging/org/apache/arrow" |
| STAGING_API_BASE_URL = "#{STAGING_BASE_URL}/service/local/staging" |
| |
| def initialize(prefix, repository_id, asf_user, asf_password) |
| @prefix = prefix |
| @repository_id = repository_id |
| @asf_user = asf_user |
| @asf_password = asf_password |
| super() |
| end |
| |
| def create_staging_repository(description="") |
| # The profile ID of "org.apache.arrow". |
| # See also: https://issues.apache.org/jira/browse/INFRA-26626 |
| profile_id = "2653a12a1cbe8b" |
| url_string = "#{STAGING_API_BASE_URL}/profiles/#{profile_id}/start" |
| url = URI(url_string) |
| headers = {"Content-Type" => "application/xml"} |
| response = request(:post, headers, url, body: <<-REQUEST) |
| <promoteRequest> |
| <data> |
| <description>#{CGI.escape_html(description)}</description> |
| </data> |
| </promoteRequest> |
| REQUEST |
| response.body[/<stagedRepositoryId>(.+?)<\/stagedRepositoryId>/, 1] |
| end |
| |
| def close_staging_repository(description="") |
| # The profile ID of "org.apache.arrow". |
| # See also: https://issues.apache.org/jira/browse/INFRA-26626 |
| profile_id = "2653a12a1cbe8b" |
| url_string = "#{STAGING_API_BASE_URL}/profiles/#{profile_id}/finish" |
| url = URI(url_string) |
| headers = {"Content-Type" => "application/xml"} |
| response = request(:post, headers, url, body: <<-REQUEST) |
| <promoteRequest> |
| <data> |
| <stagedRepositoryId>#{CGI.escape_html(@repository_id)}</stagedRepositoryId> |
| <description>#{CGI.escape_html(description)}</description> |
| </data> |
| </promoteRequest> |
| REQUEST |
| response.body |
| end |
| |
| def files |
| _files = [] |
| directories = [""] |
| until directories.empty? |
| directory = directories.shift |
| list(directory).each do |path| |
| resolved_path = "#{directory}#{path}" |
| case path |
| when "../" |
| when /\/\z/ |
| directories << resolved_path |
| else |
| _files << resolved_path |
| end |
| end |
| end |
| _files |
| end |
| |
| def list(path) |
| url = build_deployed_url(path) |
| with_retry(3, url) do |
| begin |
| request(:get, {}, url) do |response| |
| response.body.scan(/<a href="(.+?)"/).flatten.collect do |href| |
| href.delete_prefix(url.to_s) |
| end |
| end |
| rescue Error => error |
| case error.response |
| when Net::HTTPNotFound |
| return [] |
| else |
| raise |
| end |
| end |
| end |
| end |
| |
| def upload(path, destination_path) |
| destination_url = build_api_url(destination_path) |
| with_retry(3, destination_url) do |
| headers = { |
| "Content-Length" => File.size(path).to_s, |
| "Content-Type" => content_type(path), |
| } |
| File.open(path, "rb") do |input| |
| request(:put, headers, destination_url, body: input) |
| end |
| end |
| end |
| |
| private |
| def build_read_url(path) |
| build_deployed_url(path) |
| end |
| |
| def build_write_url(path) |
| build_api_url(path) |
| end |
| |
| def build_api_url(path) |
| url_string = STAGING_API_BASE_URL + |
| "/deployByRepositoryId/#{@repository_id}/org/apache/arrow" + |
| "/#{@prefix}/#{path}" |
| URI(url_string) |
| end |
| |
| def build_deployed_url(path) |
| url_string = "#{PRODUCTION_DEPLOYED_BASE_URL}/#{@prefix}/#{path}" |
| URI(url_string) |
| end |
| |
| def setup_auth(request) |
| request.basic_auth(@asf_user, @asf_password) |
| end |
| |
| def content_type(path) |
| case File.extname(path) |
| when ".rpm" |
| "application/x-redhat-package-manager" |
| else |
| "application/octet-stream" |
| end |
| end |
| end |
| |
| class ArtifactoryClient < HTTPClient |
| def initialize(prefix, api_key) |
| @prefix = prefix |
| @api_key = api_key |
| super() |
| end |
| |
| def files |
| _files = [] |
| directories = [""] |
| until directories.empty? |
| directory = directories.shift |
| list(directory).each do |path| |
| resolved_path = "#{directory}#{path}" |
| case path |
| when "../" |
| when /\/\z/ |
| directories << resolved_path |
| else |
| _files << resolved_path |
| end |
| end |
| end |
| _files |
| end |
| |
| def list(path) |
| url = build_deployed_url(path) |
| with_retry(3, url) do |
| begin |
| request(:get, {}, url) do |response| |
| response.body.scan(/<a href="(.+?)"/).flatten |
| end |
| rescue Error => error |
| case error.response |
| when Net::HTTPNotFound |
| return [] |
| else |
| raise |
| end |
| end |
| end |
| end |
| |
| def upload(path, destination_path) |
| destination_url = build_deployed_url(destination_path) |
| with_retry(3, destination_url) do |
| sha1 = Digest::SHA1.file(path).hexdigest |
| sha256 = Digest::SHA256.file(path).hexdigest |
| headers = { |
| "X-Artifactory-Last-Modified" => File.mtime(path).rfc2822, |
| "X-Checksum-Deploy" => "false", |
| "X-Checksum-Sha1" => sha1, |
| "X-Checksum-Sha256" => sha256, |
| "Content-Length" => File.size(path).to_s, |
| "Content-Type" => "application/octet-stream", |
| } |
| File.open(path, "rb") do |input| |
| request(:put, headers, destination_url, body: input) |
| end |
| end |
| end |
| |
| def copy(source, destination) |
| url = build_api_url("copy/arrow/#{source}", |
| "to" => "/arrow/#{destination}") |
| with_retry(3, url) do |
| with_read_timeout(300) do |
| request(:post, {}, url) |
| end |
| end |
| end |
| |
| private |
| def build_read_url(path) |
| build_deployed_url(path) |
| end |
| |
| def build_write_url(path) |
| build_api_url(path, {}) |
| end |
| |
| def build_api_url(path, parameters) |
| uri_string = "https://packages.apache.org/artifactory/api/#{path}" |
| unless parameters.empty? |
| uri_string << "?" |
| escaped_parameters = parameters.collect do |key, value| |
| "#{CGI.escape(key)}=#{CGI.escape(value)}" |
| end |
| uri_string << escaped_parameters.join("&") |
| end |
| URI(uri_string) |
| end |
| |
| def build_deployed_url(path) |
| uri_string = "https://packages.apache.org/artifactory/arrow" |
| uri_string << "/#{@prefix}" unless @prefix.nil? |
| uri_string << "/#{path}" |
| URI(uri_string) |
| end |
| |
| def setup_auth(request) |
| request["X-JFrog-Art-Api"] = @api_key |
| end |
| end |
| |
| class HTTPClientPool |
| class << self |
| def open(*args) |
| pool = new(*args) |
| begin |
| yield(pool) |
| ensure |
| pool.close |
| end |
| end |
| end |
| |
| def initialize(*args) |
| @args = args |
| @mutex = Thread::Mutex.new |
| @clients = [] |
| end |
| |
| def pull |
| client = @mutex.synchronize do |
| if @clients.empty? |
| create_client |
| else |
| @clients.pop |
| end |
| end |
| begin |
| yield(client) |
| ensure |
| release(client) |
| end |
| end |
| |
| def release(client) |
| @mutex.synchronize do |
| @clients << client |
| end |
| end |
| |
| def close |
| @clients.each(&:close) |
| end |
| end |
| |
| class MavenRepositoryClientPool < HTTPClientPool |
| private |
| def create_client |
| MavenRepositoryClient.new(*@args) |
| end |
| end |
| |
| class ArtifactoryClientPool < HTTPClientPool |
| private |
| def create_client |
| ArtifactoryClient.new(*@args) |
| end |
| end |
| |
| class Downloader |
| def download |
| progress_label = "Downloading: #{target_base_path}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| prefix = [target_base_path, @prefix].compact.join("/") |
| open_client_pool(prefix) do |client_pool| |
| thread_pool = ThreadPool.new(thread_pool_use_case) do |path, output_path| |
| client_pool.pull do |client| |
| client.download(path, output_path) |
| end |
| progress_reporter.advance |
| end |
| files = client_pool.pull do |client| |
| client.files |
| end |
| if @target == :base and yum_repository? |
| # Download Yum repository metadata efficiently. We have many |
| # old unused repodata/*-.{sqlite,xml} files because we don't |
| # remove old unused repodata/*-.{sqlite,xml}. We want to |
| # download only used Yum repository metadata. We can find it |
| # by checking <location href="..."/> in repomd.xml. |
| dynamic_paths = [] |
| files.each do |path| |
| next unless File.basename(path) == "repomd.xml" |
| output_path = "#{@destination}/#{path}" |
| yield(output_path) |
| output_dir = File.dirname(output_path) |
| FileUtils.mkdir_p(output_dir) |
| progress_reporter.increment_max |
| client_pool.pull do |client| |
| client.download(path, output_path) |
| end |
| progress_reporter.advance |
| base_dir = File.dirname(File.dirname(path)) |
| File.read(output_path).scan(/<location\s+href="(.+?)"/) do |href,| |
| dynamic_paths << "#{base_dir}/#{href}" |
| end |
| end |
| else |
| dynamic_paths = nil |
| end |
| files.each do |path| |
| if @pattern |
| next unless @pattern.match?(path) |
| end |
| if dynamic_paths |
| next unless dynamic_paths.include?(path) |
| end |
| output_path = "#{@destination}/#{path}" |
| yield(output_path) |
| output_dir = File.dirname(output_path) |
| FileUtils.mkdir_p(output_dir) |
| progress_reporter.increment_max |
| thread_pool << [path, output_path] |
| end |
| thread_pool.join |
| end |
| progress_reporter.finish |
| end |
| |
| private |
| def yum_repository? |
| case @distribution |
| when "almalinux", "amazon-linux", "centos" |
| true |
| else |
| false |
| end |
| end |
| end |
| |
| class MavenRepositoryDownloader < Downloader |
| def initialize(asf_user:, |
| asf_password:, |
| destination:, |
| distribution:, |
| pattern: nil, |
| prefix: nil, |
| target: nil, |
| rc: nil) |
| @asf_user = asf_user |
| @asf_password = asf_password |
| @destination = destination |
| @distribution = distribution |
| @pattern = pattern |
| @prefix = prefix |
| @target = target |
| @rc = rc |
| end |
| |
| private |
| def target_base_path |
| @distribution |
| end |
| |
| def open_client_pool(prefix, &block) |
| args = [prefix, nil, @asf_user, @asf_password] |
| MavenRepositoryClientPool.open(*args, &block) |
| end |
| |
| def thread_pool_use_case |
| :maven_repository |
| end |
| end |
| |
| module ArtifactoryPath |
| private |
| def base_path |
| path = @distribution |
| path += "-staging" if @staging |
| path |
| end |
| |
| def rc_base_path |
| base_path + "-rc" |
| end |
| |
| def release_base_path |
| base_path |
| end |
| |
| def target_base_path |
| if @rc |
| rc_base_path |
| else |
| release_base_path |
| end |
| end |
| end |
| |
| class ArtifactoryDownloader < Downloader |
| include ArtifactoryPath |
| |
| def initialize(api_key:, |
| destination:, |
| distribution:, |
| pattern: nil, |
| prefix: nil, |
| target: nil, |
| rc: nil, |
| staging: false) |
| @api_key = api_key |
| @destination = destination |
| @distribution = distribution |
| @pattern = pattern |
| @prefix = prefix |
| @target = target |
| if @target == :rc |
| @rc = rc |
| else |
| @rc = nil |
| end |
| @staging = staging |
| end |
| |
| private |
| def open_client_pool(prefix, &block) |
| args = [prefix, @api_key] |
| ArtifactoryClientPool.open(*args, &block) |
| end |
| |
| def thread_pool_use_case |
| :artifactory |
| end |
| end |
| |
| class Uploader |
| def upload |
| progress_label = "Uploading: #{target_base_path}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| prefix = target_base_path |
| prefix += "/#{@destination_prefix}" if @destination_prefix |
| open_client_pool(prefix) do |client_pool| |
| if @sync |
| existing_files = client_pool.pull do |client| |
| client.files |
| end |
| else |
| existing_files = [] |
| end |
| |
| thread_pool = ThreadPool.new(thread_pool_use_case) do |path, relative_path| |
| client_pool.pull do |client| |
| client.upload(path, relative_path) |
| end |
| progress_reporter.advance |
| end |
| |
| source = Pathname(@source) |
| source.glob("**/*") do |path| |
| next if path.directory? |
| destination_path = path.relative_path_from(source) |
| progress_reporter.increment_max |
| existing_files.delete(destination_path.to_s) |
| thread_pool << [path, destination_path] |
| end |
| thread_pool.join |
| |
| if @sync |
| thread_pool = ThreadPool.new(thread_pool_use_case) do |path| |
| client_pool.pull do |client| |
| client.delete(path) |
| end |
| progress_reporter.advance |
| end |
| existing_files.each do |path| |
| if @sync_pattern |
| next unless @sync_pattern.match?(path) |
| end |
| progress_reporter.increment_max |
| thread_pool << path |
| end |
| thread_pool.join |
| end |
| end |
| progress_reporter.finish |
| end |
| end |
| |
| class MavenRepositoryUploader < Uploader |
| def initialize(asf_user:, |
| asf_password:, |
| destination_prefix: nil, |
| distribution:, |
| rc: nil, |
| source:, |
| staging: false, |
| sync: false, |
| sync_pattern: nil) |
| @asf_user = asf_user |
| @asf_password = asf_password |
| @destination_prefix = destination_prefix |
| @distribution = distribution |
| @rc = rc |
| @source = source |
| @staging = staging |
| @sync = sync |
| @sync_pattern = sync_pattern |
| end |
| |
| def upload |
| client = MavenRepositoryClient.new(nil, nil, @asf_user, @asf_password) |
| @repository_id = client.create_staging_repository |
| super |
| client = MavenRepositoryClient.new(nil, |
| @repository_id, |
| @asf_user, |
| @asf_password) |
| client.close_staging_repository |
| end |
| |
| private |
| def target_base_path |
| @distribution |
| end |
| |
| def open_client_pool(prefix, &block) |
| args = [prefix, @repository_id, @asf_user, @asf_password] |
| MavenRepositoryClientPool.open(*args, &block) |
| end |
| |
| def thread_pool_use_case |
| :maven_repository |
| end |
| end |
| |
| class ArtifactoryUploader < Uploader |
| include ArtifactoryPath |
| |
| def initialize(api_key:, |
| destination_prefix: nil, |
| distribution:, |
| rc: nil, |
| source:, |
| staging: false, |
| sync: false, |
| sync_pattern: nil) |
| @api_key = api_key |
| @destination_prefix = destination_prefix |
| @distribution = distribution |
| @rc = rc |
| @source = source |
| @staging = staging |
| @sync = sync |
| @sync_pattern = sync_pattern |
| end |
| |
| private |
| def open_client_pool(prefix, &block) |
| args = [prefix, @api_key] |
| ArtifactoryClientPool.open(*args, &block) |
| end |
| |
| def thread_pool_use_case |
| :artifactory |
| end |
| end |
| |
| class ArtifactoryReleaser |
| include ArtifactoryPath |
| |
| def initialize(api_key:, |
| distribution:, |
| list: nil, |
| rc_prefix: nil, |
| release_prefix: nil, |
| staging: false) |
| @api_key = api_key |
| @distribution = distribution |
| @list = list |
| @rc_prefix = rc_prefix |
| @release_prefix = release_prefix |
| @staging = staging |
| end |
| |
| def release |
| progress_label = "Releasing: #{release_base_path}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| rc_prefix = [rc_base_path, @rc_prefix].compact.join("/") |
| release_prefix = [release_base_path, @release_prefix].compact.join("/") |
| ArtifactoryClientPool.open(rc_prefix, @api_key) do |client_pool| |
| thread_pool = ThreadPool.new(:artifactory) do |path, release_path| |
| client_pool.pull do |client| |
| client.copy(path, release_path) |
| end |
| progress_reporter.advance |
| end |
| files = client_pool.pull do |client| |
| if @list |
| client.download(@list, nil).lines(chomp: true) |
| else |
| client.files |
| end |
| end |
| files.each do |path| |
| progress_reporter.increment_max |
| rc_path = "#{rc_prefix}/#{path}" |
| release_path = "#{release_prefix}/#{path}" |
| thread_pool << [rc_path, release_path] |
| end |
| thread_pool.join |
| end |
| progress_reporter.finish |
| end |
| end |
| |
| def define |
| define_apt_tasks |
| define_yum_tasks |
| define_summary_tasks |
| end |
| |
| private |
| def env_value(name) |
| value = ENV[name] |
| value = yield(name) if value.nil? and block_given? |
| raise "Specify #{name} environment variable" if value.nil? |
| value |
| end |
| |
| def verbose? |
| ENV["VERBOSE"] == "yes" |
| end |
| |
| def default_output |
| if verbose? |
| $stdout |
| else |
| IO::NULL |
| end |
| end |
| |
| def gpg_key_id |
| env_value("GPG_KEY_ID") |
| end |
| |
| def shorten_gpg_key_id(id) |
| id[-8..-1] |
| end |
| |
| def rpm_gpg_key_package_name(id) |
| "gpg-pubkey-#{shorten_gpg_key_id(id).downcase}" |
| end |
| |
| def artifactory_api_key |
| env_value("ARTIFACTORY_API_KEY") |
| end |
| |
| def asf_user |
| env_value("ASF_USER") |
| end |
| |
| def asf_password |
| env_value("ASF_PASSWORD") |
| end |
| |
| def artifacts_dir |
| env_value("ARTIFACTS_DIR") |
| end |
| |
| def version |
| env_value("VERSION") |
| end |
| |
| def rc |
| env_value("RC") |
| end |
| |
| def staging? |
| ENV["STAGING"] == "yes" |
| end |
| |
| def full_version |
| "#{version}-rc#{rc}" |
| end |
| |
| def valid_sign?(path, sign_path) |
| IO.pipe do |input, output| |
| begin |
| sh({"LANG" => "C"}, |
| "gpg", |
| "--verify", |
| sign_path, |
| path, |
| out: default_output, |
| err: output, |
| verbose: false) |
| rescue |
| return false |
| end |
| output.close |
| /Good signature/ === input.read |
| end |
| end |
| |
| def sign(source_path, destination_path) |
| if File.exist?(destination_path) |
| return if valid_sign?(source_path, destination_path) |
| rm(destination_path, verbose: false) |
| end |
| sh("gpg", |
| "--armor", |
| "--detach-sign", |
| "--local-user", gpg_key_id, |
| "--output", destination_path, |
| source_path, |
| out: default_output, |
| verbose: verbose?) |
| end |
| |
| def sha512(source_path, destination_path) |
| if File.exist?(destination_path) |
| sha512 = File.read(destination_path).split[0] |
| return if Digest::SHA512.file(source_path).hexdigest == sha512 |
| end |
| absolute_destination_path = File.expand_path(destination_path) |
| Dir.chdir(File.dirname(source_path)) do |
| sh("shasum", |
| "--algorithm", "512", |
| File.basename(source_path), |
| out: absolute_destination_path, |
| verbose: verbose?) |
| end |
| end |
| |
| def sign_dir(label, dir) |
| progress_label = "Signing: #{label}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| |
| target_paths = [] |
| Pathname(dir).glob("**/*") do |path| |
| next if path.directory? |
| case path.extname |
| when ".asc", ".sha512" |
| next |
| end |
| progress_reporter.increment_max |
| target_paths << path.to_s |
| end |
| target_paths.each do |path| |
| sign(path, "#{path}.asc") |
| sha512(path, "#{path}.sha512") |
| progress_reporter.advance |
| end |
| progress_reporter.finish |
| end |
| |
| def download_distribution(type, |
| distribution, |
| destination, |
| target, |
| pattern: nil, |
| prefix: nil) |
| mkdir_p(destination, verbose: verbose?) unless File.exist?(destination) |
| existing_paths = {} |
| Pathname(destination).glob("**/*") do |path| |
| next if path.directory? |
| existing_paths[path.to_s] = true |
| end |
| options = { |
| destination: destination, |
| distribution: distribution, |
| pattern: pattern, |
| prefix: prefix, |
| target: target, |
| } |
| options[:rc] = rc if target == :rc |
| if type == :artifactory |
| options[:api_key] = artifactory_api_key |
| options[:staging] = staging? |
| downloader = ArtifactoryDownloader.new(**options) |
| else |
| options[:asf_user] = asf_user |
| options[:asf_password] = asf_password |
| downloader = MavenRepositoryDownloader.new(**options) |
| end |
| downloader.download do |output_path| |
| existing_paths.delete(output_path) |
| end |
| existing_paths.each_key do |path| |
| rm_f(path, verbose: verbose?) |
| end |
| end |
| |
| def release_distribution(distribution, |
| list: nil, |
| rc_prefix: nil, |
| release_prefix: nil) |
| options = { |
| api_key: artifactory_api_key, |
| distribution: distribution, |
| list: list, |
| rc_prefix: rc_prefix, |
| release_prefix: release_prefix, |
| staging: staging?, |
| } |
| releaser = ArtifactoryReleaser.new(**options) |
| releaser.release |
| end |
| |
| def same_content?(path1, path2) |
| File.exist?(path1) and |
| File.exist?(path2) and |
| Digest::SHA256.file(path1) == Digest::SHA256.file(path2) |
| end |
| |
| def copy_artifact(source_path, |
| destination_path, |
| progress_reporter) |
| return if same_content?(source_path, destination_path) |
| progress_reporter.increment_max |
| destination_dir = File.dirname(destination_path) |
| unless File.exist?(destination_dir) |
| mkdir_p(destination_dir, verbose: verbose?) |
| end |
| cp(source_path, destination_path, verbose: verbose?) |
| progress_reporter.advance |
| end |
| |
| def prepare_staging(base_path) |
| client = ArtifactoryClient.new(nil, artifactory_api_key) |
| ["", "-rc"].each do |suffix| |
| path = "#{base_path}#{suffix}" |
| progress_reporter = ProgressReporter.new("Preparing staging for #{path}") |
| progress_reporter.increment_max |
| begin |
| staging_path = "#{base_path}-staging#{suffix}" |
| if client.exist?(staging_path) |
| client.delete(staging_path) |
| end |
| if client.exist?(path) |
| client.copy(path, staging_path) |
| end |
| ensure |
| progress_reporter.advance |
| progress_reporter.finish |
| end |
| end |
| end |
| |
| def delete_staging(base_path) |
| client = ArtifactoryClient.new(nil, artifactory_api_key) |
| ["", "-rc"].each do |suffix| |
| path = "#{base_path}#{suffix}" |
| progress_reporter = ProgressReporter.new("Deleting staging for #{path}") |
| progress_reporter.increment_max |
| begin |
| staging_path = "#{base_path}-staging#{suffix}" |
| if client.exist?(staging_path) |
| client.delete(staging_path) |
| end |
| ensure |
| progress_reporter.advance |
| progress_reporter.finish |
| end |
| end |
| end |
| |
| def uploaded_files_name |
| "uploaded-files.txt" |
| end |
| |
| def write_uploaded_files(dir) |
| dir = Pathname(dir) |
| uploaded_files = [] |
| dir.glob("**/*") do |path| |
| next if path.directory? |
| uploaded_files << path.relative_path_from(dir).to_s |
| end |
| File.open("#{dir}/#{uploaded_files_name}", "w") do |output| |
| output.puts(uploaded_files.sort) |
| end |
| end |
| |
| def tmp_dir |
| "/tmp" |
| end |
| |
| def rc_dir |
| "#{tmp_dir}/rc" |
| end |
| |
| def release_dir |
| "#{tmp_dir}/release" |
| end |
| |
| def recover_dir |
| "#{tmp_dir}/recover" |
| end |
| |
| def apt_repository_label |
| "Apache Arrow" |
| end |
| |
| def apt_repository_description |
| "Apache Arrow packages" |
| end |
| |
| def apt_rc_repositories_dir |
| "#{rc_dir}/apt/repositories" |
| end |
| |
| def apt_recover_repositories_dir |
| "#{recover_dir}/apt/repositories" |
| end |
| |
| def available_apt_targets |
| [ |
| ["debian", "bookworm", "main"], |
| ["debian", "trixie", "main"], |
| ["debian", "forky", "main"], |
| ["ubuntu", "jammy", "main"], |
| ["ubuntu", "noble", "main"], |
| ] |
| end |
| |
| def apt_targets |
| env_apt_targets = (ENV["APT_TARGETS"] || "").split(",") |
| if env_apt_targets.empty? |
| available_apt_targets |
| else |
| available_apt_targets.select do |distribution, code_name, component| |
| env_apt_targets.any? do |env_apt_target| |
| if env_apt_target.include?("-") |
| env_apt_target.start_with?("#{distribution}-#{code_name}") |
| else |
| env_apt_target == distribution |
| end |
| end |
| end |
| end |
| end |
| |
| def apt_distributions |
| apt_targets.collect(&:first).uniq |
| end |
| |
| def apt_architectures |
| [ |
| "amd64", |
| "arm64", |
| ] |
| end |
| |
| def generate_apt_release(dists_dir, code_name, component, architecture) |
| dir = "#{dists_dir}/#{component}/" |
| if architecture == "source" |
| dir << architecture |
| else |
| dir << "binary-#{architecture}" |
| end |
| |
| mkdir_p(dir, verbose: verbose?) |
| File.open("#{dir}/Release", "w") do |release| |
| release.puts(<<-RELEASE) |
| Archive: #{code_name} |
| Component: #{component} |
| Origin: #{apt_repository_label} |
| Label: #{apt_repository_label} |
| Architecture: #{architecture} |
| RELEASE |
| end |
| end |
| |
| def generate_apt_ftp_archive_generate_conf(code_name, component) |
| conf = <<-CONF |
| Dir::ArchiveDir "."; |
| Dir::CacheDir "."; |
| TreeDefault::Directory "pool/#{code_name}/#{component}"; |
| TreeDefault::SrcDirectory "pool/#{code_name}/#{component}"; |
| Default::Packages::Extensions ".deb .ddeb"; |
| Default::Packages::Compress ". gzip xz"; |
| Default::Sources::Compress ". gzip xz"; |
| Default::Contents::Compress "gzip"; |
| CONF |
| |
| apt_architectures.each do |architecture| |
| conf << <<-CONF |
| |
| BinDirectory "dists/#{code_name}/#{component}/binary-#{architecture}" { |
| Packages "dists/#{code_name}/#{component}/binary-#{architecture}/Packages"; |
| Contents "dists/#{code_name}/#{component}/Contents-#{architecture}"; |
| SrcPackages "dists/#{code_name}/#{component}/source/Sources"; |
| }; |
| CONF |
| end |
| |
| conf << <<-CONF |
| |
| Tree "dists/#{code_name}" { |
| Sections "#{component}"; |
| Architectures "#{apt_architectures.join(" ")} source"; |
| }; |
| CONF |
| |
| conf |
| end |
| |
| def generate_apt_ftp_archive_release_conf(code_name, component) |
| <<-CONF |
| APT::FTPArchive::Release::Origin "#{apt_repository_label}"; |
| APT::FTPArchive::Release::Label "#{apt_repository_label}"; |
| APT::FTPArchive::Release::Architectures "#{apt_architectures.join(" ")}"; |
| APT::FTPArchive::Release::Codename "#{code_name}"; |
| APT::FTPArchive::Release::Suite "#{code_name}"; |
| APT::FTPArchive::Release::Components "#{component}"; |
| APT::FTPArchive::Release::Description "#{apt_repository_description}"; |
| CONF |
| end |
| |
| def apt_update(base_dir, incoming_dir, merged_dir) |
| apt_targets.each do |distribution, code_name, component| |
| distribution_dir = "#{incoming_dir}/#{distribution}" |
| pool_dir = "#{distribution_dir}/pool/#{code_name}" |
| next unless File.exist?(pool_dir) |
| dists_dir = "#{distribution_dir}/dists/#{code_name}" |
| rm_rf(dists_dir, verbose: verbose?) |
| generate_apt_release(dists_dir, code_name, component, "source") |
| apt_architectures.each do |architecture| |
| generate_apt_release(dists_dir, code_name, component, architecture) |
| end |
| |
| generate_conf_file = Tempfile.new("apt-ftparchive-generate.conf") |
| File.open(generate_conf_file.path, "w") do |conf| |
| conf.puts(generate_apt_ftp_archive_generate_conf(code_name, |
| component)) |
| end |
| cd(distribution_dir, verbose: verbose?) do |
| sh("apt-ftparchive", |
| "generate", |
| generate_conf_file.path, |
| out: default_output, |
| verbose: verbose?) |
| end |
| |
| Dir.glob("#{dists_dir}/Release*") do |release| |
| rm_f(release, verbose: verbose?) |
| end |
| Dir.glob("#{distribution_dir}/*.db") do |db| |
| rm_f(db, verbose: verbose?) |
| end |
| release_conf_file = Tempfile.new("apt-ftparchive-release.conf") |
| File.open(release_conf_file.path, "w") do |conf| |
| conf.puts(generate_apt_ftp_archive_release_conf(code_name, |
| component)) |
| end |
| release_file = Tempfile.new("apt-ftparchive-release") |
| sh("apt-ftparchive", |
| "-c", release_conf_file.path, |
| "release", |
| dists_dir, |
| out: release_file.path, |
| verbose: verbose?) |
| mv(release_file.path, "#{dists_dir}/Release", verbose: verbose?) |
| |
| if base_dir and merged_dir |
| base_dists_dir = "#{base_dir}/#{distribution}/dists/#{code_name}" |
| merged_dists_dir = "#{merged_dir}/#{distribution}/dists/#{code_name}" |
| rm_rf(merged_dists_dir) |
| if Dir.exist?(base_dists_dir) and Dir.empty?(base_dists_dir) |
| mkdir_p(File.dirname(merged_dists_dir)) |
| cp_r(dists_dir, File.dirname(merged_dists_dir)) |
| else |
| merger = APTDistsMerge::Merger.new(base_dists_dir, |
| dists_dir, |
| merged_dists_dir) |
| merger.merge |
| end |
| |
| in_release_path = "#{merged_dists_dir}/InRelease" |
| release_path = "#{merged_dists_dir}/Release" |
| else |
| in_release_path = "#{dists_dir}/InRelease" |
| release_path = "#{dists_dir}/Release" |
| end |
| signed_release_path = "#{release_path}.gpg" |
| sh("gpg", |
| "--sign", |
| "--detach-sign", |
| "--armor", |
| "--local-user", gpg_key_id, |
| "--output", signed_release_path, |
| release_path, |
| out: default_output, |
| verbose: verbose?) |
| sh("gpg", |
| "--clear-sign", |
| "--local-user", gpg_key_id, |
| "--output", in_release_path, |
| release_path, |
| out: default_output, |
| verbose: verbose?) |
| end |
| end |
| |
| def define_apt_staging_tasks |
| namespace :apt do |
| namespace :staging do |
| desc "Prepare staging environment for APT repositories" |
| task :prepare do |
| apt_distributions.each do |distribution| |
| prepare_staging(distribution) |
| end |
| end |
| |
| desc "Delete staging environment for APT repositories" |
| task :delete do |
| apt_distributions.each do |distribution| |
| delete_staging(distribution) |
| end |
| end |
| end |
| end |
| end |
| |
| def define_apt_rc_tasks |
| namespace :apt do |
| namespace :rc do |
| base_dir = "#{apt_rc_repositories_dir}/base" |
| incoming_dir = "#{apt_rc_repositories_dir}/incoming" |
| merged_dir = "#{apt_rc_repositories_dir}/merged" |
| upload_dir = "#{apt_rc_repositories_dir}/upload" |
| |
| desc "Copy .deb packages" |
| task :copy do |
| apt_targets.each do |distribution, code_name, component| |
| progress_label = "Copying: #{distribution} #{code_name}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| |
| distribution_dir = "#{incoming_dir}/#{distribution}" |
| pool_dir = "#{distribution_dir}/pool/#{code_name}" |
| rm_rf(pool_dir, verbose: verbose?) |
| mkdir_p(pool_dir, verbose: verbose?) |
| |
| source_dir_prefix = "#{artifacts_dir}/#{distribution}-#{code_name}" |
| # apache/arrow uses debian-bookworm-{amd64,arm64}.tar.gz but |
| # apache/arrow-adbc uses debian-bookworm.tar.gz So the following |
| # glob must much both of them. |
| Dir.glob("#{source_dir_prefix}*.tar.gz") do |tar_gz| |
| sh("tar", "xf", tar_gz, "-C", incoming_dir) |
| progress_reporter.advance |
| end |
| |
| if distribution == "ubuntu" |
| universe_dir = "#{pool_dir}/universe" |
| next unless File.exist?(universe_dir) |
| mv(universe_dir, "#{pool_dir}/main") |
| end |
| |
| progress_reporter.finish |
| end |
| end |
| |
| desc "Download dists/ for RC APT repositories" |
| task :download do |
| apt_targets.each do |distribution, code_name, component| |
| not_checksum_pattern = /.+(?<!\.asc|\.sha512)\z/ |
| base_distribution_dir = |
| "#{base_dir}/#{distribution}/dists/#{code_name}" |
| pattern = not_checksum_pattern |
| download_distribution(:artifactory, |
| distribution, |
| base_distribution_dir, |
| :base, |
| pattern: pattern, |
| prefix: "dists/#{code_name}") |
| end |
| end |
| |
| desc "Sign .deb packages" |
| task :sign do |
| apt_distributions.each do |distribution| |
| distribution_dir = "#{incoming_dir}/#{distribution}" |
| Dir.glob("#{distribution_dir}/**/*.dsc") do |path| |
| begin |
| sh({"LANG" => "C"}, |
| "gpg", |
| "--verify", |
| path, |
| out: IO::NULL, |
| err: IO::NULL, |
| verbose: false) |
| rescue |
| sh("debsign", |
| "--no-re-sign", |
| "-k#{gpg_key_id}", |
| path, |
| out: default_output, |
| verbose: verbose?) |
| end |
| end |
| sign_dir(distribution, distribution_dir) |
| end |
| end |
| |
| desc "Update RC APT repositories" |
| task :update do |
| apt_update(base_dir, incoming_dir, merged_dir) |
| apt_targets.each do |distribution, code_name, component| |
| dists_dir = "#{merged_dir}/#{distribution}/dists/#{code_name}" |
| next unless File.exist?(dists_dir) |
| sign_dir("#{distribution} #{code_name}", |
| dists_dir) |
| end |
| end |
| |
| desc "Upload .deb packages and RC APT repositories" |
| task :upload do |
| apt_distributions.each do |distribution| |
| upload_distribution_dir = "#{upload_dir}/#{distribution}" |
| incoming_distribution_dir = "#{incoming_dir}/#{distribution}" |
| merged_dists_dir = "#{merged_dir}/#{distribution}/dists" |
| |
| rm_rf(upload_distribution_dir, verbose: verbose?) |
| mkdir_p(upload_distribution_dir, verbose: verbose?) |
| Dir.glob("#{incoming_distribution_dir}/*") do |path| |
| next if File.basename(path) == "dists" |
| cp_r(path, |
| upload_distribution_dir, |
| preserve: true, |
| verbose: verbose?) |
| end |
| cp_r(merged_dists_dir, |
| upload_distribution_dir, |
| preserve: true, |
| verbose: verbose?) |
| write_uploaded_files(upload_distribution_dir) |
| uploader = ArtifactoryUploader.new(api_key: artifactory_api_key, |
| distribution: distribution, |
| rc: rc, |
| source: upload_distribution_dir, |
| staging: staging?) |
| uploader.upload |
| end |
| end |
| end |
| |
| desc "Release RC APT repositories" |
| apt_rc_tasks = [ |
| "apt:rc:copy", |
| "apt:rc:download", |
| "apt:rc:sign", |
| "apt:rc:update", |
| "apt:rc:upload", |
| ] |
| apt_rc_tasks.unshift("apt:staging:prepare") if staging? |
| task :rc => apt_rc_tasks |
| end |
| end |
| |
| def define_apt_release_tasks |
| |
| namespace :apt do |
| desc "Release APT repository" |
| task :release do |
| apt_distributions.each do |distribution| |
| release_distribution(distribution, |
| list: uploaded_files_name) |
| end |
| end |
| end |
| end |
| |
| def define_apt_recover_tasks |
| namespace :apt do |
| namespace :recover do |
| desc "Download repositories" |
| task :download do |
| apt_targets.each do |distribution, code_name, component| |
| not_checksum_pattern = /.+(?<!\.asc|\.sha512)\z/ |
| code_name_dir = |
| "#{apt_recover_repositories_dir}/#{distribution}/pool/#{code_name}" |
| pattern = not_checksum_pattern |
| download_distribution(:artifactory, |
| distribution, |
| code_name_dir, |
| :base, |
| pattern: pattern, |
| prefix: "pool/#{code_name}") |
| end |
| end |
| |
| desc "Update repositories" |
| task :update do |
| apt_update(nil, apt_recover_repositories_dir, nil) |
| apt_targets.each do |distribution, code_name, component| |
| dists_dir = |
| "#{apt_recover_repositories_dir}/#{distribution}/dists/#{code_name}" |
| next unless File.exist?(dists_dir) |
| sign_dir("#{distribution} #{code_name}", |
| dists_dir) |
| end |
| end |
| |
| desc "Upload repositories" |
| task :upload do |
| apt_distributions.each do |distribution| |
| dists_dir = |
| "#{apt_recover_repositories_dir}/#{distribution}/dists" |
| uploader = ArtifactoryUploader.new(api_key: artifactory_api_key, |
| destination_prefix: "dists", |
| distribution: distribution, |
| source: dists_dir, |
| staging: staging?) |
| uploader.upload |
| end |
| end |
| end |
| |
| desc "Recover APT repositories" |
| apt_recover_tasks = [ |
| "apt:recover:download", |
| "apt:recover:update", |
| "apt:recover:upload", |
| ] |
| task :recover => apt_recover_tasks |
| end |
| end |
| |
| def define_apt_tasks |
| define_apt_staging_tasks |
| define_apt_rc_tasks |
| define_apt_release_tasks |
| define_apt_recover_tasks |
| end |
| |
| def yum_rc_repositories_dir |
| "#{rc_dir}/yum/repositories" |
| end |
| |
| def yum_release_repositories_dir |
| "#{release_dir}/yum/repositories" |
| end |
| |
| def available_yum_targets |
| [ |
| ["almalinux", "10"], |
| ["almalinux", "9"], |
| ["almalinux", "8"], |
| ["amazon-linux", "2023"], |
| ["centos", "9-stream"], |
| ] |
| end |
| |
| def yum_targets |
| env_yum_targets = (ENV["YUM_TARGETS"] || "").split(",") |
| if env_yum_targets.empty? |
| available_yum_targets |
| else |
| available_yum_targets.select do |distribution, distribution_version| |
| env_yum_targets.any? do |env_yum_target| |
| if /\d/.match?(env_yum_target) |
| env_yum_target.start_with?("#{distribution}-#{distribution_version}") |
| else |
| env_yum_target == distribution |
| end |
| end |
| end |
| end |
| end |
| |
| def yum_distributions |
| yum_targets.collect(&:first).uniq |
| end |
| |
| def yum_architectures |
| [ |
| "aarch64", |
| "x86_64", |
| ] |
| end |
| |
| def signed_rpm?(rpm) |
| IO.pipe do |input, output| |
| system("rpm", "--checksig", rpm, out: output) |
| output.close |
| signature = input.gets.sub(/\A#{Regexp.escape(rpm)}: /, "") |
| signature.split.include?("signatures") |
| end |
| end |
| |
| def sign_rpms(directory) |
| thread_pool = ThreadPool.new(:gpg) do |rpm| |
| unless signed_rpm?(rpm) |
| sh("rpm", |
| "-D", "_gpg_name #{gpg_key_id}", |
| "-D", "__gpg /usr/bin/gpg", |
| "-D", "__gpg_check_password_cmd /bin/true true", |
| "--resign", |
| rpm, |
| out: default_output, |
| verbose: verbose?) |
| end |
| end |
| Dir.glob("#{directory}/**/*.rpm") do |rpm| |
| thread_pool << rpm |
| end |
| thread_pool.join |
| end |
| |
| def rpm_sign(directory) |
| unless system("rpm", "-q", |
| rpm_gpg_key_package_name(gpg_key_id), |
| out: IO::NULL) |
| gpg_key = Tempfile.new(["apache-arrow-binary", ".asc"]) |
| sh("gpg", |
| "--armor", |
| "--export", gpg_key_id, |
| out: gpg_key.path, |
| verbose: verbose?) |
| sh("rpm", |
| "--import", gpg_key.path, |
| out: default_output, |
| verbose: verbose?) |
| gpg_key.close! |
| end |
| |
| yum_targets.each do |distribution, distribution_version| |
| source_dir = [ |
| directory, |
| distribution, |
| distribution_version, |
| ].join("/") |
| sign_rpms(source_dir) |
| end |
| end |
| |
| def yum_update(base_dir, incoming_dir) |
| yum_targets.each do |distribution, distribution_version| |
| target_dir = "#{incoming_dir}/#{distribution}/#{distribution_version}" |
| target_dir = Pathname(target_dir) |
| next unless target_dir.directory? |
| |
| base_target_dir = Pathname(base_dir) + distribution + distribution_version |
| if base_target_dir.exist? |
| base_target_dir.glob("*") do |base_arch_dir| |
| next unless base_arch_dir.directory? |
| |
| base_repodata_dir = base_arch_dir + "repodata" |
| next unless base_repodata_dir.exist? |
| |
| target_repodata_dir = target_dir + base_arch_dir.basename + "repodata" |
| rm_rf(target_repodata_dir, verbose: verbose?) |
| mkdir_p(target_repodata_dir.parent, verbose: verbose?) |
| cp_r(base_repodata_dir, |
| target_repodata_dir, |
| preserve: true, |
| verbose: verbose?) |
| end |
| end |
| |
| target_dir.glob("*") do |arch_dir| |
| next unless arch_dir.directory? |
| |
| packages = Tempfile.new("createrepo-c-packages") |
| Pathname.glob("#{arch_dir}/*/*.rpm") do |rpm| |
| relative_rpm = rpm.relative_path_from(arch_dir) |
| packages.puts(relative_rpm.to_s) |
| end |
| packages.close |
| sh("createrepo_c", |
| "--pkglist", packages.path, |
| "--recycle-pkglist", |
| "--retain-old-md-by-age=0", |
| "--skip-stat", |
| "--update", |
| arch_dir.to_s, |
| out: default_output, |
| verbose: verbose?) |
| end |
| end |
| end |
| |
| def define_yum_staging_tasks |
| namespace :yum do |
| namespace :staging do |
| desc "Prepare staging environment for Yum repositories" |
| task :prepare do |
| yum_distributions.each do |distribution| |
| prepare_staging(distribution) |
| end |
| end |
| |
| desc "Delete staging environment for Yum repositories" |
| task :delete do |
| yum_distributions.each do |distribution| |
| delete_staging(distribution) |
| end |
| end |
| end |
| end |
| end |
| |
| def define_yum_rc_tasks |
| namespace :yum do |
| namespace :rc do |
| base_dir = "#{yum_rc_repositories_dir}/base" |
| incoming_dir = "#{yum_rc_repositories_dir}/incoming" |
| upload_dir = "#{yum_rc_repositories_dir}/upload" |
| |
| desc "Copy RPM packages" |
| task :copy do |
| yum_targets.each do |distribution, distribution_version| |
| progress_label = "Copying: #{distribution} #{distribution_version}" |
| progress_reporter = ProgressReporter.new(progress_label) |
| |
| destination_dir = File.join(incoming_dir, |
| distribution, |
| distribution_version) |
| rm_rf(destination_dir, verbose: verbose?) |
| mkdir_p(destination_dir, verbose: verbose?) |
| |
| source_dir_prefix = |
| "#{artifacts_dir}/#{distribution}-#{distribution_version}" |
| # apache/arrow uses almalinux-10-{amd64,arm64}.tar.gz but |
| # apache/arrow-adbc uses almalinux-10.tar.gz So the |
| # following glob must much both of them. |
| Dir.glob("#{source_dir_prefix}*.tar.gz") do |tar_gz| |
| sh("tar", "xf", tar_gz, "-C", incoming_dir) |
| progress_reporter.advance |
| end |
| |
| case "#{distribution}-#{distribution_version}" |
| when "almalinux-10", |
| "almalinux-9", |
| "almalinux-8", |
| "amazon-linux-2023", |
| "centos-9-stream" |
| # Adjust source packages directory for backward |
| # compatibility. We don't need this for new supported |
| # distribution because we don't need to care about |
| # backward compatibility for them. |
| # |
| # Example: |
| # almalinux/10/Source/Packages/ -> |
| # almalinux/10/Source/SPackages/ |
| mv(File.join(destination_dir, "Source", "Packages"), |
| File.join(destination_dir, "Source", "SPackages"), |
| verbose: true) |
| end |
| |
| progress_reporter.finish |
| end |
| end |
| |
| desc "Download repodata for RC Yum repositories" |
| task :download do |
| yum_distributions.each do |distribution| |
| distribution_dir = "#{base_dir}/#{distribution}" |
| download_distribution(:artifactory, |
| distribution, |
| distribution_dir, |
| :base, |
| pattern: /\/repodata\//) |
| end |
| end |
| |
| desc "Sign RPM packages" |
| task :sign do |
| rpm_sign(incoming_dir) |
| yum_targets.each do |distribution, distribution_version| |
| source_dir = [ |
| incoming_dir, |
| distribution, |
| distribution_version, |
| ].join("/") |
| sign_dir("#{distribution}-#{distribution_version}", |
| source_dir) |
| end |
| end |
| |
| desc "Update RC Yum repositories" |
| task :update do |
| yum_update(base_dir, incoming_dir) |
| yum_targets.each do |distribution, distribution_version| |
| target_dir = [ |
| incoming_dir, |
| distribution, |
| distribution_version, |
| ].join("/") |
| target_dir = Pathname(target_dir) |
| next unless target_dir.directory? |
| target_dir.glob("*") do |arch_dir| |
| next unless arch_dir.directory? |
| sign_label = |
| "#{distribution}-#{distribution_version} #{arch_dir.basename}" |
| sign_dir(sign_label, |
| arch_dir.to_s) |
| end |
| end |
| end |
| |
| desc "Upload RC Yum repositories" |
| task :upload => yum_rc_repositories_dir do |
| yum_distributions.each do |distribution| |
| incoming_target_dir = "#{incoming_dir}/#{distribution}" |
| upload_target_dir = "#{upload_dir}/#{distribution}" |
| |
| rm_rf(upload_target_dir, verbose: verbose?) |
| mkdir_p(upload_target_dir, verbose: verbose?) |
| cp_r(Dir.glob("#{incoming_target_dir}/*"), |
| upload_target_dir.to_s, |
| preserve: true, |
| verbose: verbose?) |
| write_uploaded_files(upload_target_dir) |
| |
| uploader = ArtifactoryUploader.new(api_key: artifactory_api_key, |
| distribution: distribution, |
| rc: rc, |
| source: upload_target_dir, |
| staging: staging?, |
| # Don't remove old repodata |
| # because our implementation |
| # doesn't support it. |
| sync: false, |
| sync_pattern: /\/repodata\//) |
| uploader.upload |
| end |
| end |
| end |
| |
| desc "Release RC Yum packages" |
| yum_rc_tasks = [ |
| "yum:rc:copy", |
| "yum:rc:download", |
| "yum:rc:sign", |
| "yum:rc:update", |
| "yum:rc:upload", |
| ] |
| yum_rc_tasks.unshift("yum:staging:prepare") if staging? |
| task :rc => yum_rc_tasks |
| end |
| end |
| |
| def define_yum_release_tasks |
| directory yum_release_repositories_dir |
| |
| namespace :yum do |
| desc "Release Yum packages" |
| task :release => yum_release_repositories_dir do |
| yum_distributions.each do |distribution| |
| release_distribution(distribution, |
| list: uploaded_files_name) |
| |
| distribution_dir = "#{yum_release_repositories_dir}/#{distribution}" |
| download_distribution(:artifactory, |
| distribution, |
| distribution_dir, |
| :rc, |
| pattern: /\/repodata\//) |
| uploader = ArtifactoryUploader.new(api_key: artifactory_api_key, |
| distribution: distribution, |
| source: distribution_dir, |
| staging: staging?, |
| # Don't remove old repodata for |
| # unsupported distribution version |
| # such as Amazon Linux 2. |
| # This keeps garbage in repodata/ |
| # for currently available |
| # distribution versions but we |
| # accept it for easy to implement. |
| sync: false, |
| sync_pattern: /\/repodata\//) |
| uploader.upload |
| end |
| end |
| end |
| end |
| |
| def define_yum_tasks |
| define_yum_staging_tasks |
| define_yum_rc_tasks |
| define_yum_release_tasks |
| end |
| |
| def define_summary_tasks |
| namespace :summary do |
| desc "Show RC summary" |
| task :rc do |
| suffix = "" |
| suffix << "-staging" if staging? |
| puts(<<-SUMMARY) |
| Success! The release candidate binaries are available here: |
| https://packages.apache.org/artifactory/arrow/almalinux#{suffix}-rc/ |
| https://packages.apache.org/artifactory/arrow/amazon-linux#{suffix}-rc/ |
| https://packages.apache.org/artifactory/arrow/centos#{suffix}-rc/ |
| https://packages.apache.org/artifactory/arrow/debian#{suffix}-rc/ |
| https://packages.apache.org/artifactory/arrow/ubuntu#{suffix}-rc/ |
| SUMMARY |
| end |
| |
| desc "Show release summary" |
| task :release do |
| suffix = "" |
| suffix << "-staging" if staging? |
| puts(<<-SUMMARY) |
| Success! The release binaries are available here: |
| https://packages.apache.org/artifactory/arrow/almalinux#{suffix}/ |
| https://packages.apache.org/artifactory/arrow/amazon-linux#{suffix}/ |
| https://packages.apache.org/artifactory/arrow/centos#{suffix}/ |
| https://packages.apache.org/artifactory/arrow/debian#{suffix}/ |
| https://packages.apache.org/artifactory/arrow/ubuntu#{suffix}/ |
| SUMMARY |
| end |
| end |
| end |
| end |
| |
| class LocalBinaryTask < BinaryTask |
| def initialize(packages, top_source_directory) |
| @packages = packages |
| @top_source_directory = top_source_directory |
| super() |
| end |
| |
| def define |
| define_apt_test_task |
| define_yum_test_task |
| end |
| |
| private |
| def resolve_docker_image(target) |
| case target |
| when /-(?:arm64|aarch64)\z/ |
| target = Regexp.last_match.pre_match |
| platform = "linux/arm64" |
| else |
| platform = "linux/amd64" |
| end |
| |
| case target |
| when /\Acentos-(\d+)-stream\z/ |
| centos_stream_version = $1 |
| image = "quay.io/centos/centos:stream#{centos_stream_version}" |
| else |
| case platform |
| when "linux/arm64" |
| image = "arm64v8/" |
| else |
| image = "" |
| end |
| target = target.gsub(/\Aamazon-linux/, "amazonlinux") |
| image << target.gsub(/-/, ":") |
| end |
| |
| [platform, image] |
| end |
| |
| def verify_apt_sh |
| "/host/dev/release/verify-apt.sh" |
| end |
| |
| def verify_yum_sh |
| "/host/dev/release/verify-yum.sh" |
| end |
| |
| def verify(target) |
| verify_command_line = [ |
| "docker", |
| "run", |
| "--log-driver", "none", |
| "--rm", |
| "--security-opt", "seccomp=unconfined", |
| "--volume", "#{@top_source_directory}:/host:delegated", |
| ] |
| if $stdin.tty? |
| verify_command_line << "--interactive" |
| verify_command_line << "--tty" |
| else |
| verify_command_line.concat(["--attach", "STDOUT"]) |
| verify_command_line.concat(["--attach", "STDERR"]) |
| end |
| platform, docker_image = resolve_docker_image(target) |
| docker_info = JSON.parse(`docker info --format '{{json .}}'`) |
| case [platform, docker_info["Architecture"]] |
| when ["linux/amd64", "x86_64"], |
| ["linux/arm64", "aarch64"] |
| # Do nothing |
| else |
| verify_command_line.concat(["--platform", platform]) |
| end |
| verify_command_line << docker_image |
| case target |
| when /\Adebian-/, /\Aubuntu-/ |
| verify_command_line << verify_apt_sh |
| else |
| verify_command_line << verify_yum_sh |
| end |
| verify_command_line << version |
| verify_command_line << "local" |
| sh(*verify_command_line) |
| end |
| |
| def apt_test_targets |
| targets = (ENV["APT_TARGETS"] || "").split(",") |
| targets = apt_test_targets_default if targets.empty? |
| targets |
| end |
| |
| def apt_test_targets_default |
| # Disable arm64 targets by default for now |
| # because they require some setups on host. |
| [ |
| "debian-bookworm", |
| # "debian-bookworm-arm64", |
| "debian-trixie", |
| # "debian-trixie-arm64", |
| "debian-forky", |
| # "debian-forky-arm64", |
| "ubuntu-jammy", |
| # "ubuntu-jammy-arm64", |
| "ubuntu-noble", |
| # "ubuntu-noble-arm64", |
| ] |
| end |
| |
| def define_apt_test_task |
| namespace :apt do |
| desc "Test deb packages" |
| task :test do |
| repositories_dir = "apt/repositories" |
| unless @packages.empty? |
| rm_rf(repositories_dir) |
| @packages.each do |package| |
| package_repositories = "#{package}/apt/repositories" |
| next unless File.exist?(package_repositories) |
| sh("rsync", "-av", "#{package_repositories}/", repositories_dir) |
| end |
| end |
| Dir.glob("#{repositories_dir}/ubuntu/pool/*") do |code_name_dir| |
| universe_dir = "#{code_name_dir}/universe" |
| next unless File.exist?(universe_dir) |
| mv(universe_dir, "#{code_name_dir}/main") |
| end |
| base_dir = "nonexistent" |
| merged_dir = "apt/merged" |
| apt_update(base_dir, repositories_dir, merged_dir) |
| Dir.glob("#{merged_dir}/*/dists/*") do |dists_code_name_dir| |
| prefix = dists_code_name_dir.split("/")[-3..-1].join("/") |
| mv(Dir.glob("#{dists_code_name_dir}/*Release*"), |
| "#{repositories_dir}/#{prefix}") |
| end |
| apt_test_targets.each do |target| |
| verify(target) |
| end |
| end |
| end |
| end |
| |
| def yum_test_targets |
| targets = (ENV["YUM_TARGETS"] || "").split(",") |
| targets = yum_test_targets_default if targets.empty? |
| targets |
| end |
| |
| def yum_test_targets_default |
| # Disable aarch64 targets by default for now |
| # because they require some setups on host. |
| [ |
| "almalinux-10", |
| # "almalinux-10-aarch64", |
| "almalinux-9", |
| # "almalinux-9-aarch64", |
| "almalinux-8", |
| # "almalinux-8-aarch64", |
| "amazon-linux-2023", |
| # "amazon-linux-2023-aarch64", |
| "centos-9-stream", |
| # "centos-9-stream-aarch64", |
| ] |
| end |
| |
| def define_yum_test_task |
| namespace :yum do |
| desc "Test RPM packages" |
| task :test do |
| repositories_dir = "yum/repositories" |
| unless @packages.empty? |
| rm_rf(repositories_dir) |
| @packages.each do |package| |
| package_repositories = "#{package}/yum/repositories" |
| next unless File.exist?(package_repositories) |
| sh("rsync", "-av", "#{package_repositories}/", repositories_dir) |
| end |
| end |
| rpm_sign(repositories_dir) |
| base_dir = "nonexistent" |
| yum_update(base_dir, repositories_dir) |
| yum_test_targets.each do |target| |
| verify(target) |
| end |
| end |
| end |
| end |
| end |