diff --git a/Makefile b/Makefile index 2c45087..8982e97 100644 --- a/Makefile +++ b/Makefile @@ -14,4 +14,5 @@ test-coverage: go tool cover -html=coverage.out -o coverage.html linter: - golangci-lint run --fix + go fix ./... + golangci-lint run --fix diff --git a/adminapi/attributes.go b/adminapi/attributes.go new file mode 100644 index 0000000..3e0e91a --- /dev/null +++ b/adminapi/attributes.go @@ -0,0 +1,77 @@ +package adminapi + +import ( + "context" + "encoding/json" + "fmt" +) + +const apiEndpointAttributes = "/api/dataset/attributes" + +// Attribute describes a single attribute definition as returned by the +// Serveradmin dataset/attributes endpoint. It covers both regular attributes +// stored in the attribute table and the special attributes (e.g. hostname, +// servertype) that are not stored there but remain queryable like any other +// attribute. +type Attribute struct { + // AttributeID is the unique identifier (and name) of the attribute. + AttributeID string `json:"attribute_id"` + // Type is the attribute's data type (e.g. "string", "boolean", "relation", "inet"). + Type string `json:"type"` + // Multi reports whether the attribute holds multiple values. + Multi bool `json:"multi"` + // Hovertext is the human-readable description of the attribute. + Hovertext string `json:"hovertext"` + // Group is the grouping label the attribute belongs to. + Group string `json:"group"` + // HelpLink is an optional link to documentation for the attribute. + HelpLink string `json:"help_link"` + // InetAddressFamily is the network address family for inet-typed attributes. + InetAddressFamily string `json:"inet_address_family"` + // Readonly reports whether the attribute can be modified. + Readonly bool `json:"readonly"` + // Clone reports whether the attribute's value is copied when cloning an object. + Clone bool `json:"clone"` + // History reports whether changes to the attribute are tracked in history. + History bool `json:"history"` + // Regexp is an optional validation pattern for the attribute's values. + Regexp string `json:"regexp"` + // ReversedAttribute is the attribute_id this attribute is the reverse of, + // or empty if it is not a reversed relation. + ReversedAttribute string `json:"reversed_attribute"` + // TargetServertypes lists the servertype IDs this attribute is attached to. + // It is empty for special attributes, which are not stored in the database. + TargetServertypes []string `json:"target_servertypes"` +} + +// attributesResponse mirrors {"status": "success", "result": [{...}, ...]} +type attributesResponse struct { + Status string `json:"status"` + Result []Attribute `json:"result"` + Message string `json:"message"` +} + +// FetchAttributes retrieves all attribute definitions from the Serveradmin +// server using this client. The result includes the special attributes (e.g. +// hostname, servertype) that are not stored in the attribute table but remain +// queryable, and is suitable for auto-completion or attribute selection. +func (c *Client) FetchAttributes(ctx context.Context) ([]Attribute, error) { + // The endpoint takes no input; send an empty JSON object so the request + // body is valid for the API's signature verification. + resp, err := c.sendRequest(ctx, apiEndpointAttributes, struct{}{}) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", apiEndpointAttributes, err) + } + defer resp.Body.Close() + + var result attributesResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding attributes response: %w", err) + } + + if result.Status == "error" { + return nil, fmt.Errorf("fetching attributes failed: %s", result.Message) + } + + return result.Result, nil +} diff --git a/adminapi/attributes_test.go b/adminapi/attributes_test.go new file mode 100644 index 0000000..0446f26 --- /dev/null +++ b/adminapi/attributes_test.go @@ -0,0 +1,139 @@ +package adminapi + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchAttributesSuccess(t *testing.T) { + var requestPath string + var requestBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + requestBody = string(body) + + w.WriteHeader(200) + _, _ = w.Write([]byte(`{ + "status": "success", + "result": [ + { + "attribute_id": "hostname", + "type": "string", + "multi": false, + "hovertext": "The hostname", + "group": "base", + "help_link": "", + "inet_address_family": "", + "readonly": true, + "clone": false, + "history": true, + "regexp": null, + "reversed_attribute": null, + "target_servertypes": [] + }, + { + "attribute_id": "responsible_admins", + "type": "relation", + "multi": true, + "hovertext": "Admins responsible for the object", + "group": "base", + "help_link": "https://example.com/help", + "inet_address_family": "", + "readonly": false, + "clone": true, + "history": true, + "regexp": "^[a-z]+$", + "reversed_attribute": "responsible_for", + "target_servertypes": ["vm", "hardware"] + } + ] + }`)) + })) + defer server.Close() + + client := mustClient(t, server.URL) + + attributes, err := client.FetchAttributes(context.Background()) + require.NoError(t, err) + require.Len(t, attributes, 2) + + // Request hits the dataset/attributes endpoint with a valid JSON body. + assert.Equal(t, "/api/dataset/attributes", requestPath) + assert.Equal(t, "{}", requestBody) + + // Special attribute with null regexp/reversed_attribute and no target servertypes. + hostname := attributes[0] + assert.Equal(t, "hostname", hostname.AttributeID) + assert.Equal(t, "string", hostname.Type) + assert.False(t, hostname.Multi) + assert.Equal(t, "The hostname", hostname.Hovertext) + assert.Equal(t, "base", hostname.Group) + assert.True(t, hostname.Readonly) + assert.True(t, hostname.History) + assert.Empty(t, hostname.Regexp) + assert.Empty(t, hostname.ReversedAttribute) + assert.Empty(t, hostname.TargetServertypes) + + // Regular multi attribute with all optional fields populated. + admins := attributes[1] + assert.Equal(t, "responsible_admins", admins.AttributeID) + assert.Equal(t, "relation", admins.Type) + assert.True(t, admins.Multi) + assert.True(t, admins.Clone) + assert.Equal(t, "https://example.com/help", admins.HelpLink) + assert.Equal(t, "^[a-z]+$", admins.Regexp) + assert.Equal(t, "responsible_for", admins.ReversedAttribute) + assert.Equal(t, []string{"vm", "hardware"}, admins.TargetServertypes) +} + +func TestFetchAttributesEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"status": "success", "result": []}`)) + })) + defer server.Close() + + client := mustClient(t, server.URL) + + attributes, err := client.FetchAttributes(context.Background()) + require.NoError(t, err) + assert.Empty(t, attributes) +} + +func TestFetchAttributesError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"status": "error", "message": "something went wrong"}`)) + })) + defer server.Close() + + client := mustClient(t, server.URL) + + attributes, err := client.FetchAttributes(context.Background()) + require.Error(t, err) + assert.Nil(t, attributes) + assert.Contains(t, err.Error(), "something went wrong") +} + +func TestFetchAttributesHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"error": {"message": "Forbidden: No known public key found"}}`)) + })) + defer server.Close() + + client := mustClient(t, server.URL) + + attributes, err := client.FetchAttributes(context.Background()) + require.Error(t, err) + assert.Nil(t, attributes) + assert.Contains(t, err.Error(), "HTTP error 403 Forbidden") +} diff --git a/adminapi/multiattr.go b/adminapi/multiattr.go index b47374f..8792957 100644 --- a/adminapi/multiattr.go +++ b/adminapi/multiattr.go @@ -1,5 +1,7 @@ package adminapi +import "slices" + // MultiAttr is a helper type for multi-valued attributes. // It provides set-like operations on string slices. // @@ -70,10 +72,5 @@ func (m *MultiAttr) Clear() { // m.Contains("web") // true // m.Contains("api") // false func (m MultiAttr) Contains(elem string) bool { - for _, v := range m { - if v == elem { - return true - } - } - return false + return slices.Contains(m, elem) } diff --git a/adminapi/parse_test.go b/adminapi/parse_test.go index 370c61e..d1facf6 100644 --- a/adminapi/parse_test.go +++ b/adminapi/parse_test.go @@ -47,7 +47,7 @@ func TestParseQuery(t *testing.T) { { name: "Not Empty filter", query: "hostname=not(empty())", - want: Filters{"hostname": Filter{"Not": Filter{"Empty": []interface{}{}}}}, + want: Filters{"hostname": Filter{"Not": Filter{"Empty": []any{}}}}, }, { name: "Any multi int", diff --git a/adminapi/query_test.go b/adminapi/query_test.go index 06e7e85..36677df 100644 --- a/adminapi/query_test.go +++ b/adminapi/query_test.go @@ -42,7 +42,7 @@ func TestFilters(t *testing.T) { }) assert.Equal(t, Filters{ - "hostname": Filter{"Not": Filter{"Empty": interface{}(nil)}}, + "hostname": Filter{"Not": Filter{"Empty": any(nil)}}, "num_cpu": Filter{"Regexp": ".*GB"}, "hypervisor": Filter{"StartsWith": "datacenter-x-"}, }, q.filters) @@ -55,7 +55,7 @@ func TestFromQuery(t *testing.T) { q.OrderBy("num_cpu") assert.Equal(t, Filters{ - "hostname": Filter{"Not": Filter{"Empty": []interface{}{}}}, + "hostname": Filter{"Not": Filter{"Empty": []any{}}}, "num_cpu": Filter{"Regexp": ".*GB"}, "instance": 1, }, q.filters) diff --git a/adminapi/server_object.go b/adminapi/server_object.go index 531ebfc..3851609 100644 --- a/adminapi/server_object.go +++ b/adminapi/server_object.go @@ -3,6 +3,7 @@ package adminapi import ( "encoding/json" "fmt" + "maps" "reflect" ) @@ -160,9 +161,7 @@ func (s *ServerObject) Delete() { // Rollback reverts all local changes, restoring original attribute values. func (s *ServerObject) Rollback() { s.deleted = false - for key, oldVal := range s.oldValues { - s.attributes[key] = oldVal - } + maps.Copy(s.attributes, s.oldValues) s.oldValues = Attributes{} } diff --git a/adminapi/server_object_test.go b/adminapi/server_object_test.go index 4e90397..434b5b2 100644 --- a/adminapi/server_object_test.go +++ b/adminapi/server_object_test.go @@ -420,7 +420,7 @@ func TestToAnySlice_VariousTypes(t *testing.T) { }, { name: "[]interface{} with mixed types", - input: []interface{}{"str", 42, true}, + input: []any{"str", 42, true}, expected: []any{"str", 42, true}, }, {