diff --git a/core/arg_file_content_test.go b/core/arg_file_content_test.go index 4faadac284..e4cf5dfccd 100644 --- a/core/arg_file_content_test.go +++ b/core/arg_file_content_test.go @@ -119,7 +119,7 @@ func Test_FileContentArgs(t *testing.T) { Cmd: "scw test file-content user-data.cloud-init=@does-not-exist.yml", Check: core.TestCheckCombine( core.TestCheckExitCode(1), - core.TestCheckStderrContains("Could not open requested file"), + core.TestCheckStderrContains("could not open requested file"), ), })) diff --git a/core/bootstrap.go b/core/bootstrap.go index 9a0fe6cdb6..f6d18186be 100644 --- a/core/bootstrap.go +++ b/core/bootstrap.go @@ -315,7 +315,7 @@ func Bootstrap(ctx context.Context, config *BootstrapConfig) (exitCode int, resu } if meta.command != nil { - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.result, meta.command.getHumanLayout()) if printErr != nil { _, _ = fmt.Fprintln(config.Stderr, printErr) } diff --git a/core/command.go b/core/command.go index a9e1a296af..76a0fae027 100644 --- a/core/command.go +++ b/core/command.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/alias" ) @@ -262,9 +262,9 @@ func (c *Command) signature() string { return c.Namespace + " " + c.Resource + " " + c.Verb + " " + c.Short } -func (c *Command) getHumanMarshalerOpt() *human.MarshalOpt { +func (c *Command) getHumanLayout() *humanv2.Layout { if c.View != nil { - return c.View.getHumanMarshalerOpt() + return c.View.getHumanLayout() } return nil diff --git a/core/error.go b/core/error.go index baef3c8784..1bfca43287 100644 --- a/core/error.go +++ b/core/error.go @@ -2,10 +2,12 @@ package core import ( "encoding/json" - "fmt" "strings" + "unicode" - "github.com/scaleway/scaleway-cli/v2/core/human" + "github.com/fatih/color" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" ) // CliError is an all-in-one error structure that can be used in commands to return useful errors to the user. @@ -34,25 +36,23 @@ func (s *CliError) Error() string { return s.Err.Error() } -func (s *CliError) MarshalHuman() (string, error) { +// FormatHuman lets humanv2 render a CliError as a leaf: a value receiver so +// the pointer is dereferenced before isFormattable sees the value type. +func (s CliError) FormatHuman() (string, error) { if s.Empty { return "", nil } sections := []string(nil) if s.Err != nil { - humanError := s.Err + message := s.Err.Error() if s.Message != "" { - humanError = fmt.Errorf("%s", s.Message) + message = s.Message } - str, err := human.Marshal(humanError, nil) - if err != nil { - return "", err - } - sections = append(sections, str) + sections = append(sections, terminal.Style(capitalize(message), color.FgRed)) } if s.Details != "" { - str, err := human.Marshal(human.Capitalize(s.Details), &human.MarshalOpt{Title: "Details"}) + str, err := humanv2.Render(capitalize(s.Details), humanv2.Layout{Title: "Details"}) if err != nil { return "", err } @@ -60,7 +60,7 @@ func (s *CliError) MarshalHuman() (string, error) { } if s.Hint != "" { - str, err := human.Marshal(human.Capitalize(s.Hint), &human.MarshalOpt{Title: "Hint"}) + str, err := humanv2.Render(capitalize(s.Hint), humanv2.Layout{Title: "Hint"}) if err != nil { return "", err } @@ -96,3 +96,18 @@ func (s *CliError) MarshalJSON() ([]byte, error) { Hint: s.Hint, }) } + +// capitalize returns the given string with a first character in uppercase. +func capitalize(s string) string { + words := strings.Fields(s) + if len(words) > 0 && strings.Contains(words[0], "-") { + // First word is probably an id, don't capitalize + return s + } + + for i, c := range s { + return string(unicode.ToUpper(c)) + s[i+1:] + } + + return "" +} diff --git a/core/human/v2/error.go b/core/human/v2/error.go new file mode 100644 index 0000000000..02830422f1 --- /dev/null +++ b/core/human/v2/error.go @@ -0,0 +1,14 @@ +package humanv2 + +import "fmt" + +// UnknownFieldError is returned when a selected field path does not exist in +// the data, so callers like the printer can suggest the valid fields. +type UnknownFieldError struct { + FieldName string + ValidFields []string +} + +func (e *UnknownFieldError) Error() string { + return fmt.Sprintf("unknown field %s, valid fields are: %v", e.FieldName, e.ValidFields) +} diff --git a/core/human/v2/format.go b/core/human/v2/format.go new file mode 100644 index 0000000000..2d02fba7c6 --- /dev/null +++ b/core/human/v2/format.go @@ -0,0 +1,221 @@ +package humanv2 + +import ( + "encoding/json" + "fmt" + "net" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/dustin/go-humanize" + "github.com/fatih/color" + version "github.com/hashicorp/go-version" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +type Formatter interface { + FormatHuman() (string, error) +} + +// formatFuncs is the register of format functions bound to a concrete type, +// the v2 counterpart of v1's marshalerFuncs. It is consulted by Format at +// every pointer level and on the final value, taking precedence over the +// Formatter interface like v1's register does over Marshaler. +var formatFuncs sync.Map + +// RegisterFormatFunc binds f to the concrete type T so Format renders T +// through f. It is the v2 equivalent of v1's RegisterMarshalerFunc, without +// the MarshalOpt parameter: the function only formats a value, the layout +// (sections, fields, titles) stays in the Layout. +func RegisterFormatFunc[T any](f func(T) (string, error)) { + formatFuncs.Store(reflect.TypeFor[T](), func(i any) (string, error) { + return f(i.(T)) + }) +} + +type ( + EnumFormats[T comparable] map[T]EnumFormat + EnumFormat struct { + Color color.Attribute + Value string + } +) + +func RegisterEnumFormat[T comparable](formats EnumFormats[T]) { + RegisterFormatFunc(func(v T) (string, error) { + format, ok := formats[v] + if !ok { + return fmt.Sprint(v), nil + } + s := fmt.Sprint(v) + if format.Value != "" { + s = format.Value + } + + return terminal.Style(s, format.Color), nil + }) +} + +// getFormatFunc returns the format function registered for t, if any. +func getFormatFunc(t reflect.Type) (func(any) (string, error), bool) { + v, ok := formatFuncs.Load(t) + if !ok { + return nil, false + } + + return v.(func(any) (string, error)), true +} + +// init registers the built-in format functions so they flow through the same +// registry as user-registered ones, like v1's init registrations. The +// fmt.Sprint fallback in Format covers everything else, including Stringers +// and errors. +func init() { + RegisterFormatFunc(func(v string) (string, error) { + if v == "" { + return "-", nil + } + + return v, nil + }) + RegisterFormatFunc(func(t time.Time) (string, error) { + return humanize.Time(t), nil + }) + RegisterFormatFunc(func(v scw.Size) (string, error) { + if isIECNotation := v%1024 == 0 && v%1000 != 0; isIECNotation { + return humanize.IBytes(uint64(v)), nil + } + + return humanize.Bytes(uint64(v)), nil + }) + RegisterFormatFunc(func(v scw.Duration) (string, error) { + const ( + minutes = int64(60) + hours = 60 * minutes + days = 24 * hours + ) + d := v.Seconds / days + h := (v.Seconds - d*days) / hours + m := (v.Seconds - (d*days + h*hours)) / minutes + s := v.Seconds % 60 + res := []string(nil) + if d != 0 { + res = append(res, fmt.Sprintf("%d days", d)) + } + if h != 0 { + res = append(res, fmt.Sprintf("%d hours", h)) + } + if m != 0 { + res = append(res, fmt.Sprintf("%d minutes", m)) + } + if s != 0 { + res = append(res, fmt.Sprintf("%d seconds", s)) + } + if v.Nanos != 0 { + res = append(res, fmt.Sprintf("%d nanoseconds", v.Nanos)) + } + if len(res) == 0 { + return "0 seconds", nil + } + + return strings.Join(res, " "), nil + }) + // The remaining leaf formatters are ported verbatim from v1's registered + // marshaler funcs so Format renders them identically. + RegisterFormatFunc(func(sizes []scw.Size) (string, error) { + strs := []string(nil) + for _, size := range sizes { + s, err := Format(size) + if err != nil { + return "", err + } + strs = append(strs, s) + } + + return strings.Join(strs, ", "), nil + }) + RegisterFormatFunc(func(ip net.IP) (string, error) { + return fmt.Sprintf("%v", ip), nil + }) + RegisterFormatFunc(func(ips []net.IP) (string, error) { + return fmt.Sprintf("%v", ips), nil + }) + RegisterFormatFunc(func(ip scw.IPNet) (string, error) { + str := ip.String() + if str == "" { + return "-", nil + } + + return str, nil + }) + RegisterFormatFunc(func(v version.Version) (string, error) { + return v.String(), nil + }) + RegisterFormatFunc(func(v scw.JSONObject) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + + return string(data), nil + }) + RegisterFormatFunc(func(v []byte) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + + return strings.Trim(string(data), "\""), nil + }) +} + +func Format(data any) (string, error) { + // Dereference pointers until the underlying value is reached, letting any + // value in the chain escape through a registered function, FormatHuman or + // error.Error. + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return "-", nil + } + if f, ok := getFormatFunc(value.Type()); ok { + return f(value.Interface()) + } + if f, ok := reflect.TypeAssert[Formatter](value); ok { + return f.FormatHuman() + } + if err, ok := reflect.TypeAssert[error](value); ok { + return err.Error(), nil + } + value = value.Elem() + } + if !value.IsValid() { + return "-", nil + } + if f, ok := getFormatFunc(value.Type()); ok { + return f(value.Interface()) + } + if f, ok := reflect.TypeAssert[Formatter](value); ok { + return f.FormatHuman() + } + if err, ok := reflect.TypeAssert[error](value); ok { + return err.Error(), nil + } + if stringer, ok := reflect.TypeAssert[fmt.Stringer](value); ok { + return stringer.String(), nil + } + + // This case isn't ported to the isFormattable function intentionally, as this + // implements v1's behavior of printing the number of elements in a slice rather + // than the slice itself in table cells. + if value.Kind() == reflect.Slice || value.Kind() == reflect.Array { + return strconv.Itoa(value.Len()), nil + } + data = value.Interface() + + return fmt.Sprint(data), nil +} diff --git a/core/human/v2/format_test.go b/core/human/v2/format_test.go new file mode 100644 index 0000000000..d75e5c2974 --- /dev/null +++ b/core/human/v2/format_test.go @@ -0,0 +1,288 @@ +package humanv2_test + +import ( + "net" + "testing" + "time" + + "github.com/dustin/go-humanize" + version "github.com/hashicorp/go-version" + human "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/require" +) + +// makeIPNet parses a CIDR into a scw.IPNet for tests. +func makeIPNet(cidr string) scw.IPNet { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + + return scw.IPNet{IPNet: *ipNet} +} + +type FormatterStruct struct { + Foo string +} + +func (FormatterStruct) FormatHuman() (string, error) { + return "cell-formatted", nil +} + +type StringerStruct struct{} + +func (StringerStruct) String() string { + return "stringed" +} + +type ErrorStruct struct{} + +func (ErrorStruct) Error() string { + return "something broke" +} + +// Registry test types are distinct per test: the registry is global, so +// sharing a type between tests would couple their run order. + +type RegistryFormatted struct { + Foo string +} + +type RegistryPointerFormatted struct { + Foo string +} + +type RegistryPrecedenceFormatted struct{} + +func (RegistryPrecedenceFormatted) FormatHuman() (string, error) { + return "interface-formatted", nil +} + +type RegistryNestedStruct struct { + Root string + Sub RegistryFormatted +} + +func TestFormatRegistered(t *testing.T) { + human.RegisterFormatFunc(func(v RegistryFormatted) (string, error) { + return "registered-" + v.Foo, nil + }) + + got, err := human.Format(RegistryFormatted{Foo: "a"}) + require.NoError(t, err) + require.Equal(t, "registered-a", got) + + // A pointer derefs to the registered value type. + got, err = human.Format(&RegistryFormatted{Foo: "b"}) + require.NoError(t, err) + require.Equal(t, "registered-b", got) +} + +func TestFormatRegisteredPointer(t *testing.T) { + human.RegisterFormatFunc(func(v *RegistryPointerFormatted) (string, error) { + return "pointer-" + v.Foo, nil + }) + + got, err := human.Format(&RegistryPointerFormatted{Foo: "c"}) + require.NoError(t, err) + require.Equal(t, "pointer-c", got) +} + +func TestFormatRegisteredPrecedence(t *testing.T) { + human.RegisterFormatFunc(func(RegistryPrecedenceFormatted) (string, error) { + return "registered", nil + }) + + // Like v1, a registered format function wins over Formatter. + got, err := human.Format(RegistryPrecedenceFormatted{}) + require.NoError(t, err) + require.Equal(t, "registered", got) +} + +func TestFormat(t *testing.T) { + date := time.Date(1990, 11, 17, 20, 20, 0, 0, time.UTC) + humanDate := humanize.Time(date) + + for _, tc := range []struct { + name string + data any + want string + wantErr string // substring of the expected error, empty to expect success + }{ + { + name: "formatter value", + data: FormatterStruct{Foo: "ignored"}, + want: "cell-formatted", + }, + { + name: "formatter pointer", + data: &FormatterStruct{Foo: "ignored"}, + want: "cell-formatted", + }, + { + name: "formatter multi-pointer", + data: new(new(&FormatterStruct{Foo: "ignored"})), + want: "cell-formatted", + }, + { + name: "string", + data: "hello", + want: "hello", + }, + { + name: "integer", + data: 42, + want: "42", + }, + { + name: "boolean", + data: true, + want: "true", + }, + { + name: "empty string", + data: "", + want: "-", + }, + { + name: "nil", + data: nil, + want: "-", + }, + { + name: "stringer value", + data: StringerStruct{}, + want: "stringed", + }, + { + name: "stringer pointer", + data: &StringerStruct{}, + want: "stringed", + }, + { + name: "error", + data: ErrorStruct{}, + want: "something broke", + }, + { + name: "pointer to integer", + data: new(int(42)), + want: "42", + }, + { + name: "nil pointer", + data: (*int)(nil), + want: "-", + }, + { + name: "float", + data: 3.14, + want: "3.14", + }, + { + name: "time", + data: date, + want: humanDate, + }, + { + name: "nil time pointer", + data: (*time.Time)(nil), + want: "-", + }, + { + name: "size", + data: scw.Size(13200), + want: "13 kB", + }, + { + name: "size IEC notation", + data: scw.Size(52428800), + want: "50 MiB", + }, + { + name: "nil size pointer", + data: (*scw.Size)(nil), + want: "-", + }, + { + name: "duration", + data: scw.Duration{Seconds: 604800}, + want: "7 days", + }, + { + name: "duration combined", + data: scw.Duration{Seconds: 9000}, + want: "2 hours 30 minutes", + }, + { + name: "duration zero", + data: scw.Duration{}, + want: "0 seconds", + }, + { + name: "duration pointer", + data: &scw.Duration{Seconds: 30}, + want: "30 seconds", + }, + { + name: "sizes", + data: []scw.Size{13200, 52428800}, + want: "13 kB, 50 MiB", + }, + { + name: "ip", + data: net.ParseIP("1.2.3.4"), + want: "1.2.3.4", + }, + { + name: "ips", + data: []net.IP{net.ParseIP("1.2.3.4")}, + want: "[1.2.3.4]", + }, + { + name: "ipnet", + data: makeIPNet("10.0.0.0/24"), + want: "10.0.0.0/24", + }, + { + name: "nil ipnet", + data: scw.IPNet{}, + want: "-", + }, + { + name: "version", + data: version.Must(version.NewVersion("1.2.3")), + want: "1.2.3", + }, + { + name: "json object", + data: scw.JSONObject{"a": 1}, + want: `{"a":1}`, + }, + { + name: "bytes", + data: []byte{0, 1}, + want: "AAE=", + }, + { + name: "slices", + data: []int{1, 2, 3}, + want: "3", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := human.Format(tc.data) + + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/core/human/v2/human.go b/core/human/v2/human.go new file mode 100644 index 0000000000..3cc0e3d9a8 --- /dev/null +++ b/core/human/v2/human.go @@ -0,0 +1,789 @@ +package humanv2 + +import ( + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "sync" + + "github.com/scaleway/scaleway-cli/v2/internal/tabwriter" + "github.com/scaleway/scaleway-cli/v2/internal/terminal" + "github.com/scaleway/scaleway-sdk-go/strcase" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// Layout describes the structure of the rendered data. Sections define +// blocks that will be rendered individually from the data in their path, +// the remaining fields being collected into the main section, defined with +// an empty path and no title by default. Sections order is conserved, with +// the main section being prepended if it isn't found in the list. +type Layout struct { + // Title, when set, is rendered on its own line above the whole output, + // wrapping all sections like v1's MarshalOpt.Title. + Title string + + // NoHeaders skips the header row of table blocks, like v1's + // MarshalOpt.NoHeaders. + NoHeaders bool + + // NoShrinking keeps every table column regardless of the terminal width, + // like v1's MarshalOpt.DisableShrinking. + NoShrinking bool + + // ColPadding is the number of spaces between table columns; 0 uses the + // default of 2, like v1's colPadding. + ColPadding int + + Sections []Section +} + +type Section struct { + // Path is the dotted-path to the field composing the section in the data. + // The main section is denoted with an empty path. + Path string + + // Title is the string for the section's header. It will be omitted if empty. + Title string + + // Omittable controls whether the section should be rendered if empty. + Omittable bool + + // Fields limits the section to the selected dotted field paths: for a + // table it selects the columns, for a struct or map it selects the lines. + // Empty means all fields. + Fields []Field +} + +// Field selects one column of a table or one line of a struct or map. +type Field struct { + // Path is the dotted path of the field in the data. + Path string + + // Label overrides the derived column header. Empty derives it from Path. + Label string +} + +// getLabel returns the field's display label: the explicit Label if set, +// otherwise the Path derived like v1 (bash-arg spaced and uppercased). +func (f Field) getLabel() string { + if f.Label != "" { + return f.Label + } + + return deriveLabel(f.Path) +} + +// deriveLabel renders a path as an ALL-CAPS column header, mirroring v1's +// getDefaultFieldsOpt label derivation. +func deriveLabel(path string) string { + label := strings.ReplaceAll(path, ".", " ") + label = strcase.ToBashArg(label) + label = strings.ReplaceAll(label, "-", " ") + + return strings.ToUpper(label) +} + +// ResolveSections return the final list of sections, with any default applied +// like the default section. +func (l Layout) ResolveSections() []Section { + if slices.ContainsFunc(l.Sections, func(s Section) bool { return s.Path == "" }) { + return l.Sections + } + + return append([]Section{{Path: "", Title: ""}}, l.Sections...) +} + +var layouts sync.Map + +// RegisterLayout stores a custom layout for a particular type. This custom layout +// will override the default one, and be eventually overridden by the render +// options themselves. This is useful because we don't want to have to +// maintain the layouts in the SDK itself, nor wrap the types to implement +// an interface. +func RegisterLayout(t reflect.Type, l Layout) { + layouts.Store(t, l) +} + +// isFormattable reports whether t should be rendered inline by Format rather +// than as a composite table. +func isFormattable(t reflect.Type) bool { + // A formatter or registered format function at any pointer level escapes + // through Format, which checks each level in its deref loop, so mirror it + // here. + for t.Kind() == reflect.Pointer { + if t.Implements(reflect.TypeFor[Formatter]()) { + return true + } + if _, ok := getFormatFunc(t); ok { + return true + } + if t.Implements(reflect.TypeFor[error]()) { + return true + } + if t.Implements(reflect.TypeFor[fmt.Stringer]()) { + return true + } + t = t.Elem() + } + switch { + // A struct is a table, unless it escapes through Formatter, a registered + // format function or error.Error. + case t.Kind() == reflect.Struct: + if t.Implements(reflect.TypeFor[Formatter]()) { + return true + } + if _, ok := getFormatFunc(t); ok { + return true + } + if t.Implements(reflect.TypeFor[error]()) { + return true + } + if t.Implements(reflect.TypeFor[fmt.Stringer]()) { + return true + } + + return false + + // A slice of structs breaks into a table, unless the slice type itself + // has a registered format function; a scalar slice is inline. + case t.Kind() == reflect.Slice || t.Kind() == reflect.Array: + if _, ok := getFormatFunc(t); ok { + return true + } + elem := t.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + + return elem.Kind() != reflect.Struct + // A map is a key-value table, unless it has a registered format function. + case t.Kind() == reflect.Map: + _, hasFormatFunc := getFormatFunc(t) + + return hasFormatFunc + default: + return true + } +} + +// Block is the resolved content of one section, free of any rendering +// concern. It is either a table (Header non-empty, Rows are cells) or a set +// of lines (Header empty, Rows are [key, value] pairs or a single cell). +type Block struct { + Title string + Header []string + Rows [][]string + + // NoHeaders skips the header row when drawing a table block. + NoHeaders bool +} + +// Empty reports whether block draws nothing at all. +func (b Block) Empty() bool { + return b.Title == "" && len(b.Header) == 0 && len(b.Rows) == 0 +} + +// Render resolves data into its layout blocks and draws them, separating the +// blocks with a blank line. Pointers are dereferenced at the top level, and a +// zero layout falls back to the layout registered for the concrete type of +// data. +func Render(data any, layout Layout) (string, error) { + // Dereference pointers until the underlying value is reached, so the nil + // shortcut and the concrete type keying the fallback apply to the same + // value. + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return "-", nil + } + value = value.Elem() + } + if !value.IsValid() { + return "-", nil + } + + if reflect.ValueOf(layout).IsZero() { + if l, ok := layouts.Load(value.Type()); ok { + layout = l.(Layout) + } + } + + // Pass the raw data through: Resolve checks isFormattable on the pointer + // type, whose error and Formatter methods are often pointer-receiver. + blocks, err := layout.Resolve(data) + if err != nil { + return "", err + } + + var b strings.Builder + // Width-driven shrinking only applies to tables and only when the output + // is a terminal; disabling it (wide output) or a missing width keeps + // every column. + width := 0 + if !layout.NoShrinking { + if w := terminal.GetWidth(); w > 0 { + width = w + } + } + + for _, block := range blocks { + if block.Empty() { + continue + } + if b.Len() > 0 { + b.WriteString("\n\n") + } + b.WriteString(block.Draw(width, layout.ColPadding)) + } + + if layout.Title != "" { + return layout.Title + ":\n" + b.String(), nil + } + + return b.String(), nil +} + +// Resolve splits data into the content blocks of the layout, without any +// rendering. It is exposed so tests and a future layout engine can reuse the +// resolution part of Render. Pointers are dereferenced first, so direct +// callers can pass them too; leaves and nil values fold into single-cell +// blocks. +func (l Layout) Resolve(data any) ([]Block, error) { + // A formattable value on its raw type escapes to a single-cell block: + // error and Formatter methods often hang on the pointer, so this has to + // run before pointers are dereferenced. + if reflect.ValueOf(data).IsValid() && isFormattable(reflect.TypeOf(data)) { + str, err := Format(data) + if err != nil { + return nil, err + } + + return []Block{{Rows: [][]string{{str}}}}, nil + } + + // Dereference pointers until the underlying value is reached. + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return []Block{{Rows: [][]string{{"-"}}}}, nil + } + value = value.Elem() + } + if !value.IsValid() { + return []Block{{Rows: [][]string{{"-"}}}}, nil + } + data = value.Interface() + + // Apply the default pathless section if needed. + sections := l.ResolveSections() + + switch value.Kind() { + // A slice or array is a single pathless section, rendered as a table. + case reflect.Slice, reflect.Array: + if len(sections) != 1 { + return nil, fmt.Errorf("found %d sections for %T payload", len(sections), data) + } + + if sections[0].Path != "" { + return nil, fmt.Errorf("unknown path %q for %T payload", sections[0].Path, data) + } + + block, err := sections[0].Resolve(data, nil) + if err != nil { + return nil, fmt.Errorf("rendering section: %w", err) + } + if block.Empty() { + return nil, nil + } + + block.NoHeaders = l.NoHeaders + + return []Block{block}, nil + + // A struct resolves into one block per section. claimed tracks the dotted + // paths rendered as their own section so the flattening of the main block + // skips them, keeping declaration order. Paths are validated here, when + // they are resolved. + case reflect.Struct: + claimed := make(map[string]bool, len(sections)) + for _, section := range sections { + if section.Path != "" { + claimed[section.Path] = true + } + } + blocks := make([]Block, 0, len(sections)) + for i, section := range sections { + sectionData := data + if section.Path != "" { + var err error + sectionData, err = valueAtPath(data, section.Path) + if err != nil { + return nil, fmt.Errorf("path %q not found in %T: %w", section.Path, data, err) + } + } + block, err := section.Resolve(sectionData, claimed) + if err != nil { + return nil, fmt.Errorf("rendering section %d: %w", i, err) + } + if !block.Empty() { + block.NoHeaders = l.NoHeaders + blocks = append(blocks, block) + } + } + + return blocks, nil + } + + // A leaf (a scalar or any other kind) renders inline through its formatter. + str, err := Format(data) + if err != nil { + return nil, err + } + + return []Block{{Rows: [][]string{{str}}}}, nil +} + +// Resolve renders section into a block from data, without drawing. claimed +// tracks the dotted paths resolved as their own section so the flattening of +// the default block skips them. An omitted or empty section resolves to an +// empty block, a slice or array to a table, and a struct or map to a list of +// keyed rows. +func (s Section) Resolve(data any, claimed map[string]bool) (Block, error) { + value := reflect.ValueOf(data) + for value.Kind() == reflect.Pointer { + value = value.Elem() + } + if (!value.IsValid() || value.IsZero()) && s.Omittable { + return Block{}, nil + } + + block := Block{Title: s.Title} + if block.Title == "" && s.Path != "" { + block.Title = deriveTitle(s.Path) + } + + // isFormattable on the raw data type, not the dereferenced one: errors and + // Formatter methods often hang on the pointer. + if !value.IsValid() || isFormattable(reflect.TypeOf(data)) { + str, err := Format(data) + if err != nil { + return Block{}, err + } + block.Rows = [][]string{{str}} + + return block, nil + } + + switch value.Kind() { + case reflect.Slice, reflect.Array: + return s.resolveTable(value, block) + case reflect.Map, reflect.Struct: + return s.resolveList(value, block, claimed) + default: + return block, fmt.Errorf("unresolvable type %T", value.Interface()) + } +} + +// resolveTable renders a slice or array section as a table. With no selected +// Fields the item's fields become the columns; values are formatted per cell. +func (s Section) resolveTable(value reflect.Value, block Block) (Block, error) { + itemType := value.Type().Elem() + for itemType.Kind() == reflect.Pointer { + itemType = itemType.Elem() + } + + // Resolve the selected fields. If no field was selected, initialize the + // list with the item's fields. + fields := s.Fields + if len(s.Fields) == 0 { + fields = make([]Field, itemType.NumField()) + for fieldIndex := range itemType.NumField() { + fields[fieldIndex] = Field{Path: itemType.Field(fieldIndex).Name} + } + } + + if itemType.Kind() == reflect.Struct { + if err := validateFields(itemType, fields); err != nil { + return Block{}, err + } + } + + block.Header = make([]string, len(fields)) + for fieldIndex, field := range fields { + block.Header[fieldIndex] = field.getLabel() + } + + for index := range value.Len() { + item := value.Index(index) + + // Dereference the item's value until it is either nil or not a + // pointer so we can access the underlying's fields. + for (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && !item.IsNil() { + item = item.Elem() + } + isNil := item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface + + row := make([]string, len(fields)) + for fieldIndex, field := range fields { + if isNil { + row[fieldIndex] = "-" + continue + } + + cell, err := valueAtPath(item.Interface(), field.Path) + if err != nil { + // Field paths are pre-validated, so only per-item missing values + // (nil intermediate, absent map key) land here: degrade to "-" + // like v1 instead of failing the whole table. + row[fieldIndex] = "-" + continue + } + + str, err := Format(cell) + if err != nil { + return Block{}, err + } + row[fieldIndex] = str + } + block.Rows = append(block.Rows, row) + } + + return block, nil +} + +// resolveList renders a struct or map section into dotted keyed rows: one walk +// flattens composite values into dotted keys, formats formattable leaves in +// place, and skips paths claimed by other sections. Maps are just walked as +// keyed leaves. +func (s Section) resolveList(value reflect.Value, block Block, claimed map[string]bool) (Block, error) { + valueType := value.Type() + for valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + if valueType.Kind() == reflect.Struct { + if err := validateFields(valueType, s.Fields); err != nil { + return Block{}, err + } + } + + err := walk(value, "", func(keys string, node reflect.Value) (bool, error) { + if claimed[keys] { + return false, nil + } + + // Field selection keeps only the selected subtrees: a node above + // one still descends, a branch without selection is pruned. + if len(s.Fields) > 0 && keys != "" { + under := false + for _, f := range s.Fields { + if keys == f.Path || strings.HasPrefix(keys, f.Path+".") { + under = true + break + } + } + if !under { + for _, f := range s.Fields { + if strings.HasPrefix(f.Path, keys+".") { + return true, nil + } + } + + return false, nil + } + } + + // Scalars, formatter structs (and pointers to them) render in place. + // Everything else descends into dotted leaves. isFormattable reports + // scalar slices as inline for section bodies, but a walk always + // flattens slices into indexed rows. + t := node.Type() + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if isFormattable(node.Type()) && t.Kind() != reflect.Slice && t.Kind() != reflect.Array { + str, err := Format(node.Interface()) + if err != nil { + return false, err + } + block.Rows = append(block.Rows, []string{keys, str}) + + return false, nil + } + + return true, nil + }) + if err != nil { + return Block{}, err + } + + return block, nil +} + +// validateFields returns an UnknownFieldError for the first selected field +// whose path does not resolve through fields of t. +func validateFields(t reflect.Type, fields []Field) error { + for _, field := range fields { + if !hasField(t, field.Path) { + return &UnknownFieldError{FieldName: field.Path, ValidFields: listFields(t)} + } + } + + return nil +} + +// hasField reports whether the dotted path resolves through t's fields. +func hasField(t reflect.Type, path string) bool { + for part := range strings.SplitSeq(path, ".") { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return false + } + + field, ok := t.FieldByName(part) + if !ok { + return false + } + t = field.Type + } + + return true +} + +// listFields returns the selectable dotted field paths of t: structs recurse, +// other leaves (scalars, slices, maps) contribute their own path. +func listFields(t reflect.Type) []string { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + res := []string(nil) + collectFields(t, "", &res) + + return res +} + +func collectFields(t reflect.Type, prefix string, res *[]string) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if t.Kind() == reflect.Struct { + for field := range t.Fields() { + if field.PkgPath != "" { + continue + } + + parent := prefix + if !field.Anonymous { + parent = joinKeys(prefix, field.Name) + } + collectFields(field.Type, parent, res) + } + + return + } + + if prefix != "" { + *res = append(*res, prefix) + } +} + +// deriveTitle renders a section's dotted path as a titled section header, +// mirroring v1's marshalSection title derivation. +func deriveTitle(path string) string { + title := strings.ReplaceAll(strcase.ToBashArg(path), "-", " ") + + return cases.Title(language.English).String(strings.ReplaceAll(title, ".", " - ")) +} + +// Draw renders the block to text: an optional title line followed by its +// lines or table, aligned to columns. It is the layout hook of the package: +// column alignment today, terminal-width shrinking and lipgloss styling +// later, so swapping the layout engine only touches this method. An empty +// Draw renders the block to text: an optional title line followed by its +// lines, table columns shrunk to fit the given width (0 or negative keeps +// every column). Only table blocks shrink, like v1's formatGrid. +func (b Block) Draw(width, colPadding int) string { + if colPadding == 0 { + colPadding = 2 // v1 default + } + + var s strings.Builder + if b.Title != "" { + s.WriteString(b.Title) + s.WriteRune(':') + s.WriteRune('\n') + } + + lines := make([][]string, 0, len(b.Rows)+1) + if len(b.Header) > 0 && !b.NoHeaders { + lines = append(lines, b.Header) + } + lines = append(lines, b.Rows...) + + cols := len(b.Header) + if cols == 0 && len(b.Rows) > 0 { + cols = len(b.Rows[0]) + } + if cols > 0 && width > 0 && len(b.Header) > 0 { + if fit := MaxCols(lines, width, colPadding); fit < cols { + cols = fit + } + } + + w := tabwriter.NewWriter(&s, 5, 1, colPadding, ' ', tabwriter.ANSIGraphicsRendition) + for _, line := range lines { + if cols > 0 && len(line) > cols { + line = line[:cols] + } + fmt.Fprintln(w, strings.Join(line, "\t")) + } + w.Flush() + + return strings.TrimSpace(s.String()) +} + +// MaxCols returns the number of columns that fit within width, mirroring v1: +// each column takes its widest cell plus colPadding, and the first column +// whose running width exceeds width is dropped. +func MaxCols(lines [][]string, width, colPadding int) int { + cols := len(lines[0]) + colMaxSize := make([]int, len(lines[0])) + + for _, line := range lines { + lineSize := 0 + for j := 0; j < cols; j++ { + size := len(line[j]) + colPadding + if size >= colMaxSize[j] { + colMaxSize[j] = size + } + lineSize += colMaxSize[j] + if lineSize > width { + cols = j + } + } + } + + return cols +} + +// walk represents value and its nodes to f; f installs its own walk logic: +// it's called for every node with its dotted-path keys (Sub.X, Strings.0, +// Map.key) and returns whether to keep descending after it. Nil pointers and +// interfaces yield no node at all. +func walk(value reflect.Value, keys string, f func(keys string, value reflect.Value) (bool, error)) error { + if (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) && value.IsNil() { + return nil + } + + descend, err := f(keys, value) + if err != nil { + return err + } + if !descend { + return nil + } + + switch value.Kind() { + case reflect.Pointer, reflect.Interface: + return walk(value.Elem(), keys, f) + + case reflect.Struct: + for i := range value.NumField() { + // Unexported fields cannot be turned into an interface by the + // visitor; reflect would panic on Interface(). + if !value.Field(i).CanInterface() { + continue + } + name := value.Type().Field(i).Name + err := walk(value.Field(i), joinKeys(keys, name), f) + if err != nil { + return err + } + } + + return nil + + case reflect.Slice, reflect.Array: + for i := range value.Len() { + err := walk(value.Index(i), joinKeys(keys, strconv.Itoa(i)), f) + if err != nil { + return err + } + } + + return nil + + case reflect.Map: + mapKeys := value.MapKeys() + slices.SortFunc(mapKeys, func(i, j reflect.Value) int { + return strings.Compare(i.String(), j.String()) + }) + for _, mapKey := range mapKeys { + err := walk(value.MapIndex(mapKey), joinKeys(keys, mapKey.String()), f) + if err != nil { + return err + } + } + + return nil + + default: + // Leaf: f already decided whether to descend. + return nil + } +} + +// joinKeys appends part to keys, dropping the separator at the root. +func joinKeys(keys, part string) string { + if keys == "" { + return part + } + + return keys + "." + part +} + +// valueAtPath returns the value at the given dotted path, dereferencing +// pointers and interfaces along the way. +func valueAtPath(data any, path string) (any, error) { + value := reflect.ValueOf(data) + + for part := range strings.SplitSeq(path, ".") { + for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if value.IsNil() { + return nil, fmt.Errorf("path %q not found in %T: nil value", path, data) + } + value = value.Elem() + } + + switch value.Kind() { + case reflect.Struct: + value = value.FieldByName(part) + case reflect.Slice, reflect.Array: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= value.Len() { + return nil, pathNotFound(path, data) + } + value = value.Index(index) + case reflect.Map: + value = value.MapIndex(reflect.ValueOf(part)) + default: + return nil, pathNotFound(path, data) + } + if !value.IsValid() || !value.CanInterface() { + return nil, pathNotFound(path, data) + } + } + + return value.Interface(), nil +} + +// pathNotFound reports a dotted path component that does not resolve in data. +func pathNotFound(path string, data any) error { + return fmt.Errorf("path %q not found in %T", path, data) +} diff --git a/core/human/v2/human_test.go b/core/human/v2/human_test.go new file mode 100644 index 0000000000..ed219dffa1 --- /dev/null +++ b/core/human/v2/human_test.go @@ -0,0 +1,1068 @@ +package humanv2_test + +import ( + "errors" + "fmt" + "reflect" + "testing" + + human "github.com/scaleway/scaleway-cli/v2/core/human/v2" + "github.com/stretchr/testify/require" +) + +func TestLayout_ResolveSections(t *testing.T) { + for _, tc := range []struct { + name string + layout human.Layout + want []human.Section + }{ + { + name: "empty layout", + layout: human.Layout{}, + want: []human.Section{ + {Path: "", Title: "", Omittable: false}, + }, + }, + { + name: "non empty layout", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo"}, + }, + }, + want: []human.Section{ + {Path: "", Title: "", Omittable: false}, + {Path: "Foo", Title: "", Omittable: false}, + }, + }, + { + name: "overridden default", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "", Title: "Default"}, + {Path: "Foo"}, + }, + }, + want: []human.Section{ + {Path: "", Title: "Default", Omittable: false}, + {Path: "Foo", Title: "", Omittable: false}, + }, + }, + { + name: "out-of-order default", + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo"}, + {Path: ""}, + }, + }, + want: []human.Section{ + {Path: "Foo", Title: "", Omittable: false}, + {Path: "", Title: "", Omittable: false}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := tc.layout.ResolveSections() + require.Equal(t, tc.want, got) + }) + } +} + +type DummyStruct struct { + Foo string + Bar int +} + +type UnexportedFieldStruct struct { + Exported string + hidden string +} + +type UnexportedFieldError struct { + Message string + hidden string +} + +func (e UnexportedFieldError) Error() string { + return e.Message +} + +type RootLevelDummyStruct struct { + RootLevelFieldA string + RootLevelFieldB string + FirstLevelSection FirstLevelDummyStruct +} + +type FirstLevelDummyStruct struct { + FirstLevelFieldA string + FirstLevelFieldB string +} + +type ListDummyStruct struct { + OmittableList []FirstLevelDummyStruct + RequiredList []FirstLevelDummyStruct +} + +type RegistryDummyStruct struct { + RegistryField string + RegistrySection FirstLevelDummyStruct +} + +type ScalarSliceDummyStruct struct { + Fields []string +} + +type NestedDummyStruct struct { + X string + Y string +} + +type RootDummyStruct struct { + RootA string + Sub NestedDummyStruct + RootB string +} + +type RootSliceDummyStruct struct { + RootA string + Strings []string +} + +type RootMapDummyStruct struct { + RootA string + Labels map[string]string +} + +type NestedFormatterDummyStruct struct { + RootA string + Sub FormatterStruct +} + +type pointerRecvFormatter struct { + Foo string +} + +func (s *pointerRecvFormatter) FormatHuman() (string, error) { + return "pointer-formatted", nil +} + +type NestedPointerFormatterDummyStruct struct { + RootA string + Sub *pointerRecvFormatter +} + +type MapDummyStruct struct { + Fields map[string]string +} + +type MapListDummyStruct struct { + Fields map[string][]string +} + +func init() { + // Registered once for the whole table; only the two registry cases use + // RegistryDummyStruct, so no other case is affected. + human.RegisterLayout(reflect.TypeFor[RegistryDummyStruct](), human.Layout{ + Sections: []human.Section{ + {Path: "RegistrySection", Title: "Custom"}, + }, + }) + human.RegisterFormatFunc(func(v RegistryFormatted) (string, error) { + return "registered-" + v.Foo, nil + }) +} + +func TestLayout_Resolve(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + want []human.Block + wantErr string // substring of the expected error, empty to expect success + }{ + { + name: "simple struct", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + want: []human.Block{ + { + Header: []string{"FOO", "BAR"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + }, + }, + }, + { + name: "simple struct without headers", + data: []DummyStruct{{Foo: "foo", Bar: 1}}, + layout: human.Layout{NoHeaders: true}, + want: []human.Block{ + { + Header: []string{"FOO", "BAR"}, + Rows: [][]string{{"foo", "1"}}, + NoHeaders: true, + }, + }, + }, + { + name: "slice of pointer to struct", + data: []*FirstLevelDummyStruct{ + {FirstLevelFieldA: "a", FirstLevelFieldB: "b"}, + {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, + }, + want: []human.Block{ + { + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, + Rows: [][]string{{"a", "b"}, {"c", "d"}}, + }, + }, + }, + { + name: "slice with nil struct item", + data: []*FirstLevelDummyStruct{ + nil, + {FirstLevelFieldA: "c", FirstLevelFieldB: "d"}, + }, + want: []human.Block{ + { + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, + Rows: [][]string{{"-", "-"}, {"c", "d"}}, + }, + }, + }, + { + name: "empty string cell formatted", + data: []DummyStruct{ + {Foo: "", Bar: 1}, + }, + want: []human.Block{ + { + Header: []string{"FOO", "BAR"}, + Rows: [][]string{{"-", "1"}}, + }, + }, + }, + { + name: "struct default layout", + data: DummyStruct{Foo: "foo", Bar: 1}, + // no explicit section: only the default pathless section. + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, + }, + { + name: "pointer to struct", + data: &DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, + }, + { + name: "nil pointer", + data: (*DummyStruct)(nil), + layout: human.Layout{}, + want: []human.Block{{Rows: [][]string{{"-"}}}}, + }, + { + name: "nil", + data: nil, + layout: human.Layout{}, + want: []human.Block{{Rows: [][]string{{"-"}}}}, + }, + { + name: "multi-level pointer to struct", + data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}}, + }, + }, + { + name: "formatter value", + data: FormatterStruct{Foo: "ignored"}, + layout: human.Layout{}, + want: []human.Block{{Rows: [][]string{{"cell-formatted"}}}}, + }, + { + name: "scalar", + data: 42, + layout: human.Layout{}, + want: []human.Block{{Rows: [][]string{{"42"}}}}, + }, + { + name: "struct with titled struct section", + data: RootLevelDummyStruct{ + RootLevelFieldA: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + RootLevelFieldB: "aaaaaaaa-aaaaaa-aaaaaa", + FirstLevelSection: FirstLevelDummyStruct{ + FirstLevelFieldA: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + FirstLevelFieldB: "BBBBB", + }, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "FirstLevelSection", Title: "First Level"}, + }, + }, + want: []human.Block{ + { + Rows: [][]string{ + {"RootLevelFieldA", "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"}, + {"RootLevelFieldB", "aaaaaaaa-aaaaaa-aaaaaa"}, + }, + }, + { + Title: "First Level", + Rows: [][]string{ + {"FirstLevelFieldA", "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"}, + {"FirstLevelFieldB", "BBBBB"}, + }, + }, + }, + }, + { + name: "omittable empty section", + data: ListDummyStruct{}, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "OmittableList", Title: "Omittable List", Omittable: true}, + {Path: "RequiredList", Title: "Required List", Omittable: false}, + }, + }, + // The omittable section is empty so it is dropped, the required one + // keeps its header-only table. + want: []human.Block{ + {Title: "Required List", Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}}, + }, + }, + { + name: "omittable non-empty section", + data: ListDummyStruct{ + OmittableList: []FirstLevelDummyStruct{ + {FirstLevelFieldA: "x", FirstLevelFieldB: "y"}, + }, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "OmittableList", Title: "Omittable List", Omittable: true}, + {Path: "RequiredList", Title: "Required List", Omittable: false}, + }, + }, + want: []human.Block{ + { + Title: "Omittable List", + Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}, + Rows: [][]string{{"x", "y"}}, + }, + {Title: "Required List", Header: []string{"FIRST LEVEL FIELD A", "FIRST LEVEL FIELD B"}}, + }, + }, + { + name: "scalar slice section", + data: ScalarSliceDummyStruct{ + Fields: []string{"poweron", "backup"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Allowed"}, + }, + }, + want: []human.Block{ + {Title: "Allowed", Rows: [][]string{{"2"}}}, + }, + }, + { + name: "map section", + data: MapDummyStruct{ + Fields: map[string]string{"b": "two", "a": "one"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Fields"}, + }, + }, + want: []human.Block{ + {Title: "Fields", Rows: [][]string{{"a", "one"}, {"b", "two"}}}, + }, + }, + { + name: "map section with slice value", + data: MapListDummyStruct{ + Fields: map[string][]string{"b": {"y", "x"}, "a": {"one"}}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Fields", Title: "Fields"}, + }, + }, + want: []human.Block{ + {Title: "Fields", Rows: [][]string{{"a.0", "one"}, {"b.0", "y"}, {"b.1", "x"}}}, + }, + }, + { + name: "flattened struct fields", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"Sub.Y", "two"}, {"RootB", "bbb"}}}, + }, + }, + { + name: "omittable skipped between two sections", + data: RootDummyStruct{ + RootA: "aaa", + // Sub zero -> omitted + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "B", Omittable: true}, + {Path: "RootB", Title: "C"}, + }, + }, + // The skipped omittable section leaves no block at all. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}}}, + {Title: "C", Rows: [][]string{{"bbb"}}}, + }, + }, + { + name: "dotted section path", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub.Y", Title: "Y"}, + }, + }, + // Sub.Y is claimed by the section, Sub.X keeps flattening into + // the main block. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"RootB", "bbb"}}}, + {Title: "Y", Rows: [][]string{{"two"}}}, + }, + }, + { + name: "section derives title from path", + data: RootDummyStruct{ + RootA: "aaa", + RootB: "bbb", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "RootB"}, // no Title: derived like v1 + }, + }, + // Layout.ResolveSections prepends the main block; only the selected + // path is claimed, the rest flattens into it. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}, {"Sub.Y", "two"}}}, + {Title: "Root B", Rows: [][]string{{"bbb"}}}, + }, + }, + { + name: "section derives dotted title from path", + data: RootDummyStruct{ + RootA: "aaa", + RootB: "bbb", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub.X"}, + }, + }, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.Y", "two"}, {"RootB", "bbb"}}}, + {Title: "Sub - X", Rows: [][]string{{"one"}}}, + }, + }, + { + name: "nested formatter struct rendered inline", + data: NestedFormatterDummyStruct{ + RootA: "aaa", + Sub: FormatterStruct{Foo: "ignored"}, + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub", "cell-formatted"}}}, + }, + }, + { + name: "nested pointer-receiver formatter rendered inline", + data: NestedPointerFormatterDummyStruct{ + RootA: "aaa", + Sub: &pointerRecvFormatter{Foo: "x"}, + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub", "pointer-formatted"}}}, + }, + }, + { + name: "dotted section path for a whole struct", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + // Claiming the whole Sub struct also prunes its leaves from the main + // block, not just the exact dotted path. + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"RootB", "bbb"}}}, + {Title: "Sub", Rows: [][]string{{"X", "one"}, {"Y", "two"}}}, + }, + }, + { + name: "flattened slice fields", + data: RootSliceDummyStruct{ + RootA: "aaa", + Strings: []string{"s1", "s2"}, + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Strings.0", "s1"}, {"Strings.1", "s2"}}}, + }, + }, + { + name: "flattened map fields", + data: RootMapDummyStruct{ + RootA: "aaa", + Labels: map[string]string{"b": "two", "a": "one"}, + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Labels.a", "one"}, {"Labels.b", "two"}}}, + }, + }, + { + name: "nil composite field dropped", + data: RootSliceDummyStruct{ + RootA: "aaa", + }, + layout: human.Layout{}, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}}}, + }, + }, + { + name: "flat table with fields", + data: []DummyStruct{ + {Foo: "f", Bar: 1}, + {Foo: "b", Bar: 2}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Bar"}}}, + }, + }, + want: []human.Block{ + {Header: []string{"BAR"}, Rows: [][]string{{"1"}, {"2"}}}, + }, + }, + { + name: "table column with explicit label", + data: []DummyStruct{ + {Foo: "f", Bar: 1}, + {Foo: "b", Bar: 2}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Bar", Label: "Size"}}}, + }, + }, + want: []human.Block{ + {Header: []string{"Size"}, Rows: [][]string{{"1"}, {"2"}}}, + }, + }, + { + name: "table column with dotted path", + data: []RootDummyStruct{ + {RootA: "a", Sub: NestedDummyStruct{X: "x", Y: "y"}}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "RootA"}, {Path: "Sub.X"}}}, + }, + }, + want: []human.Block{ + {Header: []string{"ROOT A", "SUB X"}, Rows: [][]string{{"a", "x"}}}, + }, + }, + { + name: "table column with nil intermediate", + data: []NestedPointerFormatterDummyStruct{ + {RootA: "a"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Sub.Foo"}}}, + }, + }, + // The path is valid, but this item's Sub is nil: the cell degrades + // to "-" instead of failing the whole table, like v1. + want: []human.Block{ + {Header: []string{"SUB FOO"}, Rows: [][]string{{"-"}}}, + }, + }, + { + name: "struct lines filtered", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "RootA"}, {Path: "Sub.X"}}}, + }, + }, + want: []human.Block{ + {Rows: [][]string{{"RootA", "aaa"}, {"Sub.X", "one"}}}, + }, + }, + { + name: "selected composite subtree flattens", + data: RootSliceDummyStruct{ + RootA: "a", + Strings: []string{"s1", "s2"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Strings"}}}, + }, + }, + want: []human.Block{ + {Rows: [][]string{{"Strings.0", "s1"}, {"Strings.1", "s2"}}}, + }, + }, + { + name: "explicit layout overrides registry", + data: RegistryDummyStruct{ + RegistryField: "value", + RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "RegistrySection", Title: "Explicit"}, + }, + }, + want: []human.Block{ + {Rows: [][]string{{"RegistryField", "value"}}}, + {Title: "Explicit", Rows: [][]string{{"FirstLevelFieldA", "aaa"}, {"FirstLevelFieldB", "bbb"}}}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.layout.Resolve(tc.data) + + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestResolveUnknownField(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + }{ + { + name: "table column", + data: []DummyStruct{{Foo: "f", Bar: 1}}, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Nope"}}}, + }, + }, + }, + { + name: "struct line", + data: DummyStruct{Foo: "f", Bar: 1}, + layout: human.Layout{ + Sections: []human.Section{ + {Fields: []human.Field{{Path: "Nope"}}}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.layout.Resolve(tc.data) + require.Error(t, err) + + unknownFieldErr := &human.UnknownFieldError{} + require.ErrorAs(t, err, &unknownFieldErr) + require.Equal(t, "Nope", unknownFieldErr.FieldName) + require.Contains(t, unknownFieldErr.ValidFields, "Foo") + require.Contains(t, unknownFieldErr.ValidFields, "Bar") + require.NotContains(t, unknownFieldErr.ValidFields, "Nope") + }) + } +} + +func TestBlock_Draw(t *testing.T) { + for _, tc := range []struct { + name string + block human.Block + width int + want string + }{ + { + name: "table", + block: human.Block{ + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + }, + want: `Foo Bar +foo 1 +bar 2`, + }, + { + name: "key value lines", + block: human.Block{ + Rows: [][]string{{"Foo", "foo"}, {"Bar", "1"}}, + }, + want: `Foo foo +Bar 1`, + }, + { + name: "title and table", + block: human.Block{ + Title: "Required List", + Header: []string{"FirstLevelFieldA", "FirstLevelFieldB"}, + }, + want: `Required List: +FirstLevelFieldA FirstLevelFieldB`, + }, + { + name: "title and lines", + block: human.Block{ + Title: "Fields", + Rows: [][]string{{"a", "one"}, {"b", "two"}}, + }, + want: `Fields: +a one +b two`, + }, + { + name: "single cell", + block: human.Block{ + Rows: [][]string{{"-"}}, + }, + want: `-`, + }, + { + name: "table without headers", + block: human.Block{ + Header: []string{"Foo", "Bar"}, + Rows: [][]string{{"foo", "1"}, {"bar", "2"}}, + NoHeaders: true, + }, + want: `foo 1 +bar 2`, + }, + { + name: "table shrinks to fit width", + block: human.Block{ + Header: []string{"ID", "NAME", "TYPE"}, + Rows: [][]string{{"1", "xavier", "instance"}}, + }, + // Columns beyond the second drop once the running column width + // exceeds the given width; tabwriter pads 2-char cells to 5. + want: `ID NAME +1 xavier`, + width: 12, + }, + { + name: "no-headers table shrinks data rows", + block: human.Block{ + Header: []string{"ID", "NAME", "TYPE"}, + Rows: [][]string{{"1", "xavier", "instance"}}, + NoHeaders: true, + }, + want: `1 xavier`, + width: 12, + }, + { + name: "empty block draws nothing", + block: human.Block{}, + want: ``, + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.block.Draw(tc.width, 0)) + }) + } +} + +func TestRender(t *testing.T) { + for _, tc := range []struct { + name string + data any + layout human.Layout + want string + }{ + { + name: "nil", + data: nil, + layout: human.Layout{}, + want: "-", + }, + { + name: "nil pointer", + data: (*DummyStruct)(nil), + layout: human.Layout{}, + want: "-", + }, + { + name: "pointer to struct", + data: &DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "multi-level pointer to struct", + data: new(new(&DummyStruct{Foo: "foo", Bar: 1})), + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "scalar", + data: 42, + layout: human.Layout{}, + want: "42", + }, + { + name: "table without headers", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + layout: human.Layout{NoHeaders: true}, + want: `foo 1 +bar 2`, + }, + { + name: "table", + data: []DummyStruct{ + {Foo: "foo", Bar: 1}, + {Foo: "bar", Bar: 2}, + }, + layout: human.Layout{}, + want: `FOO BAR +foo 1 +bar 2`, + }, + { + name: "plain struct", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{}, + want: `Foo foo +Bar 1`, + }, + { + name: "registry layout fallback", + data: RegistryDummyStruct{ + RegistryField: "value", + RegistrySection: FirstLevelDummyStruct{FirstLevelFieldA: "aaa", FirstLevelFieldB: "bbb"}, + }, + // Zero layout: Render falls back to the one registered for the type. + layout: human.Layout{}, + want: `RegistryField value + +Custom: +FirstLevelFieldA aaa +FirstLevelFieldB bbb`, + }, + { + name: "joined sections", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + want: `RootA aaa +RootB bbb + +Sub: +X one +Y two`, + }, + { + name: "scalar sections joined with blank line", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Foo", Title: "A"}, + {Path: "Bar", Title: "B"}, + }, + }, + // v1 separates consecutive scalar sections with a blank line too. + want: `A: +foo + +B: +1`, + }, + { + name: "omittable skipped between two sections", + data: RootDummyStruct{ + RootA: "aaa", + // Sub zero -> omitted + RootB: "bbb", + }, + layout: human.Layout{ + Sections: []human.Section{ + {Path: "Sub", Title: "B", Omittable: true}, + {Path: "RootB", Title: "C"}, + }, + }, + // The skipped omittable section leaves no orphan blank line. + want: `RootA aaa + +C: +bbb`, + }, + { + name: "layout title before main section", + data: DummyStruct{Foo: "foo", Bar: 1}, + layout: human.Layout{ + Title: "Policy", + }, + want: `Policy: +Foo foo +Bar 1`, + }, + { + name: "layout title wraps sections", + data: RootDummyStruct{ + RootA: "aaa", + Sub: NestedDummyStruct{X: "one", Y: "two"}, + RootB: "bbb", + }, + layout: human.Layout{ + Title: "Details", + Sections: []human.Section{ + {Path: "Sub", Title: "Sub"}, + }, + }, + want: `Details: +RootA aaa +RootB bbb + +Sub: +X one +Y two`, + }, + { + name: "layout title wraps leaf", + data: "text", + layout: human.Layout{ + Title: "Details", + }, + want: `Details: +text`, + }, + { + // A registered struct escapes through Format at the top level instead + // of flattening into its raw fields. + name: "registered format func top-level", + data: RegistryFormatted{Foo: "x"}, + want: "registered-x", + }, + { + // Nested as a struct field, it formats in place rather than + // flattening. + name: "registered format func nested", + data: RegistryNestedStruct{Root: "r", Sub: RegistryFormatted{Foo: "s"}}, + want: "Root r\nSub registered-s", + }, + { + // Unexported fields are skipped by walk: reflect panics if a visitor + // calls Interface() on them. + name: "unexported struct field skipped", + data: UnexportedFieldStruct{Exported: "v", hidden: "secret"}, + want: "Exported v", + }, + { + // Same for error values: an error struct is a leaf rendering its + // message, so unexported fields aren't the point here anymore. + name: "error struct with unexported field", + data: UnexportedFieldError{Message: "boom", hidden: "secret"}, + want: "boom", + }, + { + // A plain error renders its message through the error case. + name: "error message", + data: errors.New("boom"), + want: "boom", + }, + { + // Wrapped errors keep the chain in the message. + name: "wrapped error message", + data: fmt.Errorf("could not open file: %w", errors.New("no such file")), + want: "could not open file: no such file", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := human.Render(tc.data, tc.layout) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestMaxCols(t *testing.T) { + grid := [][]string{ + {"ID", "NAME", "TYPE", "STATE", "ZONE", "PUBLIC IP"}, + {"1", "xavier", "instance", "running", "fr-par-1", "1.2.3.4"}, + } + for _, tc := range []struct { + width int + colPadding int + want int + }{ + {width: 3, colPadding: 2, want: 0}, + {width: 30, colPadding: 2, want: 3}, + {width: 40, colPadding: 2, want: 4}, + {width: 51, colPadding: 2, want: 5}, + {width: 60, colPadding: 2, want: 6}, + // colPadding is part of each column's estimated width: without it the + // same width fits every column. + {width: 40, colPadding: 0, want: 6}, + } { + require.Equal(t, tc.want, human.MaxCols(grid, tc.width, tc.colPadding)) + } +} diff --git a/core/printer.go b/core/printer.go index 16dcd341cd..b3ea76a3b6 100644 --- a/core/printer.go +++ b/core/printer.go @@ -10,7 +10,7 @@ import ( "text/template" "github.com/ghodss/yaml" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/gofields" ) @@ -169,7 +169,7 @@ func setupWidePrinter(printer *Printer, opts string) { printer.printerType = PrinterTypeWide } -func (p *Printer) Print(data any, opt *human.MarshalOpt) error { +func (p *Printer) Print(data any, opt *humanv2.Layout) error { // No matter the printer type if data is a RawResult we should print it as is. if rawResult, isRawResult := data.(RawResult); isRawResult { _, err := p.stdout.Write(rawResult) @@ -203,14 +203,14 @@ func (p *Printer) Print(data any, opt *human.MarshalOpt) error { return nil } -func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { +func (p *Printer) printHuman(data any, opt *humanv2.Layout) error { _, isError := data.(error) - if !isError { - if opt == nil { - opt = &human.MarshalOpt{} - } + if opt == nil { + opt = &humanv2.Layout{} + } + if !isError { if len(p.humanFields) > 0 && reflect.TypeOf(data).Kind() != reflect.Slice { return p.printHuman( errors.New( @@ -221,19 +221,26 @@ func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { } if len(p.humanFields) > 0 { - opt.Fields = []*human.MarshalFieldOpt(nil) + fields := make([]humanv2.Field, 0, len(p.humanFields)) for _, field := range p.humanFields { - opt.Fields = append(opt.Fields, &human.MarshalFieldOpt{ - FieldName: field, - }) + fields = append(fields, humanv2.Field{Path: field}) + } + // v1's MarshalOpt.Fields only applied to the default (pathless) + // section; mirror that by replacing the main section's fields. + sections := []humanv2.Section{{Path: "", Fields: fields}} + for _, s := range opt.Sections { + if s.Path != "" { + sections = append(sections, s) + } } + opt.Sections = sections } opt.NoHeaders = p.noHeaders } - str, err := human.Marshal(data, opt) - if unknownFieldErr, ok := errors.AsType[*human.UnknownFieldError](err); ok { + str, err := humanv2.Render(data, *opt) + if unknownFieldErr, ok := errors.AsType[*humanv2.UnknownFieldError](err); ok { return p.printHuman(&CliError{ Err: fmt.Errorf("unknown field '%s' in output options", unknownFieldErr.FieldName), Hint: "Valid fields are: " + strings.Join(unknownFieldErr.ValidFields, ", "), @@ -258,12 +265,12 @@ func (p *Printer) printHuman(data any, opt *human.MarshalOpt) error { return err } -func (p *Printer) printWide(data any, opt *human.MarshalOpt) error { +func (p *Printer) printWide(data any, opt *humanv2.Layout) error { if opt != nil { - opt.DisableShrinking = true + opt.NoShrinking = true } else { - opt = &human.MarshalOpt{ - DisableShrinking: true, + opt = &humanv2.Layout{ + NoShrinking: true, } } diff --git a/core/result.go b/core/result.go index a5e2e67081..800cd211fd 100644 --- a/core/result.go +++ b/core/result.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/fatih/color" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-cli/v2/internal/terminal" "github.com/scaleway/scaleway-sdk-go/strcase" @@ -30,23 +30,39 @@ var standardSuccessMessages = map[string]string{ "delete": "%s has been successfully deleted.", } -func (s *SuccessResult) MarshalHuman() (string, error) { - if s.Empty { - return "", nil - } - message := s.getMessage() - if !strings.HasSuffix(message, ".") { - message += "." - } +// init wires v2's human engine to the result types: SuccessResult renders +// its leaf string and MultiResults composes one per element. +func init() { + humanv2.RegisterFormatFunc(func(s SuccessResult) (string, error) { + if s.Empty { + return "", nil + } + message := s.getMessage() + if !strings.HasSuffix(message, ".") { + message += "." + } - message = strcase.TitleFirstWord(message) - message = "✅ " + terminal.Style(message, color.FgGreen) + message = strcase.TitleFirstWord(message) + message = "✅ " + terminal.Style(message, color.FgGreen) - if s.Details != "" { - message += "\n" + interactive.Indent(s.Details, 2) - } + if s.Details != "" { + message += "\n" + interactive.Indent(s.Details, 2) + } + + return message, nil + }) + humanv2.RegisterFormatFunc(func(mr MultiResults) (string, error) { + strs := []string(nil) + for _, r := range mr { + str, err := humanv2.Render(r, humanv2.Layout{}) + if err != nil { + return "", err + } + strs = append(strs, str) + } - return message, nil + return strings.Join(strs, "\n"), nil + }) } func (s *SuccessResult) MarshalJSON() ([]byte, error) { @@ -79,16 +95,3 @@ func (s *SuccessResult) getMessage() string { } type MultiResults []any - -func (mr *MultiResults) MarshalHuman() (string, error) { - strs := []string(nil) - for _, r := range *mr { - str, err := human.Marshal(r, nil) - if err != nil { - return "", err - } - strs = append(strs, str) - } - - return strings.Join(strs, "\n"), nil -} diff --git a/core/result_test.go b/core/result_test.go index 7ed2b04c45..e8368491fa 100644 --- a/core/result_test.go +++ b/core/result_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/scaleway/scaleway-cli/v2/core" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ func TestResult(t *testing.T) { Verb: "dummy", } - humanOutput, err := result.MarshalHuman() + humanOutput, err := humanv2.Format(result) require.NoError(t, err) assert.Empty(t, humanOutput) jsonOutput, err := result.MarshalJSON() diff --git a/core/shell.go b/core/shell.go index 61d4aac1e8..a0731fddd0 100644 --- a/core/shell.go +++ b/core/shell.go @@ -318,7 +318,7 @@ func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *Meta) func(s autoCompleteCache.Update(meta.command.Namespace) - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.result, meta.command.getHumanLayout()) if printErr != nil { _, _ = fmt.Fprintln(os.Stderr, printErr) } diff --git a/core/testdata/test-core-printer-human-simple-with-options.golden b/core/testdata/test-core-printer-human-simple-with-options.golden index 5f133a96c9..f930f63466 100644 --- a/core/testdata/test-core-printer-human-simple-with-options.golden +++ b/core/testdata/test-core-printer-human-simple-with-options.golden @@ -1,6 +1,6 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟥🟥🟥 STDERR️️ 🟥🟥🟥️ -List of fields for human output is only supported for commands that return a list +list of fields for human output is only supported for commands that return a list 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "111111111-111111111", diff --git a/core/testing.go b/core/testing.go index 7a0a3e3ded..1c09e62ecb 100644 --- a/core/testing.go +++ b/core/testing.go @@ -21,7 +21,7 @@ import ( "time" "github.com/hashicorp/go-version" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" args "github.com/scaleway/scaleway-cli/v2/internal/args" "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-cli/v2/internal/pkg/shlex" @@ -383,9 +383,8 @@ func Test(config *TestConfig) func(t *testing.T) { // Because human marshal of date is relative (e.g 3 minutes ago) we must make sure it stay consistent for golden to works. // Here we return a constant string. We may need to find a better place to put this. - human.RegisterMarshalerFunc( - time.Time{}, - func(_ any, _ *human.MarshalOpt) (string, error) { + humanv2.RegisterFormatFunc( + func(_ time.Time) (string, error) { return "few seconds ago", nil }, ) diff --git a/core/view.go b/core/view.go index 82d82969be..81719e5a5f 100644 --- a/core/view.go +++ b/core/view.go @@ -1,10 +1,10 @@ package core import ( - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" ) -// View hydrates human.MarshalOpt +// View hydrates humanv2.Layout type View struct { Title string Fields []*ViewField @@ -25,25 +25,29 @@ type ViewSection struct { HideIfEmpty bool } -func (v *View) getHumanMarshalerOpt() *human.MarshalOpt { +func (v *View) getHumanLayout() *humanv2.Layout { if v == nil { return nil } - opt := &human.MarshalOpt{} + + main := humanv2.Section{Path: ""} for _, field := range v.Fields { - opt.Fields = append(opt.Fields, &human.MarshalFieldOpt{ - FieldName: field.FieldName, - Label: field.Label, + main.Fields = append(main.Fields, humanv2.Field{ + Path: field.FieldName, + Label: field.Label, }) } + sections := []humanv2.Section{main} for _, section := range v.Sections { - opt.Sections = append(opt.Sections, &human.MarshalSection{ - Title: section.Title, - FieldName: section.FieldName, - HideIfEmpty: section.HideIfEmpty, + sections = append(sections, humanv2.Section{ + Title: section.Title, + Path: section.FieldName, + Omittable: section.HideIfEmpty, }) } - opt.Title = v.Title - return opt + return &humanv2.Layout{ + Title: v.Title, + Sections: sections, + } } diff --git a/go.mod b/go.mod index c7effe3a2c..df03ce6ae6 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-version v1.9.0 github.com/karrick/tparse/v2 v2.8.2 + github.com/kr/pretty v0.3.1 github.com/mattn/go-colorable v0.1.16 github.com/mattn/go-isatty v0.0.24 github.com/moby/buildkit v0.34.1 @@ -139,6 +140,7 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -169,6 +171,7 @@ require ( github.com/prometheus/procfs v0.21.1 // indirect github.com/rivo/tview v0.42.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect diff --git a/go.sum b/go.sum index 4100f1aebd..2c453db473 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,7 @@ github.com/containerd/ttrpc v1.2.10/go.mod h1:YrmEQKkMbBA4EM26AzVAIwHGIYgptkL4CJ github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= @@ -410,6 +411,7 @@ github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -432,6 +434,7 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/namespaces/applesilicon/v1alpha1/custom.go b/internal/namespaces/applesilicon/v1alpha1/custom.go index f14972c6cf..191f39e636 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom.go @@ -1,8 +1,11 @@ package applesilicon import ( + "fmt" + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" ) @@ -16,25 +19,26 @@ func GetCommands() *core.Commands { cmds.MustFind("apple-silicon").Groups = []string{"baremetal"} - human.RegisterMarshalerFunc(applesilicon.ServerTypeCPU{}, cpuMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.ServerTypeDisk{}, diskMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.ServerTypeMemory{}, memoryMarshalerFunc) - human.RegisterMarshalerFunc(applesilicon.OS{}, OSMarshalerFunc) - - human.RegisterMarshalerFunc( - applesilicon.ServerStatus(""), - human.EnumMarshalFunc(serverStatusMarshalSpecs), - ) - human.RegisterMarshalerFunc( - applesilicon.ServerTypeStock(""), - human.EnumMarshalFunc(serverTypeStockMarshalSpecs), - ) - cmds.MustFind("apple-silicon", "server", "create").Override(serverCreateBuilder) cmds.MustFind("apple-silicon", "server", "reboot").Override(serverRebootBuilder) cmds.MustFind("apple-silicon", "server", "delete").Override(serverDeleteBuilder) cmds.MustFind("apple-silicon", "server-type", "list").Override(serverTypeBuilder) + humanv2.RegisterLayout(reflect.TypeFor[applesilicon.OS](), osLayout) + + humanv2.RegisterFormatFunc(func(cpu applesilicon.ServerTypeCPU) (string, error) { + return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil + }) + humanv2.RegisterFormatFunc(func(disk applesilicon.ServerTypeDisk) (string, error) { + return humanv2.Format(disk.Capacity) + }) + humanv2.RegisterFormatFunc(func(memory applesilicon.ServerTypeMemory) (string, error) { + return humanv2.Format(memory.Capacity) + }) + + humanv2.RegisterEnumFormat(serverStatusFormat) + humanv2.RegisterEnumFormat(serverTypeStockFormat) + return cmds } diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_os.go b/internal/namespaces/applesilicon/v1alpha1/custom_os.go index c21a814a7d..e6924f26df 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_os.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_os.go @@ -1,23 +1,13 @@ package applesilicon import ( - "github.com/scaleway/scaleway-cli/v2/core/human" - applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" ) -func OSMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp applesilicon.OS - os := tmp(i.(applesilicon.OS)) - opt.Sections = []*human.MarshalSection{ +var osLayout = humanv2.Layout{ + Sections: []humanv2.Section{ { - FieldName: "CompatibleServerTypes", - Title: "CompatibleServerTypes", + Path: "CompatibleServerTypes", }, - } - str, err := human.Marshal(os, opt) - if err != nil { - return "", err - } - - return str, nil + }, } diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_server.go b/internal/namespaces/applesilicon/v1alpha1/custom_server.go index ba1f6f7a73..efe1b48c59 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_server.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_server.go @@ -11,7 +11,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -26,27 +26,12 @@ const ( serverActionReboot ) -var serverStatusMarshalSpecs = human.EnumMarshalSpecs{ - applesilicon.ServerStatusError: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "error", - }, - applesilicon.ServerStatusReady: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "ready", - }, - applesilicon.ServerStatusRebooting: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "rebooting", - }, - applesilicon.ServerStatusStarting: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "starting", - }, - applesilicon.ServerStatusUpdating: &human.EnumMarshalSpec{ - Attribute: color.FgBlue, - Value: "updating", - }, +var serverStatusFormat = humanv2.EnumFormats[applesilicon.ServerStatus]{ + applesilicon.ServerStatusError: humanv2.EnumFormat{Color: color.FgRed, Value: "error"}, + applesilicon.ServerStatusReady: humanv2.EnumFormat{Color: color.FgGreen, Value: "ready"}, + applesilicon.ServerStatusRebooting: humanv2.EnumFormat{Color: color.FgBlue, Value: "rebooting"}, + applesilicon.ServerStatusStarting: humanv2.EnumFormat{Color: color.FgBlue, Value: "starting"}, + applesilicon.ServerStatusUpdating: humanv2.EnumFormat{Color: color.FgBlue, Value: "updating"}, } func serverCreateBuilder(c *core.Command) *core.Command { diff --git a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go index 39e48e7435..bbaa927ac5 100644 --- a/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go +++ b/internal/namespaces/applesilicon/v1alpha1/custom_server_types.go @@ -2,56 +2,20 @@ package applesilicon import ( "context" - "fmt" "strings" "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" applesilicon "github.com/scaleway/scaleway-sdk-go/api/applesilicon/v1alpha1" productcatalog "github.com/scaleway/scaleway-sdk-go/api/product_catalog/v2alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) -var serverTypeStockMarshalSpecs = human.EnumMarshalSpecs{ - applesilicon.ServerTypeStockLowStock: &human.EnumMarshalSpec{ - Attribute: color.FgYellow, - Value: "low stock", - }, - applesilicon.ServerTypeStockNoStock: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "no stock", - }, - applesilicon.ServerTypeStockHighStock: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "high stock", - }, -} - -func cpuMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - cpu := i.(applesilicon.ServerTypeCPU) - - return fmt.Sprintf("%s (%d cores)", cpu.Name, cpu.CoreCount), nil -} - -func diskMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - disk := i.(applesilicon.ServerTypeDisk) - capacityStr, err := human.Marshal(disk.Capacity, nil) - if err != nil { - return "", err - } - - return capacityStr, nil -} - -func memoryMarshalerFunc(i any, _ *human.MarshalOpt) (string, error) { - memory := i.(applesilicon.ServerTypeMemory) - capacityStr, err := human.Marshal(memory.Capacity, nil) - if err != nil { - return "", err - } - - return capacityStr, nil +var serverTypeStockFormat = humanv2.EnumFormats[applesilicon.ServerTypeStock]{ + applesilicon.ServerTypeStockLowStock: humanv2.EnumFormat{Color: color.FgYellow, Value: "low stock"}, + applesilicon.ServerTypeStockNoStock: humanv2.EnumFormat{Color: color.FgRed, Value: "no stock"}, + applesilicon.ServerTypeStockHighStock: humanv2.EnumFormat{Color: color.FgGreen, Value: "high stock"}, } type customServerType struct { diff --git a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden index bfc7922dbc..909838598c 100644 --- a/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden +++ b/internal/namespaces/applesilicon/v1alpha1/testdata/test-os-display-simple.golden @@ -10,10 +10,10 @@ Version 14.4 XcodeVersion 15 ReleaseNotesURL - Description - -Zone +Zone -CompatibleServerTypes: -[M1-M M2-M M2-L] +Compatible Server Types: +3 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "e08d1e5d-b4b9-402a-9f9a-97732d17e374", diff --git a/internal/namespaces/autoscaling/v1alpha2/custom.go b/internal/namespaces/autoscaling/v1alpha2/custom.go index f557739c1d..120a2df474 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom.go @@ -1,16 +1,16 @@ package autoscaling import ( + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) func GetCommands() *core.Commands { commands := GetGeneratedCommands() - human.RegisterMarshalerFunc(autoscaling.Group{}, groupMarshalerFunc) - commands.MustFind("autoscaling", "group", "create").Override(GroupCreateBuilder) commands.MustFind("autoscaling", "group", "update").Override(GroupUpdateBuilder) commands.MustFind("autoscaling", "group", "list").Override(GroupListBuilder) @@ -21,5 +21,7 @@ func GetCommands() *core.Commands { commands.MustFind("autoscaling", "logs", "list").Override(LogsListBuilder) commands.MustFind("autoscaling", "servers", "list").Override(ServersListBuilder) + humanv2.RegisterLayout(reflect.TypeFor[autoscaling.Group](), groupLayout) + return commands } diff --git a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go index 274fa7da5e..ee25277c26 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling.go @@ -5,31 +5,17 @@ import ( "fmt" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" autoscaling "github.com/scaleway/scaleway-sdk-go/api/autoscaling/v1alpha2" ) -func groupMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp autoscaling.Group - template := tmp(i.(autoscaling.Group)) - - opt.Sections = []*human.MarshalSection{ - {FieldName: "Tags", HideIfEmpty: true}, - {FieldName: "ScalingPolicy", Title: "Scaling Policy"}, - {FieldName: "OpenAlerts", HideIfEmpty: true, Title: "Open Alerts"}, - { - FieldName: "LoadBalancerConfiguration", - HideIfEmpty: true, - Title: "LoadBalancer Configuration", - }, - } - - str, err := human.Marshal(template, opt) - if err != nil { - return "", err - } - - return str, nil +var groupLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "Tags", Omittable: true}, + {Path: "ScalingPolicy", Title: "Scaling Policy"}, + {Path: "OpenAlerts", Omittable: true, Title: "Open Alerts"}, + {Path: "LoadBalancerConfiguration", Omittable: true, Title: "LoadBalancer Configuration"}, + }, } func GroupCreateBuilder(c *core.Command) *core.Command { diff --git a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go index e078f081af..4b06180c0b 100644 --- a/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go +++ b/internal/namespaces/autoscaling/v1alpha2/custom_autoscaling_test.go @@ -78,7 +78,7 @@ func Test_Group(t *testing.T) { " scaling-policy-spec.minimum-size=1 scaling-policy-spec.maximum-size=5", Check: core.TestCheckCombine( core.TestCheckStderrContains( - `At least one argument from the 'scaling-policy-target' group is required`, + `at least one argument from the 'scaling-policy-target' group is required`, ), core.TestCheckExitCode(1), ), diff --git a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden index df3a81bcd9..b072ed7d9f 100644 --- a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden +++ b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-create-ok.golden @@ -10,7 +10,7 @@ Status active CurrentSize 0 TargetSize 0 TemplateID cca81e91-dce9-43fd-bf35-81572e1f4554 -Zone +Zone Tags: [cli-test-asg] diff --git a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden index a7cd6bc3ae..359d5ef988 100644 --- a/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden +++ b/internal/namespaces/autoscaling/v1alpha2/testdata/test-group-update.golden @@ -10,7 +10,7 @@ Status active CurrentSize 0 TargetSize 0 TemplateID 04d0e5ad-78e5-4a97-b0f0-2cec1c0020df -Zone +Zone Tags: [tags were updated] diff --git a/internal/namespaces/baremetal/v1/custom.go b/internal/namespaces/baremetal/v1/custom.go index 64cc6c204f..38e0fec8e7 100644 --- a/internal/namespaces/baremetal/v1/custom.go +++ b/internal/namespaces/baremetal/v1/custom.go @@ -1,8 +1,10 @@ package baremetal import ( + "reflect" + "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" baremetalV3 "github.com/scaleway/scaleway-cli/v2/internal/namespaces/baremetal/v3" baremetal "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" ) @@ -17,17 +19,10 @@ func GetCommands() *core.Commands { serverAddFlexibleIP(), )) - human.RegisterMarshalerFunc( - baremetal.ServerPingStatus(""), - human.EnumMarshalFunc(serverPingStatusMarshalSpecs), - ) - human.RegisterMarshalerFunc( - baremetal.OfferStock(""), - human.EnumMarshalFunc(offerAvailabilityMarshalSpecs), - ) - human.RegisterMarshalerFunc(baremetal.Server{}, serverMarshalerFunc) - - human.RegisterMarshalerFunc(baremetal.Offer{}, listOfferMarshalerFunc) + humanv2.RegisterLayout(reflect.TypeFor[baremetal.Server](), serverLayout) + humanv2.RegisterLayout(reflect.TypeFor[baremetal.Offer](), offerLayout) + humanv2.RegisterEnumFormat(offerStockFormat) + humanv2.RegisterEnumFormat(pingStatusFormat) cmds.MustFind("baremetal", "server", "create").Override(serverCreateBuilder) cmds.MustFind("baremetal", "server", "install").Override(serverInstallBuilder) diff --git a/internal/namespaces/baremetal/v1/custom_offer.go b/internal/namespaces/baremetal/v1/custom_offer.go index 390135e72b..2b6c93c5c8 100644 --- a/internal/namespaces/baremetal/v1/custom_offer.go +++ b/internal/namespaces/baremetal/v1/custom_offer.go @@ -5,67 +5,46 @@ import ( "slices" "strings" + "github.com/dustin/go-humanize" "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" productcatalog "github.com/scaleway/scaleway-sdk-go/api/product_catalog/v2alpha1" "github.com/scaleway/scaleway-sdk-go/scw" ) -var offerAvailabilityMarshalSpecs = human.EnumMarshalSpecs{ - baremetal.OfferStockEmpty: &human.EnumMarshalSpec{Attribute: color.FgRed, Value: "empty"}, - baremetal.OfferStockLow: &human.EnumMarshalSpec{Attribute: color.FgYellow, Value: "low"}, - baremetal.OfferStockAvailable: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "available", +var offerStockFormat = humanv2.EnumFormats[baremetal.OfferStock]{ + baremetal.OfferStockEmpty: humanv2.EnumFormat{Color: color.FgRed, Value: "empty"}, + baremetal.OfferStockLow: humanv2.EnumFormat{Color: color.FgYellow, Value: "low"}, + baremetal.OfferStockAvailable: humanv2.EnumFormat{Color: color.FgGreen, Value: "available"}, +} + +var offerLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "Disks", Title: "Disks"}, + {Path: "CPUs", Title: "CPUs"}, + {Path: "Memories", Title: "Memories"}, + {Path: "Options", Title: "Options"}, + {Path: "Bandwidth", Title: "Bandwidth(Mbit/s)"}, + {Path: "PrivateBandwidth", Title: "PrivateBandwidth(Mbit/s)"}, }, } -func listOfferMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { - type tmp baremetal.Offer - baremetalOffer := tmp(i.(baremetal.Offer)) - opt.Sections = []*human.MarshalSection{ - { - FieldName: "Disks", - Title: "Disks", - }, - { - FieldName: "CPUs", - Title: "CPUs", - }, - { - FieldName: "Memories", - Title: "Memories", - }, - { - FieldName: "Options", - Title: "Options", - }, - { - FieldName: "Bandwidth", - Title: "Bandwidth(Mbit/s)", - }, - { - FieldName: "PrivateBandwidth", - Title: "PrivateBandwidth(Mbit/s)", - }, - } - baremetalOffer.PrivateBandwidth /= 1000000 - baremetalOffer.Bandwidth /= 1000000 - str, err := human.Marshal(baremetalOffer, opt) - if err != nil { - return "", err - } +type Bandwidth uint64 - return str, nil +func (b Bandwidth) String() string { + return humanize.Bytes(uint64(b)) + "/s" } type customOffer struct { *baremetal.Offer - KgCo2Equivalent *float32 `json:"kg_co2_equivalent"` - M3WaterUsage *float32 `json:"m3_water_usage"` - CloudInitSupported bool `json:"cloud_init_supported"` + Bandwidth Bandwidth `json:"bandwidth"` + MaxBandwidth Bandwidth `json:"max_bandwidth"` + PrivateBandwidth Bandwidth `json:"private_bandwidth"` + KgCo2Equivalent *float32 `json:"kg_co2_equivalent"` + M3WaterUsage *float32 `json:"m3_water_usage"` + CloudInitSupported bool `json:"cloud_init_supported"` } func serverOfferListBuilder(c *core.Command) *core.Command { @@ -134,25 +113,21 @@ func serverOfferListBuilder(c *core.Command) *core.Command { var customOfferRes []customOffer for _, offer := range offers.Offers { - cloudInitSupported := slices.Contains(offer.Tags, "cloud-init") + custom := customOffer{ + Offer: offer, + Bandwidth: Bandwidth(offer.Bandwidth), + MaxBandwidth: Bandwidth(offer.MaxBandwidth), + PrivateBandwidth: Bandwidth(offer.PrivateBandwidth), + CloudInitSupported: slices.Contains(offer.Tags, "cloud-init"), + } + impact, ok := impactMap[offer.Name] - if !ok { - customOfferRes = append(customOfferRes, customOffer{ - Offer: offer, - KgCo2Equivalent: nil, - M3WaterUsage: nil, - CloudInitSupported: cloudInitSupported, - }) - - continue + if ok { + custom.KgCo2Equivalent = impact.EnvironmentalImpactEstimation.KgCo2Equivalent + custom.M3WaterUsage = impact.EnvironmentalImpactEstimation.M3WaterUsage } - customOfferRes = append(customOfferRes, customOffer{ - Offer: offer, - KgCo2Equivalent: impact.EnvironmentalImpactEstimation.KgCo2Equivalent, - M3WaterUsage: impact.EnvironmentalImpactEstimation.M3WaterUsage, - CloudInitSupported: cloudInitSupported, - }) + customOfferRes = append(customOfferRes, custom) } return customOfferRes, nil diff --git a/internal/namespaces/baremetal/v1/custom_server.go b/internal/namespaces/baremetal/v1/custom_server.go index 02b4f0b40a..30ff7ac26c 100644 --- a/internal/namespaces/baremetal/v1/custom_server.go +++ b/internal/namespaces/baremetal/v1/custom_server.go @@ -10,6 +10,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" baremetal "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" "github.com/scaleway/scaleway-sdk-go/logger" "github.com/scaleway/scaleway-sdk-go/scw" @@ -19,19 +20,10 @@ const ( ServerActionTimeout = 20 * time.Minute ) -var serverPingStatusMarshalSpecs = human.EnumMarshalSpecs{ - baremetal.ServerPingStatusPingStatusDown: &human.EnumMarshalSpec{ - Attribute: color.FgRed, - Value: "down", - }, - baremetal.ServerPingStatusPingStatusUp: &human.EnumMarshalSpec{ - Attribute: color.FgGreen, - Value: "up", - }, - baremetal.ServerPingStatusPingStatusUnknown: &human.EnumMarshalSpec{ - Attribute: color.Faint, - Value: "unknown", - }, +var pingStatusFormat = humanv2.EnumFormats[baremetal.ServerPingStatus]{ + baremetal.ServerPingStatusPingStatusDown: humanv2.EnumFormat{Color: color.FgRed, Value: "down"}, + baremetal.ServerPingStatusPingStatusUp: humanv2.EnumFormat{Color: color.FgGreen, Value: "up"}, + baremetal.ServerPingStatusPingStatusUnknown: humanv2.EnumFormat{Color: color.Faint, Value: "unknown"}, } func serverWaitCommand() *core.Command { @@ -236,6 +228,14 @@ func serverListBuilder(c *core.Command) *core.Command { return c } +var serverLayout = humanv2.Layout{ + Sections: []humanv2.Section{ + {Path: "IPs", Title: "IPs"}, + {Path: "Options", Title: "Options", Omittable: true}, + {Path: "Install", Title: "Install", Omittable: true}, + }, +} + func serverMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { type tmp baremetal.Server baremetalServer := tmp(i.(baremetal.Server)) diff --git a/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden b/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden index 069d0b34f8..a059c222b8 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-baremetal-get-offer-simple.golden @@ -47,20 +47,20 @@ CAPACITY TYPE FREQUENCY IS ECC ECC TYPE 96 GB DDR3 1600 true standard Options: -ID NAME ENABLED SUBSCRIPTION PERIOD PRICE MANAGEABLE OS ID -31f78309-e6e0-4005-99b3-b3de3958ca7d HDS false monthly € 15.00 false 00000000-0000-0000-0000-000000000000 -c498071f-4f8d-49f3-bc52-afc219a48fe0 Windows 2022 datacenter false monthly € 168.00 false 3422ae02-d6d7-4980-8a00-eb38c262fb41 -931df052-d713-4674-8b58-96a63244c8e2 Remote Access false hourly € 0.00 true 00000000-0000-0000-0000-000000000000 -cd4158d7-2d65-49be-8803-c4b8ab6f760c Private Network false hourly € 0.03 true 00000000-0000-0000-0000-000000000000 -f42a040b-6c91-4b11-beb0-b0b21e641fda Windows 2019 standard false monthly € 25.20 false 7c70ff80-dbfa-4e07-be73-1de0e60fe175 -f74a6322-070e-41dd-a13f-8165ea63aca8 Windows 2019 datacenter false monthly € 168.00 false e04414b9-16d4-4ffb-8ba1-6b80704c7d2a -83a8fb91-47b4-4c20-82f9-84c31d79bfd8 Windows 2022 standard false monthly € 25.20 false bf705b90-1548-4368-98ca-d17dbd8dd860 +ID NAME ENABLED SUBSCRIPTION PERIOD PRICE MANAGEABLE OS ID LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION +31f78309-e6e0-4005-99b3-b3de3958ca7d HDS false monthly € 15.00 false 00000000-0000-0000-0000-000000000000 - - - - {} +c498071f-4f8d-49f3-bc52-afc219a48fe0 Windows 2022 datacenter false monthly € 168.00 false 3422ae02-d6d7-4980-8a00-eb38c262fb41 {3422ae02-d6d7-4980-8a00-eb38c262fb41} - - - - +931df052-d713-4674-8b58-96a63244c8e2 Remote Access false hourly € 0.00 true 00000000-0000-0000-0000-000000000000 - - - {} - +cd4158d7-2d65-49be-8803-c4b8ab6f760c Private Network false hourly € 0.03 true 00000000-0000-0000-0000-000000000000 - - {1000000000} - - +f42a040b-6c91-4b11-beb0-b0b21e641fda Windows 2019 standard false monthly € 25.20 false 7c70ff80-dbfa-4e07-be73-1de0e60fe175 {7c70ff80-dbfa-4e07-be73-1de0e60fe175} - - - - +f74a6322-070e-41dd-a13f-8165ea63aca8 Windows 2019 datacenter false monthly € 168.00 false e04414b9-16d4-4ffb-8ba1-6b80704c7d2a {e04414b9-16d4-4ffb-8ba1-6b80704c7d2a} - - - - +83a8fb91-47b4-4c20-82f9-84c31d79bfd8 Windows 2022 standard false monthly € 25.20 false bf705b90-1548-4368-98ca-d17dbd8dd860 {bf705b90-1548-4368-98ca-d17dbd8dd860} - - - - Bandwidth(Mbit/s): -1000 +1000000000 PrivateBandwidth(Mbit/s): -1000 +1000000000 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "c5853302-63e4-40c7-a711-4a91629565c8", diff --git a/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden b/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden index 7393db2503..7cbba107bf 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-baremetal-list-offer-simple.golden @@ -1,50 +1,48 @@ 🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 🟩🟩🟩 STDOUT️ 🟩🟩🟩️ -ID Name Stock Disks CPUs Memories Bandwidth PrivateBandwidth CloudInit supported CO2 (kg/hour) Water (m³/hour) Price per hour -8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7 EM-A116X-SSD available 2 1 1 500000000 1000000000 true 0.01281002 5.890525e-07 € 0.077 -e1680dec-52aa-4cbc-b70c-ef32d863fdb0 EM-A610R-NVME empty 2 1 1 500000000 1000000000 true - - € 0.11 -bd757ca3-a71b-4158-9ef5-39436b6db2a4 EM-L101X-SATA low 2 1 1 1000000000 1000000000 true 0.0155900745 7.3156525e-07 € 0.122 -6ec5bbb2-8267-41cd-aaf6-d43ea1b59372 EM-A315X-SSD empty 2 1 1 1000000000 1000000000 true 0.015545019 7.695686e-07 € 0.153 -5a505e81-5f6a-42bd-acec-0b290be92697 EM-A410X-SSD empty 2 1 1 1000000000 1000000000 true 0.015003014 6.650593e-07 € 0.153 -c5853302-63e4-40c7-a711-4a91629565c8 EM-L105X-SATA available 4 1 1 1000000000 1000000000 true 0.016983224 7.600678e-07 € 0.213 -0964171b-4716-4403-9f6a-2f40d4230fa5 EM-B130E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.247 -8c2cf291-a3d3-4dc5-967f-f498a088411d EM-B111X-SATA available 2 2 1 1000000000 1000000000 true 0.022749875 1.1115991e-06 € 0.263 -a5065ba4-dde2-45f3-adec-1ebbb27b766b EM-B112X-SSD available 2 2 1 1000000000 1000000000 true 0.02332065 1.1115991e-06 € 0.263 -835ef36b-9659-450b-9110-8119deae0e15 EM-B130E-NVME-64G empty 2 1 1 3000000000 10000000000 true - - € 0.315 -c753f736-fbb4-4689-ae93-623f9d08dce5 EM-B211X-SATA empty 2 2 1 1000000000 1000000000 false 0.024065403 1.0450932e-06 € 0.321 -ddaf8ba6-b2b2-4279-8af3-51930fb602f8 EM-B212X-SSD empty 2 2 1 1000000000 1000000000 true 0.026313491 1.2351102e-06 € 0.321 -0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65 EM-B230E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.329 -206ea234-9097-4ae1-af68-6d2be09f47ed EM-B220E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.333 -a204136d-656b-44b7-9735-88ca2f62cb1f EM-L110X-SATA available 4 1 1 1000000000 1000000000 true 0.016983224 7.600678e-07 € 0.366 -0e1f7c5b-05bf-4863-ba58-9ee07496d1f7 EM-I120E-NVME low 2 1 1 1000000000 25000000000 true - - € 0.37 -e9dee4f8-c37b-42a2-8bc1-1409218973d5 EM-B230E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.411 -a60ae97c-268c-40cb-af5f-dd276e917ed7 EM-B320E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.417 -6fb2a065-b4fd-4436-aebe-da0d2ecaf641 EM-B330E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.438 -d14eef5e-3279-42db-bb57-bb2bbe6a5b28 EM-B420E-NVME low 2 1 1 1000000000 10000000000 true - - € 0.444 -faaf5d66-99d3-46d0-a948-5c1b65caf2a3 EM-B330E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.521 -d73011fa-02e0-46bc-992c-8bb62c767e97 EM-B520E-NVME low 2 1 1 1000000000 10000000000 true - - € 0.528 -1b46dfad-ec29-4f6e-af14-6af753e15630 EM-B430E-NVME empty 2 1 1 3000000000 10000000000 true - - € 0.548 -fa9c839b-c6de-401e-9156-e9b9016d712b EM-B430E-NVME-128G empty 2 1 1 3000000000 10000000000 true - - € 0.63 -fd7605a7-7493-49ba-9019-0e9c2897d4a0 EM-I220E-NVME low 2 1 1 1000000000 25000000000 true - - € 0.63 -7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a EM-I210E-NVME empty 2 1 1 1000000000 10000000000 true - - € 0.667 -402efe20-4dcb-49af-bc80-1008bcc749a6 EM-L220E-NVME low 4 1 1 1000000000 10000000000 true - - € 0.685 -ddb09834-8680-4339-b5e3-19ca5205fa1f EM-I215E-NVME available 2 1 1 1000000000 10000000000 true - - € 0.904 -66cdd2c4-664c-47af-b0ad-a70e12999f0e EM-I320E-NVME available 2 1 1 1000000000 25000000000 true - - € 0.959 -18137894-b9a7-4e7b-b026-518ab8a3e41e EM-L420E-NVME low 4 1 1 1000000000 10000000000 true - - € 1.014 -6e905149-d776-49c1-824d-8161f40968e9 EM-I420E-NVME low 2 1 1 1000000000 25000000000 true - - € 1.233 -526f75ed-4482-4e57-9c53-82f2983129ba EM-L520E-NVME empty 4 1 1 1000000000 25000000000 true - - € 1.233 -b20ba18e-34e8-44aa-b909-93cbff7635b5 EM-I520E-NVME empty 2 1 1 1000000000 25000000000 true - - € 1.562 -51131a3a-7a9b-44c7-8228-2c071c185b77 EM-I620E-NVME empty 2 1 1 1000000000 25000000000 true - - € 1.863 -c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 1 1000000000 25000000000 true - - € 2.055 -49c3ff54-7949-4a53-8f52-f2ea17c451b1 EM-I525E-NVME empty 4 1 1 1000000000 25000000000 true - - € 2.877 +ID Name Stock Disks CPUs Memories Bandwidth PrivateBandwidth CloudInit supported CO2 (kg/hour) Water (m³/hour) Price per hour +8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7 EM-A116X-SSD available 2 1 1 500 MB/s 1.0 GB/s true 0.01281002 5.890525e-07 € 0.077 +e1680dec-52aa-4cbc-b70c-ef32d863fdb0 EM-A610R-NVME empty 2 1 1 500 MB/s 1.0 GB/s true - - € 0.11 +bd757ca3-a71b-4158-9ef5-39436b6db2a4 EM-L101X-SATA low 2 1 1 1.0 GB/s 1.0 GB/s true 0.0155900745 7.3156525e-07 € 0.122 +6ec5bbb2-8267-41cd-aaf6-d43ea1b59372 EM-A315X-SSD empty 2 1 1 1.0 GB/s 1.0 GB/s true 0.015545019 7.695686e-07 € 0.153 +5a505e81-5f6a-42bd-acec-0b290be92697 EM-A410X-SSD empty 2 1 1 1.0 GB/s 1.0 GB/s true 0.015003014 6.650593e-07 € 0.153 +c5853302-63e4-40c7-a711-4a91629565c8 EM-L105X-SATA available 4 1 1 1.0 GB/s 1.0 GB/s true 0.016983224 7.600678e-07 € 0.213 +0964171b-4716-4403-9f6a-2f40d4230fa5 EM-B130E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.247 +8c2cf291-a3d3-4dc5-967f-f498a088411d EM-B111X-SATA available 2 2 1 1.0 GB/s 1.0 GB/s true 0.022749875 1.1115991e-06 € 0.263 +a5065ba4-dde2-45f3-adec-1ebbb27b766b EM-B112X-SSD available 2 2 1 1.0 GB/s 1.0 GB/s true 0.02332065 1.1115991e-06 € 0.263 +835ef36b-9659-450b-9110-8119deae0e15 EM-B130E-NVME-64G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.315 +c753f736-fbb4-4689-ae93-623f9d08dce5 EM-B211X-SATA empty 2 2 1 1.0 GB/s 1.0 GB/s false 0.024065403 1.0450932e-06 € 0.321 +ddaf8ba6-b2b2-4279-8af3-51930fb602f8 EM-B212X-SSD empty 2 2 1 1.0 GB/s 1.0 GB/s true 0.026313491 1.2351102e-06 € 0.321 +0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65 EM-B230E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.329 +206ea234-9097-4ae1-af68-6d2be09f47ed EM-B220E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.333 +a204136d-656b-44b7-9735-88ca2f62cb1f EM-L110X-SATA available 4 1 1 1.0 GB/s 1.0 GB/s true 0.016983224 7.600678e-07 € 0.366 +0e1f7c5b-05bf-4863-ba58-9ee07496d1f7 EM-I120E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 0.37 +e9dee4f8-c37b-42a2-8bc1-1409218973d5 EM-B230E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.411 +a60ae97c-268c-40cb-af5f-dd276e917ed7 EM-B320E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.417 +6fb2a065-b4fd-4436-aebe-da0d2ecaf641 EM-B330E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.438 +d14eef5e-3279-42db-bb57-bb2bbe6a5b28 EM-B420E-NVME low 2 1 1 1.0 GB/s 10 GB/s true - - € 0.444 +faaf5d66-99d3-46d0-a948-5c1b65caf2a3 EM-B330E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.521 +d73011fa-02e0-46bc-992c-8bb62c767e97 EM-B520E-NVME low 2 1 1 1.0 GB/s 10 GB/s true - - € 0.528 +1b46dfad-ec29-4f6e-af14-6af753e15630 EM-B430E-NVME empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.548 +fa9c839b-c6de-401e-9156-e9b9016d712b EM-B430E-NVME-128G empty 2 1 1 3.0 GB/s 10 GB/s true - - € 0.63 +fd7605a7-7493-49ba-9019-0e9c2897d4a0 EM-I220E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 0.63 +7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a EM-I210E-NVME empty 2 1 1 1.0 GB/s 10 GB/s true - - € 0.667 +402efe20-4dcb-49af-bc80-1008bcc749a6 EM-L220E-NVME low 4 1 1 1.0 GB/s 10 GB/s true - - € 0.685 +ddb09834-8680-4339-b5e3-19ca5205fa1f EM-I215E-NVME available 2 1 1 1.0 GB/s 10 GB/s true - - € 0.904 +66cdd2c4-664c-47af-b0ad-a70e12999f0e EM-I320E-NVME available 2 1 1 1.0 GB/s 25 GB/s true - - € 0.959 +18137894-b9a7-4e7b-b026-518ab8a3e41e EM-L420E-NVME low 4 1 1 1.0 GB/s 10 GB/s true - - € 1.014 +6e905149-d776-49c1-824d-8161f40968e9 EM-I420E-NVME low 2 1 1 1.0 GB/s 25 GB/s true - - € 1.233 +526f75ed-4482-4e57-9c53-82f2983129ba EM-L520E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 1.233 +b20ba18e-34e8-44aa-b909-93cbff7635b5 EM-I520E-NVME empty 2 1 1 1.0 GB/s 25 GB/s true - - € 1.562 +51131a3a-7a9b-44c7-8228-2c071c185b77 EM-I620E-NVME empty 2 1 1 1.0 GB/s 25 GB/s true - - € 1.863 +c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 2.055 +49c3ff54-7949-4a53-8f52-f2ea17c451b1 EM-I525E-NVME empty 4 1 1 1.0 GB/s 25 GB/s true - - € 2.877 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 [ { "id": "8779d2c1-cd10-4a34-a006-cb5b1fb5cbc7", "name": "EM-A116X-SSD", "stock": "available", - "bandwidth": 500000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -164,7 +162,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -175,6 +172,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 500000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.01281002, "m3_water_usage": 5.890525e-7, "cloud_init_supported": true @@ -183,8 +183,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "e1680dec-52aa-4cbc-b70c-ef32d863fdb0", "name": "EM-A610R-NVME", "stock": "empty", - "bandwidth": 500000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -422,7 +420,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "new", @@ -434,6 +431,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 500000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -442,8 +442,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "bd757ca3-a71b-4158-9ef5-39436b6db2a4", "name": "EM-L101X-SATA", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -615,7 +613,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -626,6 +623,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.0155900745, "m3_water_usage": 7.3156525e-7, "cloud_init_supported": true @@ -634,8 +634,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6ec5bbb2-8267-41cd-aaf6-d43ea1b59372", "name": "EM-A315X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -742,7 +740,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -753,6 +750,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.015545019, "m3_water_usage": 7.695686e-7, "cloud_init_supported": true @@ -761,8 +761,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "5a505e81-5f6a-42bd-acec-0b290be92697", "name": "EM-A410X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "aluminium", "price_per_hour": { "currency_code": "EUR", @@ -959,7 +957,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -970,6 +967,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.015003014, "m3_water_usage": 6.650593e-7, "cloud_init_supported": true @@ -978,8 +978,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c5853302-63e4-40c7-a711-4a91629565c8", "name": "EM-L105X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -1159,7 +1157,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1170,6 +1167,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.016983224, "m3_water_usage": 7.600678e-7, "cloud_init_supported": true @@ -1178,8 +1178,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0964171b-4716-4403-9f6a-2f40d4230fa5", "name": "EM-B130E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1450,7 +1448,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1461,6 +1458,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -1469,8 +1469,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "8c2cf291-a3d3-4dc5-967f-f498a088411d", "name": "EM-B111X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1700,7 +1698,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1711,6 +1708,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.022749875, "m3_water_usage": 0.0000011115991, "cloud_init_supported": true @@ -1719,8 +1719,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a5065ba4-dde2-45f3-adec-1ebbb27b766b", "name": "EM-B112X-SSD", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -1950,7 +1948,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -1961,6 +1958,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.02332065, "m3_water_usage": 0.0000011115991, "cloud_init_supported": true @@ -1969,8 +1969,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "835ef36b-9659-450b-9110-8119deae0e15", "name": "EM-B130E-NVME-64G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2241,7 +2239,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -2252,6 +2249,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -2260,8 +2260,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c753f736-fbb4-4689-ae93-623f9d08dce5", "name": "EM-B211X-SATA", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2482,7 +2480,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [], "gpus": [], @@ -2490,6 +2487,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.024065403, "m3_water_usage": 0.0000010450932, "cloud_init_supported": false @@ -2498,8 +2498,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "ddaf8ba6-b2b2-4279-8af3-51930fb602f8", "name": "EM-B212X-SSD", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -2720,7 +2718,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -2731,6 +2728,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.026313491, "m3_water_usage": 0.0000012351102, "cloud_init_supported": true @@ -2739,8 +2739,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0f93f4cd-29e1-4f28-ae8b-3cfd9729cd65", "name": "EM-B230E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -3011,7 +3009,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3022,6 +3019,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3030,8 +3030,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "206ea234-9097-4ae1-af68-6d2be09f47ed", "name": "EM-B220E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -3324,7 +3322,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3335,6 +3332,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3343,8 +3343,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a204136d-656b-44b7-9735-88ca2f62cb1f", "name": "EM-L110X-SATA", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 1000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -3524,7 +3522,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 1000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -3535,6 +3532,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 1000000000, + "private_bandwidth": 1000000000, "kg_co2_equivalent": 0.016983224, "m3_water_usage": 7.600678e-7, "cloud_init_supported": true @@ -3543,8 +3543,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "0e1f7c5b-05bf-4863-ba58-9ee07496d1f7", "name": "EM-I120E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -3890,7 +3888,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -3902,6 +3899,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -3910,8 +3910,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "e9dee4f8-c37b-42a2-8bc1-1409218973d5", "name": "EM-B230E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4182,7 +4180,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4193,6 +4190,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4201,8 +4201,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "a60ae97c-268c-40cb-af5f-dd276e917ed7", "name": "EM-B320E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4495,7 +4493,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4506,6 +4503,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4514,8 +4514,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6fb2a065-b4fd-4436-aebe-da0d2ecaf641", "name": "EM-B330E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -4786,7 +4784,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -4797,6 +4794,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -4805,8 +4805,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "d14eef5e-3279-42db-bb57-bb2bbe6a5b28", "name": "EM-B420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5099,7 +5097,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5110,6 +5107,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5118,8 +5118,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "faaf5d66-99d3-46d0-a948-5c1b65caf2a3", "name": "EM-B330E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5390,7 +5388,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5401,6 +5398,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5409,8 +5409,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "d73011fa-02e0-46bc-992c-8bb62c767e97", "name": "EM-B520E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5703,7 +5701,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -5714,6 +5711,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -5722,8 +5722,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "1b46dfad-ec29-4f6e-af14-6af753e15630", "name": "EM-B430E-NVME", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -5994,7 +5992,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6005,6 +6002,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6013,8 +6013,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "fa9c839b-c6de-401e-9156-e9b9016d712b", "name": "EM-B430E-NVME-128G", "stock": "empty", - "bandwidth": 3000000000, - "max_bandwidth": 10000000000, "commercial_range": "beryllium", "price_per_hour": { "currency_code": "EUR", @@ -6285,7 +6283,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6296,6 +6293,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 3000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6304,8 +6304,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "fd7605a7-7493-49ba-9019-0e9c2897d4a0", "name": "EM-I220E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -6651,7 +6649,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -6663,6 +6660,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6671,8 +6671,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "7e81d4d4-a84b-4dd0-9f44-f0ac34a09e3a", "name": "EM-I210E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -6956,7 +6954,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -6967,6 +6964,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -6975,8 +6975,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "402efe20-4dcb-49af-bc80-1008bcc749a6", "name": "EM-L220E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -7263,7 +7261,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -7274,6 +7271,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7282,8 +7282,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "ddb09834-8680-4339-b5e3-19ca5205fa1f", "name": "EM-I215E-NVME", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -7581,7 +7579,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -7592,6 +7589,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7600,8 +7600,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "66cdd2c4-664c-47af-b0ad-a70e12999f0e", "name": "EM-I320E-NVME", "stock": "available", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -7947,7 +7945,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -7959,6 +7956,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -7967,8 +7967,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "18137894-b9a7-4e7b-b026-518ab8a3e41e", "name": "EM-L420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -8255,7 +8253,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 10000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -8266,6 +8263,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 10000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8274,8 +8274,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "6e905149-d776-49c1-824d-8161f40968e9", "name": "EM-I420E-NVME", "stock": "low", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -8621,7 +8619,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -8633,6 +8630,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8641,8 +8641,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "526f75ed-4482-4e57-9c53-82f2983129ba", "name": "EM-L520E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -8934,7 +8932,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -8945,6 +8942,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -8953,8 +8953,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "b20ba18e-34e8-44aa-b909-93cbff7635b5", "name": "EM-I520E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -9300,7 +9298,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "certification": {} } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -9312,6 +9309,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9320,8 +9320,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "51131a3a-7a9b-44c7-8228-2c071c185b77", "name": "EM-I620E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -9667,7 +9665,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "remote_access": {} } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -9679,6 +9676,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9687,8 +9687,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "c0069aba-31a5-401d-9bdb-58bcdd7859e6", "name": "EM-L620E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 10000000000, "commercial_range": "lithium", "price_per_hour": { "currency_code": "EUR", @@ -9980,7 +9978,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "install-system-v2", @@ -9991,6 +9988,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 10000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true @@ -9999,8 +9999,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "id": "49c3ff54-7949-4a53-8f52-f2ea17c451b1", "name": "EM-I525E-NVME", "stock": "empty", - "bandwidth": 1000000000, - "max_bandwidth": 25000000000, "commercial_range": "iridium", "price_per_hour": { "currency_code": "EUR", @@ -10354,7 +10352,6 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 } } ], - "private_bandwidth": 25000000000, "shared_bandwidth": true, "tags": [ "new", @@ -10366,6 +10363,9 @@ c0069aba-31a5-401d-9bdb-58bcdd7859e6 EM-L620E-NVME empty 4 1 "commitments_available": [], "zone": "fr-par-1", "dual_attach": false, + "bandwidth": 1000000000, + "max_bandwidth": 25000000000, + "private_bandwidth": 25000000000, "kg_co2_equivalent": null, "m3_water_usage": null, "cloud_init_supported": true diff --git a/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden b/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden index bbf88c4524..7b5a392e63 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-create-server-simple-default.golden @@ -23,7 +23,7 @@ d1516a55-d00f-4e88-9730-97ac4cc92ce2 51.159.215.183 - df2977be-2932-4611-8c9f-421020a52359 2001:bc8:1201:629:569f:35ff:fe17:e970 - IPv6 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION 🟩🟩🟩 JSON STDOUT 🟩🟩🟩 { "id": "43cbe10c-49ee-43de-b528-0f6d18ef04af", diff --git a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden index 68a7499b74..80cb082f81 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-all-ssh-keys.golden @@ -23,7 +23,7 @@ ID ADDRESS REV 8ffde507-fb75-4e29-968b-cd278d581766 2001:bc8:1201:a2c:d604:e6ff:fee8:6290 - IPv6 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION Install: OsID b9a016fc-947f-4bbc-bf95-343c8524535a diff --git a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden index 354009aec4..a647b69b40 100644 --- a/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden +++ b/internal/namespaces/baremetal/v1/testdata/test-install-server-simple-with-id.golden @@ -23,7 +23,7 @@ ID ADDRESS REVE dd054664-8ac6-4623-9c70-c548b6b1682d 195.154.218.61 - IPv4 unknown - Options: -ID NAME STATUS MANAGEABLE EXPIRES AT +ID NAME STATUS MANAGEABLE EXPIRES AT LICENSE PUBLIC BANDWIDTH PRIVATE NETWORK REMOTE ACCESS CERTIFICATION Install: OsID b9a016fc-947f-4bbc-bf95-343c8524535a diff --git a/internal/namespaces/info/custom.go b/internal/namespaces/info/custom.go index 0c03d856e8..6a09f9bf4d 100644 --- a/internal/namespaces/info/custom.go +++ b/internal/namespaces/info/custom.go @@ -6,7 +6,6 @@ import ( "reflect" "github.com/scaleway/scaleway-cli/v2/core" - "github.com/scaleway/scaleway-cli/v2/core/human" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -27,23 +26,6 @@ type infoResult struct { Settings []*setting `json:"settings"` } -func (i *infoResult) MarshalHuman() (string, error) { - type tmp infoResult - - return human.Marshal(tmp(*i), &human.MarshalOpt{ - Sections: []*human.MarshalSection{ - { - FieldName: "BuildInfo", - Title: "Build Info", - }, - { - FieldName: "Settings", - Title: "Settings", - }, - }, - }) -} - type setting struct { Key string `json:"key"` Value string `json:"value"` @@ -88,6 +70,20 @@ func infosRoot() *core.Command { }, }, nil }, + View: &core.View{ + Title: "", + Fields: []*core.ViewField{}, + Sections: []*core.ViewSection{ + { + FieldName: "BuildInfo", + Title: "Build Info", + }, + { + FieldName: "Settings", + Title: "Settings", + }, + }, + }, } } diff --git a/internal/namespaces/object/v1/custom_marshaler.go b/internal/namespaces/object/v1/custom_marshaler.go index 8ed57606a3..d207ccd8b7 100644 --- a/internal/namespaces/object/v1/custom_marshaler.go +++ b/internal/namespaces/object/v1/custom_marshaler.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -33,7 +34,7 @@ type BucketResponse struct { func bucketResponseMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { resp := i.(BucketResponse) - messageStr, err := resp.SuccessResult.MarshalHuman() + messageStr, err := humanv2.Format(resp.SuccessResult) if err != nil { return "", err } diff --git a/internal/namespaces/rdb/v1/custom_acl.go b/internal/namespaces/rdb/v1/custom_acl.go index 39c1020743..6bb9592f8f 100644 --- a/internal/namespaces/rdb/v1/custom_acl.go +++ b/internal/namespaces/rdb/v1/custom_acl.go @@ -8,6 +8,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-cli/v2/internal/editor" "github.com/scaleway/scaleway-sdk-go/api/rdb/v1" "github.com/scaleway/scaleway-sdk-go/scw" @@ -39,7 +40,7 @@ type CustomACLResult struct { func rdbACLCustomResultMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { result := i.(CustomACLResult) - messageStr, err := result.Success.MarshalHuman() + messageStr, err := humanv2.Format(result.Success) if err != nil { return "", err } diff --git a/internal/namespaces/rdb/v1/custom_endpoint.go b/internal/namespaces/rdb/v1/custom_endpoint.go index b7a7eb6958..cc905f6267 100644 --- a/internal/namespaces/rdb/v1/custom_endpoint.go +++ b/internal/namespaces/rdb/v1/custom_endpoint.go @@ -8,6 +8,7 @@ import ( "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + humanv2 "github.com/scaleway/scaleway-cli/v2/core/human/v2" "github.com/scaleway/scaleway-sdk-go/api/rdb/v1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -19,7 +20,7 @@ type rdbEndpointCustomResult struct { func rdbEndpointCustomResultMarshalerFunc(i any, opt *human.MarshalOpt) (string, error) { result := i.(rdbEndpointCustomResult) - messageStr, err := result.Success.MarshalHuman() + messageStr, err := humanv2.Format(result.Success) if err != nil { return "", err }