Commit 85609c11 authored by Krasimir Angelov's avatar Krasimir Angelov Committed by Lin Jen-Shin

Implement support for CI variables of type file

Add env_var and file as supported types for CI variables. Variables of
type file expose to users existing gitlab-runner behaviour - save
variable value into a temp file and set the path to this file in an ENV
var named after the variable key.

Resolves https://gitlab.com/gitlab-org/gitlab-ce/issues/46806.
parent 4d2d8124
...@@ -26,6 +26,10 @@ export default class VariableList { ...@@ -26,6 +26,10 @@ export default class VariableList {
selector: '.js-ci-variable-input-id', selector: '.js-ci-variable-input-id',
default: '', default: '',
}, },
variable_type: {
selector: '.js-ci-variable-input-variable-type',
default: 'env_var',
},
key: { key: {
selector: '.js-ci-variable-input-key', selector: '.js-ci-variable-input-key',
default: '', default: '',
......
...@@ -19,6 +19,7 @@ export default function setupNativeFormVariableList({ container, formField = 'va ...@@ -19,6 +19,7 @@ export default function setupNativeFormVariableList({ container, formField = 'va
const isTouched = variableList.checkIfRowTouched($lastRow); const isTouched = variableList.checkIfRowTouched($lastRow);
if (!isTouched) { if (!isTouched) {
$lastRow.find('input, textarea').attr('name', ''); $lastRow.find('input, textarea').attr('name', '');
$lastRow.find('select').attr('name', '');
} }
}); });
} }
...@@ -41,7 +41,7 @@ module Groups ...@@ -41,7 +41,7 @@ module Groups
end end
def variable_params_attributes def variable_params_attributes
%i[id key secret_value protected masked _destroy] %i[id variable_type key secret_value protected masked _destroy]
end end
def authorize_admin_build! def authorize_admin_build!
......
...@@ -98,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController ...@@ -98,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params def schedule_params
params.require(:schedule) params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active, .permit(:description, :cron, :cron_timezone, :ref, :active,
variables_attributes: [:id, :key, :secret_value, :_destroy] ) variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy] )
end end
def authorize_play_pipeline_schedule! def authorize_play_pipeline_schedule!
......
...@@ -169,7 +169,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -169,7 +169,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
def create_params def create_params
params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value]) params.require(:pipeline).permit(:ref, variables_attributes: %i[key variable_type secret_value])
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
......
...@@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController ...@@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController
end end
def variable_params_attributes def variable_params_attributes
%i[id key secret_value protected masked _destroy] %i[id variable_type key secret_value protected masked _destroy]
end end
end end
...@@ -20,4 +20,11 @@ module CiVariablesHelper ...@@ -20,4 +20,11 @@ module CiVariablesHelper
true true
end end
end end
def ci_variable_type_options
[
%w(Variable env_var),
%w(File file)
]
end
end end
...@@ -4,6 +4,11 @@ module HasVariable ...@@ -4,6 +4,11 @@ module HasVariable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
enum variable_type: {
env_var: 1,
file: 2
}
validates :key, validates :key,
presence: true, presence: true,
length: { maximum: 255 }, length: { maximum: 255 },
...@@ -24,6 +29,6 @@ module HasVariable ...@@ -24,6 +29,6 @@ module HasVariable
end end
def to_runner_variable def to_runner_variable
{ key: key, value: value, public: false } { key: key, value: value, public: false, file: file? }
end end
end end
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
- only_key_value = local_assigns.fetch(:only_key_value, false) - only_key_value = local_assigns.fetch(:only_key_value, false)
- id = variable&.id - id = variable&.id
- variable_type = variable&.variable_type
- key = variable&.key - key = variable&.key
- value = variable&.value - value = variable&.value
- is_protected_default = ci_variable_protected_by_default? - is_protected_default = ci_variable_protected_by_default?
...@@ -12,6 +13,7 @@ ...@@ -12,6 +13,7 @@
- id_input_name = "#{form_field}[variables_attributes][][id]" - id_input_name = "#{form_field}[variables_attributes][][id]"
- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]"
- key_input_name = "#{form_field}[variables_attributes][][key]" - key_input_name = "#{form_field}[variables_attributes][][key]"
- value_input_name = "#{form_field}[variables_attributes][][secret_value]" - value_input_name = "#{form_field}[variables_attributes][][secret_value]"
- protected_input_name = "#{form_field}[variables_attributes][][protected]" - protected_input_name = "#{form_field}[variables_attributes][][protected]"
...@@ -21,6 +23,8 @@ ...@@ -21,6 +23,8 @@
.ci-variable-row-body .ci-variable-row-body
%input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id } %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id }
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
%select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control{ name: variable_type_input_name }
= options_for_select(ci_variable_type_options, variable_type)
%input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control{ type: "text", %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control{ type: "text",
name: key_input_name, name: key_input_name,
value: key, value: key,
......
---
title: CI variables of type file
merge_request: 27112
author:
type: added
# frozen_string_literal: true
class AddVariableTypeToCiVariables < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
ENV_VAR_VARIABLE_TYPE = 1
def up
add_column_with_default(:ci_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE)
end
def down
remove_column(:ci_variables, :variable_type)
end
end
# frozen_string_literal: true
class AddVariableTypeToCiGroupVariables < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
ENV_VAR_VARIABLE_TYPE = 1
def up
add_column_with_default(:ci_group_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE)
end
def down
remove_column(:ci_group_variables, :variable_type)
end
end
# frozen_string_literal: true
class AddVariableTypeToCiPipelineVariables < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
ENV_VAR_VARIABLE_TYPE = 1
def up
add_column_with_default(:ci_pipeline_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE)
end
def down
remove_column(:ci_pipeline_variables, :variable_type)
end
end
# frozen_string_literal: true
class AddVariableTypeToCiPipelineScheduleVariables < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
ENV_VAR_VARIABLE_TYPE = 1
def up
add_column_with_default(:ci_pipeline_schedule_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE)
end
def down
remove_column(:ci_pipeline_schedule_variables, :variable_type)
end
end
...@@ -419,6 +419,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do ...@@ -419,6 +419,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false t.datetime_with_timezone "updated_at", null: false
t.boolean "masked", default: false, null: false t.boolean "masked", default: false, null: false
t.integer "variable_type", limit: 2, default: 1, null: false
t.index ["group_id", "key"], name: "index_ci_group_variables_on_group_id_and_key", unique: true, using: :btree t.index ["group_id", "key"], name: "index_ci_group_variables_on_group_id_and_key", unique: true, using: :btree
end end
...@@ -458,6 +459,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do ...@@ -458,6 +459,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do
t.integer "pipeline_schedule_id", null: false t.integer "pipeline_schedule_id", null: false
t.datetime_with_timezone "created_at" t.datetime_with_timezone "created_at"
t.datetime_with_timezone "updated_at" t.datetime_with_timezone "updated_at"
t.integer "variable_type", limit: 2, default: 1, null: false
t.index ["pipeline_schedule_id", "key"], name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key", unique: true, using: :btree t.index ["pipeline_schedule_id", "key"], name: "index_ci_pipeline_schedule_variables_on_schedule_id_and_key", unique: true, using: :btree
end end
...@@ -484,6 +486,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do ...@@ -484,6 +486,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do
t.string "encrypted_value_salt" t.string "encrypted_value_salt"
t.string "encrypted_value_iv" t.string "encrypted_value_iv"
t.integer "pipeline_id", null: false t.integer "pipeline_id", null: false
t.integer "variable_type", limit: 2, default: 1, null: false
t.index ["pipeline_id", "key"], name: "index_ci_pipeline_variables_on_pipeline_id_and_key", unique: true, using: :btree t.index ["pipeline_id", "key"], name: "index_ci_pipeline_variables_on_pipeline_id_and_key", unique: true, using: :btree
end end
...@@ -618,6 +621,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do ...@@ -618,6 +621,7 @@ ActiveRecord::Schema.define(version: 20190426180107) do
t.boolean "protected", default: false, null: false t.boolean "protected", default: false, null: false
t.string "environment_scope", default: "*", null: false t.string "environment_scope", default: "*", null: false
t.boolean "masked", default: false, null: false t.boolean "masked", default: false, null: false
t.integer "variable_type", limit: 2, default: 1, null: false
t.index ["project_id", "key", "environment_scope"], name: "index_ci_variables_on_project_id_and_key_and_environment_scope", unique: true, using: :btree t.index ["project_id", "key", "environment_scope"], name: "index_ci_variables_on_project_id_and_key_and_environment_scope", unique: true, using: :btree
end end
......
...@@ -22,10 +22,12 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a ...@@ -22,10 +22,12 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
[ [
{ {
"key": "TEST_VARIABLE_1", "key": "TEST_VARIABLE_1",
"variable_type": "env_var",
"value": "TEST_1" "value": "TEST_1"
}, },
{ {
"key": "TEST_VARIABLE_2", "key": "TEST_VARIABLE_2",
"variable_type": "env_var",
"value": "TEST_2" "value": "TEST_2"
} }
] ]
...@@ -51,6 +53,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a ...@@ -51,6 +53,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
```json ```json
{ {
"key": "TEST_VARIABLE_1", "key": "TEST_VARIABLE_1",
"variable_type": "env_var",
"value": "TEST_1" "value": "TEST_1"
} }
``` ```
...@@ -64,10 +67,11 @@ POST /groups/:id/variables ...@@ -64,10 +67,11 @@ POST /groups/:id/variables
``` ```
| Attribute | Type | required | Description | | Attribute | Type | required | Description |
|-------------|---------|----------|-----------------------| |-----------------|---------|----------|-----------------------|
| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | | `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
| `protected` | boolean | no | Whether the variable is protected | | `protected` | boolean | no | Whether the variable is protected |
``` ```
...@@ -78,6 +82,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla ...@@ -78,6 +82,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"value": "new value", "value": "new value",
"variable_type": "env_var",
"protected": false "protected": false
} }
``` ```
...@@ -91,10 +96,11 @@ PUT /groups/:id/variables/:key ...@@ -91,10 +96,11 @@ PUT /groups/:id/variables/:key
``` ```
| Attribute | Type | required | Description | | Attribute | Type | required | Description |
|-------------|---------|----------|-------------------------| |-----------------|---------|----------|-------------------------|
| `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID of a group or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable | | `key` | string | yes | The `key` of a variable |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
| `protected` | boolean | no | Whether the variable is protected | | `protected` | boolean | no | Whether the variable is protected |
``` ```
...@@ -105,6 +111,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab ...@@ -105,6 +111,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"value": "updated value", "value": "updated value",
"variable_type": "env_var",
"protected": true "protected": true
} }
``` ```
......
...@@ -88,6 +88,7 @@ curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/ ...@@ -88,6 +88,7 @@ curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/
"variables": [ "variables": [
{ {
"key": "TEST_VARIABLE_1", "key": "TEST_VARIABLE_1",
"variable_type": "env_var",
"value": "TEST_1" "value": "TEST_1"
} }
] ]
...@@ -296,6 +297,7 @@ POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables ...@@ -296,6 +297,7 @@ POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables
| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | | `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | | `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
```sh ```sh
curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "key=NEW_VARIABLE" --form "value=new value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables" curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "key=NEW_VARIABLE" --form "value=new value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables"
...@@ -304,6 +306,7 @@ curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "key=N ...@@ -304,6 +306,7 @@ curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "key=N
```json ```json
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"variable_type": "env_var",
"value": "new value" "value": "new value"
} }
``` ```
...@@ -322,6 +325,7 @@ PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key ...@@ -322,6 +325,7 @@ PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key
| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | | `pipeline_schedule_id` | integer | yes | The pipeline schedule id |
| `key` | string | yes | The `key` of a variable | | `key` | string | yes | The `key` of a variable |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
```sh ```sh
curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "value=updated value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables/NEW_VARIABLE" curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "value=updated value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables/NEW_VARIABLE"
...@@ -331,6 +335,7 @@ curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "value= ...@@ -331,6 +335,7 @@ curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "value=
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"value": "updated value" "value": "updated value"
"variable_type": "env_var",
} }
``` ```
......
...@@ -114,6 +114,7 @@ Example of response ...@@ -114,6 +114,7 @@ Example of response
[ [
{ {
"key": "RUN_NIGHTLY_BUILD", "key": "RUN_NIGHTLY_BUILD",
"variable_type": "env_var",
"value": "true" "value": "true"
}, },
{ {
...@@ -135,7 +136,7 @@ POST /projects/:id/pipeline ...@@ -135,7 +136,7 @@ POST /projects/:id/pipeline
|------------|---------|----------|---------------------| |------------|---------|----------|---------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `ref` | string | yes | Reference to commit | | `ref` | string | yes | Reference to commit |
| `variables` | array | no | An array containing the variables available in the pipeline, matching the structure [{ 'key' => 'UPLOAD_TO_S3', 'value' => 'true' }] | | `variables` | array | no | An array containing the variables available in the pipeline, matching the structure [{ 'key' => 'UPLOAD_TO_S3', 'variable_type' => 'file', 'value' => 'true' }] |
``` ```
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/pipeline?ref=master" curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/pipeline?ref=master"
......
...@@ -20,10 +20,12 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a ...@@ -20,10 +20,12 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
[ [
{ {
"key": "TEST_VARIABLE_1", "key": "TEST_VARIABLE_1",
"variable_type": "env_var",
"value": "TEST_1" "value": "TEST_1"
}, },
{ {
"key": "TEST_VARIABLE_2", "key": "TEST_VARIABLE_2",
"variable_type": "env_var",
"value": "TEST_2" "value": "TEST_2"
} }
] ]
...@@ -49,6 +51,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a ...@@ -49,6 +51,7 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/a
```json ```json
{ {
"key": "TEST_VARIABLE_1", "key": "TEST_VARIABLE_1",
"variable_type": "env_var",
"value": "TEST_1" "value": "TEST_1"
} }
``` ```
...@@ -62,10 +65,11 @@ POST /projects/:id/variables ...@@ -62,10 +65,11 @@ POST /projects/:id/variables
``` ```
| Attribute | Type | required | Description | | Attribute | Type | required | Description |
|-------------|---------|----------|-----------------------| |-----------------|---------|----------|-----------------------|
| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | | `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
| `protected` | boolean | no | Whether the variable is protected | | `protected` | boolean | no | Whether the variable is protected |
``` ```
...@@ -76,6 +80,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla ...@@ -76,6 +80,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"value": "new value", "value": "new value",
"variable_type": "env_var",
"protected": false "protected": false
} }
``` ```
...@@ -89,10 +94,11 @@ PUT /projects/:id/variables/:key ...@@ -89,10 +94,11 @@ PUT /projects/:id/variables/:key
``` ```
| Attribute | Type | required | Description | | Attribute | Type | required | Description |
|-------------|---------|----------|-------------------------| |-----------------|---------|----------|-------------------------|
| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable | | `key` | string | yes | The `key` of a variable |
| `value` | string | yes | The `value` of a variable | | `value` | string | yes | The `value` of a variable |
| `variable_type` | string | no | The type of a variable. Available types are: `env_var` (default) and `file` |
| `protected` | boolean | no | Whether the variable is protected | | `protected` | boolean | no | Whether the variable is protected |
``` ```
...@@ -103,6 +109,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab ...@@ -103,6 +109,7 @@ curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab
{ {
"key": "NEW_VARIABLE", "key": "NEW_VARIABLE",
"value": "updated value", "value": "updated value",
"variable_type": "env_var",
"protected": true "protected": true
} }
``` ```
......
...@@ -1288,7 +1288,7 @@ module API ...@@ -1288,7 +1288,7 @@ module API
end end
class Variable < Grape::Entity class Variable < Grape::Entity
expose :key, :value expose :variable_type, :key, :value
expose :protected?, as: :protected, if: -> (entity, _) { entity.respond_to?(:protected?) } expose :protected?, as: :protected, if: -> (entity, _) { entity.respond_to?(:protected?) }
end end
......
...@@ -47,6 +47,7 @@ module API ...@@ -47,6 +47,7 @@ module API
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
requires :value, type: String, desc: 'The value of the variable' requires :value, type: String, desc: 'The value of the variable'
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :variable_type, type: String, values: Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var'
end end
post ':id/variables' do post ':id/variables' do
variable_params = declared_params(include_missing: false) variable_params = declared_params(include_missing: false)
...@@ -67,6 +68,7 @@ module API ...@@ -67,6 +68,7 @@ module API
optional :key, type: String, desc: 'The key of the variable' optional :key, type: String, desc: 'The key of the variable'
optional :value, type: String, desc: 'The value of the variable' optional :value, type: String, desc: 'The value of the variable'
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :variable_type, type: String, values: Ci::GroupVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
put ':id/variables/:key' do put ':id/variables/:key' do
......
...@@ -118,6 +118,7 @@ module API ...@@ -118,6 +118,7 @@ module API
requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
requires :value, type: String, desc: 'The value of the variable' requires :value, type: String, desc: 'The value of the variable'
optional :variable_type, type: String, values: Ci::PipelineScheduleVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var'
end end
post ':id/pipeline_schedules/:pipeline_schedule_id/variables' do post ':id/pipeline_schedules/:pipeline_schedule_id/variables' do
authorize! :update_pipeline_schedule, pipeline_schedule authorize! :update_pipeline_schedule, pipeline_schedule
...@@ -138,6 +139,7 @@ module API ...@@ -138,6 +139,7 @@ module API
requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id'
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
optional :value, type: String, desc: 'The value of the variable' optional :value, type: String, desc: 'The value of the variable'
optional :variable_type, type: String, values: Ci::PipelineScheduleVariable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file'
end end
put ':id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do put ':id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do
authorize! :update_pipeline_schedule, pipeline_schedule authorize! :update_pipeline_schedule, pipeline_schedule
......
...@@ -55,6 +55,7 @@ module API ...@@ -55,6 +55,7 @@ module API
requires :key, type: String, desc: 'The key of the variable' requires :key, type: String, desc: 'The key of the variable'
requires :value, type: String, desc: 'The value of the variable' requires :value, type: String, desc: 'The value of the variable'
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :variable_type, type: String, values: Ci::Variable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file. Defaults to env_var'
if Gitlab.ee? if Gitlab.ee?
optional :environment_scope, type: String, desc: 'The environment_scope of the variable' optional :environment_scope, type: String, desc: 'The environment_scope of the variable'
...@@ -80,6 +81,7 @@ module API ...@@ -80,6 +81,7 @@ module API
optional :key, type: String, desc: 'The key of the variable' optional :key, type: String, desc: 'The key of the variable'
optional :value, type: String, desc: 'The value of the variable' optional :value, type: String, desc: 'The value of the variable'
optional :protected, type: String, desc: 'Whether the variable is protected' optional :protected, type: String, desc: 'Whether the variable is protected'
optional :variable_type, type: String, values: Ci::Variable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file'
if Gitlab.ee? if Gitlab.ee?
optional :environment_scope, type: String, desc: 'The environment_scope of the variable' optional :environment_scope, type: String, desc: 'The environment_scope of the variable'
......
...@@ -91,7 +91,7 @@ describe Projects::PipelineSchedulesController do ...@@ -91,7 +91,7 @@ describe Projects::PipelineSchedulesController do
context 'when variables_attributes has one variable' do context 'when variables_attributes has one variable' do
let(:schedule) do let(:schedule) do
basic_param.merge({ basic_param.merge({
variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] variables_attributes: [{ key: 'AAA', secret_value: 'AAA123', variable_type: 'file' }]
}) })
end end
...@@ -105,6 +105,7 @@ describe Projects::PipelineSchedulesController do ...@@ -105,6 +105,7 @@ describe Projects::PipelineSchedulesController do
Ci::PipelineScheduleVariable.last.tap do |v| Ci::PipelineScheduleVariable.last.tap do |v|
expect(v.key).to eq("AAA") expect(v.key).to eq("AAA")
expect(v.value).to eq("AAA123") expect(v.value).to eq("AAA123")
expect(v.variable_type).to eq("file")
end end
end end
end end
......
...@@ -2,6 +2,7 @@ FactoryBot.define do ...@@ -2,6 +2,7 @@ FactoryBot.define do
factory :ci_pipeline_schedule_variable, class: Ci::PipelineScheduleVariable do factory :ci_pipeline_schedule_variable, class: Ci::PipelineScheduleVariable do
sequence(:key) { |n| "VARIABLE_#{n}" } sequence(:key) { |n| "VARIABLE_#{n}" }
value 'VARIABLE_VALUE' value 'VARIABLE_VALUE'
variable_type 'env_var'
pipeline_schedule factory: :ci_pipeline_schedule pipeline_schedule factory: :ci_pipeline_schedule
end end
......
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
"additionalProperties": false "additionalProperties": false
}, },
"variables": { "variables": {
"type": ["array", "null"], "type": "array",
"items": { "$ref": "pipeline_schedule_variable.json" } "items": { "$ref": "pipeline_schedule_variable.json" }
} }
}, },
......
{ {
"type": ["object", "null"], "type": "object",
"required": [
"key",
"value",
"variable_type"
],
"properties": { "properties": {
"key": { "type": "string" }, "key": { "type": "string" },
"value": { "type": "string" } "value": { "type": "string" },
"variable_type": { "type": "string" }
}, },
"additionalProperties": false "additionalProperties": false
} }
...@@ -5,7 +5,8 @@ require 'spec_helper' ...@@ -5,7 +5,8 @@ require 'spec_helper'
describe Ci::GroupVariable do describe Ci::GroupVariable do
subject { build(:ci_group_variable) } subject { build(:ci_group_variable) }
it { is_expected.to include_module(HasVariable) } it_behaves_like "CI variable"
it { is_expected.to include_module(Presentable) } it { is_expected.to include_module(Presentable) }
it { is_expected.to include_module(Maskable) } it { is_expected.to include_module(Maskable) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) } it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) }
......
...@@ -5,5 +5,5 @@ require 'spec_helper' ...@@ -5,5 +5,5 @@ require 'spec_helper'
describe Ci::PipelineScheduleVariable do describe Ci::PipelineScheduleVariable do
subject { build(:ci_pipeline_schedule_variable) } subject { build(:ci_pipeline_schedule_variable) }
it { is_expected.to include_module(HasVariable) } it_behaves_like "CI variable"
end end
...@@ -5,7 +5,8 @@ require 'spec_helper' ...@@ -5,7 +5,8 @@ require 'spec_helper'
describe Ci::PipelineVariable do describe Ci::PipelineVariable do
subject { build(:ci_pipeline_variable) } subject { build(:ci_pipeline_variable) }
it { is_expected.to include_module(HasVariable) } it_behaves_like "CI variable"
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:pipeline_id) } it { is_expected.to validate_uniqueness_of(:key).scoped_to(:pipeline_id) }
describe '#hook_attrs' do describe '#hook_attrs' do
......
...@@ -5,8 +5,9 @@ require 'spec_helper' ...@@ -5,8 +5,9 @@ require 'spec_helper'
describe Ci::Variable do describe Ci::Variable do
subject { build(:ci_variable) } subject { build(:ci_variable) }
it_behaves_like "CI variable"
describe 'validations' do describe 'validations' do
it { is_expected.to include_module(HasVariable) }
it { is_expected.to include_module(Presentable) } it { is_expected.to include_module(Presentable) }
it { is_expected.to include_module(Maskable) } it { is_expected.to include_module(Maskable) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) } it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) }
......
...@@ -51,6 +51,7 @@ describe API::GroupVariables do ...@@ -51,6 +51,7 @@ describe API::GroupVariables do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response['value']).to eq(variable.value) expect(json_response['value']).to eq(variable.value)
expect(json_response['protected']).to eq(variable.protected?) expect(json_response['protected']).to eq(variable.protected?)
expect(json_response['variable_type']).to eq(variable.variable_type)
end end
it 'responds with 404 Not Found if requesting non-existing variable' do it 'responds with 404 Not Found if requesting non-existing variable' do
...@@ -94,17 +95,19 @@ describe API::GroupVariables do ...@@ -94,17 +95,19 @@ describe API::GroupVariables do
expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('PROTECTED_VALUE_2') expect(json_response['value']).to eq('PROTECTED_VALUE_2')
expect(json_response['protected']).to be_truthy expect(json_response['protected']).to be_truthy
expect(json_response['variable_type']).to eq('env_var')
end end
it 'creates variable with optional attributes' do it 'creates variable with optional attributes' do
expect do expect do
post api("/groups/#{group.id}/variables", user), params: { key: 'TEST_VARIABLE_2', value: 'VALUE_2' } post api("/groups/#{group.id}/variables", user), params: { variable_type: 'file', key: 'TEST_VARIABLE_2', value: 'VALUE_2' }
end.to change {group.variables.count}.by(1) end.to change {group.variables.count}.by(1)
expect(response).to have_gitlab_http_status(201) expect(response).to have_gitlab_http_status(201)
expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('VALUE_2') expect(json_response['value']).to eq('VALUE_2')
expect(json_response['protected']).to be_falsey expect(json_response['protected']).to be_falsey
expect(json_response['variable_type']).to eq('file')
end end
it 'does not allow to duplicate variable key' do it 'does not allow to duplicate variable key' do
...@@ -145,7 +148,7 @@ describe API::GroupVariables do ...@@ -145,7 +148,7 @@ describe API::GroupVariables do
initial_variable = group.variables.reload.first initial_variable = group.variables.reload.first
value_before = initial_variable.value value_before = initial_variable.value
put api("/groups/#{group.id}/variables/#{variable.key}", user), params: { value: 'VALUE_1_UP', protected: true } put api("/groups/#{group.id}/variables/#{variable.key}", user), params: { variable_type: 'file', value: 'VALUE_1_UP', protected: true }
updated_variable = group.variables.reload.first updated_variable = group.variables.reload.first
...@@ -153,6 +156,7 @@ describe API::GroupVariables do ...@@ -153,6 +156,7 @@ describe API::GroupVariables do
expect(value_before).to eq(variable.value) expect(value_before).to eq(variable.value)
expect(updated_variable.value).to eq('VALUE_1_UP') expect(updated_variable.value).to eq('VALUE_1_UP')
expect(updated_variable).to be_protected expect(updated_variable).to be_protected
expect(json_response['variable_type']).to eq('file')
end end
it 'responds with 404 Not Found if requesting non-existing variable' do it 'responds with 404 Not Found if requesting non-existing variable' do
......
...@@ -91,6 +91,7 @@ describe API::PipelineSchedules do ...@@ -91,6 +91,7 @@ describe API::PipelineSchedules do
let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) }
before do before do
pipeline_schedule.variables << build(:ci_pipeline_schedule_variable)
pipeline_schedule.pipelines << build(:ci_pipeline, project: project) pipeline_schedule.pipelines << build(:ci_pipeline, project: project)
end end
...@@ -331,13 +332,14 @@ describe API::PipelineSchedules do ...@@ -331,13 +332,14 @@ describe API::PipelineSchedules do
it 'creates pipeline_schedule_variable' do it 'creates pipeline_schedule_variable' do
expect do expect do
post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", developer), post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", developer),
params: params params: params.merge(variable_type: 'file')
end.to change { pipeline_schedule.variables.count }.by(1) end.to change { pipeline_schedule.variables.count }.by(1)
expect(response).to have_gitlab_http_status(:created) expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('pipeline_schedule_variable') expect(response).to match_response_schema('pipeline_schedule_variable')
expect(json_response['key']).to eq(params[:key]) expect(json_response['key']).to eq(params[:key])
expect(json_response['value']).to eq(params[:value]) expect(json_response['value']).to eq(params[:value])
expect(json_response['variable_type']).to eq('file')
end end
end end
...@@ -389,11 +391,12 @@ describe API::PipelineSchedules do ...@@ -389,11 +391,12 @@ describe API::PipelineSchedules do
context 'authenticated user with valid permissions' do context 'authenticated user with valid permissions' do
it 'updates pipeline_schedule_variable' do it 'updates pipeline_schedule_variable' do
put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", developer), put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", developer),
params: { value: 'updated_value' } params: { value: 'updated_value', variable_type: 'file' }
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('pipeline_schedule_variable') expect(response).to match_response_schema('pipeline_schedule_variable')
expect(json_response['value']).to eq('updated_value') expect(json_response['value']).to eq('updated_value')
expect(json_response['variable_type']).to eq('file')
end end
end end
......
...@@ -294,6 +294,7 @@ describe API::Pipelines do ...@@ -294,6 +294,7 @@ describe API::Pipelines do
expect(variable.key).to eq(expected_variable['key']) expect(variable.key).to eq(expected_variable['key'])
expect(variable.value).to eq(expected_variable['value']) expect(variable.value).to eq(expected_variable['value'])
expect(variable.variable_type).to eq(expected_variable['variable_type'])
end end
end end
...@@ -314,7 +315,7 @@ describe API::Pipelines do ...@@ -314,7 +315,7 @@ describe API::Pipelines do
end end
context 'variables given' do context 'variables given' do
let(:variables) { [{ 'key' => 'UPLOAD_TO_S3', 'value' => 'true' }] } let(:variables) { [{ 'variable_type' => 'file', 'key' => 'UPLOAD_TO_S3', 'value' => 'true' }] }
it 'creates and returns a new pipeline using the given variables' do it 'creates and returns a new pipeline using the given variables' do
expect do expect do
...@@ -330,7 +331,7 @@ describe API::Pipelines do ...@@ -330,7 +331,7 @@ describe API::Pipelines do
end end
describe 'using variables conditions' do describe 'using variables conditions' do
let(:variables) { [{ 'key' => 'STAGING', 'value' => 'true' }] } let(:variables) { [{ 'variable_type' => 'env_var', 'key' => 'STAGING', 'value' => 'true' }] }
before do before do
config = YAML.dump(test: { script: 'test', only: { variables: ['$STAGING'] } }) config = YAML.dump(test: { script: 'test', only: { variables: ['$STAGING'] } })
...@@ -467,7 +468,7 @@ describe API::Pipelines do ...@@ -467,7 +468,7 @@ describe API::Pipelines do
subject subject
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response).to contain_exactly({ "key" => "foo", "value" => "bar" }) expect(json_response).to contain_exactly({ "variable_type" => "env_var", "key" => "foo", "value" => "bar" })
end end
end end
end end
...@@ -488,7 +489,7 @@ describe API::Pipelines do ...@@ -488,7 +489,7 @@ describe API::Pipelines do
subject subject
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response).to contain_exactly({ "key" => "foo", "value" => "bar" }) expect(json_response).to contain_exactly({ "variable_type" => "env_var", "key" => "foo", "value" => "bar" })
end end
end end
......
...@@ -43,6 +43,7 @@ describe API::Variables do ...@@ -43,6 +43,7 @@ describe API::Variables do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response['value']).to eq(variable.value) expect(json_response['value']).to eq(variable.value)
expect(json_response['protected']).to eq(variable.protected?) expect(json_response['protected']).to eq(variable.protected?)
expect(json_response['variable_type']).to eq('env_var')
end end
it 'responds with 404 Not Found if requesting non-existing variable' do it 'responds with 404 Not Found if requesting non-existing variable' do
...@@ -80,17 +81,19 @@ describe API::Variables do ...@@ -80,17 +81,19 @@ describe API::Variables do
expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('PROTECTED_VALUE_2') expect(json_response['value']).to eq('PROTECTED_VALUE_2')
expect(json_response['protected']).to be_truthy expect(json_response['protected']).to be_truthy
expect(json_response['variable_type']).to eq('env_var')
end end
it 'creates variable with optional attributes' do it 'creates variable with optional attributes' do
expect do expect do
post api("/projects/#{project.id}/variables", user), params: { key: 'TEST_VARIABLE_2', value: 'VALUE_2' } post api("/projects/#{project.id}/variables", user), params: { variable_type: 'file', key: 'TEST_VARIABLE_2', value: 'VALUE_2' }
end.to change {project.variables.count}.by(1) end.to change {project.variables.count}.by(1)
expect(response).to have_gitlab_http_status(201) expect(response).to have_gitlab_http_status(201)
expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['key']).to eq('TEST_VARIABLE_2')
expect(json_response['value']).to eq('VALUE_2') expect(json_response['value']).to eq('VALUE_2')
expect(json_response['protected']).to be_falsey expect(json_response['protected']).to be_falsey
expect(json_response['variable_type']).to eq('file')
end end
it 'does not allow to duplicate variable key' do it 'does not allow to duplicate variable key' do
...@@ -125,7 +128,7 @@ describe API::Variables do ...@@ -125,7 +128,7 @@ describe API::Variables do
initial_variable = project.variables.reload.first initial_variable = project.variables.reload.first
value_before = initial_variable.value value_before = initial_variable.value
put api("/projects/#{project.id}/variables/#{variable.key}", user), params: { value: 'VALUE_1_UP', protected: true } put api("/projects/#{project.id}/variables/#{variable.key}", user), params: { variable_type: 'file', value: 'VALUE_1_UP', protected: true }
updated_variable = project.variables.reload.first updated_variable = project.variables.reload.first
...@@ -133,6 +136,7 @@ describe API::Variables do ...@@ -133,6 +136,7 @@ describe API::Variables do
expect(value_before).to eq(variable.value) expect(value_before).to eq(variable.value)
expect(updated_variable.value).to eq('VALUE_1_UP') expect(updated_variable.value).to eq('VALUE_1_UP')
expect(updated_variable).to be_protected expect(updated_variable).to be_protected
expect(updated_variable.variable_type).to eq('file')
end end
it 'responds with 404 Not Found if requesting non-existing variable' do it 'responds with 404 Not Found if requesting non-existing variable' do
......
...@@ -120,4 +120,16 @@ shared_examples 'PATCH #update updates variables' do ...@@ -120,4 +120,16 @@ shared_examples 'PATCH #update updates variables' do
expect(response).to match_response_schema('variables') expect(response).to match_response_schema('variables')
end end
end end
context 'for variables of type file' do
let(:variables_attributes) do
[
new_variable_attributes.merge(variable_type: 'file')
]
end
it 'creates new variable of type file' do
expect { subject }.to change { owner.variables.file.count }.by(1)
end
end
end end
# frozen_string_literal: true
shared_examples_for 'CI variable' do
it { is_expected.to include_module(HasVariable) }
describe "variable type" do
it 'defines variable types' do
expect(described_class.variable_types).to eq({ "env_var" => 1, "file" => 2 })
end
it "defaults variable type to env_var" do
expect(subject.variable_type).to eq("env_var")
end
it "supports variable type file" do
variable = described_class.new(variable_type: :file)
expect(variable).to be_file
end
end
it 'strips whitespaces when assigning key' do
subject.key = " SECRET "
expect(subject.key).to eq("SECRET")
end
it 'can convert to runner variable' do
expect(subject.to_runner_variable.keys).to include(:key, :value, :public, :file)
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