diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb index 3f793065d7dff94b9a5177f58d9bd77a8e112457..fe734ab59d52db2862d1ff14eea54a3e41b45b9a 100644 --- a/app/services/ci/components/fetch_service.rb +++ b/app/services/ci/components/fetch_service.rb @@ -31,7 +31,8 @@ def execute path: result.path, project: component_path.project, sha: component_path.sha, - name: component_path.component_name + name: component_path.component_name, + version: component_path.matched_version }) elsif component_path.invalid_usage_for_latest? ServiceResponse.error( diff --git a/app/validators/json_schemas/catalog_resource_component_spec.json b/app/validators/json_schemas/catalog_resource_component_spec.json index 62878edc133af3767146c52edade0e633d87e48f..b48a93a4521194cf0468441ee69ea232363a47df 100644 --- a/app/validators/json_schemas/catalog_resource_component_spec.json +++ b/app/validators/json_schemas/catalog_resource_component_spec.json @@ -28,6 +28,18 @@ } } } + }, + "component": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "name", + "sha", + "version" + ] + }, + "uniqueItems": true } }, "additionalProperties": false diff --git a/lib/gitlab/ci/components/instance_path.rb b/lib/gitlab/ci/components/instance_path.rb index f598fdb673f1048df77cea777344a8ea42692772..56e84d292c7663a366710b76906def6575fcf1a9 100644 --- a/lib/gitlab/ci/components/instance_path.rb +++ b/lib/gitlab/ci/components/instance_path.rb @@ -42,47 +42,74 @@ def project def sha return unless project - find_version_sha(version) + find_version_sha end strong_memoize_attr :sha + def matched_version + find_catalog_version&.semver&.to_s + end + strong_memoize_attr :matched_version + def invalid_usage_for_latest? - @version == LATEST && project && project.catalog_resource.nil? + version == LATEST && project && project.catalog_resource.nil? end def invalid_usage_for_partial_semver? - @version.match?(SHORTHAND_SEMVER_PATTERN) && project && project.catalog_resource.nil? + version.match?(SHORTHAND_SEMVER_PATTERN) && project && project.catalog_resource.nil? end private attr_reader :version - def find_version_sha(version) + def find_version_sha return find_latest_sha if version == LATEST - sha_by_shorthand_semver(version) || sha_by_released_tag(version) || sha_by_ref(version) + sha_by_shorthand_semver || sha_by_released_tag || sha_by_ref + end + + def find_catalog_version + return unless project.catalog_resource + + if version == LATEST + catalog_resource_version_latest + elsif version.match?(SHORTHAND_SEMVER_PATTERN) + catalog_resource_version_by_short_semver + else + project.catalog_resource.versions.by_name(version).first + end end def find_latest_sha return unless project.catalog_resource - project.catalog_resource.versions.latest&.sha + catalog_resource_version_latest&.sha end - def sha_by_shorthand_semver(version) + def sha_by_shorthand_semver return unless version.match?(SHORTHAND_SEMVER_PATTERN) return unless project.catalog_resource + catalog_resource_version_by_short_semver&.sha + end + + def catalog_resource_version_latest + project.catalog_resource.versions.latest + end + strong_memoize_attr :catalog_resource_version_latest + + def catalog_resource_version_by_short_semver major, minor = version.split(".") - project.catalog_resource.versions.latest(major, minor)&.sha + project.catalog_resource.versions.latest(major, minor) end + strong_memoize_attr :catalog_resource_version_by_short_semver - def sha_by_released_tag(version) + def sha_by_released_tag project.releases.find_by_tag(version)&.sha end - def sha_by_ref(version) + def sha_by_ref project.commit(version)&.id end diff --git a/lib/gitlab/ci/config/external/file/component.rb b/lib/gitlab/ci/config/external/file/component.rb index 58cd017e79ddd45b8acb6116fb0c5a52cd6aa24f..77b835fdd27399cb0a5539419b241e1fef19615a 100644 --- a/lib/gitlab/ci/config/external/file/component.rb +++ b/lib/gitlab/ci/config/external/file/component.rb @@ -117,7 +117,8 @@ def component_attrs { project: component_payload.fetch(:project), sha: component_payload.fetch(:sha), - name: component_payload.fetch(:name) + name: component_payload.fetch(:name), + version: component_payload.fetch(:version) } end diff --git a/lib/gitlab/ci/config/header/component.rb b/lib/gitlab/ci/config/header/component.rb index 96ddbd750762fff8e8555d993ecef6d008fcb0fc..debbc5af75001281765e640ef9702ebe3aee3813 100644 --- a/lib/gitlab/ci/config/header/component.rb +++ b/lib/gitlab/ci/config/header/component.rb @@ -13,7 +13,7 @@ module Header class Component < ::Gitlab::Config::Entry::Node include ::Gitlab::Config::Entry::Validatable - ALLOWED_VALUES = %i[name sha].freeze + ALLOWED_VALUES = %i[name sha version].freeze ALLOWED_VALUES_TO_S = ALLOWED_VALUES.map(&:to_s).freeze validations do diff --git a/spec/lib/gitlab/ci/components/instance_path_spec.rb b/spec/lib/gitlab/ci/components/instance_path_spec.rb index 7ff045929f7ca583381c6c70a794db3d7cb967e5..0f5515e2fc7c63282a6709a6e6236ae80adac452 100644 --- a/spec/lib/gitlab/ci/components/instance_path_spec.rb +++ b/spec/lib/gitlab/ci/components/instance_path_spec.rb @@ -297,6 +297,74 @@ end end + describe '#matched_version' do + let_it_be(:project) do + create( + :project, :custom_repo, + files: { + 'templates/secret-detection.yml' => 'image: alpine_1', + 'templates/dast/template.yml' => 'image: alpine_2', + 'templates/dast/another-template.yml' => 'image: alpine_3', + 'templates/dast/another-folder/template.yml' => 'image: alpine_4' + } + ) + end + + let_it_be(:catalog_resource) { create(:ci_catalog_resource, :published, project: project) } + let_it_be(:commit) { project.repository.commit } + let_it_be(:project_path) { project.full_path } + + before_all do + project.add_maintainer(user) + + create( + :release, :with_catalog_resource_version, + project: project, tag: '0.1.0', author: user, sha: commit.id + ) + + create( + :release, :with_catalog_resource_version, + project: project, tag: '0.1.2', author: user, sha: commit.id + ) + end + + subject(:matched_version) { path.matched_version } + + context 'when using a full semantic version' do + let(:address) { "acme.com/#{project_path}/secret-detection@0.1.0" } + + it { is_expected.to eq '0.1.0' } + end + + context 'when using a partial semantic version' do + let(:address) { "acme.com/#{project_path}/secret-detection@0.1" } + + it { is_expected.to eq '0.1.2' } + end + + context 'when using ~latest' do + let(:address) { "acme.com/#{project_path}/secret-detection@~latest" } + + it { is_expected.to eq '0.1.2' } + end + + context 'when using a SHA' do + let(:address) { "acme.com/#{project_path}/secret-detection@#{commit.id}" } + + it { is_expected.to be_nil } + end + + context 'when project is not a catalog resource' do + let(:address) { "acme.com/#{project_path}/secret-detection@0.1.0" } + + before do + catalog_resource.destroy! + end + + it { is_expected.to be_nil } + end + end + describe '#invalid_usage_for_latest?' do let_it_be(:project) { create(:project) } let(:project_path) { project.full_path } diff --git a/spec/lib/gitlab/ci/config/external/context_spec.rb b/spec/lib/gitlab/ci/config/external/context_spec.rb index 1ad7febaca81e7382ec9ecfce12ef284b29bfae1..c197901282ddc4817375e024b69fbf1d927e8836 100644 --- a/spec/lib/gitlab/ci/config/external/context_spec.rb +++ b/spec/lib/gitlab/ci/config/external/context_spec.rb @@ -47,7 +47,7 @@ end context 'with component_data' do - let(:component_data) { { name: 'my-component', sha: 'abc123' } } + let(:component_data) { { name: 'my-component', sha: 'abc123', version: '1.0.0' } } let(:attributes) do { project: project, diff --git a/spec/lib/gitlab/ci/config/external/file/component_spec.rb b/spec/lib/gitlab/ci/config/external/file/component_spec.rb index 075bd7665b580ca6e8c0e1a7e3c69d24177c983d..3ba1ec0289ef2e9f1def71e406b811f91a70f009 100644 --- a/spec/lib/gitlab/ci/config/external/file/component_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/component_spec.rb @@ -35,7 +35,8 @@ path: 'templates/my_component.yml', project: project, sha: 'my_component_sha', - name: 'my_component' + name: 'my_component', + version: nil }) end @@ -170,7 +171,8 @@ component: { project: project, sha: 'my_component_sha', - name: 'my_component' + name: 'my_component', + version: nil } ) end @@ -187,7 +189,8 @@ variables: context.variables, component_data: { name: 'my_component', - sha: 'my_component_sha' + sha: 'my_component_sha', + version: nil } ) end diff --git a/spec/lib/gitlab/ci/config/header/component_spec.rb b/spec/lib/gitlab/ci/config/header/component_spec.rb index 927e1f23d178176f662f4f2d8cb213e7067476a7..2e890a2d9a5ec5d2bae24c73c382bc9ab6ba3e69 100644 --- a/spec/lib/gitlab/ci/config/header/component_spec.rb +++ b/spec/lib/gitlab/ci/config/header/component_spec.rb @@ -7,7 +7,7 @@ describe 'validations' do context 'when config is an array of valid strings' do - let(:config) { %w[name sha] } + let(:config) { %w[name sha version] } it 'passes validations' do expect(component).to be_valid @@ -25,7 +25,7 @@ end context 'when config contains invalid values' do - let(:config) { %w[name invalid_key] } + let(:config) { %w[name invalid_key version] } it 'fails validations' do expect(component).not_to be_valid @@ -52,7 +52,7 @@ end context 'when config contains non-string values' do - let(:config) { ['name', 123] } + let(:config) { ['name', 123, 'version'] } it 'fails validations' do expect(component).not_to be_valid @@ -65,19 +65,19 @@ subject(:value) { component.value } context 'when config is valid' do - let(:config) { %w[name sha] } + let(:config) { %w[name sha version] } - it { is_expected.to match_array([:name, :sha]) } + it { is_expected.to match_array([:name, :sha, :version]) } end context 'when config has duplicate values' do - let(:config) { %w[name name sha] } + let(:config) { %w[name version name sha] } - it { is_expected.to match_array([:name, :sha]) } + it { is_expected.to match_array([:name, :version, :sha]) } end context 'when config has invalid values' do - let(:config) { %w[name invalid] } + let(:config) { %w[name version invalid] } it { is_expected.to be_empty } end diff --git a/spec/lib/gitlab/ci/config/header/root_spec.rb b/spec/lib/gitlab/ci/config/header/root_spec.rb index 2fa9ab98d1385ef1b6fb61b875ff8b4887d2ea5b..7b7d7d2b87f0112bd334f89714549b35bbf9ffe5 100644 --- a/spec/lib/gitlab/ci/config/header/root_spec.rb +++ b/spec/lib/gitlab/ci/config/header/root_spec.rb @@ -136,13 +136,13 @@ let(:header_hash) do { spec: { - component: %w[name sha] + component: %w[name sha version] } } end it 'returns the component value as symbols' do - expect(config.spec_component_value).to match_array([:name, :sha]) + expect(config.spec_component_value).to match_array([:name, :sha, :version]) end end @@ -183,14 +183,14 @@ inputs: { foo: { default: 'bar' } }, - component: %w[name] + component: %w[name version] } } end it 'returns both values correctly' do expect(config.spec_inputs_value).to eq({ foo: { default: 'bar' } }) - expect(config.spec_component_value).to match_array([:name]) + expect(config.spec_component_value).to match_array([:name, :version]) end end end diff --git a/spec/lib/gitlab/ci/config/header/spec_spec.rb b/spec/lib/gitlab/ci/config/header/spec_spec.rb index 8c1b081cc4caec227801e8969140f802c80163d2..f318c39880d3750c98aa205fc4f8b85a131a5d7d 100644 --- a/spec/lib/gitlab/ci/config/header/spec_spec.rb +++ b/spec/lib/gitlab/ci/config/header/spec_spec.rb @@ -31,7 +31,7 @@ context 'when spec contains component configuration' do let(:spec_hash) do { - component: %w[name sha] + component: %w[name sha version] } end @@ -41,7 +41,7 @@ end it 'returns the component value' do - expect(config.component_value).to match_array([:name, :sha]) + expect(config.component_value).to match_array([:name, :sha, :version]) end end @@ -53,7 +53,7 @@ default: 'bar' } }, - component: %w[name] + component: %w[name version] } end @@ -64,7 +64,7 @@ it 'returns both values correctly' do expect(config.inputs_value).to eq({ foo: { default: 'bar' } }) - expect(config.component_value).to match_array([:name]) + expect(config.component_value).to match_array([:name, :version]) end end diff --git a/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb b/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb index 5d7be700073cfb3ad55c32a7a62cf5d3b408cac7..bba6c41aaccf15413b536f1e364462ae0d1fc23b 100644 --- a/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb @@ -176,17 +176,17 @@ let(:yaml_context) do ::Gitlab::Ci::Config::Yaml::Context.new( variables: [], - component: { name: 'my-component', sha: 'abc123' } + component: { name: 'my-component', sha: 'abc123', version: '1.0.0' } ) end context 'when component values are specified in spec' do let(:header) do - { spec: { component: %w[name sha] } } + { spec: { component: %w[name sha version] } } end let(:content) do - { test: 'Component $[[ component.name ]] at $[[ component.sha ]]' } + { test: 'Component $[[ component.name ]] at $[[ component.sha ]] version $[[ component.version ]]' } end let(:arguments) { {} } @@ -196,7 +196,7 @@ expect(subject).to be_interpolated expect(subject).to be_valid - expect(subject.to_hash).to eq({ test: 'Component my-component at abc123' }) + expect(subject.to_hash).to eq({ test: 'Component my-component at abc123 version 1.0.0' }) end end @@ -221,12 +221,12 @@ context 'when both inputs and component are used' do let(:header) do - { spec: { inputs: { env: nil }, component: %w[name] } } + { spec: { inputs: { env: nil }, component: %w[name version] } } end let(:content) do { - test: 'Deploy to $[[ inputs.env ]] using $[[ component.name ]]' + test: 'Deploy to $[[ inputs.env ]] using $[[ component.name ]] v$[[ component.version ]]' } end @@ -237,7 +237,7 @@ expect(subject).to be_interpolated expect(subject).to be_valid - expect(subject.to_hash).to eq({ test: 'Deploy to production using my-component' }) + expect(subject.to_hash).to eq({ test: 'Deploy to production using my-component v1.0.0' }) end end end diff --git a/spec/lib/gitlab/ci/config/yaml/context_spec.rb b/spec/lib/gitlab/ci/config/yaml/context_spec.rb index ccb535ba327c18797f706ae4d02290e480e3e4c8..372a5a224bafde7a4b86c4ac5d8d63c733f18a92 100644 --- a/spec/lib/gitlab/ci/config/yaml/context_spec.rb +++ b/spec/lib/gitlab/ci/config/yaml/context_spec.rb @@ -29,7 +29,7 @@ context 'with component data provided' do let(:component_data) do - { name: 'my-component', sha: 'abc123' } + { name: 'my-component', sha: 'abc123', version: '1.0.0' } end subject(:context) { described_class.new(component: component_data) } @@ -56,7 +56,7 @@ describe '#component' do let(:component_data) do - { name: 'test-component', sha: 'def456' } + { name: 'test-component', sha: 'def456', version: '2.0.0' } end subject(:context) { described_class.new(component: component_data) } diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 29f2fc1b82d1c847e1b0f9670ea8568af277c196..11d1d13e0773cf9362e7261449fdaf2c0d3b5e5b 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -211,8 +211,8 @@ it 'returns only components included with `include:component`' do expect(config.metadata[:includes].size).to eq(3) expect(included_components).to contain_exactly( - { project: project1, sha: project1.commit.sha, name: 'dast' }, - { project: project2, sha: project2.commit.sha, name: 'template' } + { project: project1, sha: project1.commit.sha, name: 'dast', version: nil }, + { project: project2, sha: project2.commit.sha, name: 'template', version: nil } ) end @@ -228,7 +228,7 @@ it 'returns only unique components' do expect(config.metadata[:includes].size).to eq(2) expect(included_components).to contain_exactly( - { project: project1, sha: project1.commit.sha, name: 'dast' } + { project: project1, sha: project1.commit.sha, name: 'dast', version: nil } ) end end diff --git a/spec/services/ci/components/fetch_service_spec.rb b/spec/services/ci/components/fetch_service_spec.rb index d487f6764c8f34674dd5672fde15fef32b4f19f1..d62f37163fb651772d408688459d617730d80ce1 100644 --- a/spec/services/ci/components/fetch_service_spec.rb +++ b/spec/services/ci/components/fetch_service_spec.rb @@ -26,10 +26,10 @@ } ) - project.repository.add_tag(project.creator, 'v0.1', project.repository.commit.sha) + project.repository.add_tag(project.creator, '0.1.2', project.repository.commit.sha) - create(:release, project: project, tag: 'v0.1', sha: project.repository.commit.sha) create(:ci_catalog_resource, project: project) + create(:release, :with_catalog_resource_version, project: project, tag: '0.1.2', sha: project.repository.commit.sha) project end @@ -83,10 +83,11 @@ 'templates/component/template.yml' => content } ) - project.repository.add_tag(project.creator, 'v0.1', project.repository.commit.sha) + project.repository.add_tag(project.creator, '0.1.3', project.repository.commit.sha) - create(:release, project: project, tag: 'v0.1', sha: project.repository.commit.sha) create(:ci_catalog_resource, project: project) + create(:release, :with_catalog_resource_version, + project: project, tag: '0.1.3', sha: project.repository.commit.sha) project end @@ -104,21 +105,26 @@ end context 'when version is a branch name' do - it_behaves_like 'component address' do - let(:version) { project.default_branch } - end + let(:version) { project.default_branch } + + it_behaves_like 'component address' end context 'when version is a tag name' do - it_behaves_like 'component address' do - let(:version) { project.repository.tags.first.name } + let(:version) { project.repository.tags.first.name } + + it_behaves_like 'component address' + + it 'returns the version' do + expect(result).to be_success + expect(result.payload[:version]).to eq(version) end end context 'when version is a commit sha' do - it_behaves_like 'component address' do - let(:version) { project.repository.tags.first.id } - end + let(:version) { project.repository.tags.first.id } + + it_behaves_like 'component address' end context 'when version is not provided' do diff --git a/spec/services/ci/create_pipeline_service/component_interpolation_spec.rb b/spec/services/ci/create_pipeline_service/component_interpolation_spec.rb index 39e9c1c645da268487f038e7a71216175b232560..93d3200cb1ff46f2af74948831d1ba70debfeff8 100644 --- a/spec/services/ci/create_pipeline_service/component_interpolation_spec.rb +++ b/spec/services/ci/create_pipeline_service/component_interpolation_spec.rb @@ -20,7 +20,7 @@ let_it_be(:component_yaml) do <<~YAML spec: - component: [name, sha] + component: [name, sha, version] inputs: compiler: default: gcc @@ -33,7 +33,7 @@ test: script: - echo "Building with $[[ inputs.compiler ]] and optimization level $[[ inputs.optimization_level ]]" - - echo "Component $[[ component.name ]] / $[[ component.sha ]]" + - echo "Component $[[ component.name ]] / $[[ component.sha ]] / $[[ component.version ]]" YAML end @@ -80,7 +80,73 @@ test_job = pipeline.builds.find { |build| build.name == 'test' } expect(test_job.options[:script]).to eq([ 'echo "Building with gcc and optimization level 2"', - "echo \"Component #{component_name} / #{component_sha}\"" + "echo \"Component #{component_name} / #{component_sha} / #{component_version}\"" + ]) + end + end + + context 'when the component path is with a partial version' do + let_it_be(:component_path) do + "#{Gitlab.config.gitlab.host}/#{components_project.full_path}/#{component_name}@0.1" + end + + it 'creates a pipeline with correct jobs' do + response = execute + pipeline = response.payload + + expect(response).to be_success + expect(pipeline).to be_created_successfully + + expect(pipeline.builds.map(&:name)).to contain_exactly('test') + + test_job = pipeline.builds.find { |build| build.name == 'test' } + expect(test_job.options[:script]).to eq([ + 'echo "Building with gcc and optimization level 2"', + "echo \"Component #{component_name} / #{component_sha} / #{component_version}\"" + ]) + end + end + + context 'when the component path is with latest' do + let_it_be(:component_path) do + "#{Gitlab.config.gitlab.host}/#{components_project.full_path}/#{component_name}@~latest" + end + + it 'creates a pipeline with correct jobs' do + response = execute + pipeline = response.payload + + expect(response).to be_success + expect(pipeline).to be_created_successfully + + expect(pipeline.builds.map(&:name)).to contain_exactly('test') + + test_job = pipeline.builds.find { |build| build.name == 'test' } + expect(test_job.options[:script]).to eq([ + 'echo "Building with gcc and optimization level 2"', + "echo \"Component #{component_name} / #{component_sha} / #{component_version}\"" + ]) + end + end + + context 'when the component path is with sha' do + let_it_be(:component_path) do + "#{Gitlab.config.gitlab.host}/#{components_project.full_path}/#{component_name}@#{component_sha}" + end + + it 'creates a pipeline with correct jobs without version' do + response = execute + pipeline = response.payload + + expect(response).to be_success + expect(pipeline).to be_created_successfully + + expect(pipeline.builds.map(&:name)).to contain_exactly('test') + + test_job = pipeline.builds.find { |build| build.name == 'test' } + expect(test_job.options[:script]).to eq([ + 'echo "Building with gcc and optimization level 2"', + "echo \"Component #{component_name} / #{component_sha} / \"" ]) end