diff --git a/docs/source/variable/_index.md b/docs/source/variable/_index.md index 4b4910be44d04b8e1d3a66f2d7ed27c52c424690..efae5f64bd64822602f4a896a253da4246fd7aaf 100644 --- a/docs/source/variable/_index.md +++ b/docs/source/variable/_index.md @@ -35,6 +35,7 @@ var - [`delete`](delete.md) - [`export`](export.md) - [`get`](get.md) +- [`import`](import.md) - [`list`](list.md) - [`set`](set.md) - [`update`](update.md) diff --git a/docs/source/variable/import.md b/docs/source/variable/import.md new file mode 100644 index 0000000000000000000000000000000000000000..12e4754c85529ff27a04ea6b6904df71aa1bf471 --- /dev/null +++ b/docs/source/variable/import.md @@ -0,0 +1,79 @@ +--- +title: glab variable import +stage: Create +group: Code Review +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + + + +Import variables from JSON or STDIN into a project or group. + +```plaintext +glab variable import [flags] +``` + +## Aliases + +```plaintext +im +``` + +## Examples + +```console +# Example JSON file format (variables.json) +[ + { + "key": "DATABASE_URL", + "value": "postgres://user:password@host/db", + "protected": true, + "masked": false, + "environment_scope": "*", + "variable_type": "env_var", + "description": "Database connection string" + }, + { + "key": "API_KEY", + "value": "secret_key_here", + "masked": true, + "masked_and_hidden": true, + "protected": false, + "environment_scope": "production", + "variable_type": "env_var", + "description": "API key for production services" + } +] + +# Import variables from a JSON file into the current project +$ glab variable import --file variables.json + +# Import and update existing variables if they already exist +$ glab variable import --file vars.json --update + +# Import variables from standard input +$ cat variables.json | glab variable import --stdin + +# Import variables into a specific group or subgroup +$ glab variable import --group mygroup --file group_vars.json + +``` + +## Options + +```plaintext + -f, --file string Path to JSON file containing variables. + -g, --group string Select a group or subgroup. Ignored if a repository argument is set. + --stdin Read JSON from standard input. + --update Update existing variables instead of throwing an error. +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. + -R, --repo OWNER/REPO Select another repository. Can use either OWNER/REPO or `GROUP/NAMESPACE/REPO` format. Also accepts full URL or Git URL. +``` diff --git a/internal/commands/variable/import/import.go b/internal/commands/variable/import/import.go new file mode 100644 index 0000000000000000000000000000000000000000..89046d5df03121ba3f7fabe09dfeaba3e2b9e2ab --- /dev/null +++ b/internal/commands/variable/import/import.go @@ -0,0 +1,238 @@ +package importCmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/internal/iostreams" + "gitlab.com/gitlab-org/cli/internal/mcpannotations" +) + +type options struct { + apiClient func(repoHost string) (*api.Client, error) + io *iostreams.IOStreams + baseRepo func() (glrepo.Interface, error) + + group string + filePath string + fromStdin bool + update bool // true => update existing vars, false => error if exists + + variables []gitlab.ProjectVariable +} + +func NewCmdImport(f cmdutils.Factory, runE func(opts *options) error) *cobra.Command { + opts := &options{ + io: f.IO(), + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "import", + Short: "Import variables from JSON or STDIN into a project or group.", + Aliases: []string{"im"}, + Example: heredoc.Doc(` + # Example JSON file format (variables.json) + [ + { + "key": "DATABASE_URL", + "value": "postgres://user:password@host/db", + "protected": true, + "masked": false, + "environment_scope": "*", + "variable_type": "env_var", + "description": "Database connection string" + }, + { + "key": "API_KEY", + "value": "secret_key_here", + "masked": true, + "masked_and_hidden": true, + "protected": false, + "environment_scope": "production", + "variable_type": "env_var", + "description": "API key for production services" + } + ] + + # Import variables from a JSON file into the current project + $ glab variable import --file variables.json + + # Import and update existing variables if they already exist + $ glab variable import --file vars.json --update + + # Import variables from standard input + $ cat variables.json | glab variable import --stdin + + # Import variables into a specific group or subgroup + $ glab variable import --group mygroup --file group_vars.json + `), + Annotations: map[string]string{ + mcpannotations.Safe: "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.complete(); err != nil { + return err + } + return opts.run() + }, + } + + cmd.Flags().StringVarP(&opts.group, "group", "g", "", "Select a group or subgroup. Ignored if a repository argument is set.") + cmd.Flags().StringVarP(&opts.filePath, "file", "f", "", "Path to JSON file containing variables.") + cmd.Flags().BoolVar(&opts.fromStdin, "stdin", false, "Read JSON from standard input.") + cmd.Flags().BoolVar(&opts.update, "update", false, "Update existing variables instead of throwing an error.") + + cmd.MarkFlagsMutuallyExclusive("file", "stdin") + + return cmd +} + +func (o *options) complete() error { + var input []byte + var err error + + switch { + case o.filePath != "": + input, err = os.ReadFile(o.filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + case o.fromStdin: + input, err = io.ReadAll(o.io.In) + if err != nil { + return fmt.Errorf("failed to read from stdin: %w", err) + } + if len(input) == 0 { + return fmt.Errorf("failed to read from stdin: no data") + } + + default: + return fmt.Errorf("no input source provided: use --file or --stdin") + } + + if err := json.Unmarshal(input, &o.variables); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + return nil +} + +func (o *options) run() error { + var err error + + var repoHost string + if baseRepo, err := o.baseRepo(); err == nil { + repoHost = baseRepo.RepoHost() + } + apiClient, err := o.apiClient(repoHost) + if err != nil { + return err + } + client := apiClient.Lab() + + switch { + case o.group != "": + return o.importGroupVariables(client, o.variables) + + default: + repo, err := o.baseRepo() + if err != nil { + return err + } + return o.importProjectVariables(client, repo.FullName(), o.variables) + } +} + +func (o *options) importProjectVariables(client *gitlab.Client, project string, vars []gitlab.ProjectVariable) error { + for _, v := range vars { + _, resp, err := client.ProjectVariables.GetVariable(project, v.Key, nil) + if err == nil && resp.StatusCode == http.StatusOK { + if !o.update { + return fmt.Errorf("variable %q already exists. use --update if you wish to override it", v.Key) + } + _, _, err := client.ProjectVariables.UpdateVariable(project, v.Key, &gitlab.UpdateProjectVariableOptions{ + Value: gitlab.Ptr(v.Value), + Description: gitlab.Ptr(v.Description), + EnvironmentScope: gitlab.Ptr(v.EnvironmentScope), + Masked: gitlab.Ptr(v.Masked), + Protected: gitlab.Ptr(v.Protected), + Raw: gitlab.Ptr(v.Raw), + VariableType: gitlab.Ptr(v.VariableType), + }) + if err != nil { + return fmt.Errorf("failed to update variable %s: %w", v.Key, err) + } + fmt.Fprintf(o.io.StdOut, "Updated variable: %s\n", v.Key) + } else { + _, _, err := client.ProjectVariables.CreateVariable(project, &gitlab.CreateProjectVariableOptions{ + Key: gitlab.Ptr(v.Key), + Value: gitlab.Ptr(v.Value), + Description: gitlab.Ptr(v.Description), + EnvironmentScope: gitlab.Ptr(v.EnvironmentScope), + Masked: gitlab.Ptr(v.Masked), + MaskedAndHidden: gitlab.Ptr(v.Hidden), + Protected: gitlab.Ptr(v.Protected), + Raw: gitlab.Ptr(v.Raw), + VariableType: gitlab.Ptr(v.VariableType), + }) + if err != nil { + return fmt.Errorf("failed to create variable %s: %w", v.Key, err) + } + fmt.Fprintf(o.io.StdOut, "Created variable: %s\n", v.Key) + } + } + return nil +} + +func (o *options) importGroupVariables(client *gitlab.Client, vars []gitlab.ProjectVariable) error { + for _, v := range vars { + _, resp, err := client.GroupVariables.GetVariable(o.group, v.Key, nil) + if err == nil && resp.StatusCode == http.StatusOK { + if !o.update { + return fmt.Errorf("variable %q already exists", v.Key) + } + _, _, err := client.GroupVariables.UpdateVariable(o.group, v.Key, &gitlab.UpdateGroupVariableOptions{ + Value: gitlab.Ptr(v.Value), + Description: gitlab.Ptr(v.Description), + EnvironmentScope: gitlab.Ptr(v.EnvironmentScope), + Masked: gitlab.Ptr(v.Masked), + Protected: gitlab.Ptr(v.Protected), + Raw: gitlab.Ptr(v.Raw), + VariableType: gitlab.Ptr(v.VariableType), + }) + if err != nil { + return fmt.Errorf("failed to update variable %s: %w", v.Key, err) + } + fmt.Fprintf(o.io.StdOut, "Updated variable: %s\n", v.Key) + } else { + _, _, err := client.GroupVariables.CreateVariable(o.group, &gitlab.CreateGroupVariableOptions{ + Key: gitlab.Ptr(v.Key), + Value: gitlab.Ptr(v.Value), + Description: gitlab.Ptr(v.Description), + EnvironmentScope: gitlab.Ptr(v.EnvironmentScope), + Masked: gitlab.Ptr(v.Masked), + MaskedAndHidden: gitlab.Ptr(v.Hidden), + Protected: gitlab.Ptr(v.Protected), + VariableType: gitlab.Ptr(v.VariableType), + Raw: gitlab.Ptr(v.Raw), + }) + if err != nil { + return fmt.Errorf("failed to create variable %s: %w", v.Key, err) + } + fmt.Fprintf(o.io.StdOut, "Created variable: %s\n", v.Key) + } + } + return nil +} diff --git a/internal/commands/variable/import/import_test.go b/internal/commands/variable/import/import_test.go new file mode 100644 index 0000000000000000000000000000000000000000..08c42633b40e5d3605b7d35eacb4d913b2eb63f7 --- /dev/null +++ b/internal/commands/variable/import/import_test.go @@ -0,0 +1,188 @@ +package importCmd + +import ( + "encoding/json" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + gitlabtesting "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/glinstance" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "gitlab.com/gitlab-org/cli/internal/testing/httpmock" +) + +func Test_run_FileAndStdin(t *testing.T) { + t.Parallel() + io, _, _, _ := cmdtest.TestIOStreams() + f := cmdtest.NewTestFactory(io) + + tmpFile, err := os.CreateTemp(t.TempDir(), "vars.json") + assert.NoError(t, err) + t.Cleanup(func() { os.Remove(tmpFile.Name()) }) + + variables := []gitlabtesting.ProjectVariable{{Key: "VAR1", Value: "value1"}} + data, _ := json.Marshal(variables) + _, _ = tmpFile.Write(data) + tmpFile.Close() + + tests := []struct { + name string + opts *options + expectError string + }{ + { + name: "no input", + opts: &options{ + io: io, + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + }, + expectError: "no input source provided", + }, + { + name: "invalid file path", + opts: &options{ + io: io, + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + filePath: "missing.json", + }, + expectError: "failed to read file", + }, + { + name: "invalid stdin read", + opts: &options{ + io: io, + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + fromStdin: true, + }, + expectError: "failed to read from stdin: no data", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.opts.complete() + assert.ErrorContains(t, err, test.expectError) + }) + } +} + +func Test_importProjectVariables_CreateAndUpdate(t *testing.T) { + reg := &httpmock.Mocker{MatchURL: httpmock.FullURL} + defer reg.Verify(t) + + io, _, stdout, _ := cmdtest.TestIOStreams() + reg.RegisterResponder(http.MethodGet, "https://gitlab.com/api/v4/projects/owner%2Frepo/variables/VAR1", + httpmock.NewStringResponse(http.StatusNotFound, `{"code":"404","status":"Not Found"}`)) + + reg.RegisterResponder(http.MethodPost, "https://gitlab.com/api/v4/projects/owner%2Frepo/variables", + httpmock.NewStringResponse(http.StatusCreated, `{"key": "VAR1", "value": "value1"}`)) + + opts := &options{ + apiClient: func(repoHost string) (*api.Client, error) { + return cmdtest.NewTestApiClient(t, &http.Client{Transport: reg}, "", "gitlab.com"), nil + }, + baseRepo: func() (glrepo.Interface, error) { + return glrepo.FromFullName("owner/repo", glinstance.DefaultHostname) + }, + io: io, + } + + vars := []gitlabtesting.ProjectVariable{{Key: "VAR1", Value: "value1"}} + client, _ := opts.apiClient("gitlab.com") + + err := opts.importProjectVariables(client.Lab(), "owner/repo", vars) + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Created variable: VAR1") +} + +func Test_importProjectVariables_UpdateExisting(t *testing.T) { + reg := &httpmock.Mocker{MatchURL: httpmock.FullURL} + defer reg.Verify(t) + + io, _, stdout, _ := cmdtest.TestIOStreams() + + reg.RegisterResponder(http.MethodGet, "https://gitlab.com/api/v4/projects/owner%2Frepo/variables/VAR1", + httpmock.NewStringResponse(http.StatusOK, `{"key":"VAR1","value":"value1"}`)) + + reg.RegisterResponder(http.MethodPut, "https://gitlab.com/api/v4/projects/owner%2Frepo/variables/VAR1", + httpmock.NewStringResponse(http.StatusOK, `{"key":"VAR1","value":"value1"}`)) + + opts := &options{ + update: true, + apiClient: func(repoHost string) (*api.Client, error) { + return cmdtest.NewTestApiClient(t, &http.Client{Transport: reg}, "", "gitlab.com"), nil + }, + baseRepo: func() (glrepo.Interface, error) { + return glrepo.FromFullName("owner/repo", glinstance.DefaultHostname) + }, + io: io, + } + + client, _ := opts.apiClient("gitlab.com") + vars := []gitlabtesting.ProjectVariable{{Key: "VAR1", Value: "value1"}} + + err := opts.importProjectVariables(client.Lab(), "owner/repo", vars) + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Updated variable: VAR1") +} + +func Test_importGroupVariables_CreateAndUpdate(t *testing.T) { + reg := &httpmock.Mocker{MatchURL: httpmock.FullURL} + defer reg.Verify(t) + + io, _, stdout, _ := cmdtest.TestIOStreams() + + reg.RegisterResponder(http.MethodGet, "https://gitlab.com/api/v4/groups/group/variables/VAR1", + httpmock.NewStringResponse(http.StatusNotFound, `{"code":"404","status":"Not Found"}`)) + + reg.RegisterResponder(http.MethodPost, "https://gitlab.com/api/v4/groups/group/variables", + httpmock.NewStringResponse(http.StatusCreated, `{"key":"VAR1","value":"value1"}`)) + + opts := &options{ + group: "group", + apiClient: func(repoHost string) (*api.Client, error) { + return cmdtest.NewTestApiClient(t, &http.Client{Transport: reg}, "", "gitlab.com"), nil + }, + io: io, + } + + client, _ := opts.apiClient("gitlab.com") + vars := []gitlabtesting.ProjectVariable{{Key: "VAR1", Value: "value1"}} + + err := opts.importGroupVariables(client.Lab(), vars) + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Created variable: VAR1") +} + +func Test_importGroupVariables_Existing_NoUpdate(t *testing.T) { + reg := &httpmock.Mocker{MatchURL: httpmock.FullURL} + defer reg.Verify(t) + + io, _, _, _ := cmdtest.TestIOStreams() + + reg.RegisterResponder(http.MethodGet, + "https://gitlab.com/api/v4/groups/group/variables/VAR1", + httpmock.NewStringResponse(http.StatusOK, `{"key": "VAR1", "value": "oldvalue"}`)) + + opts := &options{ + group: "group", + apiClient: func(repoHost string) (*api.Client, error) { + return cmdtest.NewTestApiClient(t, &http.Client{Transport: reg}, "", "gitlab.com"), nil + }, + io: io, + } + + client, _ := opts.apiClient("gitlab.com") + vars := []gitlabtesting.ProjectVariable{{Key: "VAR1", Value: "value1"}} + + err := opts.importGroupVariables(client.Lab(), vars) + assert.ErrorContains(t, err, "variable \"VAR1\" already exists") +} diff --git a/internal/commands/variable/variable.go b/internal/commands/variable/variable.go index fa2d6646cd13c4f5822e43ad74d14e067ecbb9fc..4b29f707155db2c44d5f1b4b74bc8f2ea2f27157 100644 --- a/internal/commands/variable/variable.go +++ b/internal/commands/variable/variable.go @@ -6,6 +6,7 @@ import ( deleteCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/delete" exportCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/export" getCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/get" + importCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/import" listCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/list" setCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/set" updateCmd "gitlab.com/gitlab-org/cli/internal/commands/variable/update" @@ -26,5 +27,6 @@ func NewVariableCmd(f cmdutils.Factory) *cobra.Command { cmd.AddCommand(updateCmd.NewCmdUpdate(f, nil)) cmd.AddCommand(getCmd.NewCmdGet(f, nil)) cmd.AddCommand(exportCmd.NewCmdExport(f, nil)) + cmd.AddCommand(importCmd.NewCmdImport(f, nil)) return cmd }