diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 8c7fd3aa..4518ca25 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -935,10 +935,24 @@ Aliases service-endpoints, serviceendpoints, se ``` -### `azdo service-endpoint create` +### `azdo service-endpoint create [ORGANIZATION/]PROJECT --from-file [flags]` Create service connections +``` +-e, --encoding string File encoding (utf-8, ascii, utf-16be, utf-16le). (default "utf-8") +-f, --from-file string Path to the JSON service endpoint definition or '-' for stdin. +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +import +``` + #### `azdo service-endpoint create azurerm [ORGANIZATION/]PROJECT --name --authentication-scheme [flags]` Create an Azure Resource Manager service connection diff --git a/docs/azdo_service-endpoint_create.md b/docs/azdo_service-endpoint_create.md index 6b3d8b57..5a70ee98 100644 --- a/docs/azdo_service-endpoint_create.md +++ b/docs/azdo_service-endpoint_create.md @@ -1,11 +1,63 @@ ## Command `azdo service-endpoint create` -Create service connections +``` +azdo service-endpoint create [ORGANIZATION/]PROJECT --from-file [flags] +``` + +Create Azure DevOps service endpoints (service connections) from a JSON definition file. + +The project scope accepts the form [ORGANIZATION/]PROJECT. When the organization segment +is omitted the default organization from configuration is used. + +Check the available subcommands to create service connections of specific well-known types. + ### Available commands * [azdo service-endpoint create azurerm](./azdo_service-endpoint_create_azurerm.md) +### Options + + +* `-e`, `--encoding` `string` (default `"utf-8"`) + + File encoding (utf-8, ascii, utf-16be, utf-16le). + +* `-f`, `--from-file` `string` + + Path to the JSON service endpoint definition or '-' for stdin. + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `import` + +### JSON Fields + +`administratorsGroup`, `authorization`, `createdBy`, `data`, `description`, `groupScopeId`, `id`, `isReady`, `isShared`, `name`, `operationStatus`, `owner`, `readersGroup`, `serviceEndpointProjectReferences`, `type`, `url` + +### Examples + +```bash +# Create a service endpoint from a UTF-8 JSON file +azdo service-endpoint create my-org/my-project --from-file ./endpoint.json + +# Read the definition from stdin using UTF-16LE encoding +cat endpoint.json | azdo service-endpoint create my-org/my-project --from-file - --encoding utf-16le +``` + ### See also * [azdo service-endpoint](./azdo_service-endpoint.md) diff --git a/docs/azdo_service-endpoint_create_azurerm.md b/docs/azdo_service-endpoint_create_azurerm.md index d4ad30da..ba547527 100644 --- a/docs/azdo_service-endpoint_create_azurerm.md +++ b/docs/azdo_service-endpoint_create_azurerm.md @@ -99,7 +99,7 @@ This command is modeled after the Azure DevOps Terraform Provider's implementati ### JSON Fields -`authorization`, `description`, `id`, `name`, `type`, `url` +`administratorsGroup`, `authorization`, `createdBy`, `data`, `description`, `groupScopeId`, `id`, `isReady`, `isShared`, `name`, `operationStatus`, `owner`, `readersGroup`, `serviceEndpointProjectReferences`, `type`, `url` ### Examples diff --git a/internal/cmd/serviceendpoint/create/azurerm/create.go b/internal/cmd/serviceendpoint/create/azurerm/create.go index b5420d46..009d42c5 100644 --- a/internal/cmd/serviceendpoint/create/azurerm/create.go +++ b/internal/cmd/serviceendpoint/create/azurerm/create.go @@ -7,7 +7,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/google/uuid" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" "github.com/spf13/cobra" "go.uber.org/zap" @@ -182,7 +181,24 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompts") cmd.Flags().BoolVar(&opts.grantPermissionToAllPipelines, "grant-permission-to-all-pipelines", false, "Grant access permission to all pipelines to use the service connection") - util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "name", "type", "url", "description", "authorization"}) + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "administratorsGroup", + "authorization", + "createdBy", + "data", + "description", + "groupScopeId", + "id", + "isReady", + "isShared", + "name", + "operationStatus", + "owner", + "readersGroup", + "serviceEndpointProjectReferences", + "type", + "url", + }) _ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("authentication-scheme") @@ -220,7 +236,7 @@ func runCreate(ctx util.CmdContext, opts *createOptions) error { } } - projectRef, err := resolveProjectReference(ctx, scope) + projectRef, err := shared.ResolveProjectReference(ctx, scope) if err != nil { return util.FlagErrorWrap(err) } @@ -467,25 +483,3 @@ func getEndpointURL(opts *createOptions) (string, error) { return "", fmt.Errorf("unknown environment: %s", opts.environment) } } - -func resolveProjectReference(ctx util.CmdContext, scope *util.Scope) (*serviceendpoint.ProjectReference, error) { - coreClient, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) - if err != nil { - return nil, fmt.Errorf("failed to create core client: %w", err) - } - - project, err := coreClient.GetProject(ctx.Context(), core.GetProjectArgs{ - ProjectId: types.ToPtr(scope.Project), - }) - if err != nil { - return nil, fmt.Errorf("failed to resolve project %q: %w", scope.Project, err) - } - if project == nil || project.Id == nil { - return nil, fmt.Errorf("project %q does not expose an ID", scope.Project) - } - - return &serviceendpoint.ProjectReference{ - Id: project.Id, - Name: project.Name, - }, nil -} diff --git a/internal/cmd/serviceendpoint/create/create.go b/internal/cmd/serviceendpoint/create/create.go index 6401d083..769904c9 100644 --- a/internal/cmd/serviceendpoint/create/create.go +++ b/internal/cmd/serviceendpoint/create/create.go @@ -1,18 +1,310 @@ package create import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "strings" + u "unicode" + "unicode/utf8" + + "github.com/MakeNowJust/heredoc" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" "github.com/spf13/cobra" + "go.uber.org/zap" + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/unicode" + "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/create/azurerm" + "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" ) +type fromFileOptions struct { + scope string + fromFile string + encoding string + exporter util.Exporter +} + +var encodingAliases = map[string]string{ + "utf-8": "utf-8", + "utf8": "utf-8", + "ascii": "ascii", + "utf-16be": "utf-16be", + "utf16be": "utf-16be", + "utf-16le": "utf-16le", + "utf16le": "utf-16le", +} + func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &fromFileOptions{ + encoding: "utf-8", + } + cmd := &cobra.Command{ - Use: "create", + Use: "create [ORGANIZATION/]PROJECT --from-file [flags]", Short: "Create service connections", + Long: heredoc.Doc(` + Create Azure DevOps service endpoints (service connections) from a JSON definition file. + + The project scope accepts the form [ORGANIZATION/]PROJECT. When the organization segment + is omitted the default organization from configuration is used. + + Check the available subcommands to create service connections of specific well-known types. + `), + Example: heredoc.Doc(` + # Create a service endpoint from a UTF-8 JSON file + azdo service-endpoint create my-org/my-project --from-file ./endpoint.json + + # Read the definition from stdin using UTF-16LE encoding + cat endpoint.json | azdo service-endpoint create my-org/my-project --from-file - --encoding utf-16le + `), + Aliases: []string{ + "import", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scope = args[0] + return runCreateFromFile(ctx, opts) + }, } + cmd.Flags().StringVarP(&opts.fromFile, "from-file", "f", "", "Path to the JSON service endpoint definition or '-' for stdin.") + cmd.Flags().StringVarP(&opts.encoding, "encoding", "e", opts.encoding, "File encoding (utf-8, ascii, utf-16be, utf-16le).") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "administratorsGroup", + "authorization", + "createdBy", + "data", + "description", + "groupScopeId", + "id", + "isReady", + "isShared", + "name", + "operationStatus", + "owner", + "readersGroup", + "serviceEndpointProjectReferences", + "type", + "url", + }) + + _ = cmd.MarkFlagRequired("from-file") + cmd.AddCommand(azurerm.NewCmd(ctx)) return cmd } + +func runCreateFromFile(ctx util.CmdContext, opts *fromFileOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + scope, err := util.ParseProjectScope(ctx, opts.scope) + if err != nil { + return util.FlagErrorWrap(err) + } + + encodingValue, err := normalizeEncoding(opts.encoding) + if err != nil { + return util.FlagErrorWrap(err) + } + opts.encoding = encodingValue + + projectRef, err := shared.ResolveProjectReference(ctx, scope) + if err != nil { + return err + } + + zap.L().Debug("Creating service endpoint from file", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("input", describeInput(opts.fromFile)), + zap.String("encoding", opts.encoding), + ) + + content, err := util.ReadFile(opts.fromFile, ios.In) + if err != nil { + return fmt.Errorf("failed to read %s: %w", describeInput(opts.fromFile), err) + } + + decoded, err := decodeContent(content, opts.encoding) + if err != nil { + return util.FlagErrorWrap(err) + } + + var endpoint serviceendpoint.ServiceEndpoint + if err := json.Unmarshal(decoded, &endpoint); err != nil { + return util.FlagErrorf("failed to parse service endpoint JSON: %w", err) + } + + if err := validateEndpointPayload(&endpoint); err != nil { + return util.FlagErrorWrap(err) + } + + if projectRef != nil { + refs := []serviceendpoint.ServiceEndpointProjectReference{ + { + ProjectReference: projectRef, + Name: endpoint.Name, + Description: endpoint.Description, + }, + } + endpoint.ServiceEndpointProjectReferences = &refs + } + + client, err := ctx.ClientFactory().ServiceEndpoint(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create service endpoint client: %w", err) + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + createdEndpoint, err := client.CreateServiceEndpoint(ctx.Context(), serviceendpoint.CreateServiceEndpointArgs{ + Endpoint: &endpoint, + }) + if err != nil { + return fmt.Errorf("failed to create service endpoint: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, createdEndpoint) + } + + tp, err := ctx.Printer("list") + if err != nil { + return err + } + tp.AddColumns("ID", "Name", "Type", "URL") + tp.EndRow() + tp.AddField(types.GetValue(createdEndpoint.Id, uuid.Nil).String()) + tp.AddField(types.GetValue(createdEndpoint.Name, "")) + tp.AddField(types.GetValue(createdEndpoint.Type, "")) + tp.AddField(types.GetValue(createdEndpoint.Url, "")) + tp.EndRow() + tp.Render() + + return nil +} + +func normalizeEncoding(value string) (string, error) { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "utf-8", nil + } + if normalized, ok := encodingAliases[trimmed]; ok { + return normalized, nil + } + return "", fmt.Errorf("unsupported encoding %q; supported values: utf-8, ascii, utf-16be, utf-16le", value) +} + +func describeInput(path string) string { + if path == "-" { + return "stdin" + } + return path +} + +func decodeContent(raw []byte, encodingName string) ([]byte, error) { + var dec *encoding.Decoder + switch encodingName { + case "utf-8": + if !utf8.Valid(raw) { + return nil, fmt.Errorf("input is not valid UTF-8") + } + return raw, nil + case "ascii": + for i, b := range raw { + if b > u.MaxASCII { + return nil, fmt.Errorf("input contains non-ASCII byte at offset %d", i) + } + } + return raw, nil + case "utf-16le": + if err := validateUTF16(raw, binary.LittleEndian); err != nil { + return nil, err + } + dec = unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder() + case "utf-16be": + if err := validateUTF16(raw, binary.BigEndian); err != nil { + return nil, err + } + dec = unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewDecoder() + default: + return nil, fmt.Errorf("unsupported encoding %q", encodingName) + } + raw, err := dec.Bytes(raw) + if err != nil { + return nil, fmt.Errorf("failed to decode input: %w", err) + } + return raw, nil +} + +func validateEndpointPayload(endpoint *serviceendpoint.ServiceEndpoint) error { + if endpoint == nil { + return errors.New("service endpoint payload is empty") + } + name := strings.TrimSpace(types.GetValue(endpoint.Name, "")) + if name == "" { + return fmt.Errorf("service endpoint JSON missing 'name'") + } + typeValue := strings.TrimSpace(types.GetValue(endpoint.Type, "")) + if typeValue == "" { + return fmt.Errorf("service endpoint JSON missing 'type'") + } + urlValue := strings.TrimSpace(types.GetValue(endpoint.Url, "")) + if urlValue == "" { + return fmt.Errorf("service endpoint JSON missing 'url'") + } + endpoint.Name = types.ToPtr(name) + endpoint.Type = types.ToPtr(typeValue) + endpoint.Url = types.ToPtr(urlValue) + endpoint.Id = nil + return nil +} + +func validateUTF16(raw []byte, order binary.ByteOrder) error { + if len(raw)%2 != 0 { + return fmt.Errorf("failed to decode input: invalid UTF-16 sequence length") + } + if len(raw) == 0 { + return nil + } + units := make([]uint16, len(raw)/2) + for i := range units { + units[i] = order.Uint16(raw[2*i:]) + } + start := 0 + if units[0] == 0xFEFF { + start = 1 + } + for i := start; i < len(units); { + val := units[i] + switch { + case val >= 0xD800 && val <= 0xDBFF: + if i+1 >= len(units) { + return fmt.Errorf("failed to decode input: invalid UTF-16 surrogate pair") + } + next := units[i+1] + if next < 0xDC00 || next > 0xDFFF { + return fmt.Errorf("failed to decode input: invalid UTF-16 surrogate pair") + } + i += 2 + case val >= 0xDC00 && val <= 0xDFFF: + return fmt.Errorf("failed to decode input: invalid UTF-16 surrogate pair") + default: + i++ + } + } + return nil +} diff --git a/internal/cmd/serviceendpoint/create/create_test.go b/internal/cmd/serviceendpoint/create/create_test.go new file mode 100644 index 00000000..92ad7168 --- /dev/null +++ b/internal/cmd/serviceendpoint/create/create_test.go @@ -0,0 +1,207 @@ +package create + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeContent(t *testing.T) { + t.Run("UTF-8 encoding", func(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input []byte + expected string + }{ + { + name: "Valid UTF-8 with Unicode", + input: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0xE4, 0xB8, 0x96, 0xE7, 0x95, 0x8C, 0x21}, // "Hello, 世界!" + expected: "Hello, 世界!", + }, + { + name: "Empty input", + input: []byte(""), + expected: "", + }, + { + name: "ASCII only", + input: []byte("Hello, World!"), + expected: "Hello, World!", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + decoded, err := decodeContent(tc.input, "utf-8") + require.NoError(t, err) + require.Equal(t, tc.expected, string(decoded)) + }) + } + }) + + t.Run("ASCII encoding", func(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input []byte + expected string + errMsg string + }{ + { + name: "Valid ASCII", + input: []byte("Hello, World!"), + expected: "Hello, World!", + }, + { + name: "Empty input", + input: []byte(""), + expected: "", + }, + { + name: "Non-ASCII byte", + input: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0xE4, 0xB8, 0x96}, // "Hello, 世" + errMsg: "input contains non-ASCII byte at offset 7", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + decoded, err := decodeContent(tc.input, "ascii") + if tc.errMsg != "" { + require.ErrorContains(t, err, tc.errMsg) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, string(decoded)) + }) + } + }) + + t.Run("UTF-16LE encoding", func(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input []byte + expected string + errMsg string + }{ + { + name: "Valid UTF-16LE with BOM", + input: []byte{0xFF, 0xFE, 'H', 0x00, 'e', 0x00, 'l', 0x00, 'l', 0x00, 'o', 0x00, ',', 0x00, ' ', 0x00, 0x16, 0x4E, 0x4C, 0x75, '!', 0x00}, + expected: "Hello, 世界!", + }, + { + name: "Valid UTF-16LE without BOM", + input: []byte{'H', 0x00, 'e', 0x00, 'l', 0x00, 'l', 0x00, 'o', 0x00}, + expected: "Hello", + }, + { + name: "Empty input", + input: []byte(""), + expected: "", + }, + { + name: "Invalid UTF-16LE", + input: []byte{0xFF, 0xFE, 0x00, 0xDC}, // lone low surrogate + errMsg: "failed to decode input", + }, + { + name: "Odd length UTF-16LE", + input: []byte{0xFF, 0xFE, 'H'}, + errMsg: "failed to decode input", + }, + { + name: "High surrogate without pair", + input: []byte{0xFF, 0xFE, 0x00, 0xD8, '!', 0x00}, + errMsg: "failed to decode input", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + decoded, err := decodeContent(tc.input, "utf-16le") + if tc.errMsg != "" { + require.ErrorContains(t, err, tc.errMsg) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, string(decoded)) + }) + } + }) + + t.Run("UTF-16BE encoding", func(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input []byte + expected string + errMsg string + }{ + { + name: "Valid UTF-16BE with BOM", + input: []byte{0xFE, 0xFF, 0x00, 'H', 0x00, 'e', 0x00, 'l', 0x00, 'l', 0x00, 'o', 0x00, ',', 0x00, ' ', 0x4E, 0x16, 0x75, 0x4C, 0x00, '!'}, + expected: "Hello, 世界!", + }, + { + name: "Valid UTF-16BE without BOM", + input: []byte{0x00, 'H', 0x00, 'e', 0x00, 'l', 0x00, 'l', 0x00, 'o'}, + expected: "Hello", + }, + { + name: "Empty input", + input: []byte(""), + expected: "", + }, + { + name: "Invalid UTF-16BE", + input: []byte{0xFE, 0xFF, 0xDC, 0x00}, // lone low surrogate + errMsg: "failed to decode input", + }, + { + name: "Odd length UTF-16BE", + input: []byte{0xFE, 0xFF, 0x00}, + errMsg: "failed to decode input", + }, + { + name: "High surrogate without pair", + input: []byte{0xFE, 0xFF, 0xD8, 0x00, 0x00, '!'}, + errMsg: "failed to decode input", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + decoded, err := decodeContent(tc.input, "utf-16be") + if tc.errMsg != "" { + require.ErrorContains(t, err, tc.errMsg) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, string(decoded)) + }) + } + }) + + t.Run("Invalid encoding", func(t *testing.T) { + t.Parallel() + _, err := decodeContent([]byte("test"), "invalid-encoding") + require.ErrorContains(t, err, "unsupported encoding \"invalid-encoding\"") + }) + + t.Run("Invalid UTF-8", func(t *testing.T) { + t.Parallel() + invalidUTF8 := []byte{0xff, 0xfe, 0xfd} // Invalid UTF-8 sequence + _, err := decodeContent(invalidUTF8, "utf-8") + require.ErrorContains(t, err, "input is not valid UTF-8") + }) +} diff --git a/internal/cmd/serviceendpoint/shared/projects.go b/internal/cmd/serviceendpoint/shared/projects.go new file mode 100644 index 00000000..3b9eda90 --- /dev/null +++ b/internal/cmd/serviceendpoint/shared/projects.go @@ -0,0 +1,43 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +// ResolveProjectReference fetches the project metadata required to attach service endpoints to a project. +// It returns a ProjectReference that includes the stable storage key (ID) and display name. +func ResolveProjectReference(ctx util.CmdContext, scope *util.Scope) (*serviceendpoint.ProjectReference, error) { + if scope == nil { + return nil, fmt.Errorf("scope is required") + } + if strings.TrimSpace(scope.Project) == "" { + return nil, fmt.Errorf("project is required in scope") + } + + coreClient, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) + if err != nil { + return nil, fmt.Errorf("failed to create core client: %w", err) + } + + project, err := coreClient.GetProject(ctx.Context(), core.GetProjectArgs{ + ProjectId: types.ToPtr(scope.Project), + }) + if err != nil { + return nil, fmt.Errorf("failed to resolve project %q: %w", scope.Project, err) + } + if project == nil || project.Id == nil { + return nil, fmt.Errorf("project %q does not expose an ID", scope.Project) + } + + return &serviceendpoint.ProjectReference{ + Id: project.Id, + Name: project.Name, + }, nil +}