Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
77 changes: 77 additions & 0 deletions adminapi/attributes.go
Original file line number Diff line number Diff line change
@@ -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
}
139 changes: 139 additions & 0 deletions adminapi/attributes_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
9 changes: 3 additions & 6 deletions adminapi/multiattr.go
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion adminapi/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions adminapi/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions adminapi/server_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package adminapi
import (
"encoding/json"
"fmt"
"maps"
"reflect"
)

Expand Down Expand Up @@ -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{}
}

Expand Down
2 changes: 1 addition & 1 deletion adminapi/server_object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
{
Expand Down
Loading