diff --git a/client/client.go b/client/client.go index bbe2949..e4ccc71 100644 --- a/client/client.go +++ b/client/client.go @@ -203,21 +203,18 @@ func (c *Client) requestWrapper(ctx context.Context, method, path string, in, ou return nil } -func (c *Client) request(ctx context.Context, method, path string, in, out interface{}) error { - var r io.Reader = http.NoBody - - if in != nil { // encode body - b, err := json.Marshal(in) - if err != nil { - return err - } - - r = bytes.NewReader(b) +// Request sends a HTTP request to the Server. The optional request body is read +// from r, and any response body is read and returned. Clients can use this +// low-level method to make arbitrary requests and avoid marshaling and +// unmarshaling JSON. +func (c *Client) Request(ctx context.Context, method, path string, r io.Reader) (*bytes.Buffer, error) { + if r == nil { + r = http.NoBody } req, err := http.NewRequestWithContext(ctx, method, c.url(path), r) if err != nil { - return err + return nil, err } req.Header.Set("Authorization", "Bearer "+c.Token) @@ -227,8 +224,7 @@ func (c *Client) request(ctx context.Context, method, path string, in, out inter resp, err := c.Do(req) if err != nil { - fmt.Printf("do error:%v\n", err) - return err + return nil, err } defer resp.Body.Close() @@ -236,18 +232,44 @@ func (c *Client) request(ctx context.Context, method, path string, in, out inter if c.Debug { b, err := httputil.DumpResponse(resp, true) if err != nil { - return err + return nil, err } fmt.Printf("RESPONSE:\n%s", string(b)) } if resp.StatusCode < http.StatusOK || resp.StatusCode > 299 { - return c.ErrorParser(resp.StatusCode, resp.Body) + return nil, c.ErrorParser(resp.StatusCode, resp.Body) + } + + var buf bytes.Buffer + + if _, err = buf.ReadFrom(resp.Body); err != nil { + return nil, err + } + + return &buf, nil +} + +func (c *Client) request(ctx context.Context, method, path string, in, out interface{}) error { + var r io.Reader = http.NoBody + + if in != nil { // encode body + b, err := json.Marshal(in) + if err != nil { + return err + } + + r = bytes.NewReader(b) + } + + resp, err := c.Request(ctx, method, path, r) + if err != nil { + return err } if out != nil { // parse response - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if err := json.NewDecoder(resp).Decode(out); err != nil { return err } } diff --git a/cloud/instance/instance.go b/cloud/instance/instance.go index cf75455..e9dae67 100644 --- a/cloud/instance/instance.go +++ b/cloud/instance/instance.go @@ -114,8 +114,8 @@ type Template struct { HypervOptions string Imported string Input string - InstanceGroup []string - InstanceID int + InstanceGroup []string `template:"VMGROUP"` + InstanceID int `template:"VMID"` Memory int MemoryCost string MemoryMax string diff --git a/cloud/instance/template.go b/cloud/instance/template.go index f105c2b..00da444 100644 --- a/cloud/instance/template.go +++ b/cloud/instance/template.go @@ -14,6 +14,13 @@ func ParseTemplate(m map[string]any) (*Template, error) { // nolint:gocognit for key, value := range m { switch v := value.(type) { + case []string: + switch key { + case "SECURITY_GROUP_RULE": + copy(dst.SecurityGroupRule, v) + case "VMGROUP": + copy(dst.InstanceGroup, v) + } case string: switch key { case "AUTOMATIC_DS_REQUIREMENTS": diff --git a/cloud/template.go b/cloud/template.go index a84519e..7a19de7 100644 --- a/cloud/template.go +++ b/cloud/template.go @@ -1,10 +1,13 @@ package cloud import ( + "bytes" "encoding/json" "fmt" + "reflect" "sort" "strings" + "unicode" ) // Template is nested map of string key x value pairs. @@ -101,13 +104,24 @@ func pad(s string, length int) string { func templateString(indent, ljust int, delim, key string, value any) string { var templates []map[string]any + lhs := fmt.Sprintf("%s%s= ", strings.Repeat("\t", indent), pad(key, ljust)) + switch v := value.(type) { - case string: - return fmt.Sprintf("%s%s = %q%s", strings.Repeat("\t", indent), pad(key, ljust), v, delim) case map[string]any: templates = []map[string]any{v} case []map[string]any: templates = v + case string, fmt.Stringer: + return lhs + fmt.Sprintf("%q", v) + delim + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return lhs + fmt.Sprintf("%v", v) + delim + case bool: + if v { + return lhs + "YES" + delim + } + return lhs + "NO" + delim + default: + return lhs + fmt.Sprintf("\"unknown value type: %v\"", v) + delim } var buf strings.Builder @@ -144,3 +158,172 @@ func templateString(indent, ljust int, delim, key string, value any) string { return buf.String() } + +// NewTemplate returns a new Template from the struct t. If t is not a struct +// (or a pointer to one) it panics. +func NewTemplate(t any) Template { + rv := reflect.ValueOf(t) + + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + + if rv.Kind() != reflect.Struct { + panic("NewTemplate: expected struct, got " + rv.Kind().String()) + } + + return newTemplate(rv.Interface()) +} + +// newTemplate returns a new map[string]any from the struct t. This exists so we +// can recursively call it and always return a map[string]any. And only deal +// with the Template type alias at the top level. This is done to simplify the +// constructed nested map, which keeps the Template String method "simple". +// +// This is not intended to be general purpose and only needs to handle types the +// are used in the Template structs (which we create). +func newTemplate(t any) map[string]any { //nolint:gocognit + template := make(map[string]any) + rv := reflect.ValueOf(t) + rt := reflect.TypeOf(t) + + for i := 0; i < rv.NumField(); i++ { + var options tagOptions + + field := rt.Field(i) + fieldName := camelToSnake(field.Name) + + if tag := field.Tag.Get("template"); tag != "" { + fieldName, options = parseTag(tag) + } + + fv := rv.Field(i) + + switch field.Type.Kind() { + case reflect.Struct: + m := newTemplate(rv.Field(i).Interface()) + if len(m) > 0 || options.Contains(always) { + template[fieldName] = newTemplate(rv.Field(i).Interface()) + } + case reflect.Slice, reflect.Array: + if fv.Len() == 0 && !options.Contains(always) { + continue + } + + m := make([]map[string]any, fv.Len()) + for i := 0; i < fv.Len(); i++ { + m[i] = newTemplate(fv.Index(i).Interface()) + } + template[fieldName] = m + case reflect.Pointer: + if fv.IsNil() { // only handles pointers to structs or simple types + continue + } + + fv = fv.Elem() + if fv.Kind() == reflect.Struct { + template[fieldName] = newTemplate(fv.Interface()) + continue + } + + fallthrough + + default: + if fv.IsZero() && !options.Contains(always) { + continue + } + + v := simpleValue(fv) + + if options.Contains("string") { // not sure about this, might not be needed or might want for other types + template[fieldName] = fmt.Sprint(v) + continue + } + + template[fieldName] = v + } + } + + return template +} + +func simpleValue(v reflect.Value) any { + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.String: + return v.String() + case reflect.Bool: + return v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() + case reflect.Float32, reflect.Float64: + return v.Float() + } + + return "" +} + +const always = "always" // opposite of omitempty, default is to omit empty fields + +// camelToSnake converts a camelCase string to SNAKE_CASE. +func camelToSnake(s string) string { + var buffer bytes.Buffer + + for i, char := range s { + if i > 0 && unicode.IsUpper(char) { + var next rune + + if i+1 < len(s) { + next = rune(s[i+1]) + } + prev := rune(s[i-1]) + + if (unicode.IsDigit(prev) || unicode.IsLower(prev)) || unicode.IsLower(next) { + buffer.WriteRune('_') + } + } + buffer.WriteRune(unicode.ToUpper(char)) + } + + return buffer.String() +} + +// The following code is from the Go standard library and is licensed under a +// BSD-style license. Specifically, this is from the encoding/json package. + +// tagOptions is the string following a comma in a struct field's "json" tag, or +// the empty string. It does not include the leading comma. +type tagOptions string + +// parseTag splits a struct field's json tag into its name and comma-separated +// options. +func parseTag(tag string) (string, tagOptions) { + tag, opt, _ := strings.Cut(tag, ",") + return tag, tagOptions(opt) +} + +// Contains reports whether a comma-separated list of options contains a +// particular substr flag. substr must be surrounded by a string boundary or +// commas. +func (o tagOptions) Contains(optionName string) bool { + if len(o) == 0 { + return false + } + s := string(o) + for s != "" { + var name string + name, s, _ = strings.Cut(s, ",") + if name == optionName { + return true + } + } + return false +}