diff --git a/cmd/api/v2/image.go b/cmd/api/v2/image.go index 1275fb2..4cac62e 100644 --- a/cmd/api/v2/image.go +++ b/cmd/api/v2/image.go @@ -33,11 +33,6 @@ func newImageCmd(c *config.Config) *cobra.Command { DescribePrinter: func() printers.Printer { return c.DescribePrinter }, ListPrinter: func() printers.Printer { return c.ListPrinter }, OnlyCmds: genericcli.OnlyCmds(genericcli.DescribeCmd, genericcli.ListCmd), - DescribeCmdMutateFn: func(cmd *cobra.Command) { - cmd.RunE = func(cmd *cobra.Command, args []string) error { - return gcli.DescribeAndPrint("", w.c.DescribePrinter) - } - }, ListCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().StringP("id", "", "", "image id to filter for") cmd.Flags().StringP("os", "", "", "image os to filter for") diff --git a/cmd/api/v2/ip.go b/cmd/api/v2/ip.go index 3e372ac..dd9a45d 100644 --- a/cmd/api/v2/ip.go +++ b/cmd/api/v2/ip.go @@ -9,6 +9,7 @@ import ( "github.com/metal-stack/cli/pkg/helpers" "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/pointer" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -69,9 +70,9 @@ func newIPCmd(c *config.Config) *cobra.Command { CreateRequestFromCLI: func() (*apiv2.IPServiceCreateRequest, error) { return &apiv2.IPServiceCreateRequest{ Project: c.GetProject(), - Name: new(viper.GetString("name")), - Description: new(viper.GetString("description")), Network: viper.GetString("network"), + Name: pointer.PointerOrNil(viper.GetString("name")), + Description: pointer.PointerOrNil(viper.GetString("description")), // Labels: viper.GetStringSlice("tags"), // FIXME implement Type: new(ipStaticToType(viper.GetBool("static"))), AddressFamily: addressFamilyToType(viper.GetString("addressfamily")), @@ -125,6 +126,10 @@ func (c *ip) Create(rq *apiv2.IPServiceCreateRequest) (*apiv2.IP, error) { resp, err := c.c.Client.Apiv2().IP().Create(ctx, rq) if err != nil { + if helpers.IsAlreadyExists(err) { + return nil, genericcli.AlreadyExistsError() + } + return nil, err } @@ -199,12 +204,14 @@ func (c *ip) Update(rq *apiv2.IPServiceUpdateRequest) (*apiv2.IP, error) { func (c *ip) Convert(r *apiv2.IP) (string, *apiv2.IPServiceCreateRequest, *apiv2.IPServiceUpdateRequest, error) { responseToUpdate, err := c.IpResponseToUpdate(r) - return helpers.EncodeProject(r.Uuid, r.Project), IpResponseToCreate(r), responseToUpdate, err + return helpers.EncodeProject(r.Ip, r.Project), IpResponseToCreate(r), responseToUpdate, err } func IpResponseToCreate(r *apiv2.IP) *apiv2.IPServiceCreateRequest { return &apiv2.IPServiceCreateRequest{ + Ip: &r.Ip, Project: r.Project, + Network: r.Network, Name: &r.Name, Description: &r.Description, Labels: r.Meta.Labels, @@ -213,7 +220,6 @@ func IpResponseToCreate(r *apiv2.IP) *apiv2.IPServiceCreateRequest { } func (c *ip) IpResponseToUpdate(desired *apiv2.IP) (*apiv2.IPServiceUpdateRequest, error) { - ctx, cancel := c.c.NewRequestContext() defer cancel() diff --git a/cmd/common_test.go b/cmd/common_test.go deleted file mode 100644 index 5befb1a..0000000 --- a/cmd/common_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "strings" - "testing" - - "slices" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - apitests "github.com/metal-stack/api/go/tests" - "github.com/metal-stack/cli/cmd/completion" - "github.com/metal-stack/cli/cmd/config" - "github.com/metal-stack/metal-lib/pkg/pointer" - "github.com/metal-stack/metal-lib/pkg/testcommon" - "github.com/spf13/afero" - "github.com/spf13/pflag" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/runtime/protoimpl" - "sigs.k8s.io/yaml" -) - -type Test[R any] struct { - Name string - Cmd func(want R) []string - - ClientMocks *apitests.ClientMockFns - FsMocks func(fs afero.Fs, want R) - MockStdin *bytes.Buffer - - DisableMockClient bool // can switch off mock client creation - - WantErr error - Want R // for json and yaml - WantTable *string // for table printer - WantWideTable *string // for wide table printer - Template *string // for template printer - WantTemplate *string // for template printer - WantMarkdown *string // for markdown printer -} - -func (c *Test[R]) TestCmd(t *testing.T) { - require.NotEmpty(t, c.Name, "test name must not be empty") - require.NotEmpty(t, c.Cmd, "cmd must not be empty") - - if c.WantErr != nil { - _, _, conf := c.newMockConfig(t) - - cmd := newRootCmd(conf) - os.Args = append([]string{config.BinaryName}, c.Cmd(c.Want)...) - - err := cmd.Execute() - if diff := cmp.Diff(c.WantErr, err, testcommon.IgnoreUnexported(), testcommon.ErrorStringComparer()); diff != "" { - t.Errorf("error diff (+got -want):\n %s", diff) - } - } - - for _, format := range outputFormats(c) { - t.Run(fmt.Sprintf("%v", format.Args()), func(t *testing.T) { - _, out, conf := c.newMockConfig(t) - - cmd := newRootCmd(conf) - os.Args = append([]string{config.BinaryName}, c.Cmd(c.Want)...) - os.Args = append(os.Args, format.Args()...) - - err := cmd.Execute() - require.NoError(t, err) - - format.Validate(t, out.Bytes()) - }) - } -} - -func (c *Test[R]) newMockConfig(t *testing.T) (any, *bytes.Buffer, *config.Config) { - mock := apitests.New(t) - - fs := afero.NewMemMapFs() - if c.FsMocks != nil { - c.FsMocks(fs, c.Want) - } - - var in io.Reader - if c.MockStdin != nil { - in = bytes.NewReader(c.MockStdin.Bytes()) - } - - var ( - out bytes.Buffer - config = &config.Config{ - Fs: fs, - Out: &out, - In: in, - PromptOut: io.Discard, - Completion: &completion.Completion{}, - Client: mock.Client(c.ClientMocks), - } - ) - - if c.DisableMockClient { - config.Client = nil - } - - return nil, &out, config -} - -func AssertExhaustiveArgs(t *testing.T, args []string, exclude ...string) { - assertContainsPrefix := func(ss []string, prefix string) error { - for _, s := range ss { - if strings.HasPrefix(s, prefix) { - return nil - } - } - return fmt.Errorf("not exhaustive: does not contain %q", prefix) - } - - root := newRootCmd(&config.Config{}) - cmd, args, err := root.Find(args) - require.NoError(t, err) - - cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { - if slices.Contains(exclude, f.Name) { - return - } - require.NoError(t, assertContainsPrefix(args, "--"+f.Name), "please ensure you all available args are used in order to increase coverage or exclude them explicitly") - }) -} - -func MustMarshal(t *testing.T, d any) []byte { - b, err := json.MarshalIndent(d, "", " ") - require.NoError(t, err) - return b -} - -func MustMarshalToMultiYAML[R any](t *testing.T, data []R) []byte { - var parts []string - for _, elem := range data { - parts = append(parts, string(MustMarshal(t, elem))) - } - return []byte(strings.Join(parts, "\n---\n")) -} - -func MustJsonDeepCopy[O any](t *testing.T, object O) O { - raw, err := json.Marshal(&object) - require.NoError(t, err) - var copy O - err = json.Unmarshal(raw, ©) - require.NoError(t, err) - return copy -} - -func outputFormats[R any](c *Test[R]) []outputFormat[R] { - var formats []outputFormat[R] - - if !pointer.IsZero(c.Want) { - formats = append(formats, &jsonOutputFormat[R]{want: c.Want}, &yamlOutputFormat[R]{want: c.Want}) - } - - if c.WantTable != nil { - formats = append(formats, &tableOutputFormat[R]{table: *c.WantTable}) - } - - if c.WantWideTable != nil { - formats = append(formats, &wideTableOutputFormat[R]{table: *c.WantWideTable}) - } - - if c.Template != nil && c.WantTemplate != nil { - formats = append(formats, &templateOutputFormat[R]{template: *c.Template, templateOutput: *c.WantTemplate}) - } - - if c.WantMarkdown != nil { - formats = append(formats, &markdownOutputFormat[R]{table: *c.WantMarkdown}) - } - - return formats -} - -type outputFormat[R any] interface { - Args() []string - Validate(t *testing.T, output []byte) -} - -type jsonOutputFormat[R any] struct { - want R -} - -func (o *jsonOutputFormat[R]) Args() []string { - return []string{"-o", "jsonraw"} -} - -func (o *jsonOutputFormat[R]) Validate(t *testing.T, output []byte) { - var got R - - err := json.Unmarshal(output, &got) - require.NoError(t, err, string(output)) - - if diff := cmp.Diff(o.want, got, testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { - t.Errorf("diff (+got -want):\n %s", diff) - } -} - -type yamlOutputFormat[R any] struct { - want R -} - -func (o *yamlOutputFormat[R]) Args() []string { - return []string{"-o", "yamlraw"} -} - -func (o *yamlOutputFormat[R]) Validate(t *testing.T, output []byte) { - var got R - - err := yaml.Unmarshal(output, &got) - require.NoError(t, err) - - if diff := cmp.Diff(o.want, got, testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { - t.Errorf("diff (+got -want):\n %s", diff) - } -} - -type tableOutputFormat[R any] struct { - table string -} - -func (o *tableOutputFormat[R]) Args() []string { - return []string{"-o", "table"} -} - -func (o *tableOutputFormat[R]) Validate(t *testing.T, output []byte) { - validateTableRows(t, o.table, string(output)) -} - -type wideTableOutputFormat[R any] struct { - table string -} - -func (o *wideTableOutputFormat[R]) Args() []string { - return []string{"-o", "wide"} -} - -func (o *wideTableOutputFormat[R]) Validate(t *testing.T, output []byte) { - validateTableRows(t, o.table, string(output)) -} - -type templateOutputFormat[R any] struct { - template string - templateOutput string -} - -func (o *templateOutputFormat[R]) Args() []string { - return []string{"-o", "template", "--template", o.template} -} - -func (o *templateOutputFormat[R]) Validate(t *testing.T, output []byte) { - t.Logf("got following template output:\n\n%s\n\nconsider using this for test comparison if it looks correct.", string(output)) - - if diff := cmp.Diff(strings.TrimSpace(o.templateOutput), strings.TrimSpace(string(output))); diff != "" { - t.Errorf("diff (+got -want):\n %s", diff) - } -} - -type markdownOutputFormat[R any] struct { - table string -} - -func (o *markdownOutputFormat[R]) Args() []string { - return []string{"-o", "markdown"} -} - -func (o *markdownOutputFormat[R]) Validate(t *testing.T, output []byte) { - validateTableRows(t, o.table, string(output)) -} - -func validateTableRows(t *testing.T, want, got string) { - trimAll := func(ss []string) []string { - var res []string - for _, s := range ss { - res = append(res, strings.TrimSpace(s)) - } - return res - } - - var ( - trimmedWant = strings.TrimSpace(want) - trimmedGot = strings.TrimSpace(string(got)) - - wantRows = trimAll(strings.Split(trimmedWant, "\n")) - gotRows = trimAll(strings.Split(trimmedGot, "\n")) - ) - - t.Logf("got following table output:\n\n%s\n\nconsider using this for test comparison if it looks correct.", trimmedGot) - - t.Log(cmp.Diff(trimmedWant, trimmedGot)) - - require.Equal(t, len(wantRows), len(gotRows), "tables have different lengths") - - for i := range wantRows { - wantFields := trimAll(strings.Split(wantRows[i], " ")) - gotFields := trimAll(strings.Split(gotRows[i], " ")) - - require.Equal(t, len(wantFields), len(gotFields), "table fields have different lengths") - - for i := range wantFields { - assert.Equal(t, wantFields[i], gotFields[i]) - } - } -} diff --git a/cmd/root.go b/cmd/root.go index f561d97..7e4cfd5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,7 @@ func Execute() { Completion: &completion.Completion{}, } - cmd := newRootCmd(cfg) + cmd := NewRootCmd(cfg) err := cmd.Execute() if err != nil { @@ -40,7 +40,7 @@ func Execute() { } } -func newRootCmd(c *config.Config) *cobra.Command { +func NewRootCmd(c *config.Config) *cobra.Command { rootCmd := &cobra.Command{ Use: config.BinaryName, Aliases: []string{"m"}, @@ -57,7 +57,7 @@ func newRootCmd(c *config.Config) *cobra.Command { }, } rootCmd.PersistentFlags().StringP("config", "c", "", "alternative config file path, (default is ~/.metal-stack/config.yaml)") - rootCmd.PersistentFlags().StringP("output-format", "o", "table", "output format (table|wide|markdown|json|yaml|template|jsonraw|yamlraw), wide is a table with more columns, jsonraw and yamlraw do not translate proto enums into string types but leave the original int32 values intact.") + rootCmd.PersistentFlags().StringP("output-format", "o", "table", "output format (table|wide|markdown|json|yaml|template|jsonraw|yamlraw), wide is a table with more columns, jsonraw and yamlraw do not translate proto enums into string types but leave the original int32 values intact (for apply, create, update, delete commands from file the raw output formatters must be used).") genericcli.Must(rootCmd.RegisterFlagCompletionFunc("output-format", cobra.FixedCompletions([]string{"table", "wide", "markdown", "json", "yaml", "template"}, cobra.ShellCompDirectiveNoFileComp))) diff --git a/cmd/tableprinters/tenant.go b/cmd/tableprinters/tenant.go index 5e09727..5e26223 100644 --- a/cmd/tableprinters/tenant.go +++ b/cmd/tableprinters/tenant.go @@ -8,28 +8,17 @@ import ( func (t *TablePrinter) TenantTable(data []*apiv2.Tenant, wide bool) ([]string, [][]string, error) { var ( - rows [][]string + rows [][]string + header = []string{"ID", "Name", "Email", "Registered"} ) - header := []string{"ID", "Name", "Email", "Registered", "Coupons", "Terms And Conditions"} - if wide { - header = []string{"ID", "Name", "Email", "Registered", "Coupons", "Terms And Conditions"} - } - for _, tenant := range data { id := tenant.Login name := tenant.Name email := tenant.Email since := humanize.Time(tenant.Meta.CreatedAt.AsTime()) - coupons := "-" - couponsWide := coupons - termsAndConditions := "" - if wide { - rows = append(rows, []string{id, name, email, since, couponsWide, termsAndConditions}) - } else { - rows = append(rows, []string{id, name, email, since, coupons, termsAndConditions}) - } + rows = append(rows, []string{id, name, email, since}) } return header, rows, nil diff --git a/go.mod b/go.mod index 84ce82e..27207ca 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/metal-stack/cli go 1.26 require ( + buf.build/go/protoyaml v0.6.0 + connectrpc.com/connect v1.19.1 + connectrpc.com/validate v0.6.0 github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 @@ -20,7 +23,9 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect - connectrpc.com/connect v1.19.1 // indirect + buf.build/go/protovalidate v1.1.3 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect @@ -34,6 +39,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/cel-go v0.27.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.4 // indirect @@ -57,9 +63,12 @@ require ( go.mongodb.org/mongo-driver v1.17.9 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apimachinery v0.35.1 // indirect diff --git a/go.sum b/go.sum index 9c15578..689d4ac 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,19 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= +buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= +buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= +buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= +connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= @@ -39,6 +49,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -81,6 +93,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -110,13 +124,19 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI= +google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/helpers/errors.go b/pkg/helpers/errors.go new file mode 100644 index 0000000..813c1da --- /dev/null +++ b/pkg/helpers/errors.go @@ -0,0 +1,15 @@ +package helpers + +import ( + "errors" + + "connectrpc.com/connect" +) + +func IsAlreadyExists(err error) bool { + if connectErr, ok := errors.AsType[*connect.Error](err); ok && connectErr.Code() == connect.CodeAlreadyExists { + return true + } + + return false +} diff --git a/testing/e2e/test_client.go b/testing/e2e/test_client.go new file mode 100644 index 0000000..ab8cf1d --- /dev/null +++ b/testing/e2e/test_client.go @@ -0,0 +1,70 @@ +package e2e + +import ( + "bytes" + "io" + "log/slog" + "testing" + + "connectrpc.com/connect" + "connectrpc.com/validate" + client "github.com/metal-stack/api/go/client" + "github.com/metal-stack/cli/cmd" + "github.com/metal-stack/cli/cmd/completion" + "github.com/metal-stack/cli/cmd/config" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +type TestConfig struct { + FsMocks func(fs *afero.Afero) + MockStdin *bytes.Buffer + ClientCalls []ClientCall +} + +func NewRootCmd(t *testing.T, c *TestConfig) NewRootCmdFunc { + return func() (*cobra.Command, *bytes.Buffer) { + interceptors := []connect.Interceptor{ + &testClientInterceptor{ + t: t, + calls: c.ClientCalls, + count: 0, + }, + validate.NewInterceptor(), + } + + cl, err := client.New(&client.DialConfig{ + BaseURL: "http://this-is-just-for-testing", + Interceptors: interceptors, + UserAgent: "cli-test", + Log: slog.Default(), + }) + require.NoError(t, err) + + fs := afero.NewMemMapFs() + if c.FsMocks != nil { + c.FsMocks(&afero.Afero{ + Fs: fs, + }) + } + + var in io.Reader + if c.MockStdin != nil { + in = bytes.NewReader(c.MockStdin.Bytes()) + } + + var out bytes.Buffer + + return cmd.NewRootCmd(&config.Config{ + Fs: fs, + Out: &out, + In: in, + PromptOut: io.Discard, + Completion: &completion.Completion{ + Client: cl, + }, + Client: cl, + }), &out + } +} diff --git a/testing/e2e/test_framework.go b/testing/e2e/test_framework.go new file mode 100644 index 0000000..af794df --- /dev/null +++ b/testing/e2e/test_framework.go @@ -0,0 +1,373 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "testing/synctest" + "time" + + "slices" + + "buf.build/go/protoyaml" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/metal-stack/metal-lib/pkg/testcommon" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/testing/protocmp" + "sigs.k8s.io/yaml" +) + +const ( + InputFilePath = "/file.yaml" +) + +// NewRootCmdFunc returns the root command for the cli and an output buffer which returns the output after command execution +type NewRootCmdFunc func() (rootCmd *cobra.Command, out *bytes.Buffer) + +type Test[Response, RawObject any] struct { + Name string + + NewRootCmd NewRootCmdFunc + CmdArgs []string + Out *bytes.Buffer + + AssertExhaustiveArgs bool + AssertExhaustiveExcludes []string + + // output format tests + WantObject RawObject // for rawyaml / rawjson printer + WantProtoObject proto.Message // for yaml / json printer + WantTable *string // for table printer + WantWideTable *string // for wide table printer + WantMarkdown *string // for markdown printer + WantTemplate *string // for template printer + Template *string // for template printer + + WantErr error +} + +func TimeBubbleStartTime() time.Time { + return time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) +} + +func (c *Test[Response, RawObject]) TestCmd(t *testing.T) { + require.NotEmpty(t, c.Name, "test name must not be empty") + require.NotEmpty(t, c.CmdArgs, "cmd must not be empty") + + if c.AssertExhaustiveArgs { + c.assertExhaustiveArgs(t) + } + + if c.WantErr != nil { + rootCmd, _ := c.NewRootCmd() + + os.Args = append([]string{rootCmd.Use}, c.CmdArgs...) + + synctest.Test(t, func(t *testing.T) { + err := rootCmd.Execute() + if diff := cmp.Diff(c.WantErr, err, testcommon.IgnoreUnexported(), testcommon.ErrorStringComparer()); diff != "" { + t.Errorf("error diff (+got -want):\n %s", diff) + } + }) + } + + formats := outputFormats(c) + + if len(formats) == 0 { + t.Errorf("at least one want section for output formats must be specified, otherwise no command is getting executed") + return + } + + for _, format := range formats { + succeeded := t.Run(fmt.Sprintf("%v", format.Args()), func(t *testing.T) { + rootCmd, out := c.NewRootCmd() + + os.Args = append([]string{rootCmd.Use}, c.CmdArgs...) + os.Args = append(os.Args, format.Args()...) + + synctest.Test(t, func(t *testing.T) { + err := rootCmd.Execute() + require.NoError(t, err) + }) + + format.Validate(t, out.Bytes()) + }) + + if !succeeded { + t.FailNow() + } + } +} + +func (c *Test[Response, RawObject]) assertExhaustiveArgs(t *testing.T) { + assertContainsPrefix := func(ss []string, prefix string) error { + for _, s := range ss { + if strings.HasPrefix(s, prefix) { + return nil + } + } + return fmt.Errorf("not exhaustive: does not contain %q", prefix) + } + + rootCmd, _ := c.NewRootCmd() + + cmd, args, err := rootCmd.Find(c.CmdArgs) + require.NoError(t, err) + + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if slices.Contains(c.AssertExhaustiveExcludes, f.Name) { + return + } + + require.NoError(t, assertContainsPrefix(args, "--"+f.Name), "ensure all available args are used in order to increase coverage") + }) +} + +func outputFormats[Response, RawObject any](c *Test[Response, RawObject]) []outputFormat { + var formats []outputFormat + + if !pointer.IsZero(c.WantObject) { + formats = append(formats, + &rawYamlOutputFormat[RawObject]{want: c.WantObject}, + &rawJsonOutputFormat[RawObject]{want: c.WantObject}, + ) + } + + if c.WantProtoObject != nil { + formats = append(formats, + &protoYAMLOutputFormat[Response]{want: c.WantProtoObject}, + &protoJSONOutputFormat[Response]{want: c.WantProtoObject}, + ) + } + + if c.WantTable != nil { + formats = append(formats, &tableOutputFormat{table: *c.WantTable}) + } + + if c.WantWideTable != nil { + formats = append(formats, &wideTableOutputFormat{table: *c.WantWideTable}) + } + + if c.Template != nil && c.WantTemplate != nil { + formats = append(formats, &templateOutputFormat{template: *c.Template, templateOutput: *c.WantTemplate}) + } + + if c.WantMarkdown != nil { + formats = append(formats, &markdownOutputFormat{table: *c.WantMarkdown}) + } + + return formats +} + +type outputFormat interface { + Args() []string + Validate(t *testing.T, output []byte) +} + +type rawYamlOutputFormat[R any] struct { + want R +} + +func (o *rawYamlOutputFormat[R]) Args() []string { + return []string{"-o", "yamlraw"} +} + +func (o *rawYamlOutputFormat[R]) Validate(t *testing.T, output []byte) { + t.Logf("got following yamlraw output:\n\n%s\n", string(output)) + + var got R + + err := yaml.Unmarshal(output, &got) + require.NoError(t, err) + + if diff := cmp.Diff(o.want, got, testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } +} + +type rawJsonOutputFormat[R any] struct { + want R +} + +func (o *rawJsonOutputFormat[R]) Args() []string { + return []string{"-o", "jsonraw"} +} + +func (o *rawJsonOutputFormat[R]) Validate(t *testing.T, output []byte) { + t.Logf("got following jsonraw output:\n\n%s\n", string(output)) + + var got R + + err := json.Unmarshal(output, &got) + require.NoError(t, err) + + if diff := cmp.Diff(o.want, got, testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } +} + +type protoYAMLOutputFormat[R any] struct { + want proto.Message +} + +func (o *protoYAMLOutputFormat[R]) Args() []string { + return []string{"-o", "yaml"} +} + +func (o *protoYAMLOutputFormat[R]) Validate(t *testing.T, output []byte) { + t.Logf("got following yaml output:\n\n%s\n", string(output)) + + got := proto.Clone(o.want) + proto.Reset(got) + + err := protoyaml.Unmarshal(output, got) + require.NoError(t, err) + + if diff := cmp.Diff(o.want, got, protocmp.Transform(), testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } +} + +type protoJSONOutputFormat[R any] struct { + want proto.Message +} + +func (o *protoJSONOutputFormat[R]) Args() []string { + return []string{"-o", "json"} +} + +func (o *protoJSONOutputFormat[R]) Validate(t *testing.T, output []byte) { + t.Logf("got following json output:\n\n%s\n", string(output)) + + got := proto.Clone(o.want) + proto.Reset(got) + + err := protojson.Unmarshal(output, got) + require.NoError(t, err) + + if diff := cmp.Diff(o.want, got, protocmp.Transform(), testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } +} + +type tableOutputFormat struct { + table string +} + +func (o *tableOutputFormat) Args() []string { + return []string{"-o", "table"} +} + +func (o *tableOutputFormat) Validate(t *testing.T, output []byte) { + validateTableRows(t, o.table, string(output)) +} + +type wideTableOutputFormat struct { + table string +} + +func (o *wideTableOutputFormat) Args() []string { + return []string{"-o", "wide"} +} + +func (o *wideTableOutputFormat) Validate(t *testing.T, output []byte) { + validateTableRows(t, o.table, string(output)) +} + +type templateOutputFormat struct { + template string + templateOutput string +} + +func (o *templateOutputFormat) Args() []string { + return []string{"-o", "template", "--template", o.template} +} + +func (o *templateOutputFormat) Validate(t *testing.T, output []byte) { + t.Logf("got following template output:\n\n%s\n\nconsider using this for test comparison if it looks correct.", string(output)) + + if diff := cmp.Diff(strings.TrimSpace(o.templateOutput), strings.TrimSpace(string(output))); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } +} + +type markdownOutputFormat struct { + table string +} + +func (o *markdownOutputFormat) Args() []string { + return []string{"-o", "markdown"} +} + +func (o *markdownOutputFormat) Validate(t *testing.T, output []byte) { + validateTableRows(t, o.table, string(output)) +} + +func validateTableRows(t *testing.T, want, got string) { + trimAll := func(ss []string) []string { + var res []string + for _, s := range ss { + res = append(res, strings.TrimSpace(s)) + } + return res + } + + var ( + trimmedWant = strings.TrimSpace(want) + trimmedGot = strings.TrimSpace(string(got)) + + wantRows = trimAll(strings.Split(trimmedWant, "\n")) + gotRows = trimAll(strings.Split(trimmedGot, "\n")) + ) + + t.Logf("got following table output:\n\n%s\n\nconsider using this for test comparison if it looks correct.", trimmedGot) + + // somehow this diff does not look nice anymore. :( + // t.Log(cmp.Diff(trimmedWant, trimmedGot)) + + require.Equal(t, len(wantRows), len(gotRows), "tables have different lengths") + + for i := range wantRows { + wantFields := trimAll(strings.Split(wantRows[i], " ")) + gotFields := trimAll(strings.Split(gotRows[i], " ")) + + require.Equal(t, len(wantFields), len(gotFields), "table fields have different lengths") + + for i := range wantFields { + assert.Equal(t, wantFields[i], gotFields[i]) + } + } +} + +func CommonExcludedFileArgs() []string { + return []string{"file", "bulk-output", "skip-security-prompts", "timestamps"} +} + +func AppendFromFileCommonArgs(args ...string) []string { + return append(args, []string{"-f", InputFilePath, "--skip-security-prompts", "--bulk-output"}...) +} + +func MustMarshal(t *testing.T, d any) []byte { + b, err := json.MarshalIndent(d, "", " ") + require.NoError(t, err) + return b +} + +func MustMarshalToMultiYAML(t *testing.T, data ...any) []byte { + var parts []string + for _, elem := range data { + parts = append(parts, string(MustMarshal(t, elem))) + } + return []byte(strings.Join(parts, "\n---\n")) +} diff --git a/testing/e2e/test_interceptor.go b/testing/e2e/test_interceptor.go new file mode 100644 index 0000000..dc43709 --- /dev/null +++ b/testing/e2e/test_interceptor.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/metal-stack/metal-lib/pkg/testcommon" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/testing/protocmp" +) + +type testClientInterceptor struct { + t *testing.T + calls []ClientCall + count int +} + +type ClientCall struct { + WantRequest proto.Message + WantResponse func() connect.AnyResponse + WantError *connect.Error +} + +func (t *testClientInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, ar connect.AnyRequest) (connect.AnyResponse, error) { + defer func() { t.count++ }() + + if t.count >= len(t.calls) { + t.t.Errorf("received an unexpected client call of type %T: %v", ar.Any(), ar.Any()) + t.t.FailNow() + } + + call := t.calls[t.count] + + if diff := cmp.Diff(call.WantRequest, ar.Any(), protocmp.Transform(), testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.t.Errorf("request diff (+got -want):\n %s", diff) + t.t.FailNow() + } + + if call.WantError != nil { + return nil, call.WantError + } + + return call.WantResponse(), nil + } +} + +func (t *testClientInterceptor) WrapStreamingClient(connect.StreamingClientFunc) connect.StreamingClientFunc { + t.t.Errorf("streaming not supported") + return nil +} + +func (t *testClientInterceptor) WrapStreamingHandler(connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + t.t.Errorf("streaming not supported") + return nil +} diff --git a/testing/e2e/test_interceptor_test.go b/testing/e2e/test_interceptor_test.go new file mode 100644 index 0000000..3305c01 --- /dev/null +++ b/testing/e2e/test_interceptor_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "log/slog" + "testing" + + "connectrpc.com/connect" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + client "github.com/metal-stack/api/go/client" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/testcommon" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestInterceptor(t *testing.T) { + cl, err := client.New(&client.DialConfig{ + BaseURL: "http://this-is-just-for-testing", + Interceptors: []connect.Interceptor{ + &testClientInterceptor{ + t: t, + calls: []ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: "1.2.3.4", + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: &apiv2.IP{Ip: "1.2.3.4"}, + }) + }, + }, + }, + }, + }, + UserAgent: "cli-test", + Log: slog.Default(), + }) + require.NoError(t, err) + + resp, err := cl.Apiv2().IP().Get(t.Context(), &apiv2.IPServiceGetRequest{ + Ip: "1.2.3.4", + }) + require.NoError(t, err) + + if diff := cmp.Diff(&apiv2.IPServiceGetResponse{ + Ip: &apiv2.IP{ + Ip: "1.2.3.4", + }, + }, resp, protocmp.Transform(), testcommon.IgnoreUnexported(), cmpopts.IgnoreTypes(protoimpl.MessageState{})); diff != "" { + t.Errorf("diff = %s", diff) + } +} diff --git a/tests/e2e/admin/component_test.go b/tests/e2e/admin/component_test.go new file mode 100644 index 0000000..26f68ae --- /dev/null +++ b/tests/e2e/admin/component_test.go @@ -0,0 +1,172 @@ +package admin_e2e + +import ( + "testing" + "time" + + "connectrpc.com/connect" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + component1 = func() *apiv2.Component { + return &apiv2.Component{ + Uuid: "c1a2b3d4-e5f6-7890-abcd-ef1234567890", + Type: apiv2.ComponentType_COMPONENT_TYPE_METAL_CORE, + Identifier: "metal-core-1", + StartedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + ReportedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + Interval: durationpb.New(10 * time.Second), + Version: &apiv2.Version{ + Version: "v1.0.0", + }, + Token: &apiv2.Token{ + Uuid: "t1a2b3d4-e5f6-7890-abcd-ef1234567890", + Expires: timestamppb.New(e2e.TimeBubbleStartTime().Add(24 * time.Hour)), + }, + } + } + component2 = func() *apiv2.Component { + return &apiv2.Component{ + Uuid: "d2b3c4e5-f6a7-8901-bcde-f12345678901", + Type: apiv2.ComponentType_COMPONENT_TYPE_PIXIECORE, + Identifier: "pixiecore-1", + StartedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + ReportedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + Interval: durationpb.New(10 * time.Second), + Version: &apiv2.Version{ + Version: "v2.0.0", + }, + Token: &apiv2.Token{ + Uuid: "t2b3c4e5-f6a7-8901-bcde-f12345678901", + Expires: timestamppb.New(e2e.TimeBubbleStartTime().Add(48 * time.Hour)), + }, + } + } +) + +func Test_AdminComponentCmd_Describe(t *testing.T) { + tests := []*e2e.Test[adminv2.ComponentServiceGetResponse, *apiv2.Component]{ + { + Name: "describe", + CmdArgs: []string{"admin", "component", "describe", component1().Uuid}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.ComponentServiceGetRequest{ + Uuid: component1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.ComponentServiceGetResponse{ + Component: component1(), + }) + }, + }, + }, + }), + WantObject: component1(), + WantProtoObject: component1(), + WantTable: new(` + ID TYPE IDENTIFIER STARTED AGE VERSION TOKEN TOKEN EXPIRES IN + c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core metal-core-1 0s 0s v1.0.0 t1a2b3d4-e5f6-7890-abcd-ef1234567890 1d + `), + WantWideTable: new(` + ID TYPE IDENTIFIER STARTED AGE VERSION TOKEN TOKEN EXPIRES IN + c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core metal-core-1 0s 0s v1.0.0 t1a2b3d4-e5f6-7890-abcd-ef1234567890 1d + `), + Template: new("{{ .uuid }} {{ .identifier }}"), + WantTemplate: new(` + c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core-1 + `), + WantMarkdown: new(` + | ID | TYPE | IDENTIFIER | STARTED | AGE | VERSION | TOKEN | TOKEN EXPIRES IN | + |--------------------------------------|------------|--------------|---------|-----|---------|--------------------------------------|------------------| + | c1a2b3d4-e5f6-7890-abcd-ef1234567890 | metal-core | metal-core-1 | 0s | 0s | v1.0.0 | t1a2b3d4-e5f6-7890-abcd-ef1234567890 | 1d | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminComponentCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.ComponentServiceListResponse, apiv2.Component]{ + { + Name: "list", + CmdArgs: []string{"admin", "component", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.ComponentServiceListRequest{ + Query: &apiv2.ComponentQuery{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.ComponentServiceListResponse{ + Components: []*apiv2.Component{ + component1(), + component2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID TYPE IDENTIFIER STARTED AGE VERSION TOKEN TOKEN EXPIRES IN + c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core metal-core-1 0s 0s v1.0.0 t1a2b3d4-e5f6-7890-abcd-ef1234567890 1d + d2b3c4e5-f6a7-8901-bcde-f12345678901 pixiecore pixiecore-1 0s 0s v2.0.0 t2b3c4e5-f6a7-8901-bcde-f12345678901 2d + `), + WantWideTable: new(` + ID TYPE IDENTIFIER STARTED AGE VERSION TOKEN TOKEN EXPIRES IN + c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core metal-core-1 0s 0s v1.0.0 t1a2b3d4-e5f6-7890-abcd-ef1234567890 1d + d2b3c4e5-f6a7-8901-bcde-f12345678901 pixiecore pixiecore-1 0s 0s v2.0.0 t2b3c4e5-f6a7-8901-bcde-f12345678901 2d + `), + Template: new("{{ .uuid }} {{ .identifier }}"), + WantTemplate: new(` +c1a2b3d4-e5f6-7890-abcd-ef1234567890 metal-core-1 +d2b3c4e5-f6a7-8901-bcde-f12345678901 pixiecore-1 + `), + WantMarkdown: new(` + | ID | TYPE | IDENTIFIER | STARTED | AGE | VERSION | TOKEN | TOKEN EXPIRES IN | + |--------------------------------------|------------|--------------|---------|-----|---------|--------------------------------------|------------------| + | c1a2b3d4-e5f6-7890-abcd-ef1234567890 | metal-core | metal-core-1 | 0s | 0s | v1.0.0 | t1a2b3d4-e5f6-7890-abcd-ef1234567890 | 1d | + | d2b3c4e5-f6a7-8901-bcde-f12345678901 | pixiecore | pixiecore-1 | 0s | 0s | v2.0.0 | t2b3c4e5-f6a7-8901-bcde-f12345678901 | 2d | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminComponentCmd_Delete(t *testing.T) { + tests := []*e2e.Test[adminv2.ComponentServiceDeleteResponse, *apiv2.Component]{ + { + Name: "delete", + CmdArgs: []string{"admin", "component", "delete", component1().Uuid}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.ComponentServiceDeleteRequest{ + Uuid: component1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.ComponentServiceDeleteResponse{ + Component: component1(), + }) + }, + }, + }, + }), + WantObject: component1(), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/admin/project_test.go b/tests/e2e/admin/project_test.go new file mode 100644 index 0000000..2739239 --- /dev/null +++ b/tests/e2e/admin/project_test.go @@ -0,0 +1,59 @@ +package admin_e2e + +import ( + "testing" + + "connectrpc.com/connect" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" +) + +func Test_AdminProjectCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.ProjectServiceListResponse, apiv2.Project]{ + { + Name: "list", + CmdArgs: []string{"admin", "project", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.ProjectServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.ProjectServiceListResponse{ + Projects: []*apiv2.Project{ + testresources.Project1(), + testresources.Project2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c metal-stack project-b second project 2000-01-01 00:00:00 UTC + `), + WantWideTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c metal-stack project-b second project 2000-01-01 00:00:00 UTC + `), + Template: new("{{ .uuid }} {{ .name }}"), + WantTemplate: new(` +0d81bca7-73f6-4da3-8397-4a8c52a0c583 project-a +f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c project-b + `), + WantMarkdown: new(` + | ID | TENANT | NAME | DESCRIPTION | CREATION DATE | + |--------------------------------------|-------------|-----------|----------------|-------------------------| + | 0d81bca7-73f6-4da3-8397-4a8c52a0c583 | metal-stack | project-a | first project | 2000-01-01 00:00:00 UTC | + | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | metal-stack | project-b | second project | 2000-01-01 00:00:00 UTC | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/admin/switch_test.go b/tests/e2e/admin/switch_test.go new file mode 100644 index 0000000..60a9581 --- /dev/null +++ b/tests/e2e/admin/switch_test.go @@ -0,0 +1,167 @@ +package admin_e2e + +import ( + "testing" + "time" + + "connectrpc.com/connect" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + switch1 = func() *apiv2.Switch { + return &apiv2.Switch{ + Id: "leaf01", + Partition: "fra-equ01", + Rack: new("rack-1"), + Description: "leaf switch 1", + ManagementIp: "10.0.0.1", + ManagementUser: new("admin"), + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC, + Version: "4.2.0", + MetalCoreVersion: "v0.9.1 (abc1234), tags/v0.9.1", + }, + LastSync: &apiv2.SwitchSync{ + Time: timestamppb.New(e2e.TimeBubbleStartTime()), + Duration: durationpb.New(100 * time.Millisecond), + }, + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } + switch2 = func() *apiv2.Switch { + return &apiv2.Switch{ + Id: "leaf02", + Partition: "fra-equ01", + Rack: new("rack-1"), + Description: "leaf switch 2", + ManagementIp: "10.0.0.2", + ManagementUser: new("admin"), + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC, + Version: "4.2.0", + MetalCoreVersion: "v0.9.1 (abc1234), tags/v0.9.1", + }, + LastSync: &apiv2.SwitchSync{ + Time: timestamppb.New(e2e.TimeBubbleStartTime()), + Duration: durationpb.New(200 * time.Millisecond), + }, + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } +) + +func Test_AdminSwitchCmd_Describe(t *testing.T) { + tests := []*e2e.Test[adminv2.SwitchServiceGetResponse, *apiv2.Switch]{ + { + Name: "describe", + CmdArgs: []string{"admin", "switch", "describe", switch1().Id}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.SwitchServiceGetRequest{ + Id: switch1().Id, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.SwitchServiceGetResponse{ + Switch: switch1(), + }) + }, + }, + }, + }), + WantObject: switch1(), + WantProtoObject: switch1(), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminSwitchCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.SwitchServiceListResponse, apiv2.Switch]{ + { + Name: "list", + CmdArgs: []string{"admin", "switch", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.SwitchServiceListRequest{ + Query: &apiv2.SwitchQuery{ + Os: &apiv2.SwitchOSQuery{}, + }, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.SwitchServiceListResponse{ + Switches: []*apiv2.Switch{ + switch1(), + switch2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID PARTITION RACK OS STATUS LAST SYNC + leaf01 fra-equ01 rack-1 🦔 ● + leaf02 fra-equ01 rack-1 🦔 ● + `), + WantWideTable: new(` + ID PARTITION RACK OS METALCORE IP MODE LAST SYNC SYNC DURATION LAST ERROR + leaf01 fra-equ01 rack-1 SONiC (4.2.0) v0.9.1 (abc1234) 10.0.0.1 operational 100ms + leaf02 fra-equ01 rack-1 SONiC (4.2.0) v0.9.1 (abc1234) 10.0.0.2 operational 200ms + `), + Template: new("{{ .id }} {{ .partition }}"), + WantTemplate: new(` +leaf01 fra-equ01 +leaf02 fra-equ01 + `), + WantMarkdown: new(` + | ID | PARTITION | RACK | OS | STATUS | LAST SYNC | + |--------|-----------|--------|----|--------|-----------| + | leaf01 | fra-equ01 | rack-1 | 🦔 | ● | | + | leaf02 | fra-equ01 | rack-1 | 🦔 | ● | | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminSwitchCmd_Delete(t *testing.T) { + tests := []*e2e.Test[adminv2.SwitchServiceDeleteResponse, *apiv2.Switch]{ + { + Name: "delete", + CmdArgs: []string{"admin", "switch", "delete", switch1().Id}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.SwitchServiceDeleteRequest{ + Id: switch1().Id, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.SwitchServiceDeleteResponse{ + Switch: switch1(), + }) + }, + }, + }, + }), + WantObject: switch1(), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/admin/tenant_test.go b/tests/e2e/admin/tenant_test.go new file mode 100644 index 0000000..9c96572 --- /dev/null +++ b/tests/e2e/admin/tenant_test.go @@ -0,0 +1,114 @@ +package admin_e2e + +import ( + "testing" + + "connectrpc.com/connect" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + adminTenant1 = func() *apiv2.Tenant { + return &apiv2.Tenant{ + Login: "metal-stack", + Name: "Metal Stack", + Email: "info@metal-stack.io", + Description: "a tenant", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } + adminTenant2 = func() *apiv2.Tenant { + return &apiv2.Tenant{ + Login: "acme-corp", + Name: "ACME Corp", + Email: "admin@acme.io", + Description: "another tenant", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } +) + +func Test_AdminTenantCmd_Create(t *testing.T) { + tests := []*e2e.Test[adminv2.TenantServiceCreateResponse, *apiv2.Tenant]{ + { + Name: "create", + CmdArgs: []string{"admin", "tenant", "create", "--name", adminTenant1().Name, "--description", adminTenant1().Description, "--email", adminTenant1().Email}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.TenantServiceCreateRequest{ + Name: adminTenant1().Name, + Description: new(adminTenant1().Description), + Email: new(adminTenant1().Email), + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.TenantServiceCreateResponse{ + Tenant: adminTenant1(), + }) + }, + }, + }, + }), + WantObject: adminTenant1(), + WantProtoObject: adminTenant1(), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminTenantCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.TenantServiceListResponse, apiv2.Tenant]{ + { + Name: "list", + CmdArgs: []string{"admin", "tenant", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.TenantServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.TenantServiceListResponse{ + Tenants: []*apiv2.Tenant{ + adminTenant1(), + adminTenant2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + acme-corp ACME Corp admin@acme.io now + `), + WantWideTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + acme-corp ACME Corp admin@acme.io now + `), + Template: new("{{ .login }} {{ .name }}"), + WantTemplate: new(` +metal-stack Metal Stack +acme-corp ACME Corp + `), + WantMarkdown: new(` + | ID | NAME | EMAIL | REGISTERED | + |-------------|-------------|---------------------|------------| + | metal-stack | Metal Stack | info@metal-stack.io | now | + | acme-corp | ACME Corp | admin@acme.io | now | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/admin/token_test.go b/tests/e2e/admin/token_test.go new file mode 100644 index 0000000..1393e31 --- /dev/null +++ b/tests/e2e/admin/token_test.go @@ -0,0 +1,87 @@ +package admin_e2e + +import ( + "testing" + + "connectrpc.com/connect" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" +) + +func Test_AdminTokenCmd_List(t *testing.T) { + tests := []*e2e.Test[adminv2.TokenServiceListResponse, apiv2.Token]{ + { + Name: "list", + CmdArgs: []string{"admin", "token", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.TokenServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.TokenServiceListResponse{ + Tokens: []*apiv2.Token{ + testresources.Token1(), + testresources.Token2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + TOKEN_TYPE_API b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev@metal-stack.io dev token 0 0 2000-01-03 00:00:00 UTC (in 2d) + `), + WantWideTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + TOKEN_TYPE_API b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev@metal-stack.io dev token 0 0 2000-01-03 00:00:00 UTC (in 2d) + `), + Template: new("{{ .uuid }} {{ .description }}"), + WantTemplate: new(` +a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 ci token +b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev token + `), + WantMarkdown: new(` + | TYPE | ID | ADMIN | USER | DESCRIPTION | ROLES | PERMS | EXPIRES | + |----------------|--------------------------------------|-------|----------------------|-------------|-------|-------|---------------------------------| + | TOKEN_TYPE_API | a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 | | admin@metal-stack.io | ci token | 0 | 0 | 2000-01-02 00:00:00 UTC (in 1d) | + | TOKEN_TYPE_API | b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 | | dev@metal-stack.io | dev token | 0 | 0 | 2000-01-03 00:00:00 UTC (in 2d) | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_AdminTokenCmd_Delete(t *testing.T) { + tests := []*e2e.Test[adminv2.TokenServiceRevokeResponse, *apiv2.Token]{ + { + Name: "delete", + CmdArgs: []string{"admin", "token", "delete", testresources.Token1().Uuid, "--user", "user-123"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &adminv2.TokenServiceRevokeRequest{ + Uuid: testresources.Token1().Uuid, + User: "user-123", + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&adminv2.TokenServiceRevokeResponse{}) + }, + }, + }, + }), + WantObject: &apiv2.Token{ + Uuid: testresources.Token1().Uuid, + }, + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/health_test.go b/tests/e2e/api/health_test.go new file mode 100644 index 0000000..eab32ef --- /dev/null +++ b/tests/e2e/api/health_test.go @@ -0,0 +1,66 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" +) + +var ( + health1 = func() *apiv2.Health { + return &apiv2.Health{ + Services: []*apiv2.HealthStatus{ + { + Name: apiv2.Service_SERVICE_IPAM, + Status: apiv2.ServiceStatus_SERVICE_STATUS_HEALTHY, + Message: "i am healthy", + }, + }, + } + } +) + +func Test_HealthCmd(t *testing.T) { + tests := []*e2e.Test[apiv2.HealthServiceGetResponse, *apiv2.Health]{ + { + Name: "health", + CmdArgs: []string{"health"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.HealthServiceGetRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.HealthServiceGetResponse{ + Health: health1(), + }) + }, + }, + }, + }), + WantTable: new(` + NAME MESSAGE + ✔ ipam i am healthy + `), + WantWideTable: new(` + NAME MESSAGE + ✔ ipam i am healthy + `), + WantMarkdown: new(` + | | NAME | MESSAGE | + |---|------|--------------| + | ✔ | ipam | i am healthy | + `), + WantObject: health1(), + WantProtoObject: health1(), + Template: new("{{ range $s := .services }}{{ $s.message }} {{ end }}"), + WantTemplate: new(` + i am healthy + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/image_test.go b/tests/e2e/api/image_test.go new file mode 100644 index 0000000..f25f151 --- /dev/null +++ b/tests/e2e/api/image_test.go @@ -0,0 +1,106 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" +) + +func Test_ImageCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.ImageServiceListResponse, *apiv2.Image]{ + { + Name: "list", + CmdArgs: []string{"image", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ImageServiceListRequest{ + Query: &apiv2.ImageQuery{}, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ImageServiceListResponse{ + Images: []*apiv2.Image{ + testresources.Image1(), + testresources.Image2(), + }, + }) + }, + }, + }, + }, + ), + WantTable: new(` + ID NAME DESCRIPTION FEATURES EXPIRATION STATUS + ubuntu-24.04 Ubuntu 24.04 Ubuntu 24.04 LTS machine supported + firewall-3.0 Firewall 3.0 Metal Firewall firewall preview + `), + WantWideTable: new(` + ID NAME DESCRIPTION FEATURES EXPIRATION STATUS + ubuntu-24.04 Ubuntu 24.04 Ubuntu 24.04 LTS machine supported + firewall-3.0 Firewall 3.0 Metal Firewall firewall preview + `), + Template: new("{{ .id }} {{ .name }}"), + WantTemplate: new(` +ubuntu-24.04 Ubuntu 24.04 +firewall-3.0 Firewall 3.0 + `), + WantMarkdown: new(` + | ID | NAME | DESCRIPTION | FEATURES | EXPIRATION | STATUS | + |--------------|--------------|------------------|----------|------------|-----------| + | ubuntu-24.04 | Ubuntu 24.04 | Ubuntu 24.04 LTS | machine | | supported | + | firewall-3.0 | Firewall 3.0 | Metal Firewall | firewall | | preview | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_ImageCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.ImageServiceGetResponse, *apiv2.Image]{ + { + Name: "describe", + CmdArgs: []string{"image", "describe", testresources.Image1().Id}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ImageServiceGetRequest{ + Id: testresources.Image1().Id, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ImageServiceGetResponse{ + Image: testresources.Image1(), + }) + }, + }, + }, + }), + WantObject: testresources.Image1(), + WantProtoObject: testresources.Image1(), + WantTable: new(` + ID NAME DESCRIPTION FEATURES EXPIRATION STATUS + ubuntu-24.04 Ubuntu 24.04 Ubuntu 24.04 LTS machine supported + `), + WantWideTable: new(` + ID NAME DESCRIPTION FEATURES EXPIRATION STATUS + ubuntu-24.04 Ubuntu 24.04 Ubuntu 24.04 LTS machine supported + `), + Template: new("{{ .id }} {{ .name }}"), + WantTemplate: new(` + ubuntu-24.04 Ubuntu 24.04 + `), + WantMarkdown: new(` + | ID | NAME | DESCRIPTION | FEATURES | EXPIRATION | STATUS | + |--------------|--------------|------------------|----------|------------|-----------| + | ubuntu-24.04 | Ubuntu 24.04 | Ubuntu 24.04 LTS | machine | | supported | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/ip_test.go b/tests/e2e/api/ip_test.go new file mode 100644 index 0000000..69985bc --- /dev/null +++ b/tests/e2e/api/ip_test.go @@ -0,0 +1,477 @@ +package api_e2e + +import ( + "fmt" + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func Test_IPCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceListResponse, apiv2.IP]{ + { + Name: "list", + CmdArgs: []string{"ip", "list", "--project", testresources.IP1().Project}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceListRequest{ + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceListResponse{ + Ips: []*apiv2.IP{ + testresources.IP1(), + testresources.IP2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 4.3.2.1 46bdfc45-9c8d-4268-b359-b40e3079d384 9cef40ec-29c6-4dfa-aee8-47ee1f49223d ephemeral b + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + WantWideTable: new(` + IP PROJECT ID TYPE NAME DESCRIPTION LABELS + 4.3.2.1 46bdfc45-9c8d-4268-b359-b40e3079d384 9cef40ec-29c6-4dfa-aee8-47ee1f49223d ephemeral b b description a=b + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a a description cluster.metal-stack.io/id/namespace/service=/default/ingress-nginx + `), + Template: new("{{ .ip }} {{ .project }}"), + WantTemplate: new(` +4.3.2.1 46bdfc45-9c8d-4268-b359-b40e3079d384 +1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 + `), + WantMarkdown: new(` + | IP | PROJECT | ID | TYPE | NAME | ATTACHED SERVICE | + |---------|--------------------------------------|--------------------------------------|-----------|------|------------------| + | 4.3.2.1 | 46bdfc45-9c8d-4268-b359-b40e3079d384 | 9cef40ec-29c6-4dfa-aee8-47ee1f49223d | ephemeral | b | | + | 1.1.1.1 | ce19a655-7933-4745-8f3e-9592b4a90488 | 2e0144a2-09ef-42b7-b629-4263295db6e8 | static | a | | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_IPCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceGetResponse, *apiv2.IP]{ + { + Name: "describe", + CmdArgs: []string{"ip", "describe", "--project", testresources.IP1().Project, testresources.IP1().Ip}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }), + WantObject: testresources.IP1(), + WantProtoObject: testresources.IP1(), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + WantWideTable: new(` + IP PROJECT ID TYPE NAME DESCRIPTION LABELS + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a a description cluster.metal-stack.io/id/namespace/service=/default/ingress-nginx + `), + Template: new("{{ .ip }} {{ .project }}"), + WantTemplate: new(` + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 + `), + WantMarkdown: new(` + | IP | PROJECT | ID | TYPE | NAME | ATTACHED SERVICE | + |---------|--------------------------------------|--------------------------------------|--------|------|------------------| + | 1.1.1.1 | ce19a655-7933-4745-8f3e-9592b4a90488 | 2e0144a2-09ef-42b7-b629-4263295db6e8 | static | a | | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_IPCmd_Create(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceGetResponse, *apiv2.IP]{ + { + Name: "create", + CmdArgs: []string{"ip", "create", "--project", testresources.IP1().Project, "--network", testresources.IP1().Network, "--static=true"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceCreateRequest{ + Project: testresources.IP1().Project, + Network: testresources.IP1().Network, + Type: &testresources.IP1().Type, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceCreateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }), + WantObject: testresources.IP1(), + }, + { + Name: "create from file", + CmdArgs: append([]string{"ip", "create"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.IP1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceCreateRequest{ + Ip: &testresources.IP1().Ip, + Project: testresources.IP1().Project, + Network: testresources.IP1().Network, + Name: &testresources.IP1().Name, + Description: &testresources.IP1().Description, + Labels: testresources.IP1().Meta.Labels, + Type: &testresources.IP1().Type, + AddressFamily: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceCreateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_IPCmd_Delete(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceDeleteResponse, *apiv2.IP]{ + { + Name: "delete", + CmdArgs: []string{"ip", "delete", "--project", testresources.IP1().Project, testresources.IP1().Ip}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceDeleteRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceDeleteResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }), + WantObject: testresources.IP1(), + }, + { + Name: "delete from file", + CmdArgs: append([]string{"ip", "delete"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.IP1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceDeleteRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceDeleteResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_IPCmd_Update(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceDeleteResponse, *apiv2.IP]{ + { + Name: "update", + CmdArgs: []string{"ip", "update", "--project", testresources.IP1().Project, testresources.IP1().Ip, "--name", "foo"}, + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + // TODO: the client gets the IP two times? + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceUpdateRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + Name: new("foo"), + + // TODO: these fields do not need to be sent? + Description: &testresources.IP1().Description, + Labels: &apiv2.UpdateLabels{ + Update: &apiv2.Labels{}, + }, + Type: &testresources.IP1().Type, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceUpdateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }, + ), + WantObject: testresources.IP1(), + }, + { + Name: "update from file", + CmdArgs: append([]string{"ip", "update"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.IP1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceUpdateRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + Description: &testresources.IP1().Description, + Labels: &apiv2.UpdateLabels{ + Update: &apiv2.Labels{}, + }, + Name: &testresources.IP1().Name, + Type: &testresources.IP1().Type, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceUpdateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_IPCmd_Apply(t *testing.T) { + tests := []*e2e.Test[apiv2.IPServiceDeleteResponse, *apiv2.IP]{ + { + Name: "apply", + CmdArgs: append([]string{"ip", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.IP1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceCreateRequest{ + Ip: &testresources.IP1().Ip, + Project: testresources.IP1().Project, + Network: testresources.IP1().Network, + Name: &testresources.IP1().Name, + Description: &testresources.IP1().Description, + Labels: testresources.IP1().Meta.Labels, + Type: &testresources.IP1().Type, + AddressFamily: nil, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceCreateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + }, + { + Name: "apply already exists", + CmdArgs: append([]string{"ip", "apply"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, + &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.IP1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceCreateRequest{ + Ip: &testresources.IP1().Ip, + Project: testresources.IP1().Project, + Network: testresources.IP1().Network, + Name: &testresources.IP1().Name, + Description: &testresources.IP1().Description, + Labels: testresources.IP1().Meta.Labels, + Type: &testresources.IP1().Type, + AddressFamily: nil, + }, + WantError: connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("already exists")), + }, + { + WantRequest: &apiv2.IPServiceGetRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceGetResponse{ + Ip: testresources.IP1(), + }) + }, + }, + { + WantRequest: &apiv2.IPServiceUpdateRequest{ + Ip: testresources.IP1().Ip, + Project: testresources.IP1().Project, + Description: &testresources.IP1().Description, + Labels: &apiv2.UpdateLabels{ + Update: &apiv2.Labels{}, + }, + Name: &testresources.IP1().Name, + Type: &testresources.IP1().Type, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.IPServiceUpdateResponse{ + Ip: testresources.IP1(), + }) + }, + }, + }, + }, + ), + WantTable: new(` + IP PROJECT ID TYPE NAME ATTACHED SERVICE + 1.1.1.1 ce19a655-7933-4745-8f3e-9592b4a90488 2e0144a2-09ef-42b7-b629-4263295db6e8 static a + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/project_test.go b/tests/e2e/api/project_test.go new file mode 100644 index 0000000..e232912 --- /dev/null +++ b/tests/e2e/api/project_test.go @@ -0,0 +1,310 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func Test_ProjectCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.ProjectServiceGetResponse, *apiv2.Project]{ + { + Name: "describe", + CmdArgs: []string{"project", "describe", testresources.Project1().Uuid}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceGetRequest{ + Project: testresources.Project1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceGetResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantObject: testresources.Project1(), + WantProtoObject: testresources.Project1(), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + `), + WantWideTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + `), + Template: new("{{ .uuid }} {{ .name }}"), + WantTemplate: new(` + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 project-a + `), + WantMarkdown: new(` + | ID | TENANT | NAME | DESCRIPTION | CREATION DATE | + |--------------------------------------|-------------|-----------|---------------|-------------------------| + | 0d81bca7-73f6-4da3-8397-4a8c52a0c583 | metal-stack | project-a | first project | 2000-01-01 00:00:00 UTC | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_ProjectCmd_Create(t *testing.T) { + tests := []*e2e.Test[apiv2.ProjectServiceCreateResponse, *apiv2.Project]{ + { + Name: "create", + CmdArgs: []string{"project", "create", "--name", testresources.Project1().Name, "--description", testresources.Project1().Description, "--tenant", testresources.Project1().Tenant}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceCreateRequest{ + Login: testresources.Project1().Tenant, + Name: testresources.Project1().Name, + Description: testresources.Project1().Description, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceCreateResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantObject: testresources.Project1(), + WantProtoObject: testresources.Project1(), + }, + { + Name: "create from file", + CmdArgs: append([]string{"project", "create"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Project1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceCreateRequest{ + Login: testresources.Project1().Tenant, + Name: testresources.Project1().Name, + Description: testresources.Project1().Description, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceCreateResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + `), + }, + { + Name: "create many from file", + CmdArgs: append([]string{"project", "create"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshalToMultiYAML(t, testresources.Project1(), testresources.Project2()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceCreateRequest{ + Login: testresources.Project1().Tenant, + Name: testresources.Project1().Name, + Description: testresources.Project1().Description, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceCreateResponse{ + Project: testresources.Project1(), + }) + }, + }, + { + WantRequest: &apiv2.ProjectServiceCreateRequest{ + Login: testresources.Project2().Tenant, + Name: testresources.Project2().Name, + Description: testresources.Project2().Description, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceCreateResponse{ + Project: testresources.Project2(), + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c metal-stack project-b second project 2000-01-01 00:00:00 UTC + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_ProjectCmd_Delete(t *testing.T) { + tests := []*e2e.Test[apiv2.ProjectServiceDeleteResponse, *apiv2.Project]{ + { + Name: "delete", + CmdArgs: []string{"project", "delete", testresources.Project1().Uuid}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceDeleteRequest{ + Project: testresources.Project1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceDeleteResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantObject: testresources.Project1(), + }, + { + Name: "delete from file", + CmdArgs: append([]string{"project", "delete"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Project1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceDeleteRequest{ + Project: testresources.Project1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceDeleteResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_ProjectCmd_Update(t *testing.T) { + tests := []*e2e.Test[apiv2.ProjectServiceUpdateResponse, *apiv2.Project]{ + { + Name: "update", + CmdArgs: []string{"project", "update", testresources.Project1().Uuid, "--name", "new-name", "--description", "new-desc"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceUpdateRequest{ + Project: testresources.Project1().Uuid, + Name: new("new-name"), + Description: new("new-desc"), + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceUpdateResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantObject: testresources.Project1(), + }, + { + Name: "update from file", + CmdArgs: append([]string{"project", "update"}, e2e.AppendFromFileCommonArgs()...), + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + FsMocks: func(fs *afero.Afero) { + require.NoError(t, fs.WriteFile(e2e.InputFilePath, e2e.MustMarshal(t, testresources.Project1()), 0755)) + }, + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceUpdateRequest{ + Project: testresources.Project1().Uuid, + Name: new(testresources.Project1().Name), + Description: new(testresources.Project1().Description), + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceUpdateResponse{ + Project: testresources.Project1(), + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_ProjectCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.ProjectServiceListResponse, apiv2.Project]{ + { + Name: "list", + CmdArgs: []string{"project", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.ProjectServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.ProjectServiceListResponse{ + Projects: []*apiv2.Project{ + testresources.Project1(), + testresources.Project2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c metal-stack project-b second project 2000-01-01 00:00:00 UTC + `), + WantWideTable: new(` + ID TENANT NAME DESCRIPTION CREATION DATE + 0d81bca7-73f6-4da3-8397-4a8c52a0c583 metal-stack project-a first project 2000-01-01 00:00:00 UTC + f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c metal-stack project-b second project 2000-01-01 00:00:00 UTC + `), + Template: new("{{ .uuid }} {{ .name }}"), + WantTemplate: new(` +0d81bca7-73f6-4da3-8397-4a8c52a0c583 project-a +f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c project-b + `), + WantMarkdown: new(` + | ID | TENANT | NAME | DESCRIPTION | CREATION DATE | + |--------------------------------------|-------------|-----------|----------------|-------------------------| + | 0d81bca7-73f6-4da3-8397-4a8c52a0c583 | metal-stack | project-a | first project | 2000-01-01 00:00:00 UTC | + | f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c | metal-stack | project-b | second project | 2000-01-01 00:00:00 UTC | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/tenant_test.go b/tests/e2e/api/tenant_test.go new file mode 100644 index 0000000..01d3fb7 --- /dev/null +++ b/tests/e2e/api/tenant_test.go @@ -0,0 +1,103 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" +) + +func Test_TenantCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.TenantServiceGetResponse, *apiv2.Tenant]{ + { + Name: "describe", + CmdArgs: []string{"tenant", "describe", testresources.Tenant1().Login}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.TenantServiceGetRequest{ + Login: testresources.Tenant1().Login, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.TenantServiceGetResponse{ + Tenant: testresources.Tenant1(), + }) + }, + }, + }, + }), + WantTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + `), + WantWideTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + `), + WantMarkdown: new(` + | ID | NAME | EMAIL | REGISTERED | + |-------------|-------------|---------------------|------------| + | metal-stack | Metal Stack | info@metal-stack.io | now | + `), + WantObject: testresources.Tenant1(), + WantProtoObject: testresources.Tenant1(), + Template: new("{{ .login }} {{ .name }}"), + WantTemplate: new(` + metal-stack Metal Stack + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_TenantCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.TenantServiceListResponse, apiv2.Tenant]{ + { + Name: "list", + CmdArgs: []string{"tenant", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.TenantServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.TenantServiceListResponse{ + Tenants: []*apiv2.Tenant{ + testresources.Tenant1(), + testresources.Tenant2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + acme-corp ACME Corp admin@acme.io now + `), + WantWideTable: new(` + ID NAME EMAIL REGISTERED + metal-stack Metal Stack info@metal-stack.io now + acme-corp ACME Corp admin@acme.io now + `), + Template: new("{{ .login }} {{ .name }}"), + WantTemplate: new(` +metal-stack Metal Stack +acme-corp ACME Corp + `), + WantMarkdown: new(` + | ID | NAME | EMAIL | REGISTERED | + |-------------|-------------|---------------------|------------| + | metal-stack | Metal Stack | info@metal-stack.io | now | + | acme-corp | ACME Corp | admin@acme.io | now | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/api/token_test.go b/tests/e2e/api/token_test.go new file mode 100644 index 0000000..45af157 --- /dev/null +++ b/tests/e2e/api/token_test.go @@ -0,0 +1,103 @@ +package api_e2e + +import ( + "testing" + + "connectrpc.com/connect" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "github.com/metal-stack/cli/tests/e2e/testresources" +) + +func Test_TokenCmd_Describe(t *testing.T) { + tests := []*e2e.Test[apiv2.TokenServiceGetResponse, *apiv2.Token]{ + { + Name: "describe", + CmdArgs: []string{"token", "describe", testresources.Token1().Uuid}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.TokenServiceGetRequest{ + Uuid: testresources.Token1().Uuid, + }, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.TokenServiceGetResponse{ + Token: testresources.Token1(), + }) + }, + }, + }, + }), + WantTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + `), + WantWideTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + `), + WantMarkdown: new(` + | TYPE | ID | ADMIN | USER | DESCRIPTION | ROLES | PERMS | EXPIRES | + |----------------|--------------------------------------|-------|----------------------|-------------|-------|-------|---------------------------------| + | TOKEN_TYPE_API | a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 | | admin@metal-stack.io | ci token | 0 | 0 | 2000-01-02 00:00:00 UTC (in 1d) | + `), + WantObject: testresources.Token1(), + WantProtoObject: testresources.Token1(), + Template: new("{{ .uuid }} {{ .description }}"), + WantTemplate: new(` + a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 ci token + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} + +func Test_TokenCmd_List(t *testing.T) { + tests := []*e2e.Test[apiv2.TokenServiceListResponse, apiv2.Token]{ + { + Name: "list", + CmdArgs: []string{"token", "list"}, + NewRootCmd: e2e.NewRootCmd(t, &e2e.TestConfig{ + ClientCalls: []e2e.ClientCall{ + { + WantRequest: &apiv2.TokenServiceListRequest{}, + WantResponse: func() connect.AnyResponse { + return connect.NewResponse(&apiv2.TokenServiceListResponse{ + Tokens: []*apiv2.Token{ + testresources.Token1(), + testresources.Token2(), + }, + }) + }, + }, + }, + }), + WantTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + TOKEN_TYPE_API b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev@metal-stack.io dev token 0 0 2000-01-03 00:00:00 UTC (in 2d) + `), + WantWideTable: new(` + TYPE ID ADMIN USER DESCRIPTION ROLES PERMS EXPIRES + TOKEN_TYPE_API a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 admin@metal-stack.io ci token 0 0 2000-01-02 00:00:00 UTC (in 1d) + TOKEN_TYPE_API b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev@metal-stack.io dev token 0 0 2000-01-03 00:00:00 UTC (in 2d) + `), + Template: new("{{ .uuid }} {{ .description }}"), + WantTemplate: new(` +a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 ci token +b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 dev token + `), + WantMarkdown: new(` + | TYPE | ID | ADMIN | USER | DESCRIPTION | ROLES | PERMS | EXPIRES | + |----------------|--------------------------------------|-------|----------------------|-------------|-------|-------|---------------------------------| + | TOKEN_TYPE_API | a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90 | | admin@metal-stack.io | ci token | 0 | 0 | 2000-01-02 00:00:00 UTC (in 1d) | + | TOKEN_TYPE_API | b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01 | | dev@metal-stack.io | dev token | 0 | 0 | 2000-01-03 00:00:00 UTC (in 2d) | + `), + }, + } + for _, tt := range tests { + tt.TestCmd(t) + } +} diff --git a/tests/e2e/testresources/images.go b/tests/e2e/testresources/images.go new file mode 100644 index 0000000..6adff27 --- /dev/null +++ b/tests/e2e/testresources/images.go @@ -0,0 +1,24 @@ +package testresources + +import apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + +var ( + Image1 = func() *apiv2.Image { + return &apiv2.Image{ + Id: "ubuntu-24.04", + Name: new("Ubuntu 24.04"), + Description: new("Ubuntu 24.04 LTS"), + Features: []apiv2.ImageFeature{apiv2.ImageFeature_IMAGE_FEATURE_MACHINE}, + Classification: apiv2.ImageClassification_IMAGE_CLASSIFICATION_SUPPORTED, + } + } + Image2 = func() *apiv2.Image { + return &apiv2.Image{ + Id: "firewall-3.0", + Name: new("Firewall 3.0"), + Description: new("Metal Firewall"), + Features: []apiv2.ImageFeature{apiv2.ImageFeature_IMAGE_FEATURE_FIREWALL}, + Classification: apiv2.ImageClassification_IMAGE_CLASSIFICATION_PREVIEW, + } + } +) diff --git a/tests/e2e/testresources/ips.go b/tests/e2e/testresources/ips.go new file mode 100644 index 0000000..5aef78e --- /dev/null +++ b/tests/e2e/testresources/ips.go @@ -0,0 +1,45 @@ +package testresources + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/tag" +) + +var ( + IP1 = func() *apiv2.IP { + return &apiv2.IP{ + Uuid: "2e0144a2-09ef-42b7-b629-4263295db6e8", + Network: "internet", + Ip: "1.1.1.1", + Name: "a", + Description: "a description", + Project: "ce19a655-7933-4745-8f3e-9592b4a90488", + Type: apiv2.IPType_IP_TYPE_STATIC, + Meta: &apiv2.Meta{ + Labels: &apiv2.Labels{ + Labels: map[string]string{ + tag.ClusterServiceFQN: "/default/ingress-nginx", + }, + }, + }, + } + } + IP2 = func() *apiv2.IP { + return &apiv2.IP{ + Uuid: "9cef40ec-29c6-4dfa-aee8-47ee1f49223d", + Network: "internet", + Ip: "4.3.2.1", + Name: "b", + Description: "b description", + Project: "46bdfc45-9c8d-4268-b359-b40e3079d384", + Type: apiv2.IPType_IP_TYPE_EPHEMERAL, + Meta: &apiv2.Meta{ + Labels: &apiv2.Labels{ + Labels: map[string]string{ + "a": "b", + }, + }, + }, + } + } +) diff --git a/tests/e2e/testresources/projects.go b/tests/e2e/testresources/projects.go new file mode 100644 index 0000000..3f06a38 --- /dev/null +++ b/tests/e2e/testresources/projects.go @@ -0,0 +1,32 @@ +package testresources + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + Project1 = func() *apiv2.Project { + return &apiv2.Project{ + Uuid: "0d81bca7-73f6-4da3-8397-4a8c52a0c583", + Name: "project-a", + Description: "first project", + Tenant: "metal-stack", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } + Project2 = func() *apiv2.Project { + return &apiv2.Project{ + Uuid: "f3b4e6a1-2c8d-4e5f-a7b9-1d3e5f7a9b0c", + Name: "project-b", + Description: "second project", + Tenant: "metal-stack", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } +) diff --git a/tests/e2e/testresources/tenants.go b/tests/e2e/testresources/tenants.go new file mode 100644 index 0000000..05b958b --- /dev/null +++ b/tests/e2e/testresources/tenants.go @@ -0,0 +1,32 @@ +package testresources + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + Tenant1 = func() *apiv2.Tenant { + return &apiv2.Tenant{ + Login: "metal-stack", + Name: "Metal Stack", + Email: "info@metal-stack.io", + Description: "a tenant", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } + Tenant2 = func() *apiv2.Tenant { + return &apiv2.Tenant{ + Login: "acme-corp", + Name: "ACME Corp", + Email: "admin@acme.io", + Description: "another tenant", + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } +) diff --git a/tests/e2e/testresources/tokens.go b/tests/e2e/testresources/tokens.go new file mode 100644 index 0000000..b3349d6 --- /dev/null +++ b/tests/e2e/testresources/tokens.go @@ -0,0 +1,40 @@ +package testresources + +import ( + "time" + + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/testing/e2e" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + Token1 = func() *apiv2.Token { + return &apiv2.Token{ + Uuid: "a3b1f6d2-4e8c-4f7a-9d2e-1b5c8f3a7e90", + User: "admin@metal-stack.io", + Description: "ci token", + TokenType: apiv2.TokenType_TOKEN_TYPE_API, + Expires: timestamppb.New(e2e.TimeBubbleStartTime().Add(24 * time.Hour)), + IssuedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + Permissions: nil, + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } + Token2 = func() *apiv2.Token { + return &apiv2.Token{ + Uuid: "b4c2e7f3-5a9d-4b8e-a1c3-2d6f9e4b8a01", + User: "dev@metal-stack.io", + Description: "dev token", + TokenType: apiv2.TokenType_TOKEN_TYPE_API, + Expires: timestamppb.New(e2e.TimeBubbleStartTime().Add(48 * time.Hour)), + IssuedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + Permissions: nil, + Meta: &apiv2.Meta{ + CreatedAt: timestamppb.New(e2e.TimeBubbleStartTime()), + }, + } + } +)