Commit 302c5edb authored by Tomasz Maczukin's avatar Tomasz Maczukin 🌴

Merge branch 'steveazz/gitlab-runner-2211-add-option-to-change-clone-path' into 'master'

Add option to specify clone path

Closes #2211

See merge request gitlab-org/gitlab-runner!1267
parents 5140f2e7 6d124010
......@@ -237,6 +237,9 @@ func (s *RegisterCommand) askExecutorOptions() {
// old CLI options/env variables were used.
func (s *RegisterCommand) prepareCache() {
cache := s.RunnerConfig.Cache
if cache == nil {
return
}
// Called to log deprecated usage, if old cli options/env variables are used
cache.Path = cache.GetPath()
......
......@@ -156,15 +156,46 @@ func (b *Build) FullProjectDir() string {
return helpers.ToSlash(b.BuildDir)
}
func (b *Build) StartBuild(rootDir, cacheDir string, sharedDir bool) {
func (b *Build) TmpProjectDir() string {
return helpers.ToSlash(b.BuildDir) + ".tmp"
}
func (b *Build) getCustomBuildDir(rootDir, overrideKey string, customBuildDirEnabled, sharedDir bool) (string, error) {
dir := b.GetAllVariables().Get(overrideKey)
if dir == "" {
return path.Join(rootDir, b.ProjectUniqueDir(sharedDir)), nil
}
if !customBuildDirEnabled {
return "", MakeBuildError("setting %s is not allowed, enable `custom_build_dir` feature", overrideKey)
}
if !strings.HasPrefix(dir, rootDir) {
return "", MakeBuildError("the %s=%q has to be within %q",
overrideKey, dir, rootDir)
}
return dir, nil
}
func (b *Build) StartBuild(rootDir, cacheDir string, customBuildDirEnabled, sharedDir bool) error {
var err error
// We set RootDir and invalidate variables
// to be able to use CI_BUILDS_DIR
b.RootDir = rootDir
b.BuildDir = path.Join(rootDir, b.ProjectUniqueDir(sharedDir))
b.CacheDir = path.Join(cacheDir, b.ProjectUniqueDir(false))
b.refreshAllVariables()
// invalidate variables cache:
// as some variables are based on dynamic
// state after build starts
b.allVariables = nil
b.BuildDir, err = b.getCustomBuildDir(b.RootDir, "GIT_CLONE_PATH", customBuildDirEnabled, sharedDir)
if err != nil {
return err
}
// We invalidate variables to be able to use
// CI_CACHE_DIR and CI_PROJECT_DIR
b.refreshAllVariables()
return nil
}
func (b *Build) executeStage(ctx context.Context, buildStage BuildStage, executor Executor) error {
......@@ -532,6 +563,7 @@ func (b *Build) String() string {
func (b *Build) GetDefaultVariables() JobVariables {
return JobVariables{
{Key: "CI_BUILDS_DIR", Value: filepath.FromSlash(b.RootDir), Public: true, Internal: true, File: false},
{Key: "CI_PROJECT_DIR", Value: filepath.FromSlash(b.FullProjectDir()), Public: true, Internal: true, File: false},
{Key: "CI_CONCURRENT_ID", Value: strconv.Itoa(b.RunnerID), Public: true, Internal: true, File: false},
{Key: "CI_CONCURRENT_PROJECT_ID", Value: strconv.Itoa(b.ProjectRunnerID), Public: true, Internal: true, File: false},
......@@ -602,6 +634,10 @@ func (b *Build) IsSharedEnv() bool {
return b.ExecutorFeatures.Shared
}
func (b *Build) refreshAllVariables() {
b.allVariables = nil
}
func (b *Build) GetAllVariables() JobVariables {
if b.allVariables != nil {
return b.allVariables
......
......@@ -93,7 +93,7 @@ func TestBuildPredefinedVariables(t *testing.T) {
// We run everything once
e.On("Prepare", mock.Anything).
Return(func(options ExecutorPrepareOptions) error {
options.Build.StartBuild("/root/dir", "/cache/dir", false)
options.Build.StartBuild("/root/dir", "/cache/dir", false, false)
return nil
}).Once()
e.On("Finish", nil).Return().Once()
......@@ -901,6 +901,131 @@ func TestIsFeatureFlagOn(t *testing.T) {
}
}
func TestStartBuild(t *testing.T) {
type startBuildArgs struct {
rootDir string
cacheDir string
customBuildDirEnabled bool
sharedDir bool
}
tests := map[string]struct {
args startBuildArgs
jobVariables JobVariables
expectedBuildDir string
expectedCacheDir string
expectedError bool
}{
"no job specific build dir with no shared dir": {
args: startBuildArgs{
rootDir: "/build",
cacheDir: "/cache",
customBuildDirEnabled: true,
sharedDir: false,
},
jobVariables: JobVariables{},
expectedBuildDir: "/build/test-namespace/test-repo",
expectedCacheDir: "/cache/test-namespace/test-repo",
expectedError: false,
},
"no job specified build dir with shared dir": {
args: startBuildArgs{
rootDir: "/builds",
cacheDir: "/cache",
customBuildDirEnabled: true,
sharedDir: true,
},
jobVariables: JobVariables{},
expectedBuildDir: "/builds/1234/0/test-namespace/test-repo",
expectedCacheDir: "/cache/test-namespace/test-repo",
expectedError: false,
},
"valid GIT_CLONE_PATH was specified": {
args: startBuildArgs{
rootDir: "/builds",
cacheDir: "/cache",
customBuildDirEnabled: true,
sharedDir: false,
},
jobVariables: JobVariables{
{Key: "GIT_CLONE_PATH", Value: "/builds/go/src/gitlab.com/test-namespace/test-repo", Public: true},
},
expectedBuildDir: "/builds/go/src/gitlab.com/test-namespace/test-repo",
expectedCacheDir: "/cache/test-namespace/test-repo",
expectedError: false,
},
"valid GIT_CLONE_PATH using CI_BUILDS_DIR was specified": {
args: startBuildArgs{
rootDir: "/builds",
cacheDir: "/cache",
customBuildDirEnabled: true,
sharedDir: false,
},
jobVariables: JobVariables{
{Key: "GIT_CLONE_PATH", Value: "$CI_BUILDS_DIR/go/src/gitlab.com/test-namespace/test-repo", Public: true},
},
expectedBuildDir: "/builds/go/src/gitlab.com/test-namespace/test-repo",
expectedCacheDir: "/cache/test-namespace/test-repo",
expectedError: false,
},
"custom build disabled": {
args: startBuildArgs{
rootDir: "/builds",
cacheDir: "/cache",
customBuildDirEnabled: false,
sharedDir: false,
},
jobVariables: JobVariables{
{Key: "GIT_CLONE_PATH", Value: "/builds/go/src/gitlab.com/test-namespace/test-repo", Public: true},
},
expectedBuildDir: "/builds/test-namespace/test-repo",
expectedCacheDir: "/cache/test-namespace/test-repo",
expectedError: true,
},
"invalid GIT_CLONE_PATH was specified": {
args: startBuildArgs{
rootDir: "/builds",
cacheDir: "/cache",
customBuildDirEnabled: true,
sharedDir: false,
},
jobVariables: JobVariables{
{Key: "GIT_CLONE_PATH", Value: "/go/src/gitlab.com/test-namespace/test-repo", Public: true},
},
expectedError: true,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
build := Build{
JobResponse: JobResponse{
GitInfo: GitInfo{
RepoURL: "https://gitlab.com/test-namespace/test-repo.git",
},
Variables: test.jobVariables,
},
Runner: &RunnerConfig{
RunnerCredentials: RunnerCredentials{
Token: "1234",
},
},
}
err := build.StartBuild(test.args.rootDir, test.args.cacheDir, test.args.customBuildDirEnabled, test.args.sharedDir)
if test.expectedError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, test.expectedBuildDir, build.BuildDir)
assert.Equal(t, test.args.rootDir, build.RootDir)
assert.Equal(t, test.expectedCacheDir, build.CacheDir)
})
}
}
func TestWaitForTerminal(t *testing.T) {
cases := []struct {
name string
......@@ -1109,3 +1234,61 @@ func TestGitCleanFlags(t *testing.T) {
})
}
}
func TestDefaultVariables(t *testing.T) {
tests := []struct {
name string
jobVariables JobVariables
rootDir string
key string
expectedValue string
}{
{
name: "get default CI_SERVER value",
jobVariables: JobVariables{},
rootDir: "/builds",
key: "CI_SERVER",
expectedValue: "yes",
},
{
name: "get default CI_PROJECT_DIR value",
jobVariables: JobVariables{},
rootDir: "/builds",
key: "CI_PROJECT_DIR",
expectedValue: "/builds/test-namespace/test-repo",
},
{
name: "get overwritten CI_PROJECT_DIR value",
jobVariables: JobVariables{
{Key: "GIT_CLONE_PATH", Value: "/builds/go/src/gitlab.com/gitlab-org/gitlab-runner", Public: true},
},
rootDir: "/builds",
key: "CI_PROJECT_DIR",
expectedValue: "/builds/go/src/gitlab.com/gitlab-org/gitlab-runner",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
build := Build{
JobResponse: JobResponse{
GitInfo: GitInfo{
RepoURL: "https://gitlab.com/test-namespace/test-repo.git",
},
Variables: test.jobVariables,
},
Runner: &RunnerConfig{
RunnerCredentials: RunnerCredentials{
Token: "1234",
},
},
}
err := build.StartBuild(test.rootDir, "/cache", true, false)
assert.NoError(t, err)
variable := build.GetAllVariables().Get(test.key)
assert.Equal(t, test.expectedValue, variable)
})
}
}
......@@ -278,13 +278,14 @@ type RunnerSettings struct {
Shell string `toml:"shell,omitempty" json:"shell" long:"shell" env:"RUNNER_SHELL" description:"Select bash, cmd or powershell"`
SSH *ssh.Config `toml:"ssh,omitempty" json:"ssh" group:"ssh executor" namespace:"ssh"`
Docker *DockerConfig `toml:"docker,omitempty" json:"docker" group:"docker executor" namespace:"docker"`
Parallels *ParallelsConfig `toml:"parallels,omitempty" json:"parallels" group:"parallels executor" namespace:"parallels"`
VirtualBox *VirtualBoxConfig `toml:"virtualbox,omitempty" json:"virtualbox" group:"virtualbox executor" namespace:"virtualbox"`
Cache *CacheConfig `toml:"cache,omitempty" json:"cache" group:"cache configuration" namespace:"cache"`
Machine *DockerMachine `toml:"machine,omitempty" json:"machine" group:"docker machine provider" namespace:"machine"`
Kubernetes *KubernetesConfig `toml:"kubernetes,omitempty" json:"kubernetes" group:"kubernetes executor" namespace:"kubernetes"`
CustomBuildDir *CustomBuildDir `toml:"custom_build_dir,omitempty" json:"custom_build_dir" group:"custom build dir configuration" namespace:"custom_build_dir"`
SSH *ssh.Config `toml:"ssh,omitempty" json:"ssh" group:"ssh executor" namespace:"ssh"`
Docker *DockerConfig `toml:"docker,omitempty" json:"docker" group:"docker executor" namespace:"docker"`
Parallels *ParallelsConfig `toml:"parallels,omitempty" json:"parallels" group:"parallels executor" namespace:"parallels"`
VirtualBox *VirtualBoxConfig `toml:"virtualbox,omitempty" json:"virtualbox" group:"virtualbox executor" namespace:"virtualbox"`
Cache *CacheConfig `toml:"cache,omitempty" json:"cache" group:"cache configuration" namespace:"cache"`
Machine *DockerMachine `toml:"machine,omitempty" json:"machine" group:"docker machine provider" namespace:"machine"`
Kubernetes *KubernetesConfig `toml:"kubernetes,omitempty" json:"kubernetes" group:"kubernetes executor" namespace:"kubernetes"`
}
type RunnerConfig struct {
......@@ -321,6 +322,10 @@ type Config struct {
Loaded bool `toml:"-"`
}
type CustomBuildDir struct {
Enabled bool `toml:"enabled,omitempty" json:"enabled" long:"enabled" env:"CUSTOM_BUILD_DIR_ENABLED" description:"Enable job specific build directories"`
}
func getDeprecatedStringSetting(setting string, tomlField string, envVariable string, tomlReplacement string, envReplacement string) string {
if setting != "" {
logrus.Warningf("%s setting is deprecated and will be removed in GitLab Runner 12.0. Please use %s instead", tomlField, tomlReplacement)
......
......@@ -66,6 +66,12 @@ func (b *BuildError) Error() string {
return b.Inner.Error()
}
func MakeBuildError(format string, args ...interface{}) error {
return &BuildError{
Inner: fmt.Errorf(format, args...),
}
}
var executors map[string]ExecutorProvider
func validateExecutorProvider(provider ExecutorProvider) error {
......
......@@ -743,6 +743,38 @@ which is based on its compilation data. After updating the Runner to a new versi
Runner will try to download the proper image. This of course means that the image should be uploaded to the registry
before upgrading the Runner, otherwise the jobs will start failing with a "No such image" error.
## The `[runners.custom_build_dir]` section
NOTE: **Note:**
[Introduced][https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1267] in Gitlab Runner 11.10
This section defines [custom build directories][https://docs.gitlab.com/ee/ci/yaml/README.html#custom-build-directories] parameters.
Please notice, that the feature - if not configured explicitly - will be
enabled by default for `kubernetes`, `docker`, `docker-ssh`, `docker+machine`
and `docker-ssh+machine` executors. It will be disabled by default for all other
executors.
This feature requires that `GIT_CLONE_PATH` is within a path defined
within `runners.builds_dir`. For the ease of using `builds_dir` the
`$CI_BUILDS_DIR` variable can be used.
The feature is by default enabled only for `docker` and `kubernetes` executors
as they provide a good way to separate resources. This feature can be
explicitly enabled for any executor, but special care should be taken when using
with executors that share `builds_dir` and have `concurrent > 1`.
| Parameter | Type | Description |
|-----------|---------|-------------|
| `enabled` | boolean | Allow user to define a custom build directory for a job |
Example:
```bash
[runners.custom_build_dir]
enabled = true
```
## Note
If you'd like to deploy to multiple servers using GitLab CI, you can create a
......
......@@ -22,6 +22,10 @@ To override the `~/builds` directory, specify the `builds_dir` option under
the `[[runners]]` section in
[`config.toml`](../configuration/advanced-configuration.md).
You can also define [custom build
directories](https://docs.gitlab.com/ce/ci/yaml/README.html#custom-build-directories) per job using the
`GIT_CLONE_PATH`.
## Create a new base virtual machine
1. Install [VirtualBox](https://www.virtualbox.org) and if running from Windows,
......
......@@ -509,7 +509,7 @@ func (e *executor) createBuildVolume() error {
// Cache Git sources:
// use a `BuildsDir`
if !path.IsAbs(e.Build.RootDir) || e.Build.RootDir == "/" {
return errors.New("build directory needs to be absolute and non-root path")
return common.MakeBuildError("build directory needs to be absolute and non-root path")
}
if e.isHostMountedVolume(e.Build.RootDir, e.Config.Docker.Volumes...) {
......
......@@ -103,9 +103,10 @@ func (s *commandExecutor) Run(cmd common.ExecutorCommand) error {
func init() {
options := executors.ExecutorOptions{
DefaultBuildsDir: "/builds",
DefaultCacheDir: "/cache",
SharedBuildsDir: false,
DefaultCustomBuildsDirEnabled: true,
DefaultBuildsDir: "/builds",
DefaultCacheDir: "/cache",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.NormalShell,
......
......@@ -45,6 +45,53 @@ func TestDockerCommandSuccessRun(t *testing.T) {
assert.NoError(t, err)
}
func TestDockerCommandUsingCustomClonePath(t *testing.T) {
if helpers.SkipIntegrationTests(t, "docker", "info") {
return
}
jobResponse, err := common.GetRemoteBuildResponse(
"ls -al $CI_BUILDS_DIR/go/src/gitlab.com/gitlab-org/repo")
require.NoError(t, err)
tests := map[string]struct {
clonePath string
expectedErrorType interface{}
}{
"uses custom clone path": {
clonePath: "$CI_BUILDS_DIR/go/src/gitlab.com/gitlab-org/repo",
expectedErrorType: nil,
},
"path has to be within CI_BUILDS_DIR": {
clonePath: "/unknown/go/src/gitlab.com/gitlab-org/repo",
expectedErrorType: &common.BuildError{},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
build := &common.Build{
JobResponse: jobResponse,
Runner: &common.RunnerConfig{
RunnerSettings: common.RunnerSettings{
Executor: "docker",
Docker: &common.DockerConfig{
Image: common.TestAlpineImage,
PullPolicy: common.PullPolicyIfNotPresent,
},
Environment: []string{
"GIT_CLONE_PATH=" + test.clonePath,
},
},
},
}
err = build.Run(&common.Config{}, &common.Trace{Writer: os.Stdout})
assert.IsType(t, test.expectedErrorType, err)
})
}
}
func TestDockerCommandNoRootImage(t *testing.T) {
if helpers.SkipIntegrationTests(t, "docker", "info") {
return
......
......@@ -83,8 +83,9 @@ func (s *sshExecutor) Cleanup() {
func init() {
options := executors.ExecutorOptions{
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
DefaultCustomBuildsDirEnabled: true,
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.LoginShell,
......
......@@ -8,11 +8,12 @@ import (
)
type ExecutorOptions struct {
DefaultBuildsDir string
DefaultCacheDir string
SharedBuildsDir bool
Shell common.ShellScriptInfo
ShowHostname bool
DefaultCustomBuildsDirEnabled bool
DefaultBuildsDir string
DefaultCacheDir string
SharedBuildsDir bool
Shell common.ShellScriptInfo
ShowHostname bool
}
type AbstractExecutor struct {
......@@ -64,8 +65,13 @@ func (e *AbstractExecutor) startBuild() error {
if cacheDir == "" {
cacheDir = e.DefaultCacheDir
}
e.Build.StartBuild(rootDir, cacheDir, e.SharedBuildsDir)
return nil
customBuildDirEnabled := e.DefaultCustomBuildsDirEnabled
if e.Config.CustomBuildDir != nil {
customBuildDirEnabled = e.Config.CustomBuildDir.Enabled
}
return e.Build.StartBuild(rootDir, cacheDir,
customBuildDirEnabled, e.SharedBuildsDir)
}
func (e *AbstractExecutor) Shell() *common.ShellScriptInfo {
......
......@@ -26,9 +26,10 @@ import (
var (
executorOptions = executors.ExecutorOptions{
DefaultBuildsDir: "/builds",
DefaultCacheDir: "/cache",
SharedBuildsDir: false,
DefaultCustomBuildsDirEnabled: true,
DefaultBuildsDir: "/builds",
DefaultCacheDir: "/cache",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.NormalShell,
......
......@@ -1543,6 +1543,7 @@ func TestKubernetesNoRootImage(t *testing.T) {
RunnerSettings: common.RunnerSettings{
Executor: "kubernetes",
Kubernetes: &common.KubernetesConfig{
Image: common.TestAlpineImage,
PullPolicy: common.PullPolicyIfNotPresent,
},
},
......@@ -1553,6 +1554,52 @@ func TestKubernetesNoRootImage(t *testing.T) {
assert.NoError(t, err)
}
func TestKubernetesCustomClonePath(t *testing.T) {
if helpers.SkipIntegrationTests(t, "kubectl", "cluster-info") {
return
}
jobResponse, err := common.GetRemoteBuildResponse(
"ls -al $CI_BUILDS_DIR/go/src/gitlab.com/gitlab-org/repo")
require.NoError(t, err)
tests := map[string]struct {
clonePath string
expectedErrorType interface{}
}{
"uses custom clone path": {
clonePath: "$CI_BUILDS_DIR/go/src/gitlab.com/gitlab-org/repo",
expectedErrorType: nil,
},
"path has to be within CI_BUILDS_DIR": {
clonePath: "/unknown/go/src/gitlab.com/gitlab-org/repo",
expectedErrorType: &common.BuildError{},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
build := &common.Build{
JobResponse: jobResponse,
Runner: &common.RunnerConfig{
RunnerSettings: common.RunnerSettings{
Executor: "kubernetes",
Kubernetes: &common.KubernetesConfig{
Image: common.TestAlpineImage,
PullPolicy: common.PullPolicyIfNotPresent,
},
Environment: []string{
"GIT_CLONE_PATH=" + test.clonePath,
},
},
},
}
err = build.Run(&common.Config{}, &common.Trace{Writer: os.Stdout})
assert.IsType(t, test.expectedErrorType, err)
})
}
}
func TestKubernetesBuildFail(t *testing.T) {
if helpers.SkipIntegrationTests(t, "kubectl", "cluster-info") {
return
......
......@@ -337,8 +337,9 @@ func (s *executor) Cleanup() {
func init() {
options := executors.ExecutorOptions{
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
DefaultCustomBuildsDirEnabled: false,
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.LoginShell,
......
......@@ -135,9 +135,10 @@ func init() {
}
options := executors.ExecutorOptions{
DefaultBuildsDir: "$PWD/builds",
DefaultCacheDir: "$PWD/cache",
SharedBuildsDir: true,
DefaultCustomBuildsDirEnabled: false,
DefaultBuildsDir: "$PWD/builds",
DefaultCacheDir: "$PWD/cache",
SharedBuildsDir: true,
Shell: common.ShellScriptInfo{
Shell: common.GetDefaultShell(),
Type: common.LoginShell,
......
......@@ -64,8 +64,9 @@ func (s *executor) Cleanup() {
func init() {
options := executors.ExecutorOptions{
DefaultBuildsDir: "builds",
SharedBuildsDir: true,
DefaultCustomBuildsDirEnabled: false,
DefaultBuildsDir: "builds",
SharedBuildsDir: true,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.LoginShell,
......
......@@ -299,8 +299,9 @@ func (s *executor) Cleanup() {
func init() {
options := executors.ExecutorOptions{
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
DefaultCustomBuildsDirEnabled: false,
DefaultBuildsDir: "builds",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.LoginShell,
......
......@@ -241,7 +241,7 @@ func (b *BashShell) GetConfiguration(info common.ShellScriptInfo) (script *commo
func (b *BashShell) GenerateScript(buildStage common.BuildStage, info common.ShellScriptInfo) (script string, err error) {
w := &BashWriter{
TemporaryPath: info.Build.FullProjectDir() + ".tmp",
TemporaryPath: info.Build.TmpProjectDir(),
}
if buildStage == common.BuildStagePrepare {
......
......@@ -255,7 +255,7 @@ func (b *CmdShell) GetConfiguration(info common.ShellScriptInfo) (script *common
func (b *CmdShell) GenerateScript(buildStage common.BuildStage, info common.ShellScriptInfo) (script string, err error) {
w := &CmdWriter{
TemporaryPath: info.Build.FullProjectDir() + ".tmp",
TemporaryPath: info.Build.TmpProjectDir(),
disableDelayedErrorLevelExpansion: info.Build.IsFeatureFlagOn("FF_CMD_DISABLE_DELAYED_ERROR_LEVEL_EXPANSION"),
}
......
......@@ -251,7 +251,7 @@ func (b *PowerShell) GetConfiguration(info common.ShellScriptInfo) (script *comm
func (b *PowerShell) GenerateScript(buildStage common.BuildStage, info common.ShellScriptInfo) (script string, err error) {
w := &PsWriter{
TemporaryPath: info.Build.FullProjectDir() + ".tmp",
TemporaryPath: info.Build.TmpProjectDir(),
}
if buildStage == common.BuildStagePrepare {
......
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