lib/release_tools/product_version.rb (123 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
# ProductVersion is a container for any type of GitLab - The DevOps Platform package.
# Given a Product version we will be able to fetch its own metadata and find
# what references to both components and packagers.
#
# A ProductVersion is always a normalized version (see: Version#to_normalized_version)
class ProductVersion
include ::SemanticLogger::Loggable
include Comparable
extend Forwardable
MetadataInfo = Struct.new(:commit_id, :content, keyword_init: true)
METADATA_PATH_REGEXP = %r[releases/(?<major>\d+)/(?<normalized_version>[^/]+).json]
AUTO_DEPLOY_TAG_REGEX = %r{\A (?<major>\d+) \.(?<minor>\d+) \.(?<timestamp>\d+) \+ .* \z}x
# delegate data accessors
def_delegators :@version, :major, :minor, :patch, :rc, :to_s
# delegate predicate functions for detecting the type of version
def_delegators :@version, :rc?, :monthly?, :patch?
# Instance of ::Version
attr_reader :version
# Creates a `ProductVersion` from an auto-deploy tag or a package version
#
# @param version [String, AutoDeploy::Version] the auto-deploy version
# @return [ProductVersion, nil] the product version of the given auto-deploy
# version or nil if not an auto-deploy version.
def self.from_auto_deploy(version)
version = AutoDeploy::Version.new(version) unless version.respond_to?(:to_package)
from_package_version(version.to_package)
rescue ArgumentError
nil
end
# Creates a `ProductVersion` from an auto-deploy tag
#
# @param version [String] the auto-deploy tag
# @return [ProductVersion, nil] the product version of the given auto-deploy
# tag or nil if not an auto-deploy tag.
def self.from_auto_deploy_tag(version)
captures = AUTO_DEPLOY_TAG_REGEX.match(version)
return nil unless captures
new("#{captures['major']}.#{captures['minor']}.#{captures['timestamp']}")
end
# Creates a `ProductVersion` from an omnibus package version (i.e. $DEPLOY_VERSION)
#
# @param version [String, Version] a valid Omnibus package version
# @return [ProductVersion, nil] the product version of the given version
# or nil if not a valid version.
def self.from_package_version(version)
version = Version.new(version) unless version.is_a?(Version)
return nil unless version.version?
new(version.to_normalized_version)
end
# Creates a `ProductVersion` from a release/metadata commit id.
#
# @param sha [String] the commit id introducting a release metadata
# @return [ProductVersion, nil] the product version of the given version
# or nil if not a valid version.
def self.from_metadata_sha(sha)
Retriable.with_context(:api) do
logger.info('Fetching release metadata commit', sha: sha)
diffs = GitlabOpsClient.commit_diff(ReleaseMetadataUploader::PROJECT, sha)
diff = diffs.detect do |d|
d.new_file && METADATA_PATH_REGEXP.match(d.new_path)
end
return nil unless diff
from_package_version(METADATA_PATH_REGEXP.match(diff.new_path)[:normalized_version])
rescue Gitlab::Error::NotFound
nil
end
end
# NOTE(nolith): here we implement Enumerable at class level to iterate over all the
# product versions in release-metadata. It's implemented as a lazy enumerator in order
# to only fetch commits one by one. Because release-metadata acts as a ledger, we can
# navigate it commit by commit and build the product version history.
class << self
include Enumerable
def each(&block)
return lazy_enumerator unless block
lazy_enumerator.each(&block)
end
def last_auto_deploy
@last_auto_deploy ||= find(&:auto_deploy?)
end
private
def lazy_enumerator
Enumerator.new do |yielder|
commits = Retriable.with_context(:api) do
GitlabOpsClient.commits(ReleaseMetadataUploader::PROJECT, trailers: true)
end
loop do
commits.each do |commit|
version = commit.trailers.to_h['Product-Version']
# If a commit does not have the trailer, it is not a commit that
# recorded a new version.
next unless version
product_version = new(version)
yielder << product_version
end
break unless commits.has_next_page?
Retriable.with_context(:api) do
commits = commits.next_page
end
end
end.lazy
end
end
def initialize(version)
@version = Version.new(version)
end
def ==(other)
@version.to_s == other.to_s
end
def <=>(other)
@version <=> other.version
end
def metadata
full_metadata.content
end
def metadata_commit_id
full_metadata.commit_id
end
# product version for auto_deploy is MAJOR.MINOR.TIMESTAMP<YYYYMMDDhhmm>
# it's safe to assume that we will never have a 12 digit patch version
# so we can identify auto_deploy packages by checking the patch value
def auto_deploy?
# YYYYMMDDhhmm first gitlab commit by DZ
patch? && patch > 201110082230 # rubocop:disable Style/NumericLiterals
end
def auto_deploy_package
metadata
.dig('releases', Project::OmnibusGitlab.metadata_project_name, 'ref')
.tr('+', '-')
end
# extract the release metadata for the given released_item
#
# @param released_item [String, Symbol, Project] the component
# @return [ReleaseMetadata::Release, nil] the release information
# or nil if the component does not exists
def [](released_item)
released_item = released_item.metadata_project_name if released_item.respond_to?(:metadata_project_name)
name = released_item.to_s
raw = metadata.dig('releases', name)
return unless raw
ReleaseMetadata::Release.new(name: name, **raw)
end
private
def metadata_path
"releases/#{major}/#{@version.to_normalized_version}.json"
end
def full_metadata
@full_metadata ||= Retriable.with_context(:api) do
logger.info('Fetching release metadata', version: to_s)
meta_info = GitlabOpsClient.get_file(
ReleaseMetadataUploader::PROJECT,
metadata_path,
ReleaseMetadataUploader::PROJECT.default_branch
)
json = Base64.strict_decode64(meta_info.content)
MetadataInfo.new(
commit_id: meta_info.last_commit_id,
content: JSON.parse(json)
)
rescue Gitlab::Error::NotFound
MetadataInfo.new(content: {})
end
end
end
end