Commit 8568e729 authored by Tiger's avatar Tiger Committed by Lin Jen-Shin

Add AWS cluster provider

Similar to the existing GCP cluster data model, introduce
models to represent an AWS user role, and an EKS cluster
that has been created by GitLab.
parent 2223b141
# frozen_string_literal: true
module Aws
class Role < ApplicationRecord
self.table_name = 'aws_roles'
belongs_to :user, inverse_of: :aws_role
validates :role_external_id, uniqueness: true, length: { in: 1..64 }
validates :role_arn,
length: 1..2048,
format: {
with: Gitlab::Regex.aws_arn_regex,
message: Gitlab::Regex.aws_arn_regex_message
}
end
end
...@@ -35,6 +35,7 @@ module Clusters ...@@ -35,6 +35,7 @@ module Clusters
# we force autosave to happen when we save `Cluster` model # we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
has_one :provider_aws, class_name: 'Clusters::Providers::Aws', autosave: true
has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true
...@@ -96,14 +97,20 @@ module Clusters ...@@ -96,14 +97,20 @@ module Clusters
enum provider_type: { enum provider_type: {
user: 0, user: 0,
gcp: 1 gcp: 1,
aws: 2
} }
scope :enabled, -> { where(enabled: true) } scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) } scope :disabled, -> { where(enabled: false) }
scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) }
scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) } scope :user_provided, -> { where(provider_type: :user) }
scope :gcp_provided, -> { where(provider_type: :gcp) }
scope :aws_provided, -> { where(provider_type: :aws) }
scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) } scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) }
scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) }
scope :managed, -> { where(managed: true) } scope :managed, -> { where(managed: true) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
...@@ -140,7 +147,11 @@ module Clusters ...@@ -140,7 +147,11 @@ module Clusters
end end
def provider def provider
return provider_gcp if gcp? if gcp?
provider_gcp
elsif aws?
provider_aws
end
end end
def platform def platform
......
...@@ -42,6 +42,10 @@ module Clusters ...@@ -42,6 +42,10 @@ module Clusters
def on_creation? def on_creation?
scheduled? || creating? scheduled? || creating?
end end
def assign_operation_id(_)
# Implemented by individual providers if operation ID is supported.
end
end end
end end
end end
......
# frozen_string_literal: true
module Clusters
module Providers
class Aws < ApplicationRecord
include Clusters::Concerns::ProviderStatus
self.table_name = 'cluster_providers_aws'
belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster'
belongs_to :created_by_user, class_name: 'User'
default_value_for :region, 'us-east-1'
default_value_for :num_nodes, 3
default_value_for :instance_type, 'm5.large'
attr_encrypted :secret_access_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm'
validates :role_arn,
length: 1..2048,
format: {
with: Gitlab::Regex.aws_arn_regex,
message: Gitlab::Regex.aws_arn_regex_message
}
validates :num_nodes,
numericality: {
only_integer: true,
greater_than: 0
}
validates :key_name, :region, :instance_type, :security_group_id, length: { in: 1..255 }
validates :subnet_ids, presence: true
def nullify_credentials
assign_attributes(
access_key_id: nil,
secret_access_key: nil,
session_token: nil
)
end
end
end
end
...@@ -99,6 +99,7 @@ class User < ApplicationRecord ...@@ -99,6 +99,7 @@ class User < ApplicationRecord
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :user_synced_attributes_metadata, autosave: true has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
# Groups # Groups
has_many :members has_many :members
......
---
title: Add database tables to store AWS roles and cluster providers
merge_request: 17057
author:
type: added
# frozen_string_literal: true
class CreateClusterProvidersAws < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :cluster_providers_aws do |t|
t.references :cluster, null: false, type: :bigint, index: { unique: true }, foreign_key: { on_delete: :cascade }
t.references :created_by_user, type: :integer, foreign_key: { on_delete: :nullify, to_table: :users }
t.integer :num_nodes, null: false
t.integer :status, null: false
t.timestamps_with_timezone null: false
t.string :key_name, null: false, limit: 255
t.string :role_arn, null: false, limit: 2048
t.string :region, null: false, limit: 255
t.string :vpc_id, null: false, limit: 255
t.string :subnet_ids, null: false, array: true, default: [], limit: 255
t.string :security_group_id, null: false, limit: 255
t.string :instance_type, null: false, limit: 255
t.string :access_key_id, limit: 255
t.string :encrypted_secret_access_key_iv, limit: 255
t.text :encrypted_secret_access_key
t.text :session_token
t.text :status_reason
t.index [:cluster_id, :status]
end
end
end
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateAwsRoles < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :aws_roles, id: false do |t|
t.references :user, primary_key: true, default: nil, type: :integer, index: { unique: true }, foreign_key: { on_delete: :cascade }
t.timestamps_with_timezone null: false
t.string :role_arn, null: false, limit: 2048
t.string :role_external_id, null: false, limit: 64
t.index :role_external_id, unique: true
end
end
end
...@@ -466,6 +466,15 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do ...@@ -466,6 +466,15 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do
t.index ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name" t.index ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name"
end end
create_table "aws_roles", primary_key: "user_id", id: :integer, default: nil, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "role_arn", limit: 2048, null: false
t.string "role_external_id", limit: 64, null: false
t.index ["role_external_id"], name: "index_aws_roles_on_role_external_id", unique: true
t.index ["user_id"], name: "index_aws_roles_on_user_id", unique: true
end
create_table "badges", id: :serial, force: :cascade do |t| create_table "badges", id: :serial, force: :cascade do |t|
t.string "link_url", null: false t.string "link_url", null: false
t.string "image_url", null: false t.string "image_url", null: false
...@@ -967,6 +976,30 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do ...@@ -967,6 +976,30 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do
t.index ["project_id"], name: "index_cluster_projects_on_project_id" t.index ["project_id"], name: "index_cluster_projects_on_project_id"
end end
create_table "cluster_providers_aws", force: :cascade do |t|
t.bigint "cluster_id", null: false
t.integer "created_by_user_id"
t.integer "num_nodes", null: false
t.integer "status", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "key_name", limit: 255, null: false
t.string "role_arn", limit: 2048, null: false
t.string "region", limit: 255, null: false
t.string "vpc_id", limit: 255, null: false
t.string "subnet_ids", limit: 255, default: [], null: false, array: true
t.string "security_group_id", limit: 255, null: false
t.string "instance_type", limit: 255, null: false
t.string "access_key_id", limit: 255
t.string "encrypted_secret_access_key_iv", limit: 255
t.text "encrypted_secret_access_key"
t.text "session_token"
t.text "status_reason"
t.index ["cluster_id", "status"], name: "index_cluster_providers_aws_on_cluster_id_and_status"
t.index ["cluster_id"], name: "index_cluster_providers_aws_on_cluster_id", unique: true
t.index ["created_by_user_id"], name: "index_cluster_providers_aws_on_created_by_user_id"
end
create_table "cluster_providers_gcp", id: :serial, force: :cascade do |t| create_table "cluster_providers_gcp", id: :serial, force: :cascade do |t|
t.integer "cluster_id", null: false t.integer "cluster_id", null: false
t.integer "status" t.integer "status"
...@@ -3932,6 +3965,7 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do ...@@ -3932,6 +3965,7 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do
add_foreign_key "approval_project_rules_users", "users", on_delete: :cascade add_foreign_key "approval_project_rules_users", "users", on_delete: :cascade
add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "aws_roles", "users", on_delete: :cascade
add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "projects", on_delete: :cascade add_foreign_key "badges", "projects", on_delete: :cascade
add_foreign_key "board_assignees", "boards", on_delete: :cascade add_foreign_key "board_assignees", "boards", on_delete: :cascade
...@@ -3995,6 +4029,8 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do ...@@ -3995,6 +4029,8 @@ ActiveRecord::Schema.define(version: 2019_10_16_072826) do
add_foreign_key "cluster_platforms_kubernetes", "clusters", on_delete: :cascade add_foreign_key "cluster_platforms_kubernetes", "clusters", on_delete: :cascade
add_foreign_key "cluster_projects", "clusters", on_delete: :cascade add_foreign_key "cluster_projects", "clusters", on_delete: :cascade
add_foreign_key "cluster_projects", "projects", on_delete: :cascade add_foreign_key "cluster_projects", "projects", on_delete: :cascade
add_foreign_key "cluster_providers_aws", "clusters", on_delete: :cascade
add_foreign_key "cluster_providers_aws", "users", column: "created_by_user_id", on_delete: :nullify
add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade
add_foreign_key "clusters", "projects", column: "management_project_id", name: "fk_f05c5e5a42", on_delete: :nullify add_foreign_key "clusters", "projects", column: "management_project_id", name: "fk_f05c5e5a42", on_delete: :nullify
add_foreign_key "clusters", "users", on_delete: :nullify add_foreign_key "clusters", "users", on_delete: :nullify
......
...@@ -119,6 +119,15 @@ module Gitlab ...@@ -119,6 +119,15 @@ module Gitlab
def breakline_regex def breakline_regex
@breakline_regex ||= /\r\n|\r|\n/ @breakline_regex ||= /\r\n|\r|\n/
end end
# https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html
def aws_arn_regex
/\Aarn:\S+\z/
end
def aws_arn_regex_message
"must be a valid Amazon Resource Name"
end
end end
end end
......
...@@ -19,6 +19,7 @@ describe 'Database schema' do ...@@ -19,6 +19,7 @@ describe 'Database schema' do
approver_groups: %w[target_id], approver_groups: %w[target_id],
audit_events: %w[author_id entity_id], audit_events: %w[author_id entity_id],
award_emoji: %w[awardable_id user_id], award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id],
boards: %w[milestone_id], boards: %w[milestone_id],
chat_names: %w[chat_id service_id team_id user_id], chat_names: %w[chat_id service_id team_id user_id],
chat_teams: %w[team_id], chat_teams: %w[team_id],
...@@ -26,6 +27,7 @@ describe 'Database schema' do ...@@ -26,6 +27,7 @@ describe 'Database schema' do
ci_pipelines: %w[user_id], ci_pipelines: %w[user_id],
ci_runner_projects: %w[runner_id], ci_runner_projects: %w[runner_id],
ci_trigger_requests: %w[commit_id], ci_trigger_requests: %w[commit_id],
cluster_providers_aws: %w[security_group_id vpc_id access_key_id],
cluster_providers_gcp: %w[gcp_project_id operation_id], cluster_providers_gcp: %w[gcp_project_id operation_id],
deploy_keys_projects: %w[deploy_key_id], deploy_keys_projects: %w[deploy_key_id],
deployments: %w[deployable_id environment_id user_id], deployments: %w[deployable_id environment_id user_id],
......
# frozen_string_literal: true
FactoryBot.define do
factory :aws_role, class: Aws::Role do
user
role_arn { 'arn:aws:iam::123456789012:role/role-name' }
sequence(:role_external_id) { |n| "external-id-#{n}" }
end
end
...@@ -53,6 +53,14 @@ FactoryBot.define do ...@@ -53,6 +53,14 @@ FactoryBot.define do
platform_kubernetes factory: [:cluster_platform_kubernetes, :configured] platform_kubernetes factory: [:cluster_platform_kubernetes, :configured]
end end
trait :provided_by_aws do
provider_type { :aws }
platform_type { :kubernetes }
provider_aws factory: [:cluster_provider_aws, :created]
platform_kubernetes factory: [:cluster_platform_kubernetes, :configured]
end
trait :providing_by_gcp do trait :providing_by_gcp do
provider_type { :gcp } provider_type { :gcp }
provider_gcp factory: [:cluster_provider_gcp, :creating] provider_gcp factory: [:cluster_provider_gcp, :creating]
......
# frozen_string_literal: true
FactoryBot.define do
factory :cluster_provider_aws, class: Clusters::Providers::Aws do
cluster
created_by_user factory: :user
role_arn { 'arn:aws:iam::123456789012:role/role-name' }
vpc_id { 'vpc-00000000000000000' }
subnet_ids { %w(subnet-00000000000000000 subnet-11111111111111111) }
security_group_id { 'sg-00000000000000000' }
key_name { 'user' }
trait :scheduled do
access_key_id { 'access_key_id' }
secret_access_key { 'secret_access_key' }
session_token { 'session_token' }
end
trait :creating do
after(:build) do |provider|
provider.make_creating
end
end
trait :created do
after(:build) do |provider|
provider.make_created
end
end
trait :errored do
after(:build) do |provider|
provider.make_errored('An error occurred')
end
end
end
end
...@@ -64,4 +64,15 @@ describe Gitlab::Regex do ...@@ -64,4 +64,15 @@ describe Gitlab::Regex do
it { is_expected.not_to match('.my/image') } it { is_expected.not_to match('.my/image') }
it { is_expected.not_to match('my/image.') } it { is_expected.not_to match('my/image.') }
end end
describe '.aws_account_id_regex' do
subject { described_class.aws_arn_regex }
it { is_expected.to match('arn:aws:iam::123456789012:role/role-name') }
it { is_expected.to match('arn:aws:s3:::bucket/key') }
it { is_expected.to match('arn:aws:ec2:us-east-1:123456789012:volume/vol-1') }
it { is_expected.to match('arn:aws:rds:us-east-1:123456789012:pg:prod') }
it { is_expected.not_to match('123456789012') }
it { is_expected.not_to match('role/role-name') }
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Aws::Role do
it { is_expected.to belong_to(:user) }
it { is_expected.to validate_length_of(:role_external_id).is_at_least(1).is_at_most(64) }
describe 'custom validations' do
subject { role.valid? }
context ':role_arn' do
let(:role) { build(:aws_role, role_arn: role_arn) }
context 'length is zero' do
let(:role_arn) { '' }
it { is_expected.to be_falsey }
end
context 'length is longer than 2048' do
let(:role_arn) { '1' * 2049 }
it { is_expected.to be_falsey }
end
context 'ARN is valid' do
let(:role_arn) { 'arn:aws:iam::123456789012:role/test-role' }
it { is_expected.to be_truthy }
end
end
end
end
...@@ -17,6 +17,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do ...@@ -17,6 +17,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it { is_expected.to have_many(:cluster_groups) } it { is_expected.to have_many(:cluster_groups) }
it { is_expected.to have_many(:groups) } it { is_expected.to have_many(:groups) }
it { is_expected.to have_one(:provider_gcp) } it { is_expected.to have_one(:provider_gcp) }
it { is_expected.to have_one(:provider_aws) }
it { is_expected.to have_one(:platform_kubernetes) } it { is_expected.to have_one(:platform_kubernetes) }
it { is_expected.to have_one(:application_helm) } it { is_expected.to have_one(:application_helm) }
it { is_expected.to have_one(:application_ingress) } it { is_expected.to have_one(:application_ingress) }
...@@ -108,6 +109,31 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do ...@@ -108,6 +109,31 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it { is_expected.to contain_exactly(cluster) } it { is_expected.to contain_exactly(cluster) }
end end
describe '.aws_provided' do
subject { described_class.aws_provided }
let!(:cluster) { create(:cluster, :provided_by_aws) }
before do
create(:cluster, :provided_by_user)
end
it { is_expected.to contain_exactly(cluster) }
end
describe '.aws_installed' do
subject { described_class.aws_installed }
let!(:cluster) { create(:cluster, :provided_by_aws) }
before do
errored_cluster = create(:cluster, :provided_by_aws)
errored_cluster.provider.make_errored!("Error message")
end
it { is_expected.to contain_exactly(cluster) }
end
describe '.managed' do describe '.managed' do
subject do subject do
described_class.managed described_class.managed
...@@ -398,7 +424,14 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do ...@@ -398,7 +424,14 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it 'returns a provider' do it 'returns a provider' do
is_expected.to eq(cluster.provider_gcp) is_expected.to eq(cluster.provider_gcp)
expect(subject.class.name.deconstantize).to eq(Clusters::Providers.to_s) end
end
context 'when provider is aws' do
let(:cluster) { create(:cluster, :provided_by_aws) }
it 'returns a provider' do
is_expected.to eq(cluster.provider_aws)
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Clusters::Providers::Aws do
it { is_expected.to belong_to(:cluster) }
it { is_expected.to belong_to(:created_by_user) }
it { is_expected.to validate_length_of(:key_name).is_at_least(1).is_at_most(255) }
it { is_expected.to validate_length_of(:region).is_at_least(1).is_at_most(255) }
it { is_expected.to validate_length_of(:instance_type).is_at_least(1).is_at_most(255) }
it { is_expected.to validate_length_of(:security_group_id).is_at_least(1).is_at_most(255) }
it { is_expected.to validate_presence_of(:subnet_ids) }
include_examples 'provider status', :cluster_provider_aws
describe 'default_value_for' do
let(:provider) { build(:cluster_provider_aws) }
it "sets default values" do
expect(provider.region).to eq('us-east-1')
expect(provider.num_nodes).to eq(3)
expect(provider.instance_type).to eq('m5.large')
end
end
describe 'custom validations' do
subject { provider.valid? }
context ':num_nodes' do
let(:provider) { build(:cluster_provider_aws, num_nodes: num_nodes) }
context 'contains non-digit characters' do
let(:num_nodes) { 'A3' }
it { is_expected.to be_falsey }
end
context 'is blank' do
let(:num_nodes) { nil }
it { is_expected.to be_falsey }
end
context 'is less than 1' do
let(:num_nodes) { 0 }
it { is_expected.to be_falsey }
end
context 'is a positive integer' do
let(:num_nodes) { 3 }
it { is_expected.to be_truthy }
end
end
end
describe '#nullify_credentials' do
let(:provider) { create(:cluster_provider_aws, :scheduled) }
subject { provider.nullify_credentials }
before do
expect(provider.access_key_id).to be_present
expect(provider.secret_access_key).to be_present
end
it 'removes access_key_id and secret_access_key' do
subject
expect(provider.access_key_id).to be_nil
expect(provider.secret_access_key).to be_nil
end
end
end
...@@ -17,7 +17,7 @@ shared_examples 'provider status' do |factory| ...@@ -17,7 +17,7 @@ shared_examples 'provider status' do |factory|
let(:provider) { build(factory) } let(:provider) { build(factory) }
let(:operation_id) { 'operation-xxx' } let(:operation_id) { 'operation-xxx' }
it 'calls #operation_id on the provider' do it 'calls #assign_operation_id on the provider' do
expect(provider).to receive(:assign_operation_id).with(operation_id).and_call_original expect(provider).to receive(:assign_operation_id).with(operation_id).and_call_original
provider.make_creating(operation_id) provider.make_creating(operation_id)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment