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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025
Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ test-race:
go test -race ./...

test-coverage:
go test -v ./... -coverprofile=coverage.out
go test -v ./adminapi/... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

linter:
Expand Down
22 changes: 12 additions & 10 deletions adminapi/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (

// commitRequest is the payload sent to /api/dataset/commit
type commitRequest struct {
Created []map[string]any `json:"created"`
Changed []map[string]any `json:"changed"`
Deleted []any `json:"deleted"`
Created []Attributes `json:"created"`
Changed []Attributes `json:"changed"`
Deleted []int `json:"deleted"` // the object-ids
}

type commitResponse struct {
Expand Down Expand Up @@ -79,19 +79,21 @@ func (s *ServerObject) Commit() (int, error) {

func buildCommit(objects ServerObjects) commitRequest {
commit := commitRequest{
Created: []map[string]any{},
Changed: []map[string]any{},
Deleted: []any{},
Created: []Attributes{},
Changed: []Attributes{},
Deleted: []int{}, // the object-ids
}

for _, obj := range objects {
switch obj.CommitState() {
case "created":
case StateCreated:
commit.Created = append(commit.Created, obj.attributes)
case "changed":
case StateChanged:
commit.Changed = append(commit.Changed, obj.serializeChanges())
case "deleted":
commit.Deleted = append(commit.Deleted, obj.Get("object_id"))
case StateDeleted:
commit.Deleted = append(commit.Deleted, obj.ObjectID())
case StateConsistent:
// No changes to commit
}
}

Expand Down
89 changes: 55 additions & 34 deletions adminapi/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func TestCommitSingle(t *testing.T) {
t.Setenv("SERVERADMIN_BASE_URL", server.URL)

obj := &ServerObject{
attributes: map[string]any{"hostname": "new.local", "object_id": float64(42)},
oldValues: map[string]any{"hostname": "old.local"},
attributes: Attributes{"hostname": "new.local", "object_id": float64(42)},
oldValues: Attributes{"hostname": "old.local"},
}

commitID, err := obj.Commit()
Expand All @@ -42,7 +42,7 @@ func TestCommitSingle(t *testing.T) {
assert.Empty(t, receivedBody.Deleted)

// State should be reset after commit
assert.Equal(t, "consistent", obj.CommitState())
assert.Equal(t, StateConsistent, obj.CommitState())
assert.Empty(t, obj.oldValues)
}

Expand All @@ -64,16 +64,16 @@ func TestCommitResultSet(t *testing.T) {

objects := ServerObjects{
{
attributes: map[string]any{"hostname": "changed.local", "object_id": float64(1)},
oldValues: map[string]any{"hostname": "orig1.local"},
attributes: Attributes{"hostname": "changed.local", "object_id": float64(1)},
oldValues: Attributes{"hostname": "orig1.local"},
},
{
attributes: map[string]any{"hostname": "unchanged.local", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "unchanged.local", "object_id": float64(2)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "deleted.local", "object_id": float64(3)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "deleted.local", "object_id": float64(3)},
oldValues: Attributes{},
deleted: true,
},
}
Expand All @@ -90,12 +90,12 @@ func TestCommitResultSet(t *testing.T) {
func TestServerObjectsSetSuccess(t *testing.T) {
objects := ServerObjects{
{
attributes: map[string]any{"hostname": "server1", "object_id": float64(1)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server1", "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "server2", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
},
}

Expand All @@ -111,12 +111,12 @@ func TestServerObjectsSetSuccess(t *testing.T) {
func TestServerObjectsSetAllErrors(t *testing.T) {
objects := ServerObjects{
{
attributes: map[string]any{"hostname": "server1", "object_id": float64(1)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server1", "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "server2", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
},
}

Expand All @@ -126,18 +126,18 @@ func TestServerObjectsSetAllErrors(t *testing.T) {
// Should contain errors for both objects
assert.Contains(t, err.Error(), "object 0")
assert.Contains(t, err.Error(), "object 1")
assert.Contains(t, err.Error(), "does not exist")
assert.ErrorIs(t, err, ErrUnknownAttribute)
}

func TestServerObjectsSetPartialErrors(t *testing.T) {
objects := ServerObjects{
{
attributes: map[string]any{"hostname": "server1", "memory": 16, "object_id": float64(1)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server1", "memory": 16, "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "server2", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
},
}

Expand All @@ -164,21 +164,21 @@ func TestServerObjectsSetEmpty(t *testing.T) {
func TestServerObjectsDelete(t *testing.T) {
objects := ServerObjects{
{
attributes: map[string]any{"hostname": "server1", "object_id": float64(1)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server1", "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "server2", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
},
}

objects.Delete()

assert.True(t, objects[0].deleted)
assert.True(t, objects[1].deleted)
assert.Equal(t, "deleted", objects[0].CommitState())
assert.Equal(t, "deleted", objects[1].CommitState())
assert.Equal(t, StateDeleted, objects[0].CommitState())
assert.Equal(t, StateDeleted, objects[1].CommitState())
}

func TestServerObjectsDeleteEmpty(_ *testing.T) {
Expand All @@ -199,12 +199,12 @@ func TestServerObjectsSetWithCommit(t *testing.T) {

objects := ServerObjects{
{
attributes: map[string]any{"hostname": "server1", "object_id": float64(1)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server1", "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: map[string]any{"hostname": "server2", "object_id": float64(2)},
oldValues: map[string]any{},
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
},
}

Expand All @@ -218,6 +218,27 @@ func TestServerObjectsSetWithCommit(t *testing.T) {
assert.Equal(t, 999, commitID)

// State should be consistent after commit
assert.Equal(t, "consistent", objects[0].CommitState())
assert.Equal(t, "consistent", objects[1].CommitState())
assert.Equal(t, StateConsistent, objects[0].CommitState())
assert.Equal(t, StateConsistent, objects[1].CommitState())
}

func TestServerObjectsRollback(t *testing.T) {
objects := ServerObjects{
{
attributes: Attributes{"hostname": "server1", "object_id": float64(1)},
oldValues: Attributes{},
},
{
attributes: Attributes{"hostname": "server2", "object_id": float64(2)},
oldValues: Attributes{},
deleted: true,
},
}

objects[0].Set("hostname", "modified")
objects.Rollback()

assert.Equal(t, "server1", objects[0].GetString("hostname"))
assert.Equal(t, StateConsistent, objects[0].CommitState())
assert.Equal(t, StateConsistent, objects[1].CommitState())
}
64 changes: 64 additions & 0 deletions adminapi/create_object.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package adminapi

import (
"encoding/json"
"fmt"
"net/url"
)

// NewObject creates a new server object with the given attributes, commits it,
// and returns the fully populated object with a server-assigned object_id.
// The attributes map must include "hostname".
func NewObject(serverType string, attributes Attributes) (*ServerObject, error) {
if !attributes.Has("hostname") {
return nil, fmt.Errorf("attributes must include %q: %w", "hostname", ErrUnknownAttribute)
}

server := &ServerObject{
oldValues: Attributes{},
}

// Fetch default attributes from the API
params := url.Values{}
params.Add("servertype", serverType)
fullURL := apiEndpointNewObject + "?" + params.Encode()

resp, err := sendRequest(fullURL, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var response struct {
Result Attributes `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, err
}
server.attributes = response.Result

// Ensure object_id is nil so CommitState() returns "created"
server.attributes["object_id"] = nil

// Apply caller-provided attributes (validates each exists in schema)
for key, value := range attributes {
if err := server.Set(key, value); err != nil {
return nil, fmt.Errorf("setting attribute %q: %w", key, err)
}
}

// Commit the new object
if _, err := server.Commit(); err != nil {
return nil, fmt.Errorf("committing new object: %w", err)
}

// Re-query to get the server-assigned object_id
q := NewQuery(Filters{"hostname": attributes["hostname"]})
created, err := q.One()
if err != nil {
return nil, fmt.Errorf("re-querying created object: %w", err)
}
_ = server.Set("object_id", created.ObjectID())

return created, nil
}
Loading