From 8e7118ab7a33fe26f97b1c71a01cd1334b54cf45 Mon Sep 17 00:00:00 2001 From: Omer Kushmaro Date: Tue, 17 Feb 2026 10:39:26 +0100 Subject: [PATCH 1/2] Add `ecctl project list` command for serverless projects Introduce support for Elastic Cloud Serverless projects in ecctl, starting with the ability to list projects. The command calls the Serverless Projects API (`/api/v1/serverless/projects/{type}`) for elasticsearch, observability, and security project types. - Add `pkg/project/` API layer with types, validation, and HTTP client - Add `cmd/project/` with parent command and `list` subcommand - Support `--type` flag to filter by project type - Add text formatter template for tabular output - Regenerate bindata.go and docs - Include unit tests with 87.5% coverage Co-authored-by: Cursor --- cmd/commands.go | 2 + cmd/project/command.go | 32 +++ cmd/project/list.go | 54 +++++ cmd/project/list_test.go | 209 ++++++++++++++++++ docs/ecctl_project.adoc | 45 ++++ docs/ecctl_project.md | 42 ++++ docs/ecctl_project_list.adoc | 45 ++++ docs/ecctl_project_list.md | 42 ++++ pkg/formatter/templates/bindata.go | 25 +++ .../templates/text/project/list.gotmpl | 7 + pkg/project/project.go | 195 ++++++++++++++++ 11 files changed, 698 insertions(+) create mode 100644 cmd/project/command.go create mode 100644 cmd/project/list.go create mode 100644 cmd/project/list_test.go create mode 100644 docs/ecctl_project.adoc create mode 100644 docs/ecctl_project.md create mode 100644 docs/ecctl_project_list.adoc create mode 100644 docs/ecctl_project_list.md create mode 100644 pkg/formatter/templates/text/project/list.gotmpl create mode 100644 pkg/project/project.go diff --git a/cmd/commands.go b/cmd/commands.go index 74d66f50a..021a72086 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -22,6 +22,7 @@ import ( cmdcomment "github.com/elastic/ecctl/cmd/comment" cmddeployment "github.com/elastic/ecctl/cmd/deployment" cmdplatform "github.com/elastic/ecctl/cmd/platform" + cmdproject "github.com/elastic/ecctl/cmd/project" cmdstack "github.com/elastic/ecctl/cmd/stack" cmduser "github.com/elastic/ecctl/cmd/user" ) @@ -32,6 +33,7 @@ func init() { cmdcomment.Command, cmddeployment.Command, cmdplatform.Command, + cmdproject.Command, cmduser.Command, cmdstack.Command, ) diff --git a/cmd/project/command.go b/cmd/project/command.go new file mode 100644 index 000000000..00588c99e --- /dev/null +++ b/cmd/project/command.go @@ -0,0 +1,32 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package cmdproject + +import ( + "github.com/spf13/cobra" +) + +// Command is the top level project command. +var Command = &cobra.Command{ + Use: "project", + Short: "Manages serverless projects", + PreRunE: cobra.MaximumNArgs(0), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} diff --git a/cmd/project/list.go b/cmd/project/list.go new file mode 100644 index 000000000..0ff765d95 --- /dev/null +++ b/cmd/project/list.go @@ -0,0 +1,54 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package cmdproject + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/elastic/ecctl/pkg/ecctl" + "github.com/elastic/ecctl/pkg/project" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Lists serverless projects", + PreRunE: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + projectType, _ := cmd.Flags().GetString("type") + + res, err := project.List(project.ListParams{ + API: ecctl.Get().API, + Host: ecctl.Get().Config.Host, + Type: projectType, + Client: ecctl.Get().Config.Client, + }) + if err != nil { + return err + } + + return ecctl.Get().Formatter.Format(filepath.Join("project", "list"), res) + }, +} + +func init() { + Command.AddCommand(listCmd) + + listCmd.Flags().String("type", "", "Filters by project type (elasticsearch, observability, security)") +} diff --git a/cmd/project/list_test.go b/cmd/project/list_test.go new file mode 100644 index 000000000..e3ac94c17 --- /dev/null +++ b/cmd/project/list_test.go @@ -0,0 +1,209 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package cmdproject + +import ( + "encoding/json" + "testing" + + "github.com/elastic/cloud-sdk-go/pkg/api/mock" + + "github.com/elastic/ecctl/cmd/util/testutils" + "github.com/elastic/ecctl/pkg/project" +) + +func newProjectListBody(projects []project.Project) mock.Response { + body := project.ListResponse{Items: projects} + b, _ := json.Marshal(body) + return mock.New200Response(mock.NewByteBody(b)) +} + +func initListFlags() { + listCmd.ResetFlags() + listCmd.Flags().String("type", "", "Filters by project type (elasticsearch, observability, security)") +} + +func Test_listCmd(t *testing.T) { + var esProjects = []project.Project{ + { + ID: "abc123", + Name: "my-es-project", + Type: "elasticsearch", + RegionID: "aws-us-east-1", + Alias: "my-es-project-abc123", + }, + } + var obsProjects = []project.Project{ + { + ID: "def456", + Name: "my-obs-project", + Type: "observability", + RegionID: "gcp-us-central1", + Alias: "my-obs-project-def456", + }, + } + var secProjects = []project.Project{ + { + ID: "ghi789", + Name: "my-sec-project", + Type: "security", + RegionID: "azure-eastus2", + Alias: "", + }, + } + + var allProjects project.ListResult + allProjects.Projects = append(allProjects.Projects, esProjects...) + allProjects.Projects = append(allProjects.Projects, obsProjects...) + allProjects.Projects = append(allProjects.Projects, secProjects...) + + allProjectsJSON, _ := json.MarshalIndent(allProjects, "", " ") + + var esResult = project.ListResult{Projects: esProjects} + esResultJSON, _ := json.MarshalIndent(esResult, "", " ") + + tests := []struct { + name string + args testutils.Args + want testutils.Assertion + }{ + { + name: "fails due to arguments passed", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list", "some-argument"}, + Cfg: testutils.MockCfg{Responses: []mock.Response{ + mock.SampleInternalError(), + }}, + }, + want: testutils.Assertion{ + Err: `unknown command "some-argument" for "project list"`, + }, + }, + { + name: "fails due to invalid type", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list", "--type", "invalid"}, + Cfg: testutils.MockCfg{Responses: []mock.Response{ + mock.SampleInternalError(), + }}, + }, + want: testutils.Assertion{ + Err: `invalid project type "invalid", must be one of: elasticsearch, observability, security`, + }, + }, + { + name: "succeeds filtering by elasticsearch type with JSON format", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list", "--type", "elasticsearch"}, + Cfg: testutils.MockCfg{ + OutputFormat: "json", + Responses: []mock.Response{ + newProjectListBody(esProjects), + }, + }, + }, + want: testutils.Assertion{ + Stdout: string(esResultJSON) + "\n", + }, + }, + { + name: "succeeds filtering by elasticsearch type with text format", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list", "--type", "elasticsearch"}, + Cfg: testutils.MockCfg{ + OutputFormat: "text", + Responses: []mock.Response{ + newProjectListBody(esProjects), + }, + }, + }, + want: testutils.Assertion{ + Stdout: "ID NAME TYPE REGION ALIAS\n" + + "abc123 my-es-project elasticsearch aws-us-east-1 my-es-project-abc123\n", + }, + }, + { + name: "succeeds listing all project types with JSON format", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list"}, + Cfg: testutils.MockCfg{ + OutputFormat: "json", + Responses: []mock.Response{ + newProjectListBody(esProjects), + newProjectListBody(obsProjects), + newProjectListBody(secProjects), + }, + }, + }, + want: testutils.Assertion{ + Stdout: string(allProjectsJSON) + "\n", + }, + }, + { + name: "succeeds listing all project types with text format", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list"}, + Cfg: testutils.MockCfg{ + OutputFormat: "text", + Responses: []mock.Response{ + newProjectListBody(esProjects), + newProjectListBody(obsProjects), + newProjectListBody(secProjects), + }, + }, + }, + want: testutils.Assertion{ + Stdout: "ID NAME TYPE REGION ALIAS\n" + + "abc123 my-es-project elasticsearch aws-us-east-1 my-es-project-abc123\n" + + "def456 my-obs-project observability gcp-us-central1 my-obs-project-def456\n" + + "ghi789 my-sec-project security azure-eastus2 -\n", + }, + }, + { + name: "succeeds with empty project list", + args: testutils.Args{ + Cmd: listCmd, + Args: []string{"list", "--type", "security"}, + Cfg: testutils.MockCfg{ + OutputFormat: "json", + Responses: []mock.Response{ + newProjectListBody(nil), + }, + }, + }, + want: testutils.Assertion{ + Stdout: `{ + "projects": null +} +`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.RunCmdAssertion(t, tt.args, tt.want) + initListFlags() + }) + } +} diff --git a/docs/ecctl_project.adoc b/docs/ecctl_project.adoc new file mode 100644 index 000000000..1c44d64c6 --- /dev/null +++ b/docs/ecctl_project.adoc @@ -0,0 +1,45 @@ +[#ecctl_project] +== ecctl project + +Manages serverless projects + +---- +ecctl project [flags] +---- + +[float] +=== Options + +---- + -h, --help help for project +---- + +[float] +=== Options inherited from parent commands + +---- + --api-key string API key to use to authenticate (If empty will look for EC_API_KEY environment variable) + --config string Config name, used to have multiple configs in $HOME/.ecctl/ (default "config") + --force Do not ask for confirmation + --format string Formats the output using a Go template + --host string Base URL to use + --insecure Skips all TLS validation + --message string A message to set on cluster operation + --output string Output format [text|json] (default "text") + --pass string Password to use to authenticate (If empty will look for EC_PASS environment variable) + --pprof Enables pprofing and saves the profile to pprof-20060102150405 + -q, --quiet Suppresses the configuration file used for the run, if any + --region string Elastic Cloud Hosted region + --timeout duration Timeout to use on all HTTP calls (default 30s) + --trace Enables tracing saves the trace to trace-20060102150405 + --user string Username to use to authenticate (If empty will look for EC_USER environment variable) + --verbose Enable verbose mode + --verbose-credentials When set, Authorization headers on the request/response trail will be displayed as plain text + --verbose-file string When set, the verbose request/response trail will be written to the defined file +---- + +[float] +=== SEE ALSO + +* xref:ecctl[ecctl] - Elastic Cloud Control +* xref:ecctl_project_list[ecctl project list] - Lists serverless projects diff --git a/docs/ecctl_project.md b/docs/ecctl_project.md new file mode 100644 index 000000000..c896d9d11 --- /dev/null +++ b/docs/ecctl_project.md @@ -0,0 +1,42 @@ +## ecctl project + +Manages serverless projects + +``` +ecctl project [flags] +``` + +### Options + +``` + -h, --help help for project +``` + +### Options inherited from parent commands + +``` + --api-key string API key to use to authenticate (If empty will look for EC_API_KEY environment variable) + --config string Config name, used to have multiple configs in $HOME/.ecctl/ (default "config") + --force Do not ask for confirmation + --format string Formats the output using a Go template + --host string Base URL to use + --insecure Skips all TLS validation + --message string A message to set on cluster operation + --output string Output format [text|json] (default "text") + --pass string Password to use to authenticate (If empty will look for EC_PASS environment variable) + --pprof Enables pprofing and saves the profile to pprof-20060102150405 + -q, --quiet Suppresses the configuration file used for the run, if any + --region string Elastic Cloud Hosted region + --timeout duration Timeout to use on all HTTP calls (default 30s) + --trace Enables tracing saves the trace to trace-20060102150405 + --user string Username to use to authenticate (If empty will look for EC_USER environment variable) + --verbose Enable verbose mode + --verbose-credentials When set, Authorization headers on the request/response trail will be displayed as plain text + --verbose-file string When set, the verbose request/response trail will be written to the defined file +``` + +### SEE ALSO + +* [ecctl](ecctl.md) - Elastic Cloud Control +* [ecctl project list](ecctl_project_list.md) - Lists serverless projects + diff --git a/docs/ecctl_project_list.adoc b/docs/ecctl_project_list.adoc new file mode 100644 index 000000000..afa670949 --- /dev/null +++ b/docs/ecctl_project_list.adoc @@ -0,0 +1,45 @@ +[#ecctl_project_list] +== ecctl project list + +Lists serverless projects + +---- +ecctl project list [flags] +---- + +[float] +=== Options + +---- + -h, --help help for list + --type string Filters by project type (elasticsearch, observability, security) +---- + +[float] +=== Options inherited from parent commands + +---- + --api-key string API key to use to authenticate (If empty will look for EC_API_KEY environment variable) + --config string Config name, used to have multiple configs in $HOME/.ecctl/ (default "config") + --force Do not ask for confirmation + --format string Formats the output using a Go template + --host string Base URL to use + --insecure Skips all TLS validation + --message string A message to set on cluster operation + --output string Output format [text|json] (default "text") + --pass string Password to use to authenticate (If empty will look for EC_PASS environment variable) + --pprof Enables pprofing and saves the profile to pprof-20060102150405 + -q, --quiet Suppresses the configuration file used for the run, if any + --region string Elastic Cloud Hosted region + --timeout duration Timeout to use on all HTTP calls (default 30s) + --trace Enables tracing saves the trace to trace-20060102150405 + --user string Username to use to authenticate (If empty will look for EC_USER environment variable) + --verbose Enable verbose mode + --verbose-credentials When set, Authorization headers on the request/response trail will be displayed as plain text + --verbose-file string When set, the verbose request/response trail will be written to the defined file +---- + +[float] +=== SEE ALSO + +* xref:ecctl_project[ecctl project] - Manages serverless projects diff --git a/docs/ecctl_project_list.md b/docs/ecctl_project_list.md new file mode 100644 index 000000000..165a25fe7 --- /dev/null +++ b/docs/ecctl_project_list.md @@ -0,0 +1,42 @@ +## ecctl project list + +Lists serverless projects + +``` +ecctl project list [flags] +``` + +### Options + +``` + -h, --help help for list + --type string Filters by project type (elasticsearch, observability, security) +``` + +### Options inherited from parent commands + +``` + --api-key string API key to use to authenticate (If empty will look for EC_API_KEY environment variable) + --config string Config name, used to have multiple configs in $HOME/.ecctl/ (default "config") + --force Do not ask for confirmation + --format string Formats the output using a Go template + --host string Base URL to use + --insecure Skips all TLS validation + --message string A message to set on cluster operation + --output string Output format [text|json] (default "text") + --pass string Password to use to authenticate (If empty will look for EC_PASS environment variable) + --pprof Enables pprofing and saves the profile to pprof-20060102150405 + -q, --quiet Suppresses the configuration file used for the run, if any + --region string Elastic Cloud Hosted region + --timeout duration Timeout to use on all HTTP calls (default 30s) + --trace Enables tracing saves the trace to trace-20060102150405 + --user string Username to use to authenticate (If empty will look for EC_USER environment variable) + --verbose Enable verbose mode + --verbose-credentials When set, Authorization headers on the request/response trail will be displayed as plain text + --verbose-file string When set, the verbose request/response trail will be written to the defined file +``` + +### SEE ALSO + +* [ecctl project](ecctl_project.md) - Manages serverless projects + diff --git a/pkg/formatter/templates/bindata.go b/pkg/formatter/templates/bindata.go index 7c7689293..b5606fea5 100644 --- a/pkg/formatter/templates/bindata.go +++ b/pkg/formatter/templates/bindata.go @@ -37,6 +37,7 @@ // text/legacy-deployment-template/list.gotmpl // text/metadata/show.gotmpl // text/platform/repositorylist.gotmpl +// text/project/list.gotmpl // text/proxy/list.gotmpl // text/roles/list.gotmpl // text/runner/list.gotmpl @@ -523,6 +524,26 @@ func textPlatformRepositorylistGotmpl() (*asset, error) { return a, nil } +var _textProjectListGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x90\xcd\x6a\xc4\x20\x14\x85\xf7\xf3\x14\x17\xf7\xe3\x3b\x08\x33\x14\xa1\x4d\x87\x34\x9b\x2e\x6d\x3c\x09\x16\x63\x82\x31\xa5\x45\x7c\xf7\x62\x7e\x4a\x92\xae\xee\xe1\x13\xbf\x7b\x34\xc6\x2b\x69\x34\xc6\x81\x58\xff\x05\xef\x8d\x06\xa3\x94\x62\x24\xaf\x5c\x0b\xe2\x0f\xdf\x7f\xa2\x0e\xe3\x02\xf1\x8d\x7a\x0a\xa8\xd0\x0d\x56\x05\x10\x4f\xe9\x92\xb1\xd3\xeb\xf9\x16\x36\xa9\x46\xa3\x26\x1b\xb2\xf3\x92\x97\x31\x79\x5b\xfc\x41\x7d\xe4\xc1\x0a\xf1\x72\x67\x7b\x50\xbd\x3f\x8e\xa0\xbc\x3f\xc9\xd7\xe2\x80\xc4\xb3\x14\x6f\x6c\x55\xfe\x2f\x9a\x2b\x71\x79\xdb\xef\x21\x5e\xa8\x0e\x47\x52\xfd\x0c\x27\x52\xa2\x35\xbd\x3b\xdd\x34\x0d\x71\x61\x8d\x5a\xbf\x60\x9f\x61\xc7\x6c\xb8\xfe\xbd\x7c\x2e\x04\xa7\xe7\xb4\xcc\xdf\x00\x00\x00\xff\xff\x22\xe6\xa9\xf7\x63\x01\x00\x00") + +func textProjectListGotmplBytes() ([]byte, error) { + return bindataRead( + _textProjectListGotmpl, + "text/project/list.gotmpl", + ) +} + +func textProjectListGotmpl() (*asset, error) { + bytes, err := textProjectListGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "text/project/list.gotmpl", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + var _textProxyListGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x64\x8f\xc1\x6a\xf3\x30\x10\x84\xef\x79\x8a\xc5\xe4\x98\xdf\x0f\x10\xf8\x0f\x6e\x6d\xb0\x21\x6e\x44\xeb\x42\x73\x54\xa2\x49\x2b\x90\xe5\xe2\xc8\xc5\x61\xd1\xbb\x17\x45\x71\xc0\xee\x69\xd7\x33\xb3\xf3\x59\xcc\xff\x48\xe1\xac\x2d\x28\xe9\x7e\xd0\xf7\x5a\x21\x21\xef\x99\xa9\x97\xf6\x13\x94\x8a\xbe\x1b\x35\x2e\x37\x0d\x23\x4e\x83\x43\x83\xf6\xdb\x48\x07\x4a\xbd\x5f\x31\x13\xac\x8a\x27\x8f\x65\xaa\x54\x38\xcb\xc1\xb8\xd0\xb8\x0a\xa8\x44\xbc\xee\x3f\x0e\x54\xe5\x91\xe1\xe4\x31\x8c\xa4\xdc\xbf\x35\x54\x89\x99\x48\x89\x78\x7f\xda\x55\xcf\x14\xcc\x97\xac\x2e\x16\x6e\x59\x64\xbb\xa6\x3c\x2c\xd4\xba\x68\xb2\x3c\x6b\xb2\x07\xf1\xcf\x2b\xc2\x0f\xdf\x3e\xaf\x55\x3e\xbb\x4d\xcb\xee\xe2\x2a\x31\xd7\xc4\x70\x34\xfa\x14\x1c\x2b\x5b\x2c\xf2\x90\xc6\x7d\x5d\x27\xd2\x5d\x8f\xc0\xb5\xb6\x0a\xe3\x86\xd6\x30\x68\x61\x1d\x6d\xff\x53\x5a\xc3\x49\x25\x9d\x0c\xb1\x18\xf0\x7e\xcb\x3c\x65\xbc\xdf\x30\xc3\xaa\x7b\xdf\xb4\xc5\xf9\x1b\x00\x00\xff\xff\xe9\xa1\x73\xa9\xa9\x01\x00\x00") func textProxyListGotmplBytes() ([]byte, error) { @@ -795,6 +816,7 @@ var _bindata = map[string]func() (*asset, error){ "text/legacy-deployment-template/list.gotmpl": textLegacyDeploymentTemplateListGotmpl, "text/metadata/show.gotmpl": textMetadataShowGotmpl, "text/platform/repositorylist.gotmpl": textPlatformRepositorylistGotmpl, + "text/project/list.gotmpl": textProjectListGotmpl, "text/proxy/list.gotmpl": textProxyListGotmpl, "text/roles/list.gotmpl": textRolesListGotmpl, "text/runner/list.gotmpl": textRunnerListGotmpl, @@ -887,6 +909,9 @@ var _bintree = &bintree{nil, map[string]*bintree{ "platform": &bintree{nil, map[string]*bintree{ "repositorylist.gotmpl": &bintree{textPlatformRepositorylistGotmpl, map[string]*bintree{}}, }}, + "project": &bintree{nil, map[string]*bintree{ + "list.gotmpl": &bintree{textProjectListGotmpl, map[string]*bintree{}}, + }}, "proxy": &bintree{nil, map[string]*bintree{ "list.gotmpl": &bintree{textProxyListGotmpl, map[string]*bintree{}}, }}, diff --git a/pkg/formatter/templates/text/project/list.gotmpl b/pkg/formatter/templates/text/project/list.gotmpl new file mode 100644 index 000000000..f134c70a5 --- /dev/null +++ b/pkg/formatter/templates/text/project/list.gotmpl @@ -0,0 +1,7 @@ +{{- define "override" }}{{ range .Projects }}{{ executeTemplate .}} +{{ end }}{{ end }}{{ define "default" }} +{{- "ID" }}{{tab}}{{"NAME"}}{{tab}}{{"TYPE"}}{{tab}}{{"REGION"}}{{tab}}{{"ALIAS"}} +{{- range .Projects }} +{{ .ID }}{{tab}}{{ .Name }}{{tab}}{{ .Type }}{{tab}}{{ .RegionID }}{{tab}}{{ if .Alias }}{{ .Alias }}{{ else }}-{{ end }} +{{- end}} +{{end}} diff --git a/pkg/project/project.go b/pkg/project/project.go new file mode 100644 index 000000000..d62300a33 --- /dev/null +++ b/pkg/project/project.go @@ -0,0 +1,195 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package project + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/elastic/cloud-sdk-go/pkg/api" + "github.com/elastic/cloud-sdk-go/pkg/multierror" +) + +// ProjectType represents the type of serverless project. +type ProjectType string + +const ( + // Elasticsearch project type. + Elasticsearch ProjectType = "elasticsearch" + // Observability project type. + Observability ProjectType = "observability" + // Security project type. + Security ProjectType = "security" +) + +// AllTypes contains all valid project types. +var AllTypes = []ProjectType{Elasticsearch, Observability, Security} + +// ValidateType checks whether the given string is a valid project type. +func ValidateType(t string) (ProjectType, error) { + switch ProjectType(strings.ToLower(t)) { + case Elasticsearch: + return Elasticsearch, nil + case Observability: + return Observability, nil + case Security: + return Security, nil + default: + return "", fmt.Errorf( + "invalid project type %q, must be one of: elasticsearch, observability, security", t, + ) + } +} + +// Metadata contains additional project metadata. +type Metadata struct { + CreatedAt string `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + SuspendedAt string `json:"suspended_at,omitempty"` + SuspendedReason string `json:"suspended_reason,omitempty"` +} + +// Project represents a serverless project. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Type string `json:"type"` + RegionID string `json:"region_id"` + CloudID string `json:"cloud_id,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` +} + +// ListResponse is the response from listing projects of a given type. +type ListResponse struct { + Items []Project `json:"items"` + NextPage string `json:"next_page,omitempty"` +} + +// ListResult is the aggregated result of listing projects, potentially +// across multiple project types. +type ListResult struct { + Projects []Project `json:"projects"` +} + +// ListParams are the parameters for listing projects. +type ListParams struct { + API *api.API + Host string + Type string + Client *http.Client +} + +// Validate ensures the parameters are usable. +func (p ListParams) Validate() error { + var merr = multierror.NewPrefixed("invalid project list params") + if p.API == nil { + merr = merr.Append(errors.New("api reference is required")) + } + if p.Host == "" { + merr = merr.Append(errors.New("host is required")) + } + return merr.ErrorOrNil() +} + +func (p ListParams) httpClient() *http.Client { + if p.Client != nil { + return p.Client + } + return &http.Client{} +} + +// List retrieves serverless projects. When Type is specified, only that +// project type is listed. Otherwise all types are queried. +func List(params ListParams) (*ListResult, error) { + if err := params.Validate(); err != nil { + return nil, err + } + + types := AllTypes + if params.Type != "" { + pt, err := ValidateType(params.Type) + if err != nil { + return nil, err + } + types = []ProjectType{pt} + } + + var result ListResult + for _, pt := range types { + resp, err := listByType(params, pt) + if err != nil { + return nil, fmt.Errorf("failed to list %s projects: %w", pt, err) + } + for i := range resp.Items { + if resp.Items[i].Type == "" { + resp.Items[i].Type = string(pt) + } + } + result.Projects = append(result.Projects, resp.Items...) + } + + return &result, nil +} + +func listByType(params ListParams, pt ProjectType) (*ListResponse, error) { + host := strings.TrimRight(params.Host, "/") + endpoint := fmt.Sprintf("%s/api/v1/serverless/projects/%s", host, pt) + + reqURL, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req = params.API.AuthWriter.AuthRequest(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := params.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + var listResp ListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &listResp, nil +} From 5af7ad2a07c8b73f23ac5d9e3cb4c41a614f90b7 Mon Sep 17 00:00:00 2001 From: Omer Kushmaro Date: Tue, 24 Feb 2026 09:33:14 +0100 Subject: [PATCH 2/2] Update docs/ecctl_project.md Co-authored-by: Toby Brain --- docs/ecctl_project.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ecctl_project.md b/docs/ecctl_project.md index c896d9d11..a6d22340d 100644 --- a/docs/ecctl_project.md +++ b/docs/ecctl_project.md @@ -37,6 +37,6 @@ ecctl project [flags] ### SEE ALSO -* [ecctl](ecctl.md) - Elastic Cloud Control +* [ecctl](/reference/ecctl.md) - Elastic Cloud Control * [ecctl project list](ecctl_project_list.md) - Lists serverless projects