diff --git a/Gemfile b/Gemfile index 7a0b17a065f25a8d2f727fcc845aff9d4e95c96f..b21cf13768bd08b41dae52190dcd220fc1b61d43 100644 --- a/Gemfile +++ b/Gemfile @@ -742,4 +742,4 @@ gem 'paper_trail', '~> 15.0' # rubocop:todo Gemfile/MissingFeatureCategory gem "i18n_data", "~> 0.13.1", feature_category: :system_access -gem "gitlab-cloud-connector", "~> 0.2.1", require: 'cloud_connector', feature_category: :cloud_connector +gem "gitlab-cloud-connector", "~> 0.2.5", require: 'cloud_connector', feature_category: :cloud_connector diff --git a/Gemfile.checksum b/Gemfile.checksum index eccbb04ecbcd8870f8259a320edb259acdd5acd1..c6dce9dd9de47c02cc8f3cc9ca6789dc50ce3d75 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -222,7 +222,7 @@ {"name":"gitaly","version":"17.5.0.pre.rc42","platform":"ruby","checksum":"15469230245c5d83f09c6e057ae1088ce87133ff156086bf02a2b8b2ec24e817"}, {"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"}, {"name":"gitlab-chronic","version":"0.10.5","platform":"ruby","checksum":"f80f18dc699b708870a80685243331290bc10cfeedb6b99c92219722f729c875"}, -{"name":"gitlab-cloud-connector","version":"0.2.3","platform":"ruby","checksum":"89bc5ebf00c6421f6bc7276a8c598357faebf73769efac47064fa991394603cb"}, +{"name":"gitlab-cloud-connector","version":"0.2.5","platform":"ruby","checksum":"ff9ec4032c61b4354948a8d06d4f46b757fa3658c8dc5ddc6eb5175053be643a"}, {"name":"gitlab-dangerfiles","version":"4.8.0","platform":"ruby","checksum":"b327d079552ec974a63bf34d749a0308425af6ebf51d01064f1a6ff216a523db"}, {"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"}, {"name":"gitlab-fog-azure-rm","version":"2.2.0","platform":"ruby","checksum":"31aa7c2170f57874053144e7f716ec9e15f32e71ffbd2c56753dce46e2e78ba9"}, diff --git a/Gemfile.lock b/Gemfile.lock index 0cd7efb69992d4d7203e266b107bd02995b02150..79ad725281d15d5f3a3cdb6f6a3f8af11fbbb949 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -726,7 +726,7 @@ GEM terminal-table (>= 1.5.1) gitlab-chronic (0.10.5) numerizer (~> 0.2) - gitlab-cloud-connector (0.2.3) + gitlab-cloud-connector (0.2.5) activesupport (~> 7.0) jwt (~> 2.9.3) gitlab-dangerfiles (4.8.0) @@ -2068,7 +2068,7 @@ DEPENDENCIES gitaly (~> 17.5.0.pre.rc1) gitlab-backup-cli! gitlab-chronic (~> 0.10.5) - gitlab-cloud-connector (~> 0.2.1) + gitlab-cloud-connector (~> 0.2.5) gitlab-dangerfiles (~> 4.8.0) gitlab-duo-workflow-service-client (~> 0.1)! gitlab-experiment (~> 0.9.1) diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum index 5befb50059ea222255dd469bc17057cc86ee1e0b..ec2ce141a3a76eae134f4fae44436557cfde53d6 100644 --- a/Gemfile.next.checksum +++ b/Gemfile.next.checksum @@ -223,7 +223,7 @@ {"name":"gitaly","version":"17.5.0.pre.rc42","platform":"ruby","checksum":"15469230245c5d83f09c6e057ae1088ce87133ff156086bf02a2b8b2ec24e817"}, {"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"}, {"name":"gitlab-chronic","version":"0.10.5","platform":"ruby","checksum":"f80f18dc699b708870a80685243331290bc10cfeedb6b99c92219722f729c875"}, -{"name":"gitlab-cloud-connector","version":"0.2.3","platform":"ruby","checksum":"89bc5ebf00c6421f6bc7276a8c598357faebf73769efac47064fa991394603cb"}, +{"name":"gitlab-cloud-connector","version":"0.2.5","platform":"ruby","checksum":"ff9ec4032c61b4354948a8d06d4f46b757fa3658c8dc5ddc6eb5175053be643a"}, {"name":"gitlab-dangerfiles","version":"4.8.0","platform":"ruby","checksum":"b327d079552ec974a63bf34d749a0308425af6ebf51d01064f1a6ff216a523db"}, {"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"}, {"name":"gitlab-fog-azure-rm","version":"2.2.0","platform":"ruby","checksum":"31aa7c2170f57874053144e7f716ec9e15f32e71ffbd2c56753dce46e2e78ba9"}, diff --git a/Gemfile.next.lock b/Gemfile.next.lock index 34aa559688ffb16b011fb8bb2bd4deba57058aa8..4cb351ba6d8cc44a12d595ee427db68a081a1808 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -736,7 +736,7 @@ GEM terminal-table (>= 1.5.1) gitlab-chronic (0.10.5) numerizer (~> 0.2) - gitlab-cloud-connector (0.2.3) + gitlab-cloud-connector (0.2.5) activesupport (~> 7.0) jwt (~> 2.9.3) gitlab-dangerfiles (4.8.0) @@ -2096,7 +2096,7 @@ DEPENDENCIES gitaly (~> 17.5.0.pre.rc1) gitlab-backup-cli! gitlab-chronic (~> 0.10.5) - gitlab-cloud-connector (~> 0.2.1) + gitlab-cloud-connector (~> 0.2.5) gitlab-dangerfiles (~> 4.8.0) gitlab-duo-workflow-service-client (~> 0.1)! gitlab-experiment (~> 0.9.1) diff --git a/ee/app/assets/javascripts/amazon_q_settings/components/app.vue b/ee/app/assets/javascripts/amazon_q_settings/components/app.vue new file mode 100644 index 0000000000000000000000000000000000000000..3b8ffd8dc2a1b161d7fa7c46a7dfc6eafe7dfffd --- /dev/null +++ b/ee/app/assets/javascripts/amazon_q_settings/components/app.vue @@ -0,0 +1,268 @@ + + + diff --git a/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js b/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9c57e93e7f71a9839c00274feaf849c80482ec35 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/ai/amazon_q_settings/index.js @@ -0,0 +1,4 @@ +import SettingsApp from 'ee/amazon_q_settings/components/app.vue'; +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; + +initSimpleApp('#js-amazon-q-settings', SettingsApp); diff --git a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb index d2a758cdb3e9de572e46ee4eb7e1eae093e2a166..11fe50ca3df4682bcc77026bacbc5510eaed6bfb 100644 --- a/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb +++ b/ee/app/controllers/admin/ai/amazon_q_settings_controller.rb @@ -2,7 +2,6 @@ module Admin module Ai - # NOTE: This module is under development. See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174614 class AmazonQSettingsController < Admin::ApplicationController feature_category :ai_abstraction_layer @@ -17,6 +16,7 @@ def index def setup_view_model @view_model = { submitUrl: admin_ai_amazon_q_settings_path, + identityProviderPayload: identity_provider, amazonQSettings: { ready: ::Ai::Setting.instance.amazon_q_ready, roleArn: ::Ai::Setting.instance.amazon_q_role_arn, @@ -25,6 +25,23 @@ def setup_view_model } end + def identity_provider + return if ::Ai::Setting.instance.amazon_q_ready + + result = ::Ai::AmazonQ::IdentityProviderPayloadFactory.new.execute + case result + in { ok: payload } + payload + in { err: err } + flash[:alert] = [ + s_('AmazonQ|Something went wrong retrieving the identity provider payload.'), + err[:message] + ].reject(&:blank?).join(' ') + + {} + end + end + def check_can_admin_amazon_q render_404 unless ::Ai::AmazonQ.feature_available? end diff --git a/ee/config/cloud_connector/access_data.yml b/ee/config/cloud_connector/access_data.yml index ae68e7b2238f8dd4655717b64d476d1f32ef9ef2..9d86e5a7d3d72c1581bf86c8195ebdb946d94e0c 100644 --- a/ee/config/cloud_connector/access_data.yml +++ b/ee/config/cloud_connector/access_data.yml @@ -267,3 +267,21 @@ services: # Cloud connector features (i.e. code_suggestions, duo_chat...) - semantic_search_issue - summarize_issue_discussions - summarize_merge_request + amazon_q_integration: + backend: 'gitlab-ai-gateway' + bundled_with: + _irrelevant: + unit_primitives: + - amazon_q_integration + license_types: + - ultimate + measure_comment_temperature: + backend: 'gitlab-ai-gateway' + cut_off_date: 2024-10-17 00:00:00 UTC + bundled_with: + duo_enterprise: + unit_primitives: + - measure_comment_temperature + duo_pro: + unit_primitives: + - measure_comment_temperature diff --git a/ee/lib/ai/amazon_q/identity_provider_payload_factory.rb b/ee/lib/ai/amazon_q/identity_provider_payload_factory.rb new file mode 100644 index 0000000000000000000000000000000000000000..09385513670a12411a128231e04f059d52bbbb14 --- /dev/null +++ b/ee/lib/ai/amazon_q/identity_provider_payload_factory.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class IdentityProviderPayloadFactory + def execute + cloud_connector_token_result + .and_then(->(token) { decode_token(token) }) + .and_then(->(token) { instance_uid_from_token(token) }) + .map(->(instance_uid) { build_payload(instance_uid) }) + end + + private + + def cloud_connector_token_result + token = ::CloudConnector::AvailableServices.find_by_name(:amazon_q_integration)&.access_token + return ::Gitlab::Fp::Result.ok(token) if token + + ::Gitlab::Fp::Result.err({ + message: s_('AmazonQ|Active cloud connector token not found.'), + reason: :cc_token_not_found + }) + end + + def decode_token(token) + ::Gitlab::Fp::Result.ok( + JWT.decode(token, false, nil)&.first + ) + rescue JWT::DecodeError => e + Gitlab::AppLogger.error(e) + + ::Gitlab::Fp::Result.err({ + message: s_('AmazonQ|Cloud connector token could not be decoded'), + reason: :cc_token_jwt_decode + }) + end + + def instance_uid_from_token(token) + gitlab_instance_uid = token['gitlab_instance_uid'] + if gitlab_instance_uid + Gitlab::AppLogger.info( + "gitlab_instance_uid found in latest Cloud Connector token. Using gitlab_instance_uid." + ) + + return ::Gitlab::Fp::Result.ok(gitlab_instance_uid) + end + + instance_identifier = token['sub'] + if instance_identifier + Gitlab::AppLogger.info("gitlab_instance_uid not found in latest Cloud Connector token. Using subject.") + + return ::Gitlab::Fp::Result.ok(instance_identifier) + end + + ::Gitlab::Fp::Result.err({ + message: s_('Neither gitlab_instance_uid or sub found on Cloud Connector token'), + reason: :cc_token_no_uid + }) + end + + def build_payload(instance_uid) + { + instance_uid: instance_uid, + aws_provider_url: "https://auth.token.gitlab.com/cc/oidc/#{instance_uid}", + aws_audience: "gitlab-cc-#{instance_uid}" + } + end + end + end +end diff --git a/ee/spec/frontend/amazon_q_settings/components/app_spec.js b/ee/spec/frontend/amazon_q_settings/components/app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3c14e7c348dffb0b3babced9b038b4f9d675dd4f --- /dev/null +++ b/ee/spec/frontend/amazon_q_settings/components/app_spec.js @@ -0,0 +1,338 @@ +import { nextTick } from 'vue'; +import { + GlAlert, + GlButton, + GlForm, + GlFormInput, + GlFormInputGroup, + GlFormGroup, + GlFormRadioGroup, + GlFormRadio, + GlSprintf, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import App from 'ee/amazon_q_settings/components/app.vue'; +import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +jest.mock('~/lib/utils/create_and_submit_form'); + +const TEST_SUBMIT_URL = '/foo/submit/url'; +const TEST_AMAZON_Q_SETTINGS = { + ready: true, + availability: 'default_on', + roleArn: 'aws:role:arn', +}; + +describe('ee/amazon_q_settings/components/app.vue', () => { + let wrapper; + + const createWrapper = (props = {}) => { + wrapper = shallowMount(App, { + propsData: { + submitUrl: TEST_SUBMIT_URL, + identityProviderPayload: { + instance_uid: 'instance-uid', + aws_provider_url: 'https://provider.url', + aws_audience: 'audience', + }, + ...props, + }, + stubs: { + GlFormInputGroup, + GlSprintf, + }, + }); + }; + + const findForm = () => wrapper.findComponent(GlForm); + const findFormGroup = (label) => + findForm() + .findAllComponents(GlFormGroup) + .wrappers.find((x) => x.attributes('label') === label); + + const findStatusFormGroup = () => findFormGroup('Status'); + const findSetupFormGroup = () => findFormGroup('Setup'); + const listItems = () => findSetupFormGroup().findAll('ol li').wrappers; + const listItem = (at) => listItems()[at]; + + // arn helpers ----- + const findArnFormGroup = () => findFormGroup("IAM role's ARN"); + const findArnField = () => findArnFormGroup().findComponent(GlFormInput); + const setArn = (val) => findArnField().vm.$emit('input', val); + + // availability helpers ----- + const findAvailabilityRadioGroup = () => + findFormGroup('Availability').findComponent(GlFormRadioGroup); + const findAvailabilityRadioButtons = () => + findAvailabilityRadioGroup() + .findAllComponents(GlFormRadio) + .wrappers.map((x) => ({ + value: x.attributes('value'), + label: x.text(), + })); + const setAvailability = (val) => findAvailabilityRadioGroup().vm.$emit('input', val); + + // warning helpers ----- + const findAvailabilityWarning = () => findForm().findComponent(GlAlert); + const findSaveWarning = () => findForm().find('[data-testid=amazon-q-save-warning]'); + const findSaveWarningLink = () => findSaveWarning().find('a'); + + // button helpers ----- + const findButton = (text) => + findForm() + .findAllComponents(GlButton) + .wrappers.find((x) => x.text() === text); + const findSubmitButton = () => findButton('Save changes'); + + describe('default', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('does not render status', () => { + expect(findStatusFormGroup()).toBeUndefined(); + }); + + describe('setup', () => { + it('renders setup', () => { + expect(findSetupFormGroup().exists()).toBe(true); + + expect(listItems()).toHaveLength(3); + }); + + it('renders step 1', () => { + const idpStepText = listItem(0).text(); + const idpStepHelpPageLink = listItem(0).findComponent(HelpPageLink); + + expect(idpStepText).toBe( + 'Create an identity provider for this GitLab instance within AWS using the following values. Learn more.', + ); + expect(idpStepHelpPageLink.props()).toEqual({ + anchor: 'create-an-iam-identity-provider', + href: 'user/duo_amazon_q/setup.md', + }); + expect(idpStepHelpPageLink.text()).toEqual('Learn more'); + }); + + it('renders identity provider details with clipboard buttons', () => { + const idpFormFields = listItem(0).findAllComponents(GlFormInputGroup).wrappers; + const idpClipboardButtons = listItem(0).findAllComponents(ClipboardButton).wrappers; + + expect(idpFormFields[0].props('value')).toEqual('instance-uid'); + expect(idpClipboardButtons[0].props('text')).toEqual('instance-uid'); + + expect(idpFormFields[1].props('value')).toEqual('OpenID Connect'); + expect(idpClipboardButtons[1].props('text')).toEqual('OpenID Connect'); + + expect(idpFormFields[2].props('value')).toEqual('https://provider.url'); + expect(idpClipboardButtons[2].props('text')).toEqual('https://provider.url'); + + expect(idpFormFields[3].props('value')).toEqual('audience'); + expect(idpClipboardButtons[3].props('text')).toEqual('audience'); + }); + + it('renders step 2', () => { + const iamStepText = listItem(1).text(); + const iamStepHelpPageLink = listItem(1).findComponent(HelpPageLink); + + expect(iamStepText).toBe( + 'Within your AWS account, create an IAM role for Amazon Q and the relevant identity provider. Learn how to create an IAM role.', + ); + expect(iamStepHelpPageLink.props()).toEqual({ + anchor: 'create-an-iam-role', + href: 'user/duo_amazon_q/setup.md', + }); + expect(iamStepHelpPageLink.text()).toEqual('Learn how to create an IAM role'); + }); + + it('renders step 3', () => { + const arnStepText = listItem(2).text(); + + expect(arnStepText).toEqual("Enter the IAM role's ARN."); + }); + }); + + it('renders arn field', () => { + expect(findArnFormGroup().exists()).toBe(true); + + const input = findArnFormGroup().findComponent(GlFormInput); + + expect(input.attributes()).toMatchObject({ + value: '', + type: 'text', + width: 'lg', + name: 'aws_role', + placeholder: 'arn:aws:iam::account-id:role/role-name', + }); + }); + + it('renders availability field', () => { + expect(findAvailabilityRadioGroup().attributes()).toMatchObject({ + checked: 'default_on', + name: 'availability', + }); + expect(findAvailabilityRadioButtons()).toEqual([ + { + label: 'On by default', + value: 'default_on', + }, + { + label: 'Off by default', + value: 'default_off', + }, + { + label: 'Always off', + value: 'never_on', + }, + ]); + }); + + it('does not render availability warning', () => { + expect(findAvailabilityWarning().exists()).toBe(false); + }); + + it('renders enabled arn', () => { + expect(findArnFormGroup().attributes('disabled')).toBeUndefined(); + }); + + it('renders save button', () => { + expect(findSubmitButton().attributes()).toMatchObject({ + type: 'submit', + variant: 'confirm', + category: 'primary', + }); + }); + + it('renders save acknowledgement', () => { + expect(findSaveWarning().text()).toBe( + 'I understand that by selecting Save changes, GitLab creates a service account for Amazon Q and sends its credentials to AWS. Use of the Amazon Q Developer capabilities as part of GitLab Duo with Amazon Q is governed by the AWS Customer Agreement or other written agreement between you and AWS governing your use of AWS services.', + ); + + expect(findSaveWarningLink().attributes()).toEqual({ + href: 'http://aws.amazon.com/agreement', + rel: 'noopener noreferrer', + target: '_blank', + }); + expect(findSaveWarningLink().text()).toEqual('AWS Customer Agreement'); + }); + + describe('when submitting', () => { + let event; + + beforeEach(async () => { + event = new Event('submit'); + jest.spyOn(event, 'preventDefault'); + + setArn('aws:test:value'); + setAvailability('default_off'); + + await nextTick(); + + findForm().vm.$emit('submit', event); + }); + + it('prevents default', () => { + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('triggers submit form', () => { + expect(createAndSubmitForm).toHaveBeenCalledTimes(1); + expect(createAndSubmitForm).toHaveBeenCalledWith({ + url: TEST_SUBMIT_URL, + data: { + availability: 'default_off', + role_arn: 'aws:test:value', + }, + }); + }); + }); + }); + + describe('when ready', () => { + beforeEach(() => { + createWrapper({ + amazonQSettings: TEST_AMAZON_Q_SETTINGS, + }); + }); + + it('renders status', () => { + expect(findStatusFormGroup().exists()).toBe(true); + expect(findStatusFormGroup().text()).toBe(App.I18N_READY); + }); + + it('does not render setup', () => { + expect(findSetupFormGroup()).toBeUndefined(); + }); + + it('renders disabled arn', () => { + expect(findArnFormGroup().attributes('disabled')).toBeDefined(); + }); + + it('does not render save acknowledgement', () => { + expect(findSaveWarning().exists()).toBe(false); + }); + + describe('when submitting', () => { + beforeEach(async () => { + setAvailability('default_off'); + + await nextTick(); + + findForm().vm.$emit('submit', new Event('submit')); + }); + + it('triggers submit form', () => { + expect(createAndSubmitForm).toHaveBeenCalledTimes(1); + expect(createAndSubmitForm).toHaveBeenCalledWith({ + url: TEST_SUBMIT_URL, + data: { + availability: 'default_off', + }, + }); + }); + }); + }); + + describe('availability warnings', () => { + it.each` + orig | value | expected + ${'default_off'} | ${'default_off'} | ${''} + ${'default_off'} | ${'never_on'} | ${App.I18N_WARNING_NEVER_ON} + ${'default_off'} | ${'default_on'} | ${''} + ${'never_on'} | ${'never_on'} | ${''} + ${'never_on'} | ${'default_off'} | ${App.I18N_WARNING_OFF_BY_DEFAULT} + ${'never_on'} | ${'default_on'} | ${''} + ${'default_on'} | ${'default_on'} | ${''} + ${'default_on'} | ${'default_off'} | ${App.I18N_WARNING_OFF_BY_DEFAULT} + ${'default_on'} | ${'never_on'} | ${App.I18N_WARNING_NEVER_ON} + `('from $orig to $value', async ({ orig, value, expected }) => { + createWrapper({ + amazonQSettings: { + ...TEST_AMAZON_Q_SETTINGS, + availability: orig, + }, + }); + + expect(findAvailabilityWarning().exists()).toBe(false); + + setAvailability(value); + await nextTick(); + + if (expected) { + expect(findAvailabilityWarning().props()).toMatchObject({ + dismissible: false, + variant: 'warning', + }); + expect(findAvailabilityWarning().text()).toBe(expected); + } else { + expect(findAvailabilityWarning().exists()).toBe(false); + } + }); + }); +}); diff --git a/ee/spec/lib/ai/amazon_q/identity_provider_payload_factory_spec.rb b/ee/spec/lib/ai/amazon_q/identity_provider_payload_factory_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..37591fd0ddc3364b24362f038f308a062f1bffa0 --- /dev/null +++ b/ee/spec/lib/ai/amazon_q/identity_provider_payload_factory_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::IdentityProviderPayloadFactory, feature_category: :ai_agents do + using RSpec::Parameterized::TableSyntax + + let(:signing_key) { OpenSSL::PKey::RSA.new(Rails.application.credentials.openid_connect_signing_key) } + let(:service_data_default) { instance_double(CloudConnector::SelfManaged::AvailableServiceData) } + + let(:token_invalid) { 'NOT_A_REAL_TOKEN' } + let(:token_no_uid) { JWT.encode({ foo: 'bar' }, signing_key, 'RS256', { typ: 'JWT' }) } + let(:token_with_sub) { JWT.encode({ sub: 'test-subject' }, signing_key, 'RS256', { typ: 'JWT' }) } + let(:token_with_uid) do + JWT.encode({ sub: 'test-subject', gitlab_instance_uid: 'test-gitlab-uid' }, signing_key, 'RS256', { typ: 'JWT' }) + end + + describe '#execute' do + subject(:execution) { described_class.new.execute } + + where(:service_data, :token, :expectation) do + nil | nil | { err: hash_including(reason: :cc_token_not_found) } + ref(:service_data_default) | nil | { err: hash_including(reason: :cc_token_not_found) } + ref(:service_data_default) | ref(:token_invalid) | { err: hash_including(reason: :cc_token_jwt_decode) } + ref(:service_data_default) | ref(:token_no_uid) | { err: hash_including(reason: :cc_token_no_uid) } + ref(:service_data_default) | ref(:token_with_uid) | { ok: { aws_audience: 'gitlab-cc-test-gitlab-uid', + aws_provider_url: 'https://auth.token.gitlab.com/cc/oidc/test-gitlab-uid', + instance_uid: 'test-gitlab-uid' } } + ref(:service_data_default) | ref(:token_with_sub) | { ok: { aws_audience: 'gitlab-cc-test-subject', + aws_provider_url: 'https://auth.token.gitlab.com/cc/oidc/test-subject', + instance_uid: 'test-subject' } } + end + + with_them do + before do + allow(service_data).to receive(:access_token).and_return(token) if service_data + + allow(::CloudConnector::AvailableServices).to receive(:find_by_name) + .with(:amazon_q_integration) + .and_return(service_data) + end + + it { expect(execution.to_h).to include(expectation) } + end + end +end diff --git a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb index b19963da06c2c6a5ad04eda726b8bcefe003dc63..e70eb1c73b57543c0f064b4e3ce6f98966664d3a 100644 --- a/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb +++ b/ee/spec/requests/admin/ai/amazon_q_settings_controller_spec.rb @@ -4,6 +4,7 @@ RSpec.describe Admin::Ai::AmazonQSettingsController, :enable_admin_mode, feature_category: :ai_abstraction_layer do let(:admin) { create(:admin) } + let(:amazon_q_ready) { false } let(:actual_view_model) do Gitlab::Json.parse( @@ -19,7 +20,7 @@ # NOTE: Updating this singleton in the top-level before each for increasing predictability with tests Ai::Setting.instance.update!( - amazon_q_ready: true, + amazon_q_ready: amazon_q_ready, amazon_q_role_arn: 'test-arn' ) @@ -43,17 +44,61 @@ it_behaves_like 'returns 404 when feature is unavailable' - it 'renders the frontend entrypoint with view model' do - perform_request + context 'when Amazon Q is ready' do + let(:amazon_q_ready) { true } + + it 'renders the frontend entrypoint with view model' do + perform_request + + expect(flash[:alert]).to be_nil + expect(actual_view_model).to eq({ + "amazonQSettings" => { + "availability" => "default_on", + "ready" => true, + "roleArn" => 'test-arn' + }, + "submitUrl" => admin_ai_amazon_q_settings_path, + "identityProviderPayload" => nil + }) + end + end + + context 'when there is a problem retreiving the identity provider payload' do + it 'renders alert and empty identityProviderPayload' do + perform_request + + expect(actual_view_model).to include("identityProviderPayload" => {}) + expect(flash[:alert]).to include(s_('AmazonQ|Something went wrong retrieving the identity provider payload.')) + end + end + + context 'when there is a valid identity provider payload' do + before do + jwt = JWT.encode({ sub: 'abc123' }, '') + service = instance_double(::CloudConnector::SelfSigned::AvailableServiceData, access_token: jwt) + + allow(::CloudConnector::AvailableServices).to receive(:find_by_name).and_call_original + allow(::CloudConnector::AvailableServices).to receive(:find_by_name).with(:amazon_q_integration) + .and_return(service) + end + + it 'renders the frontend entrypoint with view model' do + perform_request - expect(actual_view_model).to eq({ - "amazonQSettings" => { - "availability" => 'default_on', - "ready" => true, - "roleArn" => 'test-arn' - }, - "submitUrl" => admin_ai_amazon_q_settings_path - }) + expect(actual_view_model).to eq({ + "amazonQSettings" => { + "availability" => "default_on", + "ready" => false, + "roleArn" => 'test-arn' + }, + "submitUrl" => admin_ai_amazon_q_settings_path, + "identityProviderPayload" => { + "aws_audience" => "gitlab-cc-abc123", + "aws_provider_url" => "https://auth.token.gitlab.com/cc/oidc/abc123", + "instance_uid" => "abc123" + } + }) + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fc1b89f88c08d1ddd9228a0f8b2df6e104d84578..fa3815f0a03b0f3614dc01fc8bfdb8a31718812f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5886,24 +5886,102 @@ msgstr "" msgid "Amazon Q" msgstr "" +msgid "AmazonQ|Active cloud connector token not found." +msgstr "" + +msgid "AmazonQ|Always off" +msgstr "" + msgid "AmazonQ|Amazon Q Configuration" msgstr "" +msgid "AmazonQ|Amazon Q will be turned off by default, but still be available to any groups or projects that have previously enabled it." +msgstr "" + +msgid "AmazonQ|Amazon Q will be turned off for all groups, subgroups, and projects, even if they have previously enabled it." +msgstr "" + +msgid "AmazonQ|An unexpected error occurred while submitting the form. Please see the browser console log for more details." +msgstr "" + +msgid "AmazonQ|Audience" +msgstr "" + +msgid "AmazonQ|Availability" +msgstr "" + msgid "AmazonQ|Beta" msgstr "" +msgid "AmazonQ|Cloud connector token could not be decoded" +msgstr "" + msgid "AmazonQ|Configure GitLab Duo with Amazon Q" msgstr "" msgid "AmazonQ|Configure GitLab Duo with Amazon Q (Beta)" msgstr "" +msgid "AmazonQ|Copy to clipboard" +msgstr "" + +msgid "AmazonQ|Create an identity provider for this GitLab instance within AWS using the following values. %{helpStart}Learn more%{helpEnd}." +msgstr "" + +msgid "AmazonQ|Enter the IAM role's ARN." +msgstr "" + +msgid "AmazonQ|Features are available. However, any group, subgroup, or project can turn them off." +msgstr "" + +msgid "AmazonQ|Features are not available and cannot be turned on for any group, subgroup, or project." +msgstr "" + +msgid "AmazonQ|Features are not available. However, any group, subgroup, or project can turn them on." +msgstr "" + msgid "AmazonQ|Get started" msgstr "" msgid "AmazonQ|GitLab Duo with Amazon Q" msgstr "" +msgid "AmazonQ|GitLab Duo with Amazon Q is ready to go! 🎉" +msgstr "" + +msgid "AmazonQ|I understand that by selecting Save changes, GitLab creates a service account for Amazon Q and sends its credentials to AWS. Use of the Amazon Q Developer capabilities as part of GitLab Duo with Amazon Q is governed by the %{helpStart}AWS Customer Agreement%{helpEnd} or other written agreement between you and AWS governing your use of AWS services." +msgstr "" + +msgid "AmazonQ|IAM role's ARN" +msgstr "" + +msgid "AmazonQ|Instance ID" +msgstr "" + +msgid "AmazonQ|Off by default" +msgstr "" + +msgid "AmazonQ|On by default" +msgstr "" + +msgid "AmazonQ|Provider URL" +msgstr "" + +msgid "AmazonQ|Provider type" +msgstr "" + +msgid "AmazonQ|Save changes" +msgstr "" + +msgid "AmazonQ|Setup" +msgstr "" + +msgid "AmazonQ|Something went wrong retrieving the identity provider payload." +msgstr "" + +msgid "AmazonQ|Status" +msgstr "" + msgid "AmazonQ|Use Amazon Q to automate workflows, create a merge request from an issue, upgrade Java, and improve your code with AI-powered reviews." msgstr "" @@ -5916,6 +5994,9 @@ msgstr "" msgid "AmazonQ|View configuration setup" msgstr "" +msgid "AmazonQ|Within your AWS account, create an IAM role for Amazon Q and the relevant identity provider. %{helpStart}Learn how to create an IAM role%{helpEnd}." +msgstr "" + msgid "AmbiguousRef|There is a branch and a tag with the same name of %{ref}." msgstr "" @@ -36102,6 +36183,9 @@ msgstr "" msgid "Needs attention" msgstr "" +msgid "Neither gitlab_instance_uid or sub found on Cloud Connector token" +msgstr "" + msgid "Network" msgstr ""