Commit 6fb6be1a authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch...

Merge branch '207401-encrypt-decrypt-object-storage-to-support-terraform-state-backend' into 'master'

Add Terraform state model with encrypted uploader

Closes #207401

See merge request gitlab-org/gitlab!26619
parents 5003b5eb e17d5bf1
......@@ -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'
......@@ -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
......
# frozen_string_literal: true
module Terraform
def self.table_name_prefix
'terraform_'
end
end
# 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
# 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
---
title: Create model to store Terraform state files
merge_request: 26619
author:
type: added
......@@ -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
......
......@@ -369,6 +369,14 @@ Gitlab.ee do
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
#
......
# 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
......@@ -5976,6 +5976,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,
......@@ -7329,6 +7347,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);
......@@ -8230,6 +8250,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);
......@@ -9974,6 +9997,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);
......@@ -11279,6 +11304,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;
......@@ -12751,6 +12779,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200304211738
20200305121159
20200305151736
20200305200641
20200306095654
20200306160521
20200306170211
......
# 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"
# 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
{
"version": 4,
"terraform_version": "0.12.21",
"serial": 1,
"lineage": "25e05991-243d-28d6-ebe3-ee0baae462cf",
"outputs": {},
"resources": []
}
\ No newline at end of file
# 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
......@@ -70,6 +70,13 @@ module StubObjectStorage
**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
......
# 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
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