diff --git a/Gemfile b/Gemfile index 414a8a6774430d5306ef2a9e225e4878df98dee1..53754392fcae9c01d9ccd3e41a471d70ce64a79c 100644 --- a/Gemfile +++ b/Gemfile @@ -493,3 +493,6 @@ gem 'erubi', '~> 1.9.0' # Monkey-patched in `config/initializers/mail_encoding_patch.rb` # See https://gitlab.com/gitlab-org/gitlab/issues/197386 gem 'mail', '= 2.7.1' + +# File encryption +gem 'lockbox', '~> 0.3.3' diff --git a/Gemfile.lock b/Gemfile.lock index bb64fb09649fd489d77312017ffd8a3858c81070..9e5c29dc820825a21b1989af32cd927559c7d846 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -605,6 +605,7 @@ GEM rb-inotify (~> 0.9, >= 0.9.7) ruby_dep (~> 1.2) locale (2.1.2) + lockbox (0.3.3) lograge (0.10.0) actionpack (>= 4) activesupport (>= 4) @@ -1280,6 +1281,7 @@ DEPENDENCIES license_finder (~> 5.4) licensee (~> 8.9) liquid (~> 4.0) + lockbox (~> 0.3.3) lograge (~> 0.5) loofah (~> 2.2) lru_redux diff --git a/app/models/terraform.rb b/app/models/terraform.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d974ffe0510f1765fc5ea9bb4ecf7973643cf4e --- /dev/null +++ b/app/models/terraform.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Terraform + def self.table_name_prefix + 'terraform_' + end +end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb new file mode 100644 index 0000000000000000000000000000000000000000..8ca4ee9239a81efde8a288b25cbd1f33cb367ce1 --- /dev/null +++ b/app/models/terraform/state.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Terraform + class State < ApplicationRecord + belongs_to :project + + validates :project_id, presence: true + + after_save :update_file_store, if: :saved_change_to_file? + + mount_uploader :file, StateUploader + + def update_file_store + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) + end + + def file_store + super || StateUploader.default_store + end + end +end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb new file mode 100644 index 0000000000000000000000000000000000000000..9c5ae8a8bdc52eeeeaf348346323d966ac83019e --- /dev/null +++ b/app/uploaders/terraform/state_uploader.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Terraform + class StateUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.terraform_state + + delegate :project_id, to: :model + + # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) + encrypt(key: :key) + + def filename + "#{model.id}.tfstate" + end + + def store_dir + project_id.to_s + end + + def key + OpenSSL::HMAC.digest('SHA256', Gitlab::Application.secrets.db_key_base, project_id.to_s) + end + + class << self + def direct_upload_enabled? + false + end + + def background_upload_enabled? + false + end + + def proxy_download_enabled? + true + end + + def default_store + object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL + end + end + end +end diff --git a/changelogs/unreleased/207401-encrypt-decrypt-object-storage-to-support-terraform-state-backend.yml b/changelogs/unreleased/207401-encrypt-decrypt-object-storage-to-support-terraform-state-backend.yml new file mode 100644 index 0000000000000000000000000000000000000000..31d4c0d15875979595c5653cee4b06664ae969cc --- /dev/null +++ b/changelogs/unreleased/207401-encrypt-decrypt-object-storage-to-support-terraform-state-backend.yml @@ -0,0 +1,5 @@ +--- +title: Create model to store Terraform state files +merge_request: 26619 +author: +type: added diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index eb41e4ac42334822c46ef9d4187e8c3ca485f567..b66389b1a6f9de334bb43a45c4a13ce13f303fa6 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -320,6 +320,24 @@ production: &base # aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4. # path_style: true # Use 'host/bucket_name/object' instead of 'bucket_name.host/object' + ## Terraform state + terraform_state: + enabled: true + # The location where Terraform state files are stored (default: shared/terraform_state). + # storage_path: shared/terraform_state + object_store: + enabled: false + remote_directory: terraform_state # The bucket name + connection: + provider: AWS + aws_access_key_id: AWS_ACCESS_KEY_ID + aws_secret_access_key: AWS_SECRET_ACCESS_KEY + region: us-east-1 + # host: 'localhost' # default: s3.amazonaws.com + # endpoint: 'http://127.0.0.1:9000' # default: nil + # aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4. + # path_style: true # Use 'host/bucket_name/object' instead of 'bucket_name.host/object' + ## GitLab Pages pages: enabled: false @@ -1193,6 +1211,19 @@ test: aws_access_key_id: AWS_ACCESS_KEY_ID aws_secret_access_key: AWS_SECRET_ACCESS_KEY region: us-east-1 + + terraform_state: + enabled: true + storage_path: tmp/tests/terraform_state + object_store: + enabled: false + remote_directory: terraform_state + connection: + provider: AWS # Only AWS supported at the moment + aws_access_key_id: AWS_ACCESS_KEY_ID + aws_secret_access_key: AWS_SECRET_ACCESS_KEY + region: us-east-1 + gitlab: host: localhost port: 80 diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 79bfcfd79e137821037d88fd8452dd74f94bbf01..f31762d9ac6ea4d4475080f24e4d7a8b0ab107ff 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -369,6 +369,14 @@ Settings.dependency_proxy['enabled'] = false unless Gitlab::Runtime.puma? end +# +# Terraform state +# +Settings['terraform_state'] ||= Settingslogic.new({}) +Settings.terraform_state['enabled'] = true if Settings.terraform_state['enabled'].nil? +Settings.terraform_state['storage_path'] = Settings.absolute(Settings.terraform_state['storage_path'] || File.join(Settings.shared['path'], "terraform_state")) +Settings.terraform_state['object_store'] = ObjectStoreSettings.parse(Settings.terraform_state['object_store']) + # # Mattermost # diff --git a/db/migrate/20200305200641_create_terraform_states.rb b/db/migrate/20200305200641_create_terraform_states.rb new file mode 100644 index 0000000000000000000000000000000000000000..3e137369e3312737e9182191e553e9e3fb775cd4 --- /dev/null +++ b/db/migrate/20200305200641_create_terraform_states.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTerraformStates < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + create_table :terraform_states do |t| + t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: false + t.timestamps_with_timezone null: false + t.integer :file_store, limit: 2 + t.string :file, limit: 255 + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 6301ec6df80f4bcaeefef8be3dc9d7253ae1e552..0bde7c7469e3a1fc92af4b420a0738345a52df7b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -5975,6 +5975,24 @@ CREATE SEQUENCE public.term_agreements_id_seq ALTER SEQUENCE public.term_agreements_id_seq OWNED BY public.term_agreements.id; +CREATE TABLE public.terraform_states ( + id bigint NOT NULL, + project_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + file_store smallint, + file character varying(255) +); + +CREATE SEQUENCE public.terraform_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.terraform_states_id_seq OWNED BY public.terraform_states.id; + CREATE TABLE public.timelogs ( id integer NOT NULL, time_spent integer NOT NULL, @@ -7327,6 +7345,8 @@ ALTER TABLE ONLY public.tags ALTER COLUMN id SET DEFAULT nextval('public.tags_id ALTER TABLE ONLY public.term_agreements ALTER COLUMN id SET DEFAULT nextval('public.term_agreements_id_seq'::regclass); +ALTER TABLE ONLY public.terraform_states ALTER COLUMN id SET DEFAULT nextval('public.terraform_states_id_seq'::regclass); + ALTER TABLE ONLY public.timelogs ALTER COLUMN id SET DEFAULT nextval('public.timelogs_id_seq'::regclass); ALTER TABLE ONLY public.todos ALTER COLUMN id SET DEFAULT nextval('public.todos_id_seq'::regclass); @@ -8228,6 +8248,9 @@ ALTER TABLE ONLY public.tags ALTER TABLE ONLY public.term_agreements ADD CONSTRAINT term_agreements_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.terraform_states + ADD CONSTRAINT terraform_states_pkey PRIMARY KEY (id); + ALTER TABLE ONLY public.timelogs ADD CONSTRAINT timelogs_pkey PRIMARY KEY (id); @@ -9966,6 +9989,8 @@ CREATE INDEX index_term_agreements_on_term_id ON public.term_agreements USING bt CREATE INDEX index_term_agreements_on_user_id ON public.term_agreements USING btree (user_id); +CREATE INDEX index_terraform_states_on_project_id ON public.terraform_states USING btree (project_id); + CREATE INDEX index_timelogs_on_issue_id ON public.timelogs USING btree (issue_id); CREATE INDEX index_timelogs_on_merge_request_id ON public.timelogs USING btree (merge_request_id); @@ -11271,6 +11296,9 @@ ALTER TABLE ONLY public.pages_domain_acme_orders ALTER TABLE ONLY public.ci_subscriptions_projects ADD CONSTRAINT fk_rails_7871f9a97b FOREIGN KEY (upstream_project_id) REFERENCES public.projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.terraform_states + ADD CONSTRAINT fk_rails_78f54ca485 FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.software_license_policies ADD CONSTRAINT fk_rails_7a7a2a92de FOREIGN KEY (software_license_id) REFERENCES public.software_licenses(id) ON DELETE CASCADE; @@ -12742,6 +12770,7 @@ COPY "schema_migrations" (version) FROM STDIN; 20200304211738 20200305121159 20200305151736 +20200305200641 20200306095654 20200306160521 20200306170211 diff --git a/doc/administration/terraform_state.md b/doc/administration/terraform_state.md new file mode 100644 index 0000000000000000000000000000000000000000..c684178f13ea14ec91a4df23a28f5d4b8092bed4 --- /dev/null +++ b/doc/administration/terraform_state.md @@ -0,0 +1,135 @@ +# Terraform state administration (alpha) + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2673) in GitLab 12.10. + +GitLab can be used as a backend for [Terraform](../user/infrastructure/index.md) state +files. The files are encrypted before being stored. This feature is enabled by default. + +The storage location of these files defaults to: + +- `/var/opt/gitlab/gitlab-rails/shared/terraform_state` for Omnibus GitLab installations. +- `/home/git/gitlab/shared/terraform_state` for source installations. + +These locations can be configured using the options described below. + +## Using local storage + +NOTE: **Note:** +This is the default configuration + +To change the location where Terraform state files are stored locally, follow the steps +below. + +**In Omnibus installations:** + +1. To change the storage path for example to `/mnt/storage/terraform_state`, edit + `/etc/gitlab/gitlab.rb` and add the following line: + + ```ruby + gitlab_rails['terraform_state_enabled'] = true + gitlab_rails['terraform_state_storage_path'] = "/mnt/storage/terraform_state" + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +**In installations from source:** + +1. To change the storage path for example to `/mnt/storage/terraform_state`, edit + `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: + + ```yaml + terraform_state: + enabled: true + storage_path: /mnt/storage/terraform_state + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +## Using object storage **(CORE ONLY)** + +Instead of storing Terraform state files on disk, we recommend the use of an object +store that is S3-compatible instead. This configuration relies on valid credentials to +be configured already. + +### Object storage settings + +The following settings are: + +- Nested under `terraform_state:` and then `object_store:` on source installations. +- Prefixed by `terraform_state_object_store_` on Omnibus GitLab installations. + +| Setting | Description | Default | +|---------|-------------|---------| +| `enabled` | Enable/disable object storage | `true` | +| `remote_directory` | The bucket name where Terraform state files will be stored | | +| `connection` | Various connection options described below | | + +### S3-compatible connection settings + +The connection settings match those provided by [Fog](https://github.com/fog), and are as follows: + +| Setting | Description | Default | +|---------|-------------|---------| +| `provider` | Always `AWS` for compatible hosts | `AWS` | +| `aws_access_key_id` | Credentials for AWS or compatible provider | | +| `aws_secret_access_key` | Credentials for AWS or compatible provider | | +| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | +| `enable_signature_v4_streaming` | Set to true to enable HTTP chunked transfers with [AWS v4 signatures](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html). Oracle Cloud S3 needs this to be false | `true` | +| `region` | AWS region | us-east-1 | +| `host` | S3-compatible host when not using AWS. For example, `localhost` or `storage.example.com` | `s3.amazonaws.com` | +| `endpoint` | Can be used when configuring an S3-compatible service such as [MinIO](https://min.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | +| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | `false` | +| `use_iam_profile` | For AWS S3, set to true to use an IAM profile instead of access keys | `false` | + +**In Omnibus installations:** + +1. Edit `/etc/gitlab/gitlab.rb` and add the following lines; replacing with + the values you want: + + ```ruby + gitlab_rails['terraform_state_enabled'] = true + gitlab_rails['terraform_state_object_store_enabled'] = true + gitlab_rails['terraform_state_object_store_remote_directory'] = "terraform_state" + gitlab_rails['terraform_state_object_store_connection'] = { + 'provider' => 'AWS', + 'region' => 'eu-central-1', + 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID', + 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY' + } + ``` + + NOTE: **Note:** + If you are using AWS IAM profiles, be sure to omit the AWS access key and secret access key/value pairs. + + ```ruby + gitlab_rails['terraform_state_object_store_connection'] = { + 'provider' => 'AWS', + 'region' => 'eu-central-1', + 'use_iam_profile' => true + } + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +**In installations from source:** + +1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following + lines: + + ```yaml + terraform_state: + enabled: true + object_store: + enabled: true + remote_directory: "terraform_state" # The bucket name + connection: + provider: AWS # Only AWS supported at the moment + aws_access_key_id: AWS_ACESS_KEY_ID + aws_secret_access_key: AWS_SECRET_ACCESS_KEY + region: eu-central-1 + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab" +[restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab" diff --git a/spec/factories/terraform/state.rb b/spec/factories/terraform/state.rb new file mode 100644 index 0000000000000000000000000000000000000000..4b83128ff6edb6c6a9cd5a67658efca7d9dbf34e --- /dev/null +++ b/spec/factories/terraform/state.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :terraform_state, class: 'Terraform::State' do + project { create(:project) } + + trait :with_file do + file { fixture_file_upload('spec/fixtures/terraform/terraform.tfstate') } + end + end +end diff --git a/spec/fixtures/terraform/terraform.tfstate b/spec/fixtures/terraform/terraform.tfstate new file mode 100644 index 0000000000000000000000000000000000000000..3384d9eb005fc32b8499481d2eba126ba5477a37 --- /dev/null +++ b/spec/fixtures/terraform/terraform.tfstate @@ -0,0 +1,8 @@ +{ + "version": 4, + "terraform_version": "0.12.21", + "serial": 1, + "lineage": "25e05991-243d-28d6-ebe3-ee0baae462cf", + "outputs": {}, + "resources": [] +} \ No newline at end of file diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1d677e7ece517f618ec38f67ffd34eacbe439166 --- /dev/null +++ b/spec/models/terraform/state_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Terraform::State do + subject { create(:terraform_state, :with_file) } + + it { is_expected.to belong_to(:project) } + + it { is_expected.to validate_presence_of(:project_id) } + + before do + stub_terraform_state_object_storage(Terraform::StateUploader) + end + + describe '#file_store' do + context 'when no value is set' do + it 'returns the default store of the uploader' do + [ObjectStorage::Store::LOCAL, ObjectStorage::Store::REMOTE].each do |store| + expect(Terraform::StateUploader).to receive(:default_store).and_return(store) + expect(described_class.new.file_store).to eq(store) + end + end + end + + context 'when a value is set' do + it 'returns the value' do + [ObjectStorage::Store::LOCAL, ObjectStorage::Store::REMOTE].each do |store| + expect(build(:terraform_state, file_store: store).file_store).to eq(store) + end + end + end + end + + describe '#update_file_store' do + context 'when file is stored in object storage' do + it 'sets file_store to remote' do + expect(subject.file_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'when file is stored locally' do + before do + stub_terraform_state_object_storage(Terraform::StateUploader, enabled: false) + end + + it 'sets file_store to local' do + expect(subject.file_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end +end diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 392300a443637ec45bfaa378a8869313c4171a69..d4ac286e9596610f91a8aa3aec4cad8089dcf52c 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -70,6 +70,13 @@ def stub_uploads_object_storage(uploader = described_class, **params) **params) end + def stub_terraform_state_object_storage(uploader = described_class, **params) + stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, + uploader: uploader, + remote_directory: 'terraform_state', + **params) + end + def stub_object_storage_multipart_init(endpoint, upload_id = "upload_id") stub_request(:post, %r{\A#{endpoint}tmp/uploads/[a-z0-9-]*\?uploads\z}) .to_return status: 200, body: <<-EOS.strip_heredoc diff --git a/spec/uploaders/terraform/state_uploader_spec.rb b/spec/uploaders/terraform/state_uploader_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4577a2c4738215b26e5ccfc27b57e7741daa53e5 --- /dev/null +++ b/spec/uploaders/terraform/state_uploader_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Terraform::StateUploader do + subject { terraform_state.file } + + let(:terraform_state) { create(:terraform_state, file: fixture_file_upload('spec/fixtures/terraform/terraform.tfstate')) } + + before do + stub_terraform_state_object_storage + end + + describe '#filename' do + it 'contains the ID of the terraform state record' do + expect(subject.filename).to include(terraform_state.id.to_s) + end + end + + describe '#store_dir' do + it 'contains the ID of the project' do + expect(subject.store_dir).to include(terraform_state.project_id.to_s) + end + end + + describe '#key' do + it 'creates a digest with a secret key and the project id' do + expect(OpenSSL::HMAC) + .to receive(:digest) + .with('SHA256', Gitlab::Application.secrets.db_key_base, terraform_state.project_id.to_s) + .and_return('digest') + + expect(subject.key).to eq('digest') + end + end + + describe 'encryption' do + it 'encrypts the stored file' do + expect(subject.file.read).not_to eq(fixture_file('terraform/terraform.tfstate')) + end + + it 'decrypts the file when reading' do + expect(subject.read).to eq(fixture_file('terraform/terraform.tfstate')) + end + end + + describe '.direct_upload_enabled?' do + it 'returns false' do + expect(described_class.direct_upload_enabled?).to eq(false) + end + end + + describe '.background_upload_enabled?' do + it 'returns false' do + expect(described_class.background_upload_enabled?).to eq(false) + end + end + + describe '.proxy_download_enabled?' do + it 'returns true' do + expect(described_class.proxy_download_enabled?).to eq(true) + end + end + + describe '.default_store' do + context 'when object storage is enabled' do + it 'returns REMOTE' do + expect(described_class.default_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'when object storage is disabled' do + before do + stub_terraform_state_object_storage(enabled: false) + end + + it 'returns LOCAL' do + expect(described_class.default_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end +end